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
5 changes: 5 additions & 0 deletions .changeset/sunny-ads-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add configuration and CLI flags to control the sidebar in non-pager mode.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,10 @@ vcs = "git" # git, jj, sl
watch = false
exclude_untracked = false
line_numbers = true
tab_width = 4 # tab stops, 1-16
tab_width = 4 # tab stops, 1-16
wrap_lines = false
menu_bar = true
sidebar = "auto" # "auto", true, false
agent_notes = false
prompt_save_view_preferences = true
transparent_background = false
Expand Down
10 changes: 10 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,16 @@ describe("parseCli", () => {
});
});

test("parses sidebar toggles", async () => {
const shown = await parseCli(["bun", "hunk", "diff", "--sidebar"]);
const hidden = await parseCli(["bun", "hunk", "diff", "--no-sidebar"]);
const unset = await parseCli(["bun", "hunk", "diff"]);

expect(shown).toMatchObject({ kind: "vcs", options: { sidebar: true } });
expect(hidden).toMatchObject({ kind: "vcs", options: { sidebar: false } });
expect(unset.kind === "vcs" ? unset.options.sidebar : "unset").toBeUndefined();
});

test("parses staged git-style diff aliases", async () => {
const staged = await parseCli(["bun", "hunk", "diff", "--staged"]);
const cached = await parseCli(["bun", "hunk", "diff", "--cached"]);
Expand Down
4 changes: 4 additions & 0 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export const COMMON_REVIEW_OPTIONS = [
{ flag: "--no-wrap", description: "truncate long diff lines to one row" },
{ flag: "--hunk-headers", description: "show hunk metadata rows" },
{ flag: "--no-hunk-headers", description: "hide hunk metadata rows" },
{ flag: "--sidebar", description: "show sidebar" },
{ flag: "--no-sidebar", description: "hide sidebar" },
{ flag: "--agent-notes", description: "show agent notes by default" },
{ flag: "--no-agent-notes", description: "hide agent notes by default" },
{ flag: "--transparent-bg", description: "let terminal background show through Hunk surfaces" },
Expand Down Expand Up @@ -283,6 +285,7 @@ function buildCommonOptions(
tabWidth: options.tabWidth,
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
sidebar: resolveBooleanFlag(argv, "--sidebar", "--no-sidebar"),
agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"),
transparentBackground: resolveBooleanFlag(argv, "--transparent-bg", "--no-transparent-bg"),
// Read straight from argv so the absence of the flag stays undefined rather than
Expand Down Expand Up @@ -394,6 +397,7 @@ function renderCliHelp() {
" -x, --tab-width <columns> tab stop width: 1-16 (default: 4)",
" --wrap / --no-wrap wrap or truncate long diff lines",
" --hunk-headers / --no-hunk-headers show or hide hunk metadata rows",
" --sidebar / --no-sidebar show or hide sidebar by default",
" --agent-notes / --no-agent-notes show or hide agent notes by default",
" --transparent-bg / --no-transparent-bg let terminal background show through Hunk surfaces",
" --theme <theme> named theme override",
Expand Down
23 changes: 23 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,27 @@ describe("config resolution", () => {
}
});

test("resolves the sidebar preference from config, CLI flags, and the auto default", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
createRepo(repo);

const resolveSidebar = (input: CliInput) =>
resolveConfiguredCliInput(input, { cwd: repo, env: { HOME: home } }).input.options.sidebar;

expect(resolveSidebar(createPatchPagerInput())).toBe("auto");

mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), "sidebar = false\n");
expect(resolveSidebar(createPatchPagerInput())).toBe(false);
// `--sidebar` outranks the config layer.
expect(resolveSidebar(createPatchPagerInput({ sidebar: true }))).toBe(true);

// Values outside `true`, `false`, and "auto" fall back to the built-in default.
writeFileSync(join(home, ".config", "hunk", "config.toml"), 'sidebar = "always"\n');
expect(resolveSidebar(createPatchPagerInput())).toBe("auto");
});

test("merges custom theme overrides from global and repo config", () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
Expand Down Expand Up @@ -999,6 +1020,7 @@ describe("config resolution", () => {
"tab_width = 8",
"wrap_lines = true",
"menu_bar = false",
"sidebar = true",
"hunk_headers = false",
"agent_notes = true",
"copy_decorations = false",
Expand Down Expand Up @@ -1027,6 +1049,7 @@ describe("config resolution", () => {
expect(bootstrap.initialTabWidth).toBe(8);
expect(bootstrap.initialWrapLines).toBe(true);
expect(bootstrap.initialShowMenuBar).toBe(false);
expect(bootstrap.initialSidebar).toBe(true);
expect(bootstrap.initialShowHunkHeaders).toBe(false);
expect(bootstrap.initialShowAgentNotes).toBe(true);
expect(bootstrap.initialCopyDecorations).toBe(false);
Expand Down
19 changes: 19 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
LayoutMode,
NamedCustomThemeConfig,
PersistedViewPreferences,
SidebarVisibility,
UserKeyBinding,
VcsMode,
} from "./types";
Expand Down Expand Up @@ -167,6 +168,11 @@ function normalizeVcsMode(value: unknown): VcsMode | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}

/** Accept a plain boolean, or `auto` for responsive behavior. */
function normalizeSidebarVisibility(value: unknown): SidebarVisibility | undefined {
return typeof value === "boolean" || value === "auto" ? value : undefined;
}

/** Accept only plain booleans from config files. */
function normalizeBoolean(value: unknown) {
return typeof value === "boolean" ? value : undefined;
Expand Down Expand Up @@ -301,6 +307,15 @@ export const CONFIG_REFERENCE_OPTIONS: readonly ConfigReferenceOption[] = [
runtimeDefault: DEFAULT_VIEW_PREFERENCES.showMenuBar,
description: "Show the top application menu bar.",
},
{
key: "sidebar",
property: "sidebar",
type: "string or boolean",
accepted: '`"auto"`, `true`, or `false`',
runtimeDefault: "auto",
description:
"Show the sidebar if it fits, keep it closed, or let the responsive layout decide. Pager sessions always open with the sidebar closed.",
},
{
key: "agent_notes",
property: "agentNotes",
Expand Down Expand Up @@ -827,6 +842,8 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk
return normalizeString(value);
case "tabWidth":
return normalizeTabWidth(value);
case "sidebar":
return normalizeSidebarVisibility(value);
default:
return normalizeBoolean(value);
}
Expand Down Expand Up @@ -885,6 +902,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
wrapLines: overrides.wrapLines ?? base.wrapLines,
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
menuBar: overrides.menuBar ?? base.menuBar,
sidebar: overrides.sidebar ?? base.sidebar,
agentNotes: overrides.agentNotes ?? base.agentNotes,
copyDecorations: overrides.copyDecorations ?? base.copyDecorations,
promptSaveViewPreferences:
Expand Down Expand Up @@ -1101,6 +1119,7 @@ export function resolveConfiguredCliInput(
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
menuBar: resolvedOptions.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar,
sidebar: resolvedOptions.sidebar ?? "auto",
agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes,
copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations,
promptSaveViewPreferences: resolvedOptions.promptSaveViewPreferences ?? true,
Expand Down
1 change: 1 addition & 0 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ export async function loadAppBootstrap(
initialWrapLines: input.options.wrapLines ?? false,
initialShowHunkHeaders: input.options.hunkHeaders ?? true,
initialShowMenuBar: input.options.menuBar ?? true,
initialSidebar: input.options.sidebar ?? "auto",
initialShowAgentNotes: input.options.agentNotes ?? false,
initialCopyDecorations: input.options.copyDecorations ?? false,
initialCursorLine: input.options.cursorLine ?? "row",
Expand Down
3 changes: 3 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type {

export type LayoutMode = "auto" | "split" | "stack";
export type CursorLine = "row" | "number" | "off";
export type SidebarVisibility = boolean | "auto";
export type VcsMode = string;
export type TerminalThemeMode = "light" | "dark";

Expand Down Expand Up @@ -101,6 +102,7 @@ export interface CommonOptions {
wrapLines?: boolean;
hunkHeaders?: boolean;
menuBar?: boolean;
sidebar?: SidebarVisibility;
agentNotes?: boolean;
copyDecorations?: boolean;
promptSaveViewPreferences?: boolean;
Expand Down Expand Up @@ -391,6 +393,7 @@ export interface AppBootstrap {
initialWrapLines?: boolean;
initialShowHunkHeaders?: boolean;
initialShowMenuBar?: boolean;
initialSidebar?: SidebarVisibility;
initialShowAgentNotes?: boolean;
initialCopyDecorations?: boolean;
initialCursorLine?: CursorLine;
Expand Down
31 changes: 10 additions & 21 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
CursorLine,
LayoutMode,
PersistedViewPreferences,
SidebarVisibility,
UserNoteLineTarget,
} from "../core/types";
import { canReloadInput } from "../core/inputReload";
Expand Down Expand Up @@ -235,8 +236,9 @@ export function App({
selectedIndex: 0,
previewThemeId: null,
});
const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode);
const [forceSidebarOpen, setForceSidebarOpen] = useState(false);
const [sidebarState, setSidebarState] = useState<SidebarVisibility>(() =>
pagerMode ? false : (bootstrap.initialSidebar ?? "auto"),
);
const [showHelp, setShowHelp] = useState(false);
const [showAgentSkill, setShowAgentSkill] = useState(false);
const [saveConfigPromptOpen, setSaveConfigPromptOpen] = useState(false);
Expand Down Expand Up @@ -920,7 +922,9 @@ export function App({
const responsiveLayout = resolveResponsiveLayout(layoutMode, terminal.width);
const canForceShowSidebar = bodyWidth >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + DIFF_MIN_WIDTH;
const sidebarAreaVisible =
sidebarVisible && (responsiveLayout.showSidebar || (forceSidebarOpen && canForceShowSidebar));
sidebarState === "auto" ? responsiveLayout.showSidebar : sidebarState && canForceShowSidebar;
const openSidebarState: SidebarVisibility =
!responsiveLayout.showSidebar && canForceShowSidebar ? true : "auto";
const resolvedLayout = responsiveLayout.layout;
const reportedLayoutRef = useRef<string | undefined>(undefined);
useEffect(() => {
Expand Down Expand Up @@ -967,9 +971,8 @@ export function App({
// Mirrors toggleSidebar's reveal half: visible again, forced open when the
// responsive layout alone would keep it hidden and the terminal has room.
revealSidebarAreaRef.current = () => {
setSidebarVisible(true);
if (!responsiveLayout.showSidebar && canForceShowSidebar) {
setForceSidebarOpen(true);
if (!sidebarAreaVisible) {
setSidebarState(openSidebarState);
}
};
// Publish the live note geometry for daemon-driven markup validation; the
Expand Down Expand Up @@ -1243,21 +1246,7 @@ export function App({

/** Toggle the sidebar, forcing it open on narrower layouts when the app can still fit both panes. */
const toggleSidebar = () => {
if (sidebarVisible && (responsiveLayout.showSidebar || forceSidebarOpen)) {
setSidebarVisible(false);
setForceSidebarOpen(false);
return;
}

if (sidebarVisible && !responsiveLayout.showSidebar) {
if (canForceShowSidebar) {
setForceSidebarOpen(true);
}
return;
}

setSidebarVisible(true);
setForceSidebarOpen(!responsiveLayout.showSidebar && canForceShowSidebar);
setSidebarState(sidebarAreaVisible ? false : openSidebarState);
};

/** Toggle visibility of hunk metadata rows without changing the actual diff lines. */
Expand Down
118 changes: 118 additions & 0 deletions src/ui/AppHost.sidebar-visibility.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { testRender } from "@opentui/react/test-utils";
import { act } from "react";
import type { AppBootstrap, SidebarVisibility } from "../core/types";
import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap";
import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpers/diff-helpers";

const { AppHost } = await import("./AppHost");

/** Wide enough for the responsive layout to show the sidebar on its own. */
const WIDE = { width: 240, height: 24 };
/** Narrower than the full viewport, so `auto` hides the sidebar but both panes still fit. */
const MEDIUM = { width: 180, height: 24 };
// Default sidebar width (34) plus the body's 1-column left padding puts the divider at column 35.
const SIDEBAR_DIVIDER_COLUMN = 35;
// A stable mid-height row that always falls inside the sidebar/divider band.
const PROBE_ROW = 10;

function createSidebarBootstrap(initialSidebar?: SidebarVisibility): AppBootstrap {
return {
...createTestVcsAppBootstrap({
changesetId: "changeset:sidebar-visibility",
initialMode: "split",
files: [
buildTestDiffFile({
after: lines("export const a = 10;"),
agent: false,
before: lines("export const a = 1;"),
context: 3,
id: "alpha",
path: "src/alpha.ts",
}),
],
}),
initialSidebar,
};
}

/** Drive one or two render passes so pending state commits land before assertions. */
async function flush(setup: Awaited<ReturnType<typeof testRender>>) {
await act(async () => {
await setup.renderOnce();
await Bun.sleep(0);
await setup.renderOnce();
});
}

/** Whether the sidebar/diff divider sits at its default column on the probe row. */
function sidebarVisible(setup: Awaited<ReturnType<typeof testRender>>) {
const row = setup.captureCharFrame().split("\n")[PROBE_ROW] ?? "";
return row.indexOf("│") === SIDEBAR_DIVIDER_COLUMN;
}

let setup: Awaited<ReturnType<typeof testRender>> | null = null;

beforeEach(() => {
setup = null;
});

afterEach(() => {
setup?.renderer.destroy();
setup = null;
});

describe("AppHost sidebar visibility preference", () => {
test("auto shows the sidebar on a full-width viewport", async () => {
setup = await testRender(<AppHost bootstrap={createSidebarBootstrap("auto")} />, WIDE);
await flush(setup);

expect(sidebarVisible(setup)).toBe(true);
});

test("auto hides the sidebar below the full-width viewport", async () => {
setup = await testRender(<AppHost bootstrap={createSidebarBootstrap("auto")} />, MEDIUM);
await flush(setup);

expect(sidebarVisible(setup)).toBe(false);
});

test("the toggle forces the sidebar open where auto hides it", async () => {
setup = await testRender(<AppHost bootstrap={createSidebarBootstrap("auto")} />, MEDIUM);
await flush(setup);
expect(sidebarVisible(setup)).toBe(false);

await act(async () => {
setup!.mockInput.pressKey("s");
});
await flush(setup);
expect(sidebarVisible(setup)).toBe(true);

// A second press closes it again rather than returning to the responsive default.
await act(async () => {
setup!.mockInput.pressKey("s");
});
await flush(setup);
expect(sidebarVisible(setup)).toBe(false);
});

test("true shows the sidebar where auto would hide it", async () => {
setup = await testRender(<AppHost bootstrap={createSidebarBootstrap(true)} />, MEDIUM);
await flush(setup);

expect(sidebarVisible(setup)).toBe(true);
});

test("false starts the sidebar closed but leaves the toggle working", async () => {
setup = await testRender(<AppHost bootstrap={createSidebarBootstrap(false)} />, WIDE);
await flush(setup);
expect(sidebarVisible(setup)).toBe(false);

await act(async () => {
setup!.mockInput.pressKey("s");
});
await flush(setup);

expect(sidebarVisible(setup)).toBe(true);
});
});
Loading