Compare commits

...

3 Commits

Author SHA1 Message Date
Hona f41e968af7 fix(app): preserve explicit workspace selection 2026-09-05 02:51:58 +00:00
Hona ca7ff99e52 fix(app): retain mounted session workspace discovery 2026-09-05 02:36:18 +00:00
Hona e06f774db5 fix(app): defer background workspace discovery 2026-09-05 02:23:57 +00:00
18 changed files with 1008 additions and 117 deletions
@@ -0,0 +1,202 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionReady } from "../utils/waits"
const directory = "/projects/deferred-discovery"
const draftID = "draft_deferred_discovery"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const project = {
id: "proj_deferred_discovery",
canonical: directory,
worktree: directory,
vcs: "git",
name: "deferred-discovery",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
test("an existing session discovers only its own project's inventory", async ({ page }) => {
const sessionID = "ses_deferred_discovery"
const title = "Deferred discovery session"
const workspace = `${directory}/feature`
const inventory = Promise.withResolvers<void>()
const requests: (string | null)[] = []
await mockOpenCodeServer(page, {
directory: workspace,
project,
provider: { all: [], connected: [], default: {} },
sessions: [{ id: sessionID, projectID: project.id, directory: workspace, title, time: project.time }],
pageMessages: () => ({
items: [
{ id: "msg_discovery", type: "user", text: "Keep workspace identity", time: { created: project.time.created } },
],
}),
})
await page.route(
(url) => url.pathname === "/api/project",
(route) => route.fulfill({ json: [project, ...historical], headers: { "access-control-allow-origin": "*" } }),
)
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() !== "GET") return route.fallback()
requests.push(new URL(route.request().url()).searchParams.get("location[directory]"))
await inventory.promise
return route.fulfill({
json: [{ directory }, { directory: workspace, strategy: "git" }],
headers: { "access-control-allow-origin": "*" },
})
},
)
const loaded = page.waitForResponse(
(response) => response.request().method() === "GET" && new URL(response.url()).pathname === "/api/worktree",
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
const editor = page.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable()
await editor.fill("Keep this session responsive")
await expect(editor).toHaveText("Keep this session responsive")
inventory.resolve()
expect((await loaded).ok()).toBe(true)
await expect(editor).toHaveText("Keep this session responsive")
expect(requests).toEqual([directory])
})
const historical = Array.from({ length: 300 }, (_, index) => ({
...project,
id: `proj_historical_${index}`,
canonical: `/projects/historical-${index}`,
worktree: `/projects/historical-${index}`,
name: `historical-${index}`,
}))
const lastWorktree = "worktree-12-with-a-long-feature-branch-name-that-overflows-the-picker"
test.use({
viewport: { width: 1280, height: 800 },
colorScheme: "dark",
contextOptions: { reducedMotion: "reduce" },
})
for (const count of [0, 12]) {
test(`defers historical discovery until the picker opens with ${count} worktrees`, async ({ page }, testInfo) => {
const worktrees = Array.from({ length: count }, (_, index) => ({
directory: `${directory}/${index === 11 ? lastWorktree : `worktree-${index + 1}`}`,
strategy: "git",
}))
const inventory = Promise.withResolvers<void>()
const requests: (string | null)[] = []
page.on("request", (request) => {
const url = new URL(request.url())
if (request.method() === "GET" && url.pathname === "/api/worktree") {
requests.push(url.searchParams.get("location[directory]"))
}
})
await mockOpenCodeServer(page, {
directory,
project,
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.route(
(url) => url.pathname === "/api/project",
(route) => route.fulfill({ json: [project, ...historical], headers: { "access-control-allow-origin": "*" } }),
)
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() !== "GET") return route.fallback()
await inventory.promise
return route.fulfill({
json: [{ directory }, ...worktrees],
headers: { "access-control-allow-origin": "*" },
})
},
)
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
const metadata = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/project")
await page.goto(`/new-session?draftId=${draftID}`)
expect(await (await metadata).json()).toHaveLength(301)
const editor = page.locator('[data-component="composer-editor"]')
await expectAppVisible(editor)
await expect(editor).toBeEditable()
await editor.fill("Keep this draft while discovering worktrees")
await expect(editor).toHaveText("Keep this draft while discovering worktrees")
const local = page.getByRole("button", { name: "Local", exact: true })
await expect(local).toBeEnabled()
expect(requests).toEqual([])
const requested = page.waitForRequest(
(request) => request.method() === "GET" && new URL(request.url()).pathname === "/api/worktree",
)
await local.click()
await requested
const loading = page.getByRole("status").filter({ hasText: "Loading" })
await expect(loading).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Local repository", exact: true })).toBeEnabled()
await expect(page.getByRole("menuitem", { name: "New worktree", exact: true })).toBeEnabled()
expect(requests).toEqual([directory])
inventory.resolve()
await expect(loading).toBeHidden()
if (count === 0) {
await expect(page.getByRole("menuitem", { name: "Worktree", exact: true })).toHaveCount(0)
await expect(page.getByRole("menuitem", { name: "View all", exact: true })).toBeEnabled()
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeEnabled()
await expect(page.getByRole("button", { name: "from main", exact: true })).toBeVisible()
}
if (count > 0) {
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
const search = page.getByRole("textbox", { name: "Search worktrees", exact: true })
await expect(search).toBeEditable()
await search.fill("not-a-worktree")
await expect(page.getByRole("menuitem", { name: /^worktree-\d+/ })).toHaveCount(0)
await search.fill("worktree-12")
const choice = page.getByRole("menuitem", { name: lastWorktree, exact: true })
await expect(choice).toBeEnabled()
const screenshot = testInfo.outputPath("loaded-worktree-picker.png")
await page.screenshot({ path: screenshot })
await testInfo.attach("loaded-worktree-picker", { path: screenshot, contentType: "image/png" })
await choice.click()
const selected = page.getByRole("button", { name: lastWorktree, exact: true })
await expect(selected).toBeEnabled()
await page.setViewportSize({ width: 800, height: 800 })
await expect(selected).toBeInViewport()
await selected.click()
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
await expect(search).toHaveValue("")
await expect(page.getByRole("menuitem", { name: /^worktree-\d+/ })).toHaveCount(12)
await expect(choice).toBeInViewport()
const narrow = testInfo.outputPath("narrow-worktree-picker.png")
await page.screenshot({ path: narrow })
await testInfo.attach("narrow-worktree-picker", { path: narrow, contentType: "image/png" })
await page.keyboard.press("Escape")
await expect(selected).toHaveAttribute("aria-expanded", "false")
await expect(selected).toBeFocused()
await selected.click()
await page.getByRole("menuitem", { name: "Local repository", exact: true }).click()
await expect(local).toBeEnabled()
}
await expect(editor).toBeEditable()
await expect(editor).toHaveText("Keep this draft while discovering worktrees")
expect(requests).toEqual([directory])
})
}
@@ -225,16 +225,9 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
},
{ root, server },
)
const loaded = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
response.request().method() === "GET",
)
await page.goto(
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
)
expect((await loaded).ok()).toBe(true)
if (!draft) await expectSessionReady(page, { server, sessionID, title })
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
+3
View File
@@ -84,6 +84,9 @@ export function NewSessionView(props: {
value={props.workspace.selection.value()}
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
loading={props.workspace.project.loading()}
error={props.workspace.project.error()}
onOpenChange={props.workspace.project.setOpen}
branches={props.workspace.project.branches()}
branch={props.workspace.bar.branch()}
onboarding={onboardingReady() && !onboarding.used}
@@ -1,20 +1,16 @@
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useData, useServer } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import { useTabs } from "@/shell/tabs/tabs"
import { ServerConnection } from "@/runtime/server/registry"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
import { withWorktreeInventory } from "@/workspaces/inventory"
import { workspaceDefaultSelection, workspaceDirectories, workspaceSelectionDestination } from "@/workspaces/paths"
export function resolveNewSessionWorktree(input: { enabled: boolean; selected?: string; fallback?: string }) {
if (!input.enabled) return "main"
@@ -46,28 +42,32 @@ export function createNewSessionWorkspaceController(input: {
}) {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const server = useServer()
const data = useData()
const settings = useSettings()
const tabs = useTabs()
const [state, setState] = createStore({ search: "" })
const [state, setState] = createStore({ search: "", open: false })
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
const projectMetadata = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
const current = projectID ? data.project.get(projectID) : undefined
return current ? normalizeProjectInfo(current) : undefined
})
const projectRoot = createMemo(() => projectMetadata()?.worktree ?? sdk().directory)
const inventory = useQuery(() => ({
...server.ctx.sync.worktrees.query(projectRoot()),
enabled: state.open && serverSDK.connection.status() === "connected",
}))
const currentProject = createMemo(() => {
const project = projectMetadata()
return project ? withWorktreeInventory(project, inventory.data) : undefined
})
const visible = createMemo(() =>
resolveNewSessionGit({
projectVcs: currentProject()?.vcs,
branch: data.location.vcs.info({ directory: sdk().directory })?.branch.current,
}),
)
const selected = createMemo(() => {
const project = currentProject()
const worktree = input.selectedWorktree()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
const fallback = createMemo(() => {
const project = currentProject()
if (!project) return "main"
@@ -79,13 +79,13 @@ export function createNewSessionWorkspaceController(input: {
const value = createMemo(() =>
resolveNewSessionWorktree({
enabled: visible(),
selected: selected(),
// Inventory can predate a just-created worktree. Never replace an explicit destination with a fallback.
selected: input.selectedWorktree(),
fallback: fallback(),
}),
)
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
const [branches] = createResource(
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
() => (visible() && state.open ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs
.branches({ location: { directory }, search, limit: 50 })
@@ -96,9 +96,11 @@ export function createNewSessionWorkspaceController(input: {
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
() => undefined,
)
const project = currentProject()
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
})
createEffect(() => {
const selection = value()
const directory = selection === "main" || selection === "create" ? sdk().directory : selection
void data.location.vcs.sync({ directory }).catch(() => undefined)
})
const branch = createMemo(() =>
resolveNewSessionBranch({
@@ -122,7 +124,9 @@ export function createNewSessionWorkspaceController(input: {
workspace: createMemo(() => {
const project = currentProject()
const current = value()
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
return (
current === "create" || (!!project && workspaceSelectionDestination(current, project.worktree) !== "main")
)
}),
reset: () => {
input.setSelectedWorktree(undefined)
@@ -142,6 +146,9 @@ export function createNewSessionWorkspaceController(input: {
},
project: {
root: projectRoot,
loading: () => state.open && inventory.isPending,
error: () => inventory.isError,
setOpen: (open: boolean) => setState("open", open),
workspaces: () => {
const project = currentProject()
return project ? workspaceDirectories(project) : []
@@ -11,6 +11,9 @@ export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
loading?: boolean
error?: boolean
onOpenChange?: (open: boolean) => void
branches: string[]
branch?: string
onboarding?: boolean
@@ -41,6 +44,7 @@ export function PromptWorkspaceSelector(props: {
pending = { type: "select", value }
}
const onOpenChange = (open: boolean) => {
props.onOpenChange?.(open)
if (open) {
setSearch({ workspaces: "", branches: "" })
props.onSearch("")
@@ -133,6 +137,11 @@ export function PromptWorkspaceSelector(props: {
</Show>
</Menu.Item>
</Menu.Group>
<Show when={props.loading || props.error}>
<div class="px-3 py-1 text-13-regular text-v2-text-text-muted" role="status">
{language.t(props.error ? "session.location.worktreesFailed" : "common.loading")}
</div>
</Show>
<Show
when={props.workspaces.length > 0}
fallback={
@@ -0,0 +1,206 @@
import { afterEach, describe, expect, test } from "bun:test"
import { OpenCode, type LocationRef, type OpenCodeEvent } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createOpenCodeEventSource } from "./client"
import { createAppData } from "./catalog-events"
const cleanups: Array<() => void> = []
afterEach(() => cleanups.splice(0).forEach((dispose) => dispose()))
function setup() {
return createRoot((dispose) => {
cleanups.push(dispose)
const events = createOpenCodeEventSource()
const calls: Array<{ path: string; location: LocationRef }> = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
const location = {
directory: url.searchParams.get("location[directory]") ?? "/default",
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
}
calls.push({ path: url.pathname, location })
if (url.pathname === "/api/health") return Response.json({ healthy: true, version: "2.0.0-test", pid: 1 })
if (url.pathname === "/api/mcp") return Response.json({ location, data: [] })
if (url.pathname === "/api/mcp/resource")
return Response.json({ location, data: { resources: [], templates: [] } })
throw new Error(`Unexpected request: ${url.pathname}`)
},
{ preconnect() {} },
),
})
const data = createAppData({
api: () => api,
directory: "/default",
event: {
on: events.event.on,
listen: (handler) => events.event.listen((event) => handler({ name: event.type, details: event })),
},
onError: (error) => {
throw error
},
})
const emit = (location: LocationRef) => {
events.publish({
id: "evt_status",
created: 1,
type: "mcp.status.changed",
location,
data: { server: "test" },
})
events.publish({
id: "evt_resources",
created: 2,
type: "mcp.resources.changed",
location,
data: { server: "test" },
})
}
return { data, calls, emit, events, api, dispose }
})
}
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
describe("desktop catalog events", () => {
test("does not fetch MCP catalogs for hundreds of unopened locations", async () => {
const input = setup()
Array.from({ length: 400 }, (_, index) => input.emit({ directory: `/history/${index}` }))
await settle()
await input.api.health.get()
expect(input.calls).toHaveLength(1)
expect(input.calls[0].path).toBe("/api/health")
})
test("coalesces active MCP events and leaves unrelated locations deferred", async () => {
const input = setup()
const location = { directory: "/active" }
const release = input.data.location.retain(location)
Array.from({ length: 30 }, () => {
input.emit(location)
input.emit({ directory: "/history" })
})
await settle()
await Promise.all([input.data.location.mcp.server.sync(location), input.data.location.mcp.resource.sync(location)])
expect(input.calls).toHaveLength(2)
expect(input.calls.every((call) => call.location.directory === location.directory)).toBe(true)
release()
input.emit(location)
await settle()
expect(input.calls).toHaveLength(2)
const reopen = input.data.location.retain(location)
await Promise.all([input.data.location.mcp.server.sync(location), input.data.location.mcp.resource.sync(location)])
expect(input.calls).toHaveLength(4)
reopen()
})
test("keeps shared owners active and distinguishes workspace identities", async () => {
const input = setup()
const location = { directory: "C:\\repo", workspaceID: "workspace_a" }
const first = input.data.location.retain(location)
const second = input.data.location.retain(location)
first()
input.emit({ directory: "C:/repo/", workspaceID: "workspace_a" })
input.emit({ directory: "C:/repo/", workspaceID: "workspace_b" })
await settle()
expect(input.calls).toHaveLength(2)
expect(input.calls.every((call) => call.location.workspaceID === "workspace_a")).toBe(true)
second()
input.emit(location)
await settle()
expect(input.calls).toHaveLength(2)
})
test("never suppresses session permission events for an unopened location", () => {
const input = setup()
const event = {
id: "evt_permission",
created: 1,
type: "permission.asked",
location: { directory: "/history" },
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} satisfies Extract<OpenCodeEvent, { type: "permission.asked" }>
input.events.publish(event)
expect(input.data.session.permission.list("ses_1")).toEqual([event.data])
expect(input.calls).toHaveLength(0)
})
test("refreshes a requested Windows path after changes arrive using a normalized alias", async () => {
const input = setup()
const location = { directory: "C:\\repo" }
const release = input.data.location.retain(location)
await input.data.location.mcp.server.sync(location)
release()
input.emit({ directory: "C:/repo/" })
await settle()
expect(input.calls).toHaveLength(1)
const reopen = input.data.location.retain(location)
await input.data.location.mcp.server.sync(location)
expect(input.calls).toHaveLength(2)
reopen()
})
test("keeps background questions visible without loading that location's MCP catalogs", () => {
const input = setup()
const event = {
id: "evt_form",
created: 1,
type: "form.created",
location: { directory: "/history" },
data: {
form: {
id: "frm_question",
sessionID: "ses_1",
title: "Choose an option",
fields: [{ key: "answer", type: "string" }],
},
},
} satisfies Extract<OpenCodeEvent, { type: "form.created" }>
input.events.publish(event)
expect(input.data.session.form.list("ses_1")).toEqual([event.data.form])
expect(input.calls).toHaveLength(0)
})
test("refreshes a mounted shared catalog independently of the project catalog", async () => {
const input = setup()
const project = input.data.location.retain({ directory: "/project" })
await input.data.location.mcp.server.sync()
const shared = input.data.location.retain(input.data.location.default())
input.emit(input.data.location.default())
await settle()
await input.data.location.mcp.server.sync()
expect(input.calls).toHaveLength(3)
expect(input.calls.every((call) => call.location.directory === "/default")).toBe(true)
shared()
input.emit(input.data.location.default())
await settle()
expect(input.calls).toHaveLength(3)
project()
})
test("does not dispatch a queued catalog refresh after its last owner closes", async () => {
const input = setup()
const release = input.data.location.retain({ directory: "/active" })
input.emit({ directory: "/active" })
release()
await settle()
expect(input.calls).toHaveLength(0)
})
test("disposes a queued refresh without starting requests", async () => {
const input = setup()
input.data.location.retain({ directory: "/active" })
input.emit({ directory: "/active" })
input.dispose()
await settle()
expect(input.calls).toHaveLength(0)
})
})
@@ -0,0 +1,73 @@
import { createData, type CreateDataInput, type Data } from "@opencode-ai/client/solid"
import type { LocationRef, OpenCodeEvent } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
import { pathKey } from "@/workspaces/path-key"
type DataEvent = { name: OpenCodeEvent["type"]; details: OpenCodeEvent }
const key = (location: LocationRef) => JSON.stringify([pathKey(location.directory), location.workspaceID])
export function createAppData(input: CreateDataInput) {
const owners = new Map<string, number>()
const pending = new Map<string, DataEvent>()
const data: Data = createData({
...input,
event: {
...input.event,
listen: (handler) =>
input.event.listen((event) => {
const info = event.details
if (!info.location || (info.type !== "mcp.status.changed" && info.type !== "mcp.resources.changed")) {
handler(event)
return
}
const resource = info.type === "mcp.status.changed" ? data.location.mcp.server : data.location.mcp.resource
// Keep unopened catalogs stale without booting their Location just to refresh the UI.
resource.invalidate(info.location)
if (!owners.has(key(info.location))) return
if (pending.size === 0) {
queueMicrotask(() => {
const events = [...pending.values()]
pending.clear()
for (const event of events) {
const location = event.details.location
if (location && owners.has(key(location))) handler(event)
}
})
}
// A server may publish several MCP changes in one event-stream batch; one catalog read suffices.
pending.set(`${key(info.location)}:${info.type}`, event)
}),
},
})
onCleanup(() => {
pending.clear()
owners.clear()
})
return {
...data,
location: {
...data.location,
retain(location: LocationRef) {
const id = key(location)
if (!owners.has(id)) {
// Reopening a view also refreshes requested-path aliases of the server's event Location.
data.location.mcp.server.invalidate(location)
data.location.mcp.resource.invalidate(location)
}
owners.set(id, (owners.get(id) ?? 0) + 1)
return () => {
const count = owners.get(id) ?? 0
if (count > 1) {
owners.set(id, count - 1)
return
}
owners.delete(id)
}
},
},
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { createStore } from "solid-js/store"
type SessionMutation = { readonly id: string; readonly type: "remove"; readonly sessionID: string }
export function createDesktopData(input: { data: Data; remove: (sessionID: string) => Promise<void> }) {
export function createDesktopData<T extends Data>(input: { data: T; remove: (sessionID: string) => Promise<void> }) {
const mutation = createSessionMutations(input.remove)
onCleanup(input.data.on("session.deleted", (event) => mutation.deleted(event.data.sessionID)))
@@ -6,6 +6,7 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
import { worktreeInventoryKey } from "@/workspaces/inventory"
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
@@ -47,6 +48,17 @@ test("bootstraps projects through the native store setter and preserves subseque
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
expect(store.config).toEqual({})
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo"), [
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
expect(store.project[0]?.worktrees).toEqual([
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
} finally {
queryClient.clear()
}
@@ -76,7 +88,7 @@ describe("query keys", () => {
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads each project's inventory through its own location using the real client", async () => {
test("loads 400 historical project records without discovering their worktrees", async () => {
const calls: string[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
@@ -85,8 +97,16 @@ describe("query keys", () => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/project")
return Response.json([
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
...Array.from({ length: 400 }, (_, index) => ({
id: `project-${index.toString().padStart(3, "0")}`,
canonical: `/old/${index}`,
name: `Project ${index}`,
time: { created: 1, updated: 1 },
sandboxes: [`/old/${index}/known`],
})),
{ id: "test", canonical: "/tmp/opencode-test-123", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "empty", canonical: "", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "duplicate", canonical: "/old/0", time: { created: 1, updated: 1 }, sandboxes: [] },
])
const directory = url.searchParams.get("location[directory]")
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
@@ -101,21 +121,19 @@ describe("query keys", () => {
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
["/a/clone", "/a/copy"],
["/b/clone", "/b/copy"],
])
expect(result.map((project) => project.worktrees)).toEqual([
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["/a", "/b"])
expect(calls).toEqual([])
expect(result).toHaveLength(401)
expect(result.find((project) => project.id === "project-000")).toMatchObject({
name: "Project 0",
worktree: "/old/0",
sandboxes: ["/old/0/known"],
})
expect(result.some((project) => project.id === "test" || project.id === "empty")).toBe(false)
})
test("keeps projects whose directory inventory cannot load", async () => {
test("keeps metadata without consulting unavailable directories", async () => {
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
@@ -135,10 +153,10 @@ describe("query keys", () => {
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
{ id: "a", sandboxes: [] },
{ id: "b", sandboxes: [] },
])
})
@@ -15,8 +15,7 @@ import { cmp, normalizeProjectInfo } from "./utils"
import { formatServerError } from "@/runtime/server/errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import type { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { sameDirectory } from "@/workspaces/paths"
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
type GlobalStore = {
path: Path
@@ -64,38 +63,17 @@ type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type WorktreeApi = Pick<ServerApi["worktree"], "list">
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
projects.list().then(async (items) => {
return (
await Promise.all(
items
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ location: { directory: project.canonical } })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
.filter((directory) => !sameDirectory(project.canonical, directory))
.map((directory) => ({ directory })),
])
return normalizeProjectInfo({
...project,
sandboxes: directories
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.canonical, directory)),
worktrees: directories,
})
}),
)
)
projects.list().then((items) => {
return items
.filter((project) => !!project?.id)
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
@@ -107,7 +85,6 @@ export async function bootstrapGlobal(input: {
serverAPI: {
readonly location: LocationApi
readonly project: ProjectApi
readonly worktree: WorktreeApi
}
scope: ServerScope
setGlobalStore: SetStoreFunction<GlobalStore>
@@ -117,9 +94,17 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
.then((data) => input.setGlobalStore("project", data)),
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
input.setGlobalStore(
"project",
data.map((project) =>
withWorktreeInventory(
project,
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
),
),
),
),
]
await runAll(slow)
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
import { createServerSdkContext } from "./client"
import { createServerSyncContext } from "./sync"
import { createData } from "@opencode-ai/client/solid"
import { createAppData } from "./catalog-events"
import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
@@ -133,7 +133,7 @@ function createServerController(
const language = useLanguage()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const source = createData({
const source = createAppData({
api: () => sdk.api,
event: {
on: sdk.event.on,
+29 -4
View File
@@ -20,6 +20,8 @@ import { toggleMcp } from "./global-sync/mcp"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "@/runtime/platform/platform"
import type { Data } from "@opencode-ai/client/solid"
import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
import { sameDirectory } from "@/workspaces/paths"
type GlobalStore = {
path: Path
@@ -79,6 +81,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const queryClient = useQueryClient()
const worktrees = createWorktreeInventory({
scope: serverSDK.scope,
queryClient,
api: () => serverSDK.api.worktree,
updated: (directory, items) =>
setGlobalStore("project", (projects) =>
projects.map((project) =>
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
),
),
})
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
@@ -197,17 +210,29 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setGlobalStore("project", (projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
projects.map((project) =>
project.id === update.id
? withWorktreeInventory(
updateProjectInfo(project, update),
queryClient.getQueryData(worktreeInventoryKey(serverSDK.scope, update.canonical)),
)
: project,
),
)
}
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (event.type === "project.updated") applyProjectUpdate(event.data)
if (event.type === "worktree.updated") {
const project = globalStore.project.find((project) => project.id === event.data.projectID)
const directory = project?.worktree ?? data.project.get(event.data.projectID)?.canonical
if (directory) void worktrees.invalidate(directory)
return
}
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
bootstrap.refetch()
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
return
}
@@ -216,7 +241,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (!children.children[key]) return
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
})
onCleanup(unsub)
@@ -261,6 +285,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
worktrees,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
+11 -2
View File
@@ -1,6 +1,7 @@
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { createMediaQuery } from "@solid-primitives/media"
import { createMemo } from "solid-js"
import { useQuery } from "@tanstack/solid-query"
import { useFile } from "@/workspaces/files/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
@@ -18,6 +19,7 @@ import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useTabs } from "@/shell/tabs/tabs"
import { useServer } from "@/runtime/server/current"
import { withWorktreeInventory } from "@/workspaces/inventory"
const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
@@ -73,6 +75,10 @@ export function useSessionModel() {
if (!value) return
return { ...value, worktree: value.canonical, worktrees: [] }
})
const inventory = useQuery(() => ({
...server.ctx.sync.worktrees.query(project()?.worktree ?? location().directory),
enabled: !!project() && server.ctx.sdk.connection.status() === "connected",
}))
const canReview = createMemo(() => !!project())
const normalizeTab = (tab: string) => normalizeSessionTab(tab, file.tab)
const tabs = createSessionTabs({
@@ -94,12 +100,15 @@ export function useSessionModel() {
current: createMemo(() => {
const current = info()
const directory = current?.location.directory ?? location().directory
// Global sync enriches projects with discovered worktrees; raw project metadata does not.
// Only mounted sessions demand inventory; historical projects remain metadata-only.
const projects = server.ctx.sync.data.project
const value = current
? projectForSession(current, projects)
: projects.find((item) => isProjectDirectory(item, directory))
return isWorkspaceDirectory(value, directory)
return isWorkspaceDirectory(
value && withWorktreeInventory(value, inventory.isSuccess ? inventory.data : undefined),
directory,
)
}),
},
identity: {
@@ -1,7 +1,17 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal, type JSX } from "solid-js"
import {
type Component,
For,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
type JSX,
} from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -69,12 +79,17 @@ export const ProjectSettingsExtensions: Component = () => {
const data = useData()
const toggleMcp = useMcpToggle(() => directorySDK().directory)
createEffect(() => {
if (serverSDK.connection.status() !== "connected") return
// This view displays shared MCP servers as well as the project Location's catalog.
onCleanup(data.location.retain(data.location.default()))
})
createEffect(() => {
if (serverSDK.connection.status() !== "connected") return
const ref = { directory: directorySDK().directory }
void Promise.all([
data.location.mcp.server.sync(),
data.location.skill.sync(),
data.location.syncInfo().then(() => Promise.all([data.location.mcp.server.sync(), data.location.skill.sync()])),
data.location.mcp.server.sync(ref),
data.location.skill.sync(ref),
]).catch(() => undefined)
@@ -2,7 +2,7 @@ import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { useQueries, useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
@@ -13,7 +13,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/util/path"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useData, useServer } from "@/runtime/server/current"
import { showToast } from "@/shell/notifications/toast"
import { getRelativeTime } from "@/shell/time"
import { sessionLabel } from "@/session/title"
@@ -29,7 +29,6 @@ import {
containsDirectory,
filterWorkspaceInventory,
inspectWorkspaceDeletion,
managedWorkspaceDirectories,
mergeWorkspaceSessionInventory,
removeWorkspacesSequentially,
sessionsForWorkspace,
@@ -38,7 +37,8 @@ import {
} from "@/workspaces/paths"
import { listAllSessions } from "@/session/list"
import type { ServerScope } from "@/runtime/server/scope"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import { loadProjectsQuery } from "@/runtime/server/global-sync/bootstrap"
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
import "@/settings/settings.css"
type Workspace = {
@@ -50,6 +50,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const server = useServer()
const data = useData()
const tabs = useTabs()
const platform = usePlatform()
@@ -59,22 +60,15 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
})
const projectQuery = useQuery(() => ({
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
...loadProjectsQuery(serverSDK.scope, serverSDK.api.project),
enabled: serverSDK.connection.status() === "connected",
queryFn: async () =>
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
const worktrees = await serverSDK.api.worktree
.list({ location: { directory: project.canonical } })
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
return normalizeProjectInfo({ ...project, worktrees })
}),
),
refetchOnMount: "always",
}))
const inventory = createMemo(() => (projectQuery.isPending ? [] : (projectQuery.data ?? [])))
const workspaces = createMemo(() => workspaceInventory(inventory()))
const projects = createMemo(() => inventory().filter((project) => managedWorkspaceDirectories(project).length > 0))
const projects = createMemo(() => [
...new Map(
(projectQuery.data ?? []).map((project) => [worktreeInventoryKey(serverSDK.scope, project.worktree)[2], project]),
).values(),
])
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
{ id: "all", label: language.t("settings.workspaces.filter.all") },
@@ -83,12 +77,28 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
const selectedProject = createMemo(() =>
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
)
const requested = createMemo(() =>
projects().filter((project) => selectedProject() === "all" || project.id === selectedProject()),
)
const inventoryQueries = useQueries(() => ({
queries: requested().map((project) => ({
...server.ctx.sync.worktrees.query(project.worktree),
enabled: serverSDK.connection.status() === "connected",
})),
}))
const inventory = createMemo(() =>
requested().map((project, index) => withWorktreeInventory(project, inventoryQueries[index]?.data)),
)
const loading = () => projectQuery.isPending || inventoryQueries.some((query) => query.isPending)
const failed = () => projectQuery.isError || inventoryQueries.some((query) => query.isError)
const workspaces = createMemo(() => workspaceInventory(inventory()))
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
const sdk = serverSDK
return {
sdk,
data,
worktrees: server.ctx.sync.worktrees,
server: ServerConnection.key(sdk.server),
activeDirectory: props.activeDirectory,
}
@@ -110,7 +120,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
workspaceDirectories().map((directory) => String(pathKey(directory))),
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
enabled: serverSDK.connection.status() === "connected" && !loading() && workspaceDirectories().length > 0,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(() => {
@@ -205,7 +215,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
})
})
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
await context.worktrees.invalidate(workspace.project.worktree)
}
let inspectionID = 0
@@ -279,7 +289,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
<div class="settings-tab-body settings-workspaces">
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<Show when={!projectQuery.isPending && !projectQuery.isError}>
<Show when={!loading() && !failed()}>
{language.plural("settings.workspaces.count", filtered().length)}
</Show>
</span>
@@ -316,7 +326,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
variant="ghost-muted"
size="small"
aria-label={language.t("common.moreOptions")}
disabled={!!store.transaction}
disabled={!!store.transaction || loading() || failed()}
icon={<Icon name="outline-dots" size="small" />}
/>
<Menu.Portal>
@@ -332,16 +342,17 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
</div>
<div class="settings-workspaces-inventory">
<Show when={filtered().length > 0 && (loading() || failed())}>
<div class="settings-workspaces-empty" role="status">
{language.t(failed() ? "common.requestFailed" : "common.loading")}
</div>
</Show>
<Show
when={filtered().length > 0}
fallback={
<div class="settings-workspaces-empty">
{language.t(
projectQuery.isPending
? "common.loading"
: projectQuery.isError
? "common.requestFailed"
: "settings.workspaces.empty",
loading() ? "common.loading" : failed() ? "common.requestFailed" : "settings.workspaces.empty",
)}
</div>
}
@@ -0,0 +1,240 @@
import { expect, test } from "bun:test"
import { QueryClient, QueryObserver } from "@tanstack/solid-query"
import { createWorktreeInventory, withWorktreeInventory } from "./inventory"
import { ServerScope } from "@/runtime/server/scope"
import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global-sync/utils"
function pending() {
let resolve!: () => void
const promise = new Promise<void>((done) => (resolve = done))
return { promise, resolve }
}
test("picker demand loads once, shares in-flight work, and reuses cache", async () => {
const client = new QueryClient()
const calls: string[] = []
const gate = pending()
const inventory = createWorktreeInventory({
scope: ServerScope.local,
queryClient: client,
api: () => ({
list: async (input) => {
const directory = input!.location!.directory!
calls.push(directory)
await gate.promise
return [{ directory }, { directory: `${directory}/existing`, strategy: "git" }]
},
}),
})
const closed = new QueryObserver(client, { ...inventory.query("/repo"), enabled: false })
const unsubscribe = closed.subscribe(() => {})
expect(calls).toEqual([])
closed.setOptions({ ...inventory.query("/repo"), enabled: true })
expect(closed.getCurrentResult().isFetching).toBe(true)
const duplicate = client.fetchQuery(inventory.query("/repo/"))
expect(calls).toEqual(["/repo"])
gate.resolve()
expect(await duplicate).toHaveLength(2)
await client.fetchQuery(inventory.query("/repo"))
expect(calls).toEqual(["/repo"])
unsubscribe()
client.clear()
})
test("invalidation is targeted and does not fetch inactive inventories", async () => {
const client = new QueryClient()
const calls: string[] = []
const inventory = createWorktreeInventory({
scope: ServerScope.local,
queryClient: client,
api: () => ({
list: async (input) => {
const directory = input!.location!.directory!
calls.push(directory)
return [{ directory }]
},
}),
})
await client.fetchQuery(inventory.query("/a"))
await client.fetchQuery(inventory.query("/b"))
await inventory.invalidate("/a")
expect(calls).toEqual(["/a", "/b"])
await client.fetchQuery(inventory.query("/b"))
await client.fetchQuery(inventory.query("/a"))
expect(calls).toEqual(["/a", "/b", "/a"])
client.clear()
})
test("explicit multi-project inventory has at most four active lists and cancels queued offscreen demand", async () => {
const client = new QueryClient()
const calls: string[] = []
const gate = pending()
const inventory = createWorktreeInventory({
scope: ServerScope.local,
queryClient: client,
api: () => ({
list: async (input) => {
const directory = input!.location!.directory!
calls.push(directory)
await gate.promise
return [{ directory }]
},
}),
})
const queries = Array.from({ length: 20 }, (_, index) =>
client.fetchQuery(inventory.query(`/repo/${index}`)).catch(() => undefined),
)
await Promise.resolve()
expect(calls).toHaveLength(4)
await client.cancelQueries()
gate.resolve()
await Promise.all(queries)
await Promise.resolve()
expect(calls).toHaveLength(4)
client.clear()
})
test("switching the selected project exposes its own pending/error state, not previous choices", async () => {
const client = new QueryClient()
const gate = pending()
const inventory = createWorktreeInventory({
scope: ServerScope.local,
queryClient: client,
api: () => ({
list: async (input) => {
const directory = input!.location!.directory!
if (directory === "/b") {
await gate.promise
throw new Error("Unavailable")
}
return [{ directory }, { directory: "/a/existing" }]
},
}),
})
await client.fetchQuery(inventory.query("/a"))
const observer = new QueryObserver(client, inventory.query("/a"))
const unsubscribe = observer.subscribe(() => {})
expect(observer.getCurrentResult().data).toHaveLength(2)
observer.setOptions(inventory.query("/b"))
expect(observer.getCurrentResult().isPending).toBe(true)
expect(observer.getCurrentResult().data).toBeUndefined()
gate.resolve()
await client.fetchQuery(inventory.query("/b")).catch(() => undefined)
expect(observer.getCurrentResult().isError).toBe(true)
expect(observer.getCurrentResult().data).toBeUndefined()
unsubscribe()
client.clear()
})
test("visible multi-project demand drains the bounded queue and deduplicates canonical directories", async () => {
const client = new QueryClient()
const calls: string[] = []
let active = 0
let peak = 0
const inventory = createWorktreeInventory({
scope: ServerScope.local,
queryClient: client,
api: () => ({
list: async (input) => {
const directory = input!.location!.directory!
calls.push(directory)
active++
peak = Math.max(peak, active)
await Promise.resolve()
active--
return [{ directory }]
},
}),
})
await Promise.all(
Array.from({ length: 30 }, (_, index) =>
client.fetchQuery(inventory.query(`/repo/${index % 20}${index >= 20 ? "/" : ""}`)),
),
)
expect(calls).toHaveLength(20)
expect(new Set(calls).size).toBe(20)
expect(peak).toBe(4)
client.clear()
})
test("an update during an active list does not cancel it and start another backend refresh", async () => {
const client = new QueryClient()
const gate = pending()
let calls = 0
const inventory = createWorktreeInventory({
scope: ServerScope.local,
queryClient: client,
api: () => ({
list: async () => {
calls++
await gate.promise
return [{ directory: "/repo" }]
},
}),
})
const observer = new QueryObserver(client, inventory.query("/repo"))
const unsubscribe = observer.subscribe(() => {})
const invalidated = inventory.invalidate("/repo")
expect(calls).toBe(1)
gate.resolve()
await invalidated
expect(calls).toBe(1)
expect(observer.getCurrentResult().data).toEqual([{ directory: "/repo" }])
unsubscribe()
client.clear()
})
test("an unopened inventory update waits until a view mounts, overriding the app's no-refetch default", async () => {
const client = new QueryClient({ defaultOptions: { queries: { refetchOnMount: false } } })
let calls = 0
const inventory = createWorktreeInventory({
scope: ServerScope.local,
queryClient: client,
api: () => ({
list: async () => {
calls++
return [{ directory: `/repo/feature-${calls}` }]
},
}),
})
await client.fetchQuery(inventory.query("/repo"))
await inventory.invalidate("/repo")
expect(calls).toBe(1)
const observer = new QueryObserver(client, inventory.query("/repo"))
const unsubscribe = observer.subscribe(() => {})
expect(calls).toBe(2)
await client.fetchQuery(inventory.query("/repo"))
expect(calls).toBe(2)
expect(observer.getCurrentResult().data).toEqual([{ directory: "/repo/feature-2" }])
unsubscribe()
client.clear()
})
test("cached inventory survives metadata updates and is partitioned by server", async () => {
const client = new QueryClient()
const api = () => ({ list: async () => [{ directory: "/repo/feature", strategy: "git" }] })
const local = createWorktreeInventory({ scope: ServerScope.local, queryClient: client, api })
const remote = createWorktreeInventory({
scope: "https://remote.example" as typeof ServerScope.local,
queryClient: client,
api,
})
const cached = await client.fetchQuery(local.query("/repo"))
expect(client.getQueryData(remote.query("/repo").queryKey)).toBeUndefined()
const metadata = {
id: "project",
canonical: "/repo",
name: "Before",
time: { created: 1, updated: 1 },
sandboxes: [],
}
const updated = updateProjectInfo(normalizeProjectInfo(metadata), { ...metadata, name: "After" })
expect(withWorktreeInventory(updated, cached)).toMatchObject({
name: "After",
sandboxes: ["/repo/feature"],
worktrees: cached,
})
expect(local.query("C:\\Repo\\").queryKey).toEqual(local.query("C:/Repo").queryKey)
expect(local.query("C:/Repo").queryKey).not.toEqual(local.query("C:/repo").queryKey)
client.clear()
})
+87
View File
@@ -0,0 +1,87 @@
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerScope } from "@/runtime/server/scope"
import type { Project } from "@/runtime/server/types"
import { pathKey } from "./path-key"
import { sameDirectory } from "./paths"
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
return [scope, "worktree", pathKey(directory)] as const
}
export function withWorktreeInventory(project: Project, worktrees: readonly WorktreeDirectory[] | undefined): Project {
if (!worktrees) return project
return {
...project,
worktrees: [...worktrees],
sandboxes: worktrees
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.worktree, directory)),
}
}
// Owned by one server context. Only picker/settings observers demand these queries;
// metadata bootstrap never enumerates directories. QueryClient owns cache and deduplication.
export function createWorktreeInventory(input: {
scope: ServerScope
queryClient: QueryClient
api: () => Pick<ServerApi["worktree"], "list">
updated?: (directory: string, worktrees: readonly WorktreeDirectory[]) => void
}) {
let active = 0
const waiting: Array<() => void> = []
const query = (directory: string) =>
queryOptions({
queryKey: worktreeInventoryKey(input.scope, directory),
staleTime: 30_000,
retry: false,
refetchOnMount: true,
refetchOnWindowFocus: false,
queryFn: ({ signal }) =>
new Promise<WorktreeDirectory[]>((resolve, reject) => {
const cancel = () => {
const index = waiting.indexOf(run)
if (index >= 0) waiting.splice(index, 1)
reject(signal.reason)
}
const run = () => {
signal.removeEventListener("abort", cancel)
if (signal.aborted) {
reject(signal.reason)
return
}
active++
// list already refreshes the server inventory; do not call refresh first.
void input
.api()
.list({ location: { directory } }, { signal })
.then((items) => {
signal.throwIfAborted()
input.updated?.(directory, items)
resolve(items)
})
.catch(reject)
.finally(() => {
active--
waiting.shift()?.()
})
}
if (active < 4) return run()
waiting.push(run)
signal.addEventListener("abort", cancel, { once: true })
}),
})
return {
query,
invalidate: (directory: string) =>
input.queryClient.invalidateQueries(
{
queryKey: worktreeInventoryKey(input.scope, directory),
exact: true,
refetchType: "active",
},
{ cancelRefetch: false },
),
}
}
+8
View File
@@ -28,6 +28,14 @@ const context = createSimpleContext({
)
const current = createMemo(() => data.location.info(ref()))
createEffect(() => {
onCleanup(data.location.retain(ref()))
})
createEffect(() => {
const resolved = current()
if (resolved) onCleanup(data.location.retain(resolved))
})
createEffect(() => {
const location = ref()
let stale = false