diff --git a/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts b/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts new file mode 100644 index 000000000000..81de0a055f90 --- /dev/null +++ b/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts @@ -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('#review-panel [data-component="file-tree-v2"]') + const rows = [...document.querySelectorAll('#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("#review-panel") + const tree = panel?.querySelector('[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('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? "" + const viewers = panel + ? [...panel.querySelectorAll('[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 }, + ) +} diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts index 92bf12d51cf5..3a85afebc2c2 100644 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ b/packages/app/e2e/regression/review-tab-switch.spec.ts @@ -25,6 +25,10 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac await expectSessionTitle(page, titleA) 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"]') await expectAppVisible(review) await expectAppVisible(page.getByRole("button", { name: /example\.ts/ })) @@ -33,11 +37,13 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac await switchTab(page, titleB) await expectSessionTitle(page, titleB) await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: /example\.ts/ })) expect(await readProbe(page)).toBe(PROBE) await switchTab(page, titleA) await expectSessionTitle(page, titleA) await expectAppVisible(review) + await expectAppVisible(page.getByRole("button", { name: /example\.ts/ })) expect(await readProbe(page)).toBe(PROBE) }) diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts new file mode 100644 index 000000000000..df74f5be0ec9 --- /dev/null +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -0,0 +1,202 @@ +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 page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + // Terminal activation retries focus through 240ms; let that settle before opening the mode select. + await page.waitForTimeout(300) + await selectMode(page, "Git changes", "Branch changes") + await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible() + await expectTree(page, 2_745, "action.yml") + await expectStackGeometry(page) + + await page.locator('#review-panel [data-component="file-tree-v2"]').evaluate((element) => { + const viewport = element.closest(".scroll-view__viewport")! + viewport.scrollTop = viewport.scrollHeight + }) + 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() + await page.getByRole("option", { name: next }).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(".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("#review-panel")! + const terminal = document.querySelector("#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`, + } +} diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts new file mode 100644 index 000000000000..288f7112e929 --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -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) + }) +}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts new file mode 100644 index 000000000000..2127b6800f86 --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -0,0 +1,77 @@ +import type { FileNode } from "@opencode-ai/sdk/v2" + +export type FileTreeV2Model = { + children: ReadonlyMap + 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() + + 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() + 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 +} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index 302d9b58a353..af6fa6185357 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -1,95 +1,25 @@ import { useFile } from "@/context/file" -import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import "@opencode-ai/ui/v2/file-tree-v2.css" import { createEffect, createMemo, + createSignal, For, - Match, - on, Show, splitProps, - Switch, - untrack, type ComponentProps, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" import { Icon } from "@opencode-ai/ui/v2/icon" -import { - dirsToExpand, - pathToFileUrl, - shouldListRoot, - visibleKind, - withFileDragImage, - type Filter, - type Kind, -} from "@/components/file-tree" +import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" +import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" +import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" 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 function rowPaddingLeft(level: number, type: FileNode["type"]) { @@ -123,7 +53,6 @@ const FileTreeNodeV2 = ( active?: string draggable: boolean kinds?: ReadonlyMap - marks?: Set as?: "div" | "button" }, ) => { @@ -133,18 +62,18 @@ const FileTreeNodeV2 = ( "active", "draggable", "kinds", - "marks", "as", "children", "class", "classList", ]) - const kind = () => visibleKind(local.node, local.kinds, local.marks) + const kind = () => local.kinds?.get(local.node.path) return ( + {(_, index) => ( +
+ )} + + ) +} + export default function FileTreeV2(props: { - path: string active?: string - level?: number allowed?: readonly string[] kinds?: ReadonlyMap draggable?: boolean + scrollElement?: HTMLDivElement onFileClick?: (file: FileNode) => void - - _filter?: Filter - _marks?: Set - _deeps?: Map - _kinds?: ReadonlyMap - _chain?: readonly string[] }) { const file = useFile() - const level = props.level ?? 0 const draggable = () => props.draggable ?? true - - const key = (p: string) => - file - .normalize(p) - .replace(/[\\/]+$/, "") - .replaceAll("\\", "/") - const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] - - const filter = createMemo(() => { - if (props._filter) return props._filter - - const allowed = props.allowed - if (!allowed) return - - const files = new Set(allowed) - const dirs = new Set() - - for (const item of allowed) { - const parts = item.split("/") - const parents = parts.slice(0, -1) - for (const [idx] of parents.entries()) { - const dir = parents.slice(0, idx + 1).join("/") - if (dir) dirs.add(dir) - } - } - - return { files, dirs } - }) - - const marks = createMemo(() => { - if (props._marks) return props._marks - - const out = new Set(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() - - const root = props.path - if (!(file.tree.state(root)?.expanded ?? false)) return out - - const seen = new Set() - 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 + const active = () => normalizeFileTreeV2Path(props.active ?? "") + const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? [])) + const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true)) + const [root, setRoot] = createSignal() + const [focused, setFocused] = createSignal() + const virtualizer = createVirtualizer({ + get count() { + return rows().length + }, + getScrollElement: () => props.scrollElement ?? root()?.closest(".scroll-view__viewport") ?? null, + initialRect: { width: 0, height: 600 }, + estimateSize: () => 28, + gap: 2, + overscan: 10, + get getItemKey() { + const current = rows() + return (index: number) => current[index]?.node.path ?? index + }, + rangeExtractor: (range) => { + const indexes = defaultRangeExtractor(range) + const path = focused() + const index = path ? rows().findIndex((row) => row.node.path === path) : -1 + if (index < 0 || indexes.includes(index)) return indexes + return [...indexes, index].sort((a, b) => a - b) + }, }) - createEffect(() => { - const current = filter() - const dirs = dirsToExpand({ - level, - filter: current, - expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, + const path = active() + if (!path) return + const index = rows().findIndex((row) => row.node.path === path) + if (index < 0) return + 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 }) }) - - createEffect( - on( - () => props.path, - (path) => { - const dir = untrack(() => file.tree.state(path)) - if (!shouldListRoot({ level, dir })) return - void file.tree.list(path) - }, - { defer: false }, - ), + const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) + const virtualItemByKey = createMemo( + () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), ) - - const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter())) + const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) return ( - // group/file-tree-v2 scopes the group-hover guide lines below; hosts may add - // an outer group with the same name to widen the hover area. -
- - {(node) => { - const expanded = () => file.tree.state(node.path)?.expanded ?? false - const deep = () => deeps().get(node.path) ?? -1 - const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0 - return ( - - - - open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path) - } - > - - + + {(key) => ( + + {(item) => ( +
+ + {(row) => ( + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + onClick={() => + props.onFileClick?.({ + ...row().node, + path: row().node.originalPath, + absolute: row().node.originalPath, + }) + } + > + + 0}> +
+ + + + + + + } > -
- -
- - - - -
- ...
} + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + aria-expanded={file.tree.state(row().node.path)?.expanded ?? true} + onClick={() => + file.tree.state(row().node.path)?.expanded === false + ? file.tree.expand(row().node.path, { list: false }) + : file.tree.collapse(row().node.path) + } > - -
- - - - - - props.onFileClick?.(node)} - > - 0}> -
- - } - > - - - - - - - - - ) - }} + +
+ +
+ + + )} + +
+ )} +
+ )}
) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 8b422f713edc..46bd907167a4 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -2157,6 +2157,7 @@ export default function Page() { diffsReady={reviewReady} empty={reviewEmptyText} hasReview={hasReview} + reviewHasFocusableContent={hasReview} reviewCount={reviewCount} reviewPanel={reviewPanel} activeDiff={tree.activeDiff} @@ -2168,14 +2169,20 @@ export default function Page() {
- -
+ +
hasReview() || reviewV2State.sidebarOpened()} reviewCount={reviewCount} reviewPanel={reviewPanelV2} activeDiff={tree.activeDiff} @@ -2204,7 +2211,12 @@ export default function Page() {
-
+
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 690262102039..3f44aba48832 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -14,6 +14,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" 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 { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" @@ -45,6 +48,7 @@ export function SessionSidePanel(props: { diffsReady: () => boolean empty: () => string hasReview: () => boolean + reviewHasFocusableContent: () => boolean reviewCount: () => number reviewPanel: () => JSX.Element activeDiff?: string @@ -75,6 +79,7 @@ export function SessionSidePanel(props: { }), ) const open = createMemo(() => reviewOpen() || fileOpen()) + const rendered = createMemo((previous) => previous || open(), false) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { if (!open()) return "0px" @@ -155,6 +160,10 @@ export function SessionSidePanel(props: { const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab + const reviewContentRendered = createMemo( + (previous) => previous || (reviewOpen() && activeTab() === "review"), + false, + ) const fileTreeTab = () => layout.fileTree.tab() @@ -223,7 +232,7 @@ export function SessionSidePanel(props: { class="relative min-w-0 flex overflow-hidden bg-background-base" classList={{ "h-full shrink-0": !props.stacked, - "min-h-0 flex-1": props.stacked, + "h-full min-h-0": props.stacked, "pointer-events-none": !open(), "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, @@ -232,7 +241,7 @@ export function SessionSidePanel(props: { }} style={{ width: panelWidth() }} > - +
- +
{language.t("session.tab.review")}
@@ -328,10 +341,20 @@ export function SessionSidePanel(props: {
- - - {props.reviewPanel()} - + +
+ {props.reviewPanel()} +
diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.test.ts b/packages/app/src/pages/session/v2/review-diff-kinds.test.ts index b5a26cb32147..ee37a2c8b4c9 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.test.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.test.ts @@ -12,6 +12,13 @@ describe("reviewDiffKinds", () => { expect(kinds.get("src/b.ts")).toBe("del") 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", () => { diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index 4d22d9a67a5c..c288c357068d 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,10 +1,11 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/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 function normalizePath(p: string) { - return p.replaceAll("\\", "/").replace(/\/+$/, "") + return normalizeFileTreeV2Path(p) } export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 4b8e6359c8c6..f6180b3cd3de 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -160,6 +160,7 @@ function ReviewPanelV2Sidebar(props: { }) { const language = useLanguage() const [explicitHighlight, setExplicitHighlight] = createSignal() + const [viewport, setViewport] = createSignal() const highlightedPath = createMemo(() => { if (!props.searching()) return undefined const files = props.filteredFiles() @@ -189,6 +190,7 @@ function ReviewPanelV2Sidebar(props: { onWidthChange={props.state.resizeSidebar} minWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN} maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX} + viewportRef={setViewport} > props.onSelectFile(node.path)} /> } @@ -221,6 +223,7 @@ function ReviewPanelV2Sidebar(props: { kinds={props.kinds()} active={props.activeDiff()} highlighted={highlightedPath()} + scrollElement={viewport()} onFileClick={(path) => { setExplicitHighlight(path) props.onSelectFile(path) diff --git a/packages/app/src/pages/session/v2/session-file-list-v2.tsx b/packages/app/src/pages/session/v2/session-file-list-v2.tsx index dc4da257230f..51b6f11c57e4 100644 --- a/packages/app/src/pages/session/v2/session-file-list-v2.tsx +++ b/packages/app/src/pages/session/v2/session-file-list-v2.tsx @@ -1,9 +1,10 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import "@opencode-ai/ui/v2/file-tree-v2.css" 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 { normalizePath } from "@/pages/session/v2/review-diff-kinds" +import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" // Drives the highlight/selection of the flat search-result list from the filter // input's keyboard events. @@ -41,66 +42,112 @@ export function SessionFileListV2(props: { active?: string highlighted?: string kinds?: ReadonlyMap + scrollElement?: HTMLDivElement onFileClick: (path: string) => void }) { const active = () => normalizePath(props.active ?? "") const highlighted = () => normalizePath(props.highlighted ?? "") - let rootRef: HTMLDivElement | undefined + const normalized = createMemo(() => props.files.map(normalizePath)) + const [root, setRoot] = createSignal() + const [focused, setFocused] = createSignal() + const virtualizer = createVirtualizer({ + get count() { + return props.files.length + }, + getScrollElement: () => props.scrollElement ?? root()?.closest(".scroll-view__viewport") ?? null, + 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(() => { - highlighted() - if (!rootRef) return + const index = normalized().indexOf(highlighted()) + if (index < 0) return queueMicrotask(() => { - const row = rootRef?.querySelector('[data-slot="file-tree-v2-row"][data-highlighted]') - row?.scrollIntoView({ block: "nearest" }) + if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return + 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 (
{ - rootRef = el - }} + ref={setRoot} data-component="file-tree-v2" + data-total-rows={props.files.length} + style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }} > - - {(path) => { - const normalized = normalizePath(path) - const selected = () => { - if (highlighted()) return highlighted() === normalized - return active() === normalized - } - const highlightedRow = () => highlighted() === normalized - const kind = () => props.kinds?.get(normalized) - const directory = () => (normalized.includes("/") ? getDirectory(normalized) : undefined) - const filename = () => getFilename(normalized) + + {(key) => { + const path = key as string + const value = normalizePath(path) + const selected = () => (highlighted() ? highlighted() === value : active() === value) + const highlightedRow = () => highlighted() === value + const kind = () => props.kinds?.get(value) + const directory = () => (value.includes("/") ? getDirectory(value) : undefined) + const filename = () => getFilename(value) return ( - + + {(item) => ( +
+ +
+ )} +
) }}
diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 727248d606e8..716fa7fa9ea7 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -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({ + 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", () => { createRoot((dispose) => { const virtualizer = createVirtualizer({ diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.test.ts b/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.test.ts new file mode 100644 index 000000000000..3bbf173c8bbd --- /dev/null +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.test.ts @@ -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) + }) +}) diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.ts b/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.ts new file mode 100644 index 000000000000..7795a859619f --- /dev/null +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2-virtualize.ts @@ -0,0 +1,5 @@ +const lineThreshold = 500 + +export function shouldVirtualizeReviewDiff(input: { additionLines: number; deletionLines: number }) { + return Math.max(input.additionLines, input.deletionLines) > lineThreshold +} 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 c2607971dff1..3e7bd830519e 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 @@ -22,6 +22,7 @@ import type { } from "../../components/session-review" import type { SessionReviewExpandMode } from "./session-review-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 { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import "./session-review-v2.css" @@ -219,6 +220,10 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop preloadedDiff={view().preloaded} diffStyle={props.diffStyle} expandUnchanged={expandUnchanged()} + virtualize={shouldVirtualizeReviewDiff({ + additionLines: view().fileDiff.additionLines.length, + deletionLines: view().fileDiff.deletionLines.length, + })} hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"} enableLineSelection={lineCommentsEnabled()} enableGutterUtility={lineCommentsEnabled()} diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index 34ee36532ab5..176dce4fc983 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -11,6 +11,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { makeEventListener } from "@solid-primitives/event-listener" import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js" +import { getWorkerPool } from "../../pierre/worker" import "./session-review-v2.css" export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240 @@ -48,6 +49,7 @@ export type SessionReviewV2SidebarProps = { onWidthChange?: (width: number) => void minWidth?: number maxWidth?: number + viewportRef?: (element: HTMLDivElement) => void children?: JSX.Element } @@ -74,44 +76,47 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { inert={!props.open} style={{ width: props.open ? `${width()}px` : "0px" }} > - -
-
{props.title}
- {props.stats} -
-
- props.onFilterChange(event.currentTarget.value)} - onKeyDown={props.onFilterKeyDown} - showClearButton={props.filter.length > 0} - clearLabel={i18n.t("ui.list.clearFilter")} - onClearClick={() => props.onFilterChange("")} - placeholder={i18n.t("ui.sessionReviewV2.filterFiles")} - aria-label={i18n.t("ui.sessionReviewV2.filterFiles")} - leadingIcon={ - - } - /> -
- - {props.children} - -
+
+
{props.title}
+ {props.stats} +
+
+ props.onFilterChange(event.currentTarget.value)} + onKeyDown={props.onFilterKeyDown} + showClearButton={props.filter.length > 0} + clearLabel={i18n.t("ui.list.clearFilter")} + onClearClick={() => props.onFilterChange("")} + placeholder={i18n.t("ui.sessionReviewV2.filterFiles")} + aria-label={i18n.t("ui.sessionReviewV2.filterFiles")} + leadingIcon={ + + } + /> +
+ + {props.children} +
setResizing(true)}> @@ -131,6 +136,10 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { export function SessionReviewV2(props: SessionReviewV2Props) { const i18n = useI18n() + createEffect(() => { + getWorkerPool(props.diffStyle) + }) + const fileIndex = () => { const files = props.files if (files.length === 0) return -1