Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions packages/app/e2e/regression/review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
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
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(() => ({
Expand All @@ -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<SessionReviewComment>({
comments,
label: i18n.t("ui.lineComment.submit"),
Expand Down Expand Up @@ -283,6 +330,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 @@ -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",
Expand Down
Loading