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/shared-log-chrome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add Hunk's desktop menu chrome, live theme picker, and provider-owned merge-parent selection to the interactive repository history browser.
11 changes: 7 additions & 4 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,14 @@ deleted until its replies are removed.
The built-in commands and the keys they ship with:

`hunk log --interactive` is a separate, fixed read-only history entry point rather than part of
the configurable review command table. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`,
`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `y` to copy the full commit id,
`Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit
the configurable review command table. `F10` opens its File, View, Navigate, Commit, and Help menus;
View includes Hunk's shared theme selector. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`,
`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `r` to refresh, `y` to copy
the full commit id, `Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit
id to open it immediately, click elsewhere on a row to select it, or double-click a row to open it.
Quitting the opened review returns to the retained history selection and viewport.
Quitting the opened review returns to the retained history selection and viewport. The Commit menu's
**Compare with first parent** and **Compare with parent…** actions compare the selected commit against
an ordered provider-owned parent; they do not navigate the history selection to that parent.

| Command id | Does | Default keys |
| ---------------------------------------------- | ---------------------------------------------- | ---------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ bad or duplicate id is skipped with a startup notice.
| Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event |
| Read user-supplied settings | `hunk.config` (`[extension.<id>]` table) |
| Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command |
| Branch on the API generation (currently `17`) | `hunk.apiVersion` |
| Branch on the API generation (currently `18`) | `hunk.apiVersion` |

Registration is only valid while the factory runs — Hunk seals the API object
afterwards.
Expand Down
81 changes: 81 additions & 0 deletions src/app/historyBootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { HistoryCommandInput } from "../core/run/commandInputs";
import type { VcsAdapter, VcsCatalog, VcsHistorySource } from "../core/vcs/types";
import { loadHistoryBootstrap } from "./historyBootstrap";

const input: HistoryCommandInput = {
kind: "history",
color: "never",
format: "compact",
ascii: false,
interactive: true,
vcs: "test",
extensionsEnabled: false,
extensionPaths: [],
};

describe("history bootstrap cursor ownership", () => {
test("cancels refresh before opening and closes each active provider cursor once", async () => {
const cwd = mkdtempSync(join(tmpdir(), "hunk-history-bootstrap-"));
const configHome = mkdtempSync(join(tmpdir(), "hunk-history-config-"));
const closeCounts: number[] = [];
let opens = 0;
const makeSource = (): VcsHistorySource => {
const index = opens++;
closeCounts[index] = 0;
return {
async read() {
return { commits: [], done: true };
},
async close() {
closeCounts[index]! += 1;
},
};
};
const adapter: VcsAdapter = {
id: "test",
name: "Test",
detect: () => ({ id: "test", repoRoot: cwd }),
operations: {},
history: {
async open() {
return makeSource();
},
async planReview(commit) {
return { kind: "revision-show", revisionId: commit.revisionId };
},
},
};
const catalog: VcsCatalog = {
adapters: [adapter],
defaultAdapterId: "test",
reservedIds: new Set(["test"]),
};

try {
const bootstrap = await loadHistoryBootstrap({
input,
cwd,
env: { ...process.env, XDG_CONFIG_HOME: configHome },
baseVcsCatalog: catalog,
});
const cancelled = new AbortController();
cancelled.abort();
await expect(bootstrap.reopenSource(cancelled.signal)).rejects.toThrow();
expect(opens).toBe(1);

await bootstrap.reopenSource();
expect(opens).toBe(2);
expect(closeCounts).toEqual([1, 0]);
await bootstrap.close();
await bootstrap.close();
expect(closeCounts).toEqual([1, 1]);
} finally {
rmSync(cwd, { recursive: true, force: true });
rmSync(configHome, { recursive: true, force: true });
}
});
});
55 changes: 36 additions & 19 deletions src/app/historyBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { collectSessionCustomThemes } from "../core/theme/customThemes";
import type {
ExtensionVcsHistoryCommit,
ExtensionVcsHistoryReviewAction,
ExtensionVcsHistoryReviewOptions,
NamedCustomThemeConfig,
} from "../extension-api/types";
import { sanitizeTerminalLine } from "../lib/terminalText";
Expand Down Expand Up @@ -31,7 +32,11 @@ export interface HistoryBootstrap {
extensions: ExtensionLoadResult;
notices: readonly string[];
customThemes: readonly NamedCustomThemeConfig[];
planReview(commit: ExtensionVcsHistoryCommit): Promise<ExtensionVcsHistoryReviewAction>;
planReview(
commit: ExtensionVcsHistoryCommit,
options?: ExtensionVcsHistoryReviewOptions,
): Promise<ExtensionVcsHistoryReviewAction>;
reopenSource(signal?: AbortSignal): Promise<VcsHistorySource>;
close(): Promise<void>;
}

