From 6e0405b1920be2822ec4ac70214d19c5d2de1b5c Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 22 Jun 2025 14:20:22 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feature:=20Fold=20completed=20phases,?= =?UTF-8?q?=20open=20first=20phase=20with=20incomplete=20tasks=20by=20defa?= =?UTF-8?q?ult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/extension.ts | 58 ++++++++++++++- src/roadmapTree.ts | 175 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 193 insertions(+), 40 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 2c91997..de3b958 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,17 +1,69 @@ import * as vscode from 'vscode'; -import { RoadmapTreeProvider } from './roadmapTree'; +import { RoadmapTreeProvider, RoadmapItem } from './roadmapTree'; import * as path from 'path'; export function activate(context: vscode.ExtensionContext) { const checklistFile = path.join(vscode.workspace.workspaceFolders?.[0].uri.fsPath || '', 'Development Checklist.md'); console.log('[Extension] Using checklist path:', checklistFile); - const roadmapProvider = new RoadmapTreeProvider(checklistFile); + const roadmapProvider = new RoadmapTreeProvider(checklistFile, context); vscode.window.registerTreeDataProvider('roadmapChecklist', roadmapProvider); + const treeView = vscode.window.createTreeView('roadmapChecklist', { + treeDataProvider: roadmapProvider + }); context.subscriptions.push( - vscode.commands.registerCommand('roadmapView.refresh', () => roadmapProvider.refresh()) + vscode.commands.registerCommand('roadmap.toggleCheckbox', async (item: RoadmapItem) => { + const doc = vscode.workspace.textDocuments.find(d => d.uri.fsPath === checklistFile); + if (!doc) { return; } + + const editor = await vscode.window.showTextDocument(doc, { preview: false }); + const lines = doc.getText().split('\n'); + + const labelRegex = new RegExp(`[-*]\\s+\\[(${item.checked ? 'x' : ' '})\\]\\s+${escapeRegex(item.label)}$`); + const matchIndex = lines.findIndex(line => labelRegex.test(line.trim())); + + if (matchIndex === -1) { + vscode.window.showWarningMessage(`Could not find task "${item.label}" in the file.`); + return; + } + + const line = lines[matchIndex]; + const newCheck = item.checked ? '[ ]' : '[x]'; + const newLine = line.replace(/\[( |x)\]/, newCheck); + + const edit = new vscode.WorkspaceEdit(); + const uri = doc.uri; + + edit.replace(uri, doc.lineAt(matchIndex).range, newLine); + + // If parent, toggle all children too + if (item.children.length > 0) { + const indentLevel = line.match(/^(\s*)/)?.[1].length || 0; + + for (let i = matchIndex + 1; i < lines.length; i++) { + const thisLine = lines[i]; + const currentIndent = thisLine.match(/^(\s*)/)?.[1].length || 0; + + if (currentIndent <= indentLevel) { break; } + if (/^\s*[-*]\s+\[( |x)\]/.test(thisLine)) { + const toggled = thisLine.replace(/\[( |x)\]/, newCheck); + edit.replace(uri, doc.lineAt(i).range, toggled); + } + } + } + + await vscode.workspace.applyEdit(edit); + await doc.save(); + }), + vscode.commands.registerCommand('roadmapChecklist.reveal', async (item: RoadmapItem) => { + treeView.reveal(item, { expand: true }); + }) ); + + function escapeRegex(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } } export function deactivate() {} diff --git a/src/roadmapTree.ts b/src/roadmapTree.ts index f550e53..22873f4 100644 --- a/src/roadmapTree.ts +++ b/src/roadmapTree.ts @@ -1,35 +1,49 @@ +// roadmapTree.ts import * as vscode from 'vscode'; export class RoadmapItem extends vscode.TreeItem { public children: RoadmapItem[] = []; + public readonly checked: boolean; constructor( public readonly label: string, public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly checked: boolean = false + checked: boolean = false ) { super(label, collapsibleState); + this.checked = checked; - // Only set icon/description for non-phase items here if (collapsibleState === vscode.TreeItemCollapsibleState.None) { + this.description = checked ? '✅ Done' : ''; this.iconPath = new vscode.ThemeIcon(checked ? 'check' : 'circle-large-outline'); } } - // Call this after assigning children updatePhaseInfo() { - if (this.children.length === 0) { return; } - + if (this.children.length === 0) {return;} const total = this.children.length; const completed = this.children.filter(c => c.checked).length; const percent = Math.round((completed / total) * 100); const bar = '▓'.repeat(Math.floor(percent / 10)).padEnd(10, '░'); - this.description = `${bar} ${percent}%`; this.iconPath = new vscode.ThemeIcon( completed === total ? 'check' : 'tasklist' ); } + + static fromMarkdownLine( + label: string, + indent: number, + checked: boolean, + lines: string[], + currentIndex: number + ): RoadmapItem { + const collapsibleState = hasChildrenLater(lines, currentIndex, indent) + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None; + + return new RoadmapItem(label, collapsibleState, checked); + } } export class RoadmapTreeProvider implements vscode.TreeDataProvider { @@ -38,8 +52,11 @@ export class RoadmapTreeProvider implements vscode.TreeDataProvider private items: RoadmapItem[] = []; - constructor(private readonly checklistPath: string) { - this.refresh(); // Load initial data + constructor( + private readonly checklistPath: string, + private readonly context: vscode.ExtensionContext + ) { + this.refresh(); vscode.workspace.onDidSaveTextDocument(doc => { if (doc.uri.fsPath === checklistPath) { this.refresh(); @@ -48,16 +65,18 @@ export class RoadmapTreeProvider implements vscode.TreeDataProvider } refresh(): void { - console.log('[Roadmap] Refresh called'); const doc = vscode.workspace.textDocuments.find(d => d.uri.fsPath === this.checklistPath); - if (!doc) { - console.warn(`[Roadmap] Document not open: ${this.checklistPath}`); - } - const content = doc?.getText() || ''; - console.log('[Roadmap] Loaded content:', content.slice(0, 200)); // preview first 200 chars - this.items = this.parseMarkdown(content); + const expanded = this.context.workspaceState.get('expandedPhases') || []; + const { items, firstOpenPhase } = this.parseMarkdown(content, expanded); + this.items = items; this._onDidChangeTreeData.fire(); + + if (firstOpenPhase) { + setTimeout(() => { + vscode.commands.executeCommand('roadmapChecklist.reveal', firstOpenPhase, { expand: true }); + }, 100); + } } getTreeItem(element: RoadmapItem): vscode.TreeItem { @@ -65,33 +84,115 @@ export class RoadmapTreeProvider implements vscode.TreeDataProvider } getChildren(element?: RoadmapItem): vscode.ProviderResult { - return element ? element.children : this.items; - } + if (!element) {return this.items;} - private parseMarkdown(content: string): RoadmapItem[] { - const lines = content.split('\n'); - const items: RoadmapItem[] = []; - let currentPhase: RoadmapItem | null = null; - - for (const line of lines) { - const phaseMatch = line.match(/^##\s+(.+)/); - const taskMatch = line.match(/^[-*]\s+\[( |x)\]\s+(.+)/); - - if (phaseMatch) { - currentPhase = new RoadmapItem(phaseMatch[1].trim(), vscode.TreeItemCollapsibleState.Collapsed); - items.push(currentPhase); - } else if (taskMatch && currentPhase) { - const checked = taskMatch[1] === 'x'; - const task = new RoadmapItem(taskMatch[2].trim(), vscode.TreeItemCollapsibleState.None, checked); - currentPhase.children.push(task); + // Track expand/collapse state + const expanded = this.context.workspaceState.get('expandedPhases') || []; + if (element.collapsibleState === vscode.TreeItemCollapsibleState.Expanded) { + if (!expanded.includes(element.label)) { + expanded.push(element.label); + this.context.workspaceState.update('expandedPhases', expanded); + } + } else if (element.collapsibleState === vscode.TreeItemCollapsibleState.Collapsed) { + if (expanded.includes(element.label)) { + const updated = expanded.filter(label => label !== element.label); + this.context.workspaceState.update('expandedPhases', updated); } } - // After building all children, update phase progress - for (const phase of items) { - phase.updatePhaseInfo(); + return element.children; + } + + getParent(element: RoadmapItem): vscode.ProviderResult { + for (const phase of this.items) { + for (const task of phase.children) { + if (task === element) {return phase;} + const match = findParentRecursive(task, element); + if (match) {return match;} + } + } + return null; + } + + private parseMarkdown(content: string, expandedLabels: string[]): { items: RoadmapItem[]; firstOpenPhase: RoadmapItem | null } { + const lines = content.split('\n'); + const items: RoadmapItem[] = []; + let currentPhase: RoadmapItem | null = null; + const taskStack: { indent: number; item: RoadmapItem }[] = []; + let firstOpenPhase: RoadmapItem | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const phaseMatch = line.match(/^##\s+(.+)/); + const taskMatch = line.match(/^(\s*)[-*]\s+\[( |x)\]\s+(.+)/); + + if (phaseMatch) { + const label = phaseMatch[1].trim(); + const isExpanded = expandedLabels.includes(label); + const state = isExpanded ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed; + currentPhase = new RoadmapItem(label, state); + items.push(currentPhase); + taskStack.length = 0; + } else if (taskMatch && currentPhase) { + const indent = taskMatch[1].length; + const checked = taskMatch[2] === 'x'; + const label = taskMatch[3].trim(); + + const task = RoadmapItem.fromMarkdownLine(label, indent, checked, lines, i); + + while (taskStack.length && taskStack[taskStack.length - 1].indent >= indent) { + taskStack.pop(); + } + + if (taskStack.length === 0) { + currentPhase.children.push(task); + } else { + taskStack[taskStack.length - 1].item.children.push(task); + } + + taskStack.push({ indent, item: task }); + } } - return items; + for (const phase of items) { + phase.updatePhaseInfo(); + updateParentCheckState(phase); + + const total = phase.children.length; + const completed = phase.children.filter(c => c.checked).length; + if (!firstOpenPhase && total > 0 && completed < total) { + firstOpenPhase = phase; + } + } + + return { items, firstOpenPhase }; } } + +function findParentRecursive(parent: RoadmapItem, child: RoadmapItem): RoadmapItem | null { + for (const c of parent.children) { + if (c === child) {return parent;} + const found = findParentRecursive(c, child); + if (found) {return found;} + } + return null; +} + +function updateParentCheckState(item: RoadmapItem): boolean { + if (item.children.length === 0) {return item.checked;} + const allChildrenChecked = item.children.map(updateParentCheckState).every(Boolean); + (item as any).checked = allChildrenChecked; // hacky override to set read-only property + item.updatePhaseInfo(); + return allChildrenChecked; +} + +function hasChildrenLater(lines: string[], currentIndex: number, parentIndent: number): boolean { + for (let i = currentIndex + 1; i < lines.length; i++) { + const match = lines[i].match(/^(\s*)[-*]\s+\[( |x)\]/); + if (!match) {continue;} + const indent = match[1].length; + if (indent <= parentIndent) {return false;} + return true; + } + return false; +}