Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/app/src/runtime/server/global-sync/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => [
Expand Down
15 changes: 2 additions & 13 deletions packages/app/src/runtime/server/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<void>((resolve) => {
let done = false
Expand Down Expand Up @@ -110,16 +102,13 @@ export async function bootstrapGlobal(input: {
readonly worktree: WorktreeApi
}
scope: ServerScope
setGlobalStore: SetStoreFunction<GlobalStore>
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)
}
Expand Down
38 changes: 15 additions & 23 deletions packages/app/src/runtime/server/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<GlobalStore>({
project: [],
get project() {
return projectQuery.data ?? []
},
provider_auth: {},
get path() {
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
Expand All @@ -79,40 +91,20 @@ 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"],
queryFn: async () => {
await bootstrapGlobal({
serverAPI: serverSDK.api,
scope: serverSDK.scope,
setGlobalStore: setBootStore,
queryClient,
})
return Date.now()
},
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({
Expand Down Expand Up @@ -269,7 +261,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {

return {
data: globalStore,
set,
set: setGlobalStore,
child: children.child,
disableMcp: children.disableMcp,
// bootstrap,
Expand Down
22 changes: 17 additions & 5 deletions packages/app/src/settings/workspaces/project-model.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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())
Expand Down Expand Up @@ -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
}

Expand All @@ -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()
},
}))
Expand Down
13 changes: 2 additions & 11 deletions packages/app/src/settings/workspaces/workspaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 ?? []))
Expand Down
13 changes: 13 additions & 0 deletions packages/core/test/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading