From 7c09c2f17ff29e12b9353a29133dc1587fbdd48b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 11:21:14 -0400 Subject: [PATCH] refactor(project): apply simplify review - Fix prunable parser to match 'prunable ' lines, not just bare 'prunable'. - Parallelize Effect.forEach in Worktree.list with concurrency: 'unbounded' to match the rest of the file. - Simplify the experimental worktree GET handler to use Effect.fn(fn, Effect.map(...)). - Drop unused isProjectList/isProjectUpdate type guards and the unknown-cast set/setBootStore wrappers in global-sync; expose setGlobalStore directly. - Centralize project worktree mutations as globalSync.project.addWorktree / removeWorktree, replacing three near-duplicate produce blocks in pages/layout.tsx and prompt-input/submit.ts. Use pathKey for dedup in all sites. - Cast project.updated event properties as Project (not ProjectInfo) and seed worktrees: [] on insert. - Extract projectDirectories(project) helper in pages/layout/helpers.ts; use at six call sites that built [project.worktree, ...(project.worktrees ?? [])] inline. --- .../components/dialog-select-directory.tsx | 3 +- .../app/src/components/dialog-select-file.tsx | 3 +- .../app/src/components/prompt-input/submit.ts | 13 +---- packages/app/src/context/global-sync.tsx | 55 ++++++++++--------- .../src/context/global-sync/event-reducer.ts | 5 +- packages/app/src/pages/layout.tsx | 27 ++------- packages/app/src/pages/layout/helpers.ts | 5 ++ .../app/src/pages/layout/sidebar-items.tsx | 4 +- .../instance/httpapi/handlers/experimental.ts | 7 ++- packages/opencode/src/worktree/index.ts | 31 ++++++----- 10 files changed, 72 insertions(+), 81 deletions(-) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 6f397f86c9..3ffd48e938 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -10,6 +10,7 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLayout } from "@/context/layout" import { useLanguage } from "@/context/language" +import { projectDirectories } from "@/pages/layout/helpers" interface DialogSelectDirectoryProps { title?: string @@ -284,7 +285,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { for (const project of projects) { let at = 0 - const dirs = [project.worktree, ...(project.worktrees ?? [])] + const dirs = projectDirectories(project) for (const directory of dirs) { const sessions = sync.child(directory, { bootstrap: false })[0].session for (const session of sessions) { diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 8f447c02a4..9adf87000f 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -16,6 +16,7 @@ import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" +import { projectDirectories } from "@/pages/layout/helpers" import { decode64 } from "@/utils/base64" import { getRelativeTime } from "@/utils/time" @@ -287,7 +288,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const current = project() if (!current) return directory ? [directory] : [] - const dirs = [current.worktree, ...(current.worktrees ?? [])] + const dirs = projectDirectories(current) if (directory && !dirs.includes(directory)) return [...dirs, directory] return dirs }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 15920ca5e2..be0ab40111 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -4,7 +4,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" -import { produce } from "solid-js/store" import type { FileSelection } from "@/context/file" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" @@ -343,17 +342,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } WorktreeState.pending(createdWorktree.directory) - globalSync.set( - "project", - produce((draft) => { - const project = draft.find((item) => item.worktree === projectDirectory) - if (!project) return - project.worktrees = [ - createdWorktree.directory, - ...(project.worktrees ?? []).filter((worktree) => worktree !== createdWorktree.directory), - ] - }), - ) + globalSync.project.addWorktree(projectDirectory, createdWorktree.directory) sessionDirectory = createdWorktree.directory } diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 4d86d2842a..d1d4d52eb3 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -32,6 +32,7 @@ import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" +import { pathKey } from "@/utils/path-key" type GlobalStore = { ready: boolean @@ -47,10 +48,6 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -const isProjectList = (input: unknown): input is ProjectInfo[] => Array.isArray(input) -const isProjectUpdate = (input: unknown): input is (draft: ProjectInfo[]) => ProjectInfo[] => - typeof input === "function" - export const loadSessionsQueryKey = (directory: string) => [directory, "loadSessions"] as const export const mcpQueryKey = (directory: string) => [directory, "mcp"] as const @@ -125,18 +122,6 @@ function createGlobalSync() { if (eventTimer !== undefined) clearTimeout(eventTimer) }) - const setProjects = (next: ProjectInfo[] | ((draft: ProjectInfo[]) => ProjectInfo[])) => { - setGlobalStore("project", next) - } - - const setBootStore = ((...input: unknown[]) => { - if (input[0] === "project" && isProjectList(input[1])) { - setProjects(input[1]) - return input[1] - } - return (setGlobalStore as (...args: unknown[]) => unknown)(...input) - }) as typeof setGlobalStore - const bootstrap = useQuery(() => ({ queryKey: ["bootstrap"], queryFn: async () => { @@ -145,7 +130,7 @@ function createGlobalSync() { requestFailedTitle: language.t("common.requestFailed"), translate: language.t, formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, + setGlobalStore, queryClient, }) bootedAt = Date.now() @@ -153,13 +138,31 @@ function createGlobalSync() { }, })) - const set = ((...input: unknown[]) => { - if (input[0] === "project" && (isProjectList(input[1]) || isProjectUpdate(input[1]))) { - setProjects(input[1]) - return input[1] - } - return (setGlobalStore as (...args: unknown[]) => unknown)(...input) - }) as typeof setGlobalStore + const set = setGlobalStore + + const addProjectWorktree = (projectWorktree: string, directory: string) => { + setGlobalStore( + "project", + produce((draft) => { + const project = draft.find((item) => item.worktree === projectWorktree) + if (!project) return + const key = pathKey(directory) + project.worktrees = [directory, ...(project.worktrees ?? []).filter((item) => pathKey(item) !== key)] + }), + ) + } + + const removeProjectWorktree = (projectWorktree: string, directory: string) => { + setGlobalStore( + "project", + produce((draft) => { + const project = draft.find((item) => item.worktree === projectWorktree) + if (!project) return + const key = pathKey(directory) + project.worktrees = (project.worktrees ?? []).filter((item) => pathKey(item) !== key) + }), + ) + } const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { if (!sessionID) return @@ -347,7 +350,7 @@ function createGlobalSync() { if (recent) return bootstrap.refetch() }, - setGlobalProject: setProjects, + setGlobalProject: (next) => setGlobalStore("project", next), }) if (event.type === "server.connected" || event.type === "global.disposed") { if (recent) return @@ -411,6 +414,8 @@ function createGlobalSync() { icon(directory: string, value: string | undefined) { children.projectIcon(directory, value) }, + addWorktree: addProjectWorktree, + removeWorktree: removeProjectWorktree, } const updateConfigMutation = useMutation(() => ({ diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 4c929c66e3..2c05de8899 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -4,6 +4,7 @@ import type { Message, Part, PermissionRequest, + Project, QuestionRequest, Session, SessionStatus, @@ -29,7 +30,7 @@ export function applyGlobalEvent(input: { } if (input.event.type !== "project.updated") return - const properties = input.event.properties as ProjectInfo + const properties = input.event.properties as Project const result = Binary.search(input.project, properties.id, (s) => s.id) if (result.found) { input.setGlobalProject( @@ -41,7 +42,7 @@ export function applyGlobalEvent(input: { } input.setGlobalProject( produce((draft) => { - draft.splice(result.index, 0, properties) + draft.splice(result.index, 0, { ...properties, worktrees: [] }) }), ) } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f75dbd58d5..c798d99179 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -70,6 +70,7 @@ import { effectiveWorkspaceOrder, errorMessage, latestRootSession, + projectDirectories, sortedRootSessions, } from "./layout/helpers" import { @@ -1274,9 +1275,7 @@ export default function Layout(props: ParentProps) { const root = projectRoot(directory) server.projects.touch(root) const project = layout.projects.list().find((item) => item.worktree === root) - let dirs = project - ? effectiveWorkspaceOrder(root, [root, ...(project.worktrees ?? [])], store.workspaceOrder[root]) - : [root] + let dirs = project ? effectiveWorkspaceOrder(root, projectDirectories(project), store.workspaceOrder[root]) : [root] const canOpen = (value: string | undefined) => { if (!value) return false return dirs.some((item) => pathKey(item) === pathKey(value)) @@ -1509,14 +1508,7 @@ export default function Layout(props: ParentProps) { clearLastProjectSession(root) } - globalSync.set( - "project", - produce((draft) => { - const project = draft.find((item) => item.worktree === root) - if (!project) return - project.worktrees = (project.worktrees ?? []).filter((worktree) => worktree !== directory) - }), - ) + globalSync.project.removeWorktree(root, directory) setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory)) layout.projects.close(directory) @@ -1528,7 +1520,7 @@ export default function Layout(props: ParentProps) { const nextKey = pathKey(nextCurrent) const project = layout.projects.list().find((item) => item.worktree === root) const dirs = project - ? effectiveWorkspaceOrder(root, [root, ...(project.worktrees ?? [])], store.workspaceOrder[root]) + ? effectiveWorkspaceOrder(root, projectDirectories(project), store.workspaceOrder[root]) : [root] const valid = dirs.some((item) => pathKey(item) === nextKey) @@ -1862,7 +1854,7 @@ export default function Layout(props: ParentProps) { function workspaceIds(project: LocalProject | undefined) { if (!project) return [] const local = project.worktree - const dirs = [local, ...(project.worktrees ?? [])] + const dirs = projectDirectories(project) const active = currentProject() const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined const extra = @@ -1954,14 +1946,7 @@ export default function Layout(props: ParentProps) { }) return [created.directory, ...next] }) - globalSync.set( - "project", - produce((draft) => { - const item = draft.find((item) => item.worktree === project.worktree) - if (!item) return - item.worktrees = [created.directory, ...(item.worktrees ?? []).filter((worktree) => pathKey(worktree) !== key)] - }), - ) + globalSync.project.addWorktree(project.worktree, created.directory) globalSync.child(created.directory) navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index d53381e404..439ca8a2ef 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -55,6 +55,11 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri export const displayName = (project: { name?: string; worktree: string }) => project.name || getFilename(project.worktree) +export const projectDirectories = (project: { worktree: string; worktrees?: string[] }) => [ + project.worktree, + ...(project.worktrees ?? []), +] + export const errorMessage = (err: unknown, fallback: string) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 9b90c28b79..4a07fcd7b6 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -15,7 +15,7 @@ import { usePermission } from "@/context/permission" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/composer/session-request-tree" -import { childSessionOnPath, hasProjectPermissions } from "./helpers" +import { childSessionOnPath, hasProjectPermissions, projectDirectories } from "./helpers" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -35,7 +35,7 @@ export const ProjectIcon = (props: { const globalSync = useGlobalSync() const notification = useNotification() const permission = usePermission() - const dirs = createMemo(() => [props.project.worktree, ...(props.project.worktrees ?? [])]) + const dirs = createMemo(() => projectDirectories(props.project)) const unseenCount = createMemo(() => dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 20f100f5ae..cc102a87fc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -89,9 +89,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper return yield* registry.ids() }) - const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () { - return yield* worktreeSvc.list().pipe(Effect.map((items) => items.map((item) => item.directory))) - }) + const worktree = Effect.fn("ExperimentalHttpApi.worktree")( + () => worktreeSvc.list(), + Effect.map((items) => items.map((item) => item.directory)), + ) const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: { payload: Worktree.CreateInput | undefined diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 9cf1c90c44..d6b240144c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -315,7 +315,7 @@ export const layer: Layer.Layer< if (line.startsWith("branch ")) { current.branch = line.slice("branch ".length).trim() } - if (line === "prunable") { + if (line === "prunable" || line.startsWith("prunable ")) { current.prunable = true } return acc @@ -347,19 +347,22 @@ export const layer: Layer.Layer< const primary = yield* canonical(ctx.worktree) const primaryName = pathSvc.basename(primary).toLowerCase() - return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => - Effect.gen(function* () { - if (entry.prunable) return undefined - if (!entry.path) return undefined - const directory = yield* canonical(entry.path) - if (directory === primary) return undefined - const name = pathSvc.basename(directory).toLowerCase() - return { - name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name, - directory, - ...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}), - } - }), + return yield* Effect.forEach( + parseWorktreeList(result.text), + (entry) => + Effect.gen(function* () { + if (entry.prunable) return undefined + if (!entry.path) return undefined + const directory = yield* canonical(entry.path) + if (directory === primary) return undefined + const name = pathSvc.basename(directory).toLowerCase() + return { + name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name, + directory, + ...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}), + } + }), + { concurrency: "unbounded" }, ).pipe(Effect.map((items) => items.filter((item) => item !== undefined))) })