Skip to content
Draft
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/faster-wheels-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Let reviewers configure a fixed number of rows per mouse-wheel event for faster large-diff scrolling.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ watch = false
exclude_untracked = false
line_numbers = true
tab_width = 4 # tab stops, 1-16
wheel_scroll_lines = "auto" # auto acceleration, or a fixed 1-10 rows per event
wrap_lines = false
menu_bar = true
sidebar = "auto" # "auto", true, false
Expand All @@ -165,6 +166,7 @@ syntax scopes, and legacy syntax-table migration.

`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only.
`tab_width` controls source-code tab stops and can be overridden with `-x4` or `--tab-width 4`.
`wheel_scroll_lines` is a user-only preference and can be overridden with `--wheel-scroll-lines 3`.
`prompt_save_view_preferences = false` disables the quit prompt for saving changed view preferences.
`transparent_background` can also be written as `transparentBackground`.

Expand Down
3 changes: 2 additions & 1 deletion scripts/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@ export function renderConfigReference() {
option.runtimeDefault !== undefined
? `\`${String(option.runtimeDefault)}\``
: (option.defaultValue ?? "—");
return `**\`${option.key}\`**\n\n${option.description}\n\n- **Type:** ${option.type}\n- **Accepted:** ${option.accepted}\n- **Built-in default:** ${defaultValue}${aliasDetails}`;
const scopeDetails = option.userOnly ? "\n- **Scope:** user config only" : "";
return `**\`${option.key}\`**\n\n${option.description}\n\n- **Type:** ${option.type}\n- **Accepted:** ${option.accepted}\n- **Built-in default:** ${defaultValue}${scopeDetails}${aliasDetails}`;
});
const sectionRows = Object.entries(CONFIG_COMMAND_SECTIONS).map(
([section, description]) => `| \`[${section}]\` | ${description} |`,
Expand Down
17 changes: 17 additions & 0 deletions src/app/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ describe("parseCli", () => {
"notes.json",
"--no-line-numbers",
"-x4",
"--wheel-scroll-lines",
"3",
"--wrap",
"--no-hunk-headers",
"--agent-notes",
Expand All @@ -144,6 +146,7 @@ describe("parseCli", () => {
experimental: true,
lineNumbers: false,
tabWidth: 4,
wheelScrollLines: 3,
wrapLines: true,
hunkHeaders: false,
agentNotes: true,
Expand All @@ -152,6 +155,20 @@ describe("parseCli", () => {
});
});

test("parses wheel scroll lines and rejects invalid values", async () => {
const automatic = await parseCli(["bun", "hunk", "diff", "--wheel-scroll-lines", "auto"]);
const fixed = await parseCli(["bun", "hunk", "diff", "--wheel-scroll-lines", "5"]);

expect(automatic).toMatchObject({ kind: "vcs", options: { wheelScrollLines: "auto" } });
expect(fixed).toMatchObject({ kind: "vcs", options: { wheelScrollLines: 5 } });

for (const invalid of ["0", "11", "fast"]) {
await expect(
parseCli(["bun", "hunk", "diff", "--wheel-scroll-lines", invalid]),
).rejects.toThrow(/wheel scroll lines/);
}
});

test("parses the current-line style and rejects an unknown one", async () => {
const parsed = await parseCli(["bun", "hunk", "diff", "--cursor-line", "number"]);

Expand Down
23 changes: 22 additions & 1 deletion src/app/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,23 @@ import {
} from "../session/agent/errors";
import { DEFAULT_TAB_WIDTH, parseTabWidth } from "../core/run/tabWidth";
import { resolveCliVersion } from "../core/run/version";
import {
DEFAULT_WHEEL_SCROLL_LINES,
parseWheelScrollLines,
type WheelScrollLines,
} from "../core/run/wheelScrollLines";

/** Structured option metadata shared by Commander registration and generated CLI docs. */
export interface CliReferenceOption {
readonly flag: string;
readonly description: string;
readonly parse?: "layout" | "cursorLine" | "positiveInt" | "tabWidth" | "collect";
readonly parse?:
| "layout"
| "cursorLine"
| "positiveInt"
| "tabWidth"
| "wheelScrollLines"
| "collect";
readonly defaultValue?: string;
/** Default applied directly by Commander (as opposed to a config-resolved default). */
readonly commanderDefault?: string;
Expand Down Expand Up @@ -97,6 +108,12 @@ export const COMMON_REVIEW_OPTIONS = [
parse: "tabWidth",
defaultValue: String(DEFAULT_TAB_WIDTH),
},
{
flag: "--wheel-scroll-lines <lines>",
description: "rows per wheel event: auto or 1-10",
parse: "wheelScrollLines",
defaultValue: DEFAULT_WHEEL_SCROLL_LINES,
},
{ flag: "--wrap", description: "wrap long diff lines" },
{ flag: "--no-wrap", description: "truncate long diff lines to one row" },
{ flag: "--hunk-headers", description: "show hunk metadata rows" },
Expand Down Expand Up @@ -351,6 +368,7 @@ function buildCommonOptions(
fast?: boolean;
transparentBackground?: boolean;
tabWidth?: number;
wheelScrollLines?: WheelScrollLines;
extension?: string[];
},
argv: string[],
Expand All @@ -374,6 +392,7 @@ function buildCommonOptions(
),
lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"),
tabWidth: options.tabWidth,
wheelScrollLines: options.wheelScrollLines,
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
sidebar: resolveBooleanFlag(argv, "--sidebar", "--no-sidebar"),
Expand All @@ -398,6 +417,8 @@ function applyReferenceOption(command: Command, option: CliReferenceOption) {
commanderOption.argParser(parsePositiveInt);
} else if (option.parse === "tabWidth") {
commanderOption.argParser(parseTabWidth);
} else if (option.parse === "wheelScrollLines") {
commanderOption.argParser(parseWheelScrollLines);
} else if (option.parse === "collect") {
commanderOption.argParser(collectRepeatedValue);
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { NamedCustomThemeConfig } from "../extension-api/types";
import type { Changeset } from "./changeset/model";
import type { CliInput, CursorLine, LayoutMode, SidebarVisibility } from "./run/commandInputs";
import type { UserKeyBinding } from "./run/config";
import type { WheelScrollLines } from "./run/wheelScrollLines";
import type { StartupNotice } from "./process/startupNotice";
import type { TerminalThemeMode } from "./theme/detection";
import type { VcsCatalog } from "./vcs/types";
Expand Down Expand Up @@ -45,6 +46,7 @@ export interface AppBootstrap<ExtensionState = unknown> {
customThemes?: readonly NamedCustomThemeConfig[];
initialShowLineNumbers?: boolean;
initialTabWidth?: number;
initialWheelScrollLines?: WheelScrollLines;
initialWrapLines?: boolean;
initialShowHunkHeaders?: boolean;
initialShowMenuBar?: boolean;
Expand Down
2 changes: 2 additions & 0 deletions src/core/changeset/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createFileSourceFetcher, type FileSourceSpec } from "./fileSource";
import { changesetFromPatch } from "./fromPatch";

import { DEFAULT_TAB_WIDTH } from "../run/tabWidth";
import { DEFAULT_WHEEL_SCROLL_LINES } from "../run/wheelScrollLines";
import {
getConfiguredVcsAdapter,
isVcsReviewInput,
Expand Down Expand Up @@ -330,6 +331,7 @@ export async function loadAppBootstrap(
customThemes,
initialShowLineNumbers: input.options.lineNumbers ?? true,
initialTabWidth: input.options.tabWidth ?? DEFAULT_TAB_WIDTH,
initialWheelScrollLines: input.options.wheelScrollLines ?? DEFAULT_WHEEL_SCROLL_LINES,
initialWrapLines: input.options.wrapLines ?? false,
initialShowHunkHeaders: input.options.hunkHeaders ?? true,
initialShowMenuBar: input.options.menuBar ?? true,
Expand Down
2 changes: 2 additions & 0 deletions src/core/run/commandInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ExtensionVcsStashShowInput,
} from "../../extension-api/types";
import type { InstallSource } from "../install/installSource";
import type { WheelScrollLines } from "./wheelScrollLines";

export type LayoutMode = "auto" | "split" | "stack";
export type CursorLine = "row" | "number" | "off";
Expand All @@ -35,6 +36,7 @@ export interface CommonOptions {
excludeUntracked?: boolean;
lineNumbers?: boolean;
tabWidth?: number;
wheelScrollLines?: WheelScrollLines;
wrapLines?: boolean;
hunkHeaders?: boolean;
menuBar?: boolean;
Expand Down
39 changes: 39 additions & 0 deletions src/core/run/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,43 @@ describe("config resolution", () => {
}
});

test("resolves wheel scroll lines from user config and CLI but not repository config", () => {
const home = createTempDir("hunk-config-wheel-home-");
const repo = createTempDir("hunk-config-wheel-repo-");
createRepo(repo);
const input = createPatchPagerInput();
const env = { HOME: home };

expect(
resolveConfiguredCliInput(input, { cwd: repo, env }).input.options.wheelScrollLines,
).toBe("auto");

mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), "wheel_scroll_lines = 3\n");
mkdirSync(join(repo, ".hunk"), { recursive: true });
writeFileSync(join(repo, ".hunk", "config.toml"), "wheel_scroll_lines = 8\n");

expect(
resolveConfiguredCliInput(input, { cwd: repo, env }).input.options.wheelScrollLines,
).toBe(3);
expect(
resolveConfiguredCliInput(createPatchPagerInput({ wheelScrollLines: 6 }), {
cwd: repo,
env,
}).input.options.wheelScrollLines,
).toBe(6);

for (const invalid of ["0", "11", '"fast"']) {
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
`wheel_scroll_lines = ${invalid}\n`,
);
expect(() => resolveConfiguredCliInput(input, { cwd: repo, env })).toThrow(
/wheel_scroll_lines/,
);
}
});

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-");
Expand Down Expand Up @@ -1037,6 +1074,7 @@ describe("config resolution", () => {
'theme = "github-light-default"',
"line_numbers = false",
"tab_width = 8",
"wheel_scroll_lines = 4",
"wrap_lines = true",
"menu_bar = false",
"sidebar = true",
Expand Down Expand Up @@ -1066,6 +1104,7 @@ describe("config resolution", () => {
expect(bootstrap.initialTheme).toBe("github-light-default");
expect(bootstrap.initialShowLineNumbers).toBe(false);
expect(bootstrap.initialTabWidth).toBe(8);
expect(bootstrap.initialWheelScrollLines).toBe(4);
expect(bootstrap.initialWrapLines).toBe(true);
expect(bootstrap.initialShowMenuBar).toBe(false);
expect(bootstrap.initialSidebar).toBe(true);
Expand Down
53 changes: 47 additions & 6 deletions src/core/run/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { resolveGlobalConfigPath } from "./paths";
import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "../process/startupNotice";
import { DEFAULT_TAB_WIDTH, validateTabWidth } from "./tabWidth";
import { DEFAULT_WHEEL_SCROLL_LINES, validateWheelScrollLines } from "./wheelScrollLines";
import { findProjectRootCandidate } from "../process/projectRoot";
import { createVcsCatalog, detectVcs } from "../vcs";
import type { VcsCatalog } from "../vcs/types";
Expand Down Expand Up @@ -248,6 +249,19 @@ function normalizeTabWidth(value: unknown) {
return validateTabWidth(value, "tab_width");
}

/** Accept `auto` or a bounded integer wheel step from TOML configuration. */
function normalizeWheelScrollLines(value: unknown) {
if (value === undefined || value === DEFAULT_WHEEL_SCROLL_LINES) {
return value;
}

if (typeof value !== "number" || !Number.isInteger(value)) {
throw new Error("Expected wheel_scroll_lines to be auto or an integer from 1 to 10.");
}

return validateWheelScrollLines(value, "wheel_scroll_lines");
}

/** One top-level configuration key shared by runtime parsing and generated reference docs. */
export interface ConfigReferenceOption {
readonly key: string;
Expand All @@ -262,6 +276,8 @@ export interface ConfigReferenceOption {
readonly aliases?: readonly { key: string; deprecated?: boolean }[];
/** Ordered source keys preserve compatibility precedence where an old alias historically won. */
readonly runtimeKeys?: readonly string[];
/** Machine-local input preferences do not resolve from repository config. */
readonly userOnly?: boolean;
}

/**
Expand Down Expand Up @@ -335,6 +351,16 @@ export const CONFIG_REFERENCE_OPTIONS: readonly ConfigReferenceOption[] = [
runtimeDefault: DEFAULT_TAB_WIDTH,
description: "Set terminal-cell tab stops used for display and wrapping.",
},
{
key: "wheel_scroll_lines",
property: "wheelScrollLines",
type: "string or integer",
accepted: "`auto` or 1 through 10",
runtimeDefault: DEFAULT_WHEEL_SCROLL_LINES,
description:
"Set review rows per vertical wheel event. `auto` keeps cadence-based acceleration from one to three rows.",
userOnly: true,
},
{
key: "wrap_lines",
property: "wrapLines",
Expand Down Expand Up @@ -894,6 +920,8 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk
return normalizeString(value);
case "tabWidth":
return normalizeTabWidth(value);
case "wheelScrollLines":
return normalizeWheelScrollLines(value);
case "sidebar":
return normalizeSidebarVisibility(value);
default:
Expand All @@ -902,11 +930,18 @@ function normalizeConfigReferenceValue(property: keyof CommonOptions, value: unk
}

/** Read the view preferences stored at one TOML object level. */
function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
function readConfigPreferences(
source: Record<string, unknown>,
{ includeUserOnly = true }: { includeUserOnly?: boolean } = {},
): CommonOptions {
const preferences: CommonOptions = {};
const mutable = preferences as Record<string, unknown>;

for (const option of CONFIG_REFERENCE_OPTIONS) {
if (option.userOnly && !includeUserOnly) {
continue;
}

const runtimeKeys = option.runtimeKeys ?? [
option.key,
...(option.aliases?.map(({ key }) => key) ?? []),
Expand Down Expand Up @@ -952,6 +987,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked,
lineNumbers: overrides.lineNumbers ?? base.lineNumbers,
tabWidth: overrides.tabWidth ?? base.tabWidth,
wheelScrollLines: overrides.wheelScrollLines ?? base.wheelScrollLines,
wrapLines: overrides.wrapLines ?? base.wrapLines,
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
menuBar: overrides.menuBar ?? base.menuBar,
Expand All @@ -968,17 +1004,21 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
}

/** Apply one parsed config object, including command/pager sections, to the current invocation. */
function resolveConfigLayer(source: Record<string, unknown>, input: CliInput): CommonOptions {
let resolved = readConfigPreferences(source);
function resolveConfigLayer(
source: Record<string, unknown>,
input: CliInput,
{ includeUserOnly = true }: { includeUserOnly?: boolean } = {},
): CommonOptions {
let resolved = readConfigPreferences(source, { includeUserOnly });

const commandSection = CONFIG_COMMAND_SECTIONS[input.kind] ? source[input.kind] : undefined;
if (isRecord(commandSection)) {
resolved = mergeOptions(resolved, readConfigPreferences(commandSection));
resolved = mergeOptions(resolved, readConfigPreferences(commandSection, { includeUserOnly }));
}

const pagerSection = source.pager;
if (input.options.pager && isRecord(pagerSection)) {
resolved = mergeOptions(resolved, readConfigPreferences(pagerSection));
resolved = mergeOptions(resolved, readConfigPreferences(pagerSection, { includeUserOnly }));
}

return resolved;
Expand Down Expand Up @@ -1152,7 +1192,7 @@ export function resolveConfiguredCliInput(

if (repoConfigPath) {
const repoConfig = readTomlRecord(repoConfigPath);
const repoLayer = resolveConfigLayer(repoConfig, input);
const repoLayer = resolveConfigLayer(repoConfig, input, { includeUserOnly: false });
explicitVcsId = repoLayer.vcs ?? explicitVcsId;
resolvedOptions = mergeOptions(resolvedOptions, repoLayer);
applyCustomThemeLayer(readCustomThemes(repoConfig));
Expand All @@ -1174,6 +1214,7 @@ export function resolveConfiguredCliInput(
mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode,
lineNumbers: resolvedOptions.lineNumbers ?? DEFAULT_VIEW_PREFERENCES.showLineNumbers,
tabWidth: resolvedOptions.tabWidth ?? DEFAULT_TAB_WIDTH,
wheelScrollLines: resolvedOptions.wheelScrollLines ?? DEFAULT_WHEEL_SCROLL_LINES,
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
menuBar: resolvedOptions.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar,
Expand Down
22 changes: 22 additions & 0 deletions src/core/run/wheelScrollLines.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import {
DEFAULT_WHEEL_SCROLL_LINES,
parseWheelScrollLines,
validateWheelScrollLines,
} from "./wheelScrollLines";

describe("wheel scroll lines", () => {
test("accepts auto and bounded integer CLI values", () => {
expect(parseWheelScrollLines("auto")).toBe(DEFAULT_WHEEL_SCROLL_LINES);
expect(parseWheelScrollLines("1")).toBe(1);
expect(parseWheelScrollLines("10")).toBe(10);
});

test("rejects malformed and out-of-range values", () => {
for (const value of ["0", "11", "1.5", "fast"]) {
expect(() => parseWheelScrollLines(value)).toThrow(/wheel scroll lines/);
}

expect(() => validateWheelScrollLines(Number.NaN)).toThrow(/wheel scroll lines/);
});
});
Loading
Loading