diff --git a/CHANGELOG.md b/CHANGELOG.md index 22a498102ecd..31bf6aac5337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Release notes for OpenCode V2, newest first. +## Unreleased + +### App + +- Copy a review file's full current text from its header without selecting diff lines or opening a comment. Deleted files copy their complete old text when available; binary or unreadable files leave the clipboard unchanged. + ## v2.2.0 — 2026-10-27 ### Highlights diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index b6002a3ea4d8..f7d5deac6317 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -9,6 +9,87 @@ import { expectSessionTitle } from "../utils/waits" test.use({ viewport: { width: 1440, height: 900 } }) +test("copies the current whole review file rather than its bounded diff", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + + const content = { + text: "export const value = 'after'\n" + "// unchanged outside the hunk\n".repeat(20) + "export const last = true\n", + } + + await openSession(page, { + name: "ReviewCopyContent", + vcsDiff: [fileDiff("src/example.ts")], + fileContent: () => content.text, + }) + await page.getByRole("button", { name: "Toggle review", exact: true }).click() + const panel = page.locator("#review-panel") + await openReviewLauncher(panel) + const copy = panel.getByRole("button", { name: "Copy file contents", exact: true }) + await copy.focus() + await copy.press("Enter") + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(content.text) + await expect(page.getByText("File contents copied", { exact: true })).toBeVisible() + await expect(panel.getByRole("textbox")).toHaveCount(0) + + content.text += "// edited after the diff was loaded\n" + await copy.click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(content.text) +}) + +test("copies a deleted review file's complete old text without reading its missing path", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + const reads: string[] = [] + await openSession(page, { + name: "ReviewCopyDeleted", + vcsDiff: [ + { + file: "deleted.txt", + status: "deleted", + additions: 0, + deletions: 3, + patch: "diff --git a/deleted.txt b/deleted.txt\n--- a/deleted.txt\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-first\n-second\n-last\n\\ No newline at end of file\n", + }, + ], + fileContent: (path) => { + reads.push(path) + + return "" + }, + }) + await page.getByRole("button", { name: "Toggle review", exact: true }).click() + const panel = page.locator("#review-panel") + await openReviewLauncher(panel) + await panel.getByRole("button", { name: "Copy file contents", exact: true }).click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("first\nsecond\nlast") + await expect(page.getByText("File contents copied", { exact: true })).toBeVisible() + expect(reads).toEqual([]) +}) + +test("does not replace the clipboard with binary or unavailable review content", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await openSession(page, { + name: "ReviewCopyUnavailable", + vcsDiff: [fileDiff("unknown.dat")], + fileContent: () => "\0binary bytes", + }) + await page.evaluate(() => navigator.clipboard.writeText("keep this clipboard")) + await page.getByRole("button", { name: "Toggle review", exact: true }).click() + const panel = page.locator("#review-panel") + await openReviewLauncher(panel) + const copy = panel.getByRole("button", { name: "Copy file contents", exact: true }) + await copy.click() + await expect(page.getByText("Full text content is unavailable", { exact: true })).toBeVisible() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("keep this clipboard") + await page.getByRole("button", { name: "Dismiss", exact: true }).click() + await expect(page.getByText("Full text content is unavailable", { exact: true })).toHaveCount(0) + + await page.route("**/api/fs/read/unknown.dat*", (route) => route.abort()) + await copy.click() + await expect(page.getByText("Full text content is unavailable", { exact: true })).toBeVisible() + await expect(copy).toBeEnabled() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("keep this clipboard") +}) + for (const view of ["desktop", "mobile"] as const) { test(`offers Git initialization for a project without VCS (${view})`, async ({ page }) => { if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 }) diff --git a/packages/gui-plugins/src/review/model.ts b/packages/gui-plugins/src/review/model.ts index 1e6fad1ab879..874a1cd52c98 100644 --- a/packages/gui-plugins/src/review/model.ts +++ b/packages/gui-plugins/src/review/model.ts @@ -1,7 +1,7 @@ import type { FileDiffInfo } from "@opencode/client/promise" import type { SessionReviewLineComment } from "@opencode/session-ui/session-review" import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge" -import { artifactMime } from "@opencode/util/artifact" +import { artifactKind, artifactMime } from "@opencode/util/artifact" import { bytesToBase64 } from "@opencode/util/base64" import { checksum } from "@opencode/util/encode" import { showToast } from "@opencode/ui/toast" @@ -460,16 +460,27 @@ export function createReviewModel(input: { }) } - // Previews read files only to show images and audio, which need the raw bytes and their MIME type. + // Read the current whole file, independently of the bounded diff used by the preview. const readFile = (path: string) => view() .server.client.file.read({ path, location: { directory: directory() } }) - .then((bytes) => ({ - type: "binary" as const, - content: bytesToBase64(bytes), - encoding: "base64" as const, - mimeType: artifactMime(path), - })) + .then((bytes) => { + const kind = artifactKind(path) + + if ( + ["text", "svg", "html", "markdown", "mermaid", "table"].includes(kind) && + !bytes.subarray(0, 8192).includes(0) + ) { + return { type: "text" as const, content: new TextDecoder().decode(bytes) } + } + + return { + type: "binary" as const, + content: bytesToBase64(bytes), + encoding: "base64" as const, + mimeType: artifactMime(path), + } + }) .catch((error) => { console.debug("[session-review] failed to read file", { path, error }) diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index ddc7e2cf8dd6..8bd9f0870df5 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -4,11 +4,15 @@ import { DiffChanges } from "@opencode/ui/diff-changes" import { FileIcon } from "@opencode/ui/file-icon" import { useFileComponent } from "@opencode/ui/context/file" import { useI18n } from "@opencode/ui/context/i18n" +import { Icon } from "@opencode/ui/icon" +import { IconButton } from "@opencode/ui/icon-button" +import { Tooltip } from "@opencode/ui/tooltip" +import { showToast } from "@opencode/ui/toast" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" import type { FileDiffInfo } from "@opencode/client/promise" import type { PresentationFileContent, PresentationFileDiff } from "../../file-presentation" -import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { normalize, text, type ViewDiff } from "../../components/session-diff" @@ -107,10 +111,14 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop let scrollRef: HTMLDivElement | undefined let focusToken = 0 - const [store, setStore] = createStore({ - selection: null as SelectedLineRange | null, - commenting: null as SelectedLineRange | null, - opened: null as string | null, + const [store, setStore] = createStore<{ + selection: SelectedLineRange | null + commenting: SelectedLineRange | null + opened: string | null + }>({ + selection: null, + commenting: null, + opened: null, }) const view = createMemo(() => ({ @@ -124,6 +132,45 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop const commentedLines = createMemo(() => comments().map((comment) => comment.selection)) const lineCommentsEnabled = () => props.onLineComment != null + const [copying, setCopying] = createSignal(false) + + const deletedContent = createMemo(() => { + if (view().status !== "deleted") return undefined + + if ("before" in props.diff && props.diff.before !== undefined) return props.diff.before + + if (view().fileDiff.isPartial || view().deletions === 0) return undefined + + return text(view(), "deletions") + }) + + const canCopy = () => (view().status === "deleted" ? deletedContent() !== undefined : !!props.readFile) + + const copyContents = async () => { + if (copying()) return + setCopying(true) + + try { + const content = + view().status === "deleted" + ? { type: "text", content: deletedContent() } + : await props.readFile?.(props.file) + + if (content?.type !== "text" || content.content === undefined || content.content.includes("\0")) { + showToast({ variant: "error", title: i18n.t("ui.sessionReviewV2.copyUnavailable") }) + + return + } + + await navigator.clipboard.writeText(content.content) + showToast({ variant: "success", title: i18n.t("ui.sessionReviewV2.copiedFileContents") }) + } catch { + showToast({ variant: "error", title: i18n.t("ui.sessionReviewV2.copyFailed") }) + } finally { + setCopying(false) + } + } + const commentsUi = createLineCommentControllerV2({ comments, label: i18n.t("ui.lineComment.submit"), @@ -283,6 +330,16 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
+ + } + variant="ghost" + size="small" + aria-label={i18n.t("ui.sessionReviewV2.copyFileContents")} + disabled={!canCopy() || copying()} + onClick={() => void copyContents()} + /> +
{ diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index a96904931276..da4e89dc58d8 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -25,6 +25,10 @@ const source = { "ui.sessionReviewV2.previousFile": "Previous file", "ui.sessionReviewV2.nextFile": "Next file", "ui.sessionReviewV2.diffView": "Diff view", + "ui.sessionReviewV2.copyFileContents": "Copy file contents", + "ui.sessionReviewV2.copiedFileContents": "File contents copied", + "ui.sessionReviewV2.copyUnavailable": "Full text content is unavailable", + "ui.sessionReviewV2.copyFailed": "Couldn't copy file contents", "ui.sessionReviewV2.empty.noGit.title": "No tracked changes", "ui.sessionReviewV2.empty.noGit.description": "Track, review, and undo changes in this project", "ui.sessionReviewV2.empty.noGit.action": "Create Git repository",