mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-21 20:50:09 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f41e968af7 | |||
| ca7ff99e52 | |||
| e06f774db5 |
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 },
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user