Skip to content

Commit e01d605

Browse files
authored
ui: group sidebar files by folder (#99)
* ui: group sidebar files by folder * ui: align sidebar file stats
1 parent 036edd6 commit e01d605

7 files changed

Lines changed: 174 additions & 80 deletions

File tree

src/ui/App.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { PaneDivider } from "./components/panes/PaneDivider";
3535
import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge";
3636
import { useMenuController } from "./hooks/useMenuController";
3737
import { buildAppMenus } from "./lib/appMenus";
38-
import { buildFileListEntry } from "./lib/files";
38+
import { buildSidebarEntries } from "./lib/files";
3939
import { buildHunkCursors, findNextHunkCursor } from "./lib/hunks";
4040
import { fileRowId } from "./lib/ids";
4141
import { resolveResponsiveLayout } from "./lib/responsive";
@@ -553,7 +553,7 @@ function AppShell({
553553
event?.stopPropagation();
554554
};
555555

556-
const fileEntries = filteredFiles.map(buildFileListEntry);
556+
const fileEntries = buildSidebarEntries(filteredFiles);
557557
const totalAdditions = bootstrap.changeset.files.reduce(
558558
(sum, file) => sum + file.stats.additions,
559559
0,
@@ -563,7 +563,7 @@ function AppShell({
563563
0,
564564
);
565565
const topTitle = `${bootstrap.changeset.title} +${totalAdditions} -${totalDeletions}`;
566-
const filesTextWidth = Math.max(8, clampedFilesPaneWidth - 4);
566+
const filesTextWidth = Math.max(8, clampedFilesPaneWidth - 2);
567567
const diffContentWidth = Math.max(12, diffPaneWidth - 2);
568568
const diffHeaderStatsWidth = Math.min(24, Math.max(16, Math.floor(diffContentWidth / 3)));
569569
const diffHeaderLabelWidth = Math.max(8, diffContentWidth - diffHeaderStatsWidth - 1);

src/ui/components/panes/FileListItem.tsx

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,85 @@
1-
import type { FileListEntry } from "../../lib/files";
2-
import { fitText } from "../../lib/text";
1+
import type { FileGroupEntry, FileListEntry } from "../../lib/files";
2+
import { fitText, padText } from "../../lib/text";
33
import type { AppTheme } from "../../themes";
44
import { fileRowId } from "../../lib/ids";
55

6+
/** Render one folder header in the navigation sidebar. */
7+
export function FileGroupHeader({
8+
entry,
9+
textWidth,
10+
theme,
11+
}: {
12+
entry: FileGroupEntry;
13+
textWidth: number;
14+
theme: AppTheme;
15+
}) {
16+
return (
17+
<box
18+
style={{
19+
width: "100%",
20+
height: 1,
21+
paddingLeft: 1,
22+
backgroundColor: theme.panel,
23+
}}
24+
>
25+
<text fg={theme.muted}>{fitText(entry.label, Math.max(1, textWidth))}</text>
26+
</box>
27+
);
28+
}
29+
630
/** Render one file row in the navigation sidebar. */
731
export function FileListItem({
32+
additionsWidth,
33+
deletionsWidth,
834
entry,
935
selected,
1036
textWidth,
1137
theme,
1238
onSelect,
1339
}: {
40+
additionsWidth: number;
41+
deletionsWidth: number;
1442
entry: FileListEntry;
1543
selected: boolean;
1644
textWidth: number;
1745
theme: AppTheme;
1846
onSelect: () => void;
1947
}) {
48+
const rowBackground = selected ? theme.panelAlt : theme.panel;
49+
const statsWidth = additionsWidth + 1 + deletionsWidth;
50+
const nameWidth = Math.max(1, textWidth - 1 - statsWidth - 1);
51+
2052
return (
2153
<box
2254
id={fileRowId(entry.id)}
2355
style={{
2456
width: "100%",
25-
height: 2,
26-
backgroundColor: theme.panel,
57+
height: 1,
58+
backgroundColor: rowBackground,
2759
flexDirection: "row",
2860
}}
2961
onMouseUp={onSelect}
3062
>
3163
<box
3264
style={{
3365
width: 1,
34-
height: 2,
35-
backgroundColor: selected ? theme.accent : theme.panel,
66+
height: 1,
67+
backgroundColor: selected ? theme.accent : rowBackground,
3668
}}
3769
/>
3870
<box
3971
style={{
4072
flexGrow: 1,
41-
height: 2,
42-
flexDirection: "column",
43-
backgroundColor: theme.panel,
73+
height: 1,
74+
paddingLeft: 1,
75+
flexDirection: "row",
76+
backgroundColor: rowBackground,
4477
}}
4578
>
46-
<text fg={theme.text}>{fitText(entry.label, textWidth)}</text>
47-
<text fg={theme.muted}>{fitText(entry.description, textWidth)}</text>
79+
<text fg={theme.text}>{padText(fitText(entry.name, nameWidth), nameWidth)}</text>
80+
<text fg={theme.badgeAdded}>{entry.additionsText.padStart(additionsWidth, " ")}</text>
81+
<text fg={selected ? theme.text : theme.muted}> </text>
82+
<text fg={theme.badgeRemoved}>{entry.deletionsText.padStart(deletionsWidth, " ")}</text>
4883
</box>
4984
</box>
5085
);

src/ui/components/panes/FilesPane.tsx

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { ScrollBoxRenderable } from "@opentui/core";
22
import type { RefObject } from "react";
3-
import type { FileListEntry } from "../../lib/files";
3+
import type { SidebarEntry } from "../../lib/files";
44
import type { AppTheme } from "../../themes";
5-
import { FileListItem } from "./FileListItem";
5+
import { FileGroupHeader, FileListItem } from "./FileListItem";
66

77
/** Render the file navigation sidebar. */
88
export function FilesPane({
@@ -14,14 +14,18 @@ export function FilesPane({
1414
width,
1515
onSelectFile,
1616
}: {
17-
entries: FileListEntry[];
17+
entries: SidebarEntry[];
1818
scrollRef: RefObject<ScrollBoxRenderable | null>;
1919
selectedFileId?: string;
2020
textWidth: number;
2121
theme: AppTheme;
2222
width: number;
2323
onSelectFile: (fileId: string) => void;
2424
}) {
25+
const fileEntries = entries.filter((entry) => entry.kind === "file");
26+
const additionsWidth = Math.max(2, ...fileEntries.map((entry) => entry.additionsText.length));
27+
const deletionsWidth = Math.max(2, ...fileEntries.map((entry) => entry.deletionsText.length));
28+
2529
return (
2630
<box
2731
style={{
@@ -49,16 +53,22 @@ export function FilesPane({
4953
horizontalScrollbarOptions={{ visible: false }}
5054
>
5155
<box style={{ width: "100%", flexDirection: "column" }}>
52-
{entries.map((entry) => (
53-
<FileListItem
54-
key={entry.id}
55-
entry={entry}
56-
selected={entry.id === selectedFileId}
57-
textWidth={textWidth}
58-
theme={theme}
59-
onSelect={() => onSelectFile(entry.id)}
60-
/>
61-
))}
56+
{entries.map((entry) =>
57+
entry.kind === "group" ? (
58+
<FileGroupHeader key={entry.id} entry={entry} textWidth={textWidth} theme={theme} />
59+
) : (
60+
<FileListItem
61+
key={entry.id}
62+
additionsWidth={additionsWidth}
63+
deletionsWidth={deletionsWidth}
64+
entry={entry}
65+
selected={entry.id === selectedFileId}
66+
textWidth={textWidth}
67+
theme={theme}
68+
onSelect={() => onSelectFile(entry.id)}
69+
/>
70+
),
71+
)}
6272
</box>
6373
</scrollbox>
6474
</box>

src/ui/lib/files.ts

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,63 @@
1+
import { basename, dirname } from "node:path/posix";
12
import type { DiffFile } from "../../core/types";
23

34
export interface FileListEntry {
5+
kind: "file";
6+
id: string;
7+
name: string;
8+
additionsText: string;
9+
deletionsText: string;
10+
}
11+
12+
export interface FileGroupEntry {
13+
kind: "group";
414
id: string;
515
label: string;
6-
description: string;
716
}
817

9-
/** Build the sidebar label and summary text for one diff file. */
10-
export function buildFileListEntry(file: DiffFile): FileListEntry {
11-
const prefix =
12-
file.metadata.type === "new"
13-
? "A"
14-
: file.metadata.type === "deleted"
15-
? "D"
16-
: file.metadata.type.startsWith("rename")
17-
? "R"
18-
: "M";
19-
20-
const pathLabel =
21-
file.previousPath && file.previousPath !== file.path
22-
? `${file.previousPath} -> ${file.path}`
23-
: file.path;
24-
25-
return {
26-
id: file.id,
27-
label: `${prefix} ${pathLabel}`,
28-
description: `+${file.stats.additions} -${file.stats.deletions}${file.agent ? " agent" : ""}`,
29-
};
18+
export type SidebarEntry = FileListEntry | FileGroupEntry;
19+
20+
/** Build the filename-first label shown inside one sidebar row. */
21+
function sidebarFileName(file: DiffFile) {
22+
if (!file.previousPath || file.previousPath === file.path) {
23+
return basename(file.path);
24+
}
25+
26+
const previousName = basename(file.previousPath);
27+
const nextName = basename(file.path);
28+
return previousName === nextName ? nextName : `${previousName} -> ${nextName}`;
29+
}
30+
31+
/** Group sidebar rows by their current parent folder while preserving file order. */
32+
export function buildSidebarEntries(files: DiffFile[]): SidebarEntry[] {
33+
const entries: SidebarEntry[] = [];
34+
let activeGroup: string | null = null;
35+
36+
files.forEach((file, index) => {
37+
const group = dirname(file.path);
38+
const nextGroup = group === "." ? null : group;
39+
40+
if (nextGroup !== activeGroup) {
41+
activeGroup = nextGroup;
42+
if (activeGroup) {
43+
entries.push({
44+
kind: "group",
45+
id: `group:${activeGroup}:${index}`,
46+
label: `${activeGroup}/`,
47+
});
48+
}
49+
}
50+
51+
entries.push({
52+
kind: "file",
53+
id: file.id,
54+
name: sidebarFileName(file),
55+
additionsText: `+${file.stats.additions}`,
56+
deletionsText: `-${file.stats.deletions}`,
57+
});
58+
});
59+
60+
return entries;
3061
}
3162

3263
/** Build the canonical file label used across headers and note cards. */

test/app-interactions.test.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -667,9 +667,7 @@ describe("App interactions", () => {
667667
let frame = setup.captureCharFrame();
668668
expect(frame).toContain("filter:");
669669
expect(frame).toContain("beta");
670-
expect(frame).toContain("M beta.ts");
671-
expect(frame).not.toContain("M alpha.ts");
672-
expect(frame).toContain("beta.ts");
670+
expect((frame.match(/beta\.ts/g) ?? []).length).toBeGreaterThanOrEqual(1);
673671
expect(frame).not.toContain("Annotation for alpha.ts");
674672

675673
await act(async () => {
@@ -739,24 +737,23 @@ describe("App interactions", () => {
739737
await flush(setup);
740738

741739
let frame = setup.captureCharFrame();
742-
expect(frame).toContain("M alpha.ts");
740+
expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(2);
743741

744742
await act(async () => {
745743
await setup.mockInput.typeText("s");
746744
});
747745
await flush(setup);
748746

749747
frame = setup.captureCharFrame();
750-
expect(frame).not.toContain("M alpha.ts");
751-
expect(frame).toContain("alpha.ts");
748+
expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(1);
752749

753750
await act(async () => {
754751
await setup.mockInput.typeText("s");
755752
});
756753
await flush(setup);
757754

758755
frame = setup.captureCharFrame();
759-
expect(frame).toContain("M alpha.ts");
756+
expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(2);
760757
} finally {
761758
await act(async () => {
762759
setup.renderer.destroy();
@@ -774,23 +771,23 @@ describe("App interactions", () => {
774771
await flush(setup);
775772

776773
let frame = setup.captureCharFrame();
777-
expect(frame).not.toContain("M alpha.ts");
774+
expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(1);
778775

779776
await act(async () => {
780777
await setup.mockInput.typeText("s");
781778
});
782779
await flush(setup);
783780

784781
frame = setup.captureCharFrame();
785-
expect(frame).toContain("M alpha.ts");
782+
expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(2);
786783

787784
await act(async () => {
788785
await setup.mockInput.typeText("s");
789786
});
790787
await flush(setup);
791788

792789
frame = setup.captureCharFrame();
793-
expect(frame).not.toContain("M alpha.ts");
790+
expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(1);
794791
} finally {
795792
await act(async () => {
796793
setup.renderer.destroy();

test/app-responsive.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,10 @@ describe("responsive shell", () => {
152152
test("App adjusts the visible panes and diff layout on live resize", async () => {
153153
const { ultraWide, full, medium, tight } = await captureResponsiveFrames();
154154

155-
expect(ultraWide).toContain("M alpha.ts");
155+
expect((ultraWide.match(/alpha\.ts/g) ?? []).length).toBe(2);
156156
expect(ultraWide).not.toContain("Changeset summary");
157157

158-
expect(full).toContain("M alpha.ts");
158+
expect((full.match(/alpha\.ts/g) ?? []).length).toBe(2);
159159
expect(full).not.toContain("Changeset summary");
160160
expect(full).toMatch(/.*/);
161161

@@ -176,7 +176,7 @@ describe("responsive shell", () => {
176176
expect(forcedSplit).not.toContain("Changeset summary");
177177
expect(forcedSplit).toMatch(/.*/);
178178

179-
expect(forcedStack).toContain("M alpha.ts");
179+
expect((forcedStack.match(/alpha\.ts/g) ?? []).length).toBe(2);
180180
expect(forcedStack).not.toContain("Changeset summary");
181181
expect(forcedStack).not.toMatch(/.*/);
182182
});
@@ -187,12 +187,12 @@ describe("responsive shell", () => {
187187

188188
expect(wide).not.toContain("File View Navigate Theme Agent Help");
189189
expect(wide).not.toContain("F10 menu");
190-
expect(wide).not.toContain("M alpha.ts");
190+
expect((wide.match(/alpha\.ts/g) ?? []).length).toBe(1);
191191
expect(wide).toMatch(/.*/);
192192

193193
expect(narrow).not.toContain("File View Navigate Theme Agent Help");
194194
expect(narrow).not.toContain("F10 menu");
195-
expect(narrow).not.toContain("M alpha.ts");
195+
expect((narrow.match(/alpha\.ts/g) ?? []).length).toBe(1);
196196
expect(narrow).not.toMatch(/.*/);
197197
});
198198

0 commit comments

Comments
 (0)