From c4be4193542562620370cd71831867925eef6768 Mon Sep 17 00:00:00 2001 From: Ignaciobrenas Date: Sat, 14 Nov 2026 23:23:45 +0100 Subject: [PATCH] feat(dag): add branch minimap, advanced filters (--first-parent/date/path), interactive drag-and-drop & onboarding (Refs #8, Refs #9) --- src/git/gitClient.ts | 48 +++- src/git/types.ts | 15 +- src/graph/graphWebviewProvider.ts | 123 +++++++++- src/webview/main.ts | 378 +++++++++++++++++++++++++++--- src/webview/style.css | 89 +++++++ test/runTests.ts | 51 +++- 6 files changed, 652 insertions(+), 52 deletions(-) diff --git a/src/git/gitClient.ts b/src/git/gitClient.ts index 1d345a0..5e0717d 100644 --- a/src/git/gitClient.ts +++ b/src/git/gitClient.ts @@ -9,6 +9,7 @@ import { CloudPatch, CommitComparisonResult, ContributorStat, + DagQueryFilters, FileCommitRevision, GitAuthor, GitBisectStatus, @@ -202,20 +203,35 @@ export class GitClient { public async getCommits( cwd: string, limit = 300, - skip = 0 + skip = 0, + filters?: DagQueryFilters ): Promise { const format = '%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%D%x1f%s%x1f%b%x1f%G?%x1f%GS%x1f%GK%x1e'; - const args = [ - 'log', - '--all', + const args = ['log', filters?.hideRemotes ? '--branches' : '--all']; + if (filters?.hideRemotes) { + args.push('--tags'); + } + if (filters?.firstParent) { + args.push('--first-parent'); + } + args.push( '--topo-order', '--parents', `--format=${format}`, - `-n`, + '-n', limit.toString(), - `--skip=${skip}`, - ]; + `--skip=${skip}` + ); + if (filters?.since) { + args.push(`--since=${filters.since}`); + } + if (filters?.until) { + args.push(`--until=${filters.until}`); + } + if (filters?.filePath && !filters.filePath.includes('..') && !filters.filePath.startsWith('-')) { + args.push('--', filters.filePath); + } try { const raw = await this.execute(args, cwd); @@ -2096,6 +2112,24 @@ export class GitClient { }; } + /** + * Moves or resets a branch pointer to a target commit hash (supports soft/mixed/hard for active branch). + */ + public async resetBranchToCommit( + cwd: string, + branchName: string, + targetHash: string, + mode: 'soft' | 'mixed' | 'hard' = 'mixed' + ): Promise { + const cleanBranch = branchName.replace(/^HEAD\s*->\s*/, '').trim(); + const current = await this.getCurrentBranch(cwd).catch(() => ''); + if (current && current === cleanBranch) { + await this.execute(['reset', `--${mode}`, targetHash], cwd); + } else { + await this.execute(['branch', '-f', cleanBranch, targetHash], cwd); + } + } + /** * Discovers all submodules in the repository completely offline via GitFsReader */ diff --git a/src/git/types.ts b/src/git/types.ts index 65d83e8..81cab4c 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -346,10 +346,23 @@ export interface WebviewToExtensionMessage { | 'skipRebase' | 'requestAiKeys' | 'generateAiKey' - | 'revokeAiKey'; + | 'revokeAiKey' + // Advanced DAG Filters & Drag-and-Drop: + | 'applyDagFilters' + | 'dndResetBranchToCommit' + | 'dndCherryPickToBranch' + | 'dndRebaseOntoCommit'; payload?: any; } +export interface DagQueryFilters { + firstParent?: boolean; + hideRemotes?: boolean; + since?: string; + until?: string; + filePath?: string; +} + export interface ExtensionToWebviewMessage { command: | 'graphData' diff --git a/src/graph/graphWebviewProvider.ts b/src/graph/graphWebviewProvider.ts index 187c368..ff3a05b 100644 --- a/src/graph/graphWebviewProvider.ts +++ b/src/graph/graphWebviewProvider.ts @@ -13,6 +13,7 @@ import { ApiKeyManager } from '../mcp/apiKeyManager'; import { AdaptiveDebouncer } from '../git/branchWatcher'; import { CommitFlowSettings, + DagQueryFilters, ExtensionToWebviewMessage, LayoutMode, WebviewToExtensionMessage, @@ -37,6 +38,7 @@ export class GraphWebviewProvider }); private refreshInFlight: Promise | null = null; private refreshPending = false; + private currentDagFilters: DagQueryFilters = {}; private ipcBatchQueue = new Map(); private ipcBatchTimer: NodeJS.Timeout | null = null; @@ -273,7 +275,7 @@ export class GraphWebviewProvider repositories, submodules, ] = await Promise.all([ - this.gitClient.getCommits(root, limit), + this.gitClient.getCommits(root, limit, 0, this.currentDagFilters), this.gitClient.getBranches(root), this.gitClient.getCurrentBranch(root), this.gitClient.getGitHubRepoInfo(root), @@ -1223,6 +1225,60 @@ export class GraphWebviewProvider } break; + case 'applyDagFilters': + this.currentDagFilters = (msg.payload && typeof msg.payload === 'object') ? msg.payload : {}; + await this.refreshData(); + break; + + case 'dndResetBranchToCommit': + if (root && msg.payload?.branchName && msg.payload?.targetHash) { + try { + const mode = msg.payload.mode || 'mixed'; + await this.gitClient.resetBranchToCommit(root, msg.payload.branchName, msg.payload.targetHash, mode); + vscode.window.showInformationMessage( + `Branch "${msg.payload.branchName}" updated to ${String(msg.payload.targetHash).slice(0, 7)} (--${mode}).` + ); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Drag-and-drop reset failed: ${err.message}`); + } + } + break; + + case 'dndCherryPickToBranch': + if (root && msg.payload?.commitHash) { + try { + if (msg.payload.targetBranch) { + await this.gitClient.checkoutBranch(root, msg.payload.targetBranch); + } + const cpRes = await this.gitClient.cherryPick(root, msg.payload.commitHash); + if (cpRes.success) { + vscode.window.showInformationMessage(`Cherry-picked ${String(msg.payload.commitHash).slice(0, 7)}.`); + } else { + vscode.window.showWarningMessage(`Cherry-pick notice: ${cpRes.message}`); + } + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Drag-and-drop cherry-pick failed: ${err.message}`); + } + } + break; + + case 'dndRebaseOntoCommit': + if (root && msg.payload?.targetHash) { + try { + if (msg.payload.sourceBranch) { + await this.gitClient.checkoutBranch(root, msg.payload.sourceBranch); + } + await this.gitClient.rebase(root, msg.payload.targetHash); + vscode.window.showInformationMessage(`Rebased onto ${String(msg.payload.targetHash).slice(0, 7)}.`); + await this.refreshData(); + } catch (err: any) { + vscode.window.showErrorMessage(`Drag-and-drop rebase failed: ${err.message}`); + } + } + break; + case 'requestRebaseCommits': if (root && msg.payload?.baseHash) { try { @@ -1671,11 +1727,43 @@ export class GraphWebviewProvider -
-
- -
${tr('graph.loading')}
+ +
+ + + + + + + +
+ + +
+
+ +
+
+
+ +
${tr('graph.loading')}
+
+
@@ -2386,6 +2474,31 @@ Always check repo state and active branch before making modifications.

+ + +
diff --git a/src/webview/main.ts b/src/webview/main.ts index 4eb51d0..31c8890 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -692,9 +692,180 @@ function parseSearchQuery(query: string): { return result; } +let advFilterFirstParent = false; +let advFilterHideRemotes = false; +let advFilterSince = ''; +let advFilterUntil = ''; +let advFilterFilePath = ''; + +interface DagDragPayload { + kind: 'branch' | 'commit'; + branchName?: string; + sourceHash: string; + shortHash?: string; +} + +let activeDagDragPayload: DagDragPayload | null = null; +let pendingDndConfirmAction: { + command: 'dndResetBranchToCommit' | 'dndCherryPickToBranch' | 'dndRebaseOntoCommit'; + payload: any; +} | null = null; + +function createRefPillElement( + ref: { type: string; name: string; isHead: boolean }, + node: GraphNode +): HTMLElement { + const pill = document.createElement('span'); + pill.className = `ref-pill ${ref.isHead ? 'head' : ref.type === 'tag' ? 'tag' : 'branch'}`; + const icon = ref.type === 'tag' ? 'codicon-tag' : 'codicon-git-branch'; + pill.innerHTML = ` ${ + ref.isHead ? `HEAD -> ${escapeHtml(ref.name)}` : escapeHtml(ref.name) + }`; + pill.title = ref.name; + + if (ref.type !== 'tag' && ref.type !== 'remote-branch') { + pill.draggable = true; + pill.addEventListener('dragstart', (e) => { + e.stopPropagation(); + activeDagDragPayload = { + kind: 'branch', + branchName: ref.name, + sourceHash: node.commit.hash, + shortHash: node.commit.shortHash, + }; + pill.classList.add('dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('application/x-commitflow-dag', JSON.stringify(activeDagDragPayload)); + } + }); + pill.addEventListener('dragend', () => { + activeDagDragPayload = null; + pill.classList.remove('dragging'); + }); + } + + pill.addEventListener('mouseenter', (e) => { + e.stopPropagation(); + highlightBranchPath(node.commit.hash); + }); + pill.addEventListener('mouseleave', () => { + clearBranchPathHighlight(); + }); + return pill; +} + +function openDndConfirmModal(dragData: DagDragPayload, targetNode: GraphNode): void { + const modal = document.getElementById('dnd-confirm-modal'); + const summary = document.getElementById('dnd-modal-summary'); + const actionSelect = document.getElementById('dnd-action-select') as HTMLSelectElement | null; + if (!modal || !summary || !actionSelect) return; + + actionSelect.innerHTML = ''; + const targetShort = targetNode.commit.shortHash || targetNode.commit.hash.slice(0, 7); + const targetBranch = targetNode.commit.refs.find((r) => r.type === 'branch' || r.type === 'head')?.name; + + if (dragData.kind === 'branch' && dragData.branchName) { + summary.innerHTML = `Arrastraste la rama ${escapeHtml(dragData.branchName)} sobre el commit ${escapeHtml(targetShort)}.`; + [ + { value: 'reset-mixed', label: `Mover / Reset '${dragData.branchName}' a ${targetShort} (--mixed)` }, + { value: 'reset-soft', label: `Reset suave '${dragData.branchName}' a ${targetShort} (--soft)` }, + { value: 'reset-hard', label: `Reset duro '${dragData.branchName}' a ${targetShort} (--hard)` }, + { value: 'rebase', label: `Rebase '${dragData.branchName}' sobre ${targetShort}` }, + ].forEach((opt) => { + const o = document.createElement('option'); + o.value = opt.value; + o.textContent = opt.label; + actionSelect.appendChild(o); + }); + } else { + const srcShort = dragData.shortHash || dragData.sourceHash.slice(0, 7); + summary.innerHTML = `Arrastraste el commit ${escapeHtml(srcShort)} sobre ${escapeHtml(targetBranch || targetShort)}.`; + [ + { + value: 'cherry-pick', + label: targetBranch ? `Cherry-pick ${srcShort} en '${targetBranch}'` : `Cherry-pick ${srcShort} en HEAD`, + }, + { value: 'rebase-onto', label: `Rebase HEAD sobre ${targetShort}` }, + ].forEach((opt) => { + const o = document.createElement('option'); + o.value = opt.value; + o.textContent = opt.label; + actionSelect.appendChild(o); + }); + } + + const updatePendingAction = () => { + const val = actionSelect.value; + if (dragData.kind === 'branch' && dragData.branchName) { + if (val === 'rebase') { + pendingDndConfirmAction = { + command: 'dndRebaseOntoCommit', + payload: { sourceBranch: dragData.branchName, targetHash: targetNode.commit.hash }, + }; + } else { + const mode = val === 'reset-soft' ? 'soft' : val === 'reset-hard' ? 'hard' : 'mixed'; + pendingDndConfirmAction = { + command: 'dndResetBranchToCommit', + payload: { branchName: dragData.branchName, targetHash: targetNode.commit.hash, mode }, + }; + } + } else if (val === 'rebase-onto') { + pendingDndConfirmAction = { + command: 'dndRebaseOntoCommit', + payload: { targetHash: targetNode.commit.hash }, + }; + } else { + pendingDndConfirmAction = { + command: 'dndCherryPickToBranch', + payload: { commitHash: dragData.sourceHash, targetBranch }, + }; + } + }; + + actionSelect.onchange = updatePendingAction; + updatePendingAction(); + modal.classList.add('open'); + trapModalFocus(modal); +} + +function updateDagMinimap(): void { + const minimapCanvas = document.getElementById('dag-minimap-canvas') as HTMLCanvasElement | null; + if (!minimapCanvas || filteredNodes.length === 0) return; + + const rowHeight = getRowHeight(); + const totalHeight = Math.max(1, filteredNodes.length * rowHeight); + const startRatio = Math.max(0, Math.min(1, tableContainer.scrollTop / totalHeight)); + const endRatio = Math.max(startRatio, Math.min(1, (tableContainer.scrollTop + (tableContainer.clientHeight || 600)) / totalHeight)); + const selectedIndex = selectedNode ? filteredNodes.indexOf(selectedNode) : -1; + + dagWorkerEngine.renderMinimap(filteredNodes, minimapCanvas, { + width: minimapCanvas.clientWidth || 76, + height: minimapCanvas.clientHeight || 480, + selectedIndex, + viewportStartRatio: startRatio, + viewportEndRatio: endRatio, + }); +} + function applyFilters(): void { filteredNodes = allNodes.filter((node) => { const commit = node.commit; + if (advFilterHideRemotes && commit.refs.length > 0 && commit.refs.every((r) => r.type === 'remote-branch')) { + return false; + } + if (advFilterSince) { + const sinceMs = Date.parse(advFilterSince); + if (!Number.isNaN(sinceMs) && new Date(commit.author.date).getTime() < sinceMs) { + return false; + } + } + if (advFilterUntil) { + const untilMs = Date.parse(advFilterUntil); + if (!Number.isNaN(untilMs) && new Date(commit.author.date).getTime() > untilMs + 86_399_999) { + return false; + } + } if (searchQuery) { const parsed = parseSearchQuery(searchQuery); @@ -960,23 +1131,7 @@ function renderCommitRow( } node.commit.refs.forEach((ref) => { - const pill = document.createElement('span'); - pill.className = `ref-pill ${ - ref.isHead ? 'head' : ref.type === 'tag' ? 'tag' : 'branch' - }`; - const icon = ref.type === 'tag' ? 'codicon-tag' : 'codicon-git-branch'; - pill.innerHTML = ` ${ - ref.isHead ? `HEAD -> ${ref.name}` : ref.name - }`; - pill.title = ref.name; - pill.addEventListener('mouseenter', (e) => { - e.stopPropagation(); - highlightBranchPath(node.commit.hash); - }); - pill.addEventListener('mouseleave', () => { - clearBranchPathHighlight(); - }); - cardTop.appendChild(pill); + cardTop.appendChild(createRefPillElement(ref, node)); }); const subjectSpan = document.createElement('span'); @@ -1067,23 +1222,7 @@ function renderCommitRow( } node.commit.refs.forEach((ref) => { - const pill = document.createElement('span'); - pill.className = `ref-pill ${ - ref.isHead ? 'head' : ref.type === 'tag' ? 'tag' : 'branch' - }`; - const icon = ref.type === 'tag' ? 'codicon-tag' : 'codicon-git-branch'; - pill.innerHTML = ` ${ - ref.isHead ? `HEAD -> ${ref.name}` : ref.name - }`; - pill.title = ref.name; - pill.addEventListener('mouseenter', (e) => { - e.stopPropagation(); - highlightBranchPath(node.commit.hash); - }); - pill.addEventListener('mouseleave', () => { - clearBranchPathHighlight(); - }); - infoCol.appendChild(pill); + infoCol.appendChild(createRefPillElement(ref, node)); }); const subjectSpan = document.createElement('span'); @@ -1220,7 +1359,43 @@ function renderCommitRow( row.appendChild(rowActions); } - // Row Click, Context Menu & Hover handlers + // Row Click, Context Menu, Drag-and-Drop & Hover handlers + row.draggable = true; + row.addEventListener('dragstart', (e) => { + if ((e.target as HTMLElement)?.closest('.ref-pill')) return; + activeDagDragPayload = { + kind: 'commit', + sourceHash: node.commit.hash, + shortHash: node.commit.shortHash, + }; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'copyMove'; + e.dataTransfer.setData('application/x-commitflow-dag', JSON.stringify(activeDagDragPayload)); + } + }); + row.addEventListener('dragover', (e) => { + if (!activeDagDragPayload || activeDagDragPayload.sourceHash === node.commit.hash) return; + e.preventDefault(); + row.classList.add('dag-drop-target'); + }); + row.addEventListener('dragleave', () => { + row.classList.remove('dag-drop-target'); + }); + row.addEventListener('drop', (e) => { + e.preventDefault(); + row.classList.remove('dag-drop-target'); + const rawJson = e.dataTransfer?.getData('application/x-commitflow-dag'); + let dragData = activeDagDragPayload; + if (!dragData && rawJson) { + try { + dragData = JSON.parse(rawJson); + } catch {} + } + activeDagDragPayload = null; + if (dragData && dragData.sourceHash !== node.commit.hash) { + openDndConfirmModal(dragData, node); + } + }); row.addEventListener('click', () => selectCommit(node)); row.addEventListener('contextmenu', (e) => { e.preventDefault(); @@ -1304,6 +1479,7 @@ function updateVirtualWindow(): void { virtualViewport.innerHTML = ''; virtualViewport.appendChild(fragment); + updateDagMinimap(); } function jumpToCommitIndex(index: number): void { @@ -1327,12 +1503,23 @@ function renderGraph(): void { if (allNodes.length === 0) { tableContainer.innerHTML = ` -
- +
+
${tr('graph.emptyRepo')}
-
${tr('graph.emptyRepoDesc')}
+
${tr('graph.emptyRepoDesc')}
+
`; + document.getElementById('btn-empty-refresh')?.addEventListener('click', () => { + vscode.postMessage({ command: 'requestData' }); + }); return; } @@ -2422,6 +2609,10 @@ function renderComparisonModal(comparison: CommitComparisonResult): void { // Git Bisect Assistant function updateBisectBanner(): void { + const probeJumpBtn = document.getElementById('btn-jump-bisect-probe'); + if (probeJumpBtn) { + probeJumpBtn.style.display = bisectStatus?.isActive ? 'inline-flex' : 'none'; + } if (!bisectBanner) return; if (bisectStatus?.isActive) { bisectBanner.style.display = 'block'; @@ -4375,3 +4566,114 @@ document.getElementById('active-branch-pill')?.addEventListener('click', () => { branchSelect.focus(); } }); + +// Advanced DAG Filters & Quick Jumps (Issue #8) +function dispatchDagFilters(): void { + advFilterSince = (document.getElementById('filter-date-since') as HTMLInputElement | null)?.value || ''; + advFilterUntil = (document.getElementById('filter-date-until') as HTMLInputElement | null)?.value || ''; + advFilterFilePath = (document.getElementById('filter-file-path') as HTMLInputElement | null)?.value.trim() || ''; + vscode.postMessage({ + command: 'applyDagFilters', + payload: { + firstParent: advFilterFirstParent, + hideRemotes: advFilterHideRemotes, + since: advFilterSince || undefined, + until: advFilterUntil || undefined, + filePath: advFilterFilePath || undefined, + }, + }); + applyFilters(); +} + +document.getElementById('btn-filter-first-parent')?.addEventListener('click', (e) => { + advFilterFirstParent = !advFilterFirstParent; + const btn = e.currentTarget as HTMLElement; + btn.classList.toggle('active', advFilterFirstParent); + btn.setAttribute('aria-pressed', String(advFilterFirstParent)); + dispatchDagFilters(); +}); + +document.getElementById('btn-filter-hide-remotes')?.addEventListener('click', (e) => { + advFilterHideRemotes = !advFilterHideRemotes; + const btn = e.currentTarget as HTMLElement; + btn.classList.toggle('active', advFilterHideRemotes); + btn.setAttribute('aria-pressed', String(advFilterHideRemotes)); + dispatchDagFilters(); +}); + +document.getElementById('btn-filter-apply')?.addEventListener('click', dispatchDagFilters); + +document.getElementById('btn-filter-reset')?.addEventListener('click', () => { + advFilterFirstParent = false; + advFilterHideRemotes = false; + advFilterSince = ''; + advFilterUntil = ''; + advFilterFilePath = ''; + ['btn-filter-first-parent', 'btn-filter-hide-remotes'].forEach((id) => { + const el = document.getElementById(id); + el?.classList.remove('active'); + el?.setAttribute('aria-pressed', 'false'); + }); + ['filter-date-since', 'filter-date-until', 'filter-file-path'].forEach((id) => { + const inp = document.getElementById(id) as HTMLInputElement | null; + if (inp) inp.value = ''; + }); + dispatchDagFilters(); +}); + +document.getElementById('btn-jump-head')?.addEventListener('click', () => { + const headIdx = filteredNodes.findIndex((n) => n.commit.refs.some((r) => r.isHead)); + if (headIdx >= 0) jumpToCommitIndex(headIdx); + else if (filteredNodes.length > 0) jumpToCommitIndex(0); +}); + +document.getElementById('btn-jump-bisect-probe')?.addEventListener('click', () => { + if (!bisectStatus?.isActive) return; + const probeIdx = filteredNodes.findIndex( + (n) => + n.commit.hash === bisectStatus?.currentCommit || + n.commit.shortHash === bisectStatus?.currentShortCommit + ); + if (probeIdx >= 0) jumpToCommitIndex(probeIdx); +}); + +// Interactive Minimap Scrubbing (Issue #8) +(() => { + const minimapCanvas = document.getElementById('dag-minimap-canvas') as HTMLCanvasElement | null; + if (!minimapCanvas) return; + let isScrubbing = false; + + const jumpFromPointer = (clientY: number) => { + if (filteredNodes.length === 0) return; + const rect = minimapCanvas.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientY - rect.top) / Math.max(1, rect.height))); + jumpToCommitIndex(Math.min(filteredNodes.length - 1, Math.floor(ratio * filteredNodes.length))); + }; + + minimapCanvas.addEventListener('pointerdown', (e) => { + isScrubbing = true; + minimapCanvas.setPointerCapture?.(e.pointerId); + jumpFromPointer(e.clientY); + }); + minimapCanvas.addEventListener('pointermove', (e) => { + if (isScrubbing) jumpFromPointer(e.clientY); + }); + minimapCanvas.addEventListener('pointerup', () => { + isScrubbing = false; + }); +})(); + +// DAG Drag-and-Drop Confirmation Modal controls (Issue #9) +function closeDndConfirmModal(): void { + document.getElementById('dnd-confirm-modal')?.classList.remove('open'); + pendingDndConfirmAction = null; +} + +document.getElementById('dnd-modal-close')?.addEventListener('click', closeDndConfirmModal); +document.getElementById('btn-dnd-cancel')?.addEventListener('click', closeDndConfirmModal); +document.getElementById('btn-dnd-confirm')?.addEventListener('click', () => { + if (pendingDndConfirmAction) { + vscode.postMessage(pendingDndConfirmAction); + } + closeDndConfirmModal(); +}); diff --git a/src/webview/style.css b/src/webview/style.css index dae72d9..f57969a 100644 --- a/src/webview/style.css +++ b/src/webview/style.css @@ -5485,3 +5485,92 @@ button:focus-visible, display: none !important; } } + +/* ========================================================================== + Advanced DAG Filters, Minimap & Drag-and-Drop (Issues #8 & #9) + ========================================================================== */ +.dag-advanced-filters-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + box-sizing: border-box; +} + +.filter-toggle-pill.active { + background: rgba(6, 182, 212, 0.16); + color: var(--accent-cyan); + border-color: rgba(6, 182, 212, 0.45); +} + +.dag-filter-date { + width: 122px; + min-width: 0; + padding: 2px 6px; + font-size: var(--font-size-xs); +} + +.dag-filter-path { + width: 130px; + min-width: 80px; + padding: 2px 8px; + font-size: var(--font-size-xs); +} + +.dag-jump-group { + margin-left: auto; + display: flex; + align-items: center; + gap: 4px; +} + +.dag-workspace-row { + display: flex; + flex: 1 1 auto; + min-height: 0; + width: 100%; + overflow: hidden; +} + +.dag-workspace-row .graph-table-container { + flex: 1 1 auto; + min-width: 0; +} + +.dag-minimap-container { + width: 76px; + flex: 0 0 76px; + border-left: 1px solid var(--border-color); + background: var(--bg-secondary); + display: flex; + flex-direction: column; + align-items: stretch; + overflow: hidden; +} + +#dag-minimap-canvas { + width: 100%; + height: 100%; + cursor: pointer; + display: block; +} + +.commit-row.dag-drop-target { + outline: 2px dashed var(--accent-cyan); + outline-offset: -2px; + background: rgba(6, 182, 212, 0.12) !important; +} + +@media (max-width: 440px) { + .dag-minimap-container { + display: none; + } + .dag-filter-date, + .dag-filter-path { + flex: 1 1 100px; + width: auto; + } +} diff --git a/test/runTests.ts b/test/runTests.ts index 1733192..59f53b2 100644 --- a/test/runTests.ts +++ b/test/runTests.ts @@ -1608,7 +1608,56 @@ Host *.internal.corp } console.log(' [PASS] DAG Worker Engine, DOM Recycling, AdaptiveDebouncer & IPC Batching tests passed!'); - console.log('\n[PASS] ALL 46 COMMITFLOW TEST SUITES PASSED WITH 100% SUCCESS!\n'); + console.log('\nTesting DAG Minimap, Advanced Filters, Interactive Drag-and-Drop & Onboarding...'); + { + const providerSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'graph', 'graphWebviewProvider.ts'), 'utf8'); + const webviewSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'webview', 'main.ts'), 'utf8'); + + assert.ok(providerSrc.includes('id="dag-advanced-filters-bar"')); + assert.ok(providerSrc.includes('id="dag-minimap-canvas"')); + assert.ok(providerSrc.includes('id="btn-jump-head"') && providerSrc.includes('id="btn-jump-bisect-probe"')); + assert.ok(providerSrc.includes('id="dnd-confirm-modal"')); + assert.ok(webviewSrc.includes('createRefPillElement') && webviewSrc.includes('openDndConfirmModal')); + assert.ok(webviewSrc.includes('updateDagMinimap') && webviewSrc.includes('dag-empty-onboarding')); + assert.ok(cssContent.includes('.dag-advanced-filters-bar') && cssContent.includes('.dag-minimap-container')); + assert.ok(cssContent.includes('.commit-row.dag-drop-target')); + + // Real git filter & resetBranchToCommit test + const osMod = await import('os'); + const cpMod = await import('child_process'); + const tmpFilterRepo = fs.mkdtempSync(path.join(osMod.tmpdir(), 'commitflow-filter-dnd-')); + try { + cpMod.execFileSync('git', ['init'], { cwd: tmpFilterRepo }); + cpMod.execFileSync('git', ['config', 'user.email', 'f@commitflow.dev'], { cwd: tmpFilterRepo }); + cpMod.execFileSync('git', ['config', 'user.name', 'Filter Test'], { cwd: tmpFilterRepo }); + fs.writeFileSync(path.join(tmpFilterRepo, 'app.ts'), 'const x = 1;\n', 'utf8'); + cpMod.execFileSync('git', ['add', 'app.ts'], { cwd: tmpFilterRepo }); + cpMod.execFileSync('git', ['commit', '-m', 'c1: add app.ts'], { cwd: tmpFilterRepo }); + const baseSha = cpMod.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpFilterRepo, encoding: 'utf8' }).trim(); + + fs.writeFileSync(path.join(tmpFilterRepo, 'readme.md'), '# doc\n', 'utf8'); + cpMod.execFileSync('git', ['add', 'readme.md'], { cwd: tmpFilterRepo }); + cpMod.execFileSync('git', ['commit', '-m', 'c2: add readme.md'], { cwd: tmpFilterRepo }); + + const gc = new GitClient(); + const allCommits = await gc.getCommits(tmpFilterRepo, 50, 0); + assert.strictEqual(allCommits.length, 2); + + const pathCommits = await gc.getCommits(tmpFilterRepo, 50, 0, { filePath: 'app.ts', firstParent: true }); + assert.strictEqual(pathCommits.length, 1); + assert.strictEqual(pathCommits[0].subject, 'c1: add app.ts'); + + cpMod.execFileSync('git', ['branch', 'feature/dnd-test', 'HEAD'], { cwd: tmpFilterRepo }); + await gc.resetBranchToCommit(tmpFilterRepo, 'feature/dnd-test', baseSha, 'mixed'); + const movedSha = cpMod.execFileSync('git', ['rev-parse', 'feature/dnd-test'], { cwd: tmpFilterRepo, encoding: 'utf8' }).trim(); + assert.strictEqual(movedSha, baseSha, 'resetBranchToCommit should move non-current branch to baseSha'); + } finally { + fs.rmSync(tmpFilterRepo, { recursive: true, force: true }); + } + } + console.log(' [PASS] DAG Minimap, Advanced Filters, Interactive Drag-and-Drop & Onboarding tests passed!'); + + console.log('\n[PASS] ALL 47 COMMITFLOW TEST SUITES PASSED WITH 100% SUCCESS!\n'); })().catch((err) => { console.error('[CommitFlow] Test suite failed:', err); process.exit(1);