refactor(app): load sync state through TanStack Query (#23792)
This commit is contained in:
@@ -82,7 +82,15 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function QueryProvider(props: ParentProps) {
|
function QueryProvider(props: ParentProps) {
|
||||||
const client = new QueryClient()
|
const client = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||||
import { Component, createEffect, createMemo, on, Show } from "solid-js"
|
import { Component, createMemo, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { loadMcpQuery } from "@/context/global-sync"
|
||||||
|
|
||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
connected: "mcp.status.connected",
|
connected: "mcp.status.connected",
|
||||||
@@ -20,48 +19,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [state, setState] = createStore({
|
const queryClient = useQueryClient()
|
||||||
done: false,
|
|
||||||
loading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => sync.data.mcp_ready,
|
|
||||||
(ready, prev) => {
|
|
||||||
if (!ready && prev) setState("done", false)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (state.done || state.loading) return
|
|
||||||
if (sync.data.mcp_ready) {
|
|
||||||
setState("done", true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setState("loading", true)
|
|
||||||
void sdk.client.mcp
|
|
||||||
.status()
|
|
||||||
.then((result) => {
|
|
||||||
sync.set("mcp", result.data ?? {})
|
|
||||||
sync.set("mcp_ready", true)
|
|
||||||
setState("done", true)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setState("done", true)
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setState("loading", false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
@@ -71,16 +29,10 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
|
|
||||||
const toggle = useMutation(() => ({
|
const toggle = useMutation(() => ({
|
||||||
mutationFn: async (name: string) => {
|
mutationFn: async (name: string) => {
|
||||||
const status = sync.data.mcp[name]
|
if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name })
|
||||||
if (status?.status === "connected") {
|
else await sdk.client.mcp.connect({ name })
|
||||||
await sdk.client.mcp.disconnect({ name })
|
|
||||||
} else {
|
|
||||||
await sdk.client.mcp.connect({ name })
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await sdk.client.mcp.status()
|
|
||||||
if (result.data) sync.set("mcp", result.data)
|
|
||||||
},
|
},
|
||||||
|
onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
@@ -15,6 +15,7 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
import { loadMcpQuery } from "@/context/global-sync"
|
||||||
|
|
||||||
const pollMs = 10_000
|
const pollMs = 10_000
|
||||||
|
|
||||||
@@ -137,14 +138,14 @@ const useMcpToggleMutation = () => {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation(() => ({
|
return useMutation(() => ({
|
||||||
mutationFn: async (name: string) => {
|
mutationFn: async (name: string) => {
|
||||||
const status = sync.data.mcp[name]
|
const status = sync.data.mcp[name]
|
||||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||||
const result = await sdk.client.mcp.status()
|
|
||||||
if (result.data) sync.set("mcp", result.data)
|
|
||||||
},
|
},
|
||||||
|
onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -162,14 +163,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sdk = useSDK()
|
|
||||||
|
|
||||||
const [load, setLoad] = createStore({
|
|
||||||
lspDone: false,
|
|
||||||
lspLoading: false,
|
|
||||||
mcpDone: false,
|
|
||||||
mcpLoading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const fail = (err: unknown) => {
|
const fail = (err: unknown) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -181,40 +174,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.shown()) return
|
if (!props.shown()) return
|
||||||
|
|
||||||
if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) {
|
|
||||||
setLoad("mcpLoading", true)
|
|
||||||
void sdk.client.mcp
|
|
||||||
.status()
|
|
||||||
.then((result) => {
|
|
||||||
sync.set("mcp", result.data ?? {})
|
|
||||||
sync.set("mcp_ready", true)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setLoad("mcpDone", true)
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoad("mcpLoading", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) {
|
|
||||||
setLoad("lspLoading", true)
|
|
||||||
void sdk.client.lsp
|
|
||||||
.status()
|
|
||||||
.then((result) => {
|
|
||||||
sync.set("lsp", result.data ?? [])
|
|
||||||
sync.set("lsp_ready", true)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setLoad("lspDone", true)
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoad("lspLoading", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let dialogRun = 0
|
let dialogRun = 0
|
||||||
|
|||||||
@@ -14,17 +14,25 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
|
import {
|
||||||
|
bootstrapDirectory,
|
||||||
|
bootstrapGlobal,
|
||||||
|
clearProviderRev,
|
||||||
|
loadGlobalConfigQuery,
|
||||||
|
loadPathQuery,
|
||||||
|
loadProjectsQuery,
|
||||||
|
loadProvidersQuery,
|
||||||
|
} from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||||
import { createRefreshQueue } from "./global-sync/queue"
|
|
||||||
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
||||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
import { trimSessions } from "./global-sync/session-trim"
|
import { trimSessions } from "./global-sync/session-trim"
|
||||||
import type { ProjectMeta } from "./global-sync/types"
|
import type { ProjectMeta } from "./global-sync/types"
|
||||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
|
import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||||
|
import { createRefreshQueue } from "./global-sync/queue"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -43,6 +51,18 @@ type GlobalStore = {
|
|||||||
export const loadSessionsQuery = (directory: string) =>
|
export const loadSessionsQuery = (directory: string) =>
|
||||||
queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: skipToken })
|
queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: skipToken })
|
||||||
|
|
||||||
|
export const loadMcpQuery = (directory: string, sdk?: OpencodeClient) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [directory, "mcp"],
|
||||||
|
queryFn: sdk ? () => sdk.mcp.status().then((r) => r.data ?? {}) : skipToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadLspQuery = (directory: string, sdk?: OpencodeClient) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [directory, "lsp"],
|
||||||
|
queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? []) : skipToken,
|
||||||
|
})
|
||||||
|
|
||||||
function createGlobalSync() {
|
function createGlobalSync() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -54,15 +74,34 @@ function createGlobalSync() {
|
|||||||
const sessionLoads = new Map<string, Promise<void>>()
|
const sessionLoads = new Map<string, Promise<void>>()
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
|
|
||||||
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
|
queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()],
|
||||||
|
}))
|
||||||
|
|
||||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||||
ready: false,
|
get ready() {
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
return bootstrap.isPending
|
||||||
|
},
|
||||||
project: [],
|
project: [],
|
||||||
session_todo: {},
|
session_todo: {},
|
||||||
provider: { all: [], connected: [], default: {} },
|
|
||||||
provider_auth: {},
|
provider_auth: {},
|
||||||
config: {},
|
get path() {
|
||||||
reload: undefined,
|
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
|
||||||
|
if (pathQuery.isLoading) return EMPTY
|
||||||
|
return pathQuery.data ?? EMPTY
|
||||||
|
},
|
||||||
|
get provider() {
|
||||||
|
const EMPTY = { all: [], connected: [], default: {} }
|
||||||
|
if (providerQuery.isLoading) return EMPTY
|
||||||
|
return providerQuery.data ?? EMPTY
|
||||||
|
},
|
||||||
|
get config() {
|
||||||
|
if (configQuery.isLoading) return {}
|
||||||
|
return configQuery.data ?? {}
|
||||||
|
},
|
||||||
|
get reload() {
|
||||||
|
return updateConfigMutation.isPending ? "pending" : undefined
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
@@ -88,6 +127,22 @@ function createGlobalSync() {
|
|||||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||||
}) as typeof setGlobalStore
|
}) as typeof setGlobalStore
|
||||||
|
|
||||||
|
const bootstrap = useQuery(() => ({
|
||||||
|
queryKey: ["bootstrap"],
|
||||||
|
queryFn: async () => {
|
||||||
|
await bootstrapGlobal({
|
||||||
|
globalSDK: globalSDK.client,
|
||||||
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
|
translate: language.t,
|
||||||
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||||
|
setGlobalStore: setBootStore,
|
||||||
|
queryClient,
|
||||||
|
})
|
||||||
|
bootedAt = Date.now()
|
||||||
|
return bootedAt
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
const set = ((...input: unknown[]) => {
|
const set = ((...input: unknown[]) => {
|
||||||
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||||
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
|
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
|
||||||
@@ -114,10 +169,21 @@ function createGlobalSync() {
|
|||||||
|
|
||||||
const queue = createRefreshQueue({
|
const queue = createRefreshQueue({
|
||||||
paused,
|
paused,
|
||||||
bootstrap,
|
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
|
||||||
bootstrapInstance,
|
bootstrapInstance,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sdkFor = (directory: string) => {
|
||||||
|
const cached = sdkCache.get(directory)
|
||||||
|
if (cached) return cached
|
||||||
|
const sdk = globalSDK.createClient({
|
||||||
|
directory,
|
||||||
|
throwOnError: true,
|
||||||
|
})
|
||||||
|
sdkCache.set(directory, sdk)
|
||||||
|
return sdk
|
||||||
|
}
|
||||||
|
|
||||||
const children = createChildStoreManager({
|
const children = createChildStoreManager({
|
||||||
owner,
|
owner,
|
||||||
isBooting: (directory) => booting.has(directory),
|
isBooting: (directory) => booting.has(directory),
|
||||||
@@ -133,19 +199,9 @@ function createGlobalSync() {
|
|||||||
clearSessionPrefetchDirectory(directory)
|
clearSessionPrefetchDirectory(directory)
|
||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
|
getSdk: sdkFor,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdkFor = (directory: string) => {
|
|
||||||
const cached = sdkCache.get(directory)
|
|
||||||
if (cached) return cached
|
|
||||||
const sdk = globalSDK.createClient({
|
|
||||||
directory,
|
|
||||||
throwOnError: true,
|
|
||||||
})
|
|
||||||
sdkCache.set(directory, sdk)
|
|
||||||
return sdk
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSessions(directory: string) {
|
async function loadSessions(directory: string) {
|
||||||
const pending = sessionLoads.get(directory)
|
const pending = sessionLoads.get(directory)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
@@ -264,26 +320,13 @@ function createGlobalSync() {
|
|||||||
const event = e.details
|
const event = e.details
|
||||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
|
||||||
const error = event.properties.error
|
|
||||||
if (error?.name !== "MessageAbortedError") {
|
|
||||||
console.error("[global-sync] session error", {
|
|
||||||
scope: directory === "global" ? "global" : "workspace",
|
|
||||||
directory: directory === "global" ? undefined : directory,
|
|
||||||
project: directory === "global" ? undefined : getFilename(directory),
|
|
||||||
sessionID: event.properties.sessionID,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
event,
|
event,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
if (recent) return
|
if (recent) return
|
||||||
queue.refresh()
|
bootstrap.refetch()
|
||||||
},
|
},
|
||||||
setGlobalProject: setProjects,
|
setGlobalProject: setProjects,
|
||||||
})
|
})
|
||||||
@@ -309,12 +352,7 @@ function createGlobalSync() {
|
|||||||
setSessionTodo,
|
setSessionTodo,
|
||||||
vcsCache: children.vcsCache.get(directory),
|
vcsCache: children.vcsCache.get(directory),
|
||||||
loadLsp: () => {
|
loadLsp: () => {
|
||||||
void sdkFor(directory)
|
void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory)))
|
||||||
.lsp.status()
|
|
||||||
.then((x) => {
|
|
||||||
setStore("lsp", x.data ?? [])
|
|
||||||
setStore("lsp_ready", true)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -329,23 +367,6 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
bootingRoot = true
|
|
||||||
try {
|
|
||||||
await bootstrapGlobal({
|
|
||||||
globalSDK: globalSDK.client,
|
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
|
||||||
translate: language.t,
|
|
||||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
|
||||||
setGlobalStore: setBootStore,
|
|
||||||
queryClient,
|
|
||||||
})
|
|
||||||
bootedAt = Date.now()
|
|
||||||
} finally {
|
|
||||||
bootingRoot = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (typeof requestAnimationFrame === "function") {
|
if (typeof requestAnimationFrame === "function") {
|
||||||
eventFrame = requestAnimationFrame(() => {
|
eventFrame = requestAnimationFrame(() => {
|
||||||
@@ -361,7 +382,6 @@ function createGlobalSync() {
|
|||||||
void globalSDK.event.start()
|
void globalSDK.event.start()
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
void bootstrap()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const projectApi = {
|
const projectApi = {
|
||||||
@@ -374,21 +394,10 @@ function createGlobalSync() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateConfig = async (config: Config) => {
|
const updateConfigMutation = useMutation(() => ({
|
||||||
setGlobalStore("reload", "pending")
|
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
|
||||||
return globalSDK.client.global.config
|
onSuccess: () => bootstrap.refetch(),
|
||||||
.update({ config })
|
}))
|
||||||
.then(bootstrap)
|
|
||||||
.then(() => {
|
|
||||||
queue.refresh()
|
|
||||||
setGlobalStore("reload", undefined)
|
|
||||||
queue.refresh()
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setGlobalStore("reload", undefined)
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: globalStore,
|
data: globalStore,
|
||||||
@@ -401,8 +410,8 @@ function createGlobalSync() {
|
|||||||
},
|
},
|
||||||
child: children.child,
|
child: children.child,
|
||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
bootstrap,
|
// bootstrap,
|
||||||
updateConfig,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
todo: {
|
todo: {
|
||||||
set: setSessionTodo,
|
set: setSessionTodo,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { State, VcsCache } from "./types"
|
|||||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
|
||||||
|
import { loadMcpQuery } from "../global-sync"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -66,6 +67,62 @@ function runAll(list: Array<() => Promise<unknown>>) {
|
|||||||
return Promise.allSettled(list.map((item) => item()))
|
return Promise.allSettled(list.map((item) => item()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showErrors(input: {
|
||||||
|
errors: unknown[]
|
||||||
|
title: string
|
||||||
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
|
formatMoreCount: (count: number) => string
|
||||||
|
}) {
|
||||||
|
if (input.errors.length === 0) return
|
||||||
|
const message = formatServerError(input.errors[0], input.translate)
|
||||||
|
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.title,
|
||||||
|
description: message + more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadGlobalConfigQuery = (
|
||||||
|
sdk?: OpencodeClient,
|
||||||
|
transform?: (x: Awaited<ReturnType<OpencodeClient["global"]["config"]["get"]>>) => void,
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: sdk
|
||||||
|
? () =>
|
||||||
|
retry(() =>
|
||||||
|
sdk.global.config.get().then((x) => {
|
||||||
|
transform?.(x)
|
||||||
|
return x.data!
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: skipToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadProjectsQuery = (
|
||||||
|
sdk?: OpencodeClient,
|
||||||
|
transform?: (x: Awaited<ReturnType<OpencodeClient["project"]["list"]>>["data"]) => void,
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["project"],
|
||||||
|
queryFn: sdk
|
||||||
|
? () =>
|
||||||
|
retry(() =>
|
||||||
|
sdk.project
|
||||||
|
.list()
|
||||||
|
.then((x) => {
|
||||||
|
return (x.data ?? [])
|
||||||
|
.filter((p) => !!p?.id)
|
||||||
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
})
|
||||||
|
.then(transform),
|
||||||
|
)
|
||||||
|
: skipToken,
|
||||||
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
globalSDK: OpencodeClient
|
globalSDK: OpencodeClient
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
@@ -74,53 +131,15 @@ export async function bootstrapGlobal(input: {
|
|||||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
const fast = [
|
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.global.config.get().then((x) => {
|
|
||||||
input.setGlobalStore("config", reconcile(x.data!, { merge: false }))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
const slow = [
|
const slow = [
|
||||||
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)),
|
||||||
|
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)),
|
||||||
|
() => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery({
|
input.queryClient.fetchQuery(
|
||||||
...loadProvidersQuery(null),
|
loadProjectsQuery(input.globalSDK, (data) => input.setGlobalStore("project", data ?? [])),
|
||||||
queryFn: () =>
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.provider.list().then((x) => {
|
|
||||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
|
||||||
return null
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.path.get().then((x) => {
|
|
||||||
input.setGlobalStore("path", x.data!)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.project.list().then((x) => {
|
|
||||||
const projects = (x.data ?? [])
|
|
||||||
.filter((p) => !!p?.id)
|
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
|
||||||
input.setGlobalStore("project", projects)
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
await runAll(fast)
|
|
||||||
// showErrors({
|
|
||||||
// errors: errors(await runAll(fast)),
|
|
||||||
// title: input.requestFailedTitle,
|
|
||||||
// translate: input.translate,
|
|
||||||
// formatMoreCount: input.formatMoreCount,
|
|
||||||
// })
|
|
||||||
await waitForPaint()
|
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
// showErrors({
|
// showErrors({
|
||||||
// errors: errors(),
|
// errors: errors(),
|
||||||
@@ -128,7 +147,6 @@ export async function bootstrapGlobal(input: {
|
|||||||
// translate: input.translate,
|
// translate: input.translate,
|
||||||
// formatMoreCount: input.formatMoreCount,
|
// formatMoreCount: input.formatMoreCount,
|
||||||
// })
|
// })
|
||||||
input.setGlobalStore("ready", true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
|
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
|
||||||
@@ -179,26 +197,28 @@ function warmSessions(input: {
|
|||||||
).then(() => undefined)
|
).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadProvidersQuery = (directory: string | null) =>
|
export const loadProvidersQuery = (directory: string | null, sdk?: OpencodeClient) =>
|
||||||
queryOptions<null>({ queryKey: [directory, "providers"], queryFn: skipToken })
|
queryOptions({
|
||||||
|
queryKey: [directory, "providers"],
|
||||||
|
queryFn: sdk ? () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))) : skipToken,
|
||||||
|
})
|
||||||
|
|
||||||
export const loadAgentsQuery = (
|
export const loadAgentsQuery = (
|
||||||
directory: string | null,
|
directory: string | null,
|
||||||
sdk?: OpencodeClient,
|
sdk?: OpencodeClient,
|
||||||
transform?: (x: Awaited<ReturnType<OpencodeClient["app"]["agents"]>>) => void,
|
transform?: (x: Awaited<ReturnType<OpencodeClient["app"]["agents"]>>) => void,
|
||||||
) =>
|
) =>
|
||||||
queryOptions<null>({
|
queryOptions({
|
||||||
queryKey: [directory, "agents"],
|
queryKey: [directory, "agents"],
|
||||||
queryFn:
|
queryFn: sdk
|
||||||
sdk && transform
|
? () =>
|
||||||
? () =>
|
retry(() =>
|
||||||
retry(() =>
|
sdk.app.agents().then((x) => {
|
||||||
sdk.app
|
transform?.(x)
|
||||||
.agents()
|
return x.data!
|
||||||
.then(transform)
|
}),
|
||||||
.then(() => null),
|
)
|
||||||
)
|
: skipToken,
|
||||||
: skipToken,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadPathQuery = (
|
export const loadPathQuery = (
|
||||||
@@ -208,16 +228,15 @@ export const loadPathQuery = (
|
|||||||
) =>
|
) =>
|
||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [directory, "path"],
|
queryKey: [directory, "path"],
|
||||||
queryFn:
|
queryFn: sdk
|
||||||
sdk && transform
|
? () =>
|
||||||
? () =>
|
retry(() =>
|
||||||
retry(() =>
|
sdk.path.get().then(async (x) => {
|
||||||
sdk.path.get().then(async (x) => {
|
transform?.(x)
|
||||||
transform(x)
|
return x.data!
|
||||||
return x.data!
|
}),
|
||||||
}),
|
)
|
||||||
)
|
: skipToken,
|
||||||
: skipToken,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
@@ -247,13 +266,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||||
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
||||||
}
|
}
|
||||||
if (loading || input.store.provider.all.length === 0) {
|
|
||||||
input.setStore("provider_ready", false)
|
|
||||||
}
|
|
||||||
input.setStore("mcp_ready", false)
|
|
||||||
input.setStore("mcp", {})
|
|
||||||
input.setStore("lsp_ready", false)
|
|
||||||
input.setStore("lsp", [])
|
|
||||||
if (loading) input.setStore("status", "partial")
|
if (loading) input.setStore("status", "partial")
|
||||||
|
|
||||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
||||||
@@ -340,33 +352,15 @@ export async function bootstrapDirectory(input: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
|
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
|
||||||
input.sdk.mcp.status().then((x) => {
|
const project = getFilename(input.directory)
|
||||||
input.setStore("mcp", x.data!)
|
showToast({
|
||||||
input.setStore("mcp_ready", true)
|
variant: "error",
|
||||||
}),
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
),
|
description: formatServerError(err, input.translate),
|
||||||
() =>
|
})
|
||||||
input.queryClient.ensureQueryData({
|
|
||||||
...loadProvidersQuery(input.directory),
|
|
||||||
queryFn: () =>
|
|
||||||
retry(() => input.sdk.provider.list())
|
|
||||||
.then((x) => {
|
|
||||||
if (providerRev.get(input.directory) !== rev) return
|
|
||||||
input.setStore("provider", normalizeProviderList(x.data!))
|
|
||||||
input.setStore("provider_ready", true)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err)
|
|
||||||
const project = getFilename(input.directory)
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
|
||||||
description: formatServerError(err, input.translate),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(() => null),
|
|
||||||
}),
|
}),
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe("createChildStoreManager", () => {
|
|||||||
onBootstrap() {},
|
onBootstrap() {},
|
||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
|
getSdk: () => null!,
|
||||||
})
|
})
|
||||||
|
|
||||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
import type { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
DIR_IDLE_TTL_MS,
|
DIR_IDLE_TTL_MS,
|
||||||
MAX_DIR_STORES,
|
MAX_DIR_STORES,
|
||||||
@@ -14,8 +14,9 @@ import {
|
|||||||
type VcsCache,
|
type VcsCache,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||||
import { useQuery } from "@tanstack/solid-query"
|
import { useQueries } from "@tanstack/solid-query"
|
||||||
import { loadPathQuery } from "./bootstrap"
|
import { loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||||
|
import { loadLspQuery, loadMcpQuery } from "../global-sync"
|
||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
@@ -24,6 +25,7 @@ export function createChildStoreManager(input: {
|
|||||||
onBootstrap: (directory: string) => void
|
onBootstrap: (directory: string) => void
|
||||||
onDispose: (directory: string) => void
|
onDispose: (directory: string) => void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
|
getSdk: (directory: string) => OpencodeClient
|
||||||
}) {
|
}) {
|
||||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||||
const vcsCache = new Map<string, VcsCache>()
|
const vcsCache = new Map<string, VcsCache>()
|
||||||
@@ -156,14 +158,27 @@ export function createChildStoreManager(input: {
|
|||||||
|
|
||||||
const init = () =>
|
const init = () =>
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
|
const sdk = input.getSdk(directory)
|
||||||
|
|
||||||
|
const initialMeta = meta[0].value
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
|
|
||||||
const pathQuery = useQuery(() => loadPathQuery(directory))
|
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
|
||||||
|
queries: [
|
||||||
|
loadPathQuery(directory, sdk),
|
||||||
|
loadMcpQuery(directory, sdk),
|
||||||
|
loadLspQuery(directory, sdk),
|
||||||
|
loadProvidersQuery(directory, sdk),
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: undefined,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
provider_ready: false,
|
get provider_ready() {
|
||||||
|
return providerQuery.isLoading
|
||||||
|
},
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
config: {},
|
config: {},
|
||||||
get path() {
|
get path() {
|
||||||
@@ -181,10 +196,18 @@ export function createChildStoreManager(input: {
|
|||||||
todo: {},
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
question: {},
|
||||||
mcp_ready: false,
|
get mcp_ready() {
|
||||||
mcp: {},
|
return mcpQuery.isLoading
|
||||||
lsp_ready: false,
|
},
|
||||||
lsp: [],
|
get mcp() {
|
||||||
|
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
||||||
|
},
|
||||||
|
get lsp_ready() {
|
||||||
|
return lspQuery.isLoading
|
||||||
|
},
|
||||||
|
get lsp() {
|
||||||
|
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||||
|
},
|
||||||
vcs: vcsStore.value,
|
vcs: vcsStore.value,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
@@ -207,6 +230,11 @@ export function createChildStoreManager(input: {
|
|||||||
child[1]("vcs", (value) => value ?? cached)
|
child[1]("vcs", (value) => value ?? cached)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onPersistedInit(meta[2], () => {
|
||||||
|
if (child[0].projectMeta !== initialMeta) return
|
||||||
|
child[1]("projectMeta", meta[0].value)
|
||||||
|
})
|
||||||
|
|
||||||
onPersistedInit(icon[2], () => {
|
onPersistedInit(icon[2], () => {
|
||||||
if (child[0].icon !== initialIcon) return
|
if (child[0].icon !== initialIcon) return
|
||||||
child[1]("icon", icon[0].value)
|
child[1]("icon", icon[0].value)
|
||||||
|
|||||||
Reference in New Issue
Block a user