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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added the Vesper theme as a built-in diff viewer theme.
- Added `vcs = "jj"` support, enabling `hunk diff [revset]` and `hunk show [revset]`.

### Changed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ You can persist preferences to a config file:
Example:

```toml
theme = "graphite" # graphite, midnight, paper, ember
theme = "graphite" # graphite, vesper, midnight, paper, ember
mode = "auto" # auto, split, stack
vcs = "git" # git, jj
exclude_untracked = false
Expand Down
26 changes: 13 additions & 13 deletions docs/opentui-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,19 @@ if (!metadata) {

## Props

| Prop | Type | Default | Notes |
| ------------------- | ------------------------------------------------ | ------------ | ------------------------------------------------------------------------- |
| `diff` | `HunkDiffFile` | `undefined` | File to render. When omitted, the component shows an empty-state message. |
| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. |
| `width` | `number` | — | Required content width in terminal columns. |
| `theme` | `"graphite" \| "midnight" \| "paper" \| "ember"` | `"graphite"` | Matches Hunk's built-in themes. |
| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. |
| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. |
| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. |
| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. |
| `highlight` | `boolean` | `true` | Enables syntax highlighting. |
| `scrollable` | `boolean` | `true` | Set to `false` if your parent view owns scrolling. |
| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target. |
| Prop | Type | Default | Notes |
| ------------------- | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------- |
| `diff` | `HunkDiffFile` | `undefined` | File to render. When omitted, the component shows an empty-state message. |
| `layout` | `"split" \| "stack"` | `"split"` | Chooses side-by-side or stacked rendering. |
| `width` | `number` | — | Required content width in terminal columns. |
| `theme` | `"graphite" \| "vesper" \| "midnight" \| "paper" \| "ember"` | `"graphite"` | Matches Hunk's built-in themes. |
| `showLineNumbers` | `boolean` | `true` | Toggles line-number columns. |
| `showHunkHeaders` | `boolean` | `true` | Toggles `@@ ... @@` hunk header rows. |
| `wrapLines` | `boolean` | `false` | Wraps long lines instead of clipping horizontally. |
| `horizontalOffset` | `number` | `0` | Scroll offset for non-wrapped code rows. |
| `highlight` | `boolean` | `true` | Enables syntax highlighting. |
| `scrollable` | `boolean` | `true` | Set to `false` if your parent view owns scrolling. |
| `selectedHunkIndex` | `number` | `0` | Highlights one hunk as the active target. |

## Other exports

Expand Down
2 changes: 1 addition & 1 deletion src/opentui/HunkDiffView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ describe("HunkDiffView", () => {
});

test("exports the documented built-in theme names", () => {
expect(HUNK_DIFF_THEME_NAMES).toEqual(["graphite", "midnight", "paper", "ember"]);
expect(HUNK_DIFF_THEME_NAMES).toEqual(["graphite", "vesper", "midnight", "paper", "ember"]);
});
});
2 changes: 1 addition & 1 deletion src/opentui/HunkDiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function HunkDiffView({
const internalDiff = useMemo(() => (diff ? toInternalDiffFile(diff) : undefined), [diff]);
const resolvedHighlighted = useHighlightedDiff({
file: internalDiff,
appearance: resolvedTheme.appearance,
theme: resolvedTheme,
shouldLoadHighlight: highlight,
});
const rows = useMemo(
Expand Down
2 changes: 1 addition & 1 deletion src/opentui/themes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const HUNK_DIFF_THEME_NAMES = ["graphite", "midnight", "paper", "ember"] as const;
export const HUNK_DIFF_THEME_NAMES = ["graphite", "vesper", "midnight", "paper", "ember"] as const;

export type HunkDiffThemeName = (typeof HUNK_DIFF_THEME_NAMES)[number];
4 changes: 2 additions & 2 deletions src/ui/components/panes/DiffPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -495,10 +495,10 @@ export function DiffPane({

void prefetchHighlightedDiff({
file,
appearance: theme.appearance,
theme,
});
}
}, [files, highlightPrefetchFileIds, theme.appearance]);
}, [files, highlightPrefetchFileIds, theme]);

// Read the live scroll box position during render so pinned-header ownership flips
// immediately after imperative scrolls instead of waiting for the polled viewport snapshot.
Expand Down
2 changes: 1 addition & 1 deletion src/ui/diff/PierreDiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function PierreDiffView({
}) {
const resolvedHighlighted = useHighlightedDiff({
file,
appearance: theme.appearance,
theme,
shouldLoadHighlight,
});

Expand Down
35 changes: 34 additions & 1 deletion src/ui/diff/pierre.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,44 @@ describe("Pierre diff rows", () => {
}
});

test("uses the Vesper syntax palette for highlighted code tokens", async () => {
const file = createDiffFile();
const theme = resolveTheme("vesper", null);
const highlighted = await loadHighlightedDiff(file, theme);
const rows = buildStackRows(file, highlighted, theme).filter(
(row): row is Extract<DiffRow, { type: "stack-line" }> =>
row.type === "stack-line" && row.cell.kind === "addition",
);

const changedRow = rows.find((row) => row.cell.spans.some((span) => span.text.includes("42")));
expect(changedRow).toBeDefined();

if (!changedRow) {
throw new Error("Expected highlighted Vesper addition row");
}

expect(
changedRow.cell.spans.some(
(span) => span.text.includes("export const") && span.fg === theme.syntaxColors.keyword,
),
).toBe(true);
expect(
changedRow.cell.spans.some(
(span) => span.text.includes("answer") && span.fg === theme.syntaxColors.default,
),
).toBe(true);
expect(
changedRow.cell.spans.some(
(span) => span.text.includes("42") && span.fg === theme.syntaxColors.number,
),
).toBe(true);
});

test("keeps reserved-color remaps isolated across dark themes", async () => {
const file = createMarkdownDiffFile();
const highlighted = await loadHighlightedDiff(file, "dark");

for (const themeId of ["graphite", "midnight", "ember"] as const) {
for (const themeId of ["graphite", "vesper", "midnight", "ember"] as const) {
const theme = resolveTheme(themeId, null);
const rows = buildStackRows(file, highlighted, theme).filter(
(row): row is Extract<DiffRow, { type: "stack-line" }> =>
Expand Down
119 changes: 101 additions & 18 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,
type FileDiffMetadata,
} from "@pierre/diffs";
Expand All @@ -15,12 +16,95 @@ const PIERRE_THEME = {
light: "pierre-light",
dark: "pierre-dark",
} as const;
const HUNK_VESPER_PIERRE_THEME = "hunk-vesper";

registerCustomTheme(HUNK_VESPER_PIERRE_THEME, async () => ({
name: HUNK_VESPER_PIERRE_THEME,
type: "dark",
bg: "#101010",
fg: "#FFFFFF",
colors: {
"editor.background": "#101010",
"editor.foreground": "#FFFFFF",
"editor.lineHighlightBackground": "#101010",
"editor.selectionBackground": "#282828",
"editorCursor.foreground": "#FFC799",
"editorLineNumber.foreground": "#505050",
"gitDecoration.addedResourceForeground": "#99FFE4",
"gitDecoration.deletedResourceForeground": "#FF8080",
"gitDecoration.modifiedResourceForeground": "#FFC799",
"terminal.ansiGreen": "#99FFE4",
"terminal.ansiRed": "#FF8080",
"terminal.ansiYellow": "#FFC799",
},
settings: [
{ settings: { background: "#101010", foreground: "#FFFFFF" } },
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#5C5C5C", fontStyle: "italic" },
},
{
scope: ["string", "constant.other.symbol", "constant.other.key"],
settings: { foreground: "#99FFE4" },
},
{
scope: [
"constant",
"constant.numeric",
"constant.language",
"entity.name.function",
"support.function",
"entity.name.type",
"entity.name.class",
"support.type",
"meta.type.annotation",
],
settings: { foreground: "#FFC799" },
},
{
scope: [
"keyword",
"keyword.operator",
"storage",
"storage.type",
"storage.modifier",
"punctuation",
"meta.brace",
"meta.delimiter",
],
settings: { foreground: "#A0A0A0" },
},
{
scope: [
"variable",
"variable.parameter",
"variable.other.property",
"support.variable.property",
"entity.other.attribute-name",
],
settings: { foreground: "#FFFFFF" },
},
{
scope: ["invalid", "invalid.illegal"],
settings: { foreground: "#FF8080" },
},
],
}));

/** Resolve the single Pierre theme name needed for the current appearance. */
function pierreThemeName(appearance: AppTheme["appearance"]) {
return PIERRE_THEME[appearance];
}

/** Resolve the concrete syntax-highlighting theme for one Hunk theme. */
function pierreSyntaxThemeName(theme: AppTheme | AppTheme["appearance"]) {
if (typeof theme === "string") {
return pierreThemeName(theme);
}

return theme.id === "vesper" ? HUNK_VESPER_PIERRE_THEME : pierreThemeName(theme.appearance);
}

const PIERRE_RENDER_OPTIONS_BY_APPEARANCE = {
light: {
theme: pierreThemeName("light"),
Expand All @@ -38,9 +122,12 @@ const PIERRE_RENDER_OPTIONS_BY_APPEARANCE = {
},
} as const;

/** Reuse the render options for one appearance so startup work avoids extra object churn. */
function pierreRenderOptions(appearance: AppTheme["appearance"]) {
return PIERRE_RENDER_OPTIONS_BY_APPEARANCE[appearance];
/** Resolve render options for one theme while preserving shared objects for built-in Pierre themes. */
function pierreRenderOptions(theme: AppTheme | AppTheme["appearance"]) {
const themeName = pierreSyntaxThemeName(theme);
const appearance = typeof theme === "string" ? theme : theme.appearance;
const options = PIERRE_RENDER_OPTIONS_BY_APPEARANCE[appearance];
return options.theme === themeName ? options : { ...options, theme: themeName };
}

type HighlightOptions = ReturnType<typeof getHighlighterOptions>;
Expand Down Expand Up @@ -427,16 +514,13 @@ function trailingCollapsedLines(metadata: FileDiffMetadata) {
}

/** Prepare syntax highlighting for one language/appearance pair using Pierre's shared highlighter. */
async function prepareHighlighter(
language: string | undefined,
appearance: AppTheme["appearance"],
) {
async function prepareHighlighter(language: string | undefined, syntaxThemeName: string) {
const resolvedLanguage = language ?? "text";
const cacheKey = `${appearance}:${resolvedLanguage}`;
const cacheKey = `${syntaxThemeName}:${resolvedLanguage}`;
const options =
highlighterOptionsByKey.get(cacheKey) ??
getHighlighterOptions(resolvedLanguage, {
theme: pierreThemeName(appearance),
theme: syntaxThemeName,
});

if (!highlighterOptionsByKey.has(cacheKey)) {
Expand Down Expand Up @@ -513,28 +597,27 @@ function aliasHighlightedContextLines(file: DiffFile, highlighted: HighlightedDi
/** Highlight a diff file and return just the rendered line trees the UI needs. */
export async function loadHighlightedDiff(
file: DiffFile,
appearance: AppTheme["appearance"] = "dark",
theme: AppTheme | AppTheme["appearance"] = "dark",
): Promise<HighlightedDiffCode> {
const syntaxThemeName = pierreSyntaxThemeName(theme);
const renderOptions = pierreRenderOptions(theme);

try {
const highlighter = await prepareHighlighter(file.language, appearance);
const highlighter = await prepareHighlighter(file.language, syntaxThemeName);
return queueHighlightedDiff(() => {
const highlighted = renderDiffWithHighlighter(
file.metadata,
highlighter,
pierreRenderOptions(appearance),
);
const highlighted = renderDiffWithHighlighter(file.metadata, highlighter, renderOptions);
return aliasHighlightedContextLines(file, {
deletionLines: highlighted.code.deletionLines as Array<HastNode | undefined>,
additionLines: highlighted.code.additionLines as Array<HastNode | undefined>,
});
});
} catch {
const highlighter = await prepareHighlighter("text", appearance);
const highlighter = await prepareHighlighter("text", syntaxThemeName);
return queueHighlightedDiff(() => {
const highlighted = renderDiffWithHighlighter(
{ ...file.metadata, lang: "text" },
highlighter,
pierreRenderOptions(appearance),
renderOptions,
);
return aliasHighlightedContextLines(file, {
deletionLines: highlighted.code.deletionLines as Array<HastNode | undefined>,
Expand Down
Loading