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/restore-github-default-theme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Restore `github-dark-default` as the default theme. The `terminal` theme remains available explicitly through config, `--theme terminal`, or the theme picker.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ and falling back to it if migration fails. Global extension paths are unchanged.
For example:

```toml
theme = "terminal"
theme = "github-dark-default"
mode = "auto" # auto, split, unified
auto_split_min_width = 160 # minimum available diff columns for split in auto mode
line_numbers = true
Expand Down
6 changes: 3 additions & 3 deletions docs/themes.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Themes

Hunk follows your terminal's own colors by default. You can instead choose a
built-in theme, let Hunk select one from your terminal background, or create a
Hunk uses `github-dark-default` by default. You can instead follow your terminal's
own colors, choose another built-in theme, select one from your terminal background, or create a
custom theme in `~/.config/hunk/config.toml` or `.hunk/config.toml`.

```toml
Expand All @@ -13,7 +13,7 @@ You can also change themes while reviewing: press `t` or choose

## Terminal theme

`theme = "terminal"` is the default. At startup Hunk asks the terminal for its
Set `theme = "terminal"` to follow your terminal's colors. At startup Hunk asks the terminal for its
foreground, background, and 16-color ANSI palette, then draws diffs, chrome,
and syntax highlighting from those colors: green for additions and strings, red
for removals, blue for functions and accents, magenta for keywords, and so on.
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ and falling back to it if migration fails. Global extension paths are unchanged.
For example:

```toml
theme = "terminal"
theme = "github-dark-default"
mode = "auto" # auto, split, unified
auto_split_min_width = 160 # minimum available diff columns for split in auto mode
line_numbers = true
Expand Down
8 changes: 4 additions & 4 deletions packages/hunk/src/core/run/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,7 +920,7 @@ describe("config resolution", () => {
expect(configured.input.options.transparentBackground).toBe(true);
});

test("defaults unspecified themes to terminal, including piped pager-style patch input", () => {
test("defaults unspecified themes to GitHub Dark Default, including piped pager-style patch input", () => {
const home = createTempDir("hunk-config-home-");
const cwd = createTempDir("hunk-config-cwd-");

Expand All @@ -931,7 +931,7 @@ describe("config resolution", () => {

expect(resolved.repoConfigPath).toBeUndefined();
expect(resolved.viewPreferencesConfigPath).toBe(join(home, ".config", "hunk", "config.toml"));
expect(resolved.input.options.theme).toBe("terminal");
expect(resolved.input.options.theme).toBe("github-dark-default");
});

test("command-specific config sections also apply to show mode", () => {
Expand Down Expand Up @@ -1288,7 +1288,7 @@ describe("config resolution", () => {
]);
});

test("loadAppBootstrap exposes terminal when no theme is configured", async () => {
test("loadAppBootstrap exposes GitHub Dark Default when no theme is configured", async () => {
const home = createTempDir("hunk-config-home-");
const repo = createTempDir("hunk-config-repo-");
createRepo(repo);
Expand All @@ -1309,7 +1309,7 @@ describe("config resolution", () => {
);
const bootstrap = await loadAppBootstrap(resolved.input);

expect(bootstrap.initialTheme).toBe("terminal");
expect(bootstrap.initialTheme).toBe("github-dark-default");
});
});

Expand Down
3 changes: 1 addition & 2 deletions packages/hunk/src/core/run/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import fs from "node:fs";
import { dirname, join } from "node:path";
import { sanitizeTerminalLine } from "../../lib/terminalText";
import { BUNDLED_SHIKI_THEME_IDS, LEGACY_THEME_ID_ALIASES } from "../theme/catalog";
import { TERMINAL_THEME_ID } from "../theme/terminalColors";
import {
createInvalidThemeIdNotice,
createThemeCollisionNotice,
Expand Down Expand Up @@ -101,7 +100,7 @@ export interface PersistedViewPreferences {
export const BUILT_IN_THEME_IDS = BUNDLED_SHIKI_THEME_IDS;
// Widen the large literal tuple before formatting it, avoiding TypeScript's deep tuple inference.
const BUILT_IN_THEME_IDS_FOR_MESSAGES: readonly string[] = BUILT_IN_THEME_IDS;
const DEFAULT_THEME_ID = TERMINAL_THEME_ID;
const DEFAULT_THEME_ID = "github-dark-default";
const DEFAULT_CUSTOM_THEME_BASE_ID = "github-dark-default";
const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
mode: "auto",
Expand Down
10 changes: 10 additions & 0 deletions packages/hunk/src/core/theme/terminalColors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test";
import { themeFollowsTerminal } from "./terminalColors";

test("only explicit terminal and auto themes need terminal color detection", () => {
expect(themeFollowsTerminal(undefined)).toBe(false);
expect(themeFollowsTerminal("github-dark-default")).toBe(false);
expect(themeFollowsTerminal("github-light-default")).toBe(false);
expect(themeFollowsTerminal("terminal")).toBe(true);
expect(themeFollowsTerminal("auto")).toBe(true);
});
8 changes: 4 additions & 4 deletions packages/hunk/src/core/theme/terminalColors.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/** Id of the built-in theme drawn from the terminal's own colors, and Hunk's default theme. */
/** Id of the opt-in built-in theme drawn from the terminal's own colors. */
export const TERMINAL_THEME_ID = "terminal";

/**
* Return whether a requested theme id depends on what the terminal looks like: the default
* (no id), the terminal-colors theme itself, and `auto` light/dark selection.
* Return whether a requested theme id depends on what the terminal looks like:
* the terminal-colors theme itself and `auto` light/dark selection.
*/
export function themeFollowsTerminal(themeId: string | undefined) {
return themeId === undefined || themeId === TERMINAL_THEME_ID || themeId === "auto";
return themeId === TERMINAL_THEME_ID || themeId === "auto";
}

/**
Expand Down
7 changes: 4 additions & 3 deletions packages/hunk/src/lib/shikiWasm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { REPO_ROOT } from "../../../../scripts/build/package-paths";
import { removeTestDirectory } from "../../../../test/helpers/filesystem";

// Install the main-thread counter before Pierre evaluates its static imports.
const highlightDecodeTestPreload = `
Expand Down Expand Up @@ -94,7 +95,7 @@ function runHighlightTestProcess(args: string[], cwd = REPO_ROOT) {
return JSON.parse(result.stdout.toString());
}

