diff --git a/.changeset/copy-url-osc52.md b/.changeset/copy-url-osc52.md new file mode 100644 index 0000000..300e12e --- /dev/null +++ b/.changeset/copy-url-osc52.md @@ -0,0 +1,5 @@ +--- +"@kitlangton/ghui": patch +--- + +Add a shortcut and command palette action for copying the selected pull request URL, plus an OSC52 clipboard fallback for terminals that support it. diff --git a/README.md b/README.md index dd27453..ae87665 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ running. - `l`: manage labels - `o`: open PR in browser - `y`: copy PR metadata +- `shift-y`: copy PR URL - `q`: quit Review submission: diff --git a/src/App.tsx b/src/App.tsx index c4fdbad..f2a699b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2419,7 +2419,14 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const copySelectedPullRequestMetadata = () => { if (!selectedPullRequest) return void copyToClipboard(pullRequestMetadataText(selectedPullRequest)) - .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`)) + .then((result) => flashNotice(result === "sent-osc52" ? `Sent OSC52 copy for #${selectedPullRequest.number} metadata` : `Copied #${selectedPullRequest.number} metadata`)) + .catch((error) => flashNotice(errorMessage(error))) + } + + const copySelectedPullRequestUrl = () => { + if (!selectedPullRequest) return + void copyToClipboard(selectedPullRequest.url) + .then((result) => flashNotice(result === "sent-osc52" ? `Sent OSC52 copy for #${selectedPullRequest.number} URL` : `Copied #${selectedPullRequest.number} URL`)) .catch((error) => flashNotice(errorMessage(error))) } @@ -3003,6 +3010,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { if (selectedPullRequest) openSelectedPullRequestInBrowser(selectedPullRequest) }, copyPullRequestMetadata: copySelectedPullRequestMetadata, + copyPullRequestUrl: copySelectedPullRequestUrl, quit: () => renderer.destroy(), }, }) @@ -3335,6 +3343,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { refresh: () => runCommandById("pull.refresh"), openInBrowser: () => runCommandById("pull.open-browser"), copyMetadata: () => runCommandById("pull.copy-metadata"), + copyUrl: () => runCommandById("pull.copy-url"), }, commentsView: { halfPage, diff --git a/src/appCommands.ts b/src/appCommands.ts index abb6557..54b0aba 100644 --- a/src/appCommands.ts +++ b/src/appCommands.ts @@ -40,6 +40,7 @@ interface AppCommandActions { readonly openCloseModal: () => void readonly openPullRequestInBrowser: () => void readonly copyPullRequestMetadata: () => void + readonly copyPullRequestUrl: () => void readonly quit: () => void } @@ -445,6 +446,14 @@ export const buildAppCommands = ({ keywords: ["clipboard", "url", "title"], run: actions.copyPullRequestMetadata, }), + forSelected({ + id: "pull.copy-url", + title: "Copy pull request URL", + scope: "Pull request", + shortcut: "shift-y", + keywords: ["clipboard", "url", "link"], + run: actions.copyPullRequestUrl, + }), defineCommand({ id: "app.quit", title: "Quit ghui", diff --git a/src/keymap/detailView.ts b/src/keymap/detailView.ts index 8840375..aa6df25 100644 --- a/src/keymap/detailView.ts +++ b/src/keymap/detailView.ts @@ -13,6 +13,7 @@ export interface DetailViewCtx extends Scrollable { readonly refresh: () => void readonly openInBrowser: () => void readonly copyMetadata: () => void + readonly copyUrl: () => void } const Detail = context() @@ -31,4 +32,5 @@ export const detailViewKeymap = Detail( { id: "detail.refresh", title: "Refresh", keys: ["r"], run: (s) => s.refresh() }, { id: "detail.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.openInBrowser() }, { id: "detail.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.copyMetadata() }, + { id: "detail.copy-url", title: "Copy URL", keys: ["shift+y"], run: (s) => s.copyUrl() }, ) diff --git a/src/keymap/listNav.ts b/src/keymap/listNav.ts index 9307fb0..9bfe5d6 100644 --- a/src/keymap/listNav.ts +++ b/src/keymap/listNav.ts @@ -37,6 +37,7 @@ export const listNavKeymap = List( { id: "list.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.runCommandById("pull.open-browser") }, { id: "list.toggle-draft", title: "Toggle draft", keys: ["s", "shift+s"], run: (s) => s.runCommandById("pull.toggle-draft") }, { id: "list.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.runCommandById("pull.copy-metadata") }, + { id: "list.copy-url", title: "Copy URL", keys: ["shift+y"], run: (s) => s.runCommandById("pull.copy-url") }, { id: "list.detail.open", title: "Open details", keys: ["return"], run: (s) => s.runCommandById("detail.open") }, // Queue mode tabs diff --git a/src/services/Clipboard.ts b/src/services/Clipboard.ts index 4d79542..f092c07 100644 --- a/src/services/Clipboard.ts +++ b/src/services/Clipboard.ts @@ -12,13 +12,53 @@ const clipboardCommands: readonly (readonly [string, ...(readonly string[])])[] ? [...(process.env.WAYLAND_DISPLAY ? [["wl-copy"] as const] : []), ["xclip", "-selection", "clipboard"] as const, ["xsel", "--clipboard", "--input"] as const] : [] -const installHint = process.platform === "linux" ? " Install wl-clipboard, xclip, or xsel." : "" -const unavailableDetail = `Clipboard is not available.${installHint}` +export type ClipboardCopyResult = "copied" | "sent-osc52" + +const unknownErrorMessage = (error: unknown) => { + if (error instanceof Error && error.message.length > 0) return error.message + return String(error) +} + +const errorDetail = (error: unknown) => { + if (typeof error === "object" && error !== null) { + const detail = "detail" in error ? (error as { readonly detail?: unknown }).detail : undefined + if (typeof detail === "string" && detail.length > 0) return detail + const message = "message" in error ? (error as { readonly message?: unknown }).message : undefined + if (typeof message === "string" && message.length > 0) return message + } + return unknownErrorMessage(error) +} + +const commandLabel = (command: string, args: readonly string[]) => [command, ...args].join(" ") + +const osc52FailureDetail = (cause: unknown, commandFailures: readonly string[]) => { + const osc52Detail = `Failed to send OSC52 clipboard sequence: ${unknownErrorMessage(cause)}` + if (commandFailures.length === 0) return osc52Detail + return `Clipboard tools failed (${commandFailures.join("; ")}); ${osc52Detail}` +} + +const copyWithOsc52 = (text: string, commandFailures: readonly string[]) => + Effect.try({ + try() { + writeOsc52(text) + return "sent-osc52" as const + }, + catch: (cause) => new ClipboardError({ detail: osc52FailureDetail(cause, commandFailures) }), + }) + +const writeOsc52 = (text: string) => { + const sequence = `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07` + if (process.env.TMUX) { + process.stdout.write(`\x1bPtmux;${sequence.split("\x1b").join("\x1b\x1b")}\x1b\\`) + return + } + process.stdout.write(sequence) +} export class Clipboard extends Context.Service< Clipboard, { - readonly copy: (text: string) => Effect.Effect + readonly copy: (text: string) => Effect.Effect } >()("ghui/Clipboard") { static readonly layerNoDeps = Layer.effect( @@ -28,15 +68,16 @@ export class Clipboard extends Context.Service< const copy = Effect.fn("Clipboard.copy")(function* (text: string) { if (clipboardCommands.length === 0) { - return yield* new ClipboardError({ detail: unavailableDetail }) + return yield* copyWithOsc52(text, []) } - let lastDetail = "" + const commandFailures: string[] = [] for (const [cmd, ...args] of clipboardCommands) { const result = yield* command.run(cmd, args, { stdin: text }).pipe(Effect.result) - if (result._tag === "Success") return - lastDetail = result.failure.detail + if (result._tag === "Success") return "copied" + const failure = errorDetail(result.failure) + commandFailures.push(`${commandLabel(cmd, args)}: ${failure}`) } - return yield* new ClipboardError({ detail: lastDetail || unavailableDetail }) + return yield* copyWithOsc52(text, commandFailures) }) return Clipboard.of({ copy }) diff --git a/test/appCommands.test.ts b/test/appCommands.test.ts index cbd5afc..aeca548 100644 --- a/test/appCommands.test.ts +++ b/test/appCommands.test.ts @@ -7,6 +7,7 @@ const selectedPullRequest: PullRequestItem = { repository: "owner/repo", author: "kit", headRefOid: "abc123", + headRefName: "feat/review-ux", number: 42, title: "Review UX", body: "", @@ -91,6 +92,7 @@ const buildCommands = (overrides: Partial[0] openCloseModal: noop, openPullRequestInBrowser: noop, copyPullRequestMetadata: noop, + copyPullRequestUrl: noop, quit: noop, }, ...overrides, diff --git a/test/cacheService.test.ts b/test/cacheService.test.ts index b1df32f..0d0aae0 100644 --- a/test/cacheService.test.ts +++ b/test/cacheService.test.ts @@ -23,29 +23,32 @@ const tempCachePath = async () => { const view: PullRequestView = { _tag: "Queue", mode: "authored", repository: null } -const pullRequest = (number: number, overrides: Partial = {}): PullRequestItem => ({ - repository: "owner/repo", - author: "author", - headRefOid: `sha-${number}`, - number, - title: `PR ${number}`, - body: "Body", - labels: [{ name: "bug", color: "#d73a4a" }], - additions: 10, - deletions: 2, - changedFiles: 3, - state: "open", - reviewStatus: "none", - checkStatus: "passing", - checkSummary: "1/1", - checks: [{ name: "ci", status: "completed", conclusion: "success" }], - autoMergeEnabled: false, - detailLoaded: true, - createdAt: new Date(`2026-01-${String(number).padStart(2, "0")}T00:00:00Z`), - closedAt: null, - url: `https://github.com/owner/repo/pull/${number}`, - ...overrides, -}) +const pullRequest = (number: number, overrides: Partial = {}): PullRequestItem => { + const base: PullRequestItem = { + repository: "owner/repo", + author: "author", + headRefOid: `sha-${number}`, + headRefName: `feature/pr-${number}`, + number, + title: `PR ${number}`, + body: "Body", + labels: [{ name: "bug", color: "#d73a4a" }], + additions: 10, + deletions: 2, + changedFiles: 3, + state: "open", + reviewStatus: "none", + checkStatus: "passing", + checkSummary: "1/1", + checks: [{ name: "ci", status: "completed", conclusion: "success" }], + autoMergeEnabled: false, + detailLoaded: true, + createdAt: new Date(`2026-01-${String(number).padStart(2, "0")}T00:00:00Z`), + closedAt: null, + url: `https://github.com/owner/repo/pull/${number}`, + } + return { ...base, ...overrides } +} const load = (data: readonly PullRequestItem[]): PullRequestLoad => ({ view, @@ -179,7 +182,7 @@ describe("CacheService", () => { ) const db = new Database(filename) - db.run("update pull_requests set data_json = ? where pr_key = ?", "{", pullRequestCacheKey({ repository: "owner/repo", number: 1 })) + db.run("update pull_requests set data_json = ? where pr_key = ?", ["{", pullRequestCacheKey({ repository: "owner/repo", number: 1 })]) db.close() const cached = await runCache( diff --git a/test/clipboard.test.ts b/test/clipboard.test.ts new file mode 100644 index 0000000..a66ca31 --- /dev/null +++ b/test/clipboard.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer, Schema } from "effect" +import { Clipboard, type ClipboardCopyResult } from "../src/services/Clipboard.ts" +import { CommandError, CommandRunner, type CommandResult, type RunOptions } from "../src/services/CommandRunner.ts" + +interface RecordedCall { + readonly command: string + readonly args: readonly string[] + readonly stdin: string | undefined +} + +type CommandOutcome = "success" | "failure" + +const expectedClipboardCommands: readonly (readonly [string, ...(readonly string[])])[] = + process.platform === "darwin" + ? [["pbcopy"]] + : process.platform === "linux" + ? [...(process.env.WAYLAND_DISPLAY ? [["wl-copy"] as const] : []), ["xclip", "-selection", "clipboard"] as const, ["xsel", "--clipboard", "--input"] as const] + : [] + +const fakeCommandRunner = (outcomes: readonly CommandOutcome[], recorder: RecordedCall[]) => + Layer.succeed( + CommandRunner, + CommandRunner.of({ + run: (command: string, args: readonly string[], options?: RunOptions) => { + recorder.push({ command, args: [...args], stdin: options?.stdin }) + const outcome = outcomes[recorder.length - 1] ?? "failure" + if (outcome === "success") { + const result: CommandResult = { stdout: "", stderr: "", exitCode: 0 } + return Effect.succeed(result) + } + return Effect.fail(new CommandError({ command, args: [...args], detail: `${command} failed`, cause: `${command} failed` })) + }, + runSchema: () => Effect.die("unused test command runner") as Effect.Effect, + }), + ) + +const runClipboard = (effect: Effect.Effect, layer: Layer.Layer) => + Effect.runPromise(effect.pipe(Effect.provide(layer)) as Effect.Effect) + +const withCapturedStdout = async (run: (writes: string[]) => Promise) => { + const originalWrite = process.stdout.write + const writes: string[] = [] + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")) + return true + }) as typeof process.stdout.write + try { + return await run(writes) + } finally { + process.stdout.write = originalWrite + } +} + +const withTmux = async (value: string | undefined, run: () => Promise) => { + const originalTmux = process.env.TMUX + if (value === undefined) { + delete process.env.TMUX + } else { + process.env.TMUX = value + } + try { + return await run() + } finally { + if (originalTmux === undefined) { + delete process.env.TMUX + } else { + process.env.TMUX = originalTmux + } + } +} + +describe("Clipboard", () => { + test("reports copied when a clipboard command succeeds", async () => { + if (expectedClipboardCommands.length === 0) return + const recorder: RecordedCall[] = [] + const layer = Clipboard.layerNoDeps.pipe(Layer.provide(fakeCommandRunner(["success"], recorder))) + + const result = await runClipboard( + Clipboard.use((clipboard) => clipboard.copy("https://github.com/owner/repo/pull/42")), + layer, + ) + + expect(result).toBe("copied") + expect(recorder).toEqual([ + { + command: expectedClipboardCommands[0]![0], + args: expectedClipboardCommands[0]!.slice(1), + stdin: "https://github.com/owner/repo/pull/42", + }, + ]) + }) + + test("falls back to a raw OSC52 sequence when clipboard commands fail", async () => { + const recorder: RecordedCall[] = [] + const layer = Clipboard.layerNoDeps.pipe(Layer.provide(fakeCommandRunner([], recorder))) + + await withTmux(undefined, () => + withCapturedStdout(async (writes) => { + const result = await runClipboard( + Clipboard.use((clipboard) => clipboard.copy("copy me")), + layer, + ) + + expect(result).toBe("sent-osc52") + expect(recorder).toHaveLength(expectedClipboardCommands.length) + expect(writes).toEqual([`\x1b]52;c;${Buffer.from("copy me", "utf8").toString("base64")}\x07`]) + }), + ) + }) + + test("wraps OSC52 sequences for tmux passthrough", async () => { + const recorder: RecordedCall[] = [] + const layer = Clipboard.layerNoDeps.pipe(Layer.provide(fakeCommandRunner([], recorder))) + const sequence = `\x1b]52;c;${Buffer.from("tmux copy", "utf8").toString("base64")}\x07` + + await withTmux("/tmp/tmux-1000/default,1,0", () => + withCapturedStdout(async (writes) => { + const result = await runClipboard( + Clipboard.use((clipboard) => clipboard.copy("tmux copy")), + layer, + ) + + expect(result).toBe("sent-osc52") + expect(writes).toEqual([`\x1bPtmux;${sequence.split("\x1b").join("\x1b\x1b")}\x1b\\`]) + }), + ) + }) +}) diff --git a/test/copyCommands.test.ts b/test/copyCommands.test.ts new file mode 100644 index 0000000..447bd4e --- /dev/null +++ b/test/copyCommands.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test" +import { formatSequence } from "@ghui/keymap" +import { buildAppCommands } from "../src/appCommands.js" +import type { PullRequestItem } from "../src/domain.js" +import { detailViewKeymap } from "../src/keymap/detailView.js" +import { listNavKeymap } from "../src/keymap/listNav.js" + +const activeView = { _tag: "Queue", mode: "review", repository: null } as const +const selectedPullRequest: PullRequestItem = { + repository: "owner/repo", + author: "kit", + headRefOid: "abc123", + headRefName: "feat/clipboard-copy", + number: 42, + title: "Review UX", + body: "", + labels: [], + additions: 1, + deletions: 1, + changedFiles: 2, + state: "open", + reviewStatus: "review", + checkStatus: "passing", + checkSummary: "1/1", + checks: [], + autoMergeEnabled: false, + detailLoaded: true, + createdAt: new Date("2026-01-01T00:00:00Z"), + closedAt: null, + url: "https://github.com/owner/repo/pull/42", +} + +const noop = () => {} + +const buildCommands = () => + buildAppCommands({ + pullRequestStatus: "ready", + filterQuery: "", + filterMode: false, + selectedRepository: null, + activeViews: [activeView], + activeView, + loadedPullRequestCount: 1, + hasMorePullRequests: false, + isLoadingMorePullRequests: false, + selectedPullRequest, + detailFullView: false, + diffFullView: false, + commentsViewActive: false, + hasSelectedComment: false, + canEditSelectedComment: false, + diffReady: false, + effectiveDiffRenderView: "split", + diffWrapMode: "none", + diffWhitespaceMode: "ignore", + readyDiffFileCount: 0, + diffFileIndex: 0, + diffRangeActive: false, + selectedDiffCommentAnchorLabel: null, + selectedDiffCommentThreadCount: 0, + hasDiffCommentThreads: false, + actions: { + openCommandPalette: noop, + refreshPullRequests: noop, + openFilter: noop, + clearFilter: noop, + openThemeModal: noop, + openRepositoryPicker: noop, + loadMorePullRequests: noop, + switchViewTo: noop, + openDetails: noop, + closeDetails: noop, + openDiffView: noop, + closeDiffView: noop, + openCommentsView: noop, + closeCommentsView: noop, + openNewIssueCommentModal: noop, + openReplyToSelectedComment: noop, + openEditSelectedComment: noop, + openDeleteSelectedComment: noop, + reloadDiff: noop, + toggleDiffRenderView: noop, + toggleDiffWrapMode: noop, + toggleDiffWhitespaceMode: noop, + openChangedFilesModal: noop, + jumpDiffFile: noop, + openSelectedDiffComment: noop, + toggleDiffCommentRange: noop, + moveDiffCommentThread: noop, + openDiffCommentModal: noop, + openSubmitReviewModal: noop, + openPullRequestStateModal: noop, + openLabelModal: noop, + openMergeModal: noop, + openCloseModal: noop, + openPullRequestInBrowser: noop, + copyPullRequestMetadata: noop, + copyPullRequestUrl: noop, + quit: noop, + }, + }) + +describe("copy commands", () => { + test("remain available in the command palette with copy shortcut labels", () => { + const commands = buildCommands() + const metadata = commands.find((command) => command.id === "pull.copy-metadata") + const url = commands.find((command) => command.id === "pull.copy-url") + + expect(metadata?.shortcut).toBe("y") + expect(metadata?.disabledReason).toBeFalsy() + expect(url?.shortcut).toBe("shift-y") + expect(url?.disabledReason).toBeFalsy() + }) + + test("stay bound to y/Y in list and detail views", () => { + const listBindings = [...listNavKeymap].map((binding) => formatSequence(binding.sequence)) + const detailBindings = [...detailViewKeymap].map((binding) => formatSequence(binding.sequence)) + + expect(listBindings).toContain("y") + expect(listBindings).toContain("shift+y") + expect(detailBindings).toContain("y") + expect(detailBindings).toContain("shift+y") + }) +}) diff --git a/test/detailsPane.test.ts b/test/detailsPane.test.ts index 287cbea..cc1f690 100644 --- a/test/detailsPane.test.ts +++ b/test/detailsPane.test.ts @@ -6,6 +6,7 @@ const pullRequest = (body: string): PullRequestItem => ({ repository: "owner/repo", author: "kitlangton", headRefOid: "abc123", + headRefName: "feat/details", number: 1, title: "Title", body, diff --git a/test/diffStacking.test.ts b/test/diffStacking.test.ts index 82494ff..4465a70 100644 --- a/test/diffStacking.test.ts +++ b/test/diffStacking.test.ts @@ -101,7 +101,7 @@ describe("stacked diff helpers", () => { const anchors = getDiffCommentAnchors(visible!) const target = { ...anchors[1]!, line: 99 } - expect(nearestDiffAnchorForLocation(anchors, target)).toBe(anchors[2]) + expect(nearestDiffAnchorForLocation(anchors, target)).toBe(anchors[2]!) }) test("keeps visible lines stable while scrolling only near viewport edges", () => { diff --git a/test/filterLabels.test.ts b/test/filterLabels.test.ts index 7347877..2ffb7c2 100644 --- a/test/filterLabels.test.ts +++ b/test/filterLabels.test.ts @@ -67,11 +67,11 @@ describe("filterChangedFiles", () => { }) test("path match is case-insensitive and keeps source index", () => { - expect(filterChangedFiles(files, "REVIEW").map((entry) => ({ file: entry.file, index: entry.index }))).toEqual([{ file: files[1], index: 1 }]) + expect(filterChangedFiles(files, "REVIEW").map((entry) => ({ file: entry.file, index: entry.index }))).toEqual([{ file: files[1]!, index: 1 }]) }) test("trims whitespace from query", () => { - expect(filterChangedFiles(files, " readme ").map((entry) => ({ file: entry.file, index: entry.index }))).toEqual([{ file: files[2], index: 2 }]) + expect(filterChangedFiles(files, " readme ").map((entry) => ({ file: entry.file, index: entry.index }))).toEqual([{ file: files[2]!, index: 2 }]) }) test("matches all query tokens across path segments", () => { diff --git a/test/scrolling.test.tsx b/test/scrolling.test.tsx index 58da8d1..eb41a7d 100644 --- a/test/scrolling.test.tsx +++ b/test/scrolling.test.tsx @@ -116,7 +116,7 @@ const press = async ( ) => { await act(async () => { if (key.kind === "arrow") mockInput.pressArrow(key.dir) - else mockInput.pressKey(key.name, { shift: key.shift }) + else mockInput.pressKey(key.name, key.shift === undefined ? undefined : { shift: key.shift }) }) for (let i = 0; i < settleFrames; i++) await stepFrame(renderOnce) } diff --git a/tsconfig.json b/tsconfig.json index eb4e85b..0aadb28 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,5 @@ "types": ["bun"], "plugins": [{ "name": "@effect/language-service" }] }, - "include": ["src/**/*.ts", "src/**/*.tsx", "dev/**/*.ts", "dev/**/*.tsx"] + "include": ["src/**/*.ts", "src/**/*.tsx", "dev/**/*.ts", "dev/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"] }