fix(app): optimize large review panes (#35375)

This commit is contained in:
Luke Parker 2026-07-06 16:26:03 +10:00 committed by GitHub
commit 3a149ba71c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1074 additions and 404 deletions

View file

@ -0,0 +1,312 @@
import type { Page } from "@playwright/test"
import { benchmark, expect } from "../benchmark"
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
const changedLinesPerFile = 100
const linesPerSide = changedLinesPerFile / 2
const fileCounts = [1, 10, 100, 1_000, 10_000]
const filesPerDirectory = 100
const readyFrames = 3
const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
type ReviewPaneScalingSample = {
observedAtMs: number
logicalRows: number
treeRows: number
fileRows: number
diffLines: number
header: string
ready: boolean
}
type ReviewPaneScalingProbe = {
startedAt?: number
firstTreeRowMs?: number
logicalTreeReadyMs?: number
firstDiffRenderMs?: number
stableReadyMs?: number
samples: ReviewPaneScalingSample[]
frameTimesMs: number[]
longTasks: { startTime: number; duration: number }[]
stop: () => void
}
benchmark.describe("performance: review pane scaling", () => {
for (const fileCount of fileCounts) {
const changedLines = fileCount * changedLinesPerFile
benchmark(
`${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
async ({ page, report }) => {
benchmark.setTimeout(1_200_000)
await page.emulateMedia({ reducedMotion: "reduce" })
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
await setupTimelineBenchmark(page, {
historyTurns: 0,
eventBatch: 1,
newLayoutDesigns: true,
})
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: responseBody,
}),
)
const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
const metrics = await measureReviewPaneLoad(page, {
expectedFile: reviewFile(0),
expectedRows,
})
const search = await measureBroadReviewSearch(page, fileCount)
expect(metrics.logicalRows).toBe(expectedRows)
expect(metrics.fileRows).toBeGreaterThan(0)
expect(metrics.treeRows).toBeGreaterThan(0)
expect(metrics.diffLines).toBeGreaterThan(0)
expect(search.logicalRows).toBe(fileCount)
expect(search.renderedRows).toBeGreaterThan(0)
report(
{ ...metrics, search },
{
fileCount,
changedLinesPerFile,
changedLines,
additions: changedLines / 2,
deletions: changedLines / 2,
patchLines: changedLines,
patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
payloadBytes: new TextEncoder().encode(responseBody).byteLength,
expectedRows,
},
)
},
)
}
})
async function measureBroadReviewSearch(page: Page, expectedRows: number) {
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.evaluate((element) => {
element.addEventListener(
"input",
() => {
;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
},
{ once: true, capture: true },
)
})
await filter.fill("file-")
return page.evaluate((expectedRows) => {
const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
let previous = -1
let streak = 0
const sample = () => {
const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
const ready =
logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
previous = rows.length
if (streak >= 3) {
resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
return
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
})
}, expectedRows)
}
function createScalingDiffs(fileCount: number, patchByteLimit: number) {
const changes = Array.from({ length: linesPerSide }, (_, index) => {
const line = String(index).padStart(3, "0")
return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
}).join("\n")
let patchBytes = 0
let capped = false
return Array.from({ length: fileCount }, (_, index) => {
const file = reviewFile(index)
const fullPatch = [
`diff --git a/${file} b/${file}`,
`--- a/${file}`,
`+++ b/${file}`,
`@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
changes,
].join("\n")
if (index === 0 && fullPatch.length > patchByteLimit)
throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
if (patch === fullPatch) patchBytes += fullPatch.length
else capped = true
return {
file,
patch,
additions: linesPerSide,
deletions: linesPerSide,
status: "modified" as const,
}
})
}
function emptyReviewPatch(file: string) {
return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
}
function reviewFile(index: number) {
return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
}
async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
const toggle = page.getByRole("button", { name: "Toggle review" })
await expect(toggle).toBeVisible()
await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
await installReviewPaneScalingProbe(page, input)
await toggle.click()
await page.waitForFunction(
() =>
(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
?.stableReadyMs !== undefined,
undefined,
{ timeout: completionTimeoutMs },
)
return page.evaluate(() => {
const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
probe.stop()
const startedAt = probe.startedAt!
const final = probe.samples.at(-1)!
const resources = performance
.getEntriesByType("resource")
.filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
const resource = resources.at(-1)
const longTasks = probe.longTasks.filter(
(entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
)
const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
return {
firstTreeRowMs: probe.firstTreeRowMs ?? null,
logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
stableReadyMs: probe.stableReadyMs ?? null,
responseStartMs: resource ? resource.responseStart - startedAt : null,
responseEndMs: resource ? resource.responseEnd - startedAt : null,
responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
treeRows: final.treeRows,
logicalRows: final.logicalRows,
fileRows: final.fileRows,
diffLines: final.diffLines,
samples: probe.samples.length,
maxFrameGapMs: Math.max(0, ...frameGaps),
longTaskCount: longTasks.length,
longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
}
})
}
async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
await page.evaluate(
({ expectedFile, expectedRows, stableFrames }) => {
let running = true
let readyStreak = 0
const basename = expectedFile.split("/").at(-1)!
const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
? new PerformanceObserver((list) => {
probe.longTasks.push(
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
)
})
: undefined
const probe: ReviewPaneScalingProbe = {
samples: [],
frameTimesMs: [],
longTasks: [],
stop: () => {
running = false
longTaskObserver?.disconnect()
},
}
const sample = (time: number) => {
if (!running || probe.startedAt === undefined) return
const panel = document.querySelector<HTMLElement>("#review-panel")
const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
const header =
panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
const viewers = panel
? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
: []
const diffLines = viewers.reduce(
(sum, viewer) =>
sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
0,
)
const observedAtMs = time - probe.startedAt
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
const ready =
logicalRows === expectedRows &&
fileRows.length > 0 &&
header.includes(basename) &&
viewers.length === 1 &&
diffLines > 0
const previous = probe.samples.at(-1)
const stable =
ready &&
previous?.ready === true &&
previous.logicalRows === logicalRows &&
previous.treeRows === rows.length &&
previous.fileRows === fileRows.length &&
previous.diffLines === diffLines &&
previous.header === header
probe.frameTimesMs.push(observedAtMs)
probe.samples.push({
observedAtMs,
logicalRows,
treeRows: rows.length,
fileRows: fileRows.length,
diffLines,
header,
ready,
})
if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
probe.logicalTreeReadyMs = observedAtMs
if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
}
longTaskObserver?.observe({ type: "longtask", buffered: true })
document.addEventListener(
"click",
(event) => {
const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
probe.startedAt = performance.now()
performance.mark("opencode.review-pane-scaling.click")
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
},
{ ...input, stableFrames: readyFrames },
)
}

View file

@ -10,6 +10,9 @@ const sessionB = "ses_review_tab_b"
const titleA = "Alpha session" const titleA = "Alpha session"
const titleB = "Beta session" const titleB = "Beta session"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const diffs = Array.from({ length: 2_740 }, (_, index) =>
fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
)
// Marks the review pane DOM node so a remount (fresh node) is detectable. // Marks the review pane DOM node so a remount (fresh node) is detectable.
const PROBE = "original" const PROBE = "original"
@ -25,20 +28,34 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
await expectSessionTitle(page, titleA) await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click() await page.getByRole("button", { name: "Toggle review" }).click()
const reviewTab = page.getByRole("tab", { name: /Review/ })
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]') const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review) await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ })) await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page) await writeProbe(page)
await switchTab(page, titleB) await switchTab(page, titleB)
await expectSessionTitle(page, titleB) await expectSessionTitle(page, titleB)
await expectAppVisible(review) await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE) expect(await readProbe(page)).toBe(PROBE)
await switchTab(page, titleA) await switchTab(page, titleA)
await expectSessionTitle(page, titleA) await expectSessionTitle(page, titleA)
await expectAppVisible(review) await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE) expect(await readProbe(page)).toBe(PROBE)
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
}) })
type Probed = HTMLElement & { __e2eProbe?: string } type Probed = HTMLElement & { __e2eProbe?: string }
@ -80,16 +97,7 @@ async function setup(page: Page) {
default: { providerID: "opencode", modelID: "test" }, default: { providerID: "opencode", modelID: "test" },
}, },
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
vcsDiff: [ vcsDiff: diffs,
{
file: "src/example.ts",
additions: 1,
deletions: 1,
status: "modified",
patch:
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
},
],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
@ -127,3 +135,13 @@ function session(id: string, title: string, created: number) {
function sessionHref(sessionID: string) { function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}` return `/server/${base64Encode(server)}/session/${sessionID}`
} }
function fileDiff(file: string) {
return {
file,
additions: 1,
deletions: 1,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}

View file

@ -0,0 +1,212 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewTerminalStacked"
const projectID = "proj_review_terminal_stacked"
const sessionID = "ses_review_terminal_stacked"
const title = "Review terminal stacked"
const branchDiffs = [
fileDiff(".github/actions/setup-bun/action.yml", 7),
...Array.from({ length: 2_739 }, (_, index) =>
fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100),
),
]
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
test.setTimeout(120_000)
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "review-terminal-stacked",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "review-terminal-stacked",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
}),
)
await page.route("**/pty", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
}),
)
await page.route("**/pty/pty_review_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes")
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_745, "action.yml")
await expectStackGeometry(page)
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await treeViewport.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
await expect(lastFile).toBeVisible()
const bottomGap = await lastFile.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
return viewport.bottom - element.getBoundingClientRect().bottom
})
expect(bottomGap).toBeGreaterThanOrEqual(0)
expect(bottomGap).toBeLessThanOrEqual(16)
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_745, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_745, "action.yml")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_745, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_745, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_745, "action.yml")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_745, "action.yml")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_745, "action.yml")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_745, "action.yml")
await expectStackGeometry(page)
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
const option = page.getByRole("option", { name: next })
await expect(option).toBeVisible()
await option.click()
}
async function expectTree(page: Page, total: number, file: string) {
await expectMountedTree(page, total)
await expect(page.getByRole("button", { name: file })).toBeVisible()
}
async function expectMountedTree(page: Page, total: number) {
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
await expect(tree).toHaveAttribute("data-total-rows", String(total))
await expect
.poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
.toBeGreaterThan(0)
const state = await tree.evaluate((element) => ({
root: element.getBoundingClientRect().height,
viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
}))
expect(state.viewport).toBeGreaterThan(0)
expect(state.root).toBeGreaterThan(0)
expect(state.rows).toBeGreaterThan(0)
expect(state.rows).toBeLessThanOrEqual(60)
}
async function expectStackGeometry(page: Page) {
const geometry = await page.evaluate(() => {
const review = document.querySelector<HTMLElement>("#review-panel")!
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
const reviewParent = review.parentElement!.getBoundingClientRect()
const terminalParent = terminal.parentElement!.getBoundingClientRect()
return {
review: review.getBoundingClientRect().height,
reviewParent: reviewParent.height,
terminal: terminal.getBoundingClientRect().height,
terminalParent: terminalParent.height,
}
})
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function fileDiff(file: string, additions: number) {
return {
file,
additions,
deletions: 0,
status: "modified",
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
}
}

View file

@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
describe("file tree v2 model", () => {
test("builds sorted depth-first rows", () => {
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
expect(model.total).toBe(8)
expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([
["docs", "directory", 0],
["docs/guide.md", "file", 1],
["src", "directory", 0],
["src/lib", "directory", 1],
["src/lib/a.ts", "file", 2],
["src/lib/b.ts", "file", 2],
["src/z.ts", "file", 1],
["README.md", "file", 0],
])
})
test("omits descendants of collapsed directories", () => {
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
"src",
"src/lib",
"src/z.ts",
])
})
test("normalizes separators and duplicate paths", () => {
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
const rows = flattenFileTreeV2(model, () => true)
expect(model.total).toBe(4)
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
})
test("supports paths deeper than the legacy recursion limit", () => {
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
const model = buildFileTreeV2Model([file])
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
})
})

View file

@ -0,0 +1,77 @@
import type { FileNode } from "@opencode-ai/sdk/v2"
export type FileTreeV2Model = {
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
total: number
}
export type FileTreeV2Node = FileNode & { originalPath: string }
export type FileTreeV2Row = {
node: FileTreeV2Node
level: number
}
export function normalizeFileTreeV2Path(value: string) {
return value
.replaceAll("\\", "/")
.replace(/^\/+|\/+$/g, "")
.replace(/\/{2,}/g, "/")
}
export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model {
const nodes = new Map<string, FileTreeV2Node>()
paths.forEach((value) => {
const file = normalizeFileTreeV2Path(value)
if (!file) return
const parts = file.split("/")
parts.forEach((name, index) => {
const path = parts.slice(0, index + 1).join("/")
if (nodes.has(path)) return
nodes.set(path, {
name,
path,
absolute: path,
type: index === parts.length - 1 ? "file" : "directory",
ignored: false,
originalPath: index === parts.length - 1 ? value : path,
})
})
})
const children = new Map<string, FileTreeV2Node[]>()
nodes.forEach((node) => {
const index = node.path.lastIndexOf("/")
const parent = index === -1 ? "" : node.path.slice(0, index)
const list = children.get(parent)
if (list) list.push(node)
else children.set(parent, [node])
})
children.forEach((nodes) =>
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
}),
)
return { children, total: nodes.size }
}
export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) {
const rows: FileTreeV2Row[] = []
const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 }))
while (stack.length > 0) {
const row = stack.pop()!
rows.push(row)
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
const children = model.children.get(row.node.path) ?? []
for (let index = children.length - 1; index >= 0; index--) {
stack.push({ node: children[index]!, level: row.level + 1 })
}
}
return rows
}

View file

@ -1,95 +1,26 @@
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css" import "@opencode-ai/ui/v2/file-tree-v2.css"
import { import {
createEffect, createEffect,
createMemo, createMemo,
createSignal,
For, For,
Match,
on,
Show, Show,
splitProps, splitProps,
Switch,
untrack,
type ComponentProps, type ComponentProps,
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon" import { Icon } from "@opencode-ai/ui/v2/icon"
import { import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
dirsToExpand, import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
pathToFileUrl, import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
shouldListRoot, import { virtualScrollElement } from "@/components/virtual-scroll-element"
visibleKind,
withFileDragImage,
type Filter,
type Kind,
} from "@/components/file-tree"
export type { Kind } from "@/components/file-tree" export type { Kind } from "@/components/file-tree"
const MAX_DEPTH = 128
function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
const nodes = children(path)
if (!current) return nodes
const parent = (item: string) => {
const idx = item.lastIndexOf("/")
if (idx === -1) return ""
return item.slice(0, idx)
}
const leaf = (item: string) => {
const idx = item.lastIndexOf("/")
return idx === -1 ? item : item.slice(idx + 1)
}
const out = nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
const seen = new Set(out.map((node) => node.path))
for (const dir of current.dirs) {
if (parent(dir) !== path) continue
if (seen.has(dir)) continue
out.push({
name: leaf(dir),
path: dir,
absolute: dir,
type: "directory",
ignored: false,
})
seen.add(dir)
}
for (const item of current.files) {
if (parent(item) !== path) continue
if (seen.has(item)) continue
out.push({
name: leaf(item),
path: item,
absolute: item,
type: "file",
ignored: false,
})
seen.add(item)
}
out.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
return out
}
const INDENT_STEP = 16 const INDENT_STEP = 16
function rowPaddingLeft(level: number, type: FileNode["type"]) { function rowPaddingLeft(level: number, type: FileNode["type"]) {
@ -123,7 +54,6 @@ const FileTreeNodeV2 = (
active?: string active?: string
draggable: boolean draggable: boolean
kinds?: ReadonlyMap<string, Kind> kinds?: ReadonlyMap<string, Kind>
marks?: Set<string>
as?: "div" | "button" as?: "div" | "button"
}, },
) => { ) => {
@ -133,18 +63,18 @@ const FileTreeNodeV2 = (
"active", "active",
"draggable", "draggable",
"kinds", "kinds",
"marks",
"as", "as",
"children", "children",
"class", "class",
"classList", "classList",
]) ])
const kind = () => visibleKind(local.node, local.kinds, local.marks) const kind = () => local.kinds?.get(local.node.path)
return ( return (
<Dynamic <Dynamic
component={local.as ?? "div"} component={local.as ?? "div"}
data-slot="file-tree-v2-row" data-slot="file-tree-v2-row"
data-path={local.node.path}
data-selected={local.node.path === local.active ? "" : undefined} data-selected={local.node.path === local.active ? "" : undefined}
data-ignored={local.node.ignored ? "" : undefined} data-ignored={local.node.ignored ? "" : undefined}
classList={{ classList={{
@ -177,244 +107,161 @@ const FileTreeNodeV2 = (
) )
} }
// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never function GuideLines(props: { level: number }) {
// lists unloaded subdirectories, so callers must pass `allowed` (nodes are return (
// synthesized from that list) for nested content to appear. <For each={Array.from({ length: props.level })}>
{(_, index) => (
<div
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
style={`left: ${guideLineLeft(index())}px`}
/>
)}
</For>
)
}
export default function FileTreeV2(props: { export default function FileTreeV2(props: {
path: string
active?: string active?: string
level?: number
allowed?: readonly string[] allowed?: readonly string[]
kinds?: ReadonlyMap<string, Kind> kinds?: ReadonlyMap<string, Kind>
draggable?: boolean draggable?: boolean
onFileClick?: (file: FileNode) => void onFileClick?: (file: FileNode) => void
_filter?: Filter
_marks?: Set<string>
_deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
}) { }) {
const file = useFile() const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true const draggable = () => props.draggable ?? true
const active = () => normalizeFileTreeV2Path(props.active ?? "")
const key = (p: string) => const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
file const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
.normalize(p) const [root, setRoot] = createSignal<HTMLDivElement>()
.replace(/[\\/]+$/, "") const [focused, setFocused] = createSignal<string>()
.replaceAll("\\", "/") const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] get count() {
return rows().length
const filter = createMemo(() => { },
if (props._filter) return props._filter getScrollElement: () => virtualScrollElement(root()),
initialRect: { width: 0, height: 600 },
const allowed = props.allowed estimateSize: () => 28,
if (!allowed) return gap: 2,
overscan: 10,
const files = new Set(allowed) get getItemKey() {
const dirs = new Set<string>() const current = rows()
return (index: number) => current[index]?.node.path ?? index
for (const item of allowed) { },
const parts = item.split("/") rangeExtractor: (range) => {
const parents = parts.slice(0, -1) const indexes = defaultRangeExtractor(range)
for (const [idx] of parents.entries()) { const path = focused()
const dir = parents.slice(0, idx + 1).join("/") const index = path ? rows().findIndex((row) => row.node.path === path) : -1
if (dir) dirs.add(dir) if (index < 0 || indexes.includes(index)) return indexes
} return [...indexes, index].sort((a, b) => a - b)
} },
return { files, dirs }
}) })
const marks = createMemo(() => {
if (props._marks) return props._marks
const out = new Set<string>(props.kinds?.keys() ?? [])
if (out.size === 0) return
return out
})
const kinds = createMemo(() => {
if (props._kinds) return props._kinds
return props.kinds
})
const deeps = createMemo(() => {
if (props._deeps) return props._deeps
const out = new Map<string, number>()
const root = props.path
if (!(file.tree.state(root)?.expanded ?? false)) return out
const seen = new Set<string>()
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
const push = (dir: string, lvl: number) => {
const id = key(dir)
if (seen.has(id)) return
seen.add(id)
const kids = file.tree
.children(dir)
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
.map((node) => node.path)
stack.push({ dir, lvl, i: 0, kids, max: lvl })
}
push(root, level - 1)
while (stack.length > 0) {
const top = stack[stack.length - 1]!
if (top.i < top.kids.length) {
const next = top.kids[top.i]!
top.i++
push(next, top.lvl + 1)
continue
}
out.set(top.dir, top.max)
stack.pop()
const parent = stack[stack.length - 1]
if (!parent) continue
parent.max = Math.max(parent.max, top.max)
}
return out
})
createEffect(() => { createEffect(() => {
const current = filter() const path = active()
const dirs = dirsToExpand({ if (!path) return
level, const index = rows().findIndex((row) => row.node.path === path)
filter: current, if (index < 0) return
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, queueMicrotask(() => {
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
virtualizer.scrollToIndex(index, { align: "auto" })
}) })
// Nodes come from the `allowed` filter; skip listing so directories that only
// exist on the diff's base branch do not each fail with an error toast.
for (const dir of dirs) file.tree.expand(dir, { list: false })
}) })
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
createEffect( const virtualItemByKey = createMemo(
on( () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
() => props.path,
(path) => {
const dir = untrack(() => file.tree.state(path))
if (!shouldListRoot({ level, dir })) return
void file.tree.list(path)
},
{ defer: false },
),
) )
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
return ( return (
// group/file-tree-v2 scopes the group-hover guide lines below; hosts may add <div
// an outer group with the same name to widen the hover area. ref={setRoot}
<div data-component="file-tree-v2" class="group/file-tree-v2"> data-component="file-tree-v2"
<For each={nodes()}> data-total-rows={model().total}
{(node) => { class="group/file-tree-v2"
const expanded = () => file.tree.state(node.path)?.expanded ?? false style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
const deep = () => deeps().get(node.path) ?? -1 >
const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0 <For each={virtualRowKeys()}>
return ( {(key) => (
<Switch> <Show when={virtualItemByKey().get(key)}>
<Match when={node.type === "directory"}> {(item) => (
<Collapsible <div
variant="ghost" style={{
class="w-full" position: "absolute",
data-scope="file-tree-v2" top: "0",
forceMount={false} left: "0",
open={expanded()} width: "100%",
onOpenChange={(open) => height: `${item().size}px`,
open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path) transform: `translateY(${item().start}px)`,
} }}
> >
<Collapsible.Trigger> <Show when={rowByKey().get(key as string)}>
<FileTreeNodeV2 {(row) => (
node={node} <Show
level={level} when={row().node.type === "directory"}
active={props.active} fallback={
draggable={draggable()} <FileTreeNodeV2
kinds={kinds()} node={row().node}
marks={marks()} level={row().level}
> active={active()}
<div draggable={draggable()}
data-slot="file-tree-v2-chevron"
data-expanded={expanded() ? "" : undefined}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
</div>
</FileTreeNodeV2>
</Collapsible.Trigger>
<Show when={hasChildren()}>
<Collapsible.Content class="relative">
<div
classList={{
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
"group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
"group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
}}
style={`left: ${guideLineLeft(level)}px`}
/>
<Show
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
>
<FileTreeV2
path={node.path}
level={level + 1}
allowed={props.allowed}
kinds={props.kinds} kinds={props.kinds}
active={props.active} as="button"
draggable={props.draggable} type="button"
onFileClick={props.onFileClick} class="relative"
_filter={filter()} onFocus={() => setFocused(row().node.path)}
_marks={marks()} onBlur={() => setFocused(undefined)}
_deeps={deeps()} onClick={() =>
_kinds={kinds()} props.onFileClick?.({
_chain={chain} ...row().node,
/> path: row().node.originalPath,
</Show> absolute: row().node.originalPath,
</Collapsible.Content> })
</Show> }
</Collapsible> >
</Match> <GuideLines level={row().level} />
<Match when={node.type === "file"}> <Show when={row().level > 0}>
<FileTreeNodeV2 <div class="w-4 shrink-0" />
node={node} </Show>
level={level} <span class="filetree-iconpair size-4">
active={props.active} <FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
draggable={draggable()} <FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
kinds={kinds()} </span>
marks={marks()} </FileTreeNodeV2>
as="button" }
type="button" >
onClick={() => props.onFileClick?.(node)} <FileTreeNodeV2
> node={row().node}
<Show when={level > 0}> level={row().level}
<div class="w-4 shrink-0" /> active={active()}
</Show> draggable={draggable()}
<Show kinds={props.kinds}
when={!node.ignored} as="button"
fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />} type="button"
> class="relative"
<span class="filetree-iconpair size-4"> onFocus={() => setFocused(row().node.path)}
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" /> onBlur={() => setFocused(undefined)}
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono /> aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
</span> onClick={() =>
</Show> file.tree.state(row().node.path)?.expanded === false
</FileTreeNodeV2> ? file.tree.expand(row().node.path, { list: false })
</Match> : file.tree.collapse(row().node.path)
</Switch> }
) >
}} <GuideLines level={row().level} />
<div
data-slot="file-tree-v2-chevron"
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
class="size-4 flex items-center justify-center"
>
<Icon name="chevron-down" />
</div>
</FileTreeNodeV2>
</Show>
)}
</Show>
</div>
)}
</Show>
)}
</For> </For>
</div> </div>
) )

View file

@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { virtualScrollElement } from "./virtual-scroll-element"
test("resolves the connected viewport that owns the virtual root", () => {
const stale = document.createElement("div")
stale.className = "scroll-view__viewport"
const viewport = document.createElement("div")
viewport.className = "scroll-view__viewport"
const root = document.createElement("div")
viewport.append(root)
document.body.append(viewport)
expect(virtualScrollElement(root)).toBe(viewport)
expect(virtualScrollElement(root)).not.toBe(stale)
viewport.remove()
expect(virtualScrollElement(root)).toBeNull()
})

View file

@ -0,0 +1,4 @@
export function virtualScrollElement(root: HTMLElement | undefined) {
if (!root?.isConnected) return null
return root.closest<HTMLDivElement>(".scroll-view__viewport")
}

View file

@ -2157,6 +2157,7 @@ export default function Page() {
diffsReady={reviewReady} diffsReady={reviewReady}
empty={reviewEmptyText} empty={reviewEmptyText}
hasReview={hasReview} hasReview={hasReview}
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount} reviewCount={reviewCount}
reviewPanel={reviewPanel} reviewPanel={reviewPanel}
activeDiff={tree.activeDiff} activeDiff={tree.activeDiff}
@ -2168,14 +2169,20 @@ export default function Page() {
<Show when={newSessionDesign()}> <Show when={newSessionDesign()}>
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}> <Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
<div class="min-w-0 h-full flex flex-1 flex-col"> <div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}> <Show when={isDesktop()}>
<div class="min-h-0 flex-1"> <div
classList={{
"min-h-0 flex-1": desktopV2ReviewOpen() || desktopFileTreeOpen(),
"size-0 shrink-0 overflow-hidden": !(desktopV2ReviewOpen() || desktopFileTreeOpen()),
}}
>
<SessionSidePanel <SessionSidePanel
canReview={canReview} canReview={canReview}
diffs={reviewDiffs} diffs={reviewDiffs}
diffsReady={reviewReady} diffsReady={reviewReady}
empty={reviewEmptyText} empty={reviewEmptyText}
hasReview={hasReview} hasReview={hasReview}
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount} reviewCount={reviewCount}
reviewPanel={reviewPanelV2} reviewPanel={reviewPanelV2}
activeDiff={tree.activeDiff} activeDiff={tree.activeDiff}
@ -2204,7 +2211,12 @@ export default function Page() {
</div> </div>
</Show> </Show>
<Show when={terminalOpen()}> <Show when={terminalOpen()}>
<div class="min-h-0 flex-1"> <div
classList={{
"min-h-0 shrink-0": desktopV2PanelLayout().stacked,
"min-h-0 flex-1": !desktopV2PanelLayout().stacked,
}}
>
<TerminalPanelV2 stacked={desktopV2PanelLayout().stacked} /> <TerminalPanelV2 stacked={desktopV2PanelLayout().stacked} />
</div> </div>
</Show> </Show>

View file

@ -14,6 +14,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree" import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file" import { useFile, type SelectedLineRange } from "@/context/file"
@ -45,6 +48,7 @@ export function SessionSidePanel(props: {
diffsReady: () => boolean diffsReady: () => boolean
empty: () => string empty: () => string
hasReview: () => boolean hasReview: () => boolean
reviewHasFocusableContent: () => boolean
reviewCount: () => number reviewCount: () => number
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
activeDiff?: string activeDiff?: string
@ -75,6 +79,7 @@ export function SessionSidePanel(props: {
}), }),
) )
const open = createMemo(() => reviewOpen() || fileOpen()) const open = createMemo(() => reviewOpen() || fileOpen())
const rendered = createMemo<boolean>((previous) => previous || open(), false)
const reviewTab = createMemo(() => isDesktop()) const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => { const panelWidth = createMemo(() => {
if (!open()) return "0px" if (!open()) return "0px"
@ -155,6 +160,10 @@ export function SessionSidePanel(props: {
const openedTabs = tabState.openedTabs const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab const activeFileTab = tabState.activeFileTab
const reviewContentRendered = createMemo<boolean>(
(previous) => previous || (reviewOpen() && activeTab() === "review"),
false,
)
const fileTreeTab = () => layout.fileTree.tab() const fileTreeTab = () => layout.fileTree.tab()
@ -223,7 +232,7 @@ export function SessionSidePanel(props: {
class="relative min-w-0 flex overflow-hidden bg-background-base" class="relative min-w-0 flex overflow-hidden bg-background-base"
classList={{ classList={{
"h-full shrink-0": !props.stacked, "h-full shrink-0": !props.stacked,
"min-h-0 flex-1": props.stacked, "h-full min-h-0": props.stacked,
"pointer-events-none": !open(), "pointer-events-none": !open(),
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active() && !props.reviewSnap, !props.size.active() && !props.reviewSnap,
@ -232,7 +241,7 @@ export function SessionSidePanel(props: {
}} }}
style={{ width: panelWidth() }} style={{ width: panelWidth() }}
> >
<Show when={open()}> <Show when={rendered()}>
<div <div
class="size-full flex" class="size-full flex"
classList={{ classList={{
@ -265,7 +274,11 @@ export function SessionSidePanel(props: {
}} }}
> >
<Show when={reviewTab() && props.canReview()}> <Show when={reviewTab() && props.canReview()}>
<Tabs.Trigger value="review"> <Tabs.Trigger
value="review"
id={reviewTabID}
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div> <div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview()}> <Show when={props.hasReview()}>
@ -328,10 +341,20 @@ export function SessionSidePanel(props: {
</Tabs.List> </Tabs.List>
</div> </div>
<Show when={reviewTab() && props.canReview()}> <Show when={reviewTab() && props.canReview() && reviewContentRendered()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <div
<Show when={reviewOpen() && activeTab() === "review"}>{props.reviewPanel()}</Show> id={reviewTabPanelID}
</Tabs.Content> role="tabpanel"
aria-labelledby={reviewTabID}
aria-hidden={activeTab() !== "review"}
inert={activeTab() !== "review"}
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
data-slot="tabs-content"
class="flex flex-col h-full overflow-hidden contain-strict"
classList={{ hidden: activeTab() !== "review" }}
>
{props.reviewPanel()}
</div>
</Show> </Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">

View file

@ -12,6 +12,13 @@ describe("reviewDiffKinds", () => {
expect(kinds.get("src/b.ts")).toBe("del") expect(kinds.get("src/b.ts")).toBe("del")
expect(kinds.get("src")).toBe("mix") expect(kinds.get("src")).toBe("mix")
}) })
test("normalizes file and directory paths", () => {
const kinds = reviewDiffKinds([{ file: "\\src//lib/a.ts/", additions: 1, deletions: 1, status: "modified" }])
expect(kinds.get("src/lib/a.ts")).toBe("mix")
expect(kinds.get("src/lib")).toBe("mix")
})
}) })
describe("filterReviewFiles", () => { describe("filterReviewFiles", () => {

View file

@ -1,10 +1,11 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Kind } from "@/components/file-tree-v2" import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export function normalizePath(p: string) { export function normalizePath(p: string) {
return p.replaceAll("\\", "/").replace(/\/+$/, "") return normalizeFileTreeV2Path(p)
} }
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {

View file

@ -203,7 +203,6 @@ function ReviewPanelV2Sidebar(props: {
when={props.searching()} when={props.searching()}
fallback={ fallback={
<FileTreeV2 <FileTreeV2
path=""
allowed={props.filteredFiles()} allowed={props.filteredFiles()}
kinds={props.kinds()} kinds={props.kinds()}
draggable={false} draggable={false}

View file

@ -1,9 +1,11 @@
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import "@opencode-ai/ui/v2/file-tree-v2.css" import "@opencode-ai/ui/v2/file-tree-v2.css"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createEffect, For, Show } from "solid-js" import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2" import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
import { normalizePath } from "@/pages/session/v2/review-diff-kinds" import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
import { virtualScrollElement } from "@/components/virtual-scroll-element"
// Drives the highlight/selection of the flat search-result list from the filter // Drives the highlight/selection of the flat search-result list from the filter
// input's keyboard events. // input's keyboard events.
@ -45,62 +47,107 @@ export function SessionFileListV2(props: {
}) { }) {
const active = () => normalizePath(props.active ?? "") const active = () => normalizePath(props.active ?? "")
const highlighted = () => normalizePath(props.highlighted ?? "") const highlighted = () => normalizePath(props.highlighted ?? "")
let rootRef: HTMLDivElement | undefined const normalized = createMemo(() => props.files.map(normalizePath))
const [root, setRoot] = createSignal<HTMLDivElement>()
const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return props.files.length
},
getScrollElement: () => virtualScrollElement(root()),
initialRect: { width: 0, height: 600 },
estimateSize: () => 28,
gap: 2,
overscan: 10,
get getItemKey() {
const files = props.files
return (index: number) => files[index] ?? index
},
rangeExtractor: (range) => {
const indexes = defaultRangeExtractor(range)
const path = focused()
const index = path ? props.files.indexOf(path) : -1
if (index < 0 || indexes.includes(index)) return indexes
return [...indexes, index].sort((a, b) => a - b)
},
})
createEffect(() => { createEffect(() => {
highlighted() const index = normalized().indexOf(highlighted())
if (!rootRef) return if (index < 0) return
queueMicrotask(() => { queueMicrotask(() => {
const row = rootRef?.querySelector<HTMLElement>('[data-slot="file-tree-v2-row"][data-highlighted]') if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
row?.scrollIntoView({ block: "nearest" }) virtualizer.scrollToIndex(index, { align: "auto" })
}) })
}) })
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
return ( return (
<div <div
ref={(el) => { ref={setRoot}
rootRef = el
}}
data-component="file-tree-v2" data-component="file-tree-v2"
data-total-rows={props.files.length}
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
> >
<For each={props.files}> <For each={virtualRowKeys()}>
{(path) => { {(key) => {
const normalized = normalizePath(path) const path = key as string
const selected = () => { const value = normalizePath(path)
if (highlighted()) return highlighted() === normalized const selected = () => (highlighted() ? highlighted() === value : active() === value)
return active() === normalized const highlightedRow = () => highlighted() === value
} const kind = () => props.kinds?.get(value)
const highlightedRow = () => highlighted() === normalized const directory = () => (value.includes("/") ? getDirectory(value) : undefined)
const kind = () => props.kinds?.get(normalized) const filename = () => getFilename(value)
const directory = () => (normalized.includes("/") ? getDirectory(normalized) : undefined)
const filename = () => getFilename(normalized)
return ( return (
<button <Show when={virtualItemByKey().get(key)}>
type="button" {(item) => (
data-slot="file-tree-v2-row" <div
data-selected={selected() ? "" : undefined} style={{
data-highlighted={highlightedRow() ? "" : undefined} position: "absolute",
style="padding-left: 8px" top: "0",
onClick={() => props.onFileClick(path)} left: "0",
> width: "100%",
<span class="filetree-iconpair size-4"> height: `${item().size}px`,
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" /> transform: `translateY(${item().start}px)`,
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono /> }}
</span> >
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap"> <button
<Show when={directory()}> type="button"
{(value) => <span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>} data-slot="file-tree-v2-row"
</Show> data-path={path}
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span> data-selected={selected() ? "" : undefined}
</span> data-highlighted={highlightedRow() ? "" : undefined}
<Show when={kind()}> style="padding-left: 8px"
{(value) => ( onFocus={() => setFocused(path)}
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}> onBlur={() => setFocused(undefined)}
{kindLabel(value())} onClick={() => props.onFileClick(path)}
</span> >
)} <span class="filetree-iconpair size-4">
</Show> <FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
</button> <FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
<Show when={directory()}>
{(value) => (
<span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
)}
</Show>
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
</button>
</div>
)}
</Show>
) )
}} }}
</For> </For>

View file

@ -27,6 +27,21 @@ test("reactive count updates preserve measured row sizes", () => {
}) })
}) })
test("initial rect projects rows before a scroll element connects", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: 100,
getScrollElement: () => null,
estimateSize: () => 28,
initialRect: { width: 0, height: 600 },
overscan: 10,
})
expect(virtualizer.getVirtualItems().length).toBeGreaterThan(0)
dispose()
})
})
test("logical scroll offset includes pending measurement adjustments", () => { test("logical scroll offset includes pending measurement adjustments", () => {
createRoot((dispose) => { createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({ const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({

View file

@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test"
import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize"
describe("shouldVirtualizeReviewDiff", () => {
test("renders small diffs directly", () => {
expect(shouldVirtualizeReviewDiff({ additionLines: 500, deletionLines: 500 })).toBe(false)
})
test("virtualizes large diffs", () => {
expect(shouldVirtualizeReviewDiff({ additionLines: 501, deletionLines: 1 })).toBe(true)
expect(shouldVirtualizeReviewDiff({ additionLines: 1, deletionLines: 501 })).toBe(true)
})
})

View file

@ -0,0 +1,5 @@
const lineThreshold = 500
export function shouldVirtualizeReviewDiff(input: { additionLines: number; deletionLines: number }) {
return Math.max(input.additionLines, input.deletionLines) > lineThreshold
}

View file

@ -22,6 +22,7 @@ import type {
} from "../../components/session-review" } from "../../components/session-review"
import type { SessionReviewExpandMode } from "./session-review-v2" import type { SessionReviewExpandMode } from "./session-review-v2"
import { createLineCommentControllerV2 } from "./line-comment-annotations-v2" import { createLineCommentControllerV2 } from "./line-comment-annotations-v2"
import { shouldVirtualizeReviewDiff } from "./session-review-file-preview-v2-virtualize"
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import "./session-review-v2.css" import "./session-review-v2.css"
@ -219,6 +220,10 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
preloadedDiff={view().preloaded} preloadedDiff={view().preloaded}
diffStyle={props.diffStyle} diffStyle={props.diffStyle}
expandUnchanged={expandUnchanged()} expandUnchanged={expandUnchanged()}
virtualize={shouldVirtualizeReviewDiff({
additionLines: view().fileDiff.additionLines.length,
deletionLines: view().fileDiff.deletionLines.length,
})}
hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"} hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"}
enableLineSelection={lineCommentsEnabled()} enableLineSelection={lineCommentsEnabled()}
enableGutterUtility={lineCommentsEnabled()} enableGutterUtility={lineCommentsEnabled()}

View file

@ -11,6 +11,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js" import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"
import { getWorkerPool } from "../../pierre/worker"
import "./session-review-v2.css" import "./session-review-v2.css"
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240 export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240
@ -48,6 +49,7 @@ export type SessionReviewV2SidebarProps = {
onWidthChange?: (width: number) => void onWidthChange?: (width: number) => void
minWidth?: number minWidth?: number
maxWidth?: number maxWidth?: number
viewportRef?: (element: HTMLDivElement) => void
children?: JSX.Element children?: JSX.Element
} }
@ -74,44 +76,47 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
inert={!props.open} inert={!props.open}
style={{ width: props.open ? `${width()}px` : "0px" }} style={{ width: props.open ? `${width()}px` : "0px" }}
> >
<Show when={props.open}> <div data-slot="session-review-v2-sidebar-header">
<div data-slot="session-review-v2-sidebar-header"> <div data-slot="session-review-v2-sidebar-title">{props.title}</div>
<div data-slot="session-review-v2-sidebar-title">{props.title}</div> {props.stats}
{props.stats} </div>
</div> <div data-slot="session-review-v2-sidebar-filter">
<div data-slot="session-review-v2-sidebar-filter"> <TextInputV2
<TextInputV2 type="search"
type="search" value={props.filter}
value={props.filter} onInput={(event) => props.onFilterChange(event.currentTarget.value)}
onInput={(event) => props.onFilterChange(event.currentTarget.value)} onKeyDown={props.onFilterKeyDown}
onKeyDown={props.onFilterKeyDown} showClearButton={props.filter.length > 0}
showClearButton={props.filter.length > 0} clearLabel={i18n.t("ui.list.clearFilter")}
clearLabel={i18n.t("ui.list.clearFilter")} onClearClick={() => props.onFilterChange("")}
onClearClick={() => props.onFilterChange("")} placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
placeholder={i18n.t("ui.sessionReviewV2.filterFiles")} aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
aria-label={i18n.t("ui.sessionReviewV2.filterFiles")} leadingIcon={
leadingIcon={ <svg
<svg width="14"
width="14" height="14"
height="14" viewBox="0 0 14 14"
viewBox="0 0 14 14" fill="none"
fill="none" xmlns="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true"
aria-hidden="true" >
> <path
<path d="M12.25 12.25L10.0625 10.0625M11.0833 6.41667C11.0833 8.994 8.994 11.0833 6.41667 11.0833C3.83934 11.0833 1.75 8.994 1.75 6.41667C1.75 3.83934 3.83934 1.75 6.41667 1.75C8.994 1.75 11.0833 3.83934 11.0833 6.41667Z"
d="M12.25 12.25L10.0625 10.0625M11.0833 6.41667C11.0833 8.994 8.994 11.0833 6.41667 11.0833C3.83934 11.0833 1.75 8.994 1.75 6.41667C1.75 3.83934 3.83934 1.75 6.41667 1.75C8.994 1.75 11.0833 3.83934 11.0833 6.41667Z" stroke="currentColor"
stroke="currentColor" stroke-linecap="square"
stroke-linecap="square" />
/> </svg>
</svg> }
} />
/> </div>
</div> <ScrollView
<ScrollView data-slot="session-review-v2-sidebar-tree" class="group/file-tree-v2" thumbVisibility="scroll"> data-slot="session-review-v2-sidebar-tree"
{props.children} class="group/file-tree-v2"
</ScrollView> thumbVisibility="scroll"
</Show> viewportRef={props.viewportRef}
>
{props.children}
</ScrollView>
</aside> </aside>
<Show when={props.open && props.onWidthChange}> <Show when={props.open && props.onWidthChange}>
<div data-slot="session-review-v2-sidebar-resize" onPointerDown={() => setResizing(true)}> <div data-slot="session-review-v2-sidebar-resize" onPointerDown={() => setResizing(true)}>
@ -131,6 +136,10 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
export function SessionReviewV2(props: SessionReviewV2Props) { export function SessionReviewV2(props: SessionReviewV2Props) {
const i18n = useI18n() const i18n = useI18n()
createEffect(() => {
getWorkerPool(props.diffStyle)
})
const fileIndex = () => { const fileIndex = () => {
const files = props.files const files = props.files
if (files.length === 0) return -1 if (files.length === 0) return -1