Skip to content
Merged
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
6 changes: 3 additions & 3 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
57 changes: 46 additions & 11 deletions src/ui/components/panes/FileListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,85 @@
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 (
<box
style={{
width: "100%",
height: 1,
paddingLeft: 1,
backgroundColor: theme.panel,
}}
>
<text fg={theme.muted}>{fitText(entry.label, Math.max(1, textWidth))}</text>
</box>
);
}

/** 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 (
<box
id={fileRowId(entry.id)}
style={{
width: "100%",
height: 2,
backgroundColor: theme.panel,
height: 1,
backgroundColor: rowBackground,
flexDirection: "row",
}}
onMouseUp={onSelect}
>
<box
style={{
width: 1,
height: 2,
backgroundColor: selected ? theme.accent : theme.panel,
height: 1,
backgroundColor: selected ? theme.accent : rowBackground,
}}
/>
<box
style={{
flexGrow: 1,
height: 2,
flexDirection: "column",
backgroundColor: theme.panel,
height: 1,
paddingLeft: 1,
flexDirection: "row",
backgroundColor: rowBackground,
}}
>
<text fg={theme.text}>{fitText(entry.label, textWidth)}</text>
<text fg={theme.muted}>{fitText(entry.description, textWidth)}</text>
<text fg={theme.text}>{padText(fitText(entry.name, nameWidth), nameWidth)}</text>
<text fg={theme.badgeAdded}>{entry.additionsText.padStart(additionsWidth, " ")}</text>
<text fg={selected ? theme.text : theme.muted}> </text>
<text fg={theme.badgeRemoved}>{entry.deletionsText.padStart(deletionsWidth, " ")}</text>
</box>
</box>
);
Expand Down
36 changes: 23 additions & 13 deletions src/ui/components/panes/FilesPane.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -14,14 +14,18 @@ export function FilesPane({
width,
onSelectFile,
}: {
entries: FileListEntry[];
entries: SidebarEntry[];
scrollRef: RefObject<ScrollBoxRenderable | null>;
selectedFileId?: string;
textWidth: number;
theme: AppTheme;
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 (
<box
style={{
Expand Down Expand Up @@ -49,16 +53,22 @@ export function FilesPane({
horizontalScrollbarOptions={{ visible: false }}
>
<box style={{ width: "100%", flexDirection: "column" }}>
{entries.map((entry) => (
<FileListItem
key={entry.id}
entry={entry}
selected={entry.id === selectedFileId}
textWidth={textWidth}
theme={theme}
onSelect={() => onSelectFile(entry.id)}
/>
))}
{entries.map((entry) =>
entry.kind === "group" ? (
<FileGroupHeader key={entry.id} entry={entry} textWidth={textWidth} theme={theme} />
) : (
<FileListItem
key={entry.id}
additionsWidth={additionsWidth}
deletionsWidth={deletionsWidth}
entry={entry}
selected={entry.id === selectedFileId}
textWidth={textWidth}
theme={theme}
onSelect={() => onSelectFile(entry.id)}
/>
),
)}
</box>
</scrollbox>
</box>
Expand Down
75 changes: 53 additions & 22 deletions src/ui/lib/files.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down
17 changes: 7 additions & 10 deletions test/app-interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -739,24 +737,23 @@ 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");
});
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");
});
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();
Expand All @@ -774,23 +771,23 @@ 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");
});
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");
});
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();
Expand Down
10 changes: 5 additions & 5 deletions test/app-responsive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(/▌.*▌/);

Expand All @@ -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(/▌.*▌/);
});
Expand All @@ -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(/▌.*▌/);
});

Expand Down
Loading
Loading