diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0cf3570a8b3d..cbf733951b4a 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -26,6 +26,7 @@ import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" +import { workspaceKey } from "@/pages/layout/helpers" type GlobalStore = { ready: boolean @@ -151,39 +152,42 @@ function createGlobalSync() { const children = createChildStoreManager({ owner, - isBooting: (directory) => booting.has(directory), - isLoadingSessions: (directory) => sessionLoads.has(directory), + isBooting: (directory) => booting.has(workspaceKey(directory)), + isLoadingSessions: (directory) => sessionLoads.has(workspaceKey(directory)), onBootstrap: (directory) => { void bootstrapInstance(directory) }, onDispose: (directory) => { + const key = workspaceKey(directory) queue.clear(directory) - sessionMeta.delete(directory) - sdkCache.delete(directory) - clearProviderRev(directory) - clearSessionPrefetchDirectory(directory) + sessionMeta.delete(key) + sdkCache.delete(key) + clearProviderRev(key) + clearSessionPrefetchDirectory(key) }, translate: language.t, }) const sdkFor = (directory: string) => { - const cached = sdkCache.get(directory) + const key = workspaceKey(directory) + const cached = sdkCache.get(key) if (cached) return cached const sdk = globalSDK.createClient({ directory, throwOnError: true, }) - sdkCache.set(directory, sdk) + sdkCache.set(key, sdk) return sdk } async function loadSessions(directory: string) { - const pending = sessionLoads.get(directory) + const key = workspaceKey(directory) + const pending = sessionLoads.get(key) if (pending) return pending children.pin(directory) const [store, setStore] = children.child(directory, { bootstrap: false }) - const meta = sessionMeta.get(directory) + const meta = sessionMeta.get(key) if (meta && meta.limit >= store.limit) { const next = trimSessions(store.session, { limit: store.limit, @@ -224,7 +228,7 @@ function createGlobalSync() { ) setStore("session", reconcile(sessions, { key: "id" })) cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) - sessionMeta.set(directory, { limit }) + sessionMeta.set(key, { limit }) }) .catch((err) => { console.error("Failed to load sessions", err) @@ -236,9 +240,9 @@ function createGlobalSync() { }) }) - sessionLoads.set(directory, promise) + sessionLoads.set(key, promise) promise.finally(() => { - sessionLoads.delete(directory) + sessionLoads.delete(key) children.unpin(directory) }) return promise @@ -246,13 +250,14 @@ function createGlobalSync() { async function bootstrapInstance(directory: string) { if (!directory) return - const pending = booting.get(directory) + const key = workspaceKey(directory) + const pending = booting.get(key) if (pending) return pending children.pin(directory) const promise = (async () => { const child = children.ensureChild(directory) - const cache = children.vcsCache.get(directory) + const cache = children.vcsCache.get(key) if (!cache) return const sdk = sdkFor(directory) await bootstrapDirectory({ @@ -272,9 +277,9 @@ function createGlobalSync() { }) })() - booting.set(directory, promise) + booting.set(key, promise) promise.finally(() => { - booting.delete(directory) + booting.delete(key) children.unpin(directory) }) return promise @@ -297,14 +302,15 @@ function createGlobalSync() { }) if (event.type === "server.connected" || event.type === "global.disposed") { if (recent) return - for (const directory of Object.keys(children.children)) { + for (const directory of children.canonicalDir.values()) { queue.push(directory) } } return } - const existing = children.children[directory] + const key = workspaceKey(directory) + const existing = children.children[key] if (!existing) return children.mark(directory) const [store, setStore] = existing @@ -315,7 +321,7 @@ function createGlobalSync() { setStore, push: queue.push, setSessionTodo, - vcsCache: children.vcsCache.get(directory), + vcsCache: children.vcsCache.get(key), loadLsp: () => { sdkFor(directory) .lsp.status() diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 7edd5a1ce106..f1673ad35f42 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -18,6 +18,7 @@ import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" +import { workspaceKey } from "@/pages/layout/helpers" type GlobalStore = { ready: boolean @@ -58,7 +59,7 @@ function errors(list: PromiseSettledResult[]) { const providerRev = new Map() export function clearProviderRev(directory: string) { - providerRev.delete(directory) + providerRev.delete(workspaceKey(directory)) } function runAll(list: Array<() => Promise>) { @@ -339,16 +340,17 @@ export async function bootstrapDirectory(input: { if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") - const rev = (providerRev.get(input.directory) ?? 0) + 1 - providerRev.set(input.directory, rev) + const wk = workspaceKey(input.directory) + const rev = (providerRev.get(wk) ?? 0) + 1 + providerRev.set(wk, rev) void retry(() => input.sdk.provider.list()) .then((x) => { - if (providerRev.get(input.directory) !== rev) return + if (providerRev.get(wk) !== rev) return input.setStore("provider", normalizeProviderList(x.data!)) input.setStore("provider_ready", true) }) .catch((err) => { - if (providerRev.get(input.directory) !== rev) return + if (providerRev.get(wk) !== rev) return console.error("Failed to refresh provider list", err) const project = getFilename(input.directory) showToast({ diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 5678491f8973..a147c1658bb7 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -14,6 +14,7 @@ import { type VcsCache, } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" +import { workspaceKey } from "@/pages/layout/helpers" export function createChildStoreManager(input: { owner: Owner @@ -31,31 +32,35 @@ export function createChildStoreManager(input: { const pins = new Map() const ownerPins = new WeakMap>() const disposers = new Map void>() + const canonicalDir = new Map() const mark = (directory: string) => { if (!directory) return - lifecycle.set(directory, { lastAccessAt: Date.now() }) - runEviction(directory) + const k = workspaceKey(directory) + lifecycle.set(k, { lastAccessAt: Date.now() }) + runEviction(k) } const pin = (directory: string) => { if (!directory) return - pins.set(directory, (pins.get(directory) ?? 0) + 1) + const k = workspaceKey(directory) + pins.set(k, (pins.get(k) ?? 0) + 1) mark(directory) } const unpin = (directory: string) => { if (!directory) return - const next = (pins.get(directory) ?? 0) - 1 + const k = workspaceKey(directory) + const next = (pins.get(k) ?? 0) - 1 if (next > 0) { - pins.set(directory, next) + pins.set(k, next) return } - pins.delete(directory) + pins.delete(k) runEviction() } - const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0 + const pinned = (directory: string) => (pins.get(workspaceKey(directory)) ?? 0) > 0 const pinForOwner = (directory: string) => { const current = getOwner() @@ -78,29 +83,32 @@ export function createChildStoreManager(input: { } function disposeDirectory(directory: string) { + const key = workspaceKey(directory) + const orig = canonicalDir.get(key) ?? directory if ( !canDisposeDirectory({ - directory, - hasStore: !!children[directory], + directory: orig, + hasStore: !!children[key], pinned: pinned(directory), - booting: input.isBooting(directory), - loadingSessions: input.isLoadingSessions(directory), + booting: input.isBooting(orig), + loadingSessions: input.isLoadingSessions(orig), }) ) { return false } - vcsCache.delete(directory) - metaCache.delete(directory) - iconCache.delete(directory) - lifecycle.delete(directory) - const dispose = disposers.get(directory) + vcsCache.delete(key) + metaCache.delete(key) + iconCache.delete(key) + lifecycle.delete(key) + canonicalDir.delete(key) + const dispose = disposers.get(key) if (dispose) { dispose() - disposers.delete(directory) + disposers.delete(key) } - delete children[directory] - input.onDispose(directory) + delete children[key] + input.onDispose(orig) return true } @@ -123,7 +131,11 @@ export function createChildStoreManager(input: { function ensureChild(directory: string) { if (!directory) console.error("No directory provided") - if (!children[directory]) { + const key = workspaceKey(directory) + // Prefer backslash format on Windows for API calls that need server-side exact match. + // If backslash path is available, always use it as the canonical form. + if (!canonicalDir.has(key) || directory.includes("\\")) canonicalDir.set(key, directory) + if (!children[key]) { const vcs = runWithOwner(input.owner, () => persisted( Persist.workspace(directory, "vcs", ["vcs.v1"]), @@ -132,7 +144,7 @@ export function createChildStoreManager(input: { ) if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) const vcsStore = vcs[0] - vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) + vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) const meta = runWithOwner(input.owner, () => persisted( @@ -141,7 +153,7 @@ export function createChildStoreManager(input: { ), ) if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) - metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) + metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] }) const icon = runWithOwner(input.owner, () => persisted( @@ -150,7 +162,7 @@ export function createChildStoreManager(input: { ), ) if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) - iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) + iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] }) const init = () => createRoot((dispose) => { @@ -183,13 +195,13 @@ export function createChildStoreManager(input: { message: {}, part: {}, }) - children[directory] = child - disposers.set(directory, dispose) + children[key] = child + disposers.set(key, dispose) const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return void init.then(() => { - if (children[directory] !== child) return + if (children[key] !== child) return run() }) } @@ -214,7 +226,7 @@ export function createChildStoreManager(input: { runWithOwner(input.owner, init) } mark(directory) - const childStore = children[directory] + const childStore = children[key] if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) return childStore } @@ -240,7 +252,7 @@ export function createChildStoreManager(input: { function projectMeta(directory: string, patch: ProjectMeta) { const [store, setStore] = ensureChild(directory) - const cached = metaCache.get(directory) + const cached = metaCache.get(workspaceKey(directory)) if (!cached) return const previous = store.projectMeta ?? {} const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon @@ -257,7 +269,7 @@ export function createChildStoreManager(input: { function projectIcon(directory: string, value: string | undefined) { const [store, setStore] = ensureChild(directory) - const cached = iconCache.get(directory) + const cached = iconCache.get(workspaceKey(directory)) if (!cached) return if (store.icon === value) return cached.setStore("value", value) @@ -266,6 +278,7 @@ export function createChildStoreManager(input: { return { children, + canonicalDir, ensureChild, child, peek, diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index c3468583b930..5f749ee96bc7 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -4,6 +4,10 @@ type QueueInput = { bootstrapInstance: (directory: string) => Promise | void } +function normalizeKey(directory: string) { + return directory.replaceAll("\\", "/").replace(/\/+$/, "") +} + export function createRefreshQueue(input: QueueInput) { const queued = new Set() let root = false @@ -33,7 +37,7 @@ export function createRefreshQueue(input: QueueInput) { const push = (directory: string) => { if (!directory) return - queued.add(directory) + queued.add(normalizeKey(directory)) if (input.paused()) return schedule() } @@ -72,7 +76,7 @@ export function createRefreshQueue(input: QueueInput) { push, refresh, clear(directory: string) { - queued.delete(directory) + queued.delete(normalizeKey(directory)) }, dispose() { if (!timer) return diff --git a/packages/app/src/context/global-sync/session-prefetch.ts b/packages/app/src/context/global-sync/session-prefetch.ts index 608561f855c7..ec3a810e07ff 100644 --- a/packages/app/src/context/global-sync/session-prefetch.ts +++ b/packages/app/src/context/global-sync/session-prefetch.ts @@ -1,4 +1,6 @@ -const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}` +import { workspaceKey } from "@/pages/layout/helpers" + +const key = (directory: string, sessionID: string) => `${workspaceKey(directory)}\n${sessionID}` export const SESSION_PREFETCH_TTL = 15_000 @@ -89,7 +91,7 @@ export function clearSessionPrefetch(directory: string, sessionIDs: Iterable>, key: string, task: () => P return promise } -const keyFor = (directory: string, id: string) => `${directory}\n${id}` +const keyFor = (directory: string, id: string) => `${workspaceKey(directory)}\n${id}` const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) @@ -231,15 +232,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ] const seenFor = (directory: string) => { - const existing = seen.get(directory) + const wk = workspaceKey(directory) + const existing = seen.get(wk) if (existing) { - seen.delete(directory) - seen.set(directory, existing) + seen.delete(wk) + seen.set(wk, existing) return existing } const created = new Set() - seen.set(directory, created) + seen.set(wk, created) while (seen.size > maxDirs) { + // `first` is a workspaceKey, not a raw directory. + // child()/evict() internally call workspaceKey() which is idempotent. const first = seen.keys().next().value if (!first) break const stale = [...(seen.get(first) ?? [])] @@ -312,7 +316,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } } - const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false + const tracked = (directory: string, sessionID: string) => seen.get(workspaceKey(directory))?.has(sessionID) ?? false const loadMessages = async (input: { directory: string diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 48158debba1d..8548c9f868ed 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -31,13 +31,14 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -const roots = (store: SessionStore) => - (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) +const roots = (store: SessionStore, dir?: string) => + (store.session ?? []).filter((session) => isRootVisibleSession(session, dir ?? store.path.directory)) -export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) +export const sortedRootSessions = (store: SessionStore, now: number, dir?: string) => + roots(store, dir).sort(sortSessions(now)) export const latestRootSession = (stores: SessionStore[], now: number) => - stores.flatMap(roots).sort(sortSessions(now))[0] + stores.flatMap((s) => roots(s)).sort(sortSessions(now))[0] export function hasProjectPermissions( request: Record | undefined, diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 7c9ae1aafba6..f847788e7981 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -301,10 +301,10 @@ export const SortableProject = (props: { } const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) - const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow(), props.project.worktree)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - return sortedRootSessions(data, props.sortNow()) + return sortedRootSessions(data, props.sortNow(), directory) } const tile = () => ( base64Encode(props.directory)) - const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow(), props.directory)) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const workspaceValue = createMemo(() => { @@ -451,7 +451,7 @@ export const LocalWorkspace = (props: { return { store, setStore } }) const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) + const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow(), props.project.worktree)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) const loading = createMemo(() => !booted() && count() === 0) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f587f50b3996..118a53e99f01 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -2,6 +2,7 @@ import z from "zod" import { and, Database, eq } from "../storage/db" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" +import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { Flag } from "@/flag/flag" import { BusEvent } from "@/bus/bus-event" @@ -61,7 +62,7 @@ export namespace Project { : undefined return { id: row.id, - worktree: row.worktree, + worktree: Filesystem.posixPath(row.worktree), vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, icon, @@ -70,7 +71,7 @@ export namespace Project { updated: row.time_updated, initialized: row.time_initialized ?? undefined, }, - sandboxes: row.sandboxes, + sandboxes: row.sandboxes.map(Filesystem.posixPath), commands: row.commands ?? undefined, } } diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts index 4bd7802e2a96..9a99b750aeaf 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance.ts @@ -4,7 +4,7 @@ import { proxy } from "hono/proxy" import type { UpgradeWebSocket } from "hono/ws" import z from "zod" import { createHash } from "node:crypto" -import * as fs from "node:fs/promises" +import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { Format } from "../format" import { TuiRoutes } from "./routes/tui" @@ -29,7 +29,6 @@ import { ExperimentalRoutes } from "./routes/experimental" import { ProviderRoutes } from "./routes/provider" import { EventRoutes } from "./routes/event" import { errorHandler } from "./middleware" -import { getMimeType } from "hono/utils/mime" const log = Log.create({ service: "server" }) @@ -112,11 +111,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono() }), async (c) => { return c.json({ - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: Instance.worktree, - directory: Instance.directory, + home: Filesystem.posixPath(Global.Path.home), + state: Filesystem.posixPath(Global.Path.state), + config: Filesystem.posixPath(Global.Path.config), + worktree: Filesystem.posixPath(Instance.worktree), + directory: Filesystem.posixPath(Instance.directory), }) }, ) @@ -287,14 +286,13 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono() if (embeddedWebUI) { const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null if (!match) return c.json({ error: "Not Found" }, 404) - - if (await fs.exists(match)) { - const mime = getMimeType(match) ?? "text/plain" - c.header("Content-Type", mime) - if (mime.startsWith("text/html")) { + const file = Bun.file(match) + if (await file.exists()) { + c.header("Content-Type", file.type) + if (file.type.startsWith("text/html")) { c.header("Content-Security-Policy", DEFAULT_CSP) } - return c.body(new Uint8Array(await fs.readFile(match))) + return c.body(await file.arrayBuffer()) } else { return c.json({ error: "Not Found" }, 404) } diff --git a/packages/opencode/src/server/routes/file.ts b/packages/opencode/src/server/routes/file.ts index 60789ef4b722..ee55d714b757 100644 --- a/packages/opencode/src/server/routes/file.ts +++ b/packages/opencode/src/server/routes/file.ts @@ -6,6 +6,7 @@ import { Ripgrep } from "../../file/ripgrep" import { LSP } from "../../lsp" import { Instance } from "../../project/instance" import { lazy } from "../../util/lazy" +import { Filesystem } from "../../util/filesystem" export const FileRoutes = lazy(() => new Hono() @@ -138,7 +139,7 @@ export const FileRoutes = lazy(() => }), ), async (c) => { - const path = c.req.valid("query").path + const path = Filesystem.posixPath(c.req.valid("query").path) const content = await File.list(path) return c.json(content) }, @@ -167,7 +168,7 @@ export const FileRoutes = lazy(() => }), ), async (c) => { - const path = c.req.valid("query").path + const path = Filesystem.posixPath(c.req.valid("query").path) const content = await File.read(path) return c.json(content) }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index bbd6693c53ac..8541a78152cd 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -14,6 +14,7 @@ import type { SQL } from "../storage/db" import { PartTable, SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" +import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" @@ -38,6 +39,8 @@ export namespace Session { const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " + const normalizeDirectory = Filesystem.posixPath + function createDefaultTitle(isChild = false) { return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() } @@ -67,7 +70,7 @@ export namespace Session { slug: row.slug, projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, - directory: row.directory, + directory: normalizeDirectory(row.directory), parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -380,7 +383,7 @@ export namespace Session { slug: Slug.create(), version: Installation.VERSION, projectID: ctx.project.id, - directory: input.directory, + directory: normalizeDirectory(input.directory), workspaceID: input.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), @@ -706,7 +709,7 @@ export namespace Session { conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) } if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, normalizeDirectory(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -746,7 +749,7 @@ export namespace Session { const conditions: SQL[] = [] if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, normalizeDirectory(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 5f50231b0328..4065bd337a1e 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" import { realpathSync } from "fs" -import { dirname, join, relative, resolve as pathResolve, win32 } from "path" +import { dirname, isAbsolute, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "./glob" @@ -156,14 +156,25 @@ export namespace Filesystem { .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) ) } + export function posixPath(p: string): string { + const value = p.replaceAll("\\", "/") + const drive = value.match(/^([A-Za-z]:)\/+$/) + if (drive) return `${drive[1]}/` + if (/^\/+$/i.test(value)) return "/" + return value.replace(/\/+$/, "") + } + export function overlaps(a: string, b: string) { const relA = relative(a, b) const relB = relative(b, a) + if (isAbsolute(relA) || isAbsolute(relB)) return false return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + const rel = relative(parent, child) + if (isAbsolute(rel)) return false + return !rel.startsWith("..") } export async function findUp( diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 54986d65cd57..e70cfa4a825d 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,5 +1,6 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" +import { Filesystem } from "../util/filesystem" import { Global } from "../global" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" @@ -214,7 +215,7 @@ export namespace Worktree { const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) if (branchCheck.code === 0) continue - return Info.parse({ name, branch, directory }) + return Info.parse({ name, branch, directory: Filesystem.posixPath(directory) }) } throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 988ae27426c6..7596b1de0a28 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -4,6 +4,7 @@ import { Log } from "../../src/util/log" import { $ } from "bun" import path from "path" import { tmpdir } from "../fixture/fixture" +import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" import { ProjectID } from "../../src/project/schema" import { Effect, Layer, Stream } from "effect" @@ -226,9 +227,9 @@ describe("Project.fromDirectory with worktrees", () => { const { project } = await Project.fromDirectory(worktree2) expect(project.worktree).toBe(tmp.path) - expect(project.sandboxes).toContain(worktree1) - expect(project.sandboxes).toContain(worktree2) - expect(project.sandboxes).not.toContain(tmp.path) + expect(project.sandboxes.map(Filesystem.posixPath)).toContain(Filesystem.posixPath(worktree1)) + expect(project.sandboxes.map(Filesystem.posixPath)).toContain(Filesystem.posixPath(worktree2)) + expect(project.sandboxes.map(Filesystem.posixPath)).not.toContain(Filesystem.posixPath(tmp.path)) } finally { await $`git worktree remove ${worktree1}` .cwd(tmp.path) @@ -434,12 +435,12 @@ describe("Project.addSandbox and Project.removeSandbox", () => { await Project.addSandbox(project.id, sandboxDir) let found = Project.get(project.id) - expect(found?.sandboxes).toContain(sandboxDir) + expect(found?.sandboxes.map(Filesystem.posixPath)).toContain(Filesystem.posixPath(sandboxDir)) await Project.removeSandbox(project.id, sandboxDir) found = Project.get(project.id) - expect(found?.sandboxes).not.toContain(sandboxDir) + expect(found?.sandboxes.map(Filesystem.posixPath)).not.toContain(Filesystem.posixPath(sandboxDir)) }) test("addSandbox emits GlobalBus event", async () => { diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 05d6de04b1b1..0da88d336c1d 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" import { Session } from "../../src/session" +import { Filesystem } from "../../src/util/filesystem" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" @@ -34,9 +35,9 @@ describe("Session.listGlobal", () => { const secondItem = sessions.find((session) => session.id === secondSession.id) expect(firstItem?.project?.id).toBe(firstProject?.id) - expect(firstItem?.project?.worktree).toBe(firstProject?.worktree) + expect(Filesystem.posixPath(firstItem!.project!.worktree)).toBe(Filesystem.posixPath(firstProject!.worktree)) expect(secondItem?.project?.id).toBe(secondProject?.id) - expect(secondItem?.project?.worktree).toBe(secondProject?.worktree) + expect(Filesystem.posixPath(secondItem!.project!.worktree)).toBe(Filesystem.posixPath(secondProject!.worktree)) }) test("excludes archived sessions by default", async () => { diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 3abcf011bc06..22b332cf32de 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -645,6 +645,85 @@ describe("filesystem", () => { }) }) + describe("posixPath()", () => { + test("replaces backslashes with forward slashes", () => { + expect(Filesystem.posixPath("C:\\Users\\foo\\bar")).toBe("C:/Users/foo/bar") + }) + + test("normalizes drive root with trailing slashes", () => { + expect(Filesystem.posixPath("C:/")).toBe("C:/") + expect(Filesystem.posixPath("C:\\")).toBe("C:/") + expect(Filesystem.posixPath("C:\\\\")).toBe("C:/") + expect(Filesystem.posixPath("d:/")).toBe("d:/") + }) + + test("normalizes root path", () => { + expect(Filesystem.posixPath("/")).toBe("/") + expect(Filesystem.posixPath("///")).toBe("/") + }) + + test("strips trailing slashes", () => { + expect(Filesystem.posixPath("C:/Users/foo/")).toBe("C:/Users/foo") + expect(Filesystem.posixPath("/a/b/c/")).toBe("/a/b/c") + expect(Filesystem.posixPath("/a/b/c///")).toBe("/a/b/c") + }) + + test("preserves already-normalized posix paths", () => { + expect(Filesystem.posixPath("C:/Users/foo")).toBe("C:/Users/foo") + expect(Filesystem.posixPath("/home/user/project")).toBe("/home/user/project") + }) + + test("handles mixed separators", () => { + expect(Filesystem.posixPath("C:\\Users/foo\\bar")).toBe("C:/Users/foo/bar") + }) + }) + + describe("contains()", () => { + test("returns true for child within parent", () => { + expect(Filesystem.contains("/a/b", "/a/b/c")).toBe(true) + expect(Filesystem.contains("/a/b", "/a/b/c/d")).toBe(true) + }) + + test("returns false for sibling paths", () => { + expect(Filesystem.contains("/a/b", "/a/c")).toBe(false) + }) + + test("returns false for parent outside child", () => { + expect(Filesystem.contains("/a/b/c", "/a/b")).toBe(false) + }) + + test("returns true for same path", () => { + expect(Filesystem.contains("/a/b", "/a/b")).toBe(true) + }) + + test("returns false for cross-root paths on Windows", () => { + if (process.platform !== "win32") return + expect(Filesystem.contains("C:\\Users\\foo", "/etc/passwd")).toBe(false) + expect(Filesystem.contains("C:\\Users\\foo", "D:\\other")).toBe(false) + }) + }) + + describe("overlaps()", () => { + test("returns true when paths overlap", () => { + expect(Filesystem.overlaps("/a/b", "/a/b/c")).toBe(true) + expect(Filesystem.overlaps("/a/b/c", "/a/b")).toBe(true) + }) + + test("returns true for same path", () => { + expect(Filesystem.overlaps("/a/b", "/a/b")).toBe(true) + }) + + test("returns false for unrelated paths", () => { + expect(Filesystem.overlaps("/a", "/b")).toBe(false) + }) + + test("returns false for cross-root paths on Windows", () => { + if (process.platform !== "win32") return + expect(Filesystem.overlaps("C:\\Users\\foo", "D:\\other")).toBe(false) + expect(Filesystem.overlaps("C:\\Users\\foo", "/etc/passwd")).toBe(false) + }) + }) + describe("normalizePathPattern()", () => { test("preserves drive root globs on Windows", async () => { if (process.platform !== "win32") return