diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 77ddfe8..75af0e2 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -35,7 +35,7 @@ import { PaneDivider } from "./components/panes/PaneDivider";
import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge";
import { useMenuController } from "./hooks/useMenuController";
import { buildAppMenus } from "./lib/appMenus";
-import { buildFileListEntry } from "./lib/files";
+import { buildSidebarEntries } from "./lib/files";
import { buildHunkCursors, findNextHunkCursor } from "./lib/hunks";
import { fileRowId } from "./lib/ids";
import { resolveResponsiveLayout } from "./lib/responsive";
@@ -553,7 +553,7 @@ function AppShell({
event?.stopPropagation();
};
- const fileEntries = filteredFiles.map(buildFileListEntry);
+ const fileEntries = buildSidebarEntries(filteredFiles);
const totalAdditions = bootstrap.changeset.files.reduce(
(sum, file) => sum + file.stats.additions,
0,
@@ -563,7 +563,7 @@ function AppShell({
0,
);
const topTitle = `${bootstrap.changeset.title} +${totalAdditions} -${totalDeletions}`;
- const filesTextWidth = Math.max(8, clampedFilesPaneWidth - 4);
+ const filesTextWidth = Math.max(8, clampedFilesPaneWidth - 2);
const diffContentWidth = Math.max(12, diffPaneWidth - 2);
const diffHeaderStatsWidth = Math.min(24, Math.max(16, Math.floor(diffContentWidth / 3)));
const diffHeaderLabelWidth = Math.max(8, diffContentWidth - diffHeaderStatsWidth - 1);
diff --git a/src/ui/components/panes/FileListItem.tsx b/src/ui/components/panes/FileListItem.tsx
index 343548d..80e2f7f 100644
--- a/src/ui/components/panes/FileListItem.tsx
+++ b/src/ui/components/panes/FileListItem.tsx
@@ -1,29 +1,61 @@
-import type { FileListEntry } from "../../lib/files";
-import { fitText } from "../../lib/text";
+import type { FileGroupEntry, FileListEntry } from "../../lib/files";
+import { fitText, padText } from "../../lib/text";
import type { AppTheme } from "../../themes";
import { fileRowId } from "../../lib/ids";
+/** Render one folder header in the navigation sidebar. */
+export function FileGroupHeader({
+ entry,
+ textWidth,
+ theme,
+}: {
+ entry: FileGroupEntry;
+ textWidth: number;
+ theme: AppTheme;
+}) {
+ return (
+
+ {fitText(entry.label, Math.max(1, textWidth))}
+
+ );
+}
+
/** Render one file row in the navigation sidebar. */
export function FileListItem({
+ additionsWidth,
+ deletionsWidth,
entry,
selected,
textWidth,
theme,
onSelect,
}: {
+ additionsWidth: number;
+ deletionsWidth: number;
entry: FileListEntry;
selected: boolean;
textWidth: number;
theme: AppTheme;
onSelect: () => void;
}) {
+ const rowBackground = selected ? theme.panelAlt : theme.panel;
+ const statsWidth = additionsWidth + 1 + deletionsWidth;
+ const nameWidth = Math.max(1, textWidth - 1 - statsWidth - 1);
+
return (
- {fitText(entry.label, textWidth)}
- {fitText(entry.description, textWidth)}
+ {padText(fitText(entry.name, nameWidth), nameWidth)}
+ {entry.additionsText.padStart(additionsWidth, " ")}
+
+ {entry.deletionsText.padStart(deletionsWidth, " ")}
);
diff --git a/src/ui/components/panes/FilesPane.tsx b/src/ui/components/panes/FilesPane.tsx
index d5a8046..a9b54ad 100644
--- a/src/ui/components/panes/FilesPane.tsx
+++ b/src/ui/components/panes/FilesPane.tsx
@@ -1,8 +1,8 @@
import type { ScrollBoxRenderable } from "@opentui/core";
import type { RefObject } from "react";
-import type { FileListEntry } from "../../lib/files";
+import type { SidebarEntry } from "../../lib/files";
import type { AppTheme } from "../../themes";
-import { FileListItem } from "./FileListItem";
+import { FileGroupHeader, FileListItem } from "./FileListItem";
/** Render the file navigation sidebar. */
export function FilesPane({
@@ -14,7 +14,7 @@ export function FilesPane({
width,
onSelectFile,
}: {
- entries: FileListEntry[];
+ entries: SidebarEntry[];
scrollRef: RefObject;
selectedFileId?: string;
textWidth: number;
@@ -22,6 +22,10 @@ export function FilesPane({
width: number;
onSelectFile: (fileId: string) => void;
}) {
+ const fileEntries = entries.filter((entry) => entry.kind === "file");
+ const additionsWidth = Math.max(2, ...fileEntries.map((entry) => entry.additionsText.length));
+ const deletionsWidth = Math.max(2, ...fileEntries.map((entry) => entry.deletionsText.length));
+
return (
- {entries.map((entry) => (
- onSelectFile(entry.id)}
- />
- ))}
+ {entries.map((entry) =>
+ entry.kind === "group" ? (
+
+ ) : (
+ onSelectFile(entry.id)}
+ />
+ ),
+ )}
diff --git a/src/ui/lib/files.ts b/src/ui/lib/files.ts
index 470ac87..2ae0da6 100644
--- a/src/ui/lib/files.ts
+++ b/src/ui/lib/files.ts
@@ -1,32 +1,63 @@
+import { basename, dirname } from "node:path/posix";
import type { DiffFile } from "../../core/types";
export interface FileListEntry {
+ kind: "file";
+ id: string;
+ name: string;
+ additionsText: string;
+ deletionsText: string;
+}
+
+export interface FileGroupEntry {
+ kind: "group";
id: string;
label: string;
- description: string;
}
-/** Build the sidebar label and summary text for one diff file. */
-export function buildFileListEntry(file: DiffFile): FileListEntry {
- const prefix =
- file.metadata.type === "new"
- ? "A"
- : file.metadata.type === "deleted"
- ? "D"
- : file.metadata.type.startsWith("rename")
- ? "R"
- : "M";
-
- const pathLabel =
- file.previousPath && file.previousPath !== file.path
- ? `${file.previousPath} -> ${file.path}`
- : file.path;
-
- return {
- id: file.id,
- label: `${prefix} ${pathLabel}`,
- description: `+${file.stats.additions} -${file.stats.deletions}${file.agent ? " agent" : ""}`,
- };
+export type SidebarEntry = FileListEntry | FileGroupEntry;
+
+/** Build the filename-first label shown inside one sidebar row. */
+function sidebarFileName(file: DiffFile) {
+ if (!file.previousPath || file.previousPath === file.path) {
+ return basename(file.path);
+ }
+
+ const previousName = basename(file.previousPath);
+ const nextName = basename(file.path);
+ return previousName === nextName ? nextName : `${previousName} -> ${nextName}`;
+}
+
+/** Group sidebar rows by their current parent folder while preserving file order. */
+export function buildSidebarEntries(files: DiffFile[]): SidebarEntry[] {
+ const entries: SidebarEntry[] = [];
+ let activeGroup: string | null = null;
+
+ files.forEach((file, index) => {
+ const group = dirname(file.path);
+ const nextGroup = group === "." ? null : group;
+
+ if (nextGroup !== activeGroup) {
+ activeGroup = nextGroup;
+ if (activeGroup) {
+ entries.push({
+ kind: "group",
+ id: `group:${activeGroup}:${index}`,
+ label: `${activeGroup}/`,
+ });
+ }
+ }
+
+ entries.push({
+ kind: "file",
+ id: file.id,
+ name: sidebarFileName(file),
+ additionsText: `+${file.stats.additions}`,
+ deletionsText: `-${file.stats.deletions}`,
+ });
+ });
+
+ return entries;
}
/** Build the canonical file label used across headers and note cards. */
diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx
index cf85adb..65dd7d4 100644
--- a/test/app-interactions.test.tsx
+++ b/test/app-interactions.test.tsx
@@ -667,9 +667,7 @@ describe("App interactions", () => {
let frame = setup.captureCharFrame();
expect(frame).toContain("filter:");
expect(frame).toContain("beta");
- expect(frame).toContain("M beta.ts");
- expect(frame).not.toContain("M alpha.ts");
- expect(frame).toContain("beta.ts");
+ expect((frame.match(/beta\.ts/g) ?? []).length).toBeGreaterThanOrEqual(1);
expect(frame).not.toContain("Annotation for alpha.ts");
await act(async () => {
@@ -739,7 +737,7 @@ describe("App interactions", () => {
await flush(setup);
let frame = setup.captureCharFrame();
- expect(frame).toContain("M alpha.ts");
+ expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(2);
await act(async () => {
await setup.mockInput.typeText("s");
@@ -747,8 +745,7 @@ describe("App interactions", () => {
await flush(setup);
frame = setup.captureCharFrame();
- expect(frame).not.toContain("M alpha.ts");
- expect(frame).toContain("alpha.ts");
+ expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(1);
await act(async () => {
await setup.mockInput.typeText("s");
@@ -756,7 +753,7 @@ describe("App interactions", () => {
await flush(setup);
frame = setup.captureCharFrame();
- expect(frame).toContain("M alpha.ts");
+ expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(2);
} finally {
await act(async () => {
setup.renderer.destroy();
@@ -774,7 +771,7 @@ describe("App interactions", () => {
await flush(setup);
let frame = setup.captureCharFrame();
- expect(frame).not.toContain("M alpha.ts");
+ expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(1);
await act(async () => {
await setup.mockInput.typeText("s");
@@ -782,7 +779,7 @@ describe("App interactions", () => {
await flush(setup);
frame = setup.captureCharFrame();
- expect(frame).toContain("M alpha.ts");
+ expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(2);
await act(async () => {
await setup.mockInput.typeText("s");
@@ -790,7 +787,7 @@ describe("App interactions", () => {
await flush(setup);
frame = setup.captureCharFrame();
- expect(frame).not.toContain("M alpha.ts");
+ expect((frame.match(/alpha\.ts/g) ?? []).length).toBe(1);
} finally {
await act(async () => {
setup.renderer.destroy();
diff --git a/test/app-responsive.test.tsx b/test/app-responsive.test.tsx
index a2578fb..d8a7071 100644
--- a/test/app-responsive.test.tsx
+++ b/test/app-responsive.test.tsx
@@ -152,10 +152,10 @@ describe("responsive shell", () => {
test("App adjusts the visible panes and diff layout on live resize", async () => {
const { ultraWide, full, medium, tight } = await captureResponsiveFrames();
- expect(ultraWide).toContain("M alpha.ts");
+ expect((ultraWide.match(/alpha\.ts/g) ?? []).length).toBe(2);
expect(ultraWide).not.toContain("Changeset summary");
- expect(full).toContain("M alpha.ts");
+ expect((full.match(/alpha\.ts/g) ?? []).length).toBe(2);
expect(full).not.toContain("Changeset summary");
expect(full).toMatch(/▌.*▌/);
@@ -176,7 +176,7 @@ describe("responsive shell", () => {
expect(forcedSplit).not.toContain("Changeset summary");
expect(forcedSplit).toMatch(/▌.*▌/);
- expect(forcedStack).toContain("M alpha.ts");
+ expect((forcedStack.match(/alpha\.ts/g) ?? []).length).toBe(2);
expect(forcedStack).not.toContain("Changeset summary");
expect(forcedStack).not.toMatch(/▌.*▌/);
});
@@ -187,12 +187,12 @@ describe("responsive shell", () => {
expect(wide).not.toContain("File View Navigate Theme Agent Help");
expect(wide).not.toContain("F10 menu");
- expect(wide).not.toContain("M alpha.ts");
+ expect((wide.match(/alpha\.ts/g) ?? []).length).toBe(1);
expect(wide).toMatch(/▌.*▌/);
expect(narrow).not.toContain("File View Navigate Theme Agent Help");
expect(narrow).not.toContain("F10 menu");
- expect(narrow).not.toContain("M alpha.ts");
+ expect((narrow.match(/alpha\.ts/g) ?? []).length).toBe(1);
expect(narrow).not.toMatch(/▌.*▌/);
});
diff --git a/test/ui-components.test.tsx b/test/ui-components.test.tsx
index 6fb0b42..eff30ad 100644
--- a/test/ui-components.test.tsx
+++ b/test/ui-components.test.tsx
@@ -6,6 +6,7 @@ import type { AppBootstrap, DiffFile } from "../src/core/types";
import { resolveTheme } from "../src/ui/themes";
const { App } = await import("../src/ui/App");
+const { buildSidebarEntries } = await import("../src/ui/lib/files");
const { HelpDialog } = await import("../src/ui/components/chrome/HelpDialog");
const { FilesPane } = await import("../src/ui/components/panes/FilesPane");
const { AgentCard } = await import("../src/ui/components/panes/AgentCard");
@@ -283,31 +284,51 @@ function frameHasHighlightedMarker(
}
describe("UI components", () => {
- test("FilesPane renders file rows and diff stats", async () => {
- const bootstrap = createBootstrap();
+ test("FilesPane renders grouped file rows with indented filenames and right-aligned stats", async () => {
const theme = resolveTheme("midnight", null);
+ const files = [
+ createDiffFile(
+ "app",
+ "src/ui/App.tsx",
+ "export const app = 1;\n",
+ "export const app = 2;\nexport const view = true;\n",
+ true,
+ ),
+ createDiffFile(
+ "menu",
+ "src/ui/MenuDropdown.tsx",
+ "export const menu = 1;\n",
+ "export const menu = 2;\n",
+ ),
+ createDiffFile(
+ "watch",
+ "src/core/watch.ts",
+ "export const watch = 1;\n",
+ "export const watch = 2;\nexport const enabled = true;\n",
+ ),
+ ];
const frame = await captureFrame(
({
- id: file.id,
- label: `M ${file.path}`,
- description: `+${file.stats.additions} -${file.stats.deletions}${file.agent ? " agent" : ""}`,
- }))}
+ entries={buildSidebarEntries(files)}
scrollRef={createRef()}
- selectedFileId="alpha"
- textWidth={24}
+ selectedFileId="app"
+ textWidth={28}
theme={theme}
- width={28}
+ width={32}
onSelectFile={() => {}}
/>,
- 32,
- 12,
+ 36,
+ 10,
);
- expect(frame).toContain("M alpha.ts");
- expect(frame).toContain("+2 -1 agent");
- expect(frame).toContain("M beta.ts");
- expect(frame).toContain("+1 -1");
+ expect(frame).toContain("src/ui/");
+ expect(frame).toContain("src/core/");
+ expect(frame).toContain(" App.tsx");
+ expect(frame).toContain(" MenuDropdown.tsx");
+ expect(frame).toContain(" watch.ts");
+ expect(frame).toContain("+2 -1");
+ expect(frame).toContain("+1 -1");
+ expect(frame).not.toContain("M +2 -1 AI");
});
test("DiffPane renders all diff sections in file order", async () => {