✨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 * as vscode from 'vscode';
|
||||||
import { RoadmapTreeProvider } from './roadmapTree';
|
import { RoadmapTreeProvider, RoadmapItem } from './roadmapTree';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
const checklistFile = path.join(vscode.workspace.workspaceFolders?.[0].uri.fsPath || '', 'Development Checklist.md');
|
const checklistFile = path.join(vscode.workspace.workspaceFolders?.[0].uri.fsPath || '', 'Development Checklist.md');
|
||||||
console.log('[Extension] Using checklist path:', checklistFile);
|
console.log('[Extension] Using checklist path:', checklistFile);
|
||||||
|
|
||||||
const roadmapProvider = new RoadmapTreeProvider(checklistFile);
|
const roadmapProvider = new RoadmapTreeProvider(checklistFile, context);
|
||||||
vscode.window.registerTreeDataProvider('roadmapChecklist', roadmapProvider);
|
vscode.window.registerTreeDataProvider('roadmapChecklist', roadmapProvider);
|
||||||
|
const treeView = vscode.window.createTreeView('roadmapChecklist', {
|
||||||
|
treeDataProvider: roadmapProvider
|
||||||
|
});
|
||||||
|
|
||||||
context.subscriptions.push(
|
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() {}
|
export function deactivate() {}
|
||||||
|
|||||||
@@ -1,35 +1,49 @@
|
|||||||
|
// roadmapTree.ts
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
export class RoadmapItem extends vscode.TreeItem {
|
export class RoadmapItem extends vscode.TreeItem {
|
||||||
public children: RoadmapItem[] = [];
|
public children: RoadmapItem[] = [];
|
||||||
|
public readonly checked: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly label: string,
|
public readonly label: string,
|
||||||
public readonly collapsibleState: vscode.TreeItemCollapsibleState,
|
public readonly collapsibleState: vscode.TreeItemCollapsibleState,
|
||||||
public readonly checked: boolean = false
|
checked: boolean = false
|
||||||
) {
|
) {
|
||||||
super(label, collapsibleState);
|
super(label, collapsibleState);
|
||||||
|
this.checked = checked;
|
||||||
|
|
||||||
// Only set icon/description for non-phase items here
|
|
||||||
if (collapsibleState === vscode.TreeItemCollapsibleState.None) {
|
if (collapsibleState === vscode.TreeItemCollapsibleState.None) {
|
||||||
|
this.description = checked ? '✅ Done' : '';
|
||||||
this.iconPath = new vscode.ThemeIcon(checked ? 'check' : 'circle-large-outline');
|
this.iconPath = new vscode.ThemeIcon(checked ? 'check' : 'circle-large-outline');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call this after assigning children
|
|
||||||
updatePhaseInfo() {
|
updatePhaseInfo() {
|
||||||
if (this.children.length === 0) { return; }
|
if (this.children.length === 0) {return;}
|
||||||
|
|
||||||
const total = this.children.length;
|
const total = this.children.length;
|
||||||
const completed = this.children.filter(c => c.checked).length;
|
const completed = this.children.filter(c => c.checked).length;
|
||||||
const percent = Math.round((completed / total) * 100);
|
const percent = Math.round((completed / total) * 100);
|
||||||
const bar = '▓'.repeat(Math.floor(percent / 10)).padEnd(10, '░');
|
const bar = '▓'.repeat(Math.floor(percent / 10)).padEnd(10, '░');
|
||||||
|
|
||||||
this.description = `${bar} ${percent}%`;
|
this.description = `${bar} ${percent}%`;
|
||||||
this.iconPath = new vscode.ThemeIcon(
|
this.iconPath = new vscode.ThemeIcon(
|
||||||
completed === total ? 'check' : 'tasklist'
|
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> {
|
export class RoadmapTreeProvider implements vscode.TreeDataProvider<RoadmapItem> {
|
||||||
@@ -38,8 +52,11 @@ export class RoadmapTreeProvider implements vscode.TreeDataProvider<RoadmapItem>
|
|||||||
|
|
||||||
private items: RoadmapItem[] = [];
|
private items: RoadmapItem[] = [];
|
||||||
|
|
||||||
constructor(private readonly checklistPath: string) {
|
constructor(
|
||||||
this.refresh(); // Load initial data
|
private readonly checklistPath: string,
|
||||||
|
private readonly context: vscode.ExtensionContext
|
||||||
|
) {
|
||||||
|
this.refresh();
|
||||||
vscode.workspace.onDidSaveTextDocument(doc => {
|
vscode.workspace.onDidSaveTextDocument(doc => {
|
||||||
if (doc.uri.fsPath === checklistPath) {
|
if (doc.uri.fsPath === checklistPath) {
|
||||||
this.refresh();
|
this.refresh();
|
||||||
@@ -48,16 +65,18 @@ export class RoadmapTreeProvider implements vscode.TreeDataProvider<RoadmapItem>
|
|||||||
}
|
}
|
||||||
|
|
||||||
refresh(): void {
|
refresh(): void {
|
||||||
console.log('[Roadmap] Refresh called');
|
|
||||||
const doc = vscode.workspace.textDocuments.find(d => d.uri.fsPath === this.checklistPath);
|
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() || '';
|
const content = doc?.getText() || '';
|
||||||
console.log('[Roadmap] Loaded content:', content.slice(0, 200)); // preview first 200 chars
|
const expanded = this.context.workspaceState.get<string[]>('expandedPhases') || [];
|
||||||
this.items = this.parseMarkdown(content);
|
const { items, firstOpenPhase } = this.parseMarkdown(content, expanded);
|
||||||
|
this.items = items;
|
||||||
this._onDidChangeTreeData.fire();
|
this._onDidChangeTreeData.fire();
|
||||||
|
|
||||||
|
if (firstOpenPhase) {
|
||||||
|
setTimeout(() => {
|
||||||
|
vscode.commands.executeCommand('roadmapChecklist.reveal', firstOpenPhase, { expand: true });
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getTreeItem(element: RoadmapItem): vscode.TreeItem {
|
getTreeItem(element: RoadmapItem): vscode.TreeItem {
|
||||||
@@ -65,33 +84,115 @@ export class RoadmapTreeProvider implements vscode.TreeDataProvider<RoadmapItem>
|
|||||||
}
|
}
|
||||||
|
|
||||||
getChildren(element?: RoadmapItem): vscode.ProviderResult<RoadmapItem[]> {
|
getChildren(element?: RoadmapItem): vscode.ProviderResult<RoadmapItem[]> {
|
||||||
return element ? element.children : this.items;
|
if (!element) {return this.items;}
|
||||||
}
|
|
||||||
|
|
||||||
private parseMarkdown(content: string): RoadmapItem[] {
|
// Track expand/collapse state
|
||||||
const lines = content.split('\n');
|
const expanded = this.context.workspaceState.get<string[]>('expandedPhases') || [];
|
||||||
const items: RoadmapItem[] = [];
|
if (element.collapsibleState === vscode.TreeItemCollapsibleState.Expanded) {
|
||||||
let currentPhase: RoadmapItem | null = null;
|
if (!expanded.includes(element.label)) {
|
||||||
|
expanded.push(element.label);
|
||||||
for (const line of lines) {
|
this.context.workspaceState.update('expandedPhases', expanded);
|
||||||
const phaseMatch = line.match(/^##\s+(.+)/);
|
}
|
||||||
const taskMatch = line.match(/^[-*]\s+\[( |x)\]\s+(.+)/);
|
} else if (element.collapsibleState === vscode.TreeItemCollapsibleState.Collapsed) {
|
||||||
|
if (expanded.includes(element.label)) {
|
||||||
if (phaseMatch) {
|
const updated = expanded.filter(label => label !== element.label);
|
||||||
currentPhase = new RoadmapItem(phaseMatch[1].trim(), vscode.TreeItemCollapsibleState.Collapsed);
|
this.context.workspaceState.update('expandedPhases', updated);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After building all children, update phase progress
|
return element.children;
|
||||||
for (const phase of items) {
|
}
|
||||||
phase.updatePhaseInfo();
|
|
||||||
|
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