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/custom-syntax-theme-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add `custom_theme.syntax_theme` to load a full VS Code / Shiki theme JSON for source-accurate syntax highlighting. The referenced theme is registered with the highlighter and drives code coloring, so any VS Code theme renders exactly as it would in the editor instead of being approximated by the nine `[custom_theme.syntax]` tokens.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ variable = "#eef4ff"

All custom theme colors must use `#rrggbb` hex values. Press `t` in the app, or choose `View -> Themes…`, to open the theme selector.

For source-accurate syntax highlighting, point `syntax_theme` at a VS Code / Shiki theme JSON file. Hunk loads it and hands it to its Shiki-based highlighter, so any VS Code theme colors your code exactly as that theme would:

```toml
[custom_theme]
base = "catppuccin-mocha"
syntax_theme = "shades-of-purple.json" # absolute, or relative to this config file
```

When `syntax_theme` is set it drives code highlighting; the `[custom_theme.syntax]` colors then only refine tokens that would otherwise collide with diff add/remove backgrounds.

### Git integration

Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically:
Expand Down
84 changes: 84 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,90 @@ describe("config resolution", () => {
).toThrow("Expected custom_theme.accent to be a hex color like #112233.");
});

test("loads a custom_theme.syntax_theme JSON relative to the config file", () => {
const home = createTempDir("hunk-config-home-");
const hunkDir = join(home, ".config", "hunk");
mkdirSync(hunkDir, { recursive: true });
writeFileSync(
join(hunkDir, "my-theme.json"),
JSON.stringify({ name: "My VS Code Theme", type: "dark", tokenColors: [] }),
);
writeFileSync(
join(hunkDir, "config.toml"),
[
'theme = "custom"',
"",
"[custom_theme]",
'base = "github-dark-default"',
'syntax_theme = "my-theme.json"',
].join("\n"),
);

const resolved = resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
});

expect(resolved.customTheme?.syntaxThemePath).toBe("my-theme.json");
expect(resolved.customTheme?.syntaxThemeData).toEqual({
name: "My VS Code Theme",
type: "dark",
tokenColors: [],
});
});

test("rejects a custom_theme.syntax_theme that does not exist", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(
join(home, ".config", "hunk", "config.toml"),
["[custom_theme]", 'syntax_theme = "missing.json"'].join("\n"),
);

expect(() =>
resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
}),
Comment thread
eduwass marked this conversation as resolved.
).toThrow("Expected custom_theme.syntax_theme to point at a file.");
});

test("rejects a custom_theme.syntax_theme that is not valid JSON", () => {
const home = createTempDir("hunk-config-home-");
const hunkDir = join(home, ".config", "hunk");
mkdirSync(hunkDir, { recursive: true });
writeFileSync(join(hunkDir, "broken.json"), "{ not valid json }");
writeFileSync(
join(hunkDir, "config.toml"),
["[custom_theme]", 'syntax_theme = "broken.json"'].join("\n"),
);

expect(() =>
resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
}),
).toThrow("to be valid JSON:");
});

test("rejects a custom_theme.syntax_theme JSON without a name", () => {
const home = createTempDir("hunk-config-home-");
const hunkDir = join(home, ".config", "hunk");
mkdirSync(hunkDir, { recursive: true });
writeFileSync(join(hunkDir, "nameless.json"), JSON.stringify({ type: "dark" }));
writeFileSync(
join(hunkDir, "config.toml"),
["[custom_theme]", 'syntax_theme = "nameless.json"'].join("\n"),
);

expect(() =>
resolveConfiguredCliInput(createPatchPagerInput(), {
cwd: createTempDir("hunk-config-cwd-"),
env: { HOME: home },
}),
).toThrow('to be a Shiki theme with a non-empty "name".');
});

