From 8659e5e3b5780ba7172ce1a88c5ed23c64edf513 Mon Sep 17 00:00:00 2001 From: gshahbazian Date: Thu, 10 Sep 2026 22:17:51 -0700 Subject: [PATCH] feat(log): copy GitHub pull request URLs from history Add Y in hunk log to copy a PR URL when the selected commit is a GitHub merge or squash and origin is github.com. --- .changeset/copy-history-pr.md | 5 + docs/keybindings.md | 3 +- packages/hunk-git/src/history.test.ts | 46 +++++++++ packages/hunk-git/src/history.ts | 19 +++- packages/hunk-git/src/pullRequest.test.ts | 77 +++++++++++++++ packages/hunk-git/src/pullRequest.ts | 93 +++++++++++++++++++ .../src/core/history/pullRequestUrl.test.ts | 38 ++++++++ .../hunk/src/core/history/pullRequestUrl.ts | 15 +++ .../src/core/run/historyCommandCatalog.ts | 10 ++ packages/hunk/src/extension-api/types.ts | 7 ++ .../hunk/src/extensions/runExtension.test.ts | 50 ++++++++++ packages/hunk/src/extensions/runExtension.ts | 38 ++++++++ packages/hunk/src/ui/log/LogApp.tsx | 30 +++++- packages/hunk/src/ui/log/commands.test.ts | 3 + packages/hunk/src/ui/log/commands.ts | 1 + 15 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 .changeset/copy-history-pr.md create mode 100644 packages/hunk-git/src/pullRequest.test.ts create mode 100644 packages/hunk-git/src/pullRequest.ts create mode 100644 packages/hunk/src/core/history/pullRequestUrl.test.ts create mode 100644 packages/hunk/src/core/history/pullRequestUrl.ts diff --git a/.changeset/copy-history-pr.md b/.changeset/copy-history-pr.md new file mode 100644 index 000000000..2697f5728 --- /dev/null +++ b/.changeset/copy-history-pr.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Copy a GitHub pull request URL from interactive `hunk log` with `Y` when history can derive one. diff --git a/docs/keybindings.md b/docs/keybindings.md index a2674d1bd..41506c440 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -62,7 +62,7 @@ extend a contiguous commit selection with those same keys. `Shift+Up`/`Shift+Dow directly, and `Escape` collapses the selection. `PageUp`/`PageDown`, `b`/`f`, or `Shift+Space`/`Space` page through history; `u`/`d` or `Ctrl-U`/`Ctrl-D` move by half a page; `g`/`G` or `Home`/`End` jump, `/` to search, `n`/`N` for matches, `t` to choose a theme, `r` to refresh, `y` to copy the focused commit's full id, -`Enter` to open the selection in normal Hunk review, and `q` or `Ctrl-C` to quit. With a mouse, Shift-click a row to extend the +`Y` to copy a GitHub pull request URL when history can derive one, `Enter` to open the selection in normal Hunk review, and `q` or `Ctrl-C` to quit. With a mouse, Shift-click a row to extend the selection when the terminal forwards modifiers, click a commit id to open it immediately, click the adjacent copy icon to copy its full immutable id, click elsewhere on a row to select it, or double-click a row to open it. Range selection is unavailable with `--all` or author, message, date, and path filters because those traversals @@ -76,6 +76,7 @@ History-specific commands: | ----------------------------------- | ---------------------------- | ---------------------------- | | `hunk.history.openSelection` | Open the selected commit(s) | `enter` | | `hunk.history.copyRevision` | Copy the focused commit id | `y` | +| `hunk.history.copyPullRequest` | Copy the pull request URL | `Y` | | `hunk.history.refresh` | Refresh repository history | `r` | | `hunk.history.previousCommit` | Move to the previous commit | `up`, `k` | | `hunk.history.nextCommit` | Move to the next commit | `down`, `j` | diff --git a/packages/hunk-git/src/history.test.ts b/packages/hunk-git/src/history.test.ts index 37af11c41..07c037678 100644 --- a/packages/hunk-git/src/history.test.ts +++ b/packages/hunk-git/src/history.test.ts @@ -53,6 +53,52 @@ describe("Git history production", () => { ); }); + test("attaches GitHub pull-request URLs from origin and local commit facts", async () => { + const repo = mkdtempSync(join(tmpdir(), "hunk-git-history-pr-")); + const git = (...args: string[]) => { + const result = Bun.spawnSync(["git", ...args], { + cwd: repo, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); + return result.stdout.toString().trim(); + }; + try { + git("init", "--quiet"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("remote", "add", "origin", "https://github.com/modem-dev/hunk.git"); + writeFileSync(join(repo, "root.txt"), "root\n"); + git("add", "root.txt"); + git("commit", "--quiet", "-m", "root"); + writeFileSync(join(repo, "merge.txt"), "merge\n"); + git("add", "merge.txt"); + git("commit", "--quiet", "-m", "Merge pull request #42 from octocat/patch"); + writeFileSync(join(repo, "squash.txt"), "squash\n"); + git("add", "squash.txt"); + git("commit", "--quiet", "-m", "Fix the parser (#7)"); + + const source = await createGitVcsAdapter().history!.open({}, { cwd: repo }); + try { + const page = await source.read({ limit: 8 }); + const bySubject = new Map(page.commits.map((entry) => [entry.subject, entry])); + expect(bySubject.get("Fix the parser (#7)")?.pullRequestUrl).toBe( + "https://github.com/modem-dev/hunk/pull/7", + ); + expect(bySubject.get("Merge pull request #42 from octocat/patch")?.pullRequestUrl).toBe( + "https://github.com/modem-dev/hunk/pull/42", + ); + expect(bySubject.get("root")?.pullRequestUrl).toBeUndefined(); + } finally { + await source.close(); + } + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + test("parses NUL-delimited commits and copies structured decorations", () => { const decorations = new Map([["a".repeat(40), [{ kind: "head" as const, label: "HEAD" }]]]); const text = [ diff --git a/packages/hunk-git/src/history.ts b/packages/hunk-git/src/history.ts index 477473b87..5297efc94 100644 --- a/packages/hunk-git/src/history.ts +++ b/packages/hunk-git/src/history.ts @@ -11,6 +11,11 @@ import { type ExtensionVcsHistoryReviewOptions, type ExtensionVcsHistorySource, } from "hunkdiff/extension"; +import { + gitHistoryPullRequestUrl, + parseGitHubRemoteRepository, + type GitHubRepository, +} from "./pullRequest"; const HISTORY_FIELDS_PER_COMMIT = 8; const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; @@ -142,6 +147,12 @@ function hasHead(options: GitHistoryOptions) { return runGit(["rev-parse", "--verify", "--quiet", "HEAD"], options, [0, 1]).trim().length > 0; } +/** Read origin when it names a github.com repository; missing remotes stay silent. */ +function readGitHubRepository(options: GitHistoryOptions) { + const origin = runGit(["remote", "get-url", "origin"], options, [0, 2, 128]).trim(); + return parseGitHubRemoteRepository(origin); +} + /** Refuse a positional revision that Git could reinterpret as an option. */ function requireRevision(value: string) { if (!value || value.startsWith("-")) { @@ -250,6 +261,7 @@ export function parseGitHistory( decorations: ReadonlyMap = new Map(), firstParent = false, omitGraphParents = false, + githubRepository?: GitHubRepository | null, ): ExtensionVcsHistoryCommit[] { if (!text) return []; const fields = text.split("\0"); @@ -280,17 +292,20 @@ export function parseGitHistory( ) { throw new Error("Git returned an invalid history object id."); } + const resolvedSubject = subject || "(no commit message)"; + const pullRequestUrl = gitHistoryPullRequestUrl({ subject: resolvedSubject }, githubRepository); commits.push({ revisionId, displayId, parentRevisionIds, ...(omitGraphParents ? { graphParentRevisionIds: [] } : {}), - subject: subject || "(no commit message)", + subject: resolvedSubject, ...(body ? { body } : {}), authorName: authorName || "Unknown author", ...(authorEmail ? { authorEmail } : {}), authoredAt, decorations: [...(decorations.get(revisionId) ?? [])], + ...(pullRequestUrl ? { pullRequestUrl } : {}), }); } return commits; @@ -353,6 +368,7 @@ export function openGitHistory( const decorations = readDecorations(queryOptions); const omitGraphParents = gitHistoryUsesBoundaryTopology(input); + const githubRepository = readGitHubRepository(queryOptions); let child: ReturnType; try { @@ -396,6 +412,7 @@ export function openGitHistory( decorations, input.firstParent, omitGraphParents, + githubRepository, ), ); fields.length = 0; diff --git a/packages/hunk-git/src/pullRequest.test.ts b/packages/hunk-git/src/pullRequest.test.ts new file mode 100644 index 000000000..b5a59d088 --- /dev/null +++ b/packages/hunk-git/src/pullRequest.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import type { ExtensionVcsHistoryCommit } from "hunkdiff/extension"; +import { gitHistoryPullRequestUrl, parseGitHubRemoteRepository } from "./pullRequest"; + +const repository = { owner: "modem-dev", repo: "hunk" }; + +const commit = (overrides: Partial = {}): ExtensionVcsHistoryCommit => ({ + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + subject: "Fix the parser", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + ...overrides, +}); + +describe("GitHub remote parsing", () => { + test("parses common github.com remote forms", () => { + for (const remote of [ + "https://github.com/modem-dev/hunk.git", + "https://github.com/modem-dev/hunk", + "ssh://git@github.com/modem-dev/hunk.git", + "git://github.com/modem-dev/hunk.git", + "git@github.com:modem-dev/hunk.git", + "git@github.com:modem-dev/hunk", + ]) { + expect(parseGitHubRemoteRepository(remote)).toEqual(repository); + } + }); + + test("rejects non-GitHub and malformed remotes", () => { + for (const remote of [ + "", + "https://gitlab.com/modem-dev/hunk.git", + "https://github.com/modem-dev/hunk/extra.git", + "git@github.com:modem-dev.git", + "git@github.com:./hunk.git", + "not a remote", + ]) { + expect(parseGitHubRemoteRepository(remote)).toBeNull(); + } + }); +}); + +describe("Git history pull-request URLs", () => { + test("builds merge and squash URLs from origin", () => { + expect( + gitHistoryPullRequestUrl( + commit({ subject: "Merge pull request #42 from octocat/patch" }), + repository, + ), + ).toBe("https://github.com/modem-dev/hunk/pull/42"); + expect(gitHistoryPullRequestUrl(commit({ subject: "Fix the parser (#7)" }), repository)).toBe( + "https://github.com/modem-dev/hunk/pull/7", + ); + }); + + test("does not invent a URL without a github.com origin or a merge/squash subject", () => { + expect( + gitHistoryPullRequestUrl(commit({ subject: "Merge pull request #42 from octocat/patch" })), + ).toBeUndefined(); + expect(gitHistoryPullRequestUrl(commit(), repository)).toBeUndefined(); + expect( + gitHistoryPullRequestUrl( + commit({ + subject: "Explain the change", + body: "See https://github.com/acme/tools/pull/9", + }), + repository, + ), + ).toBeUndefined(); + expect( + gitHistoryPullRequestUrl(commit({ subject: "Fix handling (#2 in series)" }), repository), + ).toBeUndefined(); + }); +}); diff --git a/packages/hunk-git/src/pullRequest.ts b/packages/hunk-git/src/pullRequest.ts new file mode 100644 index 000000000..575c76642 --- /dev/null +++ b/packages/hunk-git/src/pullRequest.ts @@ -0,0 +1,93 @@ +import type { ExtensionVcsHistoryCommit } from "hunkdiff/extension"; + +const REPOSITORY_PART = /^[A-Za-z0-9_.-]+$/; +const MERGE_PULL_REQUEST = /^Merge pull request #([1-9]\d*)\b/i; +const SQUASH_PULL_REQUEST = /\(#([1-9]\d*)\)$/; + +export interface GitHubRepository { + owner: string; + repo: string; +} + +/** Validate and normalize one owner/repository pair. */ +function parseGitHubRepository(value: string): GitHubRepository | null { + const parts = value.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; + if ( + !REPOSITORY_PART.test(parts[0]) || + !REPOSITORY_PART.test(parts[1]) || + parts[0] === "." || + parts[0] === ".." || + parts[1] === "." || + parts[1] === ".." + ) { + return null; + } + return { owner: parts[0], repo: parts[1] }; +} + +/** Accept a GitHub pull-request number that fits in a safe integer. */ +function parsePullRequestNumber(value: string): string | undefined { + if (!/^[1-9]\d*$/.test(value)) return undefined; + if (!Number.isSafeInteger(Number(value))) return undefined; + return value; +} + +/** Parse a github.com remote URL into its owner and repository. */ +export function parseGitHubRemoteRepository(value: string): GitHubRepository | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + const scp = /^(?:[^@\s]+@)?github\.com:([^/\s]+\/[^/\s]+)$/i.exec(trimmed); + let repositoryPath: string | undefined; + if (scp) { + repositoryPath = scp[1]; + } else { + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + if ( + !["https:", "ssh:", "git:"].includes(url.protocol) || + url.hostname.toLowerCase() !== "github.com" || + url.port || + url.search || + url.hash + ) { + return null; + } + repositoryPath = url.pathname.replace(/^\//, ""); + } + + if (!repositoryPath) return null; + return parseGitHubRepository(repositoryPath.replace(/\.git$/i, "")); +} + +/** Extract a pull-request number from a GitHub merge or squash subject. */ +function pullRequestNumberFromSubject(subject: string): string | undefined { + const trimmed = subject.trim(); + const merge = MERGE_PULL_REQUEST.exec(trimmed); + if (merge) return parsePullRequestNumber(merge[1]!); + + const squash = SQUASH_PULL_REQUEST.exec(trimmed); + if (squash) return parsePullRequestNumber(squash[1]!); + return undefined; +} + +/** + * Derive a GitHub pull-request URL from a merge or squash subject and origin. + * + * GitHub merge commits start with `Merge pull request #N from`. Squash merges + * end the subject with `(#N)`. Both need a github.com origin to build a URL. + */ +export function gitHistoryPullRequestUrl( + commit: Pick, + repository?: GitHubRepository | null, +): string | undefined { + if (!repository) return undefined; + const number = pullRequestNumberFromSubject(commit.subject); + if (!number) return undefined; + return `https://github.com/${repository.owner}/${repository.repo}/pull/${number}`; +} diff --git a/packages/hunk/src/core/history/pullRequestUrl.test.ts b/packages/hunk/src/core/history/pullRequestUrl.test.ts new file mode 100644 index 000000000..645836c5d --- /dev/null +++ b/packages/hunk/src/core/history/pullRequestUrl.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import type { ExtensionVcsHistoryCommit } from "../../extension-api/types"; +import { historyPullRequestCopyNotice, resolveHistoryPullRequestUrl } from "./pullRequestUrl"; + +const commit = (overrides: Partial = {}): ExtensionVcsHistoryCommit => ({ + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + subject: "Fix the parser", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + ...overrides, +}); + +describe("history pull-request URLs", () => { + test("copies a provider-supplied pull-request URL", () => { + expect( + resolveHistoryPullRequestUrl( + commit({ pullRequestUrl: "https://github.com/modem-dev/hunk/pull/42" }), + ), + ).toBe("https://github.com/modem-dev/hunk/pull/42"); + expect( + resolveHistoryPullRequestUrl( + commit({ subject: "Merge pull request #42 from octocat/patch" }), + ), + ).toBeUndefined(); + }); + + test("names the copied pull request when the URL ends with its number", () => { + expect(historyPullRequestCopyNotice("https://github.com/modem-dev/hunk/pull/42")).toBe( + "Copied pull request #42", + ); + expect(historyPullRequestCopyNotice("https://example.com/review")).toBe( + "Copied pull request URL", + ); + }); +}); diff --git a/packages/hunk/src/core/history/pullRequestUrl.ts b/packages/hunk/src/core/history/pullRequestUrl.ts new file mode 100644 index 000000000..d8302fe38 --- /dev/null +++ b/packages/hunk/src/core/history/pullRequestUrl.ts @@ -0,0 +1,15 @@ +import type { ExtensionVcsHistoryCommit } from "../../extension-api/types"; + +/** Return a copyable pull-request URL from provider metadata. */ +export function resolveHistoryPullRequestUrl( + commit: ExtensionVcsHistoryCommit, +): string | undefined { + return commit.pullRequestUrl; +} + +/** Format the status-bar confirmation for one copied pull-request URL. */ +export function historyPullRequestCopyNotice(url: string): string { + const match = /\/pull\/(\d+)$/.exec(url); + if (!match) return "Copied pull request URL"; + return `Copied pull request #${match[1]}`; +} diff --git a/packages/hunk/src/core/run/historyCommandCatalog.ts b/packages/hunk/src/core/run/historyCommandCatalog.ts index 2534486b6..66f9bd885 100644 --- a/packages/hunk/src/core/run/historyCommandCatalog.ts +++ b/packages/hunk/src/core/run/historyCommandCatalog.ts @@ -55,6 +55,16 @@ const HISTORY_COMMANDS = [ group: "file", helpSection: "Commit", }, + { + id: "hunk.history.copyPullRequest", + title: "Copy pull request URL", + category: "history", + defaultKeys: ["Y"], + locus: "client-local", + publicToExtensions: false, + group: "file", + helpSection: "Commit", + }, { id: "hunk.history.refresh", title: "Refresh history", diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index e014f47e5..cb9807f38 100644 --- a/packages/hunk/src/extension-api/types.ts +++ b/packages/hunk/src/extension-api/types.ts @@ -769,6 +769,13 @@ export interface ExtensionVcsHistoryCommit { * continues to key graph and review operations by the immutable `revisionId`. */ logicalId?: string; + /** + * Optional web URL of a pull request or merge request associated with this commit. + * + * Providers may omit this when local history facts cannot name one. Hunk copies + * the value after https URL validation. + */ + pullRequestUrl?: string; } /** Provider-neutral history traversal accepted by `hunk log`. */ diff --git a/packages/hunk/src/extensions/runExtension.test.ts b/packages/hunk/src/extensions/runExtension.test.ts index 2e9df1191..06cccf235 100644 --- a/packages/hunk/src/extensions/runExtension.test.ts +++ b/packages/hunk/src/extensions/runExtension.test.ts @@ -1143,6 +1143,56 @@ describe("toInternalVcsAdapter history boundary", () => { ); }); + test("copies https pull-request URLs and rejects unsafe values", async () => { + const validCommit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "Safe subject", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const readCommit = async (commit: typeof validCommit & { pullRequestUrl?: string }) => { + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [commit], done: true }), + close() {}, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + return adapter + .history!.open({}, { cwd: "/repo" }) + .then((source) => source.read({ limit: 1 })); + }; + + const page = await readCommit({ + ...validCommit, + pullRequestUrl: "https://github.com/modem-dev/hunk/pull/42", + }); + expect(page.commits[0]?.pullRequestUrl).toBe("https://github.com/modem-dev/hunk/pull/42"); + + for (const pullRequestUrl of [ + "http://github.com/modem-dev/hunk/pull/42", + "https://user@github.com/modem-dev/hunk/pull/42", + "https://github.com/modem-dev/hunk/pull/42?foo=1", + "javascript:alert(1)", + "https://github.com/modem-dev/hunk/pull/42\x1b]52;c;cHdu\x07", + ]) { + await expect(readCommit({ ...validCommit, pullRequestUrl })).rejects.toThrow( + "pullRequestUrl", + ); + } + }); + test("snapshots source, page, commit, and decoration accessors exactly once", async () => { const reads = { sourceRead: 0, commits: 0, subject: 0, label: 0 }; const commit = { diff --git a/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 714bf1aa2..eab2c3188 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -127,6 +127,41 @@ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +const HISTORY_PULL_REQUEST_URL_MAX_BYTES = 2 * 1024; + +/** Copy a provider-owned pull-request URL after refusing unsafe or oversized values. */ +function normalizeHistoryPullRequestUrl(value: unknown): string | undefined { + if (value === undefined) return undefined; + const text = assertNonEmptyString( + value, + "VCS history commit pullRequestUrl must be a non-empty string.", + ); + if (sanitizeTerminalLine(text) !== text || text.includes("\t")) { + throw new Error("VCS history commit pullRequestUrl must be a terminal-safe URL."); + } + if (new TextEncoder().encode(text).byteLength > HISTORY_PULL_REQUEST_URL_MAX_BYTES) { + throw new Error("VCS history commit pullRequestUrl exceeds its byte limit."); + } + let url: URL; + try { + url = new URL(text); + } catch { + throw new Error("VCS history commit pullRequestUrl must be an https URL."); + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.port || + url.search || + url.hash || + url.href !== text + ) { + throw new Error("VCS history commit pullRequestUrl must be an unmodified https URL."); + } + return text; +} + /** Report whether one value is promise-like, so async factories can be awaited. */ function isThenable(value: unknown): value is Promise { return typeof (value as Promise | undefined)?.then === "function"; @@ -217,6 +252,7 @@ function normalizeHistoryCommit(value: unknown): ExtensionVcsHistoryCommit { "authoredAt", "decorations", "logicalId", + "pullRequestUrl", ]); const required = (key: string) => assertNonEmptyString(snapshot[key], `VCS history commit ${key} must be a non-empty string.`); @@ -237,6 +273,7 @@ function normalizeHistoryCommit(value: unknown): ExtensionVcsHistoryCommit { const revisionId = safeRevision(snapshot.revisionId, "VCS history commit revisionId"); const displayId = safeDisplay("displayId"); const authoredAt = required("authoredAt"); + const pullRequestUrl = normalizeHistoryPullRequestUrl(snapshot.pullRequestUrl); if ( !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(authoredAt) || Number.isNaN(Date.parse(authoredAt)) @@ -321,6 +358,7 @@ function normalizeHistoryCommit(value: unknown): ExtensionVcsHistoryCommit { ...(typeof snapshot.logicalId === "string" ? { logicalId: sanitizeTerminalLine(snapshot.logicalId).replaceAll("\t", " ") } : {}), + ...(pullRequestUrl ? { pullRequestUrl } : {}), }; } diff --git a/packages/hunk/src/ui/log/LogApp.tsx b/packages/hunk/src/ui/log/LogApp.tsx index e7786c201..5c9bb13df 100644 --- a/packages/hunk/src/ui/log/LogApp.tsx +++ b/packages/hunk/src/ui/log/LogApp.tsx @@ -2,6 +2,10 @@ import type { KeyEvent, MouseEvent as TuiMouseEvent } from "@opentui/core"; import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; import { basename } from "node:path"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { + historyPullRequestCopyNotice, + resolveHistoryPullRequestUrl, +} from "../../core/history/pullRequestUrl"; import { APP_COMMAND_NAMES } from "../../core/run/commandCatalog"; import type { PersistedViewPreferences } from "../../core/run/config"; import type { @@ -154,15 +158,28 @@ export function LogApp({ quitScheduler, }); + const copyToClipboard = (text: string, copiedNotice: string) => { + if (renderer.isOsc52Supported?.() && typeof renderer.copyToClipboardOSC52 === "function") { + renderer.copyToClipboardOSC52(text); + setTransientNotice(copiedNotice); + return; + } + setTransientNotice("Clipboard is unavailable in this terminal."); + }; const copySelected = (row = controller.getSelectedRow()) => { const currentRow = row; if (!currentRow) return; - if (renderer.isOsc52Supported?.() && typeof renderer.copyToClipboardOSC52 === "function") { - renderer.copyToClipboardOSC52(currentRow.commit.revisionId); - setTransientNotice(`Copied ${currentRow.commit.displayId}`); - } else { - setTransientNotice("Clipboard is unavailable in this terminal."); + copyToClipboard(currentRow.commit.revisionId, `Copied ${currentRow.commit.displayId}`); + }; + const copySelectedPullRequest = (row = controller.getSelectedRow()) => { + const currentRow = row; + if (!currentRow) return; + const url = resolveHistoryPullRequestUrl(currentRow.commit); + if (!url) { + setTransientNotice("No GitHub pull request found for this commit."); + return; } + copyToClipboard(url, historyPullRequestCopyNotice(url)); }; const openSelected = async (parentRevisionId?: string, pending = false) => { if (reviewPending.current && !pending) return; @@ -277,6 +294,7 @@ export function LogApp({ const commandHandlers: HistoryCommandHandlers = { "hunk.history.openSelection": () => void requestOpenSelected(), "hunk.history.copyRevision": () => copySelected(), + "hunk.history.copyPullRequest": () => copySelectedPullRequest(), "hunk.history.refresh": () => void controller.refresh(), "hunk.app.quit": (key) => requestLogQuit(key.ctrl && key.name === "c" ? 130 : undefined), "hunk.view.openThemeSelector": () => themeSelector.openThemeSelector(), @@ -359,6 +377,7 @@ export function LogApp({ file: [ commandItem("hunk.history.openSelection"), commandItem("hunk.history.copyRevision"), + commandItem("hunk.history.copyPullRequest"), commandItem("hunk.history.refresh"), { kind: "separator" }, commandItem("hunk.app.quit"), @@ -395,6 +414,7 @@ export function LogApp({ commit: [ commandItem("hunk.history.openSelection"), commandItem("hunk.history.copyRevision"), + commandItem("hunk.history.copyPullRequest"), { kind: "separator" }, commandItem("hunk.history.openFirstParent"), commandItem("hunk.history.openParent"), diff --git a/packages/hunk/src/ui/log/commands.test.ts b/packages/hunk/src/ui/log/commands.test.ts index b8ac577ee..c1724dc78 100644 --- a/packages/hunk/src/ui/log/commands.test.ts +++ b/packages/hunk/src/ui/log/commands.test.ts @@ -82,6 +82,8 @@ describe("history command authority", () => { expect(matchCommand(key("up", "", false, true))).toBe("hunk.history.extendPrevious"); expect(matchCommand(key("x", "K"))).toBe("hunk.history.extendPrevious"); expect(matchCommand(key("x", "j"))).toBe("hunk.history.nextCommit"); + expect(matchCommand(key("x", "y"))).toBe("hunk.history.copyRevision"); + expect(matchCommand(key("x", "Y"))).toBe("hunk.history.copyPullRequest"); expect(matchCommand(key("x", "v"))).toBe("hunk.history.startVisualSelection"); expect( matchCommand(key("escape", ""), undefined, { @@ -123,6 +125,7 @@ describe("history command authority", () => { description: "extend selection up", }); expect(helpRows).toContainEqual({ keys: "t", description: "choose theme" }); + expect(helpRows).toContainEqual({ keys: "Y", description: "copy pull request url" }); }); test("remaps and unbinds history independently through the shared keymap", () => { diff --git a/packages/hunk/src/ui/log/commands.ts b/packages/hunk/src/ui/log/commands.ts index b73c323bb..76b8f6742 100644 --- a/packages/hunk/src/ui/log/commands.ts +++ b/packages/hunk/src/ui/log/commands.ts @@ -40,6 +40,7 @@ export function isHistoryCommandEnabled(id: HistoryCommandId, snapshot: LogSnaps if ( id === "hunk.history.openSelection" || id === "hunk.history.copyRevision" || + id === "hunk.history.copyPullRequest" || id === "hunk.history.startVisualSelection" ) return Boolean(selected);