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
71 changes: 30 additions & 41 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
CursorLine,
LayoutMode,
PersistedViewPreferences,
SidebarVisibility,
UserNoteLineTarget,
} from "../core/types";
import { canReloadInput } from "../core/watch";
Expand Down Expand Up @@ -223,8 +224,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 @@ -372,8 +374,8 @@ export function App({
// callbacks are assigned there each render and only ever read at command
// invocation, keeping the dispatch table free of their identities.
const extensionCommandNavigationRef = useRef({
onSelectFile: (_fileId: string) => {},
onSelectHunk: (_fileId: string, _hunkIndex: number) => {},
onSelectFile: (_fileId: string) => { },
onSelectHunk: (_fileId: string, _hunkIndex: number) => { },
});
// A hard session reload (`resetApp`) remounts App under an in-flight async
// command handler, whose `ctx.navigation` closes over *this* instance's
Expand Down Expand Up @@ -563,7 +565,7 @@ export function App({
* Reveal the sidebar area, assigned each render once the responsive layout
* is known (the controls above are created before it is computed).
*/
const revealSidebarAreaRef = useRef<() => void>(() => {});
const revealSidebarAreaRef = useRef<() => void>(() => { });

const {
accept: acceptExtensionDialog,
Expand Down Expand Up @@ -598,7 +600,7 @@ export function App({
const report = (error: unknown) => {
extensions?.context.notify(
`Extension ${registered.extensionId} failed command "${registered.command.id}" • ` +
`${error instanceof Error ? error.message || error.name : String(error)}`,
`${error instanceof Error ? error.message || error.name : String(error)}`,
"warning",
);
};
Expand Down Expand Up @@ -705,7 +707,7 @@ export function App({
reportedCommandConflictsRef.current.add(reportKey);
extensions?.context.notify(
`Extension ${conflict.extensionId} key "${conflict.key}" is taken by ${conflict.conflictingId} • ` +
`command "${conflict.fullId}" left unbound`,
`command "${conflict.fullId}" left unbound`,
"warning",
);
}
Expand Down Expand Up @@ -770,7 +772,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 All @@ -787,15 +791,15 @@ export function App({
() =>
sidebarAreaVisible
? planSidebarLayout({
views: sessionSidebarViews,
openKeys: sidebarOpenState.open,
widths: sidebarWidths,
defaultWidth: SIDEBAR_DEFAULT_WIDTH,
minWidth: SIDEBAR_MIN_WIDTH,
dividerWidth: DIVIDER_WIDTH,
bodyWidth,
diffMinWidth: DIFF_MIN_WIDTH,
})
views: sessionSidebarViews,
openKeys: sidebarOpenState.open,
widths: sidebarWidths,
defaultWidth: SIDEBAR_DEFAULT_WIDTH,
minWidth: SIDEBAR_MIN_WIDTH,
dividerWidth: DIVIDER_WIDTH,
bodyWidth,
diffMinWidth: DIFF_MIN_WIDTH,
})
: { left: [], right: [], totalWidth: 0, leftWidth: 0 },
[
bodyWidth,
Expand All @@ -817,9 +821,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 @@ -1093,21 +1096,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 Expand Up @@ -1145,8 +1134,8 @@ export function App({
resetApp: false,
sourcePath:
bootstrap.input.kind === "vcs" ||
bootstrap.input.kind === "show" ||
bootstrap.input.kind === "stash-show"
bootstrap.input.kind === "show" ||
bootstrap.input.kind === "stash-show"
? bootstrap.changeset.sourceLabel
: undefined,
});
Expand Down Expand Up @@ -1264,8 +1253,8 @@ export function App({
const triggerEditSelectedFile = useCallback(() => {
const basePath =
bootstrap.input.kind === "vcs" ||
bootstrap.input.kind === "show" ||
bootstrap.input.kind === "stash-show"
bootstrap.input.kind === "show" ||
bootstrap.input.kind === "stash-show"
? bootstrap.changeset.sourceLabel
: undefined;
const message = openSelectedFileInEditor({
Expand Down Expand Up @@ -1911,8 +1900,8 @@ export function App({
) : null}

{focusArea === "filter" ||
Boolean(review.filter) ||
Boolean(sessionNoticeText ?? transientNoticeText ?? noticeText) ? (
Boolean(review.filter) ||
Boolean(sessionNoticeText ?? transientNoticeText ?? noticeText) ? (
<StatusBar
filter={review.filter}
filterFocused={focusArea === "filter"}
Expand Down
Loading
Loading