✨feature: Fold completed phases, open first phase with incomplete tasks by default
This commit is contained in:
@@ -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() {}
|
||||
|
||||
@@ -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<RoadmapItem> {
|
||||
@@ -38,8 +52,11 @@ export class RoadmapTreeProvider implements vscode.TreeDataProvider<RoadmapItem>
|
||||
|
||||
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<RoadmapItem>
|
||||
}
|
||||
|
||||
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<string[]>('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<RoadmapItem>
|
||||
}
|
||||
|
||||
getChildren(element?: RoadmapItem): vscode.ProviderResult<RoadmapItem[]> {
|
||||
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<string[]>('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<RoadmapItem> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user