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 () => {