Skip to content

Commit af14e43

Browse files
committed
fix(app): preserve virtual review tree lifecycle
1 parent 7a5010f commit af14e43

7 files changed

Lines changed: 133 additions & 53 deletions

File tree

packages/app/e2e/regression/review-terminal-stacked.spec.ts

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from "@playwright/test"
1+
import { expect, test, type Page } from "@playwright/test"
22
import { mockOpenCodeServer } from "../utils/mock-server"
33
import { expectSessionTitle } from "../utils/waits"
44

@@ -14,6 +14,7 @@ const branchDiffs = [
1414
]
1515

1616
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
17+
test.setTimeout(120_000)
1718
await page.setViewportSize({ width: 1400, height: 900 })
1819
await mockOpenCodeServer(page, {
1920
directory,
@@ -91,17 +92,73 @@ test("keeps the review tree and terminal sized when both panels are open", async
9192
await expect(page.locator("#terminal-panel")).toBeVisible()
9293
// Terminal activation retries focus through 240ms; let that settle before opening the mode select.
9394
await page.waitForTimeout(300)
94-
await page.getByRole("button", { name: "Git changes" }).click()
95-
await page.getByRole("option", { name: "Branch changes" }).click()
95+
await selectMode(page, "Git changes", "Branch changes")
9696
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
97-
await expect(page.getByRole("button", { name: "action.yml" })).toBeVisible()
97+
await expectTree(page, 2_745, "action.yml")
98+
await expectStackGeometry(page)
9899

100+
await page.locator('#review-panel [data-component="file-tree-v2"]').evaluate((element) => {
101+
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!
102+
viewport.scrollTop = viewport.scrollHeight
103+
})
104+
await selectMode(page, "Branch changes", "Git changes")
105+
await expectTree(page, 2, "git.ts")
106+
await selectMode(page, "Git changes", "Branch changes")
107+
await expectTree(page, 2_745, "action.yml")
108+
109+
const filter = page.getByRole("searchbox", { name: "Filter files" })
110+
await filter.fill("generated-2738")
111+
await expectTree(page, 1, "generated-2738.ts")
112+
await filter.fill("")
113+
await expectTree(page, 2_745, "action.yml")
114+
115+
await page.getByRole("button", { name: "Toggle file tree" }).click()
116+
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
117+
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
118+
await page.getByRole("button", { name: "Toggle file tree" }).click()
119+
await expectTree(page, 2_745, "action.yml")
120+
121+
await page.keyboard.press("Control+Backquote")
122+
await expect(page.locator("#terminal-panel")).toHaveCount(0)
123+
await expectTree(page, 2_745, "action.yml")
124+
await page.keyboard.press("Control+Backquote")
125+
await expect(page.locator("#terminal-panel")).toBeVisible()
126+
await expectTree(page, 2_745, "action.yml")
127+
128+
await page.getByRole("button", { name: "Toggle review" }).click()
129+
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
130+
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
131+
await page.getByRole("button", { name: "Toggle review" }).click()
132+
await expectTree(page, 2_745, "action.yml")
133+
await page.setViewportSize({ width: 1_000, height: 700 })
134+
await expectTree(page, 2_745, "action.yml")
135+
await expectStackGeometry(page)
136+
})
137+
138+
async function selectMode(page: Page, current: string, next: string) {
139+
await page.getByRole("button", { name: current }).click()
140+
await page.getByRole("option", { name: next }).click()
141+
}
142+
143+
async function expectTree(page: Page, total: number, file: string) {
99144
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
100-
await expect(tree).toHaveAttribute("data-total-rows", "2745")
101-
const renderedRows = await tree.locator('[data-slot="file-tree-v2-row"]').count()
102-
expect(renderedRows).toBeGreaterThan(0)
103-
expect(renderedRows).toBeLessThanOrEqual(60)
145+
await expect(tree).toHaveAttribute("data-total-rows", String(total))
146+
await expect
147+
.poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
148+
.toBeGreaterThan(0)
149+
const state = await tree.evaluate((element) => ({
150+
root: element.getBoundingClientRect().height,
151+
viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
152+
rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
153+
}))
154+
expect(state.viewport).toBeGreaterThan(0)
155+
expect(state.root).toBeGreaterThan(0)
156+
expect(state.rows).toBeGreaterThan(0)
157+
expect(state.rows).toBeLessThanOrEqual(60)
158+
await expect(page.getByRole("button", { name: file })).toBeVisible()
159+
}
104160

161+
async function expectStackGeometry(page: Page) {
105162
const geometry = await page.evaluate(() => {
106163
const review = document.querySelector<HTMLElement>("#review-panel")!
107164
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
@@ -116,7 +173,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
116173
})
117174
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
118175
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
119-
})
176+
}
120177