Expand Down Expand Up @@ -95,24 +100,22 @@ export async function loadHistoryBootstrap({
}
const repoRoot = selectedDetection?.repoRoot ?? cwd;

const historyInput = {
...(input.revision ? { revision: input.revision } : {}),
...(input.all ? { all: true } : {}),
...(input.firstParent ? { firstParent: true } : {}),
...(input.maxCount !== undefined ? { maxCount: input.maxCount } : {}),
...(input.author !== undefined ? { author: input.author } : {}),
...(input.grep !== undefined ? { grep: input.grep } : {}),
...(input.since !== undefined ? { since: input.since } : {}),
...(input.until !== undefined ? { until: input.until } : {}),
...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}),
};
const openSource = (signal?: AbortSignal) =>
openVcsHistory(adapter, historyInput, { cwd: repoRoot, signal }, catalog);
let source: VcsHistorySource;
try {
source = await openVcsHistory(
adapter,
{
...(input.revision ? { revision: input.revision } : {}),
...(input.all ? { all: true } : {}),
...(input.firstParent ? { firstParent: true } : {}),
...(input.maxCount !== undefined ? { maxCount: input.maxCount } : {}),
...(input.author !== undefined ? { author: input.author } : {}),
...(input.grep !== undefined ? { grep: input.grep } : {}),
...(input.since !== undefined ? { since: input.since } : {}),
...(input.until !== undefined ? { until: input.until } : {}),
...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}),
},
{ cwd: repoRoot },
catalog,
);
source = await openSource();
emitExtensionEvent(resolved.extensions, "startup", { cwd });
} catch (error) {
await retireExtensionLoadResult(resolved.extensions);
Expand Down Expand Up @@ -141,8 +144,22 @@ export async function loadHistoryBootstrap({
]
: []),
],
planReview(commit) {
return planVcsHistoryReview(adapter, commit, { cwd: repoRoot });
planReview(commit, options) {
return planVcsHistoryReview(adapter, commit, { cwd: repoRoot }, options);
},
async reopenSource(signal) {
if (closed) throw new Error("History session is closed.");
signal?.throwIfAborted();
const previous = source;
const replacement = await openSource(signal);
if (closed || source !== previous || signal?.aborted) {
await replacement.close();
signal?.throwIfAborted();
throw new Error("History session changed while refreshing.");
}
source = replacement;
await previous.close();
return replacement;
},
async close() {
if (closed) return;
Expand Down
4 changes: 3 additions & 1 deletion src/core/vcs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ExtensionVcsHistoryCommit,
ExtensionVcsHistoryInput,
ExtensionVcsHistoryReviewAction,
ExtensionVcsHistoryReviewOptions,
} from "../../extension-api/types";
import type { CliInput } from "../run/commandInputs";
import type {
Expand Down Expand Up @@ -182,13 +183,14 @@ export async function planVcsHistoryReview(
adapter: VcsAdapter,
commit: ExtensionVcsHistoryCommit,
context: VcsLoadContext,
options?: ExtensionVcsHistoryReviewOptions,
): Promise<ExtensionVcsHistoryReviewAction> {
if (!adapter.history) {
throw new HunkUserError(`\`hunk log\` is not supported by ${adapter.name}.`, [
"Use a VCS adapter that implements history browsing.",
]);
}
return await adapter.history.planReview(commit, context);
return await adapter.history.planReview(commit, context, options);
}

/** Build an adapter event plan, falling back to signature polling. */
Expand Down
3 changes: 3 additions & 0 deletions src/core/vcs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ExtensionVcsHistoryInput,
ExtensionVcsHistoryPage,
ExtensionVcsHistoryReviewAction,
ExtensionVcsHistoryReviewOptions,
ExtensionVcsWatchPlan,
} from "../../extension-api/types";
import type { DiffFile } from "../changeset/model";
Expand All @@ -22,6 +23,7 @@ export interface VcsDetection {

export interface VcsLoadContext {
cwd: string;
signal?: AbortSignal;
}

export type VcsReviewInput = VcsDiffCommandInput | VcsShowCommandInput | VcsStashShowCommandInput;
Expand Down Expand Up @@ -57,6 +59,7 @@ export interface VcsHistoryCapability {
planReview(
commit: ExtensionVcsHistoryCommit,
context: VcsLoadContext,
options?: ExtensionVcsHistoryReviewOptions,
): Promise<ExtensionVcsHistoryReviewAction>;
}

Expand Down
1 change: 1 addition & 0 deletions src/extension-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export type {
ExtensionVcsHistoryInput,
ExtensionVcsHistoryPage,
ExtensionVcsHistoryReviewAction,
ExtensionVcsHistoryReviewOptions,
ExtensionVcsHistorySource,
ExtensionVcsLoadContext,
ExtensionVcsOperation,
Expand Down
7 changes: 7 additions & 0 deletions src/extension-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,12 @@ export interface ExtensionVcsHistorySource {
close(): void | Promise<void>;
}

/** Optional provider-neutral selection facts for reviewing one history item. */
export interface ExtensionVcsHistoryReviewOptions {
/** One ordered parent id returned on the commit, when the caller chooses a specific parent. */
parentRevisionId?: string;
}

/** A provider-owned declaration of how Hunk should review one history item. */
export type ExtensionVcsHistoryReviewAction =
| {
Expand Down Expand Up @@ -826,6 +832,7 @@ export interface ExtensionVcsHistoryCapability {
planReview(
commit: ExtensionVcsHistoryCommit,
context: ExtensionVcsLoadContext,
options?: ExtensionVcsHistoryReviewOptions,
): ExtensionVcsHistoryReviewAction | Promise<ExtensionVcsHistoryReviewAction>;
}

Expand Down
15 changes: 15 additions & 0 deletions src/extensions/default/vcs/git/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ describe("Git history production", () => {
fromRevisionId: "c".repeat(40),
toRevisionId: "b".repeat(40),
});
expect(
await history.planReview(
{
...root,
revisionId: "b".repeat(40),
parentRevisionIds: ["c".repeat(40), "d".repeat(40)],
},
undefined,
{ parentRevisionId: "d".repeat(40) },
),
).toEqual({
kind: "revision-range",
fromRevisionId: "d".repeat(40),
toRevisionId: "b".repeat(40),
});
});

test("rejects truncated records and invalid SHA object ids", () => {
Expand Down
11 changes: 7 additions & 4 deletions src/extensions/default/vcs/git/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,12 +284,15 @@ export function createGitVcsAdapter({
open(input, { cwd }) {
return openGitHistory(input, { cwd, gitExecutable });
},
planReview(commit) {
const firstParent = commit.parentRevisionIds[0];
return firstParent
planReview(commit, _context?: unknown, options?: { parentRevisionId?: string }) {
const parent = options?.parentRevisionId ?? commit.parentRevisionIds[0];
if (parent && !commit.parentRevisionIds.includes(parent)) {
throw new Error("The selected revision is not a parent of this Git commit.");
}
return parent
? {
kind: "revision-range" as const,
fromRevisionId: firstParent,
fromRevisionId: parent,
toRevisionId: commit.revisionId,
}
: { kind: "revision-show" as const, revisionId: commit.revisionId };
Expand Down
13 changes: 12 additions & 1 deletion src/extensions/default/vcs/jujutsu/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ describe("Jujutsu history production", () => {
expect(parseJjHistory(raw, true)[0]!.parentRevisionIds).toEqual(["b".repeat(40)]);
});

test("owns ordinary, merge, and root review semantics with revision-show", async () => {
test("owns default and explicitly selected parent review semantics", async () => {
const history = createJjVcsAdapter().history!;
for (const parentRevisionIds of [[], ["b".repeat(40)], ["b".repeat(40), "c".repeat(40)]]) {
const commit = {
Expand All @@ -188,6 +188,17 @@ describe("Jujutsu history production", () => {
kind: "revision-show",
revisionId: commit.revisionId,
});
if (parentRevisionIds[0]) {
expect(
await history.planReview(commit, undefined, {
parentRevisionId: parentRevisionIds[0],
}),
).toEqual({
kind: "revision-range",
fromRevisionId: parentRevisionIds[0],
toRevisionId: commit.revisionId,
});
}
}
});

Expand Down
14 changes: 12 additions & 2 deletions src/extensions/default/vcs/jujutsu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,18 @@ export function createJjVcsAdapter({ jjExecutable = "jj" }: Readonly<JjVcsAdapte
},
// JJ's single-revision diff compares ordinary commits with their parent,
// merges with their merged-parent tree, and first commits with the root.
planReview(commit) {
return { kind: "revision-show" as const, revisionId: commit.revisionId };
planReview(commit, _context?: unknown, options?: { parentRevisionId?: string }) {
const parent = options?.parentRevisionId;
if (parent && !commit.parentRevisionIds.includes(parent)) {
throw new Error("The selected revision is not a parent of this Jujutsu commit.");
}
return parent
? {
kind: "revision-range" as const,
fromRevisionId: parent,
toRevisionId: commit.revisionId,
}
: { kind: "revision-show" as const, revisionId: commit.revisionId };
},
},
operations: {
Expand Down
Loading
Loading