Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br>
Your app is ready to be deployed!

## Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This README covers building and testing the app, not individual UI features, and nothing else documents single buttons here. The behaviour is already described in the code and the tests, so this section can be dropped to keep the change focused.


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.
Expand Down
74 changes: 74 additions & 0 deletions packages/app/e2e/regression/review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 19 additions & 8 deletions packages/gui-plugins/src/review/model.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 })

Expand Down
16 changes: 16 additions & 0 deletions packages/session-ui/component-tests/session-review-copy.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
},
)
}
Original file line number Diff line number Diff line change
@@ -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 }) => (
<CurrentSessionProviders document={editThenTestDocument}>
<div data-component="session-review-v2" style={{ height: "400px" }}>
<SessionReviewFilePreviewV2
file="example.ts"
diff={{
file: "example.ts",
before: "export const value = 'before'\n",
after: args.empty ? "" : "export const value = 'reviewed'\n// unchanged beyond the edit\n",
additions: args.empty ? 0 : 2,
deletions: 1,
status: "modified",
}}
diffStyle="unified"
readFile={args.reader ? async () => ({ type: "text", content: "later disk contents\n" }) : undefined}
/>
</div>
</CurrentSessionProviders>
),
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(() => ({
Expand All @@ -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<SessionReviewComment>({
comments,
label: i18n.t("ui.lineComment.submit"),
Expand Down Expand Up @@ -283,6 +343,16 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
<div data-slot="session-review-v2-file-diff">
<DiffChanges changes={view()} />
</div>
<Tooltip value={i18n.t(canCopy() ? "ui.sessionReviewV2.copyFileContents" : "ui.sessionReviewV2.copyUnavailable")}>
<IconButton
icon={<Icon name="copy" />}
variant="ghost"
size="small"
aria-label={i18n.t("ui.sessionReviewV2.copyFileContents")}
disabled={!canCopy() || copying()}
onClick={() => void copyContents()}
/>
</Tooltip>
</div>
<div
ref={(el) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading