diff --git a/webview-ui/src/components/commit/CommitDetails.svelte b/webview-ui/src/components/commit/CommitDetails.svelte index a9eb9b4..4f0b8bd 100644 --- a/webview-ui/src/components/commit/CommitDetails.svelte +++ b/webview-ui/src/components/commit/CommitDetails.svelte @@ -426,6 +426,29 @@ let expandedDirs = $state>(new Set()); + function compactDirectoryChains(nodes: FileTreeNode[]): FileTreeNode[] { + return nodes.map(node => { + if (node.isFile) return { ...node, children: [] }; + + let name = node.name; + let path = node.path; + let children = node.children; + while (children.length === 1 && !children[0].isFile) { + const child = children[0]; + name = `${name} / ${child.name}`; + path = child.path; + children = child.children; + } + + return { + ...node, + name, + path, + children: compactDirectoryChains(children), + }; + }); + } + function buildFileTree(commitFiles: CommitFile[]): FileTreeNode[] { const root: FileTreeNode = { name: '', path: '', children: [], isFile: false }; @@ -460,7 +483,7 @@ return nodes; } - return sortTree(root.children); + return compactDirectoryChains(sortTree(root.children)); } // All changed-file paths under a tree node (the node itself if it's a file). @@ -860,7 +883,7 @@ > - {node.name} + {node.name} {#if expandedDirs.has(`${staged ? 'staged' : 'unstaged'}:${node.path}`)} {@render renderUncommittedTree(node.children, depth + 1, staged)} @@ -1103,7 +1126,7 @@ > - {node.name} + {node.name} {#if expandedDirs.has(node.path)} {@render renderTree(node.children, depth + 1)} @@ -1632,7 +1655,12 @@ } .file-name { font-weight: normal; min-width: 0; } - .dir-name { min-width: 0; } + .dir-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .file-status { margin-left: auto; font-size: 0.85em; diff --git a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts index 9b04ec1..583ee90 100644 --- a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts +++ b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts @@ -564,6 +564,23 @@ describe('CommitDetails — uncommitted (staged/unstaged)', () => { })); } + it('compacts deep folder chains in both staged and unstaged trees', async () => { + const { container } = render(CommitDetails, { commit: commit({ hash: 'UNCOMMITTED' }) }); + deliverUncommitted( + [{ path: 'src/main/java/App.java', status: 'M' }], + [{ path: 'tests/unit/App.test.ts', status: 'A' }], + ); + await waitFor(() => expect(container.querySelector('.file-item')).toBeTruthy()); + expect(container.querySelector('.dir-name')?.textContent?.replace(/\s+/g, '')).toBe('src/main/java'); + + const unstagedTab = Array.from(container.querySelectorAll('.top-tab')) + .find(t => /unstaged/i.test(t.textContent ?? ''))!; + await fireEvent.click(unstagedTab); + await waitFor(() => { + expect(container.querySelector('.dir-name')?.textContent?.replace(/\s+/g, '')).toBe('tests/unit'); + }); + }); + it('shows "No staged changes" when staged list is empty', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'UNCOMMITTED' }) }); deliverUncommitted([], [{ path: 'a.ts', status: 'M' }]); @@ -850,6 +867,66 @@ describe('CommitDetails — resize handle', () => { }); describe('CommitDetails — directory toggle', () => { + it('compacts a chain of single-child folders into one visible row', async () => { + const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + deliverCommitDiff('h1', [{ path: 'src/main/java/App.java', status: 'M' }]); + const changesTab = Array.from(container.querySelectorAll('.top-tab')) + .find(t => /change/i.test(t.textContent ?? ''))!; + await fireEvent.click(changesTab); + await waitFor(() => expect(container.querySelector('.file-item')).toBeTruthy()); + + const labels = Array.from(container.querySelectorAll('.dir-name')) + .map(el => (el.textContent ?? '').replace(/\s+/g, '')); + expect(labels).toEqual(['src/main/java']); + expect(container.querySelector('.dir-name')?.getAttribute('title')).toBe('src/main/java'); + expect(container.querySelector('.file-name')?.textContent).toBe('App.java'); + }); + + it('handles a pathological deep folder chain without overflowing the stack', async () => { + const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + const deepPath = `${Array.from({ length: 3000 }, (_, i) => `d${i}`).join('/')}/leaf.ts`; + deliverCommitDiff('h1', [{ path: deepPath, status: 'M' }]); + const changesTab = Array.from(container.querySelectorAll('.top-tab')) + .find(t => /change/i.test(t.textContent ?? ''))!; + await fireEvent.click(changesTab); + await waitFor(() => expect(container.querySelector('.file-item')).toBeTruthy()); + + expect(container.querySelectorAll('.dir-item')).toHaveLength(1); + expect(container.querySelector('.file-name')?.textContent).toBe('leaf.ts'); + }); + + it('stops compacting at a directory branch', async () => { + const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + deliverCommitDiff('h1', [ + { path: 'src/main/java/App.java', status: 'M' }, + { path: 'src/test/java/AppTest.java', status: 'A' }, + ]); + const changesTab = Array.from(container.querySelectorAll('.top-tab')) + .find(t => /change/i.test(t.textContent ?? ''))!; + await fireEvent.click(changesTab); + await waitFor(() => expect(container.querySelectorAll('.file-item')).toHaveLength(2)); + + const labels = Array.from(container.querySelectorAll('.dir-name')) + .map(el => (el.textContent ?? '').replace(/\s+/g, '')); + expect(labels).toEqual(['src', 'main/java', 'test/java']); + }); + + it('does not compact across a folder that also contains a file', async () => { + const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + deliverCommitDiff('h1', [ + { path: 'src/index.ts', status: 'M' }, + { path: 'src/lib/a.ts', status: 'M' }, + ]); + const changesTab = Array.from(container.querySelectorAll('.top-tab')) + .find(t => /change/i.test(t.textContent ?? ''))!; + await fireEvent.click(changesTab); + await waitFor(() => expect(container.querySelectorAll('.file-item')).toHaveLength(2)); + + const labels = Array.from(container.querySelectorAll('.dir-name')) + .map(el => (el.textContent ?? '').replace(/\s+/g, '')); + expect(labels).toEqual(['src', 'lib']); + }); + it('clicking a dir toggles its expand state', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); deliverCommitDiff('h1', [ @@ -1122,7 +1199,7 @@ describe('CommitDetails — file context menu actions', () => { it('folder "Create Patch from folder" posts saveCommitPatch for the folder', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); - deliverCommitDiff('h1', [{ path: 'src/a.ts', status: 'M' }]); + deliverCommitDiff('h1', [{ path: 'src/main/java/App.java', status: 'M' }]); const changesTab = Array.from(container.querySelectorAll('.top-tab')) .find(t => /change/i.test(t.textContent ?? ''))!; await fireEvent.click(changesTab); @@ -1136,7 +1213,7 @@ describe('CommitDetails — file context menu actions', () => { const req = globalThis.__postedMessages.find((m) => (m.data as { type?: string }).type === 'saveCommitPatch'); expect((req!.data as { payload: { hash: string; paths: string[] } }).payload).toMatchObject({ hash: 'h1', - paths: ['src'], + paths: ['src/main/java'], }); });