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
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 @@ -140,9 +140,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
1 change: 1 addition & 0 deletions src/core/changesetLoaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,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
39 changes: 39 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,45 @@ 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("keeps paired-flag-shaped pathspecs after the option separator", async () => {
const cases = [
["--exclude-untracked", "excludeUntracked"],
["--no-exclude-untracked", "excludeUntracked"],
["--line-numbers", "lineNumbers"],
["--no-line-numbers", "lineNumbers"],
["--wrap", "wrapLines"],
["--no-wrap", "wrapLines"],
["--hunk-headers", "hunkHeaders"],
["--no-hunk-headers", "hunkHeaders"],
["--sidebar", "sidebar"],
["--no-sidebar", "sidebar"],
["--agent-notes", "agentNotes"],
["--no-agent-notes", "agentNotes"],
["--transparent-bg", "transparentBackground"],
["--no-transparent-bg", "transparentBackground"],
["--extensions", "extensions"],
["--no-extensions", "extensions"],
] as const;

for (const [pathspec, option] of cases) {
const parsed = await parseCli(["bun", "hunk", "diff", "--", pathspec]);

expect(parsed).toMatchObject({ kind: "vcs", pathspecs: [pathspec] });
if (parsed.kind !== "vcs") throw new Error("Expected a VCS diff input.");
expect(parsed.options[option]).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
8 changes: 7 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,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 files pane" },
{ flag: "--no-sidebar", description: "hide files pane" },
{ 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 @@ -282,11 +284,13 @@ function parseNonNegativeInt(value: string) {
return parsed;
}

/** Read one paired positive/negative boolean flag directly from raw argv. */
/** Read one paired boolean flag before the pathspec separator in raw argv. */
function resolveBooleanFlag(argv: string[], enabledFlag: string, disabledFlag: string) {
let resolved: boolean | undefined;

for (const arg of argv) {
if (arg === "--") break;

if (arg === enabledFlag) {
resolved = true;
continue;
Expand Down Expand Up @@ -341,6 +345,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 @@ -457,6 +462,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 files pane 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 @@ -304,6 +304,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 @@ -994,6 +1015,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 @@ -1022,6 +1044,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 @@ -32,6 +32,7 @@ import type {
LayoutMode,
NamedCustomThemeConfig,
PersistedViewPreferences,
SidebarVisibility,
UserKeyBinding,
VcsMode,
} from "./types";
Expand Down Expand Up @@ -179,6 +180,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 @@ -313,6 +319,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 files pane if it fits, keep it closed, or let the responsive layout decide. Pager sessions always open with the files pane closed.",
},
{
key: "agent_notes",
property: "agentNotes",
Expand Down Expand Up @@ -839,6 +854,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 @@ -897,6 +914,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 @@ -1117,6 +1135,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
3 changes: 3 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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 @@ -99,6 +100,7 @@ export interface CommonOptions {
wrapLines?: boolean;
hunkHeaders?: boolean;
menuBar?: boolean;
sidebar?: SidebarVisibility;
agentNotes?: boolean;
copyDecorations?: boolean;
promptSaveViewPreferences?: boolean;
Expand Down Expand Up @@ -445,6 +447,7 @@ export interface AppBootstrap<ExtensionState = unknown> {
initialWrapLines?: boolean;
initialShowHunkHeaders?: boolean;
initialShowMenuBar?: boolean;
initialSidebar?: SidebarVisibility;
initialShowAgentNotes?: boolean;
initialCopyDecorations?: boolean;
initialCursorLine?: CursorLine;
Expand Down
17 changes: 15 additions & 2 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,9 @@ export function App({
previewThemeId: null,
});
const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode);
const [forceSidebarOpen, setForceSidebarOpen] = useState(false);
const [forceSidebarOpen, setForceSidebarOpen] = useState(
() => !pagerMode && bootstrap.initialSidebar === true,
);
const [showHelp, setShowHelp] = useState(false);
const [showAgentSkill, setShowAgentSkill] = useState(false);
const [saveConfigPromptOpen, setSaveConfigPromptOpen] = useState(false);
Expand All @@ -351,7 +353,18 @@ export function App({
const sessionNoticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const extensions = bootstrap.extensions as ExtensionLoadResult | undefined;
const sessionPanes = useMemo(() => buildSessionPanes(extensions), [extensions]);
const [paneOpenState, setPaneOpenState] = useState(() => initialPaneOpenState(sessionPanes));
const [paneOpenState, setPaneOpenState] = useState(() => {
const initial = initialPaneOpenState(sessionPanes);
if (bootstrap.initialSidebar !== false) return initial;

// The preference targets the active files slot, not independently open extension panes.
const filesPaneKey = resolvePaneSlotKey({
panes: sessionPanes,
slotKey: HUNK_FILES_PANE_KEY,
openKeys: initial.open,
});
return { ...initial, open: initial.open.filter((key) => key !== filesPaneKey) };
});
useEffect(
() => setPaneOpenState((current) => reconcilePaneOpenState(sessionPanes, current)),
[sessionPanes],
Expand Down
Loading
Loading