Files
CodeNomad/packages/ui/src/stores/worktrees.ts

395 lines
13 KiB
TypeScript

import { createSignal } from "solid-js"
import type { WorktreeDescriptor, WorktreeMap } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { sdkManager, type OpencodeClient } from "../lib/sdk-manager"
import { sessions } from "./session-state"
import { getLogger } from "../lib/logger"
const log = getLogger("api")
const [worktreesByInstance, setWorktreesByInstance] = createSignal<Map<string, WorktreeDescriptor[]>>(new Map())
const [worktreeMapByInstance, setWorktreeMapByInstance] = createSignal<Map<string, WorktreeMap>>(new Map())
const [gitRepoStatusByInstance, setGitRepoStatusByInstance] = createSignal<Map<string, boolean | null>>(new Map())
const worktreeLoads = new Map<string, Promise<void>>()
const mapLoads = new Map<string, Promise<void>>()
function normalizeMap(input?: WorktreeMap | null): WorktreeMap {
if (!input || typeof input !== "object") {
return { version: 1, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: {} }
}
return {
version: 1,
defaultWorktreeSlug: input.defaultWorktreeSlug || "root",
parentSessionWorktreeSlug: input.parentSessionWorktreeSlug ?? {},
}
}
async function ensureWorktreesLoaded(instanceId: string): Promise<void> {
if (!instanceId) return
if (worktreesByInstance().has(instanceId) && gitRepoStatusByInstance().has(instanceId)) return
const existing = worktreeLoads.get(instanceId)
if (existing) return existing
const task = serverApi
.fetchWorktrees(instanceId)
.then((response) => {
setWorktreesByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, response.worktrees ?? [])
return next
})
setGitRepoStatusByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, typeof response.isGitRepo === "boolean" ? response.isGitRepo : null)
return next
})
// If we already loaded a worktree mapping, drop stale slugs.
if (worktreeMapByInstance().has(instanceId)) {
void pruneWorktreeMap(instanceId).catch(() => undefined)
}
})
.catch((error) => {
log.warn("Failed to load worktrees", { instanceId, error })
setWorktreesByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, [])
return next
})
// Preserve any previous value; if unknown, keep it unknown.
setGitRepoStatusByInstance((prev) => {
if (prev.has(instanceId)) return prev
const next = new Map(prev)
next.set(instanceId, null)
return next
})
})
.finally(() => {
worktreeLoads.delete(instanceId)
})
worktreeLoads.set(instanceId, task)
return task
}
async function reloadWorktrees(instanceId: string): Promise<void> {
if (!instanceId) return
await serverApi
.fetchWorktrees(instanceId)
.then((response) => {
setWorktreesByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, response.worktrees ?? [])
return next
})
setGitRepoStatusByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, typeof response.isGitRepo === "boolean" ? response.isGitRepo : null)
return next
})
if (worktreeMapByInstance().has(instanceId)) {
void pruneWorktreeMap(instanceId).catch(() => undefined)
}
})
.catch((error) => {
log.warn("Failed to reload worktrees", { instanceId, error })
})
}
function getGitRepoStatus(instanceId: string): boolean | null {
return gitRepoStatusByInstance().get(instanceId) ?? null
}
async function createWorktree(instanceId: string, slug: string): Promise<{ slug: string; directory: string; branch?: string }> {
if (!instanceId) {
throw new Error("Missing instanceId")
}
const trimmed = (slug ?? "").trim()
if (!trimmed) {
throw new Error("Worktree name is required")
}
return await serverApi.createWorktree(instanceId, { slug: trimmed })
}
async function deleteWorktree(instanceId: string, slug: string, options?: { force?: boolean }): Promise<void> {
if (!instanceId) {
throw new Error("Missing instanceId")
}
const trimmed = (slug ?? "").trim()
if (!trimmed || trimmed === "root") {
throw new Error("Invalid worktree")
}
await serverApi.deleteWorktree(instanceId, trimmed, options)
}
async function ensureWorktreeMapLoaded(instanceId: string): Promise<void> {
if (!instanceId) return
if (worktreeMapByInstance().has(instanceId)) return
const existing = mapLoads.get(instanceId)
if (existing) return existing
const task = serverApi
.readWorktreeMap(instanceId)
.then((map) => {
setWorktreeMapByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, normalizeMap(map))
return next
})
// If worktrees are already loaded, prune any mappings that reference missing worktrees.
if (worktreesByInstance().has(instanceId)) {
void pruneWorktreeMap(instanceId).catch(() => undefined)
}
})
.catch((error) => {
log.warn("Failed to load worktree map", { instanceId, error })
setWorktreeMapByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, normalizeMap(null))
return next
})
})
.finally(() => {
mapLoads.delete(instanceId)
})
mapLoads.set(instanceId, task)
return task
}
async function reloadWorktreeMap(instanceId: string): Promise<void> {
if (!instanceId) return
await serverApi
.readWorktreeMap(instanceId)
.then((map) => {
setWorktreeMapByInstance((prev) => {
const next = new Map(prev)
next.set(instanceId, normalizeMap(map))
return next
})
})
.catch((error) => {
log.warn("Failed to reload worktree map", { instanceId, error })
})
}
function getWorktrees(instanceId: string): WorktreeDescriptor[] {
return worktreesByInstance().get(instanceId) ?? []
}
function getWorktreeMap(instanceId: string): WorktreeMap {
return worktreeMapByInstance().get(instanceId) ?? normalizeMap(null)
}
function isWorktreeSlugAvailable(instanceId: string, slug: string): boolean {
const normalized = (slug ?? "").trim() || "root"
if (normalized === "root") return true
const list = getWorktrees(instanceId)
// If worktrees aren't loaded yet, don't force root incorrectly.
if (list.length === 0) return true
return list.some((wt) => wt.slug === normalized)
}
function normalizeWorktreeSlug(instanceId: string, slug: string): string {
const normalized = (slug ?? "").trim() || "root"
if (normalized === "root") return "root"
return isWorktreeSlugAvailable(instanceId, normalized) ? normalized : "root"
}
async function pruneWorktreeMap(instanceId: string): Promise<boolean> {
const current = getWorktreeMap(instanceId)
const available = new Set(getWorktrees(instanceId).map((wt) => wt.slug))
available.add("root")
let changed = false
let nextDefault = current.defaultWorktreeSlug || "root"
if (!available.has(nextDefault)) {
nextDefault = "root"
changed = true
}
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
for (const [sessionId, slug] of Object.entries(nextMapping)) {
if (!available.has(slug)) {
delete nextMapping[sessionId]
changed = true
}
}
if (!changed) return false
const next: WorktreeMap = {
version: 1,
defaultWorktreeSlug: nextDefault,
parentSessionWorktreeSlug: nextMapping,
}
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist pruned worktree map", { instanceId, error })
})
return true
}
function getDefaultWorktreeSlug(instanceId: string): string {
return normalizeWorktreeSlug(instanceId, getWorktreeMap(instanceId).defaultWorktreeSlug || "root")
}
async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise<void> {
await ensureWorktreeMapLoaded(instanceId)
const current = getWorktreeMap(instanceId)
const nextSlug = normalizeWorktreeSlug(instanceId, slug)
const next: WorktreeMap = { ...current, defaultWorktreeSlug: nextSlug }
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist default worktree", { instanceId, slug: nextSlug, error })
})
}
function getParentSessionId(instanceId: string, sessionId: string): string {
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) return sessionId
return session.parentId ?? session.id
}
function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string {
const map = getWorktreeMap(instanceId)
const candidate = map.parentSessionWorktreeSlug[parentSessionId] ?? map.defaultWorktreeSlug ?? "root"
return normalizeWorktreeSlug(instanceId, candidate)
}
function getWorktreeSlugForSession(instanceId: string, sessionId: string): string {
const parentId = getParentSessionId(instanceId, sessionId)
return getWorktreeSlugForParentSession(instanceId, parentId)
}
async function setWorktreeSlugForParentSession(instanceId: string, parentSessionId: string, slug: string): Promise<void> {
await ensureWorktreeMapLoaded(instanceId)
const current = getWorktreeMap(instanceId)
const normalizedSlug = normalizeWorktreeSlug(instanceId, slug)
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
nextMapping[parentSessionId] = normalizedSlug
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist session worktree mapping", { instanceId, parentSessionId, slug: normalizedSlug, error })
})
}
async function removeParentSessionMapping(instanceId: string, parentSessionId: string): Promise<void> {
await ensureWorktreeMapLoaded(instanceId)
const current = getWorktreeMap(instanceId)
if (!current.parentSessionWorktreeSlug[parentSessionId]) return
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
delete nextMapping[parentSessionId]
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
setWorktreeMapByInstance((prev) => {
const map = new Map(prev)
map.set(instanceId, next)
return map
})
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
log.warn("Failed to persist session worktree mapping removal", { instanceId, parentSessionId, error })
})
}
function getWorktreeSlugForDirectory(instanceId: string, directory: string | undefined): string | null {
if (!directory) return null
const list = getWorktrees(instanceId)
const match = list.find((wt) => wt.directory === directory)
return match?.slug ?? null
}
function buildWorktreeProxyPath(instanceId: string, slug: string): string {
const normalizedSlug = normalizeWorktreeSlug(instanceId, slug || "root")
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
}
function encodeBase64UrlUtf8(input: string): string {
const bytes = new TextEncoder().encode(input)
// Convert bytes -> base64 (btoa expects a binary string)
let binary = ""
const chunkSize = 0x8000
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize)
binary += String.fromCharCode(...chunk)
}
const base64 = btoa(binary)
// base64 -> base64url (strip padding)
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
}
function buildWorktreeProxyPathWithDirectoryOverride(instanceId: string, slug: string, directory: string): string {
const base = buildWorktreeProxyPath(instanceId, slug)
const encoded = encodeBase64UrlUtf8(directory)
return `${base}/__dir/${encoded}`
}
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
const proxyPath = buildWorktreeProxyPath(instanceId, normalized)
return sdkManager.createClient(instanceId, proxyPath, normalized)
}
function getOrCreateWorktreeClientWithDirectoryOverride(instanceId: string, slug: string, directory: string): OpencodeClient {
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
const proxyPath = buildWorktreeProxyPathWithDirectoryOverride(instanceId, normalized, directory)
return sdkManager.createClient(instanceId, proxyPath, normalized)
}
function getRootClient(instanceId: string): OpencodeClient {
return getOrCreateWorktreeClient(instanceId, "root")
}
export {
worktreesByInstance,
worktreeMapByInstance,
gitRepoStatusByInstance,
ensureWorktreesLoaded,
reloadWorktrees,
reloadWorktreeMap,
ensureWorktreeMapLoaded,
getGitRepoStatus,
getWorktrees,
getWorktreeMap,
getDefaultWorktreeSlug,
setDefaultWorktreeSlug,
getParentSessionId,
getWorktreeSlugForParentSession,
getWorktreeSlugForSession,
setWorktreeSlugForParentSession,
removeParentSessionMapping,
getWorktreeSlugForDirectory,
buildWorktreeProxyPath,
buildWorktreeProxyPathWithDirectoryOverride,
getOrCreateWorktreeClient,
getOrCreateWorktreeClientWithDirectoryOverride,
getRootClient,
createWorktree,
deleteWorktree,
}