diff --git a/VS Code/src/extension.ts b/VS Code/src/extension.ts index 364c78a..1ffd103 100644 --- a/VS Code/src/extension.ts +++ b/VS Code/src/extension.ts @@ -790,6 +790,44 @@ export class CodeMindMapPanel { content: '✓'; color: #4caf50; } + + /* Child task completion progress */ + .map-container me-tpc[data-child-progress-visible="true"] { + --child-progress-completed-ratio: 0%; + --child-progress-in-progress-ratio: 0%; + --child-progress-label-width: 30px; + background-image: linear-gradient( + to right, + #4caf50 var(--child-progress-completed-ratio), + #ff9800 var(--child-progress-completed-ratio), + #ff9800 var(--child-progress-in-progress-ratio), + rgba(255, 255, 255, 0.18) var(--child-progress-in-progress-ratio) + ); + background-repeat: no-repeat; + background-size: calc(100% - 8px) 4px; + background-position: 4px calc(100% - 2px); + padding-bottom: 8px; + padding-inline-end: calc(var(--child-progress-label-width) + 16px); + } + .map-container me-tpc[data-child-progress-visible="true"]::after { + content: attr(data-child-progress-text); + position: absolute; + inset-inline-end: 6px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + font-weight: 600; + text-align: center; + color: rgba(255, 255, 255, 0.92); + background: rgba(0, 0, 0, 0.3); + border-radius: 10px; + width: var(--child-progress-label-width); + box-sizing: border-box; + padding: 0 4px; + line-height: 15px; + white-space: nowrap; + pointer-events: none; + } @@ -843,6 +881,7 @@ export class CodeMindMapPanel { let linkDivDebounceTimer = null; // debounce timer for the linkDiv bus event let scheduleRafHandle = null; let scheduleTimerHandle = null; + let applyingStatuses = false; // re-entry guard: prevents linkDiv→applyAllStatuses→linkDiv loop function initMindMap() { const options = { @@ -859,6 +898,7 @@ export class CodeMindMapPanel { node.data = node.data || {}; node.data.status = 'in-progress'; updateNodeStatus(node); + scheduleApplyAllStatuses(); vscode.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; } @@ -871,6 +911,7 @@ export class CodeMindMapPanel { node.data = node.data || {}; node.data.status = 'completed'; updateNodeStatus(node); + scheduleApplyAllStatuses(); vscode.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; } @@ -883,6 +924,7 @@ export class CodeMindMapPanel { node.data = node.data || {}; delete node.data.status; updateNodeStatus(node); + scheduleApplyAllStatuses(); vscode.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; } @@ -1099,23 +1141,26 @@ export class CodeMindMapPanel { }); // Helper function to update node visual status - function updateNodeStatus(nodeObj) { - if (!nodeObj || !nodeObj.id) return; + function getTopicElementByNode(nodeObj) { + if (!nodeObj || !nodeObj.id) return null; const nodeElement = MindElixir.E(nodeObj.id); - if (!nodeElement) return; + if (!nodeElement) return null; - const status = nodeObj.data?.status || null; const domEl = nodeElement.getEl?.() || nodeElement; - if (!domEl) return; + if (!domEl) return null; - const topicEl = (() => { - if (domEl.tagName === 'ME-TPC') return domEl; - const byQuery = domEl.querySelector?.('me-tpc'); - if (byQuery) return byQuery; - const byTag = domEl.getElementsByTagName?.('me-tpc')?.[0]; - if (byTag) return byTag; - return domEl; - })(); + if (domEl.tagName === 'ME-TPC') return domEl; + const byQuery = domEl.querySelector?.('me-tpc'); + if (byQuery) return byQuery; + const byTag = domEl.getElementsByTagName?.('me-tpc')?.[0]; + if (byTag) return byTag; + + return domEl; + } + + function updateNodeStatus(nodeObj) { + const status = nodeObj.data?.status || null; + const topicEl = getTopicElementByNode(nodeObj); if (!topicEl) return; @@ -1127,6 +1172,61 @@ export class CodeMindMapPanel { topicEl.setAttribute('data-status', status); } + function updateNodeChildProgress(nodeObj) { + const topicEl = getTopicElementByNode(nodeObj); + if (!topicEl) return; + + const children = Array.isArray(nodeObj.children) ? nodeObj.children : []; + if (children.length === 0) { + topicEl.removeAttribute('data-child-progress-visible'); + topicEl.removeAttribute('data-child-progress-text'); + topicEl.style.removeProperty('--child-progress-completed-ratio'); + topicEl.style.removeProperty('--child-progress-in-progress-ratio'); + topicEl.style.removeProperty('--child-progress-label-width'); + return; + } + + let completedChildren = 0; + let inProgressChildren = 0; + let statusMarkedChildren = 0; + + for (const child of children) { + const childStatus = child?.data?.status; + if (childStatus === 'completed') { + completedChildren += 1; + statusMarkedChildren += 1; + } else if (childStatus === 'in-progress') { + inProgressChildren += 1; + statusMarkedChildren += 1; + } + } + + // Show only when at least one child has in-progress or completed status. + if (statusMarkedChildren === 0) { + topicEl.removeAttribute('data-child-progress-visible'); + topicEl.removeAttribute('data-child-progress-text'); + topicEl.style.removeProperty('--child-progress-completed-ratio'); + topicEl.style.removeProperty('--child-progress-in-progress-ratio'); + topicEl.style.removeProperty('--child-progress-label-width'); + return; + } + + const completionRatio = children.length > 0 + ? Math.round((completedChildren / children.length) * 100) + : 0; + const inProgressRatio = children.length > 0 + ? Math.round(((completedChildren + inProgressChildren) / children.length) * 100) + : 0; + const progressText = completedChildren + '/' + children.length; + const labelWidth = Math.min(84, Math.max(30, progressText.length * 6 + 10)); + + topicEl.setAttribute('data-child-progress-visible', 'true'); + topicEl.setAttribute('data-child-progress-text', progressText); + topicEl.style.setProperty('--child-progress-completed-ratio', completionRatio + '%'); + topicEl.style.setProperty('--child-progress-in-progress-ratio', inProgressRatio + '%'); + topicEl.style.setProperty('--child-progress-label-width', labelWidth + 'px'); + } + function applyAllStatuses() { const root = mind?.nodeData; if (!root) return; @@ -1135,14 +1235,18 @@ export class CodeMindMapPanel { const node = stack.pop(); if (!node) continue; updateNodeStatus(node); + updateNodeChildProgress(node); if (Array.isArray(node.children)) { for (const child of node.children) { stack.push(child); } } } - // linkDiv is called by MindElixir itself after layout; we must not call it here - // as that would create an infinite loop via the linkDiv bus listener + // Redraw branch lines to realign with resized nodes (padding changed by progress bars). + // The re-entry guard above prevents the resulting linkDiv event from re-triggering this. + applyingStatuses = true; + mind.linkDiv(); + applyingStatuses = false; } function scheduleApplyAllStatuses() { @@ -1174,7 +1278,9 @@ export class CodeMindMapPanel { // Debounced linkDiv listener: MindElixir fires linkDiv after every layout pass // (including multiple passes after refresh/changeTheme). Wait for 50ms of silence // before applying statuses so we always run after the final DOM state. + // The applyingStatuses guard skips the event fired by our own mind.linkDiv() call. mind.bus.addListener('linkDiv', () => { + if (applyingStatuses) return; clearTimeout(linkDivDebounceTimer); linkDivDebounceTimer = setTimeout(applyAllStatuses, 50); }); @@ -1257,6 +1363,7 @@ export class CodeMindMapPanel { // Update visual appearance updateNodeStatus(currentNode); + scheduleApplyAllStatuses(); // Trigger autosave vscode.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); diff --git a/Visual Studio/CodeMindMap/CodeMindMapHtml.cs b/Visual Studio/CodeMindMap/CodeMindMapHtml.cs index 6385c35..84c39d3 100644 --- a/Visual Studio/CodeMindMap/CodeMindMapHtml.cs +++ b/Visual Studio/CodeMindMap/CodeMindMapHtml.cs @@ -76,6 +76,43 @@ public class CodeMindMapHtml content: '✓'; color: #4caf50; } + /* Child task completion progress */ + .map-container me-tpc[data-child-progress-visible=""true""] { + --child-progress-completed-ratio: 0%; + --child-progress-in-progress-ratio: 0%; + --child-progress-label-width: 30px; + background-image: linear-gradient( + to right, + #4caf50 var(--child-progress-completed-ratio), + #ff9800 var(--child-progress-completed-ratio), + #ff9800 var(--child-progress-in-progress-ratio), + rgba(255, 255, 255, 0.18) var(--child-progress-in-progress-ratio) + ); + background-repeat: no-repeat; + background-size: calc(100% - 8px) 4px; + background-position: 4px calc(100% - 2px); + padding-bottom: 8px; + padding-inline-end: calc(var(--child-progress-label-width) + 16px); + } + .map-container me-tpc[data-child-progress-visible=""true""]::after { + content: attr(data-child-progress-text); + position: absolute; + inset-inline-end: 6px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + font-weight: 600; + text-align: center; + color: rgba(255, 255, 255, 0.92); + background: rgba(0, 0, 0, 0.3); + border-radius: 10px; + width: var(--child-progress-label-width); + box-sizing: border-box; + padding: 0 4px; + line-height: 15px; + white-space: nowrap; + pointer-events: none; + } @@ -88,6 +125,7 @@ public class CodeMindMapHtml let linkDivDebounceTimer = null; let scheduleRafHandle = null; let scheduleTimerHandle = null; + let applyingStatuses = false; // re-entry guard: prevents linkDiv→applyAllStatuses→linkDiv loop function initMindMap() { const options = { @@ -104,6 +142,7 @@ function initMindMap() { node.data = node.data || {}; node.data.status = 'in-progress'; updateNodeStatus(node); + scheduleApplyAllStatuses(); window.chrome.webview.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; } @@ -116,6 +155,7 @@ function initMindMap() { node.data = node.data || {}; node.data.status = 'completed'; updateNodeStatus(node); + scheduleApplyAllStatuses(); window.chrome.webview.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; } @@ -128,6 +168,7 @@ function initMindMap() { node.data = node.data || {}; delete node.data.status; updateNodeStatus(node); + scheduleApplyAllStatuses(); window.chrome.webview.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); const cm = document.querySelector('.map-container > .context-menu'); if (cm) cm.hidden = true; } @@ -322,23 +363,25 @@ function initMindMap() { scheduleApplyAllStatuses(); // Helper function to update node visual status - function updateNodeStatus(nodeObj) { - if (!nodeObj || !nodeObj.id) return; + function getTopicElementByNode(nodeObj) { + if (!nodeObj || !nodeObj.id) return null; const nodeElement = MindElixir.E(nodeObj.id); - if (!nodeElement) return; + if (!nodeElement) return null; - const status = nodeObj.data?.status || null; const domEl = nodeElement.getEl?.() || nodeElement; - if (!domEl) return; + if (!domEl) return null; + + if (domEl.tagName === 'ME-TPC') return domEl; + const byQuery = domEl.querySelector?.('me-tpc'); + if (byQuery) return byQuery; + const byTag = domEl.getElementsByTagName?.('me-tpc')?.[0]; + if (byTag) return byTag; + return domEl; + } - const topicEl = (() => { - if (domEl.tagName === 'ME-TPC') return domEl; - const byQuery = domEl.querySelector?.('me-tpc'); - if (byQuery) return byQuery; - const byTag = domEl.getElementsByTagName?.('me-tpc')?.[0]; - if (byTag) return byTag; - return domEl; - })(); + function updateNodeStatus(nodeObj) { + const status = nodeObj.data?.status || null; + const topicEl = getTopicElementByNode(nodeObj); if (!topicEl) return; @@ -350,6 +393,61 @@ function updateNodeStatus(nodeObj) { topicEl.setAttribute('data-status', status); } + function updateNodeChildProgress(nodeObj) { + const topicEl = getTopicElementByNode(nodeObj); + if (!topicEl) return; + + const children = Array.isArray(nodeObj.children) ? nodeObj.children : []; + if (children.length === 0) { + topicEl.removeAttribute('data-child-progress-visible'); + topicEl.removeAttribute('data-child-progress-text'); + topicEl.style.removeProperty('--child-progress-completed-ratio'); + topicEl.style.removeProperty('--child-progress-in-progress-ratio'); + topicEl.style.removeProperty('--child-progress-label-width'); + return; + } + + let completedChildren = 0; + let inProgressChildren = 0; + let statusMarkedChildren = 0; + + for (const child of children) { + const childStatus = child?.data?.status; + if (childStatus === 'completed') { + completedChildren += 1; + statusMarkedChildren += 1; + } else if (childStatus === 'in-progress') { + inProgressChildren += 1; + statusMarkedChildren += 1; + } + } + + // Show only when at least one child has in-progress or completed status. + if (statusMarkedChildren === 0) { + topicEl.removeAttribute('data-child-progress-visible'); + topicEl.removeAttribute('data-child-progress-text'); + topicEl.style.removeProperty('--child-progress-completed-ratio'); + topicEl.style.removeProperty('--child-progress-in-progress-ratio'); + topicEl.style.removeProperty('--child-progress-label-width'); + return; + } + + const completionRatio = children.length > 0 + ? Math.round((completedChildren / children.length) * 100) + : 0; + const inProgressRatio = children.length > 0 + ? Math.round(((completedChildren + inProgressChildren) / children.length) * 100) + : 0; + const progressText = completedChildren + '/' + children.length; + const labelWidth = Math.min(84, Math.max(30, progressText.length * 6 + 10)); + + topicEl.setAttribute('data-child-progress-visible', 'true'); + topicEl.setAttribute('data-child-progress-text', progressText); + topicEl.style.setProperty('--child-progress-completed-ratio', completionRatio + '%'); + topicEl.style.setProperty('--child-progress-in-progress-ratio', inProgressRatio + '%'); + topicEl.style.setProperty('--child-progress-label-width', labelWidth + 'px'); + } + function applyAllStatuses() { const root = mind?.nodeData; if (!root) return; @@ -358,12 +456,18 @@ function applyAllStatuses() { const node = stack.pop(); if (!node) continue; updateNodeStatus(node); + updateNodeChildProgress(node); if (Array.isArray(node.children)) { for (const child of node.children) { stack.push(child); } } } + // Redraw branch lines to realign with resized nodes (padding changed by progress bars). + // The re-entry guard above prevents the resulting linkDiv event from re-triggering this. + applyingStatuses = true; + mind.linkDiv(); + applyingStatuses = false; } function scheduleApplyAllStatuses() { @@ -394,7 +498,9 @@ function scheduleApplyAllStatuses() { // Debounced linkDiv listener: MindElixir fires linkDiv after every layout pass. // Wait for 50ms of silence before applying statuses so we run after the final DOM state. + // The applyingStatuses guard skips the event fired by our own mind.linkDiv() call. mind.bus.addListener('linkDiv', () => { + if (applyingStatuses) return; clearTimeout(linkDivDebounceTimer); linkDivDebounceTimer = setTimeout(applyAllStatuses, 50); }); @@ -461,6 +567,7 @@ function scheduleApplyAllStatuses() { } updateNodeStatus(currentNode); + scheduleApplyAllStatuses(); window.chrome.webview.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); } });