diff --git a/packages/app/src/runtime/server/global-sync/bootstrap.test.ts b/packages/app/src/runtime/server/global-sync/bootstrap.test.ts index a5f29fd6691e..a98d7c0b2029 100644 --- a/packages/app/src/runtime/server/global-sync/bootstrap.test.ts +++ b/packages/app/src/runtime/server/global-sync/bootstrap.test.ts @@ -64,6 +64,25 @@ describe("query keys", () => { expect(calls.toSorted()).toEqual(["a", "b"]) }) + test("refreshes shared project metadata after invalidation", async () => { + let name = "Before" + const projects = { + list: async () => [{ id: "a", canonical: "/a", name, time: { created: 1, updated: 1 }, sandboxes: [] }], + } as unknown as ProjectApi + const worktrees = { + list: async () => [{ directory: "/a" }], + } as unknown as WorktreeApi + const queryClient = new QueryClient() + const query = loadProjectsQuery(ServerScope.local, projects, worktrees) + + expect((await queryClient.fetchQuery(query))[0]?.name).toBe("Before") + + name = "After" + await queryClient.invalidateQueries({ queryKey: query.queryKey, exact: true }) + + expect((await queryClient.fetchQuery(query))[0]?.name).toBe("After") + }) + test("keeps projects whose directory inventory cannot load", async () => { const projects = { list: async () => [ diff --git a/packages/app/src/runtime/server/global-sync/bootstrap.ts b/packages/app/src/runtime/server/global-sync/bootstrap.ts index c51a811b35bd..386600d949ad 100644 --- a/packages/app/src/runtime/server/global-sync/bootstrap.ts +++ b/packages/app/src/runtime/server/global-sync/bootstrap.ts @@ -1,4 +1,4 @@ -import type { Config, Path, Project, ProviderAuthResponse } from "@/runtime/server/types" +import type { Config, Path, Project } from "@/runtime/server/types" import type { LocationGetInput, LocationGetOutput, @@ -18,14 +18,6 @@ import type { ServerScope } from "@/runtime/server/scope" import type { ServerApi } from "@/runtime/server/api" import { sameDirectory } from "@/workspaces/paths" -type GlobalStore = { - path: Path - project: Project[] - provider_auth: ProviderAuthResponse - config: Config - reload: undefined | "pending" | "complete" -} - function waitForPaint() { return new Promise((resolve) => { let done = false @@ -110,16 +102,13 @@ export async function bootstrapGlobal(input: { readonly worktree: WorktreeApi } scope: ServerScope - setGlobalStore: SetStoreFunction queryClient: QueryClient }) { const slow = [ () => 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, input.serverAPI.worktree)), ] await runAll(slow) } diff --git a/packages/app/src/runtime/server/sync.tsx b/packages/app/src/runtime/server/sync.tsx index 09dde826df83..ab28fcd0813f 100644 --- a/packages/app/src/runtime/server/sync.tsx +++ b/packages/app/src/runtime/server/sync.tsx @@ -5,7 +5,13 @@ import { getOwner, onCleanup, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLanguage } from "@/runtime/i18n/language" import { type ServerSDK } from "./client" -import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap" +import { + bootstrapDirectory, + bootstrapGlobal, + loadGlobalConfigQuery, + loadPathQuery, + loadProjectsQuery, +} from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import type { ProjectMeta } from "./global-sync/types" import { formatServerError } from "@/runtime/server/errors" @@ -61,8 +67,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { { ...queryOptionsApi.path(), enabled: connected() }, ], })) + const projectQuery = useQuery(() => ({ + ...loadProjectsQuery(serverSDK.scope, serverSDK.api.project, serverSDK.api.worktree), + enabled: connected(), + })) const [globalStore, setGlobalStore] = createStore({ - project: [], + get project() { + return projectQuery.data ?? [] + }, provider_auth: {}, get path() { const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" } @@ -79,17 +91,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { }) const queryClient = useQueryClient() - const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { - setGlobalStore("project", next) - } - - const setBootStore = ((...input: unknown[]) => { - if (input[0] === "project" && Array.isArray(input[1])) { - setProjects(input[1] as Project[]) - return input[1] - } - return (setGlobalStore as (...args: unknown[]) => unknown)(...input) - }) as typeof setGlobalStore const bootstrap = useQuery(() => ({ queryKey: [serverSDK.scope, "bootstrap"], @@ -97,7 +98,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { await bootstrapGlobal({ serverAPI: serverSDK.api, scope: serverSDK.scope, - setGlobalStore: setBootStore, queryClient, }) return Date.now() @@ -105,14 +105,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { enabled: connected(), })) - const set = ((...input: unknown[]) => { - if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { - setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) - return input[1] - } - return (setGlobalStore as (...args: unknown[]) => unknown)(...input) - }) as typeof setGlobalStore - const paused = () => untrack(() => globalStore.reload) !== undefined const queue = createRefreshQueue({ @@ -269,7 +261,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { return { data: globalStore, - set, + set: setGlobalStore, child: children.child, disableMcp: children.disableMcp, // bootstrap, diff --git a/packages/app/src/settings/workspaces/project-model.ts b/packages/app/src/settings/workspaces/project-model.ts index 22549861c8b6..13f8811755bb 100644 --- a/packages/app/src/settings/workspaces/project-model.ts +++ b/packages/app/src/settings/workspaces/project-model.ts @@ -1,6 +1,6 @@ import { getFilename } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useMutation } from "@tanstack/solid-query" +import { useMutation, useQueryClient } from "@tanstack/solid-query" import { createMemo } from "solid-js" import { createStore } from "solid-js/store" import { useGlobal } from "@/runtime/server/runtime" @@ -10,6 +10,7 @@ import { ServerConnection } from "@/runtime/server/registry" export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { const dialog = useDialog() const global = useGlobal() + const queryClient = useQueryClient() const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) const folderName = createMemo(() => getFilename(props.project.worktree)) const defaultName = createMemo(() => props.project.name || folderName()) @@ -70,13 +71,19 @@ export function createEditProjectModel(props: { project: LocalProject; server: S const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { + const color = store.color ?? "" + const override = store.iconOverride ?? "" + const colorChanged = color !== (props.project.icon?.color ?? "") + const overrideChanged = override !== (props.project.icon?.override ?? "") + await serverCtx().sdk.api.project.update({ projectID: props.project.id, - name, - icon: { color: store.color ?? "", override: store.iconOverride ?? "" }, - commands: { start }, + ...(name !== (props.project.name ?? "") ? { name } : {}), + ...(colorChanged || overrideChanged + ? { icon: { ...(colorChanged ? { color } : {}), ...(overrideChanged ? { override } : {}) } } + : {}), + ...(start !== (props.project.commands?.start ?? "") ? { commands: { start } } : {}), }) - dialog.close() return } @@ -85,6 +92,11 @@ export function createEditProjectModel(props: { project: LocalProject; server: S icon: { color: store.color || undefined, override: store.iconOverride || undefined }, commands: { start: start || undefined }, }) + }, + onSuccess: async () => { + if (props.project.id && props.project.id !== "global") { + await queryClient.invalidateQueries({ queryKey: [serverCtx().sdk.scope, "project"], exact: true }) + } dialog.close() }, })) diff --git a/packages/app/src/settings/workspaces/workspaces.tsx b/packages/app/src/settings/workspaces/workspaces.tsx index ae125334976e..5cde535631b6 100644 --- a/packages/app/src/settings/workspaces/workspaces.tsx +++ b/packages/app/src/settings/workspaces/workspaces.tsx @@ -38,7 +38,7 @@ 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 "@/settings/settings.css" type Workspace = { @@ -59,16 +59,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop }) const projectQuery = useQuery(() => ({ - queryKey: [serverSDK.scope, "settings-workspace-projects"] as const, - queryFn: async () => - Promise.all( - (await serverSDK.api.project.list()).map(async (project) => { - const worktrees = await serverSDK.api.worktree - .list({ projectID: project.id }) - .catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))]) - return normalizeProjectInfo({ ...project, worktrees }) - }), - ), + ...loadProjectsQuery(serverSDK.scope, serverSDK.api.project, serverSDK.api.worktree), refetchOnMount: "always", })) const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? [])) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 0d964a9f61d8..3337f84c3e10 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -100,6 +100,19 @@ describe("Project.update", () => { commands: { start: "bun install" }, }) + expect(yield* project.update({ projectID: id, name: "Renamed" })).toMatchObject({ + id, + name: "Renamed", + icon: { color: "blue", override: "data:image/png;base64,test" }, + commands: { start: "bun install" }, + }) + expect(yield* project.update({ projectID: id, icon: { color: "green" } })).toMatchObject({ + id, + name: "Renamed", + icon: { color: "green", override: "data:image/png;base64,test" }, + commands: { start: "bun install" }, + }) + expect( yield* project.update({ projectID: id,