diff --git a/src/core/todo-parser.test.ts b/src/core/todo-parser.test.ts index b0cbdc9..a104c5c 100644 --- a/src/core/todo-parser.test.ts +++ b/src/core/todo-parser.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { parseTodos } from './todo-parser'; +import { parseTodos, parseIndentLevel, buildTodoTree, parseTodosGroupedByHeading } from './todo-parser'; +import type { TodoItem } from './types'; describe('parseTodos', () => { it('should parse unchecked todos with dash marker', () => { @@ -12,6 +13,8 @@ describe('parseTodos', () => { completed: false, lineNumber: 0, rawLine: '- [ ] Buy groceries', + indentLevel: 0, + children: [], }); }); @@ -121,4 +124,209 @@ describe('parseTodos', () => { expect(result.todos).toHaveLength(1); expect(result.todos[0]?.text).toBe('Task with `code` and **bold** and [link](url)'); }); + + it('should set indentLevel for indented todos', () => { + const content = '- [ ] Top level\n - [ ] One indent\n - [ ] Two indent'; + const result = parseTodos(content); + expect(result.todos[0]?.indentLevel).toBe(0); + expect(result.todos[1]?.indentLevel).toBe(1); + expect(result.todos[2]?.indentLevel).toBe(2); + }); + + it('should set indentLevel for tab-indented todos', () => { + const content = '- [ ] Top\n\t- [ ] One tab\n\t\t- [ ] Two tabs'; + const result = parseTodos(content); + expect(result.todos[0]?.indentLevel).toBe(0); + expect(result.todos[1]?.indentLevel).toBe(1); + expect(result.todos[2]?.indentLevel).toBe(2); + }); +}); + +describe('parseIndentLevel', () => { + it('should return 0 for no indentation', () => { + expect(parseIndentLevel('- [ ] Hello')).toBe(0); + }); + + it('should return 1 for 2-space indent', () => { + expect(parseIndentLevel(' - [ ] Hello')).toBe(1); + }); + + it('should return 2 for 4-space indent', () => { + expect(parseIndentLevel(' - [ ] Hello')).toBe(2); + }); + + it('should return 1 for single tab indent', () => { + expect(parseIndentLevel('\t- [ ] Hello')).toBe(1); + }); + + it('should return 2 for double tab indent', () => { + expect(parseIndentLevel('\t\t- [ ] Hello')).toBe(2); + }); +}); + +describe('buildTodoTree', () => { + function makeTodo(lineNumber: number, indentLevel: number, text: string): TodoItem { + return { + id: `line-${lineNumber}`, + text, + completed: false, + lineNumber, + rawLine: `${' '.repeat(indentLevel)}- [ ] ${text}`, + indentLevel, + children: [], + }; + } + + it('should return empty array for empty input', () => { + expect(buildTodoTree([])).toEqual([]); + }); + + it('should return flat list when all same indent level', () => { + const todos = [makeTodo(0, 0, 'A'), makeTodo(1, 0, 'B')]; + const tree = buildTodoTree(todos); + expect(tree).toHaveLength(2); + expect(tree[0]?.text).toBe('A'); + expect(tree[1]?.text).toBe('B'); + }); + + it('should nest a child under its parent', () => { + const todos = [makeTodo(0, 0, 'Parent'), makeTodo(1, 1, 'Child')]; + const tree = buildTodoTree(todos); + expect(tree).toHaveLength(1); + expect(tree[0]?.text).toBe('Parent'); + expect(tree[0]?.children).toHaveLength(1); + expect(tree[0]?.children[0]?.text).toBe('Child'); + }); + + it('should nest deeply', () => { + const todos = [ + makeTodo(0, 0, 'Root'), + makeTodo(1, 1, 'Child'), + makeTodo(2, 2, 'Grandchild'), + ]; + const tree = buildTodoTree(todos); + expect(tree).toHaveLength(1); + expect(tree[0]?.children[0]?.children[0]?.text).toBe('Grandchild'); + }); + + it('should handle siblings after nested children', () => { + const todos = [ + makeTodo(0, 0, 'First'), + makeTodo(1, 1, 'Child of First'), + makeTodo(2, 0, 'Second'), + ]; + const tree = buildTodoTree(todos); + expect(tree).toHaveLength(2); + expect(tree[0]?.children).toHaveLength(1); + expect(tree[1]?.text).toBe('Second'); + expect(tree[1]?.children).toHaveLength(0); + }); + + it('should handle multiple children under same parent', () => { + const todos = [ + makeTodo(0, 0, 'Parent'), + makeTodo(1, 1, 'Child A'), + makeTodo(2, 1, 'Child B'), + ]; + const tree = buildTodoTree(todos); + expect(tree).toHaveLength(1); + expect(tree[0]?.children).toHaveLength(2); + }); +}); + +describe('parseTodosGroupedByHeading', () => { + it('should return empty array for empty content', () => { + expect(parseTodosGroupedByHeading('')).toEqual([]); + }); + + it('should group todos under null heading when no headings exist', () => { + const content = '- [ ] Task A\n- [ ] Task B'; + const groups = parseTodosGroupedByHeading(content); + expect(groups).toHaveLength(1); + expect(groups[0]?.heading).toBeNull(); + expect(groups[0]?.todos).toHaveLength(2); + }); + + it('should group todos under their heading', () => { + const content = '## Heading 1\n- [ ] Task A\n## Heading 2\n- [ ] Task B'; + const groups = parseTodosGroupedByHeading(content); + // First group is null heading (empty, before any heading) + expect(groups).toHaveLength(3); + expect(groups[0]?.heading).toBeNull(); + expect(groups[0]?.todos).toHaveLength(0); + expect(groups[1]?.heading?.text).toBe('Heading 1'); + expect(groups[1]?.todos).toHaveLength(1); + expect(groups[1]?.todos[0]?.text).toBe('Task A'); + expect(groups[2]?.heading?.text).toBe('Heading 2'); + expect(groups[2]?.todos).toHaveLength(1); + expect(groups[2]?.todos[0]?.text).toBe('Task B'); + }); + + it('should show headings with no todos', () => { + const content = '## Heading 1\nSome text\n## Heading 2\n- [ ] Task B'; + const groups = parseTodosGroupedByHeading(content); + expect(groups).toHaveLength(3); + expect(groups[1]?.heading?.text).toBe('Heading 1'); + expect(groups[1]?.todos).toHaveLength(0); + expect(groups[2]?.heading?.text).toBe('Heading 2'); + expect(groups[2]?.todos).toHaveLength(1); + }); + + it('should handle todos before first heading', () => { + const content = '- [ ] Before heading\n## My Heading\n- [ ] After heading'; + const groups = parseTodosGroupedByHeading(content); + expect(groups).toHaveLength(2); + expect(groups[0]?.heading).toBeNull(); + expect(groups[0]?.todos).toHaveLength(1); + expect(groups[0]?.todos[0]?.text).toBe('Before heading'); + expect(groups[1]?.heading?.text).toBe('My Heading'); + expect(groups[1]?.todos[0]?.text).toBe('After heading'); + }); + + it('should nest children within groups', () => { + const content = '## Tasks\n- [ ] Parent\n - [ ] Child'; + const groups = parseTodosGroupedByHeading(content); + const taskGroup = groups[1]!; + expect(taskGroup.todos).toHaveLength(1); + expect(taskGroup.todos[0]?.text).toBe('Parent'); + expect(taskGroup.todos[0]?.children).toHaveLength(1); + expect(taskGroup.todos[0]?.children[0]?.text).toBe('Child'); + }); + + it('should preserve heading level and line number', () => { + const content = '# H1\n## H2\n### H3'; + const groups = parseTodosGroupedByHeading(content); + expect(groups).toHaveLength(4); + expect(groups[1]?.heading?.level).toBe(1); + expect(groups[1]?.heading?.lineNumber).toBe(0); + expect(groups[2]?.heading?.level).toBe(2); + expect(groups[2]?.heading?.lineNumber).toBe(1); + expect(groups[3]?.heading?.level).toBe(3); + expect(groups[3]?.heading?.lineNumber).toBe(2); + }); + + it('should handle complex real-world content', () => { + const content = [ + '# Hello World', + '', + '## Heading 2', + '- [ ] Hello World', + '', + '## Heading 3', + '- [ ] Buy groceries', + '- [ ] Make lunch', + ' - [ ] Prep ingredients', + '- [ ] Make a new playtest', + ].join('\n'); + + const groups = parseTodosGroupedByHeading(content); + // null group, H1, H2, H3 + expect(groups).toHaveLength(4); + expect(groups[2]?.heading?.text).toBe('Heading 2'); + expect(groups[2]?.todos).toHaveLength(1); + expect(groups[3]?.heading?.text).toBe('Heading 3'); + expect(groups[3]?.todos).toHaveLength(3); // top-level only + expect(groups[3]?.todos[1]?.children).toHaveLength(1); // Make lunch has child + expect(groups[3]?.todos[1]?.children[0]?.text).toBe('Prep ingredients'); + }); }); diff --git a/src/core/todo-parser.ts b/src/core/todo-parser.ts index 056f0e9..8f51c57 100644 --- a/src/core/todo-parser.ts +++ b/src/core/todo-parser.ts @@ -1,4 +1,4 @@ -import type { ParseResult, TodoItem } from './types'; +import type { ParseResult, TodoItem, TodoGroup, HeadingInfo } from './types'; /** * Regex pattern for markdown todo checkboxes. @@ -6,6 +6,118 @@ import type { ParseResult, TodoItem } from './types'; */ const TODO_PATTERN = /^(\s*[-*+]\s+\[)([xX ]?)(\]\s+)(.*)$/; +const HEADING_PATTERN = /^(#{1,6})\s+(.+)$/; + +/** + * Parse the indentation level of a line. + * A tab counts as 1 level. Spaces use 2-space or 4-space detection (default 4). + */ +export function parseIndentLevel(line: string): number { + const leadingWhitespace = line.match(/^(\s*)/)?.[1] ?? ''; + if (leadingWhitespace.length === 0) return 0; + + let level = 0; + for (const ch of leadingWhitespace) { + if (ch === '\t') { + level += 1; + } + } + if (level > 0) return level; + + // Space-based indentation: detect indent size from the line + // Obsidian typically uses tab or 2/4 spaces for list nesting + // We'll count by the smallest indent unit we find + const spaceCount = leadingWhitespace.length; + // Default to treating each 2 spaces as 1 level (common in Obsidian) + return Math.floor(spaceCount / 2); +} + +/** + * Build a tree of todos from a flat list based on indentLevel. + * Todos with higher indent levels become children of the nearest preceding todo with a lower indent level. + */ +export function buildTodoTree(flatTodos: TodoItem[]): TodoItem[] { + if (flatTodos.length === 0) return []; + + const roots: TodoItem[] = []; + const stack: TodoItem[] = []; + + for (const todo of flatTodos) { + // Make a copy with empty children to avoid mutation + const node: TodoItem = { ...todo, children: [] }; + + // Pop stack until we find a parent with lower indent level + while (stack.length > 0 && stack[stack.length - 1]!.indentLevel >= node.indentLevel) { + stack.pop(); + } + + if (stack.length === 0) { + roots.push(node); + } else { + stack[stack.length - 1]!.children.push(node); + } + + stack.push(node); + } + + return roots; +} + +/** + * Parse a heading line, returning HeadingInfo or null. + */ +export function parseHeadingLine(line: string, lineNumber: number): HeadingInfo | null { + const match = line.match(HEADING_PATTERN); + if (!match) return null; + return { + text: match[2]!.trim(), + level: match[1]!.length, + lineNumber, + }; +} + +/** + * Parse content and return todos grouped by heading, with nested children. + * All headings are included, even those with no todos. + */ +export function parseTodosGroupedByHeading(content: string): TodoGroup[] { + if (!content) return []; + + const lines = content.split('\n'); + const groups: TodoGroup[] = []; + let currentGroup: TodoGroup = { heading: null, todos: [] }; + let currentFlatTodos: TodoItem[] = []; + + const flushGroup = () => { + currentGroup.todos = buildTodoTree(currentFlatTodos); + groups.push(currentGroup); + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + + const heading = parseHeadingLine(line, i); + if (heading) { + // Flush previous group + flushGroup(); + currentGroup = { heading, todos: [] }; + currentFlatTodos = []; + continue; + } + + const todo = parseTodoLine(line, i); + if (todo) { + currentFlatTodos.push(todo); + } + } + + // Flush the last group + flushGroup(); + + return groups; +} + /** * Parse a single line and return a TodoItem if it's a todo, null otherwise. */ @@ -24,6 +136,8 @@ export function parseTodoLine(line: string, lineNumber: number): TodoItem | null completed: checkboxContent === 'x' || checkboxContent === 'X', lineNumber, rawLine: line, + indentLevel: parseIndentLevel(line), + children: [], }; } diff --git a/src/core/todo-transformer.test.ts b/src/core/todo-transformer.test.ts index 44d03ac..0bfba9e 100644 --- a/src/core/todo-transformer.test.ts +++ b/src/core/todo-transformer.test.ts @@ -4,6 +4,8 @@ import { removeTodoLine, insertTodoAtEnd, insertTodoUnderHeading, + moveTodoWithChildren, + moveTodoUnderHeadingInFile, } from './todo-transformer'; describe('toggleTodo', () => { @@ -181,3 +183,76 @@ describe('insertTodoUnderHeading', () => { expect(result).toBe('# Heading\n - [ ] Indented\nContent'); }); }); + +describe('moveTodoWithChildren', () => { + it('should move a single todo (no children) to a new position', () => { + const content = '- [ ] A\n- [ ] B\n- [ ] C'; + // Move A (line 0) to after C (target=3, insert before line 3 = end) + const result = moveTodoWithChildren(content, 0, [], 3); + expect(result).toBe('- [ ] B\n- [ ] C\n- [ ] A'); + }); + + it('should move a todo with children', () => { + const content = '- [ ] A\n - [ ] A1\n- [ ] B\n- [ ] C'; + // Move A + A1 (lines 0,1) to after C (target=4, insert before line 4 = end) + const result = moveTodoWithChildren(content, 0, [1], 4); + expect(result).toBe('- [ ] B\n- [ ] C\n- [ ] A\n - [ ] A1'); + }); + + it('should move a todo upward', () => { + const content = '- [ ] A\n- [ ] B\n- [ ] C'; + // Move C (line 2) to position 0 (before A) + const result = moveTodoWithChildren(content, 2, [], 0); + expect(result).toBe('- [ ] C\n- [ ] A\n- [ ] B'); + }); + + it('should move todo with children upward', () => { + const content = '- [ ] A\n- [ ] B\n - [ ] B1\n- [ ] C'; + // Move B + B1 (lines 1,2) to position 0 + const result = moveTodoWithChildren(content, 1, [2], 0); + expect(result).toBe('- [ ] B\n - [ ] B1\n- [ ] A\n- [ ] C'); + }); + + it('should handle moving to same position (no-op)', () => { + const content = '- [ ] A\n- [ ] B\n- [ ] C'; + const result = moveTodoWithChildren(content, 1, [], 1); + expect(result).toBe('- [ ] A\n- [ ] B\n- [ ] C'); + }); + + it('should handle content with non-todo lines', () => { + const content = '# Heading\n- [ ] A\nSome text\n- [ ] B'; + // Move B (line 3) to position 1 (after heading, before A) + const result = moveTodoWithChildren(content, 3, [], 1); + expect(result).toBe('# Heading\n- [ ] B\n- [ ] A\nSome text'); + }); +}); + +describe('moveTodoUnderHeadingInFile', () => { + it('should move a todo to end of heading section', () => { + const content = '## H1\n- [ ] A\n## H2\n- [ ] B'; + // Move A (line 1) under H2 (heading at line 2, no next heading) + const result = moveTodoUnderHeadingInFile(content, 1, [], 2); + expect(result).toBe('## H1\n## H2\n- [ ] B\n- [ ] A'); + }); + + it('should move todo under heading with next heading boundary', () => { + const content = '## H1\n- [ ] A\n## H2\n- [ ] B\n## H3\n- [ ] C'; + // Move A (line 1) under H2 (heading at line 2, next heading at line 4) + const result = moveTodoUnderHeadingInFile(content, 1, [], 2, 4); + expect(result).toBe('## H1\n## H2\n- [ ] B\n- [ ] A\n## H3\n- [ ] C'); + }); + + it('should move todo with children under a heading', () => { + const content = '## H1\n- [ ] A\n - [ ] A1\n## H2\n- [ ] B'; + // Move A + A1 (lines 1,2) under H2 (heading at line 3) + const result = moveTodoUnderHeadingInFile(content, 1, [2], 3); + expect(result).toBe('## H1\n## H2\n- [ ] B\n- [ ] A\n - [ ] A1'); + }); + + it('should move todo under empty heading section', () => { + const content = '## H1\n- [ ] A\n## H2\n## H3'; + // Move A (line 1) under H2 (heading at line 2, next heading at line 3) + const result = moveTodoUnderHeadingInFile(content, 1, [], 2, 3); + expect(result).toBe('## H1\n## H2\n- [ ] A\n## H3'); + }); +}); diff --git a/src/core/todo-transformer.ts b/src/core/todo-transformer.ts index 0d2e2ad..05f6fa1 100644 --- a/src/core/todo-transformer.ts +++ b/src/core/todo-transformer.ts @@ -65,6 +65,78 @@ export function insertTodoAtEnd(content: string, todoLine: string): string { return content + '\n' + todoLine; } +/** + * Move a todo (and its children) from one position to another within the same content. + * @param content - The file content + * @param todoLineNumber - Line number of the todo to move + * @param childLineNumbers - Line numbers of child todos (must be consecutive after todoLineNumber) + * @param targetLineNumber - Line number to insert before (0-based). Lines are inserted at this position. + */ +export function moveTodoWithChildren( + content: string, + todoLineNumber: number, + childLineNumbers: number[], + targetLineNumber: number +): string { + const lines = content.split('\n'); + const allLineNumbers = [todoLineNumber, ...childLineNumbers].sort((a, b) => a - b); + + // Extract the lines to move + const movedLines = allLineNumbers.map((ln) => lines[ln]!); + + // If moving to same position, no-op + if (targetLineNumber === todoLineNumber) { + return content; + } + + // Remove lines from original positions (in reverse to preserve indices) + for (let i = allLineNumbers.length - 1; i >= 0; i--) { + lines.splice(allLineNumbers[i]!, 1); + } + + // Adjust target position for removed lines + let adjustedTarget = targetLineNumber; + for (const ln of allLineNumbers) { + if (ln < targetLineNumber) { + adjustedTarget--; + } + } + + // Insert at adjusted target + lines.splice(adjustedTarget, 0, ...movedLines); + + return lines.join('\n'); +} + +/** + * Move a todo (and its children) under a heading within the same file. + * Inserts at the end of the heading's section. + * @param content - The file content + * @param todoLineNumber - Line number of the todo to move + * @param childLineNumbers - Line numbers of child todos + * @param headingLineNumber - Line number of the target heading + * @param nextHeadingLineNumber - Optional line number of the next heading (section boundary) + */ +export function moveTodoUnderHeadingInFile( + content: string, + todoLineNumber: number, + childLineNumbers: number[], + headingLineNumber: number, + nextHeadingLineNumber?: number +): string { + // Determine insert position: end of section (before next heading) or after heading + let targetLineNumber: number; + if (nextHeadingLineNumber !== undefined) { + targetLineNumber = nextHeadingLineNumber; + } else { + // No next heading — insert at end of file + const lines = content.split('\n'); + targetLineNumber = lines.length; + } + + return moveTodoWithChildren(content, todoLineNumber, childLineNumbers, targetLineNumber); +} + /** * Insert a todo line under a specific heading. * @param content - The file content diff --git a/src/core/types.ts b/src/core/types.ts index 01119c5..5500546 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -12,6 +12,10 @@ export interface TodoItem { lineNumber: number; /** The full original line text (for accurate replacement) */ rawLine: string; + /** Indentation level (0 = top-level, 1 = one indent, etc.) */ + indentLevel: number; + /** Nested child todos */ + children: TodoItem[]; } /** @@ -21,6 +25,16 @@ export interface ParseResult { todos: TodoItem[]; } +/** + * A group of todos under a heading (or before any heading) + */ +export interface TodoGroup { + /** The heading this group belongs to, or null for todos before any heading */ + heading: HeadingInfo | null; + /** Top-level todos in this group (children nested within) */ + todos: TodoItem[]; +} + /** * Represents a heading in a note */ diff --git a/src/views/todo-item-component.ts b/src/views/todo-item-component.ts index c08d362..9ff3049 100644 --- a/src/views/todo-item-component.ts +++ b/src/views/todo-item-component.ts @@ -1,13 +1,28 @@ -import { Menu, setIcon } from 'obsidian'; +import { Menu } from 'obsidian'; import type { TodoItem } from '../core/types'; export interface TodoItemCallbacks { - onToggle: () => void; - onMoveClick: () => void; + onToggle: (todo: TodoItem) => void; + onMoveClick: (todo: TodoItem) => void; + onDragStart: (evt: DragEvent, todo: TodoItem) => void; + onDragEnd: (evt: DragEvent) => void; } /** - * Creates a DOM element for a todo item with checkbox and menu. + * Collect all line numbers for a todo and its descendants (for drag data). + */ +export function collectChildLineNumbers(todo: TodoItem): number[] { + const lines: number[] = []; + for (const child of todo.children) { + lines.push(child.lineNumber); + lines.push(...collectChildLineNumbers(child)); + } + return lines; +} + +/** + * Creates a DOM element for a todo item with checkbox, right-click menu, + * drag support, and recursive child rendering. */ export function createTodoItemEl( container: HTMLElement, @@ -15,12 +30,14 @@ export function createTodoItemEl( callbacks: TodoItemCallbacks ): HTMLElement { const itemEl = container.createEl('li', { cls: 'todo-tracker-item' }); + itemEl.setAttribute('draggable', 'true'); + itemEl.dataset.lineNumber = String(todo.lineNumber); // Checkbox const checkboxEl = itemEl.createEl('input', { type: 'checkbox' }); checkboxEl.checked = todo.completed; checkboxEl.addEventListener('change', () => { - callbacks.onToggle(); + callbacks.onToggle(todo); }); // Text content @@ -30,12 +47,9 @@ export function createTodoItemEl( textEl.addClass('todo-tracker-item-completed'); } - // Menu button (ellipsis) - const menuBtn = itemEl.createEl('button', { cls: 'todo-tracker-menu-btn' }); - setIcon(menuBtn, 'more-vertical'); - menuBtn.setAttribute('aria-label', 'Todo options'); - - menuBtn.addEventListener('click', (evt) => { + // Right-click context menu + itemEl.addEventListener('contextmenu', (evt) => { + evt.preventDefault(); evt.stopPropagation(); const menu = new Menu(); @@ -44,12 +58,29 @@ export function createTodoItemEl( .setTitle('Move to...') .setIcon('arrow-right') .onClick(() => { - callbacks.onMoveClick(); + callbacks.onMoveClick(todo); }); }); menu.showAtMouseEvent(evt); }); + // Drag events + itemEl.addEventListener('dragstart', (evt) => { + callbacks.onDragStart(evt, todo); + }); + + itemEl.addEventListener('dragend', (evt) => { + callbacks.onDragEnd(evt); + }); + + // Render children recursively + if (todo.children.length > 0) { + const nestedList = itemEl.createEl('ul', { cls: 'todo-tracker-nested-list' }); + for (const child of todo.children) { + createTodoItemEl(nestedList, child, callbacks); + } + } + return itemEl; } diff --git a/src/views/todo-sidebar-view.ts b/src/views/todo-sidebar-view.ts index 1de5670..4180c5a 100644 --- a/src/views/todo-sidebar-view.ts +++ b/src/views/todo-sidebar-view.ts @@ -1,13 +1,24 @@ -import { ItemView, TFile, WorkspaceLeaf } from 'obsidian'; -import type { TodoItem } from '../core/types'; -import { parseTodos } from '../core/todo-parser'; -import { toggleTodo } from '../core/todo-transformer'; -import { createTodoItemEl } from './todo-item-component'; +import { ItemView, Keymap, Platform, TFile, WorkspaceLeaf } from 'obsidian'; +import type { TodoItem, TodoGroup } from '../core/types'; +import { parseTodosGroupedByHeading } from '../core/todo-parser'; +import { toggleTodo, moveTodoWithChildren, moveTodoUnderHeadingInFile } from '../core/todo-transformer'; +import { createTodoItemEl, collectChildLineNumbers } from './todo-item-component'; export const TODO_VIEW_TYPE = 'todo-tracker-view'; +interface FlatTodoEntry { + todo: TodoItem; + element: HTMLElement; + group: TodoGroup; +} + export class TodoSidebarView extends ItemView { private currentFile: TFile | null = null; + private flatTodoList: FlatTodoEntry[] = []; + private focusedIndex = -1; + private groups: TodoGroup[] = []; + private draggedTodo: TodoItem | null = null; + private draggedChildLines: number[] = []; constructor(leaf: WorkspaceLeaf) { super(leaf); @@ -26,16 +37,19 @@ export class TodoSidebarView extends ItemView { } async onOpen(): Promise { + // Register keyboard navigation once (Bug 4 fix: no duplicate listeners) + this.contentEl.addEventListener('keydown', (evt) => { + this.handleKeydown(evt); + }); + await this.refresh(); - // Listen for active leaf changes this.registerEvent( this.app.workspace.on('active-leaf-change', () => { this.refresh(); }) ); - // Listen for file modifications this.registerEvent( this.app.vault.on('modify', (file) => { if (this.currentFile && file.path === this.currentFile.path) { @@ -52,7 +66,6 @@ export class TodoSidebarView extends ItemView { async refresh(): Promise { const activeFile = this.app.workspace.getActiveFile(); - // Check if it's a markdown file if (!activeFile || activeFile.extension !== 'md') { this.currentFile = null; this.renderEmpty('Open a markdown file to see its todos'); @@ -62,46 +75,420 @@ export class TodoSidebarView extends ItemView { this.currentFile = activeFile; const content = await this.app.vault.read(activeFile); - const { todos } = parseTodos(content); + this.groups = parseTodosGroupedByHeading(content); - if (todos.length === 0) { + // Check if there are any todos at all + const hasTodos = this.groups.some((g) => g.todos.length > 0); + if (!hasTodos) { this.renderEmpty('No todos in this note'); return; } - this.renderTodos(todos, activeFile); + this.renderGroups(activeFile); } private renderEmpty(message: string): void { const container = this.contentEl; container.empty(); container.addClass('todo-tracker-container'); + this.flatTodoList = []; + this.focusedIndex = -1; const emptyEl = container.createDiv({ cls: 'todo-tracker-empty' }); emptyEl.setText(message); } - private renderTodos(todos: TodoItem[], file: TFile): void { + private renderGroups(file: TFile): void { const container = this.contentEl; container.empty(); container.addClass('todo-tracker-container'); + container.setAttribute('tabindex', '0'); // Header with file name const headerEl = container.createDiv({ cls: 'todo-tracker-header' }); headerEl.setText(file.basename); - // Todo list - const listEl = container.createEl('ul', { cls: 'todo-tracker-list' }); + // Build flat todo list for keyboard navigation + this.flatTodoList = []; - for (const todo of todos) { - const itemEl = createTodoItemEl(listEl, todo, { - onToggle: () => this.handleToggle(file, todo), - onMoveClick: () => this.handleMoveClick(todo, file), - }); - listEl.appendChild(itemEl); + for (const group of this.groups) { + const groupEl = container.createDiv({ cls: 'todo-tracker-group' }); + + // Heading label + if (group.heading) { + const headingEl = groupEl.createDiv({ cls: 'todo-tracker-group-heading' }); + headingEl.setText(group.heading.text); + headingEl.dataset.headingLine = String(group.heading.lineNumber); + } + + // Todo list for this group + const listEl = groupEl.createEl('ul', { cls: 'todo-tracker-list' }); + + for (const todo of group.todos) { + this.renderTodoItem(listEl, todo, file, group); + } + + // Set up drop zone on the group + this.setupGroupDropZone(groupEl, group, file); + } + + // Set initial focus index (Bug 1 fix: don't call container.focus()) + if (this.flatTodoList.length > 0 && this.focusedIndex === -1) { + this.focusedIndex = 0; + } + // Clamp focus index if list shrank + if (this.focusedIndex >= this.flatTodoList.length) { + this.focusedIndex = Math.max(0, this.flatTodoList.length - 1); + } + this.updateFocusVisual(); + } + + private renderTodoItem( + container: HTMLElement, + todo: TodoItem, + file: TFile, + group: TodoGroup + ): void { + const itemEl = createTodoItemEl(container, todo, { + onToggle: (t) => this.handleToggle(file, t), + onMoveClick: (t) => this.handleMoveClick(t, file), + onDragStart: (evt, t) => this.handleDragStart(evt, t), + onDragEnd: (evt) => this.handleDragEnd(evt), + }); + + // Register in flat list for keyboard navigation + this.flatTodoList.push({ todo, element: itemEl, group }); + + // Set up drop target on individual items + this.setupItemDropZone(itemEl, todo, file); + + // Recursively register children in flat list + for (const child of todo.children) { + this.registerChildInFlatList(itemEl, child, group); } } + private registerChildInFlatList( + parentEl: HTMLElement, + todo: TodoItem, + group: TodoGroup + ): void { + // Find the child's
  • element within the parent + const childEl = parentEl.querySelector( + `li[data-line-number="${todo.lineNumber}"]` + ) as HTMLElement | null; + if (childEl) { + this.flatTodoList.push({ todo, element: childEl, group }); + this.setupItemDropZone(childEl, todo, this.currentFile!); + + for (const grandchild of todo.children) { + this.registerChildInFlatList(childEl, grandchild, group); + } + } + } + + // --- Drag and Drop --- + + private handleDragStart(evt: DragEvent, todo: TodoItem): void { + this.draggedTodo = todo; + this.draggedChildLines = collectChildLineNumbers(todo); + + evt.dataTransfer?.setData('text/plain', todo.text); + if (evt.dataTransfer) { + evt.dataTransfer.effectAllowed = 'move'; + } + + // Add dragging class + const target = evt.target as HTMLElement; + target.addClass('todo-tracker-item-dragging'); + } + + private handleDragEnd(evt: DragEvent): void { + this.draggedTodo = null; + this.draggedChildLines = []; + + // Remove dragging class + const target = evt.target as HTMLElement; + target.removeClass('todo-tracker-item-dragging'); + + // Clean up all drag-over indicators + this.contentEl.querySelectorAll('.todo-tracker-drag-over').forEach((el) => { + el.classList.remove('todo-tracker-drag-over'); + }); + this.contentEl.querySelectorAll('.todo-tracker-drop-above, .todo-tracker-drop-below').forEach((el) => { + el.classList.remove('todo-tracker-drop-above'); + el.classList.remove('todo-tracker-drop-below'); + }); + } + + private setupItemDropZone(itemEl: HTMLElement, targetTodo: TodoItem, file: TFile): void { + itemEl.addEventListener('dragover', (evt) => { + if (!this.draggedTodo || this.draggedTodo.lineNumber === targetTodo.lineNumber) return; + evt.preventDefault(); + if (evt.dataTransfer) { + evt.dataTransfer.dropEffect = 'move'; + } + + // Determine drop position (above or below) + const rect = itemEl.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const isAbove = evt.clientY < midY; + + itemEl.removeClass('todo-tracker-drop-above'); + itemEl.removeClass('todo-tracker-drop-below'); + itemEl.addClass(isAbove ? 'todo-tracker-drop-above' : 'todo-tracker-drop-below'); + }); + + itemEl.addEventListener('dragleave', () => { + itemEl.removeClass('todo-tracker-drop-above'); + itemEl.removeClass('todo-tracker-drop-below'); + }); + + itemEl.addEventListener('drop', (evt) => { + evt.preventDefault(); + itemEl.removeClass('todo-tracker-drop-above'); + itemEl.removeClass('todo-tracker-drop-below'); + + if (!this.draggedTodo || !file) return; + + const rect = itemEl.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const isAbove = evt.clientY < midY; + + // Calculate target line: insert before or after the target todo + const targetLine = isAbove ? targetTodo.lineNumber : targetTodo.lineNumber + 1; + + this.performMove(file, targetLine); + }); + } + + private setupGroupDropZone(groupEl: HTMLElement, group: TodoGroup, file: TFile): void { + groupEl.addEventListener('dragover', (evt) => { + if (!this.draggedTodo) return; + + // Only handle if drop is on the group itself, not a child item + const target = evt.target as HTMLElement; + if (target.closest('.todo-tracker-item')) return; + + evt.preventDefault(); + if (evt.dataTransfer) { + evt.dataTransfer.dropEffect = 'move'; + } + groupEl.addClass('todo-tracker-drag-over'); + }); + + groupEl.addEventListener('dragleave', (evt) => { + // Only remove if actually leaving the group + const relatedTarget = evt.relatedTarget as HTMLElement | null; + if (!relatedTarget || !groupEl.contains(relatedTarget)) { + groupEl.removeClass('todo-tracker-drag-over'); + } + }); + + groupEl.addEventListener('drop', (evt) => { + evt.preventDefault(); + groupEl.removeClass('todo-tracker-drag-over'); + + if (!this.draggedTodo || !file || !group.heading) return; + + // Find the next heading's line number for section boundary + const groupIndex = this.groups.indexOf(group); + let nextHeadingLine: number | undefined; + for (let i = groupIndex + 1; i < this.groups.length; i++) { + if (this.groups[i]?.heading) { + nextHeadingLine = this.groups[i]!.heading!.lineNumber; + break; + } + } + + this.performMoveUnderHeading(file, group.heading.lineNumber, nextHeadingLine); + }); + } + + private async performMove(file: TFile, targetLine: number): Promise { + if (!this.draggedTodo) return; + + const todoLine = this.draggedTodo.lineNumber; + const childLines = this.draggedChildLines; + + await this.app.vault.process(file, (content) => { + return moveTodoWithChildren(content, todoLine, childLines, targetLine); + }); + + this.draggedTodo = null; + this.draggedChildLines = []; + } + + private async performMoveUnderHeading( + file: TFile, + headingLine: number, + nextHeadingLine?: number + ): Promise { + if (!this.draggedTodo) return; + + const todoLine = this.draggedTodo.lineNumber; + const childLines = this.draggedChildLines; + + await this.app.vault.process(file, (content) => { + return moveTodoUnderHeadingInFile(content, todoLine, childLines, headingLine, nextHeadingLine); + }); + + this.draggedTodo = null; + this.draggedChildLines = []; + } + + // --- Keyboard Navigation --- + + private handleKeydown(evt: KeyboardEvent): void { + if (this.flatTodoList.length === 0 || !this.currentFile) return; + + switch (evt.key) { + case 'ArrowDown': + evt.preventDefault(); + this.moveFocus(1); + break; + case 'ArrowUp': + evt.preventDefault(); + this.moveFocus(-1); + break; + case 'Enter': + evt.preventDefault(); + this.openFocusedTodoInEditor(this.currentFile); + break; + case 'm': + case 'M': + // Only trigger move if no modifier keys are pressed + if (!evt.ctrlKey && !evt.metaKey && !evt.altKey && !evt.shiftKey) { + evt.preventDefault(); + this.moveFocusedTodo(this.currentFile); + } + break; + default: + // Check if this matches the toggle checkbox hotkey + if (this.isToggleCheckboxHotkey(evt)) { + evt.preventDefault(); + this.toggleFocusedTodo(this.currentFile); + } + break; + } + } + + private isToggleCheckboxHotkey(evt: KeyboardEvent): boolean { + // Bug 3 fix: properly match Obsidian hotkeys using correct API + const hotkeyManager = (this.app as any).hotkeyManager; + if (!hotkeyManager) return false; + + const commandId = 'editor:toggle-checklist-status'; + + // Get custom hotkeys (user-configured), then fall back to defaults + const customHotkeys = hotkeyManager.getHotkeys?.(commandId) ?? []; + const defaultHotkeys = hotkeyManager.getDefaultHotkeys?.(commandId) ?? []; + const allHotkeys = [...customHotkeys, ...defaultHotkeys]; + + if (allHotkeys.length === 0) return false; + + for (const hotkey of allHotkeys) { + if (this.matchesHotkey(evt, hotkey)) { + return true; + } + } + + return false; + } + + private matchesHotkey(evt: KeyboardEvent, hotkey: { modifiers: string[]; key: string }): boolean { + const modifiers = hotkey.modifiers ?? []; + const key = hotkey.key; + + // Check each modifier + const needsMod = modifiers.includes('Mod'); + const needsCtrl = modifiers.includes('Ctrl'); + const needsShift = modifiers.includes('Shift'); + const needsAlt = modifiers.includes('Alt'); + + // "Mod" means Cmd on Mac, Ctrl on Windows/Linux + const isMac = Platform.isMacOS; + const modPressed = isMac ? evt.metaKey : evt.ctrlKey; + const modExpected = needsMod; + + // On Mac, Ctrl is separate from Mod (Cmd) + // On Windows/Linux, Mod and Ctrl both map to ctrlKey + let ctrlMatch: boolean; + if (isMac) { + ctrlMatch = evt.ctrlKey === needsCtrl; + } else { + // On non-Mac, Ctrl key satisfies both 'Mod' and 'Ctrl' + ctrlMatch = evt.ctrlKey === (needsMod || needsCtrl); + } + + const metaMatch = isMac + ? evt.metaKey === needsMod + : evt.metaKey === false; // Meta shouldn't be pressed on non-Mac unless specified + + const shiftMatch = evt.shiftKey === needsShift; + const altMatch = evt.altKey === needsAlt; + + const keyMatch = evt.key.toLowerCase() === key?.toLowerCase(); + + if (isMac) { + return metaMatch && ctrlMatch && shiftMatch && altMatch && keyMatch; + } else { + return ctrlMatch && shiftMatch && altMatch && !evt.metaKey && keyMatch; + } + } + + private moveFocus(delta: number): void { + const newIndex = Math.max(0, Math.min(this.flatTodoList.length - 1, this.focusedIndex + delta)); + if (newIndex !== this.focusedIndex) { + this.focusedIndex = newIndex; + this.updateFocusVisual(); + } + } + + private updateFocusVisual(): void { + // Remove focus from all items + for (const entry of this.flatTodoList) { + entry.element.removeClass('todo-tracker-item-focused'); + } + + // Add focus to current + const current = this.flatTodoList[this.focusedIndex]; + if (current) { + current.element.addClass('todo-tracker-item-focused'); + current.element.scrollIntoView({ block: 'nearest' }); + } + } + + private openFocusedTodoInEditor(file: TFile): void { + const entry = this.flatTodoList[this.focusedIndex]; + if (!entry) return; + + // Open the file and scroll to the line + const leaf = this.app.workspace.getLeaf(false); + leaf.openFile(file).then(() => { + const editor = this.app.workspace.activeEditor?.editor; + if (editor) { + const line = entry.todo.lineNumber; + editor.setCursor({ line, ch: 0 }); + editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true); + } + }); + } + + private toggleFocusedTodo(file: TFile): void { + const entry = this.flatTodoList[this.focusedIndex]; + if (!entry) return; + this.handleToggle(file, entry.todo); + } + + private moveFocusedTodo(file: TFile): void { + const entry = this.flatTodoList[this.focusedIndex]; + if (!entry) return; + this.handleMoveClick(entry.todo, file); + } + + // --- Event Handlers --- + private async handleToggle(file: TFile, todo: TodoItem): Promise { await this.app.vault.process(file, (content) => { return toggleTodo(content, todo.lineNumber); @@ -109,7 +496,6 @@ export class TodoSidebarView extends ItemView { } private handleMoveClick(todo: TodoItem, file: TFile): void { - // Import dynamically to avoid circular dependencies import('../modals/note-select-modal').then(({ NoteSelectModal }) => { new NoteSelectModal(this.app, todo, file).open(); }); diff --git a/styles.css b/styles.css index ff73b90..43a38cc 100644 --- a/styles.css +++ b/styles.css @@ -2,6 +2,7 @@ .todo-tracker-container { padding: 10px; + outline: none; } .todo-tracker-header { @@ -20,18 +21,45 @@ text-align: center; } +/* Heading Groups */ +.todo-tracker-group { + margin-bottom: 12px; + border-radius: 4px; + transition: background-color 0.15s ease; +} + +.todo-tracker-group-heading { + font-weight: 600; + font-size: 13px; + color: var(--text-muted); + padding: 4px 4px 4px 0; + margin-bottom: 2px; +} + +/* Todo Lists */ .todo-tracker-list { list-style: none; padding: 0; margin: 0; } +.todo-tracker-nested-list { + list-style: none; + padding-left: 20px; + margin: 0; + width: 100%; +} + +/* Todo Items */ .todo-tracker-item { display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; padding: 6px 4px; border-radius: 4px; + cursor: grab; + transition: background-color 0.15s ease; } .todo-tracker-item:hover { @@ -55,23 +83,26 @@ color: var(--text-muted); } -.todo-tracker-menu-btn { - flex-shrink: 0; - background: none; - border: none; - padding: 4px; - border-radius: 4px; - cursor: pointer; - color: var(--text-muted); - opacity: 0; - transition: opacity 0.15s ease; -} - -.todo-tracker-item:hover .todo-tracker-menu-btn { - opacity: 1; -} - -.todo-tracker-menu-btn:hover { +/* Keyboard focus */ +.todo-tracker-item-focused { + outline: 2px solid var(--interactive-accent); + outline-offset: -2px; + background-color: var(--background-modifier-hover); +} + +/* Drag and drop */ +.todo-tracker-item-dragging { + opacity: 0.4; +} + +.todo-tracker-drop-above { + border-top: 2px solid var(--interactive-accent); +} + +.todo-tracker-drop-below { + border-bottom: 2px solid var(--interactive-accent); +} + +.todo-tracker-drag-over { background-color: var(--background-modifier-hover); - color: var(--text-normal); }