Skip to content

Commit c5cfe1f

Browse files
authored
feat(app): add inline file browser tabs (anomalyco#35829)
1 parent 8b50df4 commit c5cfe1f

19 files changed

Lines changed: 968 additions & 179 deletions
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { base64Encode } from "@opencode-ai/core/util/encode"
2+
import { expect, test } from "@playwright/test"
3+
import { mockOpenCodeServer } from "../utils/mock-server"
4+
import { expectSessionTitle } from "../utils/waits"
5+
6+
const directory = "C:/OpenCode/ReviewOpenFile"
7+
const projectID = "proj_review_open_file"
8+
const sessionID = "ses_review_open_file"
9+
const title = "Review open file"
10+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
11+
12+
test.use({ viewport: { width: 1440, height: 900 } })
13+
14+
test("opens and searches project files inline", async ({ page }) => {
15+
const searches: { query: string; dirs?: string; limit?: number }[] = []
16+
await mockOpenCodeServer(page, {
17+
directory,
18+
project: {
19+
id: projectID,
20+
worktree: directory,
21+
vcs: "git",
22+
name: "open-file-project",
23+
time: { created: 1700000000000, updated: 1700000000000 },
24+
sandboxes: [],
25+
},
26+
provider: {
27+
all: [
28+
{
29+
id: "opencode",
30+
name: "OpenCode",
31+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
32+
},
33+
],
34+
connected: ["opencode"],
35+
default: { providerID: "opencode", modelID: "test" },
36+
},
37+
sessions: [
38+
{
39+
id: sessionID,
40+
slug: sessionID,
41+
projectID,
42+
directory,
43+
title,
44+
version: "dev",
45+
time: { created: 1700000000000, updated: 1700000000000 },
46+
},
47+
],
48+
vcsDiff: [fileDiff("src/changed.ts")],
49+
fileList: (path) => {
50+
if (path) return []
51+
return [
52+
fileNode("README.md"),
53+
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
54+
]
55+
},
56+
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
57+
findFiles: (input) => {
58+
searches.push(input)
59+
return input.query === "nested" ? ["src/nested.ts"] : []
60+
},
61+
pageMessages: () => ({ items: [] }),
62+
})
63+
await page.addInitScript(
64+
({ directory, server, sessionID }) => {
65+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
66+
localStorage.setItem(
67+
"opencode.global.dat:server",
68+
JSON.stringify({
69+
projects: { local: [{ worktree: directory, expanded: true }] },
70+
lastProject: { local: directory },
71+
}),
72+
)
73+
localStorage.setItem(
74+
"opencode.global.dat:layout",
75+
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
76+
)
77+
localStorage.setItem(
78+
"opencode.window.browser.dat:tabs",
79+
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
80+
)
81+
},
82+
{ directory, server, sessionID },
83+
)
84+
85+
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
86+
await expectSessionTitle(page, title)
87+
88+
const panel = page.locator("#review-panel")
89+
const contextButton = page.getByRole("button", { name: "View context usage" })
90+
await contextButton.click()
91+
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
92+
await panel.getByRole("button", { name: "Open file" }).click()
93+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
94+
await contextButton.click()
95+
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
96+
await panel.getByRole("button", { name: "Open file" }).click()
97+
const filter = panel.getByRole("combobox", { name: "Filter files" })
98+
await expect(filter).toBeFocused()
99+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
100+
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
101+
102+
await panel.getByRole("button", { name: "README.md" }).click()
103+
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
104+
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
105+
106+
await panel.getByRole("button", { name: "Open file" }).click()
107+
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
108+
await filter.fill("nested")
109+
const result = panel.getByRole("option", { name: /nested\.ts/ })
110+
await expect(result).toBeVisible()
111+
const resultID = await result.getAttribute("id")
112+
expect(resultID).toBeTruthy()
113+
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
114+
await filter.press("Enter")
115+
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
116+
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
117+
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
118+
119+
await panel.getByRole("button", { name: "Open file" }).click()
120+
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
121+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
122+
await page.keyboard.press("Control+w")
123+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
124+
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
125+
})
126+
127+
function fileNode(path: string) {
128+
return {
129+
name: path,
130+
path,
131+
absolute: `${directory}/${path}`,
132+
type: "file",
133+
ignored: false,
134+
}
135+
}
136+
137+
function fileDiff(file: string) {
138+
return {
139+
file,
140+
before: "before\n",
141+
after: "after\n",
142+
additions: 1,
143+
deletions: 1,
144+
status: "modified",
145+
}
146+
}

packages/app/e2e/utils/mock-server.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface MockServerConfig {
2222
questions?: unknown[] | (() => unknown[])
2323
fileList?: (path: string) => unknown | Promise<unknown>
2424
fileContent?: (path: string) => unknown | Promise<unknown>
25+
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
2526
sessionStatus?: unknown
2627
}
2728

@@ -66,6 +67,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
6667
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
6768
if (path === "/file/content" && config.fileContent)
6869
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
70+
if (path === "/find/file" && config.findFiles)
71+
return json(
72+
route,
73+
await config.findFiles({
74+
query: url.searchParams.get("query") ?? "",
75+
dirs: url.searchParams.get("dirs") ?? undefined,
76+
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
77+
}),
78+
)
6979
if (path === "/api/reference")
7080
return json(route, {
7181
location: {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ export default function FileTree(props: {
201201
kinds?: ReadonlyMap<string, Kind>
202202
draggable?: boolean
203203
onFileClick?: (file: FileNode) => void
204+
onFileDoubleClick?: (file: FileNode) => void
204205

205206
_filter?: Filter
206207
_marks?: Set<string>
@@ -440,6 +441,7 @@ export default function FileTree(props: {
440441
active={props.active}
441442
draggable={props.draggable}
442443
onFileClick={props.onFileClick}
444+
onFileDoubleClick={props.onFileDoubleClick}
443445
_filter={filter()}
444446
_marks={marks()}
445447
_deeps={deeps()}
@@ -462,6 +464,7 @@ export default function FileTree(props: {
462464
as="button"
463465
type="button"
464466
onClick={() => props.onFileClick?.(node)}
467+
onDblClick={() => props.onFileDoubleClick?.(node)}
465468
>
466469
<div class="w-4 shrink-0" />
467470
<Switch>

packages/app/src/components/session-context-usage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
44
import { Button } from "@opencode-ai/ui/button"
55
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
66
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
7+
import { createMediaQuery } from "@solid-primitives/media"
78

89
import { useFile } from "@/context/file"
910
import { useLayout } from "@/context/layout"
@@ -14,6 +15,7 @@ import { useSDK } from "@/context/sdk"
1415
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
1516
import { useSessionLayout } from "@/pages/session/session-layout"
1617
import { createSessionTabs } from "@/pages/session/helpers"
18+
import { useSettings } from "@/context/settings"
1719

1820
interface SessionContextUsageProps {
1921
variant?: "button" | "indicator"
@@ -47,15 +49,18 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
4749
const layout = useLayout()
4850
const language = useLanguage()
4951
const sdk = useSDK()
52+
const settings = useSettings()
5053
const providers = useProviders(() => sdk().directory)
5154
const { params, tabs, view } = useSessionLayout()
55+
const isDesktop = createMediaQuery("(min-width: 768px)")
5256

5357
const variant = createMemo(() => props.variant ?? "button")
5458
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
5559
const tabState = createSessionTabs({
5660
tabs,
5761
pathFromTab: file.pathFromTab,
5862
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
63+
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
5964
})
6065
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
6166
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))

packages/app/src/components/session/session-sortable-tab.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
1010
import { useLanguage } from "@/context/language"
1111
import { useCommand } from "@/context/command"
1212

13-
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
13+
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
1414
return (
1515
<div class="flex items-center gap-x-1.5 min-w-0">
1616
<Show
@@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
2222
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
2323
</span>
2424
</Show>
25-
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
25+
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
26+
{getFilename(props.path)}
27+
</span>
2628
</div>
2729
)
2830
}
2931

30-
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
32+
export function SortableTab(props: {
33+
tab: string
34+
temporary?: boolean
35+
onTabClose: (tab: string) => void
36+
onTabDoubleClick?: (tab: string) => void
37+
}): JSX.Element {
3138
const file = useFile()
3239
const language = useLanguage()
3340
const command = useCommand()
@@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
3643
const content = createMemo(() => {
3744
const value = path()
3845
if (!value) return
39-
return <FileVisual path={value} />
46+
return <FileVisual path={value} temporary={props.temporary} />
4047
})
4148
return (
4249
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
6168
}
6269
hideCloseButton
6370
onMiddleClick={() => props.onTabClose(props.tab)}
71+
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
6472
>
6573
<Show when={content()}>{(value) => value()}</Show>
6674
</Tabs.Trigger>

packages/app/src/context/file.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
203203
return promise
204204
}
205205

206-
const search = (query: string, dirs: "true" | "false") =>
206+
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
207207
sdk()
208-
.client.find.files({ query, dirs })
208+
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
209209
.then(
210210
(x) => (x.data ?? []).map(path.normalize),
211-
() => [],
211+
(error) => {
212+
if (options?.signal?.aborted) throw error
213+
return []
214+
},
212215
)
213216

214217
const stop = sdk().event.listen((e) => {
@@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
284287
setScrollLeft,
285288
selectedLines,
286289
setSelectedLines,
287-
searchFiles: (query: string) => search(query, "false"),
290+
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
291+
search(query, "false", options),
288292
searchFilesAndDirectories: (query: string) => search(query, "true"),
289293
}
290294
},
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
SESSION_OPEN_FILE_TAB,
4+
closeSessionTab,
5+
openSessionTab,
6+
previewSessionTab,
7+
type SessionTabState,
8+
} from "./layout-tabs"
9+
10+
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
11+
tabs: { all, active },
12+
preview,
13+
})
14+
15+
describe("previewSessionTab", () => {
16+
test("appends the Open File placeholder", () => {
17+
expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
18+
state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
19+
)
20+
})
21+
22+
test("replaces the current preview in place", () => {
23+
expect(
24+
previewSessionTab(
25+
state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
26+
"file://a.ts",
27+
),
28+
).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
29+
})
30+
31+
test("activates a durable tab without duplicating it", () => {
32+
expect(
33+
previewSessionTab(
34+
state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
35+
"file://b.ts",
36+
),
37+
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
38+
})
39+
40+
test("replaces a restored Open File placeholder", () => {
41+
expect(
42+
previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
43+
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
44+
})
45+
})
46+
47+
describe("openSessionTab", () => {
48+
test("pins the current preview", () => {
49+
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
50+
state(["file://a.ts"], "file://a.ts"),
51+
)
52+
})
53+
54+
test("replaces a preview with a directly opened file", () => {
55+
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
56+
state(["file://b.ts"], "file://b.ts"),
57+
)
58+
})
59+
60+
test("keeps the preview when switching to Review", () => {
61+
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
62+
state(["file://a.ts"], "review", "file://a.ts"),
63+
)
64+
})
65+
66+
test("replaces a restored Open File placeholder with a direct open", () => {
67+
expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
68+
state(["file://a.ts", "file://b.ts"], "file://b.ts"),
69+
)
70+
})
71+
})
72+
73+
describe("closeSessionTab", () => {
74+
test("clears preview metadata and selects the left neighbor", () => {
75+
expect(
76+
closeSessionTab(
77+
state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
78+
"file://b.ts",
79+
),
80+
).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
81+
})
82+
})

0 commit comments

Comments
 (0)