121178
function base64Encode(value: string) {
122179
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")

packages/app/src/components/file-tree-v2.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export default function FileTreeV2(props: {
124124
allowed?: readonly string[]
125125
kinds?: ReadonlyMap<string, Kind>
126126
draggable?: boolean
127+
scrollElement?: HTMLDivElement
127128
onFileClick?: (file: FileNode) => void
128129
}) {
129130
const file = useFile()
@@ -137,7 +138,7 @@ export default function FileTreeV2(props: {
137138
get count() {
138139
return rows().length
139140
},
140-
getScrollElement: () => root()?.closest<HTMLDivElement>(".scroll-view__viewport") ?? null,
141+
getScrollElement: () => props.scrollElement ?? root()?.closest<HTMLDivElement>(".scroll-view__viewport") ?? null,
141142
estimateSize: () => 28,
142143
gap: 2,
143144
overscan: 10,

packages/app/src/pages/session.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2169,8 +2169,13 @@ export default function Page() {
21692169
<Show when={newSessionDesign()}>
21702170
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
21712171
<div class="min-w-0 h-full flex flex-1 flex-col">
2172-
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
2173-
<div class="min-h-0 flex-1">
2172+
<Show when={isDesktop()}>
2173+
<div
2174+
classList={{
2175+
"min-h-0 flex-1": desktopV2ReviewOpen() || desktopFileTreeOpen(),
2176+
"size-0 shrink-0 overflow-hidden": !(desktopV2ReviewOpen() || desktopFileTreeOpen()),
2177+
}}
2178+
>
21742179
<SessionSidePanel
21752180
canReview={canReview}
21762181
diffs={reviewDiffs}

packages/app/src/pages/session/session-side-panel.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export function SessionSidePanel(props: {
7979
}),
8080
)
8181
const open = createMemo(() => reviewOpen() || fileOpen())
82+
const rendered = createMemo<boolean>((previous) => previous || open(), false)
8283
const reviewTab = createMemo(() => isDesktop())
8384
const panelWidth = createMemo(() => {
8485
if (!open()) return "0px"
@@ -159,6 +160,10 @@ export function SessionSidePanel(props: {
159160
const openedTabs = tabState.openedTabs
160161
const activeTab = tabState.activeTab
161162
const activeFileTab = tabState.activeFileTab
163+
const reviewContentRendered = createMemo<boolean>(
164+
(previous) => previous || (reviewOpen() && activeTab() === "review"),
165+
false,
166+
)
162167

163168
const fileTreeTab = () => layout.fileTree.tab()
164169

@@ -236,7 +241,7 @@ export function SessionSidePanel(props: {
236241
}}
237242
style={{ width: panelWidth() }}
238243
>
239-
<Show when={open()}>
244+
<Show when={rendered()}>
240245
<div
241246
class="size-full flex"
242247
classList={{
@@ -336,14 +341,17 @@ export function SessionSidePanel(props: {
336341
</Tabs.List>
337342
</div>
338343

339-
<Show when={reviewTab() && props.canReview() && reviewOpen() && activeTab() === "review"}>
344+
<Show when={reviewTab() && props.canReview() && reviewContentRendered()}>
340345
<div
341346
id={reviewTabPanelID}
342347
role="tabpanel"
343348
aria-labelledby={reviewTabID}
349+
aria-hidden={activeTab() !== "review"}
350+
inert={activeTab() !== "review"}
344351
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
345352
data-slot="tabs-content"
346353
class="flex flex-col h-full overflow-hidden contain-strict"
354+
classList={{ hidden: activeTab() !== "review" }}
347355
>
348356
{props.reviewPanel()}
349357
</div>

packages/app/src/pages/session/v2/review-panel-v2.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ function ReviewPanelV2Sidebar(props: {
160160
}) {
161161
const language = useLanguage()
162162
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
163+
const [viewport, setViewport] = createSignal<HTMLDivElement>()
163164
const highlightedPath = createMemo(() => {
164165
if (!props.searching()) return undefined
165166
const files = props.filteredFiles()
@@ -189,6 +190,7 @@ function ReviewPanelV2Sidebar(props: {
189190
onWidthChange={props.state.resizeSidebar}
190191
minWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN}
191192
maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX}
193+
viewportRef={setViewport}
192194
>
193195
<Show
194196
when={props.diffsReady()}
@@ -207,6 +209,7 @@ function ReviewPanelV2Sidebar(props: {
207209
kinds={props.kinds()}
208210
draggable={false}
209211
active={props.activeDiff()}
212+
scrollElement={viewport()}
210213
onFileClick={(node) => props.onSelectFile(node.path)}
211214
/>
212215
}
@@ -220,6 +223,7 @@ function ReviewPanelV2Sidebar(props: {
220223
kinds={props.kinds()}
221224
active={props.activeDiff()}
222225
highlighted={highlightedPath()}
226+
scrollElement={viewport()}
223227
onFileClick={(path) => {
224228
setExplicitHighlight(path)
225229
props.onSelectFile(path)

packages/app/src/pages/session/v2/session-file-list-v2.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function SessionFileListV2(props: {
4242
active?: string
4343
highlighted?: string
4444
kinds?: ReadonlyMap<string, Kind>
45+
scrollElement?: HTMLDivElement
4546
onFileClick: (path: string) => void
4647
}) {
4748
const active = () => normalizePath(props.active ?? "")
@@ -53,7 +54,7 @@ export function SessionFileListV2(props: {
5354
get count() {
5455
return props.files.length
5556
},
56-
getScrollElement: () => root()?.closest<HTMLDivElement>(".scroll-view__viewport") ?? null,
57+
getScrollElement: () => props.scrollElement ?? root()?.closest<HTMLDivElement>(".scroll-view__viewport") ?? null,
5758
estimateSize: () => 28,
5859
gap: 2,
5960
overscan: 10,

packages/session-ui/src/v2/components/session-review-v2.tsx

Lines changed: 42 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type SessionReviewV2SidebarProps = {
4949
onWidthChange?: (width: number) => void
5050
minWidth?: number
5151
maxWidth?: number
52+
viewportRef?: (element: HTMLDivElement) => void
5253
children?: JSX.Element
5354
}
5455

@@ -75,44 +76,47 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
7576
inert={!props.open}
7677
style={{ width: props.open ? `${width()}px` : "0px" }}
7778
>
78-
<Show when={props.open}>
79-
<div data-slot="session-review-v2-sidebar-header">
80-
<div data-slot="session-review-v2-sidebar-title">{props.title}</div>
81-
{props.stats}
82-
</div>
83-
<div data-slot="session-review-v2-sidebar-filter">
84-
<TextInputV2
85-
type="search"
86-
value={props.filter}
87-
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
88-
onKeyDown={props.onFilterKeyDown}
89-
showClearButton={props.filter.length > 0}
90-
clearLabel={i18n.t("ui.list.clearFilter")}
91-
onClearClick={() => props.onFilterChange("")}
92-
placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
93-
aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
94-
leadingIcon={
95-
<svg
96-
width="14"
97-
height="14"
98-
viewBox="0 0 14 14"
99-
fill="none"
100-
xmlns="http://www.w3.org/2000/svg"
101-
aria-hidden="true"
102-
>
103-
<path
104-
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"
105-
stroke="currentColor"
106-
stroke-linecap="square"
107-
/>
108-
</svg>
109-
}
110-
/>
111-
</div>
112-
<ScrollView data-slot="session-review-v2-sidebar-tree" class="group/file-tree-v2" thumbVisibility="scroll">
113-
{props.children}
114-
</ScrollView>
115-
</Show>
79+
<div data-slot="session-review-v2-sidebar-header">
80+
<div data-slot="session-review-v2-sidebar-title">{props.title}</div>
81+
{props.stats}
82+
</div>
83+
<div data-slot="session-review-v2-sidebar-filter">
84+
<TextInputV2
85+
type="search"
86+
value={props.filter}
87+
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
88+
onKeyDown={props.onFilterKeyDown}
89+
showClearButton={props.filter.length > 0}
90+
clearLabel={i18n.t("ui.list.clearFilter")}
91+
onClearClick={() => props.onFilterChange("")}
92+
placeholder={i18n.t("ui.sessionReviewV2.filterFiles")}
93+
aria-label={i18n.t("ui.sessionReviewV2.filterFiles")}
94+
leadingIcon={
95+
<svg
96+
width="14"
97+
height="14"
98+
viewBox="0 0 14 14"
99+
fill="none"
100+
xmlns="http://www.w3.org/2000/svg"
101+
aria-hidden="true"
102+
>
103+
<path
104+
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"
105+
stroke="currentColor"
106+
stroke-linecap="square"
107+
/>
108+
</svg>
109+
}
110+
/>
111+
</div>
112+
<ScrollView
113+
data-slot="session-review-v2-sidebar-tree"
114+
class="group/file-tree-v2"
115+
thumbVisibility="scroll"
116+
viewportRef={props.viewportRef}
117+
>
118+
{props.children}
119+
</ScrollView>
116120
</aside>
117121
<Show when={props.open && props.onWidthChange}>
118122
<div data-slot="session-review-v2-sidebar-resize" onPointerDown={() => setResizing(true)}>

0 commit comments

Comments
 (0)