From 62de2b3695a44fe44b7783322b4599b4093d3a7b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:09:21 +1000 Subject: [PATCH] feat(app): add inline file browser tabs (#35829) --- .../e2e/regression/review-open-file.spec.ts | 146 ++++++++++ packages/app/e2e/utils/mock-server.ts | 10 + packages/app/src/components/file-tree.tsx | 3 + .../src/components/session-context-usage.tsx | 5 + .../session/session-sortable-tab.tsx | 16 +- packages/app/src/context/file.tsx | 12 +- packages/app/src/context/layout-tabs.test.ts | 82 ++++++ packages/app/src/context/layout-tabs.ts | 103 +++++++ packages/app/src/context/layout.tsx | 84 +++--- packages/app/src/pages/session.tsx | 2 + packages/app/src/pages/session/file-tabs.tsx | 16 +- .../app/src/pages/session/helpers.test.ts | 46 ++++ packages/app/src/pages/session/helpers.ts | 20 +- .../src/pages/session/session-side-panel.tsx | 103 ++++++- .../pages/session/use-session-commands.tsx | 2 + .../session/v2/session-file-browser-tab.tsx | 193 ++++++++++++++ .../pages/session/v2/session-file-list-v2.tsx | 10 + .../v2/components/session-file-panel-v2.tsx | 43 +++ .../src/v2/components/session-review-v2.tsx | 251 +++++++++--------- 19 files changed, 968 insertions(+), 179 deletions(-) create mode 100644 packages/app/e2e/regression/review-open-file.spec.ts create mode 100644 packages/app/src/context/layout-tabs.test.ts create mode 100644 packages/app/src/context/layout-tabs.ts create mode 100644 packages/app/src/pages/session/v2/session-file-browser-tab.tsx create mode 100644 packages/session-ui/src/v2/components/session-file-panel-v2.tsx diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts new file mode 100644 index 0000000000..f69591f608 --- /dev/null +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -0,0 +1,146 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ReviewOpenFile" +const projectID = "proj_review_open_file" +const sessionID = "ses_review_open_file" +const title = "Review open file" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("opens and searches project files inline", async ({ page }) => { + const searches: { query: string; dirs?: string; limit?: number }[] = [] + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "open-file-project", + 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: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [fileDiff("src/changed.ts")], + fileList: (path) => { + if (path) return [] + return [ + fileNode("README.md"), + { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, + ] + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + findFiles: (input) => { + searches.push(input) + return input.query === "nested" ? ["src/nested.ts"] : [] + }, + pageMessages: () => ({ items: [] }), + }) + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + const contextButton = page.getByRole("button", { name: "View context usage" }) + await contextButton.click() + await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await contextButton.click() + await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await panel.getByRole("button", { name: "Open file" }).click() + const filter = panel.getByRole("combobox", { name: "Filter files" }) + await expect(filter).toBeFocused() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible() + + await panel.getByRole("button", { name: "README.md" }).click() + await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() + + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) + await filter.fill("nested") + const result = panel.getByRole("option", { name: /nested\.ts/ }) + await expect(result).toBeVisible() + const resultID = await result.getAttribute("id") + expect(resultID).toBeTruthy() + await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) + await filter.press("Enter") + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() + expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) + + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await page.keyboard.press("Control+w") + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) + await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") +}) + +function fileNode(path: string) { + return { + name: path, + path, + absolute: `${directory}/${path}`, + type: "file", + ignored: false, + } +} + +function fileDiff(file: string) { + return { + file, + before: "before\n", + after: "after\n", + additions: 1, + deletions: 1, + status: "modified", + } +} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e5df40c7d2..e1946b8fba 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -22,6 +22,7 @@ export interface MockServerConfig { questions?: unknown[] | (() => unknown[]) fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise + findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown sessionStatus?: unknown } @@ -66,6 +67,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, await config.fileList(url.searchParams.get("path") ?? "")) if (path === "/file/content" && config.fileContent) return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) + if (path === "/find/file" && config.findFiles) + return json( + route, + await config.findFiles({ + query: url.searchParams.get("query") ?? "", + dirs: url.searchParams.get("dirs") ?? undefined, + limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, + }), + ) if (path === "/api/reference") return json(route, { location: { diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 93ff46d5c1..718e2f3493 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -201,6 +201,7 @@ export default function FileTree(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void _filter?: Filter _marks?: Set @@ -440,6 +441,7 @@ export default function FileTree(props: { active={props.active} draggable={props.draggable} onFileClick={props.onFileClick} + onFileDoubleClick={props.onFileDoubleClick} _filter={filter()} _marks={marks()} _deeps={deeps()} @@ -462,6 +464,7 @@ export default function FileTree(props: { as="button" type="button" onClick={() => props.onFileClick?.(node)} + onDblClick={() => props.onFileDoubleClick?.(node)} >
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 7ac1aa9965..fd2d1dd1a3 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2" import { Button } from "@opencode-ai/ui/button" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { createMediaQuery } from "@solid-primitives/media" import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" @@ -14,6 +15,7 @@ import { useSDK } from "@/context/sdk" import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" +import { useSettings } from "@/context/settings" interface SessionContextUsageProps { variant?: "button" | "indicator" @@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const layout = useLayout() const language = useLanguage() const sdk = useSDK() + const settings = useSettings() const providers = useProviders(() => sdk().directory) const { params, tabs, view } = useSessionLayout() + const isDesktop = createMediaQuery("(min-width: 768px)") const variant = createMemo(() => props.variant ?? "button") const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default") @@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { tabs, pathFromTab: file.pathFromTab, normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id, }) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index f04228ca66..d78f392941 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -10,7 +10,7 @@ import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" -export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { +export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element { return (
- {getFilename(props.path)} + + {getFilename(props.path)} +
) } -export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { +export function SortableTab(props: { + tab: string + temporary?: boolean + onTabClose: (tab: string) => void + onTabDoubleClick?: (tab: string) => void +}): JSX.Element { const file = useFile() const language = useLanguage() const command = useCommand() @@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v const content = createMemo(() => { const value = path() if (!value) return - return + return }) return (
@@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} + onDblClick={() => props.onTabDoubleClick?.(props.tab)} > {(value) => value()} diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index f7668c1949..6032b81dde 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return promise } - const search = (query: string, dirs: "true" | "false") => + const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => sdk() - .client.find.files({ query, dirs }) + .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal }) .then( (x) => (x.data ?? []).map(path.normalize), - () => [], + (error) => { + if (options?.signal?.aborted) throw error + return [] + }, ) const stop = sdk().event.listen((e) => { @@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ setScrollLeft, selectedLines, setSelectedLines, - searchFiles: (query: string) => search(query, "false"), + searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) => + search(query, "false", options), searchFilesAndDirectories: (query: string) => search(query, "true"), } }, diff --git a/packages/app/src/context/layout-tabs.test.ts b/packages/app/src/context/layout-tabs.test.ts new file mode 100644 index 0000000000..646ed086ce --- /dev/null +++ b/packages/app/src/context/layout-tabs.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { + SESSION_OPEN_FILE_TAB, + closeSessionTab, + openSessionTab, + previewSessionTab, + type SessionTabState, +} from "./layout-tabs" + +const state = (all: string[], active?: string, preview?: string): SessionTabState => ({ + tabs: { all, active }, + preview, +}) + +describe("previewSessionTab", () => { + test("appends the Open File placeholder", () => { + expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual( + state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB), + ) + }) + + test("replaces the current preview in place", () => { + expect( + previewSessionTab( + state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB), + "file://a.ts", + ), + ).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts")) + }) + + test("activates a durable tab without duplicating it", () => { + expect( + previewSessionTab( + state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB), + "file://b.ts", + ), + ).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts")) + }) + + test("replaces a restored Open File placeholder", () => { + expect( + previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"), + ).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts")) + }) +}) + +describe("openSessionTab", () => { + test("pins the current preview", () => { + expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual( + state(["file://a.ts"], "file://a.ts"), + ) + }) + + test("replaces a preview with a directly opened file", () => { + expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual( + state(["file://b.ts"], "file://b.ts"), + ) + }) + + test("keeps the preview when switching to Review", () => { + expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual( + state(["file://a.ts"], "review", "file://a.ts"), + ) + }) + + test("replaces a restored Open File placeholder with a direct open", () => { + expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual( + state(["file://a.ts", "file://b.ts"], "file://b.ts"), + ) + }) +}) + +describe("closeSessionTab", () => { + test("clears preview metadata and selects the left neighbor", () => { + expect( + closeSessionTab( + state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"), + "file://b.ts", + ), + ).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts")) + }) +}) diff --git a/packages/app/src/context/layout-tabs.ts b/packages/app/src/context/layout-tabs.ts new file mode 100644 index 0000000000..ea4c675ff4 --- /dev/null +++ b/packages/app/src/context/layout-tabs.ts @@ -0,0 +1,103 @@ +export const SESSION_OPEN_FILE_TAB = "open-file" + +export type SessionTabs = { + active?: string + all: string[] +} + +export type SessionTabState = { + tabs: SessionTabs + preview?: string +} + +const sessionTabPreview = (current: SessionTabState) => + current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined) + +export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState { + const preview = sessionTabPreview(current) + const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1 + const existingIndex = current.tabs.all.indexOf(tab) + + if (existingIndex !== -1) { + if (previewIndex === -1 || preview === tab) { + return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined } + } + return { + tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab }, + } + } + + if (previewIndex === -1) { + return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab } + } + + return { + tabs: { + all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)), + active: tab, + }, + preview: tab, + } +} + +export function openSessionTab(current: SessionTabState, tab: string): SessionTabState { + const preview = sessionTabPreview(current) + if (tab === "review") { + return { + tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab }, + preview, + } + } + + if (tab === "context") { + return { + tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab }, + preview, + } + } + + const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1 + const existingIndex = current.tabs.all.indexOf(tab) + if (existingIndex !== -1) { + if (previewIndex === -1 || preview === tab) { + return { tabs: { all: current.tabs.all, active: tab } } + } + return { + tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab }, + } + } + + if (previewIndex === -1) { + return { tabs: { all: [...current.tabs.all, tab], active: tab } } + } + + return { + tabs: { + all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)), + active: tab, + }, + } +} + +export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState { + if (tab === "review") { + if (current.tabs.active !== tab) return current + return { + tabs: { all: current.tabs.all, active: current.tabs.all[0] }, + preview: current.preview, + } + } + + const all = current.tabs.all.filter((item) => item !== tab) + const preview = current.preview === tab ? undefined : current.preview + if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview } + + const index = current.tabs.all.indexOf(tab) + return { + tabs: { + all, + active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0], + }, + preview, + } +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index b09a77eb33..3b2b34752b 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { requireServerKey } from "@/utils/session-route" import { type DraftTab, useTabs } from "./tabs" +import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -55,11 +56,6 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { return "gray" } -type SessionTabs = { - active?: string - all: string[] -} - type SessionView = { scroll: Record reviewOpen?: string[] @@ -87,14 +83,6 @@ export type LayoutRoute = | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } | { type: "session"; sessionId: string; server?: ServerConnection.Key } -function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { - const all = current?.all ?? [] - if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab } - if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab } - if (!all.includes(tab)) return { all: [...all, tab], active: tab } - return { all, active: tab } -} - const sessionPath = (key: string) => { const dir = SessionStateKey.route(key).split("/")[0] if (!dir) return @@ -308,6 +296,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) const [ephemeral, setEphemeral] = createStore({ reviewPanelSource: "other" as ReviewPanelSource, + sessionTabPreview: {} as Record, }) const MAX_SESSION_KEYS = 50 @@ -366,6 +355,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( scroll.drop(drop) dropSessionState(drop) + setEphemeral( + "sessionTabPreview", + produce((draft) => { + for (const key of drop) delete draft[key] + }), + ) for (const key of drop) { usage.used.delete(key) @@ -945,10 +940,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) const normalize = (tab: string) => normalizeSessionTab(path(), tab) const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all) + const apply = (session: string, next: ReturnType) => { + batch(() => { + setStore("sessionTabs", session, next.tabs) + setEphemeral("sessionTabPreview", session, next.preview) + }) + } return { tabs, active: createMemo(() => tabs().active), all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), + preview: createMemo(() => ephemeral.sessionTabPreview[key()]), setActive(tab: string | undefined) { const session = key() const next = tab ? normalize(tab) : tab @@ -961,40 +963,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setAll(all: string[]) { const session = key() const next = normalizeAll(all).filter((tab) => tab !== "review") - if (!store.sessionTabs[session]) { - setStore("sessionTabs", session, { all: next, active: undefined }) - } else { - setStore("sessionTabs", session, "all", next) - } + batch(() => { + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: next, active: undefined }) + } else { + setStore("sessionTabs", session, "all", next) + } + const preview = ephemeral.sessionTabPreview[session] + if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined) + }) }, async open(tab: string) { const session = key() - const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab)) - setStore("sessionTabs", session, next) + apply( + session, + openSessionTab( + { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, + normalize(tab), + ), + ) + }, + previewTab(tab: string) { + const session = key() + apply( + session, + previewSessionTab( + { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, + normalize(tab), + ), + ) }, close(tab: string) { const session = key() const current = store.sessionTabs[session] if (!current) return - - if (tab === "review") { - if (current.active !== tab) return - setStore("sessionTabs", session, "active", current.all[0]) - return - } - - const all = current.all.filter((x) => x !== tab) - if (current.active !== tab) { - setStore("sessionTabs", session, "all", all) - return - } - - const index = current.all.findIndex((f) => f === tab) - const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0] - batch(() => { - setStore("sessionTabs", session, "all", all) - setStore("sessionTabs", session, "active", next) - }) + apply( + session, + closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)), + ) }, move(tab: string, to: number) { const session = key() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 84f6b01309..6080e5c95f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1019,6 +1019,7 @@ export default function Page() { setActiveMessage, focusInput, review: reviewTab, + fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id, }) const openReviewFile = createOpenReviewFile({ @@ -2177,6 +2178,7 @@ export default function Page() { reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()} reviewCount={reviewCount} reviewPanel={reviewPanelV2} + fileBrowserState={reviewV2State} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} reviewSnap={ui.reviewSnap} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 7d110ee544..dd51dd0585 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -172,6 +172,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType + + + ) +} + +export function SessionFileView(props: { tab: string }) { const file = useFile() const comments = useComments() const language = useLanguage() @@ -439,8 +447,8 @@ export function FileTabContent(props: { tab: string }) {
) - return ( - + const content = () => ( +
{renderFile(contents())} @@ -450,6 +458,8 @@ export function FileTabContent(props: { tab: string }) { {(err) =>
{err()}
}
- +
) + + return content() } diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index b70917ae9b..c045025628 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { createMemo, createRoot } from "solid-js" import { createStore } from "solid-js/store" import { + SESSION_OPEN_FILE_TAB, createOpenReviewFile, createOpenSessionFileTab, createSessionTabs, @@ -165,4 +166,49 @@ describe("createSessionTabs", () => { dispose() }) }) + + test("exposes the Open File tab without treating it as a file tab", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: SESSION_OPEN_FILE_TAB as string | undefined, + all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => tab, + fileBrowser: () => true, + }) + + expect(result.openFileOpen()).toBe(true) + expect(result.panelTabs()).toEqual(["file://src/a.ts", SESSION_OPEN_FILE_TAB]) + expect(result.openedTabs()).toEqual(["file://src/a.ts"]) + expect(result.activeTab()).toBe(SESSION_OPEN_FILE_TAB) + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBe(SESSION_OPEN_FILE_TAB) + dispose() + }) + }) + + test("hides the Open File placeholder when the file browser is unavailable", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: SESSION_OPEN_FILE_TAB as string | undefined, + all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => tab, + fileBrowser: () => false, + }) + + expect(result.openFileOpen()).toBe(false) + expect(result.panelTabs()).toEqual(["file://src/a.ts"]) + expect(result.activeTab()).toBe("file://src/a.ts") + dispose() + }) + }) }) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index fcbc681acd..c43f6bc6a3 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -2,6 +2,9 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { same } from "@/utils/same" +import { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs" + +export { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs" const emptyTabs: string[] = [] @@ -16,6 +19,7 @@ type TabsInput = { normalizeTab: (tab: string) => string review?: Accessor hasReview?: Accessor + fileBrowser?: Accessor } export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}` @@ -27,8 +31,14 @@ export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) export const createSessionTabs = (input: TabsInput) => { const review = input.review ?? (() => false) const hasReview = input.hasReview ?? (() => false) + const fileBrowser = input.fileBrowser ?? (() => false) const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context")) - const openedTabs = createMemo( + const openFileOpen = createMemo( + () => + fileBrowser() && + (input.tabs().active() === SESSION_OPEN_FILE_TAB || input.tabs().all().includes(SESSION_OPEN_FILE_TAB)), + ) + const panelTabs = createMemo( () => { const seen = new Set() return input @@ -36,6 +46,7 @@ export const createSessionTabs = (input: TabsInput) => { .all() .flatMap((tab) => { if (tab === "context" || tab === "review") return [] + if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab if (seen.has(value)) return [] seen.add(value) @@ -45,9 +56,13 @@ export const createSessionTabs = (input: TabsInput) => { emptyTabs, { equals: same }, ) + const openedTabs = createMemo(() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB), emptyTabs, { + equals: same, + }) const activeTab = createMemo(() => { const active = input.tabs().active() if (active === "context") return active + if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (active === "review" && review()) return active if (active && input.pathFromTab(active)) return input.normalizeTab(active) @@ -65,12 +80,15 @@ export const createSessionTabs = (input: TabsInput) => { const closableTab = createMemo(() => { const active = activeTab() if (active === "context") return active + if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (!openedTabs().includes(active)) return return active }) return { contextOpen, + openFileOpen, + panelTabs, openedTabs, activeTab, activeFileTab, diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 3f44aba488..1ea20459c8 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { Tabs } from "@opencode-ai/ui/tabs" import { IconButton } from "@opencode-ai/ui/icon-button" +import { Icon } from "@opencode-ai/ui/icon" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" @@ -23,10 +24,10 @@ import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSettings } from "@/context/settings" -import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { + SESSION_OPEN_FILE_TAB, createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, @@ -35,6 +36,7 @@ import { } from "@/pages/session/helpers" import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff @@ -51,6 +53,7 @@ export function SessionSidePanel(props: { reviewHasFocusableContent: () => boolean reviewCount: () => number reviewPanel: () => JSX.Element + fileBrowserState?: SessionFileBrowserState activeDiff?: string focusReviewDiff: (path: string) => void reviewSnap: boolean @@ -59,7 +62,6 @@ export function SessionSidePanel(props: { }) { const layout = useLayout() const settings = useSettings() - const sync = useSync() const file = useFile() const language = useLanguage() const command = useCommand() @@ -155,8 +157,10 @@ export function SessionSidePanel(props: { normalizeTab, review: reviewTab, hasReview: props.canReview, + fileBrowser: () => !!props.fileBrowserState, }) const contextOpen = tabState.contextOpen + const panelTabs = tabState.panelTabs const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab @@ -180,6 +184,33 @@ export function SessionSidePanel(props: { const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, }) + let fileFilter: HTMLInputElement | undefined + const temporaryTab = tabs().preview + const previewTab = (value: string) => { + const next = normalizeTab(value) + tabs().previewTab(next) + const path = file.pathFromTab(next) + if (path) void file.load(path) + openReviewPanel() + queueMicrotask(() => tabs().setActive(next)) + } + const openFileBrowser = () => { + previewTab(SESSION_OPEN_FILE_TAB) + queueMicrotask(() => fileFilter?.focus()) + } + const activateTab = (value: string) => { + const next = normalizeTab(value) + const path = file.pathFromTab(next) + if (path) void file.load(path) + openReviewPanel() + tabs().setActive(next) + } + const browserTab = createMemo(() => { + if (!props.fileBrowserState) return undefined + if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB + return activeFileTab() + }) + const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix"))) const handleDragStart = (event: unknown) => { const id = getDraggableId(event) @@ -265,7 +296,7 @@ export function SessionSidePanel(props: { > - +
{ @@ -316,7 +347,48 @@ export function SessionSidePanel(props: { - {(tab) => } + + {(tab) => ( + + } + > + + tabs().close(SESSION_OPEN_FILE_TAB)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)} + > +
+ + {language.t("command.file.open")} +
+
+
+ )} +
{ + if (props.fileBrowserState) { + openFileBrowser() + return + } void import("@/components/dialog-select-file").then((x) => { dialog.show(() => ) }) @@ -380,7 +456,20 @@ export function SessionSidePanel(props: { - + + previewTab(file.tab(path))} + onSelectPermanent={(path) => openTab(file.tab(path))} + filterRef={(element) => (fileFilter = element)} + /> + + + {(tab) => } @@ -390,7 +479,9 @@ export function SessionSidePanel(props: { const path = file.pathFromTab(tab) return (
- {(p) => } + + {(p) => } +
) }} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 3c965b8ac8..e23268c64b 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -25,6 +25,7 @@ export type SessionCommandContext = { setActiveMessage: (message: UserMessage | undefined) => void focusInput: () => void review?: () => boolean + fileBrowser?: () => boolean } const withCategory = (category: string) => { @@ -83,6 +84,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { normalizeTab, review: actions.review, hasReview, + fileBrowser: actions.fileBrowser, }) const activeFileTab = tabState.activeFileTab const closableTab = tabState.closableTab diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx new file mode 100644 index 0000000000..597ebd3f7e --- /dev/null +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -0,0 +1,193 @@ +import { createMemo, createSignal, createUniqueId, Show } from "solid-js" +import { createQuery } from "@tanstack/solid-query" +import { Tabs } from "@opencode-ai/ui/tabs" +import { Icon } from "@opencode-ai/ui/icon" +import { + SessionFilePanelV2, + SessionFilePanelV2Empty, + SessionFilePanelV2Title, +} from "@opencode-ai/session-ui/v2/session-file-panel-v2" +import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" +import FileTree, { type Kind } from "@/components/file-tree" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" +import { displayName } from "@/pages/layout/helpers" +import { useSessionLayout } from "@/pages/session/session-layout" +import { SessionFileView } from "@/pages/session/file-tabs" +import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" +import { pathKey } from "@/utils/path-key" + +const emptyFiles: string[] = [] + +export type SessionFileBrowserState = { + sidebarOpened: () => boolean + sidebarWidth: () => number + resizeSidebar: (width: number) => void + toggleSidebar: () => void +} + +export function SessionFileBrowserTab(props: { + tab: string + placeholder: boolean + active?: string + kinds: ReadonlyMap + state: SessionFileBrowserState + onSelect: (path: string) => void + onSelectPermanent: (path: string) => void + filterRef?: (element: HTMLInputElement) => void +}) { + const file = useFile() + const language = useLanguage() + const layout = useLayout() + const sdk = useSDK() + const { workspaceKey } = useSessionLayout() + const resultsID = `session-file-browser-results-${createUniqueId()}` + const [filter, setFilter] = createSignal("") + const [explicitHighlight, setExplicitHighlight] = createSignal() + const query = createMemo(() => filter().trim()) + const search = createQuery(() => { + const value = query() + return { + queryKey: ["session-open-file", workspaceKey(), value] as const, + enabled: value.length > 0, + queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }), + } + }) + const files = createMemo(() => { + if (!query() || search.isPending) return emptyFiles + return [...new Set(search.data ?? emptyFiles)] + }) + const highlighted = createMemo(() => { + const values = files() + if (values.length === 0) return undefined + const explicit = explicitHighlight() + if (explicit && values.includes(explicit)) return explicit + return values[0] + }) + const loading = createMemo(() => query().length > 0 && search.isPending) + const project = createMemo(() => { + const directory = pathKey(sdk().directory) + return layout.projects + .list() + .find( + (item) => + pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), + ) + }) + const title = createMemo(() => displayName(project() ?? { worktree: sdk().directory })) + const optionID = (path: string) => `${resultsID}-option-${files().indexOf(path)}` + + const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { + if (event.key === "Escape" && query()) { + event.preventDefault() + setFilter("") + return + } + if (!query()) return + applyFileListKeyDown(event, files(), highlighted(), { + onHighlight: setExplicitHighlight, + onSelect: props.onSelectPermanent, + }) + } + + return ( + + + + + {title()} + + + } + sidebar={ + {title()}} + filter={filter()} + onFilterChange={setFilter} + onFilterKeyDown={onFilterKeyDown} + filterAutofocus={props.placeholder} + filterRef={props.filterRef} + filterControls={resultsID} + filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined} + filterExpanded={query().length > 0 && files().length > 0} + width={props.state.sidebarWidth()} + onWidthChange={props.state.resizeSidebar} + > + props.onSelect(node.path)} + onFileDoubleClick={(node) => props.onSelectPermanent(node.path)} + /> + } + > + + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } + > + 0} + fallback={ +
+ {language.t("palette.empty")} +
+ } + > + { + setExplicitHighlight(path) + props.onSelect(path) + }} + onFileDoubleClick={props.onSelectPermanent} + /> +
+ + + + } + > + +
+ +
{language.t("command.file.open")}
+
{language.t("session.files.selectToOpen")}
+
+ + } + > +
+ + {(tab) => } + +
+
+ + + ) +} diff --git a/packages/app/src/pages/session/v2/session-file-list-v2.tsx b/packages/app/src/pages/session/v2/session-file-list-v2.tsx index 3a0a17a487..16cac2a45d 100644 --- a/packages/app/src/pages/session/v2/session-file-list-v2.tsx +++ b/packages/app/src/pages/session/v2/session-file-list-v2.tsx @@ -43,7 +43,11 @@ export function SessionFileListV2(props: { active?: string highlighted?: string kinds?: ReadonlyMap + id?: string + role?: "listbox" + optionID?: (path: string) => string onFileClick: (path: string) => void + onFileDoubleClick?: (path: string) => void }) { const active = () => normalizePath(props.active ?? "") const highlighted = () => normalizePath(props.highlighted ?? "") @@ -88,6 +92,8 @@ export function SessionFileListV2(props: { return (