diff --git a/packages/app/README.md b/packages/app/README.md index 90951727d6da..a8b87b1ae392 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -29,6 +29,13 @@ It correctly bundles Solid in production mode and optimizes the build for the be The build is minified and the filenames include the hashes.
Your app is ready to be deployed! +## Review + +The review file header's **Copy file contents** action copies the complete reviewed snapshot when the diff includes it, +including an empty file. If no snapshot is available, it reads the current file from disk. Deleted files use the old +snapshot or a complete deletion diff instead of reading the missing path. Binary or unavailable content leaves the +clipboard unchanged. + ## E2E Testing Locally, Playwright starts the Vite dev server automatically via `webServer`, or reuses one already running at the configured address. The browser suite uses isolated API fixtures rather than a live opencode backend. diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index 94456f7393c1..d93b3a382a4b 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -10,6 +10,80 @@ import { readFile } from "node:fs/promises" 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 = + "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, + }) + 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(content) + await expect(page.getByText("File contents copied", { exact: true })).toBeVisible() + await expect(panel.getByRole("textbox")).toHaveCount(0) +}) + +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") +}) + test("review toggle shows its tooltip with the side panel open or closed", async ({ page }) => { await openSession(page, { name: "ReviewTooltip", 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/component-tests/session-review-copy.spec.ts b/packages/session-ui/component-tests/session-review-copy.spec.ts new file mode 100644 index 000000000000..63cb0656f5f0 --- /dev/null +++ b/packages/session-ui/component-tests/session-review-copy.spec.ts @@ -0,0 +1,16 @@ +import { expect, story } from "../../stories/playwright/story" + +for (const empty of [false, true]) { + story( + `copies ${empty ? "an empty snapshot without a disk reader" : "the reviewed snapshot instead of later disk edits"}`, + async ({ mount, page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + const root = await mount("review-file-preview-v2--copy-snapshot", { args: { empty, reader: !empty } }) + await page.evaluate(() => navigator.clipboard.writeText("previous clipboard")) + await root.getByRole("button", { name: "Copy file contents", exact: true }).click() + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(empty ? "" : "export const value = 'reviewed'\n// unchanged beyond the edit\n") + }, + ) +} diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.stories.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.stories.tsx new file mode 100644 index 000000000000..823241ea2192 --- /dev/null +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.stories.tsx @@ -0,0 +1,31 @@ +import { CurrentSessionProviders } from "../../storybook/current-session-story" +import { editThenTestDocument } from "../../storybook/current-session-fixtures" +import { SessionReviewFilePreviewV2 } from "./session-review-file-preview-v2" + +export default { + title: "OpenCode/Review/File preview", + id: "review-file-preview-v2", +} + +export const CopySnapshot = { + args: { empty: false, reader: true }, + render: (args: { empty: boolean; reader: boolean }) => ( + +
+ ({ type: "text", content: "later disk contents\n" }) : undefined} + /> +
+
+ ), +} 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..0490b3e192f0 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,16 @@ 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 { writeClipboard } from "../../message/message-content" 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 +112,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 +133,57 @@ 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 snapshotContent = createMemo(() => + view().status === "deleted" ? deletedContent() : "after" in props.diff ? props.diff.after : undefined, + ) + + const canCopy = () => snapshotContent() !== undefined || (view().status !== "deleted" && !!props.readFile) + + const copyContents = async () => { + if (copying()) return + setCopying(true) + + try { + const snapshot = snapshotContent() + const content = + snapshot !== undefined + ? { type: "text", content: snapshot } + : view().status === "deleted" + ? undefined + : 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 + } + + if (!(await writeClipboard(content.content))) { + showToast({ variant: "error", title: i18n.t("ui.sessionReviewV2.copyFailed") }) + + return + } + + 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 +343,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 7b3b9ef3dd02..170262ee1611 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -29,6 +29,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",