fix(app): optimize large review panes (#35375)
This commit is contained in:
parent
14df88eab5
commit
3a149ba71c
19 changed files with 1074 additions and 404 deletions
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
212
packages/app/e2e/regression/review-terminal-stacked.spec.ts
Normal file
212
packages/app/e2e/regression/review-terminal-stacked.spec.ts
Normal 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`,
|
||||||
|
}
|
||||||
|
}
|
||||||
46
packages/app/src/components/file-tree-v2-model.test.ts
Normal file
46
packages/app/src/components/file-tree-v2-model.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
77
packages/app/src/components/file-tree-v2-model.ts
Normal file
77
packages/app/src/components/file-tree-v2-model.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
18
packages/app/src/components/virtual-scroll-element.test.ts
Normal file
18
packages/app/src/components/virtual-scroll-element.test.ts
Normal 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()
|
||||||
|
})
|
||||||
4
packages/app/src/components/virtual-scroll-element.ts
Normal file
4
packages/app/src/components/virtual-scroll-element.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export function virtualScrollElement(root: HTMLElement | undefined) {
|
||||||
|
if (!root?.isConnected) return null
|
||||||
|
return root.closest<HTMLDivElement>(".scroll-view__viewport")
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>({
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
const lineThreshold = 500
|
||||||
|
|
||||||
|
export function shouldVirtualizeReviewDiff(input: { additionLines: number; deletionLines: number }) {
|
||||||
|
return Math.max(input.additionLines, input.deletionLines) > lineThreshold
|
||||||
|
}
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue