import { App, ItemView, Platform, TFile, WorkspaceLeaf } from 'obsidian'; import type { TodoItem, TodoGroup } from '../core/types'; import { parseTodosGroupedByHeading } from '../core/todo-parser'; import { toggleTodo } from '../core/todo-transformer'; import { createTodoItemEl } 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[] = []; constructor(leaf: WorkspaceLeaf) { super(leaf); } getViewType(): string { return TODO_VIEW_TYPE; } getDisplayText(): string { return 'Todo tracker'; } getIcon(): string { return 'check-square'; } async onOpen(): Promise { // Register keyboard navigation once (Bug 4 fix: no duplicate listeners) this.contentEl.addEventListener('keydown', (evt) => { this.handleKeydown(evt); }); await this.refresh(); this.registerEvent( this.app.workspace.on('active-leaf-change', () => { void this.refresh(); }) ); this.registerEvent( this.app.vault.on('modify', (file) => { if (this.currentFile && file.path === this.currentFile.path) { void this.refresh(); } }) ); } async onClose(): Promise { // Cleanup is handled automatically by registerEvent } async refresh(): Promise { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile || activeFile.extension !== 'md') { this.currentFile = null; this.renderEmpty('Open a markdown file to see its todos'); return; } this.currentFile = activeFile; const content = await this.app.vault.read(activeFile); this.groups = parseTodosGroupedByHeading(content); // 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.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 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); // Build flat todo list for keyboard navigation this.flatTodoList = []; 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); } } // Don't auto-focus — user activates via ArrowDown (like Outline view) // 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) => { void this.handleToggle(file, t); }, onMoveClick: (t) => this.handleMoveClick(t, file), onMoveDailyNoteClick: (t) => this.handleMoveDailyNoteClick(t, file), onClick: (t) => this.openTodoInEditor(file, t), }); // Register in flat list for keyboard navigation this.flatTodoList.push({ todo, element: itemEl, group }); // 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}"]` ); if (childEl) { this.flatTodoList.push({ todo, element: childEl, group }); for (const grandchild of todo.children) { this.registerChildInFlatList(childEl, grandchild, group); } } } // --- Keyboard Navigation --- private handleKeydown(evt: KeyboardEvent): void { if (this.flatTodoList.length === 0 || !this.currentFile) return; switch (evt.key) { case 'ArrowDown': evt.preventDefault(); if (this.focusedIndex === -1) { this.focusedIndex = 0; this.updateFocusVisual(); } else { this.moveFocus(1); } break; case 'ArrowUp': evt.preventDefault(); if (this.focusedIndex === -1) { this.focusedIndex = 0; this.updateFocusVisual(); } else { 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 { interface HotkeyDef { modifiers: string[]; key: string; } interface HotkeyManager { getHotkeys(commandId: string): HotkeyDef[]; getDefaultHotkeys(commandId: string): HotkeyDef[]; } const hotkeyManager = (this.app as App & { hotkeyManager?: HotkeyManager }).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; // 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; this.openTodoInEditor(file, entry.todo); } private openTodoInEditor(file: TFile, todo: TodoItem): void { const leaf = this.app.workspace.getLeaf(false); void leaf.openFile(file).then(() => { const editor = this.app.workspace.activeEditor?.editor; if (editor) { const line = 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; void 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); }); } private handleMoveClick(todo: TodoItem, file: TFile): void { void import('../modals/note-select-modal').then(({ NoteSelectModal }) => { new NoteSelectModal(this.app, todo, file).open(); }); } private handleMoveDailyNoteClick(t: TodoItem, file: TFile): void { } }