From b0e7aed7600878ee229ec1f2d7351bfebca25274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre-Luc=20Gagn=C3=A9?= Date: Sun, 8 Mar 2026 14:34:45 -0400 Subject: [PATCH 1/3] feat: add dynamic child progress badge spacing --- VS Code/src/extension.ts | 116 ++++++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 13 deletions(-) diff --git a/VS Code/src/extension.ts b/VS Code/src/extension.ts index 364c78a..370ef79 100644 --- a/VS Code/src/extension.ts +++ b/VS Code/src/extension.ts @@ -790,6 +790,41 @@ export class CodeMindMapPanel { content: '✓'; color: #4caf50; } + + /* Child task completion progress */ + .map-container me-tpc[data-child-progress-visible="true"] { + --child-progress-ratio: 0%; + --child-progress-label-width: 30px; + background-image: linear-gradient( + to right, + #4caf50 var(--child-progress-ratio), + rgba(255, 255, 255, 0.18) var(--child-progress-ratio) + ); + background-repeat: no-repeat; + background-size: calc(100% - 8px) 4px; + background-position: 4px calc(100% - 2px); + padding-bottom: 8px; + padding-right: 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; + right: 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; + } @@ -859,6 +894,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 +907,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 +920,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 +1137,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; + + 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; - 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; - })(); + return domEl; + } + + function updateNodeStatus(nodeObj) { + const status = nodeObj.data?.status || null; + const topicEl = getTopicElementByNode(nodeObj); if (!topicEl) return; @@ -1127,6 +1168,53 @@ 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-ratio'); + topicEl.style.removeProperty('--child-progress-label-width'); + return; + } + + let completedChildren = 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') { + 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-ratio'); + topicEl.style.removeProperty('--child-progress-label-width'); + return; + } + + const completionRatio = children.length > 0 + ? Math.round((completedChildren / 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-ratio', completionRatio + '%'); + topicEl.style.setProperty('--child-progress-label-width', labelWidth + 'px'); + } + function applyAllStatuses() { const root = mind?.nodeData; if (!root) return; @@ -1135,6 +1223,7 @@ 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); @@ -1257,6 +1346,7 @@ export class CodeMindMapPanel { // Update visual appearance updateNodeStatus(currentNode); + scheduleApplyAllStatuses(); // Trigger autosave vscode.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); From 675aa1f1d0cb030dccf142a9ed59c8e881c25705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre-Luc=20Gagn=C3=A9?= Date: Sun, 8 Mar 2026 14:41:10 -0400 Subject: [PATCH 2/3] feat(vs): add dynamic child progress badge spacing --- Visual Studio/CodeMindMap/CodeMindMapHtml.cs | 114 ++++++++++++++++--- 1 file changed, 101 insertions(+), 13 deletions(-) diff --git a/Visual Studio/CodeMindMap/CodeMindMapHtml.cs b/Visual Studio/CodeMindMap/CodeMindMapHtml.cs index 6385c35..1314a04 100644 --- a/Visual Studio/CodeMindMap/CodeMindMapHtml.cs +++ b/Visual Studio/CodeMindMap/CodeMindMapHtml.cs @@ -76,6 +76,40 @@ public class CodeMindMapHtml content: '✓'; color: #4caf50; } + /* Child task completion progress */ + .map-container me-tpc[data-child-progress-visible=""true""] { + --child-progress-ratio: 0%; + --child-progress-label-width: 30px; + background-image: linear-gradient( + to right, + #4caf50 var(--child-progress-ratio), + rgba(255, 255, 255, 0.18) var(--child-progress-ratio) + ); + background-repeat: no-repeat; + background-size: calc(100% - 8px) 4px; + background-position: 4px calc(100% - 2px); + padding-bottom: 8px; + padding-right: 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; + right: 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; + } @@ -104,6 +138,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 +151,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 +164,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 +359,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 +389,53 @@ 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-ratio'); + topicEl.style.removeProperty('--child-progress-label-width'); + return; + } + + let completedChildren = 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') { + 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-ratio'); + topicEl.style.removeProperty('--child-progress-label-width'); + return; + } + + const completionRatio = children.length > 0 + ? Math.round((completedChildren / 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-ratio', completionRatio + '%'); + topicEl.style.setProperty('--child-progress-label-width', labelWidth + 'px'); + } + function applyAllStatuses() { const root = mind?.nodeData; if (!root) return; @@ -358,6 +444,7 @@ 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); @@ -461,6 +548,7 @@ function scheduleApplyAllStatuses() { } updateNodeStatus(currentNode); + scheduleApplyAllStatuses(); window.chrome.webview.postMessage({ action: 'mindMapOperation', operationName: 'updateNodeStatus' }); } }); From efe172963c5cab6541986d547445f06afe59a722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre-Luc=20Gagn=C3=A9?= Date: Sun, 8 Mar 2026 15:23:39 -0400 Subject: [PATCH 3/3] Show in-progress children as orange segment in child progress bar - Add orange segment between completed (green) and remainder for in-progress nodes - Use CSS logical properties (padding-inline-end, inset-inline-end) so the bar and label position correctly on both left and right-side nodes - Call mind.linkDiv() after applying statuses with a re-entry guard to realign branch lines after node heights change due to the progress bar padding --- VS Code/src/extension.ts | 37 ++++++++++++++------ Visual Studio/CodeMindMap/CodeMindMapHtml.cs | 35 +++++++++++++----- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/VS Code/src/extension.ts b/VS Code/src/extension.ts index 370ef79..1ffd103 100644 --- a/VS Code/src/extension.ts +++ b/VS Code/src/extension.ts @@ -793,23 +793,26 @@ export class CodeMindMapPanel { /* Child task completion progress */ .map-container me-tpc[data-child-progress-visible="true"] { - --child-progress-ratio: 0%; + --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-ratio), - rgba(255, 255, 255, 0.18) var(--child-progress-ratio) + #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-right: calc(var(--child-progress-label-width) + 16px); + 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; - right: 6px; + inset-inline-end: 6px; top: 50%; transform: translateY(-50%); font-size: 10px; @@ -878,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 = { @@ -1176,12 +1180,14 @@ export class CodeMindMapPanel { if (children.length === 0) { topicEl.removeAttribute('data-child-progress-visible'); topicEl.removeAttribute('data-child-progress-text'); - topicEl.style.removeProperty('--child-progress-ratio'); + 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) { @@ -1190,6 +1196,7 @@ export class CodeMindMapPanel { completedChildren += 1; statusMarkedChildren += 1; } else if (childStatus === 'in-progress') { + inProgressChildren += 1; statusMarkedChildren += 1; } } @@ -1198,7 +1205,8 @@ export class CodeMindMapPanel { if (statusMarkedChildren === 0) { topicEl.removeAttribute('data-child-progress-visible'); topicEl.removeAttribute('data-child-progress-text'); - topicEl.style.removeProperty('--child-progress-ratio'); + topicEl.style.removeProperty('--child-progress-completed-ratio'); + topicEl.style.removeProperty('--child-progress-in-progress-ratio'); topicEl.style.removeProperty('--child-progress-label-width'); return; } @@ -1206,12 +1214,16 @@ export class CodeMindMapPanel { 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-ratio', completionRatio + '%'); + 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'); } @@ -1230,8 +1242,11 @@ export class CodeMindMapPanel { } } } - // 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() { @@ -1263,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); }); diff --git a/Visual Studio/CodeMindMap/CodeMindMapHtml.cs b/Visual Studio/CodeMindMap/CodeMindMapHtml.cs index 1314a04..84c39d3 100644 --- a/Visual Studio/CodeMindMap/CodeMindMapHtml.cs +++ b/Visual Studio/CodeMindMap/CodeMindMapHtml.cs @@ -78,23 +78,26 @@ public class CodeMindMapHtml } /* Child task completion progress */ .map-container me-tpc[data-child-progress-visible=""true""] { - --child-progress-ratio: 0%; + --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-ratio), - rgba(255, 255, 255, 0.18) var(--child-progress-ratio) + #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-right: calc(var(--child-progress-label-width) + 16px); + 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; - right: 6px; + inset-inline-end: 6px; top: 50%; transform: translateY(-50%); font-size: 10px; @@ -122,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 = { @@ -397,12 +401,14 @@ function updateNodeChildProgress(nodeObj) { if (children.length === 0) { topicEl.removeAttribute('data-child-progress-visible'); topicEl.removeAttribute('data-child-progress-text'); - topicEl.style.removeProperty('--child-progress-ratio'); + 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) { @@ -411,6 +417,7 @@ function updateNodeChildProgress(nodeObj) { completedChildren += 1; statusMarkedChildren += 1; } else if (childStatus === 'in-progress') { + inProgressChildren += 1; statusMarkedChildren += 1; } } @@ -419,7 +426,8 @@ function updateNodeChildProgress(nodeObj) { if (statusMarkedChildren === 0) { topicEl.removeAttribute('data-child-progress-visible'); topicEl.removeAttribute('data-child-progress-text'); - topicEl.style.removeProperty('--child-progress-ratio'); + topicEl.style.removeProperty('--child-progress-completed-ratio'); + topicEl.style.removeProperty('--child-progress-in-progress-ratio'); topicEl.style.removeProperty('--child-progress-label-width'); return; } @@ -427,12 +435,16 @@ function updateNodeChildProgress(nodeObj) { 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-ratio', completionRatio + '%'); + 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'); } @@ -451,6 +463,11 @@ function applyAllStatuses() { } } } + // 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() { @@ -481,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); });