test("rejects theme = custom when no [custom_theme] table is configured", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
Expand Down
67 changes: 63 additions & 4 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from "node:fs";
import { join } from "node:path";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
import { normalizeBuiltInThemeId } from "../ui/themes";
import { resolveGlobalConfigPath } from "./paths";
Expand All @@ -8,6 +8,7 @@ import type {
CliInput,
CommonOptions,
CustomSyntaxColorsConfig,
CustomSyntaxThemeData,
CustomThemeConfig,
LayoutMode,
PersistedViewPreferences,
Expand Down Expand Up @@ -161,8 +162,52 @@ function readCustomSyntaxColors(
return Object.keys(syntax).length > 0 ? syntax : undefined;
}

/**
* Load and validate a full Shiki theme JSON referenced by `custom_theme.syntax_theme`.
* The path may be absolute or relative to the config file that declared it. We read it
* eagerly so a bad path fails fast at config time rather than silently dropping
* highlighting later.
*/
function readCustomSyntaxTheme(
value: unknown,
configPath: string | undefined,
): CustomSyntaxThemeData | undefined {
const rawPath = normalizeString(value);
if (rawPath === undefined) {
return undefined;
}

const basis = configPath ? dirname(configPath) : process.cwd();
const themePath = isAbsolute(rawPath) ? rawPath : resolve(basis, rawPath);

if (!fs.existsSync(themePath)) {
throw new Error(`Expected custom_theme.syntax_theme to point at a file. Missing: ${themePath}`);
}

let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(themePath, "utf8"));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Expected custom_theme.syntax_theme (${themePath}) to be valid JSON: ${reason}`,
);
}

if (!isRecord(parsed) || typeof parsed.name !== "string" || parsed.name.length === 0) {
throw new Error(
`Expected custom_theme.syntax_theme (${themePath}) to be a Shiki theme with a non-empty "name".`,
);
}

return parsed as CustomSyntaxThemeData;
}

/** Read the optional config-defined custom theme palette from one TOML object level. */
function readCustomTheme(source: Record<string, unknown>): CustomThemeConfig | undefined {
function readCustomTheme(
source: Record<string, unknown>,
configPath?: string,
): CustomThemeConfig | undefined {
const customThemeSource = source.custom_theme;
if (!isRecord(customThemeSource)) {
return undefined;
Expand All @@ -181,6 +226,12 @@ function readCustomTheme(source: Record<string, unknown>): CustomThemeConfig | u
customTheme.label = label;
}

const syntaxThemePath = normalizeString(customThemeSource.syntax_theme);
if (syntaxThemePath !== undefined) {
customTheme.syntaxThemePath = syntaxThemePath;
customTheme.syntaxThemeData = readCustomSyntaxTheme(customThemeSource.syntax_theme, configPath);
}

for (const key of CUSTOM_THEME_COLOR_KEYS) {
const value = normalizeThemeColor(customThemeSource[key], `custom_theme.${key}`);
if (value !== undefined) {
Expand Down Expand Up @@ -215,6 +266,8 @@ function mergeCustomTheme(
...overrides,
base: overrides.base ?? base.base ?? "github-dark-default",
label: overrides.label ?? base.label,
syntaxThemePath: overrides.syntaxThemePath ?? base.syntaxThemePath,
syntaxThemeData: overrides.syntaxThemeData ?? base.syntaxThemeData,
syntax:
base.syntax || overrides.syntax
? {
Expand Down Expand Up @@ -333,13 +386,19 @@ export function resolveConfiguredCliInput(
if (userConfigPath) {
const userConfig = readTomlRecord(userConfigPath);
resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(userConfig, input));
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(userConfig));
resolvedCustomTheme = mergeCustomTheme(
resolvedCustomTheme,
readCustomTheme(userConfig, userConfigPath),
);
}

if (repoConfigPath) {
const repoConfig = readTomlRecord(repoConfigPath);
resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(repoConfig, input));
resolvedCustomTheme = mergeCustomTheme(resolvedCustomTheme, readCustomTheme(repoConfig));
resolvedCustomTheme = mergeCustomTheme(
resolvedCustomTheme,
readCustomTheme(repoConfig, repoConfigPath),
);
}

resolvedOptions = mergeOptions(resolvedOptions, input.options);
Expand Down
14 changes: 14 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,23 @@ export interface CustomSyntaxColorsConfig {
punctuation?: string;
}

/**
* A full VS Code / Shiki theme JSON loaded from disk and registered with the
* highlighter for source-accurate syntax coloring. Only `name` is required; the
* remaining TextMate fields are passed through to Shiki untouched.
*/
export interface CustomSyntaxThemeData {
name: string;
[key: string]: unknown;
}

export interface CustomThemeConfig {
base?: string;
label?: string;
/** Path (from config) to a Shiki theme JSON used for syntax highlighting. */
syntaxThemePath?: string;
/** The loaded + validated Shiki theme JSON referenced by `syntaxThemePath`. */
syntaxThemeData?: CustomSyntaxThemeData;
background?: string;
panel?: string;
panelAlt?: string;
Expand Down
55 changes: 54 additions & 1 deletion src/ui/diff/pierre.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { parseDiffFromFile } from "@pierre/diffs";
import { disposeHighlighter, parseDiffFromFile } from "@pierre/diffs";
import type { DiffFile } from "../../core/types";
import {
buildSplitRows,
Expand Down Expand Up @@ -667,6 +667,59 @@ describe("Pierre diff rows", () => {
}
});

test("registers custom syntax themes under a collision-safe internal name", async () => {
const metadata = parseDiffFromFile(
{ name: "syntax.ts", contents: "const a = 1;\n", cacheKey: "collision-before" },
{
name: "syntax.ts",
contents: "export const a = 2;\n",
cacheKey: "collision-after",
},
{ context: 3 },
true,
);
const file: DiffFile = {
id: "custom-syntax-collision",
path: "syntax.ts",
patch: "",
language: "typescript",
stats: { additions: 1, deletions: 1 },
metadata,
agent: null,
};

await disposeHighlighter();
await loadHighlightedDiff(file, resolveTheme("dracula", null));

const theme = resolveTheme("custom", null, {
base: "github-dark-default",
syntaxThemeData: {
name: "dracula",
type: "dark",
colors: {
"editor.background": "#000000",
"editor.foreground": "#ffffff",
},
tokenColors: [
{
scope: ["keyword", "storage", "storage.type"],
settings: { foreground: "#ff00ff" },
},
],
},
});
const highlighted = await loadHighlightedDiff(file, theme);
const spans = buildStackRows(file, highlighted, theme)
.filter(
(row): row is Extract<DiffRow, { type: "stack-line" }> =>
row.type === "stack-line" && row.cell.kind === "addition",
)
.flatMap((row) => row.cell.spans);

expect(theme.syntaxTheme).toBe("hunk-custom-syntax:dracula");
expect(spans.find((span) => span.text.includes("export"))?.fg?.toLowerCase()).toBe("#ff00ff");
});

test("uses Shiki's bundled Catppuccin theme for Catppuccin syntax", async () => {
const metadata = parseDiffFromFile(
{ name: "syntax.ts", contents: "const a = 1;\n", cacheKey: "catppuccin-before" },
Expand Down
32 changes: 32 additions & 0 deletions src/ui/diff/pierre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
cleanLastNewline,
getHighlighterOptions,
getSharedHighlighter,
registerCustomTheme,
renderDiffWithHighlighter,
renderFileWithHighlighter,
type FileContents,
Expand Down Expand Up @@ -571,8 +572,39 @@ export function trailingCollapsedLines(metadata: FileDiffMetadata) {
return Math.max(additionRemaining, 0);
}

// Custom Shiki themes are registered once with Pierre's global theme registry. Track which
// internal ids we've registered so repeated highlight passes don't re-register (Pierre warns on
// dupes). Hunk uses a namespaced id instead of the JSON's own `name` so user themes can share names
// with Shiki's bundled themes without hitting Pierre's resolved-theme cache for the built-in.
const registeredCustomSyntaxThemes = new Set<string>();

/** Register a config-provided Shiki theme JSON with Pierre before it's referenced by name. */
function ensureCustomSyntaxThemeRegistered(theme: HighlightThemeInput) {
if (typeof theme === "string") {
return;
}

const data = theme.syntaxThemeData;
const themeName = highlighterThemeName(theme);
if (!data || registeredCustomSyntaxThemes.has(themeName)) {
return;
}

registeredCustomSyntaxThemes.add(themeName);
// Pierre requires the loader result's `name` to match the requested theme key, so clone the JSON
// with Hunk's internal registration id while keeping the original data untouched for callers.
type CustomThemeLoader = Parameters<typeof registerCustomTheme>[1];
const loader: CustomThemeLoader = () =>
Promise.resolve({
...data,
name: themeName,
} as unknown as Awaited<ReturnType<CustomThemeLoader>>);
registerCustomTheme(themeName, loader);
}

/** Prepare syntax highlighting for one language/theme pair using Pierre's shared highlighter. */
async function prepareHighlighter(language: string | undefined, theme: HighlightThemeInput) {
ensureCustomSyntaxThemeRegistered(theme);
const resolvedLanguage = language ?? "text";
const syntaxTheme = highlighterThemeName(theme);
const cacheKey = `${syntaxTheme}:${resolvedLanguage}`;
Expand Down
16 changes: 16 additions & 0 deletions src/ui/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,22 @@ describe("themes", () => {
expect(custom.syntaxColors.keyword).toBe("#ff00ff");
});

test("a full syntax theme JSON drives highlighting by name", () => {
const syntaxThemeData = { name: "Shades of Purple", type: "dark" as const, tokenColors: [] };
const custom = resolveTheme("custom", null, {
base: "catppuccin-mocha",
label: "My Theme",
syntaxThemeData,
// A 9-token block is present too, but the full theme JSON should take precedence.
syntax: { keyword: "#ff00ff" },
});

expect(custom.syntaxTheme).toBe("hunk-custom-syntax:Shades of Purple");
expect(custom.syntaxThemeData).toEqual(syntaxThemeData);
// The 9-token palette is still kept for collision normalization against diff backgrounds.
expect(custom.syntaxColors.keyword).toBe("#ff00ff");
});

test("withTransparentBackground only swaps painted background fields", () => {
const theme = resolveTheme("github-dark-default", null);
const transparent = withTransparentBackground(theme);
Expand Down
Loading