Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions webview-ui/src/components/commit/CommitDetails.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,29 @@

let expandedDirs = $state<Set<string>>(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 };

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -860,7 +883,7 @@
>
<i class="codicon" class:codicon-chevron-right={!expandedDirs.has(`${staged ? 'staged' : 'unstaged'}:${node.path}`)} class:codicon-chevron-down={expandedDirs.has(`${staged ? 'staged' : 'unstaged'}:${node.path}`)}></i>
<i class="codicon codicon-folder"></i>
<span class="dir-name">{node.name}</span>
<span class="dir-name" title={node.path}>{node.name}</span>
</button>
{#if expandedDirs.has(`${staged ? 'staged' : 'unstaged'}:${node.path}`)}
{@render renderUncommittedTree(node.children, depth + 1, staged)}
Expand Down Expand Up @@ -1103,7 +1126,7 @@
>
<i class="codicon" class:codicon-chevron-right={!expandedDirs.has(node.path)} class:codicon-chevron-down={expandedDirs.has(node.path)}></i>
<i class="codicon codicon-folder"></i>
<span class="dir-name">{node.name}</span>
<span class="dir-name" title={node.path}>{node.name}</span>
</button>
{#if expandedDirs.has(node.path)}
{@render renderTree(node.children, depth + 1)}
Expand Down Expand Up @@ -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;
Expand Down
81 changes: 79 additions & 2 deletions webview-ui/src/components/commit/__tests__/CommitDetails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>('.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' }]);
Expand Down Expand Up @@ -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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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', [
Expand Down Expand Up @@ -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<HTMLButtonElement>('.top-tab'))
.find(t => /change/i.test(t.textContent ?? ''))!;
await fireEvent.click(changesTab);
Expand All @@ -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'],
});
});

Expand Down
Loading