test("Pierre avoids base64 decoding in source, workers, bundles, and binaries with identical tokens", () => {
test("Pierre avoids base64 decoding in source, workers, bundles, and binaries with identical tokens", async () => {
const temporary = mkdtempSync(join(tmpdir(), "hunk-shiki-test-"));
// Keep the compile entry under the repository so its package imports resolve normally.
const sourceDir = mkdtempSync(join(REPO_ROOT, ".shiki-test-"));
Expand Down Expand Up @@ -177,7 +178,7 @@ test("Pierre avoids base64 decoding in source, workers, bundles, and binaries wi
expect(compiled.assets).toContain(466_610);
} finally {
rmSync(sourceDir, { recursive: true, force: true });
// Windows can briefly retain the compiled executable's file lock after the child exits.
rmSync(temporary, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
// Yield between retries so Windows can release the compiled child's executable handles.
await removeTestDirectory(temporary);
}
}, 60_000);
4 changes: 2 additions & 2 deletions packages/hunk/src/ui/AppHost.keybindings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,13 @@ describe("user keybindings", () => {
setup.mockInput.pressKey("n", { ctrl: true });
});
await flush(setup);
expect(setup.captureCharFrame()).toContain("› andromeeda");
expect(setup.captureCharFrame()).toContain("› github-dark-dimmed");

await act(async () => {
setup.mockInput.pressKey("p", { ctrl: true });
});
await flush(setup);
expect(setup.captureCharFrame()).toContain("› terminal");
expect(setup.captureCharFrame()).toContain("› github-dark-default");
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ describe("useThemeSelectorController", () => {
}),
);
expect(harness.controller.themeId).toBe(alpha.id);
expect(harness.controller.baseTheme.id).toBe("terminal");
expect(harness.controller.baseTheme.id).toBe("github-dark-default");
expect(harness.controller.themeSelectorSelectedIndex).toBeGreaterThanOrEqual(0);
expect(harness.controller.themeSelectorSelectedIndex).toBeLessThan(
harness.controller.themeSelectorItems.length,
Expand Down
8 changes: 4 additions & 4 deletions packages/hunk/src/ui/lib/ui-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ describe("ui helpers", () => {
).toBe(16);
});

test("resolveTheme falls back to the terminal theme while exposing semantic syntax colors", () => {
test("resolveTheme falls back to GitHub themes while exposing semantic syntax colors", () => {
const dracula = resolveTheme("dracula", null);
const missingLight = resolveTheme("missing", "light");
const missingDark = resolveTheme("missing", "dark");
Expand All @@ -694,16 +694,16 @@ describe("ui helpers", () => {
const missingCustom = resolveTheme("custom", null);

expect(dracula.id).toBe("dracula");
expect(missingLight).toMatchObject({ id: "terminal", appearance: "light" });
expect(missingDark).toMatchObject({ id: "terminal", appearance: "dark" });
expect(missingLight).toMatchObject({ id: "github-light-default", appearance: "light" });
expect(missingDark).toMatchObject({ id: "github-dark-default", appearance: "dark" });
expect(autoLight.id).toBe("github-light-default");
expect(autoDark.id).toBe("github-dark-default");
expect(custom.id).toBe("custom");
expect(custom.label).toBe("My Theme");
expect(custom.appearance).toBe("light");
expect(custom.accent).toBe("#7755aa");
expect(custom.syntaxScopeOverrides).toEqual({ "keyword.control": "#123456" });
expect(missingCustom.id).toBe("terminal");
expect(missingCustom.id).toBe("github-dark-default");
expect(custom.syntaxColors.default).toBe("#1f2328");
});
});
8 changes: 4 additions & 4 deletions packages/hunk/src/ui/session/HunkSessionHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,12 +309,12 @@ test("shares committed themes across history and repeated review surfaces", asyn

await act(async () => setup.mockInput.pressEnter());
await settle(setup);
// The default `terminal` theme leads the list, so one step down lands on the first bundled id.
expect(requests[0]?.themeId).toBe("andromeeda");
// One step down from GitHub Dark Default selects the next bundled GitHub theme.
expect(requests[0]?.themeId).toBe("github-dark-dimmed");

await act(async () => setup.mockInput.typeText("t"));
await setup.renderOnce();
await previewTrailingCustomTheme(setup, "andromeeda");
await previewTrailingCustomTheme(setup, "github-dark-dimmed");
expect(setup.captureCharFrame()).toContain("Review only");
expect(setup.captureCharFrame()).not.toContain("History only");
await act(async () => setup.mockInput.pressEnter());
Expand Down Expand Up @@ -727,7 +727,7 @@ test("blocks reopening until dirty-quit cancellation settles", async () => {
expect(setup.captureCharFrame()).toContain("Theme selector");
await act(async () => setup.mockInput.pressArrow("down"));
await setup.renderOnce();
expect(setup.captureCharFrame()).toContain("› andromeeda");
expect(setup.captureCharFrame()).toContain("› github-dark-dimmed");
await act(async () => setup.mockInput.pressEnter());
await setup.renderOnce();
await act(async () => setup.mockInput.pressEnter());
Expand Down
13 changes: 8 additions & 5 deletions packages/hunk/src/ui/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,12 @@ function themeContrastFailures(
describe("themes", () => {
afterEach(() => setDetectedTerminalColors(undefined));

test("defaults to the terminal theme and auto chooses GitHub light/dark", () => {
expect(resolveTheme(undefined, null).id).toBe(TERMINAL_THEME_ID);
expect(resolveTheme("missing", null).id).toBe(TERMINAL_THEME_ID);
test("falls back to GitHub themes and auto chooses GitHub light/dark", () => {
expect(resolveTheme(undefined, null).id).toBe(DEFAULT_DARK_THEME_ID);
expect(resolveTheme("missing", null).id).toBe(DEFAULT_DARK_THEME_ID);
expect(resolveTheme(undefined, "light").id).toBe(DEFAULT_LIGHT_THEME_ID);
expect(resolveTheme("missing", "light").id).toBe(DEFAULT_LIGHT_THEME_ID);
expect(resolveTheme(DEFAULT_DARK_THEME_ID, "light").id).toBe(DEFAULT_DARK_THEME_ID);
expect(resolveTheme("auto", "dark").id).toBe(DEFAULT_DARK_THEME_ID);
expect(resolveTheme("auto", "light").id).toBe(DEFAULT_LIGHT_THEME_ID);
});
Expand Down Expand Up @@ -136,7 +139,7 @@ describe("themes", () => {
"entity.name.type": "#e0af68",
});
// Repeated resolution returns the same object so theme-keyed memoization stays stable.
expect(resolveTheme(undefined, null)).toBe(theme);
expect(resolveTheme(TERMINAL_THEME_ID, "light")).toBe(theme);
});

test("falls back to a stand-in ANSI palette when the terminal was never probed", () => {
Expand Down Expand Up @@ -494,7 +497,7 @@ describe("themes", () => {
);
expect(resolveTheme("sunset", null, customThemes).accent).toBe("#654321");
// Unknown ids stay on the built-in fallback instead of picking an unrelated custom theme.
expect(resolveTheme("missing", null, customThemes).id).toBe(TERMINAL_THEME_ID);
expect(resolveTheme("missing", null, customThemes).id).toBe(DEFAULT_DARK_THEME_ID);
expect(resolveTheme("auto", "light", customThemes).id).toBe(DEFAULT_LIGHT_THEME_ID);
});

Expand Down
8 changes: 6 additions & 2 deletions packages/hunk/src/ui/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,13 +512,17 @@ export function availableThemes(
*
* Custom themes are matched before bundled ids so a custom theme that reuses a
* deprecated built-in alias still resolves to what the user actually defined. No id, or
* an id nothing defines, resolves to the `terminal` theme.
* an id nothing defines, resolves to the GitHub theme for the current light/dark mode.
*/
export function resolveTheme(
requested: string | undefined,
themeMode: ThemeMode | null,
customThemes: readonly NamedCustomThemeConfig[] = [],
) {
if (requested === TERMINAL_THEME_ID) {
return buildTerminalTheme(themeMode);
}

if (requested === "auto") {
return autoTheme(themeMode);
}
Expand All @@ -533,7 +537,7 @@ export function resolveTheme(
return exact;
}

return buildTerminalTheme(themeMode);
return autoTheme(themeMode);
}

/**
Expand Down
8 changes: 4 additions & 4 deletions test/pty/chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ describe("PTY chrome", () => {
await session.waitForText(/line60/, { timeout: 15_000 });

await harness.pressAndWaitForText(session, "t", /Theme selector/, { timeout: 5_000 });
await harness.pressAndWaitForText(session, "down", /›\s+andromeeda/, {
await harness.pressAndWaitForText(session, "down", /›\s+github-dark-dimmed/, {
timeout: 5_000,
});
await harness.pressAndWaitForSnapshot(
Expand All @@ -159,8 +159,8 @@ describe("PTY chrome", () => {
const prompt = await harness.pressAndWaitForText(session, "q", /Save view preferences\?/, {
timeout: 5_000,
});
expect(prompt).toContain('- theme = "terminal"');
expect(prompt).toContain('+ theme = "andromeeda"');
expect(prompt).toContain('- theme = "github-dark-default"');
expect(prompt).toContain('+ theme = "github-dark-dimmed"');
expect(prompt).toContain("enter/s save");

await session.click(/enter\/s save/);
Expand All @@ -173,7 +173,7 @@ describe("PTY chrome", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
}

expect(readFileSync(configPath, "utf8")).toContain('theme = "andromeeda"');
expect(readFileSync(configPath, "utf8")).toContain('theme = "github-dark-dimmed"');
} finally {
session.close();
rmSync(configHome, { recursive: true, force: true });
Expand Down
23 changes: 22 additions & 1 deletion test/pty/comment-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,18 @@ describe("PTY comment target", () => {
// and the draft opens a line or two (or a whole gap) away from the clicked line.
const fixture = harness.createMultiHunkFilePair();
const session = await harness.launchHunk({
args: ["diff", "--files", fixture.before, fixture.after, "--mode", "split"],
// Fixed wheel steps make equal event counts a true viewport roundtrip; auto
// acceleration depends on cadence and can leave the viewport one row short.
args: [
"diff",
"--files",
fixture.before,
fixture.after,
"--mode",
"split",
"--wheel-scroll-lines",
"1",
],
cols: 120,
rows: 16,
});
Expand All @@ -45,6 +56,7 @@ describe("PTY comment target", () => {
);
const clickedPaint = rowCellBackgrounds(session, 6).slice(12, 40);
expect(clickedPaint).not.toEqual(beforeClick);
const clickedText = await session.text({ immediate: true });

// Fidget: wheel down 3, then up 3 — net zero viewport movement.
for (let i = 0; i < 3; i += 1) {
Expand All @@ -59,6 +71,15 @@ describe("PTY comment target", () => {

// The viewport is back where it started with no deliberate cursor move in
// between. The highlight must still sit on line2 — and so must the draft.
// A quiet PTY can still precede the deferred viewport/cursor commit. Wait for the
// original viewport and its paint, rather than reading an intermediate scroll frame.
await harness.waitForSnapshot(
session,
(text) =>
text === clickedText &&
JSON.stringify(rowCellBackgrounds(session, 6).slice(12, 40)) ===
JSON.stringify(clickedPaint),
);
expect(rowCellBackgrounds(session, 6).slice(12, 40)).toEqual(clickedPaint);

await session.press("c");
Expand Down
6 changes: 3 additions & 3 deletions test/pty/log-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,14 +442,14 @@ describe("interactive hunk log", () => {
const prompt = await harness.pressAndWaitForText(session, "q", /Save view preferences\?/, {
timeout: 5_000,
});
expect(prompt).toContain('- theme = "terminal"');
expect(prompt).toContain('+ theme = "andromeeda"');
expect(prompt).toContain('- theme = "github-dark-default"');
expect(prompt).toContain('+ theme = "github-dark-dimmed"');

await session.press("s");
const configPath = join(configHome, "hunk", "config.toml");
const deadline = Date.now() + 5_000;
while (Date.now() < deadline && !existsSync(configPath)) await Bun.sleep(50);
expect(readFileSync(configPath, "utf8")).toContain('theme = "andromeeda"');
expect(readFileSync(configPath, "utf8")).toContain('theme = "github-dark-dimmed"');
} finally {
session.close();
}
Expand Down
5 changes: 5 additions & 0 deletions test/pty/open-scroll-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ test("watched source shrink clamps deep scrolling to the surviving end instead o
await session.waitForText("replacement");
expect(firstOpenScrollTestLine(await session.text())).toBe(survivingLine);
await session.press("w");
// Watch publication and wrap layout can briefly clear the mounted rows before repainting.
await harness.waitForSnapshot(
session,
(text) => firstOpenScrollTestLine(text) === survivingLine,
);
expect(firstOpenScrollTestLine(await session.text())).toBe(survivingLine);
await session.press("g");
await session.waitForText("LINE_001");
Expand Down
Loading
Loading