From 3326d1031a8077d0130e5b13d4b08d18865dbfee Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:46:00 +1000 Subject: [PATCH 01/42] path: add typed groundwork for pretty paths --- packages/opencode/src/path/path.ts | 150 +++++++++++++++++++++++ packages/opencode/src/path/schema.ts | 53 ++++++++ packages/opencode/test/path/path.test.ts | 106 ++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 packages/opencode/src/path/path.ts create mode 100644 packages/opencode/src/path/schema.ts create mode 100644 packages/opencode/test/path/path.test.ts diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts new file mode 100644 index 000000000000..8b90e2ce526f --- /dev/null +++ b/packages/opencode/src/path/path.ts @@ -0,0 +1,150 @@ +import { readdir } from "fs/promises" +import path from "path" + +import { FileURI, PathKey, PosixPath, PrettyPath, RelativePath } from "./schema" + +type Opts = { + cwd?: string + platform?: NodeJS.Platform +} + +type Lib = typeof path.posix + +function pf(opts?: { platform?: NodeJS.Platform }) { + return opts?.platform ?? process.platform +} + +function lib(platform: NodeJS.Platform): Lib { + return platform === "win32" ? path.win32 : path.posix +} + +function fixDrive(input: string) { + return input.replace(/^[a-z]:/, (match) => match.toUpperCase()) +} + +function clean(input: string, platform: NodeJS.Platform) { + const text = lib(platform).normalize(input) + if (platform !== "win32") return text + return fixDrive(text).replaceAll("/", "\\") +} + +function raw(input: string, platform: NodeJS.Platform) { + if (input.startsWith("file://")) return fromURIText(input, platform) + if (platform !== "win32") return input + return input + .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) + .replace(/^\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) + .replace(/^\/cygdrive\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) + .replace(/^\/mnt\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) +} + +function base(input: string, platform: NodeJS.Platform) { + const mod = lib(platform) + const text = raw(input, platform) + return clean(mod.isAbsolute(text) ? text : mod.resolve(text), platform) +} + +function encode(input: string) { + return encodeURIComponent(input) +} + +function fromURIText(input: string, platform: NodeJS.Platform) { + const url = new URL(input) + if (url.protocol !== "file:") throw new TypeError(`Expected file URI: ${input}`) + const text = decodeURIComponent(url.pathname) + if (platform !== "win32") { + return `${url.host ? `//${url.host}` : ""}${text}` + } + if (url.host) { + return `\\\\${url.host}${text.replaceAll("/", "\\")}` + } + return fixDrive(text.replace(/^\/([a-zA-Z]:)/, "$1").replaceAll("/", "\\")) +} + +function toURIText(input: string, platform: NodeJS.Platform) { + if (platform !== "win32") { + return `file://${input.split("/").map((part, idx) => (idx === 0 ? part : encode(part))).join("/")}` + } + if (input.startsWith("\\\\")) { + const parts = input.slice(2).split("\\") + const host = parts.shift() ?? "" + const body = parts.map(encode).join("/") + return `file://${host}/${body}` + } + const text = input.replaceAll("\\", "/") + const body = text + .slice(2) + .split("/") + .map((part, idx) => (idx === 0 ? part : encode(part))) + .join("/") + return `file:///${fixDrive(text.slice(0, 2))}${body}` +} + +export namespace Path { + export type Options = Opts + + export function pretty(input: string, opts: Opts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const text = raw(input, platform) + const cwd = opts.cwd ? base(opts.cwd, platform) : undefined + return PrettyPath.make(clean(cwd ? mod.resolve(cwd, text) : mod.resolve(text), platform)) + } + + export function key(input: string, opts: Opts = {}) { + const platform = pf(opts) + const text = pretty(input, opts) + if (platform !== "win32") return PathKey.make(text) + return PathKey.make(text.toLowerCase()) + } + + export function posix(input: string, opts: Opts = {}) { + return PosixPath.make(pretty(input, opts).replaceAll("\\", "/")) + } + + export function rel(from: string, to: string, opts: Opts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const text = mod.relative(pretty(from, opts), pretty(to, opts)) || "." + return RelativePath.make(text) + } + + export function uri(input: string, opts: Opts = {}) { + return FileURI.make(toURIText(pretty(input, opts), pf(opts))) + } + + export function fromURI(input: string, opts: Omit = {}) { + return PrettyPath.make(clean(fromURIText(input, pf(opts)), pf(opts))) + } + + export function eq(a: string, b: string, opts: Opts = {}) { + return key(a, opts) === key(b, opts) + } + + export function match(input: string, value: PathKey, opts: Opts = {}) { + return key(input, opts) === value + } + + export async function truecase(input: string, opts: Omit = {}) { + const platform = pf(opts) + const text = pretty(input, opts) + if (platform !== "win32") return text + + const mod = path.win32 + const root = mod.parse(text).root + const rest = text.slice(root.length).split("\\").filter(Boolean) + let out = root + + for (const [idx, seg] of rest.entries()) { + const list = await readdir(out).catch(() => undefined) + if (!list) return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) + + const hit = list.find((item) => item.toLowerCase() === seg.toLowerCase()) + if (!hit) return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) + + out = mod.join(out, hit) + } + + return PrettyPath.make(clean(out, platform)) + } +} diff --git a/packages/opencode/src/path/schema.ts b/packages/opencode/src/path/schema.ts new file mode 100644 index 000000000000..603808a0548a --- /dev/null +++ b/packages/opencode/src/path/schema.ts @@ -0,0 +1,53 @@ +import { Schema } from "effect" + +import { withStatics } from "@/util/schema" + +const prettyPathSchema = Schema.String.pipe(Schema.brand("PrettyPath")) + +export type PrettyPath = typeof prettyPathSchema.Type + +export const PrettyPath = prettyPathSchema.pipe( + withStatics((schema: typeof prettyPathSchema) => ({ + make: (input: string) => schema.makeUnsafe(input), + })), +) + +const pathKeySchema = Schema.String.pipe(Schema.brand("PathKey")) + +export type PathKey = typeof pathKeySchema.Type + +export const PathKey = pathKeySchema.pipe( + withStatics((schema: typeof pathKeySchema) => ({ + make: (input: string) => schema.makeUnsafe(input), + })), +) + +const posixPathSchema = Schema.String.pipe(Schema.brand("PosixPath")) + +export type PosixPath = typeof posixPathSchema.Type + +export const PosixPath = posixPathSchema.pipe( + withStatics((schema: typeof posixPathSchema) => ({ + make: (input: string) => schema.makeUnsafe(input), + })), +) + +const relativePathSchema = Schema.String.pipe(Schema.brand("RelativePath")) + +export type RelativePath = typeof relativePathSchema.Type + +export const RelativePath = relativePathSchema.pipe( + withStatics((schema: typeof relativePathSchema) => ({ + make: (input: string) => schema.makeUnsafe(input), + })), +) + +const fileUriSchema = Schema.String.pipe(Schema.brand("FileURI")) + +export type FileURI = typeof fileUriSchema.Type + +export const FileURI = fileUriSchema.pipe( + withStatics((schema: typeof fileUriSchema) => ({ + make: (input: string) => schema.makeUnsafe(input), + })), +) diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts new file mode 100644 index 000000000000..d359168689c9 --- /dev/null +++ b/packages/opencode/test/path/path.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" + +import { Path } from "../../src/path/path" +import { tmpdir } from "../fixture/fixture" + +describe("path", () => { + describe("pretty()", () => { + const win = { cwd: "C:\\work", platform: "win32" as const } + + for (const [name, input] of [ + ["slash drive", "/c/tmp/file.txt"], + ["slash drive with colon", "/C:/tmp/file.txt"], + ["cygdrive", "/cygdrive/c/tmp/file.txt"], + ["wsl", "/mnt/c/tmp/file.txt"], + ["file uri", "file:///C:/tmp/file.txt"], + ]) { + test(`normalizes ${name} on Windows`, () => { + expect(String(Path.pretty(input, win))).toBe("C:\\tmp\\file.txt") + }) + } + + test("normalizes relative input to native absolute form", () => { + expect(String(Path.pretty("src/../file.ts", { cwd: "/repo", platform: "linux" }))).toBe("/repo/file.ts") + }) + }) + + describe("key()", () => { + test("matches slash and case variants on Windows", () => { + const a = Path.key("C:\\Repo\\File.ts", { platform: "win32" }) + const b = Path.key("c:/repo/file.ts", { platform: "win32" }) + expect(a).toBe(b) + expect(Path.eq("C:\\Repo\\File.ts", "c:/repo/file.ts", { platform: "win32" })).toBe(true) + expect(Path.match("C:\\Repo\\File.ts", b, { platform: "win32" })).toBe(true) + }) + }) + + describe("uri()", () => { + test("round-trips POSIX file URIs", () => { + const file = "/tmp/dir/a b.txt" + const uri = Path.uri(file, { platform: "linux" }) + expect(String(uri)).toBe("file:///tmp/dir/a%20b.txt") + expect(String(Path.fromURI(uri, { platform: "linux" }))).toBe(file) + }) + + test("round-trips Windows file URIs", () => { + const file = "C:\\tmp\\dir\\a b.txt" + const uri = Path.uri(file, { platform: "win32" }) + expect(String(uri)).toBe("file:///C:/tmp/dir/a%20b.txt") + expect(String(Path.fromURI(uri, { platform: "win32" }))).toBe(file) + }) + }) + + describe("posix()", () => { + test("converts Windows pretty paths to forward slashes", () => { + expect(String(Path.posix("C:\\tmp\\dir\\file.txt", { platform: "win32" }))).toBe("C:/tmp/dir/file.txt") + }) + + test("keeps POSIX absolute paths stable", () => { + expect(String(Path.posix("/tmp/dir/file.txt", { platform: "linux" }))).toBe("/tmp/dir/file.txt") + }) + }) + + describe("rel()", () => { + test("returns branded relative paths", () => { + expect(String(Path.rel("/repo", "/repo/src/file.ts", { platform: "linux" }))).toBe("src/file.ts") + }) + }) + + describe("truecase()", () => { + test("keeps missing tails as typed on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const dir = path.join(tmp.path, "CaseDir") + const child = path.join(dir, "Leaf") + await fs.mkdir(child, { recursive: true }) + + const input = path.join(tmp.path.toLowerCase(), "casedir", "leaf", "Miss", "Tail.ts") + const result = String(await Path.truecase(input)) + + expect(result).toBe(path.join(tmp.path, "CaseDir", "Leaf", "Miss", "Tail.ts")) + }) + + test("preserves alias roots while true-casing Windows paths", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const real = path.join(tmp.path, "Target") + await fs.mkdir(path.join(real, "Leaf"), { recursive: true }) + const alias = path.join(tmp.path, "Alias") + await fs.symlink(real, alias, "junction") + + const input = path.join(tmp.path.toLowerCase(), "alias", "leaf") + const result = String(await Path.truecase(input)) + + expect(result).toBe(path.join(tmp.path, "Alias", "Leaf")) + }) + + test("is a no-op off Windows", async () => { + const file = "/tmp/test.txt" + expect(String(await Path.truecase(file, { platform: "linux" }))).toBe(file) + }) + }) +}) From 8abf73a7e14f64e1f439d44105f8ac277d857420 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:53:44 +1000 Subject: [PATCH 02/42] path: preserve pretty paths at instance ingress --- .../control-plane/workspace-server/server.ts | 17 ++-- packages/opencode/src/project/instance.ts | 50 +++++++--- packages/opencode/src/server/server.ts | 4 +- .../workspace-server-sse.test.ts | 46 +++++++++- .../opencode/test/project/instance.test.ts | 92 +++++++++++++++++++ .../opencode/test/server/path-alias.test.ts | 49 ++++++++++ 6 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/test/project/instance.test.ts create mode 100644 packages/opencode/test/server/path-alias.test.ts diff --git a/packages/opencode/src/control-plane/workspace-server/server.ts b/packages/opencode/src/control-plane/workspace-server/server.ts index b0744fe025bb..1d8dbec121c9 100644 --- a/packages/opencode/src/control-plane/workspace-server/server.ts +++ b/packages/opencode/src/control-plane/workspace-server/server.ts @@ -5,6 +5,7 @@ import { SessionRoutes } from "../../server/routes/session" import { WorkspaceServerRoutes } from "./routes" import { WorkspaceContext } from "../workspace-context" import { WorkspaceID } from "../schema" +import { Path } from "../../path/path" export namespace WorkspaceServer { export function App() { @@ -30,13 +31,15 @@ export namespace WorkspaceServer { throw new Error("directory parameter is required") } - const directory = (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })() + const directory = Path.pretty( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(rawWorkspaceID), diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 6075540161b6..fa3100640f6a 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,5 +1,7 @@ import { GlobalBus } from "@/bus/global" import { disposeInstance } from "@/effect/instance-registry" +import { Path } from "@/path/path" +import type { PathKey } from "@/path/schema" import { Filesystem } from "@/util/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util/log" @@ -13,7 +15,7 @@ interface Context { project: Project.Info } const context = Context.create("instance") -const cache = new Map>() +const cache = new Map>() const disposal = { all: undefined as Promise | undefined, @@ -52,26 +54,28 @@ function boot(input: { directory: string; init?: () => Promise; project?: P }) } -function track(directory: string, next: Promise) { +function track(key: PathKey, next: Promise) { const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) + if (cache.get(key) === task) cache.delete(key) throw error }) - cache.set(directory, task) + cache.set(key, task) return task } export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = Filesystem.resolve(input.directory) - let existing = cache.get(directory) + const key = Path.key(input.directory) + let existing = cache.get(key) if (!existing) { - Log.Default.info("creating instance", { directory }) existing = track( - directory, - boot({ - directory, - init: input.init, + key, + Path.truecase(input.directory).then((directory) => { + Log.Default.info("creating instance", { directory }) + return boot({ + directory, + init: input.init, + }) }), ) } @@ -117,19 +121,35 @@ export const Instance = { return State.create(() => Instance.directory, init, dispose) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = Filesystem.resolve(input.directory) + const key = Path.key(input.directory) + const existing = cache.get(key) + const ctx = await existing?.catch(() => undefined) + const directory = ctx?.directory ?? (await Path.truecase(input.directory)) Log.Default.info("reloading instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) - const next = track(directory, boot({ ...input, directory })) + cache.delete(key) + const next = track( + key, + Promise.all([ + Promise.resolve(directory), + input.worktree ? Path.truecase(input.worktree) : undefined, + ]).then(([directory, worktree]) => + boot({ + ...input, + directory, + worktree, + }), + ), + ) emit(directory) return await next }, async dispose() { const directory = Instance.directory + const key = Path.key(directory) Log.Default.info("disposing instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) + cache.delete(key) emit(directory) }, async disposeAll() { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 677af4da87f0..ef58cb36a850 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -40,7 +40,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status" import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" -import { Filesystem } from "@/util/filesystem" +import { Path } from "@/path/path" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -195,7 +195,7 @@ export namespace Server { if (c.req.path === "/log") return next() const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Filesystem.resolve( + const directory = Path.pretty( (() => { try { return decodeURIComponent(raw) diff --git a/packages/opencode/test/control-plane/workspace-server-sse.test.ts b/packages/opencode/test/control-plane/workspace-server-sse.test.ts index 7e7cddb1404e..a5583b2380b8 100644 --- a/packages/opencode/test/control-plane/workspace-server-sse.test.ts +++ b/packages/opencode/test/control-plane/workspace-server-sse.test.ts @@ -1,17 +1,33 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" import { Log } from "../../src/util/log" -import { WorkspaceServer } from "../../src/control-plane/workspace-server/server" import { parseSSE } from "../../src/control-plane/sse" import { GlobalBus } from "../../src/bus/global" +import { Instance } from "../../src/project/instance" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" +mock.module("../../src/project/bootstrap", () => ({ + InstanceBootstrap: async () => {}, +})) + +const { WorkspaceServer } = await import("../../src/control-plane/workspace-server/server") + afterEach(async () => { + await Instance.disposeAll() await resetDatabase() }) Log.init({ print: false }) +async function link(dir: string) { + const alias = path.join(path.dirname(dir), path.basename(dir) + "-link") + await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) + await fs.symlink(dir, alias, process.platform === "win32" ? "junction" : "dir") + return alias +} + describe("control-plane/workspace-server SSE", () => { test("streams GlobalBus events and parseSSE reads them", async () => { await using tmp = await tmpdir({ git: true }) @@ -67,4 +83,30 @@ describe("control-plane/workspace-server SSE", () => { stop.abort() } }) + + test("keeps alias directories before Instance.provide", async () => { + await using tmp = await tmpdir({ git: true }) + const alias = await link(tmp.path) + const app = WorkspaceServer.App() + const stop = new AbortController() + const provide = Instance.provide + const spy = spyOn(Instance, "provide").mockImplementation((input) => provide(input)) + + try { + const response = await app.request("/event", { + signal: stop.signal, + headers: { + "x-opencode-workspace": "wrk_test_workspace", + "x-opencode-directory": `${alias}${path.sep}work${path.sep}..`, + }, + }) + + expect(response.status).toBe(200) + expect(spy.mock.calls[0]?.[0]?.directory).toBe(alias) + } finally { + stop.abort() + spy.mockRestore() + await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) + } + }) }) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts new file mode 100644 index 000000000000..61d2afb19f62 --- /dev/null +++ b/packages/opencode/test/project/instance.test.ts @@ -0,0 +1,92 @@ +import { afterEach, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" + +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() + await resetDatabase() +}) + +async function link(dir: string) { + const alias = path.join(path.dirname(dir), path.basename(dir) + "-link") + await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) + await fs.symlink(dir, alias, process.platform === "win32" ? "junction" : "dir") + return alias +} + +test("Instance keeps alias directories and reload disposes stored state", async () => { + await using tmp = await tmpdir({ git: true }) + const alias = await link(tmp.path) + const seen: string[] = [] + let n = 0 + const state = Instance.state( + () => ({ n: ++n, dir: Instance.directory }), + async (value) => { + seen.push(value.dir) + }, + ) + + try { + const a = await Instance.provide({ + directory: `${alias}${path.sep}work${path.sep}..`, + fn: async () => state(), + }) + + expect(a.dir).toBe(alias) + + await Instance.reload({ + directory: `${alias}${path.sep}.${path.sep}`, + }) + + const b = await Instance.provide({ + directory: alias, + fn: async () => state(), + }) + + expect(b).not.toBe(a) + expect(b.dir).toBe(alias) + expect(seen).toEqual([alias]) + } finally { + await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) + } +}) + +test("Instance dedupes concurrent equivalent directories by key", async () => { + await using tmp = await tmpdir({ git: true }) + const alias = await link(tmp.path) + let n = 0 + + try { + const [a, b] = await Promise.all([ + Instance.provide({ + directory: `${alias}${path.sep}work${path.sep}..`, + init: async () => { + n += 1 + await Bun.sleep(10) + }, + fn: async () => Instance.directory, + }), + Instance.provide({ + directory: `${alias}${path.sep}.${path.sep}`, + init: async () => { + n += 1 + await Bun.sleep(10) + }, + fn: async () => Instance.directory, + }), + ]) + + expect(a).toBe(alias) + expect(b).toBe(alias) + expect(n).toBe(1) + } finally { + await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) + } +}) diff --git a/packages/opencode/test/server/path-alias.test.ts b/packages/opencode/test/server/path-alias.test.ts new file mode 100644 index 000000000000..b543fe3765b0 --- /dev/null +++ b/packages/opencode/test/server/path-alias.test.ts @@ -0,0 +1,49 @@ +import { afterEach, expect, mock, test } from "bun:test" +import fs from "fs/promises" +import path from "path" + +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +mock.module("../../src/project/bootstrap", () => ({ + InstanceBootstrap: async () => {}, +})) + +const { Server } = await import("../../src/server/server") + +Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() + await resetDatabase() +}) + +async function link(dir: string) { + const alias = path.join(path.dirname(dir), path.basename(dir) + "-link") + await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) + await fs.symlink(dir, alias, process.platform === "win32" ? "junction" : "dir") + return alias +} + +test("server ingress keeps alias directories", async () => { + await using tmp = await tmpdir({ git: true }) + const alias = await link(tmp.path) + + try { + const app = Server.createApp({}) + const response = await app.request("/path", { + headers: { + "x-opencode-directory": `${alias}${path.sep}work${path.sep}..`, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + directory: alias, + }) + } finally { + await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) + } +}) From 6c217128e52cc1eb9cf25eda870cca359c8d42c0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:58:05 +1000 Subject: [PATCH 03/42] path: keep logical roots in resolve flows --- packages/opencode/src/cli/cmd/tui/thread.ts | 8 ++-- packages/opencode/src/path/path.ts | 28 +++++++++++ packages/opencode/src/util/filesystem.ts | 12 ++--- packages/opencode/test/cli/tui/thread.test.ts | 8 ++-- packages/opencode/test/path/path.test.ts | 36 ++++++++++++++ .../opencode/test/util/filesystem.test.ts | 48 +++++++------------ 6 files changed, 91 insertions(+), 49 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 6e787c7afddd..b72ebf140455 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -113,12 +113,12 @@ export const TuiThreadCommand = cmd({ return } - // Resolve relative --project paths from PWD, then use the real cwd after - // chdir so the thread and worker share the same directory key. + // Resolve relative --project paths from the logical shell root so alias + // roots from PWD or --project stay intact. const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) const next = args.project ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) - : Filesystem.resolve(process.cwd()) + : root const file = await target() try { process.chdir(next) @@ -126,7 +126,7 @@ export const TuiThreadCommand = cmd({ UI.error("Failed to change directory to " + next) return } - const cwd = Filesystem.resolve(process.cwd()) + const cwd = next const worker = new Worker(file, { env: Object.fromEntries( diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index 8b90e2ce526f..a4afe7bfd50b 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -1,3 +1,4 @@ +import { readdirSync } from "fs" import { readdir } from "fs/promises" import path from "path" @@ -147,4 +148,31 @@ export namespace Path { return PrettyPath.make(clean(out, platform)) } + + export function truecaseSync(input: string, opts: Omit = {}) { + const platform = pf(opts) + const text = pretty(input, opts) + if (platform !== "win32") return text + + const mod = path.win32 + const root = mod.parse(text).root + const rest = text.slice(root.length).split("\\").filter(Boolean) + let out = root + + for (const [idx, seg] of rest.entries()) { + let list: string[] | undefined + try { + list = readdirSync(out) + } catch { + return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) + } + + const hit = list.find((item) => item.toLowerCase() === seg.toLowerCase()) + if (!hit) return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) + + out = mod.join(out, hit) + } + + return PrettyPath.make(clean(out, platform)) + } } diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 37f00c6b9c8e..a28d2dd3c8d1 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -5,6 +5,7 @@ import { realpathSync } from "fs" import { dirname, join, relative, resolve as pathResolve } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" +import { Path } from "@/path/path" import { Glob } from "./glob" export namespace Filesystem { @@ -114,16 +115,9 @@ export namespace Filesystem { } // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. - // Also resolves symlinks so that callers using the result as a cache key - // always get the same canonical path for a given physical directory. + // Keep logical alias roots stable while best-effort true-casing on Windows. export function resolve(p: string): string { - const resolved = pathResolve(windowsPath(p)) - try { - return normalizePath(realpathSync(resolved)) - } catch (e) { - if (isEnoent(e)) return normalizePath(resolved) - throw e - } + return Path.truecaseSync(pathResolve(windowsPath(p))) } export function windowsPath(p: string): string { diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index d3de7c3183d3..05b3a18f68f1 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -134,8 +134,8 @@ describe("tui thread", () => { process.chdir(tmp.path) process.env.PWD = link await expect(call(project)).rejects.toBe(stop) - expect(seen.inst[0]).toBe(tmp.path) - expect(seen.tui[0]).toBe(tmp.path) + expect(seen.inst[0]).toBe(link) + expect(seen.tui[0]).toBe(link) } finally { process.chdir(cwd) if (pwd === undefined) delete process.env.PWD @@ -147,11 +147,11 @@ describe("tui thread", () => { } } - test("uses the real cwd when PWD points at a symlink", async () => { + test("keeps the logical PWD when it points at a symlink", async () => { await check() }) - test("uses the real cwd after resolving a relative project from PWD", async () => { + test("keeps the logical PWD when resolving a relative project", async () => { await check(".") }) }) diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index d359168689c9..457651b2bfc6 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -103,4 +103,40 @@ describe("path", () => { expect(String(await Path.truecase(file, { platform: "linux" }))).toBe(file) }) }) + + describe("truecaseSync()", () => { + test("keeps missing tails as typed on Windows", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const dir = path.join(tmp.path, "CaseDir") + const child = path.join(dir, "Leaf") + await fs.mkdir(child, { recursive: true }) + + const input = path.join(tmp.path.toLowerCase(), "casedir", "leaf", "Miss", "Tail.ts") + const result = String(Path.truecaseSync(input)) + + expect(result).toBe(path.join(tmp.path, "CaseDir", "Leaf", "Miss", "Tail.ts")) + }) + + test("preserves alias roots while true-casing Windows paths", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const real = path.join(tmp.path, "Target") + await fs.mkdir(path.join(real, "Leaf"), { recursive: true }) + const alias = path.join(tmp.path, "Alias") + await fs.symlink(real, alias, "junction") + + const input = path.join(tmp.path.toLowerCase(), "alias", "leaf") + const result = String(Path.truecaseSync(input)) + + expect(result).toBe(path.join(tmp.path, "Alias", "Leaf")) + }) + + test("is a no-op off Windows", () => { + const file = "/tmp/test.txt" + expect(String(Path.truecaseSync(file, { platform: "linux" }))).toBe(file) + }) + }) }) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index aea0b1db87f5..b37bdd0bd3c1 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -503,13 +503,24 @@ describe("filesystem", () => { expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`)) }) - test("resolves symlinked directory to canonical path", async () => { + test("preserves symlinked directory roots", async () => { await using tmp = await tmpdir() const target = path.join(tmp.path, "real") await fs.mkdir(target) - const link = path.join(tmp.path, "link") + const link = path.join(tmp.path, "alias") await fs.symlink(target, link) - expect(Filesystem.resolve(link)).toBe(Filesystem.resolve(target)) + expect(Filesystem.resolve(link)).toBe(path.resolve(link)) + expect(Filesystem.resolve(link)).not.toBe(Filesystem.resolve(target)) + }) + + test("preserves symlink roots for nested paths", async () => { + await using tmp = await tmpdir() + const target = path.join(tmp.path, "real") + const child = path.join(target, "child") + await fs.mkdir(child, { recursive: true }) + const link = path.join(tmp.path, "alias") + await fs.symlink(target, link) + expect(Filesystem.resolve(path.join(link, "child"))).toBe(path.resolve(link, "child")) }) test("returns unresolved path when target does not exist", async () => { @@ -519,40 +530,13 @@ describe("filesystem", () => { expect(result).toBe(Filesystem.normalizePath(path.resolve(missing))) }) - test("throws ELOOP on symlink cycle", async () => { + test("keeps cyclic symlink paths as typed", async () => { await using tmp = await tmpdir() const a = path.join(tmp.path, "a") const b = path.join(tmp.path, "b") await fs.symlink(b, a) await fs.symlink(a, b) - expect(() => Filesystem.resolve(a)).toThrow() - }) - - // Windows: chmod(0o000) is a no-op, so EACCES cannot be triggered - test("throws EACCES on permission-denied symlink target", async () => { - if (process.platform === "win32") return - if (process.getuid?.() === 0) return // skip when running as root - await using tmp = await tmpdir() - const dir = path.join(tmp.path, "restricted") - await fs.mkdir(dir) - const link = path.join(tmp.path, "link") - await fs.symlink(dir, link) - await fs.chmod(dir, 0o000) - try { - expect(() => Filesystem.resolve(path.join(link, "child"))).toThrow() - } finally { - await fs.chmod(dir, 0o755) - } - }) - - // Windows: traversing through a file throws ENOENT (not ENOTDIR), - // which resolve() catches as a fallback instead of rethrowing - test("rethrows non-ENOENT errors", async () => { - if (process.platform === "win32") return - await using tmp = await tmpdir() - const file = path.join(tmp.path, "not-a-directory") - await fs.writeFile(file, "x") - expect(() => Filesystem.resolve(path.join(file, "child"))).toThrow() + expect(Filesystem.resolve(path.join(a, "child"))).toBe(path.resolve(a, "child")) }) }) }) From 8bfba24f56df60161f6a205045068696af306be3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:10:32 +1000 Subject: [PATCH 04/42] path: normalize stored backend directory state --- .../opencode/src/control-plane/workspace.ts | 11 ++- packages/opencode/src/project/project.ts | 82 ++++++++++++++----- packages/opencode/src/session/index.ts | 45 +++++----- .../test/project/migrate-global.test.ts | 26 ++++++ .../opencode/test/project/project.test.ts | 37 +++++++++ .../test/server/global-session-list.test.ts | 21 +++++ .../opencode/test/server/session-list.test.ts | 43 ++++++++++ 7 files changed, 220 insertions(+), 45 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index c3c28ed60575..32835407ccdf 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -11,6 +11,7 @@ import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" +import { Path } from "@/path/path" export namespace Workspace { export const Event = { @@ -33,13 +34,18 @@ export namespace Workspace { }) export type Info = z.infer + function fix(input: string) { + if (!input) return input + return Path.truecaseSync(input) + } + function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { id: row.id, type: row.type, branch: row.branch, name: row.name, - directory: row.directory, + directory: row.directory ? fix(row.directory) : null, extra: row.extra, projectID: row.project_id, } @@ -58,13 +64,14 @@ export namespace Workspace { const adaptor = await getAdaptor(input.type) const config = await adaptor.configure({ ...input, id, name: null, directory: null }) + const dir = config.directory ? await Path.truecase(config.directory) : null const info: Info = { id, type: config.type, branch: config.branch ?? null, name: config.name ?? null, - directory: config.directory ?? null, + directory: dir, extra: config.extra ?? null, projectID: input.projectID, } diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1cef41c85c91..70940286528c 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -1,7 +1,7 @@ import z from "zod" import { Filesystem } from "../util/filesystem" import path from "path" -import { and, Database, eq } from "../storage/db" +import { Database, eq, inArray } from "../storage/db" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" import { Log } from "../util/log" @@ -15,11 +15,35 @@ import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Path } from "@/path/path" export namespace Project { const log = Log.create({ service: "project" }) - function gitpath(cwd: string, name: string) { + function fix(input: string) { + if (!input || input === "/") return input + return Path.truecaseSync(input) + } + + function same(a: string, b: string) { + if (a === "/" || b === "/") return a === b + return Path.eq(a, b) + } + + function uniq(list: string[]) { + const seen = new Set() + const out: string[] = [] + for (const item of list) { + const dir = fix(item) + const key = dir === "/" ? dir : Path.key(dir) + if (seen.has(key)) continue + seen.add(key) + out.push(dir) + } + return out + } + + async function gitpath(cwd: string, name: string) { if (!name) return cwd // git output includes trailing newlines; keep path whitespace intact. name = name.replace(/[\r\n]+$/, "") @@ -27,8 +51,8 @@ export namespace Project { name = Filesystem.windowsPath(name) - if (path.isAbsolute(name)) return path.normalize(name) - return path.resolve(cwd, name) + if (path.isAbsolute(name)) return await Path.truecase(name) + return await Path.truecase(Path.pretty(name, { cwd })) } export const Info = z @@ -74,7 +98,7 @@ export namespace Project { : undefined return { id: ProjectID.make(row.id), - worktree: row.worktree, + worktree: fix(row.worktree), vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, icon, @@ -83,7 +107,7 @@ export namespace Project { updated: row.time_updated, initialized: row.time_initialized ?? undefined, }, - sandboxes: row.sandboxes, + sandboxes: uniq(row.sandboxes), commands: row.commands ?? undefined, } } @@ -96,6 +120,7 @@ export namespace Project { } export async function fromDirectory(directory: string) { + directory = await Path.truecase(directory) log.info("fromDirectory", { directory }) const data = await iife(async () => { @@ -103,7 +128,7 @@ export namespace Project { const dotgit = await matches.next().then((x) => x.value) await matches.return() if (dotgit) { - let sandbox = path.dirname(dotgit) + let sandbox: string = await Path.truecase(path.dirname(dotgit)) const gitBinary = which("git") @@ -123,9 +148,9 @@ export namespace Project { cwd: sandbox, }) .then(async (result) => { - const common = gitpath(sandbox, await result.text()) + const common = await gitpath(sandbox, await result.text()) // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : path.dirname(common) + return same(common, sandbox) ? sandbox : fix(path.dirname(common)) }) .catch(() => undefined) @@ -236,15 +261,17 @@ export namespace Project { const result: Info = { ...existing, worktree: data.worktree, + sandboxes: uniq(existing.sandboxes), vcs: data.vcs as Info["vcs"], time: { ...existing.time, updated: Date.now(), }, } - if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) - result.sandboxes.push(data.sandbox) - result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) + if (!same(data.sandbox, result.worktree) && !result.sandboxes.some((item) => same(item, data.sandbox))) { + result.sandboxes.push(fix(data.sandbox)) + } + result.sandboxes = uniq(result.sandboxes).filter((item) => existsSync(item)) const insert = { id: result.id, worktree: result.worktree, @@ -276,13 +303,24 @@ export namespace Project { // Runs on every startup because sessions created before git init // accumulate under "global" and need migrating whenever they appear. if (data.id !== ProjectID.global) { - Database.use((db) => + const ids = Database.use((db) => db - .update(SessionTable) - .set({ project_id: data.id }) - .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) - .run(), + .select({ id: SessionTable.id, directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.project_id, ProjectID.global)) + .all() + .filter((row) => row.directory && same(row.directory, data.worktree)) + .map((row) => row.id), ) + if (ids.length) { + Database.use((db) => + db + .update(SessionTable) + .set({ project_id: data.id }) + .where(inArray(SessionTable.id, ids)) + .run(), + ) + } } GlobalBus.emit("event", { payload: { @@ -402,7 +440,7 @@ export namespace Project { const valid: string[] = [] for (const dir of data.sandboxes) { const s = Filesystem.stat(dir) - if (s?.isDirectory()) valid.push(dir) + if (s?.isDirectory() && !valid.some((item) => same(item, dir))) valid.push(dir) } return valid } @@ -410,8 +448,9 @@ export namespace Project { export async function addSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) - const sandboxes = [...row.sandboxes] - if (!sandboxes.includes(directory)) sandboxes.push(directory) + const dir = fix(directory) + const sandboxes = uniq(row.sandboxes) + if (!sandboxes.some((item) => same(item, dir))) sandboxes.push(dir) const result = Database.use((db) => db .update(ProjectTable) @@ -434,7 +473,8 @@ export namespace Project { export async function removeSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) - const sandboxes = row.sandboxes.filter((s) => s !== directory) + const dir = fix(directory) + const sandboxes = uniq(row.sandboxes).filter((item) => !same(item, dir)) const result = Database.use((db) => db .update(ProjectTable) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 01fd214e0a44..c3925df5d079 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -32,6 +32,7 @@ import { PermissionNext } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" import { iife } from "@/util/iife" +import { Path } from "@/path/path" export namespace Session { const log = Log.create({ service: "session" }) @@ -39,6 +40,11 @@ export namespace Session { const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " + function fix(input: string) { + if (!input) return input + return Path.truecaseSync(input) + } + function createDefaultTitle(isChild = false) { return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() } @@ -68,7 +74,7 @@ export namespace Session { slug: row.slug, projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, - directory: row.directory, + directory: fix(row.directory), parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -302,12 +308,13 @@ export namespace Session { directory: string permission?: PermissionNext.Ruleset }) { + const dir = await Path.truecase(input.directory) const result: Info = { id: SessionID.descending(input.id), slug: Slug.create(), version: Installation.VERSION, projectID: Instance.project.id, - directory: input.directory, + directory: dir, workspaceID: input.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), @@ -547,13 +554,11 @@ export namespace Session { }) { const project = Instance.project const conditions = [eq(SessionTable.project_id, project.id)] + const dir = input?.directory ? fix(input.directory) : undefined if (WorkspaceContext.workspaceID) { conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID)) } - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) } @@ -566,16 +571,12 @@ export namespace Session { const limit = input?.limit ?? 100 - const rows = Database.use((db) => - db - .select() - .from(SessionTable) - .where(and(...conditions)) - .orderBy(desc(SessionTable.time_updated)) - .limit(limit) - .all(), - ) - for (const row of rows) { + const rows = Database.use((db) => { + const query = db.select().from(SessionTable).where(and(...conditions)).orderBy(desc(SessionTable.time_updated)) + return dir ? query.all() : query.limit(limit).all() + }) + const hits = dir ? rows.filter((row) => row.directory && Path.eq(row.directory, dir)).slice(0, limit) : rows + for (const row of hits) { yield fromRow(row) } } @@ -590,10 +591,8 @@ export namespace Session { archived?: boolean }) { const conditions: SQL[] = [] + const dir = input?.directory ? fix(input.directory) : undefined - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) } @@ -620,10 +619,12 @@ export namespace Session { .from(SessionTable) .where(and(...conditions)) : db.select().from(SessionTable) - return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() + const sort = query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)) + return dir ? sort.all() : sort.limit(limit).all() }) + const hits = dir ? rows.filter((row) => row.directory && Path.eq(row.directory, dir)).slice(0, limit) : rows - const ids = [...new Set(rows.map((row) => row.project_id))] + const ids = [...new Set(hits.map((row) => row.project_id))] const projects = new Map() if (ids.length > 0) { @@ -638,12 +639,12 @@ export namespace Session { projects.set(item.id, { id: item.id, name: item.name ?? undefined, - worktree: item.worktree, + worktree: item.worktree === "/" ? item.worktree : Path.truecaseSync(item.worktree), }) } } - for (const row of rows) { + for (const row of hits) { const project = projects.get(row.project_id) ?? null yield { ...fromRow(row), project } } diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index b66653f7005d..c65f77171c74 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -8,6 +8,7 @@ import { SessionID } from "../../src/session/schema" import { Log } from "../../src/util/log" import { $ } from "bun" import { tmpdir } from "../fixture/fixture" +import path from "path" Log.init({ print: false }) @@ -119,6 +120,31 @@ describe("migrateFromGlobal", () => { expect(row!.project_id).toBe(ProjectID.global) }) + test("migrates sessions with equivalent pretty directories", async () => { + await using tmp = await tmpdir() + await $`git init`.cwd(tmp.path).quiet() + await $`git config user.name "Test"`.cwd(tmp.path).quiet() + await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet() + const { project: pre } = await Project.fromDirectory(tmp.path) + expect(pre.id).toBe(ProjectID.global) + + const id = uid() + seed({ + id, + dir: `${tmp.path}${path.sep}work${path.sep}..`, + project: ProjectID.global, + }) + + await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet() + + const { project } = await Project.fromDirectory(tmp.path) + expect(project.id).not.toBe(ProjectID.global) + + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + expect(row).toBeDefined() + expect(row!.project_id).toBe(project.id) + }) + test("does not steal sessions from unrelated directories", async () => { await using tmp = await tmpdir({ git: true }) const { project } = await Project.fromDirectory(tmp.path) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f02..5375d955ac1d 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -100,6 +100,17 @@ describe("Project.fromDirectory", () => { expect(fileExists).toBe(true) }) + test("truecases authoritative git paths before persisting", async () => { + if (process.platform !== "win32") return + const p = await loadProject() + await using tmp = await tmpdir({ git: true }) + + const { project, sandbox } = await p.fromDirectory(tmp.path.toUpperCase()) + + expect(project.worktree).toBe(tmp.path) + expect(sandbox).toBe(tmp.path) + }) + test("keeps git vcs when rev-list exits non-zero with empty output", async () => { const p = await loadProject() await using tmp = await tmpdir() @@ -393,3 +404,29 @@ describe("Project.update", () => { expect(updated.commands?.start).toBe("make start") }) }) + +describe("Project sandbox paths", () => { + test("dedupes equivalent sandbox paths", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + const dir = path.join(tmp.path, "sandbox") + await Filesystem.write(path.join(dir, ".keep"), "ok") + + await Project.addSandbox(project.id, dir) + const updated = await Project.addSandbox(project.id, `${dir}${path.sep}child${path.sep}..`) + + expect(updated.sandboxes.filter((item) => item === dir)).toHaveLength(1) + }) + + test("removes equivalent sandbox paths", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + const dir = path.join(tmp.path, "sandbox") + await Filesystem.write(path.join(dir, ".keep"), "ok") + + await Project.addSandbox(project.id, dir) + const updated = await Project.removeSandbox(project.id, `${dir}${path.sep}child${path.sep}..`) + + expect(updated.sandboxes).not.toContain(dir) + }) +}) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 05d6de04b1b1..342dec43f51f 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import path from "path" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" import { Session } from "../../src/session" @@ -86,4 +87,24 @@ describe("Session.listGlobal", () => { expect(ids).toContain(first.id) expect(ids).not.toContain(second.id) }) + + test("filters by equivalent pretty paths", async () => { + await using tmp = await tmpdir({ git: true }) + await using tmp2 = await tmpdir({ git: true }) + + const first = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "global-pretty-match" }), + }) + const second = await Instance.provide({ + directory: tmp2.path, + fn: async () => Session.create({ title: "global-pretty-other" }), + }) + + const sessions = [...Session.listGlobal({ directory: `${tmp.path}${path.sep}work${path.sep}..`, limit: 200 })] + const ids = sessions.map((session) => session.id) + + expect(ids).toContain(first.id) + expect(ids).not.toContain(second.id) + }) }) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 675a89011f96..58cb51caf821 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" +import { Database, eq } from "../../src/storage/db" +import { SessionTable } from "../../src/session/session.sql" import { Log } from "../../src/util/log" const projectRoot = path.join(__dirname, "../..") @@ -29,6 +31,47 @@ describe("Session.list", () => { }) }) + test("filters by equivalent pretty paths", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const first = await Session.create({}) + + const otherDir = path.join(projectRoot, "..", "__session_list_equivalent_other") + const second = await Instance.provide({ + directory: otherDir, + fn: async () => Session.create({}), + }) + + const sessions = [...Session.list({ directory: `${projectRoot}${path.sep}work${path.sep}..` })] + const ids = sessions.map((s) => s.id) + + expect(ids).toContain(first.id) + expect(ids).not.toContain(second.id) + }, + }) + }) + + test("reads stored directories back in true case", async () => { + if (process.platform !== "win32") return + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + Database.use((db) => + db + .update(SessionTable) + .set({ directory: projectRoot.toUpperCase() }) + .where(eq(SessionTable.id, session.id)) + .run(), + ) + + const result = await Session.get(session.id) + expect(result.directory).toBe(projectRoot) + }, + }) + }) + test("filters root sessions", async () => { await Instance.provide({ directory: projectRoot, From e17190e62479fe7993a1c50d981b8aca712f8e5a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:20:17 +1000 Subject: [PATCH 05/42] app: dedupe workspaces by normalized path keys --- .../app/src/components/dialog-select-file.tsx | 5 +- .../src/components/session/session-header.tsx | 3 +- packages/app/src/context/layout.tsx | 24 +++--- packages/app/src/context/server.tsx | 22 +++-- packages/app/src/pages/layout.tsx | 82 ++++++++++--------- packages/app/src/pages/layout/helpers.test.ts | 71 ++++++++++++++-- packages/app/src/pages/layout/helpers.ts | 23 ++++-- .../app/src/pages/layout/sidebar-project.tsx | 10 +-- packages/util/src/path.ts | 30 +++++++ 9 files changed, 191 insertions(+), 79 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index e21be77fb94d..b4dfb1d0ae16 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -14,6 +14,7 @@ import { useGlobalSync } from "@/context/global-sync" import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { findProjectByDirectory, workspaceEqual } from "@/pages/layout/helpers" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { decode64 } from "@/utils/base64" @@ -280,7 +281,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const project = createMemo(() => { const directory = projectDirectory() if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + return findProjectByDirectory(layout.projects.list(), directory) }) const workspaces = createMemo(() => { const directory = projectDirectory() @@ -288,7 +289,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil if (!current) return directory ? [directory] : [] const dirs = [current.worktree, ...(current.sandboxes ?? [])] - if (directory && !dirs.includes(directory)) return [...dirs, directory] + if (directory && !dirs.some((dir) => workspaceEqual(dir, directory))) return [...dirs, directory] return dirs }) const homedir = createMemo(() => globalSync.data.path.home) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 4947ad06a9be..811d57cf7d0d 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -18,6 +18,7 @@ import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" +import { findProjectByDirectory } from "@/pages/layout/helpers" import { focusTerminalById } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" @@ -142,7 +143,7 @@ export function SessionHeader() { const project = createMemo(() => { const directory = projectDirectory() if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + return findProjectByDirectory(layout.projects.list(), directory) }) const name = createMemo(() => { const current = project() diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 78928118d72a..e60e65dddfc6 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -1,6 +1,7 @@ import { createStore, produce } from "solid-js/store" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" +import { pathEqual, pathKey } from "@opencode-ai/util/path" import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" import { useServer } from "./server" @@ -389,7 +390,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const projectID = childStore.project const metadata = projectID ? globalSync.data.project.find((x) => x.id === projectID) - : globalSync.data.project.find((x) => x.worktree === project.worktree) + : globalSync.data.project.find((x) => pathEqual(x.worktree, project.worktree)) const local = childStore.projectMeta const localOverride = @@ -429,7 +430,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( for (const project of globalSync.data.project) { const sandboxes = project.sandboxes ?? [] for (const sandbox of sandboxes) { - map.set(sandbox, project.worktree) + map.set(pathKey(sandbox), project.worktree) } } return map @@ -446,11 +447,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const current = chain[chain.length - 1] if (!current) return directory - const next = map.get(current) + const key = pathKey(current) + const next = map.get(key) if (!next) return current - if (visited.has(next)) return directory - visited.add(next) + const id = pathKey(next) + if (visited.has(id)) return directory + visited.add(id) chain.push(next) } @@ -459,18 +462,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( createEffect(() => { const projects = server.projects.list() - const seen = new Set(projects.map((project) => project.worktree)) + const seen = new Set(projects.map((project) => pathKey(project.worktree))) batch(() => { for (const project of projects) { const root = rootFor(project.worktree) - if (root === project.worktree) continue + if (pathEqual(root, project.worktree)) continue server.projects.close(project.worktree) - if (!seen.has(root)) { + const key = pathKey(root) + if (!seen.has(key)) { server.projects.open(root) - seen.add(root) + seen.add(key) } if (project.expanded) server.projects.expand(root) @@ -567,7 +571,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( list, open(directory: string) { const root = rootFor(directory) - if (server.projects.list().find((x) => x.worktree === root)) return + if (server.projects.list().some((x) => pathEqual(x.worktree, root))) return globalSync.project.loadSessions(root) server.projects.open(root) }, diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1171ca90536f..528d49058855 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,4 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" +import { pathEqual, pathKey } from "@opencode-ai/util/path" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" @@ -207,7 +208,16 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) const origin = createMemo(() => projectsKey(state.active)) - const projectsList = createMemo(() => store.projects[origin()] ?? []) + const projectsList = createMemo(() => { + const list = store.projects[origin()] ?? [] + const seen = new Set() + return list.filter((project) => { + const key = pathKey(project.worktree) + if (seen.has(key)) return false + seen.add(key) + return true + }) + }) const current: Accessor = createMemo( () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], ) @@ -241,7 +251,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const key = origin() if (!key) return const current = store.projects[key] ?? [] - if (current.find((x) => x.worktree === directory)) return + if (current.some((x) => pathEqual(x.worktree, directory))) return setStore("projects", key, [{ worktree: directory, expanded: true }, ...current]) }, close(directory: string) { @@ -251,28 +261,28 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( setStore( "projects", key, - current.filter((x) => x.worktree !== directory), + current.filter((x) => !pathEqual(x.worktree, directory)), ) }, expand(directory: string) { const key = origin() if (!key) return const current = store.projects[key] ?? [] - const index = current.findIndex((x) => x.worktree === directory) + const index = current.findIndex((x) => pathEqual(x.worktree, directory)) if (index !== -1) setStore("projects", key, index, "expanded", true) }, collapse(directory: string) { const key = origin() if (!key) return const current = store.projects[key] ?? [] - const index = current.findIndex((x) => x.worktree === directory) + const index = current.findIndex((x) => pathEqual(x.worktree, directory)) if (index !== -1) setStore("projects", key, index, "expanded", false) }, move(directory: string, toIndex: number) { const key = origin() if (!key) return const current = store.projects[key] ?? [] - const fromIndex = current.findIndex((x) => x.worktree === directory) + const fromIndex = current.findIndex((x) => pathEqual(x.worktree, directory)) if (fromIndex === -1 || fromIndex === toIndex) return const result = [...current] const [item] = result.splice(fromIndex, 1) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c84c7272d67c..499769900135 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -72,8 +72,10 @@ import { displayName, effectiveWorkspaceOrder, errorMessage, + findProjectByDirectory, latestRootSession, sortedRootSessions, + workspaceEqual, workspaceKey, } from "./layout/helpers" import { @@ -243,13 +245,13 @@ export default function Layout(props: ParentProps) { const hoverProjectData = createMemo(() => { const id = state.hoverProject if (!id) return - return layout.projects.list().find((project) => project.worktree === id) + return layout.projects.list().find((project) => workspaceEqual(project.worktree, id)) }) const peekProject = createMemo(() => { const id = state.peek if (!id) return - return layout.projects.list().find((project) => project.worktree === id) + return layout.projects.list().find((project) => workspaceEqual(project.worktree, id)) }) createEffect(() => { @@ -484,8 +486,8 @@ export default function Layout(props: ParentProps) { } const currentSession = params.id - if (directory === currentDir() && props.sessionID === currentSession) return - if (directory === currentDir() && session?.parentID === currentSession) return + if (workspaceEqual(directory, currentDir()) && props.sessionID === currentSession) return + if (workspaceEqual(directory, currentDir()) && session?.parentID === currentSession) return dismissSessionAlert(sessionKey) @@ -546,10 +548,7 @@ export default function Layout(props: ParentProps) { const projects = layout.projects.list() - const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) - if (sandbox) return sandbox - - const direct = projects.find((p) => p.worktree === directory) + const direct = findProjectByDirectory(projects, directory) if (direct) return direct const [child] = globalSync.child(directory, { bootstrap: false }) @@ -560,7 +559,7 @@ export default function Layout(props: ParentProps) { const root = meta?.worktree if (!root) return - return projects.find((p) => p.worktree === root) + return findProjectByDirectory(projects, root) }) const [autoselecting] = createResource(async () => { @@ -574,7 +573,7 @@ export default function Layout(props: ParentProps) { if (!last) return await openProject(last, true) } else { - const next = list.find((project) => project.worktree === last) ?? list[0] + const next = list.find((project) => workspaceEqual(project.worktree, last)) ?? list[0] if (!next) return await openProject(next.worktree, true) } @@ -617,8 +616,10 @@ export default function Layout(props: ParentProps) { const activeDir = currentDir() return workspaceIds(project).filter((directory) => { - const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree - const active = directory === activeDir + const key = workspaceKey(directory) + const expanded = + store.workspaceExpanded[key] ?? store.workspaceExpanded[directory] ?? workspaceEqual(directory, project.worktree) + const active = workspaceEqual(directory, activeDir) return expanded || active }) }) @@ -629,7 +630,7 @@ export default function Layout(props: ParentProps) { const projects = layout.projects.list() for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { if (!expanded) continue - const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + const project = findProjectByDirectory(projects, directory) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue setStore("workspaceExpanded", directory, false) @@ -1154,13 +1155,11 @@ export default function Layout(props: ParentProps) { } function projectRoot(directory: string) { - const project = layout.projects - .list() - .find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + const project = findProjectByDirectory(layout.projects.list(), directory) if (project) return project.worktree const known = Object.entries(store.workspaceOrder).find( - ([root, dirs]) => root === directory || dirs.includes(directory), + ([root, dirs]) => workspaceEqual(root, directory) || dirs.some((dir) => workspaceEqual(dir, directory)), ) if (known) return known[0] @@ -1179,7 +1178,7 @@ export default function Layout(props: ParentProps) { function touchProjectRoute() { const root = currentProject()?.worktree if (!root) return - if (server.projects.last() !== root) server.projects.touch(root) + if (!workspaceEqual(server.projects.last(), root)) server.projects.touch(root) return root } @@ -1201,9 +1200,10 @@ export default function Layout(props: ParentProps) { function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { rememberSessionRoute(directory, id, root) notification.session.markViewed(id) - const expanded = untrack(() => store.workspaceExpanded[directory]) + const key = workspaceKey(directory) + const expanded = untrack(() => store.workspaceExpanded[key] ?? store.workspaceExpanded[directory]) if (expanded === false) { - setStore("workspaceExpanded", directory, true) + setStore("workspaceExpanded", key, true) } requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`)) return root @@ -1213,7 +1213,7 @@ export default function Layout(props: ParentProps) { if (!directory) return const root = projectRoot(directory) server.projects.touch(root) - const project = layout.projects.list().find((item) => item.worktree === root) + const project = layout.projects.list().find((item) => workspaceEqual(item.worktree, root)) let dirs = project ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) : [root] @@ -1222,7 +1222,7 @@ export default function Layout(props: ParentProps) { return dirs.some((item) => workspaceKey(item) === workspaceKey(value)) } const refreshDirs = async (target?: string) => { - if (!target || target === root || canOpen(target)) return canOpen(target) + if (!target || workspaceEqual(target, root) || canOpen(target)) return canOpen(target) const listed = await globalSDK.client.worktree .list({ directory: root }) .then((x) => x.data ?? []) @@ -1346,8 +1346,8 @@ export default function Layout(props: ParentProps) { function closeProject(directory: string) { const list = layout.projects.list() - const index = list.findIndex((x) => x.worktree === directory) - const active = currentProject()?.worktree === directory + const index = list.findIndex((x) => workspaceEqual(x.worktree, directory)) + const active = workspaceEqual(currentProject()?.worktree, directory) if (index === -1) return const next = list[index + 1] @@ -1408,7 +1408,7 @@ export default function Layout(props: ParentProps) { } const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => { - if (directory === root) return + if (workspaceEqual(directory, root)) return const current = currentDir() const currentKey = workspaceKey(current) @@ -1442,12 +1442,12 @@ export default function Layout(props: ParentProps) { globalSync.set( "project", produce((draft) => { - const project = draft.find((item) => item.worktree === root) + const project = draft.find((item) => workspaceEqual(item.worktree, root)) if (!project) return - project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory) + project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => !workspaceEqual(sandbox, directory)) }), ) - setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory)) + setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => !workspaceEqual(workspace, directory))) layout.projects.close(directory) layout.projects.open(root) @@ -1456,19 +1456,19 @@ export default function Layout(props: ParentProps) { const nextCurrent = currentDir() const nextKey = workspaceKey(nextCurrent) - const project = layout.projects.list().find((item) => item.worktree === root) + const project = layout.projects.list().find((item) => workspaceEqual(item.worktree, root)) const dirs = project ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) : [root] const valid = dirs.some((item) => workspaceKey(item) === nextKey) - if (params.dir && projectRoot(nextCurrent) === root && !valid) { + if (params.dir && workspaceEqual(projectRoot(nextCurrent), root) && !valid) { navigateWithSidebarReset(`/${base64Encode(root)}/session`) } } const resetWorkspace = async (root: string, directory: string) => { - if (directory === root) return + if (workspaceEqual(directory, root)) return setBusy(directory, true) const progress = showToast({ @@ -1760,8 +1760,8 @@ export default function Layout(props: ParentProps) { const { draggable, droppable } = event if (draggable && droppable) { const projects = layout.projects.list() - const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) - const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) + const fromIndex = projects.findIndex((p) => workspaceEqual(p.worktree, draggable.id.toString())) + const toIndex = projects.findIndex((p) => workspaceEqual(p.worktree, droppable.id.toString())) if (fromIndex !== toIndex && toIndex !== -1) { layout.projects.move(draggable.id.toString(), toIndex) } @@ -1777,8 +1777,11 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? currentDir() : undefined - const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined + const directory = workspaceEqual(active?.worktree, project.worktree) ? currentDir() : undefined + const extra = + directory && !workspaceEqual(directory, local) && !dirs.some((dir) => workspaceEqual(dir, directory)) + ? directory + : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) @@ -1809,8 +1812,8 @@ export default function Layout(props: ParentProps) { if (!project) return const ids = workspaceIds(project) - const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) - const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) + const fromIndex = ids.findIndex((dir) => workspaceEqual(dir, draggable.id.toString())) + const toIndex = ids.findIndex((dir) => workspaceEqual(dir, droppable.id.toString())) if (fromIndex === -1 || toIndex === -1) return if (fromIndex === toIndex) return @@ -1888,8 +1891,9 @@ export default function Layout(props: ParentProps) { setEditor, InlineEditor, isBusy, - workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local, - setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value), + workspaceExpanded: (directory, local) => + store.workspaceExpanded[workspaceKey(directory)] ?? store.workspaceExpanded[directory] ?? local, + setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", workspaceKey(directory), value), showResetWorkspaceDialog: (root, directory) => dialog.show(() => ), showDeleteWorkspaceDialog: (root, directory) => diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 9dbc6c72d2fd..29d1d2778cc7 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { pathEqual, pathKey } from "@opencode-ai/util/path" +import { type Session } from "@opencode-ai/sdk/v2/client" import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks, @@ -6,13 +8,15 @@ import { parseDeepLink, parseNewSessionDeepLink, } from "./deep-links" -import { type Session } from "@opencode-ai/sdk/v2/client" import { displayName, effectiveWorkspaceOrder, errorMessage, + findProjectByDirectory, hasProjectPermissions, latestRootSession, + projectContains, + workspaceEqual, workspaceKey, } from "./helpers" @@ -102,17 +106,27 @@ describe("layout deep links", () => { }) describe("layout workspace helpers", () => { - test("normalizes trailing slash in workspace key", () => { - expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") - expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") + test("normalizes trailing slash, slash style, and windows case in path keys", () => { + expect(pathKey("/tmp/demo///")).toBe("/tmp/demo") + expect(pathKey("C:\\Tmp\\Demo\\\\")).toBe("c:/tmp/demo") + expect(pathKey("C:/tmp/demo")).toBe("c:/tmp/demo") + expect(pathKey("\\\\Server\\Share\\Repo\\\\")).toBe("//server/share/repo") + }) + + test("preserves posix case sensitivity while folding windows paths", () => { + expect(pathEqual("/Tmp/Demo", "/tmp/demo")).toBe(false) + expect(pathEqual("C:\\Tmp\\Demo", "c:/tmp/demo")).toBe(true) + expect(pathEqual("\\\\Server\\Share\\Repo", "//server/share/repo")).toBe(true) }) - test("preserves posix and drive roots in workspace key", () => { + test("preserves normalized roots in workspace key", () => { expect(workspaceKey("/")).toBe("/") expect(workspaceKey("///")).toBe("/") - expect(workspaceKey("C:\\")).toBe("C:\\") - expect(workspaceKey("C:\\\\\\")).toBe("C:\\") - expect(workspaceKey("C:///")).toBe("C:/") + expect(workspaceKey("\\")).toBe("/") + expect(workspaceKey("C:\\")).toBe("c:/") + expect(workspaceKey("C:/")).toBe("c:/") + expect(workspaceKey("C:///")).toBe("c:/") + expect(workspaceKey("\\\\Server\\Share\\")).toBe("//server/share") }) test("keeps local first while preserving known order", () => { @@ -120,6 +134,15 @@ describe("layout workspace helpers", () => { expect(result).toEqual(["/root", "/c", "/b"]) }) + test("dedupes windows workspace variants in effective order", () => { + const result = effectiveWorkspaceOrder("C:/Root", ["c:\\root\\", "C:/ROOT/feature", "c:\\root\\FEATURE\\"], [ + "c:/root", + "c:/root/feature", + ]) + + expect(result).toEqual(["C:/Root", "C:/ROOT/feature"]) + }) + test("finds the latest root session across workspaces", () => { const result = latestRootSession( [ @@ -144,6 +167,38 @@ describe("layout workspace helpers", () => { expect(result?.id).toBe("workspace") }) + test("matches root sessions with normalized workspace keys", () => { + const result = latestRootSession( + [ + { + path: { directory: "C:/Workspace" }, + session: [ + session({ + id: "win", + directory: "c:\\workspace\\", + time: { created: 10, updated: 10, archived: undefined }, + }), + ], + }, + ], + 120_000, + ) + + expect(result?.id).toBe("win") + }) + + test("matches projects by normalized worktree and workspace paths", () => { + const projects = [ + { worktree: "C:/Workspace", sandboxes: ["C:/Workspace/Feature"] }, + { worktree: "/tmp/demo", sandboxes: ["/tmp/demo/branch"] }, + ] + + expect(projectContains(projects[0], "c:\\workspace\\feature\\")).toBe(true) + expect(findProjectByDirectory(projects, "c:\\workspace\\")).toBe(projects[0]) + expect(findProjectByDirectory(projects, "/tmp/demo/branch")).toBe(projects[1]) + expect(workspaceEqual("/tmp/demo", "/Tmp/demo")).toBe(false) + }) + test("detects project permissions with a filter", () => { const result = hasProjectPermissions( { diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index be4ce9f57423..d001a654d9c4 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,11 +1,22 @@ -import { getFilename } from "@opencode-ai/util/path" +import { getFilename, pathEqual, pathKey } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" -export const workspaceKey = (directory: string) => { - const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) - if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` - if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" - return directory.replace(/[\\/]+$/, "") +export const workspaceKey = pathKey + +export const workspaceEqual = pathEqual + +export const projectContains = (project: { worktree: string; sandboxes?: string[] }, directory: string | undefined) => { + if (!directory) return false + if (pathEqual(project.worktree, directory)) return true + return project.sandboxes?.some((sandbox) => pathEqual(sandbox, directory)) === true +} + +export const findProjectByDirectory = ( + projects: T[], + directory: string | undefined, +) => { + if (!directory) return + return projects.find((project) => projectContains(project, directory)) } function sortSessions(now: number) { diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index a26bc1831188..a3c0fa2b48f1 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" -import { childMapByParent, displayName, sortedRootSessions } from "./helpers" +import { childMapByParent, displayName, projectContains, sortedRootSessions, workspaceEqual } from "./helpers" export type ProjectSidebarContext = { currentDir: Accessor @@ -38,7 +38,7 @@ export const ProjectDragOverlay = (props: { projects: Accessor activeProject: Accessor }): JSX.Element => { - const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject())) + const project = createMemo(() => props.projects().find((p) => workspaceEqual(p.worktree, props.activeProject()))) return ( {(p) => ( @@ -278,11 +278,7 @@ export const SortableProject = (props: { const globalSync = useGlobalSync() const language = useLanguage() const sortable = createSortable(props.project.worktree) - const selected = createMemo( - () => - props.project.worktree === props.ctx.currentDir() || - props.project.sandboxes?.includes(props.ctx.currentDir()) === true, - ) + const selected = createMemo(() => projectContains(props.project, props.ctx.currentDir())) const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index bb191f5120ab..07317d42c79a 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -5,6 +5,36 @@ export function getFilename(path: string | undefined) { return parts[parts.length - 1] ?? "" } +const normalizeSlashes = (path: string) => path.replace(/[\\/]+/g, "/") + +const isUncPath = (path: string) => /^[\\/]{2}[^\\/]/.test(path) + +const isWindowsDrivePath = (path: string) => /^[A-Za-z]:([\\/]|$)/.test(path) + +export function pathKey(path: string) { + if (!path) return "" + + if (isUncPath(path)) { + const normalized = normalizeSlashes(path).replace(/^\/+/, "").replace(/\/+$/, "") + return normalized ? `//${normalized.toLowerCase()}` : "//" + } + + const normalized = normalizeSlashes(path).replace(/\/+$/, "") + + if (isWindowsDrivePath(path)) { + const folded = normalized.toLowerCase() + return /^[a-z]:$/.test(folded) ? `${folded}/` : folded + } + + if (!normalized && /[\\/]/.test(path)) return "/" + return normalized +} + +export function pathEqual(a: string | undefined, b: string | undefined) { + if (!a || !b) return a === b + return pathKey(a) === pathKey(b) +} + export function getDirectory(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[\/\\]+$/, "") From 58dc2dd90adf2bbb6aa9c8c6e317b140f4df98eb Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:29:24 +1000 Subject: [PATCH 06/42] app: keep native path separators in displays --- .../dialog-select-directory-path.ts | 90 +++++++++++++++++ .../dialog-select-directory.test.ts | 21 ++++ .../components/dialog-select-directory.tsx | 98 ++++--------------- packages/ui/src/components/file-media.tsx | 3 +- packages/ui/src/components/message-part.tsx | 35 ++++--- packages/ui/src/components/session-review.tsx | 5 +- packages/ui/src/components/session-turn.tsx | 7 +- packages/util/src/path.test.ts | 26 +++++ packages/util/src/path.ts | 11 ++- 9 files changed, 196 insertions(+), 100 deletions(-) create mode 100644 packages/app/src/components/dialog-select-directory-path.ts create mode 100644 packages/app/src/components/dialog-select-directory.test.ts create mode 100644 packages/util/src/path.test.ts diff --git a/packages/app/src/components/dialog-select-directory-path.ts b/packages/app/src/components/dialog-select-directory-path.ts new file mode 100644 index 000000000000..eabefdbf5fb0 --- /dev/null +++ b/packages/app/src/components/dialog-select-directory-path.ts @@ -0,0 +1,90 @@ +import { getPathSeparator } from "@opencode-ai/util/path" + +export function normalizePath(input: string) { + const v = input.replaceAll("\\", "/") + const squash = (value: string) => { + let next = value + while (next.includes("//")) next = next.replaceAll("//", "/") + return next + } + if (v.startsWith("//") && !v.startsWith("///")) return "//" + squash(v.slice(2)) + return squash(v) +} + +export function normalizeDriveRoot(input: string) { + const v = normalizePath(input) + if (/^[A-Za-z]:$/.test(v)) return v + "/" + return v +} + +export function trimTrailing(input: string) { + const v = normalizeDriveRoot(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + return v.replace(/\/+$/, "") +} + +export function joinPath(base: string | undefined, rel: string) { + const b = trimTrailing(base ?? "") + const r = trimTrailing(rel).replace(/^\/+/, "") + if (!b) return r + if (!r) return b + if (b.endsWith("/")) return b + r + return b + "/" + r +} + +export function rootOf(input: string) { + const v = normalizeDriveRoot(input) + if (v.startsWith("//")) return "//" + if (v.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) + return "" +} + +export function parentOf(input: string) { + const v = trimTrailing(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + + const i = v.lastIndexOf("/") + if (i <= 0) return "/" + if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) + return v.slice(0, i) +} + +export function modeOf(input: string) { + const raw = normalizeDriveRoot(input.trim()) + if (!raw) return "relative" as const + if (raw.startsWith("~")) return "tilde" as const + if (rootOf(raw)) return "absolute" as const + return "relative" as const +} + +export function tildeOf(absolute: string, home: string) { + const full = trimTrailing(absolute) + if (!home) return "" + + const hn = trimTrailing(home) + const lc = full.toLowerCase() + const hc = hn.toLowerCase() + if (lc === hc) return "~" + if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) + return "" +} + +export function displaySeparator(path: string, home: string) { + return getPathSeparator(home || path) +} + +function nativePath(path: string, home: string) { + if (displaySeparator(path, home) === "/") return path + return path.replaceAll("/", "\\") +} + +export function displayPath(path: string, input: string, home: string) { + const full = trimTrailing(path) + const value = modeOf(input) === "absolute" ? full : tildeOf(full, home) || full + return nativePath(value, home) +} diff --git a/packages/app/src/components/dialog-select-directory.test.ts b/packages/app/src/components/dialog-select-directory.test.ts new file mode 100644 index 000000000000..ec1e8abec639 --- /dev/null +++ b/packages/app/src/components/dialog-select-directory.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { displayPath, displaySeparator } from "./dialog-select-directory-path" + +describe("dialog select directory display", () => { + test("keeps posix paths looking posix", () => { + expect(displayPath("/Users/dev/repo", "", "/Users/dev")).toBe("~/repo") + expect(displaySeparator("~/repo", "/Users/dev")).toBe("/") + }) + + test("renders windows home paths with native separators", () => { + expect(displayPath("C:/Users/dev/repo", "", "C:\\Users\\dev")).toBe("~\\repo") + expect(displaySeparator("~\\repo", "C:\\Users\\dev")).toBe("\\") + }) + + test("renders absolute windows paths with backslashes", () => { + expect(displayPath("C:/Users/dev/repo", "C:\\", "C:\\Users\\dev")).toBe("C:\\Users\\dev\\repo") + expect(displayPath("//server/share/repo", "\\\\server\\", "C:\\Users\\dev")).toBe( + "\\\\server\\share\\repo", + ) + }) +}) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 91e23f8ffa5f..a45c7544099b 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -10,6 +10,17 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLayout } from "@/context/layout" import { useLanguage } from "@/context/language" +import { + displayPath, + displaySeparator, + joinPath, + modeOf, + normalizeDriveRoot, + parentOf, + rootOf, + tildeOf, + trimTrailing, +} from "./dialog-select-directory-path" interface DialogSelectDirectoryProps { title?: string @@ -28,80 +39,6 @@ function cleanInput(value: string) { return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() } -function normalizePath(input: string) { - const v = input.replaceAll("\\", "/") - if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") - return v.replace(/\/+/g, "/") -} - -function normalizeDriveRoot(input: string) { - const v = normalizePath(input) - if (/^[A-Za-z]:$/.test(v)) return v + "/" - return v -} - -function trimTrailing(input: string) { - const v = normalizeDriveRoot(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - return v.replace(/\/+$/, "") -} - -function joinPath(base: string | undefined, rel: string) { - const b = trimTrailing(base ?? "") - const r = trimTrailing(rel).replace(/^\/+/, "") - if (!b) return r - if (!r) return b - if (b.endsWith("/")) return b + r - return b + "/" + r -} - -function rootOf(input: string) { - const v = normalizeDriveRoot(input) - if (v.startsWith("//")) return "//" - if (v.startsWith("/")) return "/" - if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) - return "" -} - -function parentOf(input: string) { - const v = trimTrailing(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) - return v.slice(0, i) -} - -function modeOf(input: string) { - const raw = normalizeDriveRoot(input.trim()) - if (!raw) return "relative" as const - if (raw.startsWith("~")) return "tilde" as const - if (rootOf(raw)) return "absolute" as const - return "relative" as const -} - -function tildeOf(absolute: string, home: string) { - const full = trimTrailing(absolute) - if (!home) return "" - - const hn = trimTrailing(home) - const lc = full.toLowerCase() - const hc = hn.toLowerCase() - if (lc === hc) return "~" - if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) - return "" -} - -function displayPath(path: string, input: string, home: string) { - const full = trimTrailing(path) - if (modeOf(input) === "absolute") return full - return tildeOf(full, home) || full -} function toRow(absolute: string, home: string, group: Row["group"]): Row { const full = trimTrailing(absolute) @@ -188,7 +125,7 @@ function useDirectorySearch(args: { if (!scopedInput) return [] as string[] const raw = normalizeDriveRoot(value) - const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") + const isPath = raw.startsWith("~") || !!rootOf(raw) || /[\\/]/.test(value) const query = normalizeDriveRoot(scopedInput.path) const find = () => @@ -349,7 +286,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { e.stopPropagation() const value = displayPath(item.absolute, filter(), home()) - list?.setFilter(value.endsWith("/") ? value : value + "/") + const sep = displaySeparator(value, home()) + list?.setFilter(/[\\/]$/.test(value) ? value : value + sep) }} onSelect={(path) => { if (!path) return @@ -358,6 +296,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { > {(item) => { const path = displayPath(item.absolute, filter(), home()) + const dir = getDirectory(path) + const sep = displaySeparator(path, home()) if (path === "~") { return (
@@ -365,7 +305,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
~ - / + {sep}
@@ -377,10 +317,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
- {getDirectory(path)} + {dir} {getFilename(path)} - / + {sep}
diff --git a/packages/ui/src/components/file-media.tsx b/packages/ui/src/components/file-media.tsx index 2fd54588a3c0..463167c4e1a2 100644 --- a/packages/ui/src/components/file-media.tsx +++ b/packages/ui/src/components/file-media.tsx @@ -1,4 +1,5 @@ import type { FileContent } from "@opencode-ai/sdk/v2" +import { getFilename } from "@opencode-ai/util/path" import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js" import { useI18n } from "../context/i18n" import { @@ -248,7 +249,7 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
- {cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")} + {getFilename(cfg()?.path) || i18n.t("ui.fileMedia.binary.title")}
{(() => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e8c9dcf9505b..e77c2f2336cf 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -45,7 +45,7 @@ import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" -import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory as _getDirectory, getFilename, getPathSeparator } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" @@ -197,12 +197,22 @@ function relativizeProjectPath(path: string, directory?: string) { if (!directory) return path if (directory === "/") return path if (directory === "\\") return path - if (path === directory) return "" - const separator = directory.includes("\\") ? "\\" : "/" - const prefix = directory.endsWith(separator) ? directory : directory + separator - if (!path.startsWith(prefix)) return path - return path.slice(directory.length) + const separator = getPathSeparator(path || directory) + const trailing = /[\\/]+$/.test(path) + const full = path.replace(/[\\/]+/g, "/").replace(/\/+$/, "") + const root = directory.replace(/[\\/]+/g, "/").replace(/\/+$/, "") + if (!root) return path + if (full === root) return trailing ? separator : "" + + const prefix = root + "/" + if (!full.startsWith(prefix)) return path + + const rel = full.slice(root.length).replace(/^\/+/, "") + if (!rel) return trailing ? separator : "" + + const result = separator + rel.replaceAll("/", separator) + return trailing ? result + separator : result } function getDirectory(path: string | undefined) { @@ -1172,6 +1182,7 @@ export const ToolRegistry = { function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) { const value = createMemo(() => props.path || "tool-file") + const dir = createMemo(() => getDirectory(props.path)) return (
- - {`\u202A${getDirectory(props.path)}\u202C`} + + {`\u202A${dir()}\u202C`} {getFilename(props.path)}
@@ -1783,7 +1794,7 @@ ToolRegistry.register({ {filename()}
- +
{getDirectory(props.input.filePath!)}
@@ -1855,7 +1866,7 @@ ToolRegistry.register({ {filename()}
- +
{getDirectory(props.input.filePath!)}
@@ -1976,7 +1987,7 @@ ToolRegistry.register({
- + {`\u202A${getDirectory(file.relativePath)}\u202C`} {getFilename(file.relativePath)} @@ -2046,7 +2057,7 @@ ToolRegistry.register({ {getFilename(single()!.relativePath)}
- +
{getDirectory(single()!.relativePath)}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 83d2980f61a2..da3c614740ec 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -287,6 +287,7 @@ export const SessionReview = (props: SessionReviewProps) => { let wrapper: HTMLDivElement | undefined const item = createMemo(() => diffs().get(file)!) + const dir = createMemo(() => getDirectory(file)) const expanded = createMemo(() => open().includes(file)) const force = () => !!store.force[file] @@ -404,8 +405,8 @@ export const SessionReview = (props: SessionReviewProps) => {
- - {`\u202A${getDirectory(file)}\u202C`} + + {`\u202A${dir()}\u202C`} {getFilename(file)} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 8c9c1ffe4030..0ec3f676ebf4 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -459,6 +459,7 @@ export function SessionTurn( {(diff) => { const active = createMemo(() => expanded().includes(diff.file)) + const dir = createMemo(() => getDirectory(diff.file)) const [visible, setVisible] = createSignal(false) createEffect( @@ -485,10 +486,8 @@ export function SessionTurn(
- - - {`\u202A${getDirectory(diff.file)}\u202C`} - + + {`\u202A${dir()}\u202C`} {getFilename(diff.file)} diff --git a/packages/util/src/path.test.ts b/packages/util/src/path.test.ts new file mode 100644 index 000000000000..8302f153d166 --- /dev/null +++ b/packages/util/src/path.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { getDirectory, getFilename, getPathSeparator } from "./path" + +describe("path display helpers", () => { + test("keeps posix separators in displayed directories", () => { + expect(getDirectory("src/components/app.tsx")).toBe("src/components/") + expect(getDirectory("/tmp/demo/app.ts")).toBe("/tmp/demo/") + }) + + test("keeps windows separators in displayed directories", () => { + expect(getDirectory("src\\components\\app.tsx")).toBe("src\\components\\") + expect(getDirectory("C:\\repo\\src\\app.tsx")).toBe("C:\\repo\\src\\") + expect(getDirectory("\\\\server\\share\\repo\\app.tsx")).toBe("\\\\server\\share\\repo\\") + }) + + test("extracts filenames across separator styles", () => { + expect(getFilename("src/components/app.tsx")).toBe("app.tsx") + expect(getFilename("src\\components\\app.tsx")).toBe("app.tsx") + }) + + test("infers native-looking separators for windows paths", () => { + expect(getPathSeparator("/tmp/demo")).toBe("/") + expect(getPathSeparator("C:/repo/src/app.tsx")).toBe("\\") + expect(getPathSeparator("\\\\server\\share\\repo")).toBe("\\") + }) +}) diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index 07317d42c79a..0adbf29bdf33 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -11,6 +11,12 @@ const isUncPath = (path: string) => /^[\\/]{2}[^\\/]/.test(path) const isWindowsDrivePath = (path: string) => /^[A-Za-z]:([\\/]|$)/.test(path) +export function getPathSeparator(path: string | undefined) { + if (!path) return "/" + if (path.includes("\\") || isWindowsDrivePath(path) || isUncPath(path)) return "\\" + return "/" +} + export function pathKey(path: string) { if (!path) return "" @@ -38,8 +44,9 @@ export function pathEqual(a: string | undefined, b: string | undefined) { export function getDirectory(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[\/\\]+$/, "") - const parts = trimmed.split(/[\/\\]/) - return parts.slice(0, parts.length - 1).join("/") + "/" + const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) + if (idx < 0) return "" + return trimmed.slice(0, idx + 1) || getPathSeparator(path) } export function getFileExtension(path: string | undefined) { From 0e0691bcc000a84fdec987cf1c3f6bd5105f98ee Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:35:30 +1000 Subject: [PATCH 07/42] fix(path): enforce prompt and patch permissions --- packages/opencode/src/session/prompt.ts | 28 ++-- packages/opencode/src/tool/apply_patch.ts | 6 +- .../opencode/src/tool/external-directory.ts | 3 - packages/opencode/src/tool/read.ts | 5 +- packages/opencode/test/session/prompt.test.ts | 134 +++++++++++++++++- .../opencode/test/tool/apply_patch.test.ts | 1 + .../test/tool/external-directory.test.ts | 18 --- 7 files changed, 159 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 36162656aa3c..dedc8c3b991a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -162,7 +162,7 @@ export namespace SessionPrompt { const session = await Session.get(input.sessionID) await SessionRevert.cleanup(session) - const message = await createUserMessage(input) + const message = await createUserMessage(input, session) await Session.touch(input.sessionID) // this is backwards compatibility for allowing `tools` to be specified when @@ -962,8 +962,9 @@ export namespace SessionPrompt { }) } - async function createUserMessage(input: PromptInput) { + async function createUserMessage(input: PromptInput, session: Session.Info) { const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) + const ruleset = PermissionNext.merge(agent.permission, session.permission ?? []) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const full = @@ -1151,16 +1152,20 @@ export namespace SessionPrompt { await ReadTool.init() .then(async (t) => { - const model = await Provider.getModel(info.model.providerID, info.model.modelID) const readCtx: Tool.Context = { sessionID: input.sessionID, abort: new AbortController().signal, - agent: input.agent!, + agent: agent.name, messageID: info.id, - extra: { bypassCwdCheck: true, model }, messages: [], metadata: async () => {}, - ask: async () => {}, + ask: async (req) => { + await PermissionNext.ask({ + ...req, + sessionID: input.sessionID, + ruleset, + }) + }, } const result = await t.execute(args, readCtx) pieces.push({ @@ -1214,12 +1219,17 @@ export namespace SessionPrompt { const listCtx: Tool.Context = { sessionID: input.sessionID, abort: new AbortController().signal, - agent: input.agent!, + agent: agent.name, messageID: info.id, - extra: { bypassCwdCheck: true }, messages: [], metadata: async () => {}, - ask: async () => {}, + ask: async (req) => { + await PermissionNext.ask({ + ...req, + sessionID: input.sessionID, + ruleset, + }) + }, } const result = await ReadTool.init().then((t) => t.execute(args, listCtx)) return [ diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 06293b6eba6e..e59b37478eb9 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -172,7 +172,11 @@ export const ApplyPatchTool = Tool.define("apply_patch", { })) // Check permissions if needed - const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/")) + const relativePaths = [...new Set(fileChanges.flatMap((change) => { + const items = [change.filePath] + if (change.movePath) items.push(change.movePath) + return items.map((item) => path.relative(Instance.worktree, item).replaceAll("\\", "/")) + }))] await ctx.ask({ permission: "edit", patterns: relativePaths, diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 5d8885b2ad46..9e46a9f8471d 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -5,15 +5,12 @@ import { Instance } from "../project/instance" type Kind = "file" | "directory" type Options = { - bypass?: boolean kind?: Kind } export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) { if (!target) return - if (options?.bypass) return - if (Instance.containsPath(target)) return const kind = options?.kind ?? "file" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 85be8f9d394d..0e9e529780c5 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -37,10 +37,7 @@ export const ReadTool = Tool.define("read", { const stat = Filesystem.stat(filepath) - await assertExternalDirectory(ctx, filepath, { - bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), - kind: stat?.isDirectory() ? "directory" : "file", - }) + await assertExternalDirectory(ctx, filepath, { kind: stat?.isDirectory() ? "directory" : "file" }) await ctx.ask({ permission: "read", diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3986271dab96..0a23608a7ab7 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,6 +1,8 @@ import path from "path" import { describe, expect, test } from "bun:test" -import { fileURLToPath } from "url" +import { fileURLToPath, pathToFileURL } from "url" +import { Bus } from "../../src/bus" +import { PermissionNext } from "../../src/permission" import { Instance } from "../../src/project/instance" import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" @@ -148,6 +150,136 @@ describe("session.prompt special characters", () => { }) }) +describe("session.prompt local file permissions", () => { + test("asks for read permission before synthetic file reads", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "note.txt"), "classified\n") + }, + config: { + agent: { + build: { + model: "openai/gpt-5.2", + permission: { + read: { + "*": "ask", + }, + }, + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const seen: PermissionNext.Request[] = [] + const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => { + seen.push(event.properties) + void PermissionNext.reply({ + requestID: event.properties.id, + reply: "once", + }) + }) + + try { + const msg = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [ + { + type: "file", + mime: "text/plain", + url: pathToFileURL(path.join(tmp.path, "note.txt")).href, + filename: "note.txt", + }, + ], + }) + + if (msg.info.role !== "user") throw new Error("expected user message") + + const req = seen.find((item) => item.permission === "read") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([path.join(tmp.path, "note.txt")]) + expect(msg.parts.some((part) => part.type === "text" && part.text.includes("classified"))).toBe(true) + } finally { + unsub() + await Session.remove(session.id) + } + }, + }) + }) + + test("asks for external_directory permission before synthetic directory reads", async () => { + await using outer = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "shared", "a.txt"), "a\n") + }, + }) + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + build: { + model: "openai/gpt-5.2", + permission: { + external_directory: { + "*": "ask", + }, + read: "allow", + }, + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const seen: PermissionNext.Request[] = [] + const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => { + seen.push(event.properties) + void PermissionNext.reply({ + requestID: event.properties.id, + reply: "once", + }) + }) + + try { + const dir = path.join(outer.path, "shared") + const msg = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [ + { + type: "file", + mime: "application/x-directory", + url: pathToFileURL(dir).href, + filename: "shared", + }, + ], + }) + + if (msg.info.role !== "user") throw new Error("expected user message") + + const req = seen.find((item) => item.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([path.join(dir, "*").replaceAll("\\", "/")]) + expect(msg.parts.some((part) => part.type === "text" && part.text.includes("a.txt"))).toBe(true) + } finally { + unsub() + await Session.remove(session.id) + } + }, + }) + }) +}) + describe("session.prompt agent variant", () => { test("applies agent variant only when using agent model", async () => { const prev = process.env.OPENAI_API_KEY diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 4e276517f104..b014a7793ab4 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -145,6 +145,7 @@ describe("tool.apply_patch freeform", () => { expect(calls.length).toBe(1) const permissionCall = calls[0] + expect(permissionCall.patterns).toEqual(["old/name.txt", "renamed/dir/name.txt"]) expect(permissionCall.metadata.files).toHaveLength(1) const moveFile = permissionCall.metadata.files[0] diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 229901a7228f..cb8a4b78be4f 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -107,22 +107,4 @@ describe("tool.assertExternalDirectory", () => { expect(req!.always).toEqual([expected]) }) - test("skips prompting when bypass=true", async () => { - const requests: Array> = [] - const ctx: Tool.Context = { - ...baseCtx, - ask: async (req) => { - requests.push(req) - }, - } - - await Instance.provide({ - directory: "/tmp/project", - fn: async () => { - await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) - }, - }) - - expect(requests.length).toBe(0) - }) }) From 5411832018d1f4e2ad181ebb2ce01e3b851cf536 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:39:59 +1000 Subject: [PATCH 08/42] fix(path): follow physical targets for tool bounds --- .../opencode/src/tool/external-directory.ts | 35 ++++++- .../test/tool/external-directory.test.ts | 98 ++++++++++++++++++- packages/opencode/test/tool/read.test.ts | 37 +++++++ packages/opencode/test/tool/write.test.ts | 41 ++++++++ 4 files changed, 207 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 9e46a9f8471d..5d580afe1601 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,3 +1,4 @@ +import * as fs from "fs/promises" import path from "path" import type { Tool } from "./tool" import { Instance } from "../project/instance" @@ -8,13 +9,43 @@ type Options = { kind?: Kind } +function within(parent: string, child: string) { + const rel = path.relative(parent, child) + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) +} + +async function real(input: string) { + const hit = await fs.realpath(input).catch(() => undefined) + if (hit) return hit + + const rest: string[] = [] + let dir = input + + while (true) { + const parent = path.dirname(dir) + if (parent === dir) return input + rest.unshift(path.basename(dir)) + const next = await fs.realpath(parent).catch(() => undefined) + if (next) return path.join(next, ...rest) + dir = parent + } +} + export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) { if (!target) return - if (Instance.containsPath(target)) return + const file = await real(target) + const dir = await real(Instance.directory) + + if (within(dir, file)) return + + if (Instance.worktree !== "/") { + const root = await real(Instance.worktree) + if (within(root, file)) return + } const kind = options?.kind ?? "file" - const parentDir = kind === "directory" ? target : path.dirname(target) + const parentDir = kind === "directory" ? file : path.dirname(file) const glob = path.join(parentDir, "*").replaceAll("\\", "/") await ctx.ask({ diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index cb8a4b78be4f..2ed687589bd0 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test" +import fs from "fs/promises" import path from "path" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import type { PermissionNext } from "../../src/permission" +import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { @@ -16,6 +18,10 @@ const baseCtx: Omit = { metadata: () => {}, } +async function link(target: string, alias: string) { + await fs.symlink(target, alias, process.platform === "win32" ? "junction" : "dir") +} + describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { const requests: Array> = [] @@ -66,7 +72,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside/file.txt" - const expected = path.join(path.dirname(target), "*").replaceAll("\\", "/") + const expected = path.join(path.resolve(path.dirname(target)), "*").replaceAll("\\", "/") await Instance.provide({ directory, @@ -92,7 +98,7 @@ describe("tool.assertExternalDirectory", () => { const directory = "/tmp/project" const target = "/tmp/outside" - const expected = path.join(target, "*").replaceAll("\\", "/") + const expected = path.join(path.resolve(target), "*").replaceAll("\\", "/") await Instance.provide({ directory, @@ -107,4 +113,92 @@ describe("tool.assertExternalDirectory", () => { expect(req!.always).toEqual([expected]) }) + test("asks for lexically internal paths that resolve outside via symlink", async () => { + await using outer = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "secret.txt"), "secret") + }, + }) + await using tmp = await tmpdir({ + init: async (dir) => { + await link(outer.path, path.join(dir, "link")) + }, + }) + + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await assertExternalDirectory(ctx, path.join(tmp.path, "link", "secret.txt")) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([path.join(outer.path, "*").replaceAll("\\", "/")]) + }) + + test("uses physical parent for missing file paths through symlink", async () => { + await using outer = await tmpdir() + await using tmp = await tmpdir({ + init: async (dir) => { + await link(outer.path, path.join(dir, "link")) + }, + }) + + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await assertExternalDirectory(ctx, path.join(tmp.path, "link", "new.txt")) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([path.join(outer.path, "*").replaceAll("\\", "/")]) + }) + + test("uses physical target for missing directories through symlink", async () => { + await using outer = await tmpdir() + await using tmp = await tmpdir({ + init: async (dir) => { + await link(outer.path, path.join(dir, "link")) + }, + }) + + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await assertExternalDirectory(ctx, path.join(tmp.path, "link", "newdir"), { kind: "directory" }) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([path.join(outer.path, "newdir", "*").replaceAll("\\", "/")]) + }) + }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index cfeb597fcecd..5b9cc65e455e 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import fs from "fs/promises" import path from "path" import { ReadTool } from "../../src/tool/read" import { Instance } from "../../src/project/instance" @@ -21,6 +22,10 @@ const ctx = { ask: async () => {}, } +async function link(target: string, alias: string) { + await fs.symlink(target, alias, process.platform === "win32" ? "junction" : "dir") +} + describe("tool.read external_directory permission", () => { test("allows reading absolute path inside project directory", async () => { await using tmp = await tmpdir({ @@ -151,6 +156,38 @@ describe("tool.read external_directory permission", () => { }, }) }) + + test("asks for external_directory permission when reading through symlink escape", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "secret.txt"), "secret data") + }, + }) + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await link(outerTmp.path, path.join(dir, "link")) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + const result = await read.execute({ filePath: path.join(tmp.path, "link", "secret.txt") }, testCtx) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(result.output).toContain("secret data") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "*").replaceAll("\\", "/")) + }, + }) + }) }) describe("tool.read env file permissions", () => { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index af002a39100d..1bac8d8076bf 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +import type { PermissionNext } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" const ctx = { @@ -17,6 +18,10 @@ const ctx = { ask: async () => {}, } +async function link(target: string, alias: string) { + await fs.symlink(target, alias, process.platform === "win32" ? "junction" : "dir") +} + describe("tool.write", () => { describe("new file creation", () => { test("writes content to new file", async () => { @@ -86,6 +91,42 @@ describe("tool.write", () => { }, }) }) + + test("asks for external_directory permission when writing through symlink escape", async () => { + await using outerTmp = await tmpdir() + await using tmp = await tmpdir({ + init: async (dir) => { + await link(outerTmp.path, path.join(dir, "link")) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const write = await WriteTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + + await write.execute( + { + filePath: path.join(tmp.path, "link", "new.txt"), + content: "escaped", + }, + testCtx, + ) + + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "*").replaceAll("\\", "/")) + expect(await fs.readFile(path.join(outerTmp.path, "new.txt"), "utf-8")).toBe("escaped") + }, + }) + }) }) describe("existing file overwrite", () => { From 5e259e55242a055b4be4d6cea91b22dead7c1d21 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:47:04 +1000 Subject: [PATCH 09/42] tui: format native paths consistently --- .../cli/cmd/tui/component/dialog-status.tsx | 26 +----- .../src/cli/cmd/tui/context/directory.ts | 5 +- .../src/cli/cmd/tui/routes/session/index.tsx | 25 ++---- .../cli/cmd/tui/routes/session/permission.tsx | 21 ++--- .../cli/cmd/tui/routes/session/sidebar.tsx | 18 ++-- .../opencode/src/cli/cmd/tui/util/path.ts | 85 +++++++++++++++++++ packages/opencode/test/cli/tui/path.test.ts | 56 ++++++++++++ 7 files changed, 174 insertions(+), 62 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/path.ts create mode 100644 packages/opencode/test/cli/tui/path.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 3b6b5ef21827..ab163607e9f8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -1,9 +1,10 @@ import { TextAttributes } from "@opentui/core" -import { fileURLToPath } from "bun" import { useTheme } from "../context/theme" import { useDialog } from "@tui/ui/dialog" import { useSync } from "@tui/context/sync" import { For, Match, Switch, Show, createMemo } from "solid-js" +import { Global } from "@/global" +import { formatPath, plugin } from "../util/path" export type DialogStatusProps = {} @@ -16,26 +17,7 @@ export function DialogStatus() { const plugins = createMemo(() => { const list = sync.data.config.plugin ?? [] - const result = list.map((value) => { - if (value.startsWith("file://")) { - const path = fileURLToPath(value) - const parts = path.split("/") - const filename = parts.pop() || path - if (!filename.includes(".")) return { name: filename } - const basename = filename.split(".")[0] - if (basename === "index") { - const dirname = parts.pop() - const name = dirname || basename - return { name } - } - return { name: basename } - } - const index = value.lastIndexOf("@") - if (index <= 0) return { name: value, version: "latest" } - const name = value.substring(0, index) - const version = value.substring(index + 1) - return { name, version } - }) + const result = list.map((value) => plugin(value)) return result.toSorted((a, b) => a.name.localeCompare(b.name)) }) @@ -110,7 +92,7 @@ export function DialogStatus() { • - {item.id} {item.root} + {item.id} {formatPath(item.root, { home: Global.Path.home })} )} diff --git a/packages/opencode/src/cli/cmd/tui/context/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts index 17e5c180a19a..71ebcb67b743 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -1,12 +1,15 @@ import { createMemo } from "solid-js" import { useSync } from "./sync" import { Global } from "@/global" +import { formatPath } from "../util/path" export function useDirectory() { const sync = useSync() return createMemo(() => { const directory = sync.data.path.directory || process.cwd() - const result = directory.replace(Global.Path.home, "~") + const result = formatPath(directory, { + home: Global.Path.home, + }) if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch return result }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 7456742cdf36..1247c8f0bc28 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -81,6 +81,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { formatPath } from "../../util/path" addDefaultParsers(parsers.parsers) @@ -1794,11 +1795,9 @@ function Bash(props: ToolProps) { const absolute = path.resolve(base, workdir) if (absolute === base) return undefined - const home = Global.Path.home - if (!home) return absolute - - const match = absolute === home || absolute.startsWith(home + path.sep) - return match ? absolute.replace(home, "~") : absolute + return formatPath(absolute, { + home: Global.Path.home, + }) }) const title = createMemo(() => { @@ -2250,17 +2249,11 @@ function Diagnostics(props: { diagnostics?: Record[] } function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use absolute - return absolute + return formatPath(input, { + cwd: process.cwd(), + home: Global.Path.home, + relative: true, + }) } function input(input: Record, omit?: string[]): string { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a50cd96fc843..8c39bab7b99f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -16,25 +16,16 @@ import { Locale } from "@/util/locale" import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { useTuiConfig } from "../../context/tui-config" +import { formatPath } from "../../util/path" type PermissionStage = "permission" | "always" | "reject" function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const home = Global.Path.home - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use ~ or absolute - if (home && (absolute === home || absolute.startsWith(home + path.sep))) { - return absolute.replace(home, "~") - } - return absolute + return formatPath(input, { + cwd: process.cwd(), + home: Global.Path.home, + relative: true, + }) } function filetype(input?: string) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 42ac5fbe080a..0bf14f628fcc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -2,15 +2,12 @@ import { useSync } from "@tui/context/sync" import { createMemo, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" -import { Locale } from "@/util/locale" -import path from "path" import type { AssistantMessage } from "@opencode-ai/sdk/v2" import { Global } from "@/global" import { Installation } from "@/installation" -import { useKeybind } from "../../context/keybind" -import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { formatPath, splitPath } from "../../util/path" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -60,8 +57,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { } }) - const directory = useDirectory() const kv = useKV() + const dir = createMemo(() => { + return formatPath(sync.data.path.directory || process.cwd(), { + home: Global.Path.home, + }) + }) + const dirparts = createMemo(() => splitPath(dir())) const hasProviders = createMemo(() => sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), @@ -203,7 +205,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { • - {item.id} {item.root} + {item.id} {formatPath(item.root, { home: Global.Path.home })} )} @@ -304,8 +306,8 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {directory().split("/").slice(0, -1).join("/")}/ - {directory().split("/").at(-1)} + {dirparts().dir} + {dirparts().base || dir()} Open diff --git a/packages/opencode/src/cli/cmd/tui/util/path.ts b/packages/opencode/src/cli/cmd/tui/util/path.ts new file mode 100644 index 000000000000..012a4d606ada --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/path.ts @@ -0,0 +1,85 @@ +import path from "path" +import { Path } from "@/path/path" + +type Opts = { + cwd?: string + home?: string + platform?: NodeJS.Platform + relative?: boolean +} + +function pf(opts: Opts = {}) { + return opts.platform ?? process.platform +} + +function lib(platform: NodeJS.Platform) { + return platform === "win32" ? path.win32 : path.posix +} + +function pretty(input: string, opts: Opts = {}) { + return String(Path.pretty(input, { cwd: opts.cwd, platform: pf(opts) })) +} + +function inside(parent: string, child: string, platform: NodeJS.Platform) { + const mod = lib(platform) + const rel = mod.relative(parent, child) + if (!rel) return true + if (mod.isAbsolute(rel)) return false + return !rel.startsWith("..") +} + +export function formatPath(input?: string, opts: Opts = {}) { + if (!input) return "" + + const platform = pf(opts) + const mod = lib(platform) + const text = pretty(input, opts) + if (opts.relative) { + const cwd = pretty(opts.cwd ?? process.cwd(), { platform }) + if (inside(cwd, text, platform)) return mod.relative(cwd, text) || "." + } + + const home = opts.home ? pretty(opts.home, { platform }) : undefined + if (!home) return text + if (text === home) return "~" + if (!inside(home, text, platform)) return text + + return `~${mod.sep}${mod.relative(home, text)}` +} + +export function splitPath(input: string, opts: Pick = {}) { + if (!input) return { dir: "", base: "" } + + const mod = lib(pf(opts)) + const parsed = mod.parse(input) + if (!parsed.base) return { dir: input, base: "" } + if (!parsed.dir) return { dir: "", base: parsed.base } + + return { + dir: parsed.dir.endsWith(mod.sep) ? parsed.dir : parsed.dir + mod.sep, + base: parsed.base, + } +} + +export function plugin(input: string, opts: Pick = {}) { + if (!input.startsWith("file://")) { + const idx = input.lastIndexOf("@") + if (idx <= 0) return { name: input, version: "latest" } + return { + name: input.substring(0, idx), + version: input.substring(idx + 1), + } + } + + const platform = pf(opts) + const mod = lib(platform) + const text = String(Path.fromURI(input, { platform })) + const file = mod.basename(text) + const ext = mod.extname(file) + if (!ext) return { name: file } + + const stem = file.slice(0, -ext.length) + if (stem !== "index") return { name: stem } + + return { name: mod.basename(mod.dirname(text)) || stem } +} diff --git a/packages/opencode/test/cli/tui/path.test.ts b/packages/opencode/test/cli/tui/path.test.ts new file mode 100644 index 000000000000..315fb8cebd77 --- /dev/null +++ b/packages/opencode/test/cli/tui/path.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import { formatPath, plugin, splitPath } from "../../../src/cli/cmd/tui/util/path" + +describe("tui path", () => { + test("formats Windows absolute paths without a leading slash", () => { + expect( + formatPath("/C:/Users/me/code/opencode", { + platform: "win32", + }), + ).toBe("C:\\Users\\me\\code\\opencode") + }) + + test("keeps cwd-relative paths relative before shortening home", () => { + expect( + formatPath("C:\\Users\\me\\code\\opencode\\src\\file.ts", { + cwd: "C:\\Users\\me\\code\\opencode", + home: "C:\\Users\\me", + platform: "win32", + relative: true, + }), + ).toBe("src\\file.ts") + }) + + test("shortens home with directory boundaries", () => { + expect( + formatPath("C:\\Users\\me\\code\\opencode", { + home: "C:\\Users\\me", + platform: "win32", + }), + ).toBe("~\\code\\opencode") + + expect( + formatPath("C:\\Users\\meow\\code\\opencode", { + home: "C:\\Users\\me", + platform: "win32", + }), + ).toBe("C:\\Users\\meow\\code\\opencode") + }) + + test("splits display paths with native separators", () => { + expect(splitPath("C:\\Users\\me\\code\\opencode", { platform: "win32" })).toEqual({ + dir: "C:\\Users\\me\\code\\", + base: "opencode", + }) + }) + + test("extracts plugin names from file uris safely", () => { + expect(plugin("file:///C:/Users/me/.config/opencode/plugins/demo/index.ts", { platform: "win32" })).toEqual({ + name: "demo", + }) + + expect(plugin("file:///C:/Users/me/.config/opencode/plugins/local.ts", { platform: "win32" })).toEqual({ + name: "local", + }) + }) +}) From b1b6e854228443758b9a140d2a99245756e1fa10 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:49:49 +1000 Subject: [PATCH 10/42] fix(path): block file route symlink escapes --- packages/opencode/src/file/index.ts | 36 ++++++++- .../opencode/test/file/path-traversal.test.ts | 74 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index cee03e0915a6..ac4ae3e45ee1 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -6,7 +6,6 @@ import fs from "fs" import ignore from "ignore" import { Log } from "../util/log" import { Filesystem } from "../util/filesystem" -import { Instance } from "../project/instance" import { Ripgrep } from "./ripgrep" import fuzzysort from "fuzzysort" import { Global } from "../global" @@ -268,6 +267,28 @@ function shouldEncode(mimeType: string): boolean { return false } +function within(parent: string, child: string) { + const rel = path.relative(parent, child) + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) +} + +async function real(input: string) { + const hit = await fs.promises.realpath(input).catch(() => undefined) + if (hit) return hit + + const rest: string[] = [] + let dir = input + + while (true) { + const parent = path.dirname(dir) + if (parent === dir) return input + rest.unshift(path.basename(dir)) + const next = await fs.promises.realpath(parent).catch(() => undefined) + if (next) return path.join(next, ...rest) + dir = parent + } +} + export namespace File { export const Info = z .object({ @@ -376,12 +397,21 @@ export class FileService extends ServiceMap.Service | undefined + const allow = async (input: string) => { + const file = await real(input) + if (within(await root, file)) return true + if (!worktree) return false + return within(await worktree, file) + } + const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" function kick() { @@ -557,7 +587,7 @@ export class FileService extends ServiceMap.Service { test("allows paths within project", () => { expect(Filesystem.contains("/project", "/project/src")).toBe(true) @@ -84,6 +88,56 @@ describe("File.read path traversal protection", () => { }, }) }) + + test("returns empty content for missing paths within project", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.read("missing.txt")).resolves.toMatchObject({ + type: "text", + content: "", + }) + }, + }) + }) + + test("rejects symlink escape for existing files", async () => { + await using outer = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "secret.txt"), "secret") + }, + }) + await using tmp = await tmpdir({ + init: async (dir) => { + await link(outer.path, path.join(dir, "link")) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.read("link/secret.txt")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) + + test("rejects symlink escape for missing files", async () => { + await using outer = await tmpdir() + await using tmp = await tmpdir({ + init: async (dir) => { + await link(outer.path, path.join(dir, "link")) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.read("link/missing.txt")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) }) describe("File.list path traversal protection", () => { @@ -113,6 +167,26 @@ describe("File.list path traversal protection", () => { }, }) }) + + test("rejects symlink escape for directory listings", async () => { + await using outer = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "nested"), { recursive: true }) + }, + }) + await using tmp = await tmpdir({ + init: async (dir) => { + await link(outer.path, path.join(dir, "link")) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.list("link/nested")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) }) describe("Instance.containsPath", () => { From 16074836dfeb731a098429e3d43e1c20b2c269b6 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:54:22 +1000 Subject: [PATCH 11/42] fix(path): parse ACP file uris safely --- packages/opencode/src/acp/agent.ts | 20 +-- .../opencode/test/acp/prompt-file-uri.test.ts | 169 ++++++++++++++++++ 2 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/acp/prompt-file-uri.test.ts diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2a6bbbb1e444..0b0495ee9c33 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -29,7 +29,8 @@ import { } from "@agentclientprotocol/sdk" import { Log } from "../util/log" -import { pathToFileURL } from "url" +import { win32 } from "path" +import { fileURLToPath, pathToFileURL } from "url" import { Filesystem } from "../util/filesystem" import { Hash } from "../util/hash" import { ACPSessionManager } from "./session" @@ -1594,24 +1595,23 @@ export namespace ACP { ): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } { try { if (uri.startsWith("file://")) { - const path = uri.slice(7) - const name = path.split("/").pop() || path + const url = new URL(uri) + const file = fileURLToPath(url) return { type: "file", - url: uri, - filename: name, + url: url.href, + filename: win32.basename(file), mime: "text/plain", } } if (uri.startsWith("zed://")) { const url = new URL(uri) - const path = url.searchParams.get("path") - if (path) { - const name = path.split("/").pop() || path + const file = url.searchParams.get("path") + if (file) { return { type: "file", - url: pathToFileURL(path).href, - filename: name, + url: pathToFileURL(file).href, + filename: win32.basename(file), mime: "text/plain", } } diff --git a/packages/opencode/test/acp/prompt-file-uri.test.ts b/packages/opencode/test/acp/prompt-file-uri.test.ts new file mode 100644 index 000000000000..842fcf87acec --- /dev/null +++ b/packages/opencode/test/acp/prompt-file-uri.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test" +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import { pathToFileURL } from "url" + +import { ACP } from "../../src/acp/agent" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +function createStream() { + const wait: Array<() => void> = [] + + return { + stream: async function* (signal?: AbortSignal) { + await new Promise((resolve) => { + if (signal?.aborted) return resolve() + wait.push(resolve) + signal?.addEventListener("abort", () => resolve(), { once: true }) + }) + }, + close: () => { + for (const resolve of wait.splice(0)) resolve() + }, + } +} + +function createAgent() { + const seen: any[] = [] + const connection = { + async sessionUpdate() {}, + async requestPermission() { + return { outcome: { outcome: "selected", optionId: "once" } } + }, + } as unknown as AgentSideConnection + + const events = createStream() + const sdk = { + global: { + event: async (opts?: { signal?: AbortSignal }) => ({ + stream: events.stream(opts?.signal), + }), + }, + session: { + create: async () => ({ + data: { + id: "ses_1", + time: { created: new Date().toISOString() }, + }, + }), + messages: async () => ({ data: [] }), + prompt: async (input: any) => { + seen.push(input) + return { data: {} } + }, + }, + config: { + providers: async () => ({ + data: { + providers: [ + { + id: "opencode", + name: "opencode", + models: { + "big-pickle": { id: "big-pickle", name: "big-pickle" }, + }, + }, + ], + }, + }), + }, + app: { + agents: async () => ({ + data: [ + { + name: "build", + description: "build", + mode: "agent", + }, + ], + }), + }, + command: { + list: async () => ({ data: [] }), + }, + mcp: { + add: async () => ({ data: true }), + }, + } as any + + const agent = new ACP.Agent(connection, { + sdk, + defaultModel: { providerID: "opencode", modelID: "big-pickle" }, + } as any) + + return { + agent, + seen, + stop: () => { + events.close() + ;(agent as any).eventAbort.abort() + }, + } +} + +describe("acp.agent prompt file uris", () => { + test("parses Windows file resource links with fileURLToPath", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, seen, stop } = createAgent() + try { + const cwd = tmp.path + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + await agent.prompt({ + sessionId, + prompt: [{ type: "resource_link", uri: "file:///C:/Users/me/My%20Docs/hello%20world.txt" }], + } as any) + + expect(seen[0].parts).toEqual([ + { + type: "file", + url: "file:///C:/Users/me/My%20Docs/hello%20world.txt", + filename: "hello world.txt", + mime: "text/plain", + }, + ]) + } finally { + stop() + } + }, + }) + }) + + test("keeps zed resource links mapped to local file URLs on Windows", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, seen, stop } = createAgent() + try { + const cwd = tmp.path + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const file = "C:\\Users\\me\\My Docs\\hello world.txt" + + await agent.prompt({ + sessionId, + prompt: [{ type: "resource_link", uri: `zed://file?path=${encodeURIComponent(file)}` }], + } as any) + + expect(seen[0].parts).toEqual([ + { + type: "file", + url: pathToFileURL(file).href, + filename: "hello world.txt", + mime: "text/plain", + }, + ]) + } finally { + stop() + } + }, + }) + }) +}) From 954f38437fa221432a69d0e1c343daa884020529 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:00:09 +1000 Subject: [PATCH 12/42] app: render native file paths from tab state --- .../app/src/components/dialog-select-file.tsx | 16 ++++++++-------- packages/app/src/components/prompt-input.tsx | 9 +++++++-- .../components/prompt-input/context-items.tsx | 10 +++++++--- .../components/prompt-input/slash-popover.tsx | 4 ++-- packages/app/src/context/file.tsx | 1 + packages/app/src/context/file/path.test.ts | 11 +++++++++++ packages/app/src/context/file/path.ts | 9 +++++++++ packages/app/src/pages/session/file-tabs.tsx | 7 ++++++- 8 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index b4dfb1d0ae16..e58c16fed843 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -70,10 +70,10 @@ const createCommandEntry = (option: CommandOption, category: string): Entry => ( option, }) -const createFileEntry = (path: string, category: string): Entry => ({ +const createFileEntry = (path: string, title: string, category: string): Entry => ({ id: "file:" + path, type: "file", - title: path, + title, category, path, }) @@ -153,7 +153,7 @@ function createFileEntries(props: { if (!path) continue if (seen.has(path)) continue seen.add(path) - items.push(createFileEntry(path, category)) + items.push(createFileEntry(path, props.file.display(path), category)) } return items.slice(0, ENTRY_LIMIT) @@ -166,7 +166,7 @@ function createFileEntries(props: { .filter((node) => node.type === "file") .map((node) => node.path) .sort((a, b) => a.localeCompare(b)) - return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category)) + return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, props.file.display(path), category)) }) return { recent, root } @@ -331,12 +331,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil if (filesOnly()) { const files = await file.searchFiles(query) const category = language.t("palette.group.files") - return files.map((path) => createFileEntry(path, category)) + return files.map((path) => createFileEntry(path, file.display(path), category)) } const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) const category = language.t("palette.group.files") - const entries = files.map((path) => createFileEntry(path, category)) + const entries = files.map((path) => createFileEntry(path, file.display(path), category)) return [...commandEntries.list(), ...nextSessions, ...entries] } @@ -410,9 +410,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
- {getDirectory(item.path ?? "")} + {getDirectory(item.title)} - {getFilename(item.path ?? "")} + {getFilename(item.title)}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5c25235c65c1..b3952f805f5f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -571,11 +571,16 @@ export const PromptInput: Component = (props) => { const agents = agentList() const open = recent() const seen = new Set(open) - const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + const pinned: AtOption[] = open.map((path) => ({ + type: "file", + path, + display: files.display(path), + recent: true, + })) const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) - .map((path) => ({ type: "file", path, display: path })) + .map((path) => ({ type: "file", path, display: files.display(path) })) return [...agents, ...pinned, ...fileOptions] }, key: atKey, diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index b138fe3ef690..0529f5273aa0 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -3,6 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import { useFile } from "@/context/file" import type { ContextItem } from "@/context/prompt" type PromptContextItem = ContextItem & { key: string } @@ -16,14 +17,17 @@ type ContextItemsProps = { } export const PromptContextItems: Component = (props) => { + const file = useFile() + return ( 0}>
{(item) => { - const directory = getDirectory(item.path) - const filename = getFilename(item.path) - const label = getFilenameTruncated(item.path, 14) + const path = file.display(item.path) + const directory = getDirectory(path) + const filename = getFilename(path) + const label = getFilenameTruncated(path, 14) const selected = props.active(item) return ( diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 65eb01c797b8..4fd22ab62e78 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -70,8 +70,8 @@ export const PromptPopover: Component = (props) => { } const isDirectory = item.path.endsWith("/") - const directory = isDirectory ? item.path : getDirectory(item.path) - const filename = isDirectory ? "" : getFilename(item.path) + const directory = isDirectory ? item.display : getDirectory(item.display) + const filename = isDirectory ? "" : getFilename(item.display) return ( @@ -281,18 +284,18 @@ export const SessionReview = (props: SessionReviewProps) => {
- + {(file) => { let wrapper: HTMLDivElement | undefined - const item = createMemo(() => diffs().get(file)!) + const item = createMemo(() => diffs().get(key(file))!) const dir = createMemo(() => getDirectory(file)) - const expanded = createMemo(() => open().includes(file)) + const expanded = createMemo(() => open().some((item) => pathEqual(item, file))) const force = () => !!store.force[file] - const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file)) + const comments = createMemo(() => (props.comments ?? []).filter((c) => pathEqual(c.file, file))) const commentedLines = createMemo(() => comments().map((c) => c.selection)) const beforeText = () => (typeof item().before === "string" ? item().before : "") @@ -314,13 +317,13 @@ export const SessionReview = (props: SessionReviewProps) => { const selectedLines = createMemo(() => { const current = selection() - if (!current || current.file !== file) return null + if (!current || !pathEqual(current.file, file)) return null return current.range }) const draftRange = createMemo(() => { const current = commenting() - if (!current || current.file !== file) return null + if (!current || !pathEqual(current.file, file)) return null return current.range }) @@ -331,7 +334,7 @@ export const SessionReview = (props: SessionReviewProps) => { state: { opened: () => { const current = opened() - if (!current || current.file !== file) return null + if (!current || !pathEqual(current.file, file)) return null return current.id }, setOpened: (id) => setStore("opened", id ? { file, id } : null), @@ -378,7 +381,7 @@ export const SessionReview = (props: SessionReviewProps) => { }) onCleanup(() => { - anchors.delete(file) + anchors.delete(key(file)) }) const handleLineSelected = (range: SelectedLineRange | null) => { @@ -397,7 +400,7 @@ export const SessionReview = (props: SessionReviewProps) => { id={diffId(file)} data-file={file} data-slot="session-review-accordion-item" - data-selected={props.focusedFile === file ? "" : undefined} + data-selected={props.focusedFile && pathEqual(props.focusedFile, file) ? "" : undefined} > @@ -462,7 +465,7 @@ export const SessionReview = (props: SessionReviewProps) => { data-slot="session-review-diff-wrapper" ref={(el) => { wrapper = el - anchors.set(file, el) + anchors.set(key(file), el) }} > diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 0ec3f676ebf4..11092f2caa5e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -4,7 +4,7 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { Binary } from "@opencode-ai/util/binary" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename, pathEqual, pathKey } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -87,6 +87,8 @@ function list(value: T[] | undefined | null, fallback: T[]) { const hidden = new Set(["todowrite", "todoread"]) +const key = (path: string) => pathKey(path) || path + function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { if (hidden.has(part.tool)) return @@ -233,8 +235,9 @@ export function SessionTurn( const seen = new Set() return files .reduceRight((result, diff) => { - if (seen.has(diff.file)) return result - seen.add(diff.file) + const id = key(diff.file) + if (seen.has(id)) return result + seen.add(id) result.push(diff) return result }, []) @@ -247,6 +250,11 @@ export function SessionTurn( }) const open = () => state.open const expanded = () => state.expanded + const visible = createMemo(() => + diffs() + .map((diff) => diff.file) + .filter((file) => expanded().some((item) => pathEqual(item, file))), + ) createEffect( on( @@ -451,14 +459,14 @@ export function SessionTurn( setState("expanded", Array.isArray(value) ? value : value ? [value] : []) } > {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) + const active = createMemo(() => expanded().some((item) => pathEqual(item, diff.file))) const dir = createMemo(() => getDirectory(diff.file)) const [visible, setVisible] = createSignal(false) diff --git a/packages/util/src/path.test.ts b/packages/util/src/path.test.ts index 8302f153d166..5a73fac2dfe3 100644 --- a/packages/util/src/path.test.ts +++ b/packages/util/src/path.test.ts @@ -1,5 +1,23 @@ import { describe, expect, test } from "bun:test" -import { getDirectory, getFilename, getPathSeparator } from "./path" +import { + decodeFilePath, + encodeFilePath, + getDirectory, + getFilename, + getParentPath, + getPathDisplay, + getPathDisplaySeparator, + getPathRoot, + getPathScope, + getPathSearchText, + getPathSeparator, + getRelativeDisplayPath, + getWorkspaceRelativePath, + resolveWorkspacePath, + stripFileProtocol, + stripQueryAndHash, + unquoteGitPath, +} from "./path" describe("path display helpers", () => { test("keeps posix separators in displayed directories", () => { @@ -23,4 +41,70 @@ describe("path display helpers", () => { expect(getPathSeparator("C:/repo/src/app.tsx")).toBe("\\") expect(getPathSeparator("\\\\server\\share\\repo")).toBe("\\") }) + + test("keeps UNC roots stable for lexical navigation", () => { + expect(getPathRoot("\\\\server\\share\\repo")).toBe("//server/share") + expect(getPathRoot("\\\\server\\share")).toBe("//server/share") + expect(getParentPath("//server/share/repo")).toBe("//server/share") + expect(getParentPath("\\\\server\\share")).toBe("//server/share") + }) + + test("builds picker display text with tilde and native separators", () => { + expect(getPathDisplay("/Users/dev/repo", "", "/Users/dev")).toBe("~/repo") + expect(getPathDisplaySeparator("~/repo", "/Users/dev")).toBe("/") + expect(getPathDisplay("C:/Users/dev/repo", "", "C:\\Users\\dev")).toBe("~\\repo") + expect(getPathDisplay("//server/share/repo", "\\\\server\\", "C:\\Users\\dev")).toBe( + "\\\\server\\share\\repo", + ) + }) + + test("scopes picker input from home or absolute roots", () => { + expect(getPathScope("\\\\server\\share\\repo", "C:/Users/dev", "C:/Users/dev")).toEqual({ + directory: "//server/share", + path: "repo", + }) + expect(getPathScope("~/code", "C:/Users/dev", "C:/Users/dev")).toEqual({ + directory: "C:/Users/dev", + path: "code", + }) + }) + + test("indexes search text in absolute, native, and filename forms", () => { + const search = getPathSearchText("//server/share/repo", "C:\\Users\\dev") + expect(search).toContain("//server/share/repo") + expect(search).toContain("\\\\server\\share\\repo") + expect(search).toContain("repo") + }) + + test("relativizes display paths from the workspace root", () => { + expect(getRelativeDisplayPath("/repo/src/app.ts", "/repo")).toBe("/src/app.ts") + expect(getRelativeDisplayPath("C:\\repo\\src\\", "C:\\repo")).toBe("\\src\\") + expect(getRelativeDisplayPath("/other/app.ts", "/repo")).toBe("/other/app.ts") + }) + + test("resolves and relativizes workspace paths across platforms", () => { + expect(resolveWorkspacePath("/repo", "src/app.ts")).toBe("/repo/src/app.ts") + expect(resolveWorkspacePath("C:\\repo", "src\\app.ts")).toBe("C:\\repo\\src\\app.ts") + expect(resolveWorkspacePath("/repo", "/tmp/app.ts")).toBe("/tmp/app.ts") + + expect(getWorkspaceRelativePath("/repo/src/app.ts", "/repo")).toBe("src/app.ts") + expect(getWorkspaceRelativePath("C:/repo/src/app.ts", "C:\\repo")).toBe("src/app.ts") + expect(getWorkspaceRelativePath("c:\\repo\\src\\app.ts", "C:\\repo")).toBe("src\\app.ts") + expect(getWorkspaceRelativePath("/tmp/app.ts", "/repo")).toBe("/tmp/app.ts") + }) + + test("handles shared file-uri and git path decoding", () => { + expect(stripFileProtocol("file:///repo/src/app.ts")).toBe("/repo/src/app.ts") + expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") + expect(decodeFilePath("src/file%23name%20here.ts")).toBe("src/file#name here.ts") + expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt") + expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname") + }) + + test("encodes file paths for file URIs", () => { + expect(encodeFilePath("/path/to/file#name.txt")).toBe("/path/to/file%23name.txt") + expect(encodeFilePath("C:\\Users\\test\\file with spaces.txt")).toBe("/C:/Users/test/file%20with%20spaces.txt") + expect(encodeFilePath("src\\app.ts")).toBe("src/app.ts") + }) }) diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index 0adbf29bdf33..41925432812b 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -11,12 +11,189 @@ const isUncPath = (path: string) => /^[\\/]{2}[^\\/]/.test(path) const isWindowsDrivePath = (path: string) => /^[A-Za-z]:([\\/]|$)/.test(path) +const normalizeDrive = (path: string) => { + const normalized = normalizePath(path) + if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}/` + return normalized +} + +const trimPath = (path: string) => { + const normalized = normalizeDrive(path) + if (normalized === "/" || normalized === "//") return normalized + if (/^[A-Za-z]:\/$/.test(normalized)) return normalized + return normalized.replace(/\/+$/, "") +} + +const mode = (path: string) => { + const normalized = normalizeDrive(path.trim()) + if (!normalized) return "relative" as const + if (normalized.startsWith("~")) return "tilde" as const + if (getPathRoot(normalized)) return "absolute" as const + return "relative" as const +} + +const fold = (path: string) => { + const normalized = normalizePath(path) + if (isWindowsDrivePath(normalized) || isUncPath(normalized)) return normalized.toLowerCase() + return normalized +} + +const native = (path: string, home: string) => { + if (getPathDisplaySeparator(path, home) === "/") return path + return path.replaceAll("/", "\\") +} + +const trailing = (path: string, home: string) => { + if (!path) return "" + const separator = getPathDisplaySeparator(path, home) + if (path.endsWith(separator)) return path + return path + separator +} + +export function normalizePath(path: string) { + if (!path) return "" + if (isUncPath(path)) return `//${path.slice(2).replace(/[\\/]+/g, "/")}` + return normalizeSlashes(path) +} + +export function stripFileProtocol(input: string) { + if (!input.startsWith("file://")) return input + return input.slice("file://".length) +} + +export function stripQueryAndHash(input: string) { + const hash = input.indexOf("#") + const query = input.indexOf("?") + + if (hash !== -1 && query !== -1) { + return input.slice(0, Math.min(hash, query)) + } + + if (hash !== -1) return input.slice(0, hash) + if (query !== -1) return input.slice(0, query) + return input +} + +export function unquoteGitPath(input: string) { + if (!input.startsWith('"')) return input + if (!input.endsWith('"')) return input + const body = input.slice(1, -1) + const bytes: number[] = [] + + for (let i = 0; i < body.length; i++) { + const char = body[i]! + if (char !== "\\") { + bytes.push(char.charCodeAt(0)) + continue + } + + const next = body[i + 1] + if (!next) { + bytes.push("\\".charCodeAt(0)) + continue + } + + if (next >= "0" && next <= "7") { + const chunk = body.slice(i + 1, i + 4) + const match = chunk.match(/^[0-7]{1,3}/) + if (!match) { + bytes.push(next.charCodeAt(0)) + i++ + continue + } + bytes.push(parseInt(match[0], 8)) + i += match[0].length + continue + } + + const escaped = + next === "n" + ? "\n" + : next === "r" + ? "\r" + : next === "t" + ? "\t" + : next === "b" + ? "\b" + : next === "f" + ? "\f" + : next === "v" + ? "\v" + : next === "\\" || next === '"' + ? next + : undefined + + bytes.push((escaped ?? next).charCodeAt(0)) + i++ + } + + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +export function decodeFilePath(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export function resolveWorkspacePath(root: string, path: string) { + if (!path) return path + if (getPathRoot(path)) return path + if (!root) return path + + const base = root.replace(/[\\/]+$/, "") + if (base) return `${base}${getPathSeparator(root)}${path}` + + const prefix = trimPath(root) + if (!prefix) return path + if (prefix.endsWith("/")) return prefix + path + return `${prefix}/${path}` +} + +export function getWorkspaceRelativePath(path: string, root: string) { + if (!root) return path + + const base = trimPath(root) + if (!base) return path + + const windows = isWindowsDrivePath(base) || isUncPath(base) + const canonRoot = windows ? base.replace(/\\/g, "/").toLowerCase() : base.replace(/\\/g, "/") + const canonPath = windows ? path.replace(/\\/g, "/").toLowerCase() : path.replace(/\\/g, "/") + + if (!canonPath.startsWith(canonRoot)) return path + if (!canonRoot.endsWith("/")) { + const next = canonPath[canonRoot.length] + if (next && next !== "/") return path + } + + return path.slice(base.length).replace(/^[\\/]+/, "") +} + export function getPathSeparator(path: string | undefined) { if (!path) return "/" if (path.includes("\\") || isWindowsDrivePath(path) || isUncPath(path)) return "\\" return "/" } +export function getPathRoot(path: string) { + const normalized = normalizeDrive(path) + if (normalized.startsWith("//")) { + const parts = normalized + .slice(2) + .replace(/^\/+/, "") + .split("/") + .filter(Boolean) + if (parts.length === 0) return "//" + if (parts.length === 1) return `//${parts[0]}` + return `//${parts[0]}/${parts[1]}` + } + if (normalized.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(normalized)) return normalized.slice(0, 3) + return "" +} + export function pathKey(path: string) { if (!path) return "" @@ -41,6 +218,123 @@ export function pathEqual(a: string | undefined, b: string | undefined) { return pathKey(a) === pathKey(b) } +export function getParentPath(path: string) { + if (!path) return "" + const normalized = trimPath(path) + if (normalized === "/" || normalized === "//") return normalized + if (/^[A-Za-z]:\/$/.test(normalized)) return normalized + + const root = getPathRoot(normalized) + if (root && normalized === root) return root + + const idx = normalized.lastIndexOf("/") + if (idx < 0) return root + if (idx === 0) return "/" + if (idx === 2 && /^[A-Za-z]:/.test(normalized)) return normalized.slice(0, 3) + + const parent = normalized.slice(0, idx) + if (root && parent.length < root.length) return root + return parent || root || "/" +} + +const getTildePath = (path: string, home: string) => { + const full = trimPath(path) + const base = trimPath(home) + if (!base) return "" + if (fold(full) === fold(base)) return "~" + if (fold(full).startsWith(fold(base + "/"))) return `~${full.slice(base.length)}` + return "" +} + +export function getPathDisplaySeparator(path: string, home: string) { + if (mode(path) === "absolute") return getPathSeparator(path) + return getPathSeparator(home || path) +} + +export function getPathDisplay(path: string, input: string, home: string) { + const full = trimPath(path) + const value = mode(input) === "absolute" ? full : getTildePath(full, home) || full + return native(value, home) +} + +export function getPathSearchText(path: string, home: string) { + const full = trimPath(path) + const tilde = getTildePath(full, home) + const absolute = native(full, home) + const shown = tilde ? native(tilde, home) : "" + + return Array.from( + new Set( + [ + full, + trailing(full, home), + absolute, + trailing(absolute, home), + tilde, + trailing(tilde, home), + shown, + trailing(shown, home), + getFilename(full), + ].filter(Boolean), + ), + ).join("\n") +} + +export function getPathScope(input: string, start: string | undefined, home: string) { + const base = start ? trimPath(start) : "" + if (!base) return + + const normalized = normalizeDrive(input) + if (!normalized) return { directory: base, path: "" } + if (normalized === "~") return { directory: trimPath(home || base), path: "" } + if (normalized.startsWith("~/")) return { directory: trimPath(home || base), path: normalized.slice(2) } + + const root = getPathRoot(normalized) + if (!root) return { directory: base, path: normalized } + return { + directory: trimPath(root), + path: normalized.slice(root.length).replace(/^\/+/, ""), + } +} + +export function getRelativeDisplayPath(path: string, root?: string) { + if (!path) return "" + if (!root) return path + if (root === "/" || root === "\\") return path + + const separator = getPathSeparator(path || root) + const trailing = /[\\/]+$/.test(path) + const full = normalizePath(path).replace(/\/+$/, "") + const base = normalizePath(root).replace(/\/+$/, "") + if (!base) return path + if (fold(full) === fold(base)) return trailing ? separator : "" + + const prefix = `${base}/` + if (!fold(full).startsWith(fold(prefix))) return path + + const relative = full.slice(base.length).replace(/^\/+/, "") + if (!relative) return trailing ? separator : "" + + const value = separator + relative.replaceAll("/", separator) + return trailing ? value + separator : value +} + +export function encodeFilePath(filepath: string): string { + let normalized = filepath.replace(/\\/g, "/") + + if (/^[A-Za-z]:/.test(normalized)) { + normalized = "/" + normalized + } + + return normalized + .split("/") + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment + return encodeURIComponent(segment) + }) + .join("/") +} + export function getDirectory(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[\/\\]+$/, "") From 22a9109295d27d9b6d4b2a74c42254e50e5a3d78 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:11:13 +1000 Subject: [PATCH 23/42] test(path): cover Windows permission aliases --- packages/opencode/src/path/path.ts | 17 ++++ packages/opencode/src/permission/index.ts | 11 ++- packages/opencode/src/permission/service.ts | 11 ++- packages/opencode/test/lib/windows-path.ts | 26 ++++++ packages/opencode/test/path/path.test.ts | 52 +++++++++++- .../opencode/test/permission/next.test.ts | 85 ++++++++++++++++++- packages/opencode/test/tool/edit.test.ts | 43 +++++++++- .../test/tool/external-directory.test.ts | 29 +++++++ packages/opencode/test/tool/read.test.ts | 31 +++++++ packages/opencode/test/tool/write.test.ts | 39 +++++++++ 10 files changed, 334 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/lib/windows-path.ts diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index dbd8047bf1db..0ef7da676c03 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -54,6 +54,16 @@ function raw(input: string, platform: NodeJS.Platform) { .replace(/^\/mnt\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) } +function winabs(input: string) { + return ( + input.startsWith("file://") || + /^[a-zA-Z]:/.test(input) || + /^\/([a-zA-Z]:|[a-zA-Z])(?:[\\/]|$)/.test(input) || + /^\/cygdrive\/[a-zA-Z](?:[\\/]|$)/.test(input) || + /^\/mnt\/[a-zA-Z](?:[\\/]|$)/.test(input) + ) +} + function base(input: string, platform: NodeJS.Platform) { const mod = lib(platform) const text = raw(input, platform) @@ -180,6 +190,13 @@ export namespace Path { return PosixPath.make(pretty(input, opts).replaceAll("\\", "/")) } + export function canonical(input: string, opts: Opts = {}) { + const platform = pf(opts) + if (platform !== "win32") return input + if (!winabs(input)) return input + return posix(input, opts) + } + export function expand(input: string, opts: HomeOpts = {}) { return expandText(input, opts) } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index fc2cdc1144a5..3586d450cbe7 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -10,6 +10,11 @@ export namespace PermissionNext { return Path.expand(pattern) } + function normalize(permission: string, pattern: string) { + if (permission !== "external_directory") return pattern + return Path.canonical(pattern) + } + export const Action = S.Action export type Action = S.Action export const Rule = S.Rule @@ -39,7 +44,11 @@ export namespace PermissionNext { continue } ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), + ...Object.entries(value).map(([pattern, action]) => ({ + permission: key, + pattern: normalize(key, expand(pattern)), + action, + })), ) } return ruleset diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts index 4335aa4cd835..9868393354ed 100644 --- a/packages/opencode/src/permission/service.ts +++ b/packages/opencode/src/permission/service.ts @@ -5,6 +5,7 @@ import { ProjectID } from "@/project/schema" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database, eq } from "@/storage/db" +import { Path } from "@/path/path" import { Log } from "@/util/log" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" @@ -120,11 +121,17 @@ export namespace PermissionEffect { deferred: Deferred.Deferred } + function normalize(permission: string, pattern: string) { + if (permission !== "external_directory") return pattern + return Path.canonical(pattern) + } + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { const rules = rulesets.flat() - log.info("evaluate", { permission, pattern, ruleset: rules }) + const text = normalize(permission, pattern) + log.info("evaluate", { permission, pattern: text, ruleset: rules }) const match = rules.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), + (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(text, normalize(permission, rule.pattern)), ) return match ?? { action: "ask", permission, pattern: "*" } } diff --git a/packages/opencode/test/lib/windows-path.ts b/packages/opencode/test/lib/windows-path.ts new file mode 100644 index 000000000000..55c9031bbef0 --- /dev/null +++ b/packages/opencode/test/lib/windows-path.ts @@ -0,0 +1,26 @@ +import { Path } from "../../src/path/path" + +type Item = { + name: string + path: string +} + +function glob(path: string) { + return `${path.replace(/[\\/]+$/, "")}${path.includes("\\") ? "\\*" : "/*"}` +} + +export function win(input: string) { + const path = String(Path.pretty(input, { platform: "win32" })) + const posix = String(Path.posix(path, { platform: "win32" })) + const drive = posix.match(/^([A-Z]):/)?.[1] + if (!drive) throw new Error(`Expected Windows path: ${input}`) + const rest = posix.slice(2) + const base: Item[] = [ + { name: "native", path }, + { name: "slash", path: posix }, + { name: "git", path: `/${drive.toLowerCase()}${rest}` }, + { name: "cygwin", path: `/cygdrive/${drive.toLowerCase()}${rest}` }, + { name: "wsl", path: `/mnt/${drive.toLowerCase()}${rest}` }, + ] + return base.map((item) => ({ ...item, glob: glob(item.path) })) +} diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index 29dfbedfc97b..7eeca3a58995 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -4,10 +4,11 @@ import path from "path" import { Path } from "../../src/path/path" import { tmpdir } from "../fixture/fixture" +import { win as alias } from "../lib/windows-path" describe("path", () => { describe("pretty()", () => { - const win = { cwd: "C:\\work", platform: "win32" as const } + const opts = { cwd: "C:\\work", platform: "win32" as const } for (const [name, input] of [ ["slash drive", "/c/tmp/file.txt"], @@ -17,13 +18,20 @@ describe("path", () => { ["file uri", "file:///C:/tmp/file.txt"], ]) { test(`normalizes ${name} on Windows`, () => { - expect(String(Path.pretty(input, win))).toBe("C:\\tmp\\file.txt") + expect(String(Path.pretty(input, opts))).toBe("C:\\tmp\\file.txt") }) } test("normalizes relative input to native absolute form", () => { expect(String(Path.pretty("src/../file.ts", { cwd: "/repo", platform: "linux" }))).toBe("/repo/file.ts") }) + + test("collapses Windows alias forms to the same pretty path", () => { + const file = "C:\\Users\\Dev\\tmp\\file.txt" + for (const item of alias(file)) { + expect(String(Path.pretty(item.path, { platform: "win32" }))).toBe(file) + } + }) }) describe("key()", () => { @@ -34,6 +42,16 @@ describe("path", () => { expect(Path.eq("C:\\Repo\\File.ts", "c:/repo/file.ts", { platform: "win32" })).toBe(true) expect(Path.match("C:\\Repo\\File.ts", b, { platform: "win32" })).toBe(true) }) + + test("matches all Windows alias forms", () => { + const [head, ...tail] = alias("C:\\Users\\Dev\\tmp\\file.txt") + const key = Path.key(head.path, { platform: "win32" }) + for (const item of tail) { + expect(Path.key(item.path, { platform: "win32" })).toBe(key) + expect(Path.eq(head.path, item.path, { platform: "win32" })).toBe(true) + expect(Path.match(item.path, key, { platform: "win32" })).toBe(true) + } + }) }) describe("contains()", () => { @@ -41,6 +59,16 @@ describe("path", () => { expect(Path.contains("C:\\Repo", "c:/repo/src/file.ts", { platform: "win32" })).toBe(true) }) + test("matches all Windows alias parent and child forms", () => { + const dirs = alias("C:\\Users\\Dev\\tmp") + const files = alias("C:\\Users\\Dev\\tmp\\file.txt") + for (const dir of dirs) { + for (const file of files) { + expect(Path.contains(dir.path, file.path, { platform: "win32" })).toBe(true) + } + } + }) + test("rejects absolute-relative path mixes", () => { expect(Path.contains("/repo", "repo/src/file.ts", { platform: "linux" })).toBe(false) expect(Path.contains("repo", "/repo/src/file.ts", { platform: "linux" })).toBe(false) @@ -51,6 +79,26 @@ describe("path", () => { test("normalizes Windows directory globs", () => { expect(Path.externalGlob("C:\\Users\\Dev\\tmp\\", { platform: "win32" })).toBe("C:/Users/Dev/tmp/*") }) + + test("canonicalizes all Windows alias roots", () => { + for (const item of alias("C:\\Users\\Dev\\tmp")) { + expect(Path.externalGlob(item.path, { platform: "win32" })).toBe("C:/Users/Dev/tmp/*") + } + }) + }) + + describe("canonical()", () => { + test("leaves non-path patterns alone", () => { + expect(Path.canonical("*", { platform: "win32" })).toBe("*") + expect(Path.canonical("src/*", { platform: "win32" })).toBe("src/*") + }) + + test("canonicalizes Windows alias paths to posix form", () => { + for (const item of alias("C:\\Users\\Dev\\tmp\\file.txt")) { + expect(Path.canonical(item.path, { platform: "win32" })).toBe("C:/Users/Dev/tmp/file.txt") + expect(Path.canonical(item.glob, { platform: "win32" })).toBe("C:/Users/Dev/tmp/file.txt/*") + } + }) }) describe("uri()", () => { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 413dfb488861..21a7f654cfed 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -8,8 +8,10 @@ import { PermissionNext } from "../../src/permission" import * as S from "../../src/permission/service" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" +import { Path } from "../../src/path/path" import { tmpdir } from "../fixture/fixture" import { MessageID, SessionID } from "../../src/session/schema" +import { win } from "../lib/windows-path" afterEach(async () => { await Instance.disposeAll() @@ -73,17 +75,17 @@ test("fromConfig - empty object", () => { test("fromConfig - expands tilde to home directory", () => { const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: path.join(dir, "*"), action: "allow" }]) + expect(result).toEqual([{ permission: "external_directory", pattern: Path.canonical(path.join(dir, "*")), action: "allow" }]) }) test("fromConfig - expands $HOME to home directory", () => { const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: path.join(dir, "*"), action: "allow" }]) + expect(result).toEqual([{ permission: "external_directory", pattern: Path.canonical(path.join(dir, "*")), action: "allow" }]) }) test("fromConfig - expands $HOME without trailing slash", () => { const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: home, action: "allow" }]) + expect(result).toEqual([{ permission: "external_directory", pattern: Path.canonical(home), action: "allow" }]) }) test("fromConfig - does not expand tilde in middle of path", () => { @@ -93,7 +95,7 @@ test("fromConfig - does not expand tilde in middle of path", () => { test("fromConfig - expands exact tilde to home directory", () => { const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } }) - expect(result).toEqual([{ permission: "external_directory", pattern: home, action: "allow" }]) + expect(result).toEqual([{ permission: "external_directory", pattern: Path.canonical(home), action: "allow" }]) }) test("evaluate - matches expanded tilde pattern", () => { @@ -108,6 +110,81 @@ test("evaluate - matches expanded $HOME pattern", () => { expect(result.action).toBe("allow") }) +test("fromConfig - canonicalizes Windows external_directory aliases", () => { + if (process.platform !== "win32") return + for (const item of win(dir)) { + const result = PermissionNext.fromConfig({ external_directory: { [item.glob]: "allow" } }) + expect(result).toEqual([{ permission: "external_directory", pattern: Path.externalGlob(dir), action: "allow" }]) + } +}) + +test("evaluate - matches Windows external_directory aliases across rule forms", () => { + if (process.platform !== "win32") return + const list = win(dir) + for (const rule of list) { + for (const item of list) { + const result = PermissionNext.evaluate("external_directory", item.glob, [ + { permission: "external_directory", pattern: rule.glob, action: "allow" }, + ]) + expect(result.action).toBe("allow") + } + } +}) + +test("evaluate - keeps Windows external_directory matching case-insensitive", () => { + if (process.platform !== "win32") return + const result = PermissionNext.evaluate("external_directory", "c:/users/test/projects/file.txt", [ + { permission: "external_directory", pattern: "C:/Users/Test/Projects/*", action: "allow" }, + ]) + expect(result.action).toBe("allow") +}) + +test("evaluate - keeps non-Windows external_directory matching case-sensitive", () => { + if (process.platform === "win32") return + const result = PermissionNext.evaluate("external_directory", "/users/test/projects/file.txt", [ + { permission: "external_directory", pattern: "/Users/Test/Projects/*", action: "allow" }, + ]) + expect(result.action).toBe("ask") +}) + +test("ask - auto-allow matches Windows external_directory aliases after always", async () => { + if (process.platform !== "win32") return + const [head, tail] = win(dir) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const first = PermissionNext.ask({ + id: PermissionID.make("per_alias1"), + sessionID: SessionID.make("session_alias"), + permission: "external_directory", + patterns: [head.glob], + metadata: {}, + always: [head.glob], + ruleset: [{ permission: "external_directory", pattern: "*", action: "ask" }], + }) + + await waitForPending(1) + await PermissionNext.reply({ + requestID: PermissionID.make("per_alias1"), + reply: "always", + }) + await expect(first).resolves.toBeUndefined() + + await expect( + PermissionNext.ask({ + sessionID: SessionID.make("session_alias"), + permission: "external_directory", + patterns: [tail.glob], + metadata: {}, + always: [], + ruleset: [], + }), + ).resolves.toBeUndefined() + }, + }) +}) + // merge tests test("merge - simple concatenation", () => { diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 7b6784cf49a7..5599ea0f9684 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -6,6 +6,8 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { FileTime } from "../../src/file/time" import { SessionID, MessageID } from "../../src/session/schema" +import type { PermissionNext } from "../../src/permission" +import { win } from "../lib/windows-path" const ctx = { sessionID: SessionID.make("ses_test-edit-session"), @@ -678,7 +680,46 @@ describe("tool.edit", () => { const results = await Promise.allSettled([promise1, promise2]) expect(results.some((r) => r.status === "fulfilled")).toBe(true) }, + }) + }) + }) + + test("canonicalizes external_directory permission across Windows aliases", async () => { + if (process.platform !== "win32") return + await using outerTmp = await tmpdir() + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await EditTool.init() + for (const item of win(path.join(outerTmp.path, "new.txt"))) { + const requests: Array> = [] + const stop = new Error("stop") + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + if (req.permission === "external_directory") throw stop + }, + } + + await expect( + edit.execute( + { + filePath: item.path, + oldString: "", + newString: "new content", + }, + testCtx, + ), + ).rejects.toBe(stop) + + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toEqual([path.join(outerTmp.path, "*").replaceAll("\\", "/")]) + } + }, }) }) }) -}) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index c103a5334c32..e5e018c19236 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -8,6 +8,7 @@ import { assertExternalDirectory } from "../../src/tool/external-directory" import type { PermissionNext } from "../../src/permission" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import { win } from "../lib/windows-path" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), @@ -150,6 +151,34 @@ describe("tool.assertExternalDirectory", () => { expect(req!.patterns).toEqual([Path.externalGlob(outer.path)]) }) + test("asks with the same canonical glob across Windows aliases", async () => { + if (process.platform !== "win32") return + await using outer = await tmpdir() + await using tmp = await tmpdir() + + for (const item of win(path.join(outer.path, "secret.txt"))) { + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await assertExternalDirectory(ctx, item.path) + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual([Path.externalGlob(outer.path)]) + expect(req!.always).toEqual([Path.externalGlob(outer.path)]) + } + }) + test("uses physical parent for missing file paths through symlink", async () => { await using outer = await tmpdir() await using tmp = await tmpdir({ diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 5b9cc65e455e..a9ad60a5cca7 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -8,6 +8,7 @@ import { tmpdir } from "../fixture/fixture" import { PermissionNext } from "../../src/permission" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" +import { win } from "../lib/windows-path" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -188,6 +189,36 @@ describe("tool.read external_directory permission", () => { }, }) }) + + test("canonicalizes external_directory permission across Windows aliases", async () => { + if (process.platform !== "win32") return + await using outerTmp = await tmpdir() + await using tmp = await tmpdir({ git: true }) + const expected = path.join(outerTmp.path, "*").replaceAll("\\", "/") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + for (const item of win(path.join(outerTmp.path, "secret.txt"))) { + const requests: Array> = [] + const stop = new Error("stop") + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + if (req.permission === "external_directory") throw stop + }, + } + + await expect(read.execute({ filePath: item.path }, testCtx)).rejects.toBe(stop) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toEqual([expected]) + } + }, + }) + }) }) describe("tool.read env file permissions", () => { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 1bac8d8076bf..b6be32e23786 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -6,6 +6,7 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import type { PermissionNext } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" +import { win } from "../lib/windows-path" const ctx = { sessionID: SessionID.make("ses_test-write-session"), @@ -127,6 +128,44 @@ describe("tool.write", () => { }, }) }) + + test("canonicalizes external_directory permission across Windows aliases", async () => { + if (process.platform !== "win32") return + await using outerTmp = await tmpdir() + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const write = await WriteTool.init() + for (const item of win(path.join(outerTmp.path, "new.txt"))) { + const requests: Array> = [] + const stop = new Error("stop") + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + if (req.permission === "external_directory") throw stop + }, + } + + await expect( + write.execute( + { + filePath: item.path, + content: "escaped", + }, + testCtx, + ), + ).rejects.toBe(stop) + + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toEqual([path.join(outerTmp.path, "*").replaceAll("\\", "/")]) + } + }, + }) + }) }) describe("existing file overwrite", () => { From 75873cc024ed963cd58ee41d06227626cf12daf0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:17:53 +1000 Subject: [PATCH 24/42] path: route tool inputs through shared helpers --- packages/opencode/src/tool/apply_patch.ts | 18 +++++---- packages/opencode/src/tool/edit.ts | 10 ++--- packages/opencode/src/tool/glob.ts | 9 ++--- packages/opencode/src/tool/grep.ts | 10 ++--- packages/opencode/src/tool/ls.ts | 5 ++- packages/opencode/src/tool/lsp.ts | 9 ++--- packages/opencode/src/tool/read.ts | 8 ++-- packages/opencode/src/tool/write.ts | 8 ++-- packages/opencode/test/tool/glob.test.ts | 48 ++++++++++++++++++++++ packages/opencode/test/tool/grep.test.ts | 29 ++++++++++++++ packages/opencode/test/tool/lsp.test.ts | 49 +++++++++++++++++++++++ 11 files changed, 164 insertions(+), 39 deletions(-) create mode 100644 packages/opencode/test/tool/glob.test.ts create mode 100644 packages/opencode/test/tool/lsp.test.ts diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index c4eb5cdbaa40..093629ac7360 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -12,6 +12,7 @@ import { trimDiff } from "./edit" import { LSP } from "../lsp" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" +import { Path } from "@/path/path" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -55,9 +56,10 @@ export const ApplyPatchTool = Tool.define("apply_patch", { }> = [] let totalDiff = "" + const rel = (input: string) => String(Path.rel(Instance.worktree, input)).replaceAll("\\", "/") for (const hunk of hunks) { - const filePath = path.resolve(Instance.directory, hunk.path) + const filePath = Path.pretty(hunk.path, { cwd: Instance.directory }) await assertExternalDirectory(ctx, filePath) switch (hunk.type) { @@ -115,7 +117,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { if (change.removed) deletions += change.count || 0 } - const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + const movePath = hunk.move_path ? Path.pretty(hunk.move_path, { cwd: Instance.directory }) : undefined await assertExternalDirectory(ctx, movePath) fileChanges.push({ @@ -160,7 +162,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { // Build per-file metadata for UI rendering (used for both permission and result) const files = fileChanges.map((change) => ({ filePath: change.filePath, - relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), + relativePath: rel(change.movePath ?? change.filePath), type: change.type, diff: change.diff, before: change.oldContent, @@ -174,7 +176,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const relativePaths = [...new Set(fileChanges.flatMap((change) => { const items = [change.filePath] if (change.movePath) items.push(change.movePath) - return items.map((item) => path.relative(Instance.worktree, item).replaceAll("\\", "/")) + return items.map(rel) }))] await ctx.ask({ permission: "edit", @@ -245,13 +247,13 @@ export const ApplyPatchTool = Tool.define("apply_patch", { // Generate output summary const summaryLines = fileChanges.map((change) => { if (change.type === "add") { - return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `A ${rel(change.filePath)}` } if (change.type === "delete") { - return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `D ${rel(change.filePath)}` } const target = change.movePath ?? change.filePath - return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}` + return `M ${rel(target)}` }) let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` @@ -266,7 +268,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const suffix = errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" - output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` + output += `\n\nLSP errors detected in ${rel(target)}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` } } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 672d8419551b..2a9dbb8c0974 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -4,7 +4,6 @@ // https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts import z from "zod" -import * as path from "path" import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" @@ -17,6 +16,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectory } from "./external-directory" +import { Path } from "@/path/path" const MAX_DIAGNOSTICS_PER_FILE = 20 @@ -50,7 +50,7 @@ export const EditTool = Tool.define("edit", { throw new Error("No changes to apply: oldString and newString are identical.") } - const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + const filePath = Path.pretty(params.filePath, { cwd: Instance.directory }) await assertExternalDirectory(ctx, filePath) let diff = "" @@ -63,7 +63,7 @@ export const EditTool = Tool.define("edit", { diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) await ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], + patterns: [String(Path.rel(Instance.worktree, filePath))], always: ["*"], metadata: { filepath: filePath, @@ -99,7 +99,7 @@ export const EditTool = Tool.define("edit", { ) await ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], + patterns: [String(Path.rel(Instance.worktree, filePath))], always: ["*"], metadata: { filepath: filePath, @@ -160,7 +160,7 @@ export const EditTool = Tool.define("edit", { diff, filediff, }, - title: `${path.relative(Instance.worktree, filePath)}`, + title: String(Path.rel(Instance.worktree, filePath)), output, } }, diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index a2611246c66f..08b16f866e8e 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -1,11 +1,11 @@ import z from "zod" -import path from "path" import { Tool } from "./tool" import { Filesystem } from "../util/filesystem" import DESCRIPTION from "./glob.txt" import { Ripgrep } from "../file/ripgrep" import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" +import { Path } from "@/path/path" export const GlobTool = Tool.define("glob", { description: DESCRIPTION, @@ -29,8 +29,7 @@ export const GlobTool = Tool.define("glob", { }, }) - let search = params.path ?? Instance.directory - search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + const search = Path.pretty(params.path ?? ".", { cwd: Instance.directory }) await assertExternalDirectory(ctx, search, { kind: "directory" }) const limit = 100 @@ -45,7 +44,7 @@ export const GlobTool = Tool.define("glob", { truncated = true break } - const full = path.resolve(search, file) + const full = Path.pretty(file, { cwd: search }) const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0 files.push({ path: full, @@ -67,7 +66,7 @@ export const GlobTool = Tool.define("glob", { } return { - title: path.relative(Instance.worktree, search), + title: String(Path.rel(Instance.worktree, search)), metadata: { count: files.length, truncated, diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 82e7ac1667e1..8ff47e61fbdc 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -7,8 +7,8 @@ import { Process } from "../util/process" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" -import path from "path" import { assertExternalDirectory } from "./external-directory" +import { Path } from "@/path/path" const MAX_LINE_LENGTH = 2000 @@ -35,8 +35,7 @@ export const GrepTool = Tool.define("grep", { }, }) - let searchPath = params.path ?? Instance.directory - searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) + const searchPath = Path.pretty(params.path ?? ".", { cwd: Instance.directory }) await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) const rgPath = await Ripgrep.filepath() @@ -90,11 +89,12 @@ export const GrepTool = Tool.define("grep", { const lineNum = parseInt(lineNumStr, 10) const lineText = lineTextParts.join("|") - const stats = Filesystem.stat(filePath) + const file = Path.pretty(filePath, { cwd: searchPath }) + const stats = Filesystem.stat(file) if (!stats) continue matches.push({ - path: filePath, + path: file, modTime: stats.mtime.getTime(), lineNum, lineText, diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index b848e969b74e..e3b896aba8f0 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -5,6 +5,7 @@ import DESCRIPTION from "./ls.txt" import { Instance } from "../project/instance" import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectory } from "./external-directory" +import { Path } from "@/path/path" export const IGNORE_PATTERNS = [ "node_modules/", @@ -42,7 +43,7 @@ export const ListTool = Tool.define("list", { ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(), }), async execute(params, ctx) { - const searchPath = path.resolve(Instance.directory, params.path || ".") + const searchPath = Path.pretty(params.path ?? ".", { cwd: Instance.directory }) await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) await ctx.ask({ @@ -110,7 +111,7 @@ export const ListTool = Tool.define("list", { const output = `${searchPath}/\n` + renderDir(".", 0) return { - title: path.relative(Instance.worktree, searchPath), + title: String(Path.rel(Instance.worktree, searchPath)), metadata: { count: files.length, truncated: files.length >= LIMIT, diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 52aef0f9e3f2..de59a1114a97 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -1,12 +1,11 @@ import z from "zod" import { Tool } from "./tool" -import path from "path" import { LSP } from "../lsp" import DESCRIPTION from "./lsp.txt" import { Instance } from "../project/instance" -import { pathToFileURL } from "url" import { assertExternalDirectory } from "./external-directory" import { Filesystem } from "../util/filesystem" +import { Path } from "@/path/path" const operations = [ "goToDefinition", @@ -29,7 +28,7 @@ export const LspTool = Tool.define("lsp", { character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"), }), execute: async (args, ctx) => { - const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath) + const file = Path.pretty(args.filePath, { cwd: Instance.directory }) await assertExternalDirectory(ctx, file) await ctx.ask({ @@ -38,14 +37,14 @@ export const LspTool = Tool.define("lsp", { always: ["*"], metadata: {}, }) - const uri = pathToFileURL(file).href + const uri = String(Path.uri(file)) const position = { file, line: args.line - 1, character: args.character - 1, } - const relPath = path.relative(Instance.worktree, file) + const relPath = String(Path.rel(Instance.worktree, file)) const title = `${args.operation} ${relPath}:${args.line}:${args.character}` const exists = await Filesystem.exists(file) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 0e9e529780c5..5896e047dbb7 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" import { Filesystem } from "../util/filesystem" +import { Path } from "@/path/path" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -29,11 +30,8 @@ export const ReadTool = Tool.define("read", { if (params.offset !== undefined && params.offset < 1) { throw new Error("offset must be greater than or equal to 1") } - let filepath = params.filePath - if (!path.isAbsolute(filepath)) { - filepath = path.resolve(Instance.directory, filepath) - } - const title = path.relative(Instance.worktree, filepath) + const filepath = Path.pretty(params.filePath, { cwd: Instance.directory }) + const title = String(Path.rel(Instance.worktree, filepath)) const stat = Filesystem.stat(filepath) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 92bda71ed37d..45761e5c8a00 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -1,5 +1,4 @@ import z from "zod" -import * as path from "path" import { Tool } from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch } from "diff" @@ -12,6 +11,7 @@ import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectory } from "./external-directory" +import { Path } from "@/path/path" const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -23,7 +23,7 @@ export const WriteTool = Tool.define("write", { filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), }), async execute(params, ctx) { - const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) + const filepath = Path.pretty(params.filePath, { cwd: Instance.directory }) await assertExternalDirectory(ctx, filepath) const exists = await Filesystem.exists(filepath) @@ -33,7 +33,7 @@ export const WriteTool = Tool.define("write", { const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) await ctx.ask({ permission: "edit", - patterns: [path.relative(Instance.worktree, filepath)], + patterns: [String(Path.rel(Instance.worktree, filepath))], always: ["*"], metadata: { filepath, @@ -72,7 +72,7 @@ export const WriteTool = Tool.define("write", { } return { - title: path.relative(Instance.worktree, filepath), + title: String(Path.rel(Instance.worktree, filepath)), metadata: { diagnostics, filepath, diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts new file mode 100644 index 000000000000..0d43fa3252d0 --- /dev/null +++ b/packages/opencode/test/tool/glob.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { GlobTool } from "../../src/tool/glob" +import { Instance } from "../../src/project/instance" +import { SessionID, MessageID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" +import { win } from "../lib/windows-path" + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.glob", () => { + test("accepts Windows alias directories and returns canonical matches", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "src", "tool.ts"), "export const ok = true\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const glob = await GlobTool.init() + for (const item of win(tmp.path)) { + const result = await glob.execute( + { + pattern: "**/*.ts", + path: item.path, + }, + ctx, + ) + + expect(result.metadata.count).toBe(1) + expect(result.output).toContain(path.join(tmp.path, "src", "tool.ts")) + } + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index e03b1752ec03..ccbc3bac70e9 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -4,6 +4,7 @@ import { GrepTool } from "../../src/tool/grep" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" +import { win } from "../lib/windows-path" const ctx = { sessionID: SessionID.make("ses_test"), @@ -84,6 +85,34 @@ describe("tool.grep", () => { }, }) }) + + test("accepts Windows alias directories and reports canonical file paths", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "nested", "hit.txt"), "hello alias") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const grep = await GrepTool.init() + for (const item of win(tmp.path)) { + const result = await grep.execute( + { + pattern: "hello", + path: item.path, + }, + ctx, + ) + + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(path.join(tmp.path, "nested", "hit.txt")) + } + }, + }) + }) }) describe("CRLF regex handling", () => { diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts new file mode 100644 index 000000000000..5455415afcbf --- /dev/null +++ b/packages/opencode/test/tool/lsp.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { LspTool } from "../../src/tool/lsp" +import { Instance } from "../../src/project/instance" +import { SessionID, MessageID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" +import { win } from "../lib/windows-path" + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.lsp", () => { + test("accepts Windows alias file paths before LSP availability checks", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "note.txt"), "hello\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const lsp = await LspTool.init() + for (const item of win(path.join(tmp.path, "note.txt"))) { + await expect( + lsp.execute( + { + operation: "hover", + filePath: item.path, + line: 1, + character: 1, + }, + ctx, + ), + ).rejects.toThrow("No LSP server available") + } + }, + }) + }) +}) From 5163481783f1db861382b3b50dbab614fdd2ab09 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:18:00 +1000 Subject: [PATCH 25/42] app: normalize watcher invalidation paths --- packages/app/src/context/file/watcher.test.ts | 73 +++++++++++++++++++ packages/app/src/context/file/watcher.ts | 20 +++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts index 9536b52536b6..936dcf6616f1 100644 --- a/packages/app/src/context/file/watcher.test.ts +++ b/packages/app/src/context/file/watcher.test.ts @@ -27,6 +27,32 @@ describe("file watcher invalidation", () => { expect(refresh).toEqual(["src"]) }) + test("matches loaded files and parents across slash variants", () => { + const loads: string[] = [] + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src\\new.ts", + event: "add", + }, + }, + { + normalize: (input) => input, + hasFile: (path) => path === "src/new.ts", + loadFile: (path) => loads.push(path), + node: () => undefined, + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(loads).toEqual(["src/new.ts"]) + expect(refresh).toEqual(["src"]) + }) + test("reloads files that are open in tabs", () => { const loads: string[] = [] @@ -106,6 +132,33 @@ describe("file watcher invalidation", () => { expect(refresh).toEqual(["src"]) }) + test("refreshes changed directories across slash variants", () => { + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src\\nested", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: (path) => + path === "src/nested" + ? { path: "src/nested", type: "directory", name: "nested", absolute: "/repo/src/nested", ignored: false } + : undefined, + isDirLoaded: (path) => path === "src/nested", + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(refresh).toEqual(["src/nested"]) + }) + test("ignores invalid or git watcher updates", () => { const refresh: string[] = [] @@ -129,6 +182,26 @@ describe("file watcher invalidation", () => { }, ) + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: ".git\\index.lock", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => true, + loadFile: () => { + throw new Error("should not load") + }, + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + invalidateFromWatcher( { type: "project.updated", diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index fbf71992791a..f5b07c3f09d3 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -1,4 +1,5 @@ import type { FileNode } from "@opencode-ai/sdk/v2" +import { getParentPath, pathKey } from "@opencode-ai/util/path" type WatcherEvent = { type: string @@ -26,18 +27,25 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { const path = ops.normalize(rawPath) if (!path) return - if (path.startsWith(".git/")) return + const key = pathKey(path) || path + if (key.startsWith(".git/")) return - if (ops.hasFile(path) || ops.isOpen?.(path)) { - ops.loadFile(path) + const file = (() => { + if (ops.hasFile(path) || ops.isOpen?.(path)) return path + if (key === path) return + if (ops.hasFile(key) || ops.isOpen?.(key)) return key + })() + + if (file) { + ops.loadFile(file) } if (kind === "change") { const dir = (() => { if (path === "") return "" - const node = ops.node(path) + const node = ops.node(path) ?? (key === path ? undefined : ops.node(key)) if (node?.type !== "directory") return - return path + return node.path })() if (dir === undefined) return if (!ops.isDirLoaded(dir)) return @@ -46,7 +54,7 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { } if (kind !== "add" && kind !== "unlink") return - const parent = path.split("/").slice(0, -1).join("/") + const parent = getParentPath(key) if (!ops.isDirLoaded(parent)) return ops.refreshDir(parent) From 21a396fe807212bae4db0826b7d62bc6a322b0f0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:44:20 +1000 Subject: [PATCH 26/42] path: normalize remaining entry boundaries --- packages/opencode/src/agent/agent.ts | 7 ++- packages/opencode/src/cli/cmd/run.ts | 9 ++-- packages/opencode/src/config/paths.ts | 5 +- packages/opencode/src/file/watcher.ts | 4 +- packages/opencode/src/path/path.ts | 5 ++ packages/opencode/src/session/instruction.ts | 19 +++---- packages/opencode/src/session/prompt.ts | 8 +-- packages/opencode/src/tool/bash.ts | 14 +++-- packages/opencode/test/agent/agent.test.ts | 9 ++++ packages/opencode/test/config/config.test.ts | 44 +++++++++++++++ packages/opencode/test/path/path.test.ts | 18 +++++++ .../opencode/test/session/instruction.test.ts | 54 +++++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 47 ++++++++++++++++ packages/opencode/test/tool/bash.test.ts | 42 +++++++++++++++ 14 files changed, 257 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b2dae0402ca3..d04658268f40 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -15,6 +15,7 @@ import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { PermissionNext } from "@/permission" +import { Path } from "@/path/path" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@/global" import path from "path" @@ -105,7 +106,7 @@ export namespace Agent { edit: { "*": "deny", [path.join(".opencode", "plans", "*.md")]: "allow", - [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + [String(Path.rel(Instance.worktree, path.join(Global.Path.data, "plans", "*.md")))]: "allow", }, }), user, @@ -232,13 +233,15 @@ export namespace Agent { item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) } + const trunc = Path.canonical(Truncate.GLOB) + // Ensure Truncate.GLOB is allowed unless explicitly configured for (const name in result) { const agent = result[name] const explicit = agent.permission.some((r) => { if (r.permission !== "external_directory") return false if (r.action !== "deny") return false - return r.pattern === Truncate.GLOB + return Path.canonical(r.pattern) === trunc }) if (explicit) continue diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 85b5689daa13..9da405de2ab6 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,6 +1,5 @@ import type { Argv } from "yargs" import path from "path" -import { pathToFileURL } from "url" import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" @@ -27,6 +26,7 @@ import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" +import { Path } from "@/path/path" type ToolProps = { input: Tool.InferParameters @@ -214,8 +214,7 @@ function todo(info: ToolProps) { function normalizePath(input?: string) { if (!input) return "" - if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." - return input + return Path.display(input, { cwd: process.cwd(), home: false, relative: true }) } export const RunCommand = cmd({ @@ -325,7 +324,7 @@ export const RunCommand = cmd({ const list = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) + const resolvedPath = Path.pretty(filePath, { cwd: process.cwd() }) if (!(await Filesystem.exists(resolvedPath))) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -335,7 +334,7 @@ export const RunCommand = cmd({ files.push({ type: "file", - url: pathToFileURL(resolvedPath).href, + url: String(Path.uri(resolvedPath)), filename: path.basename(resolvedPath), mime, }) diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index d2d381a07a38..34280e8ddd3b 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -78,7 +78,7 @@ export namespace ConfigPaths { } function dir(input: ParseSource) { - return typeof input === "string" ? path.dirname(input) : input.dir + return typeof input === "string" ? path.dirname(Path.pretty(input)) : input.dir } /** Apply {env:VAR} and {file:path} substitutions to config text. */ @@ -109,8 +109,7 @@ export namespace ConfigPaths { } const filePath = Path.expand(token.replace(/^\{file:/, "").replace(/\}$/, "")) - - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) + const resolvedPath = Path.pretty(filePath, { cwd: configDir }) const fileContent = ( await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => { if (missing === "empty") return "" diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 16ee8f27c84e..45793eac1b8b 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -6,7 +6,6 @@ import z from "zod" import { Log } from "../util/log" import { FileIgnore } from "./ignore" import { Config } from "../config/config" -import path from "path" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import { lazy } from "@/util/lazy" @@ -16,6 +15,7 @@ import { git } from "@/util/git" import { Protected } from "./protected" import { Flag } from "@/flag/flag" import { Cause, Effect, Layer, ServiceMap } from "effect" +import { Path } from "@/path/path" const SUBSCRIBE_TIMEOUT_MS = 10_000 @@ -128,7 +128,7 @@ export class FileWatcherService extends ServiceMap.Service readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index 0ef7da676c03..fdaa1ae85d8f 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -175,6 +175,11 @@ async function physicalAsync(input: string, opts: Opts = {}) { export namespace Path { export type Options = Opts + export function isAbsolute(input: string, opts: Omit = {}) { + const platform = pf(opts) + return lib(platform).isAbsolute(raw(input, platform)) + } + export function pretty(input: string, opts: Opts = {}) { return PrettyPath.make(prettyText(input, opts)) } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 4250579f5db0..e7c278edc4f9 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -20,9 +20,9 @@ const FILES = [ function globalFiles() { const files = [] if (Flag.OPENCODE_CONFIG_DIR) { - files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) + files.push(Path.pretty("AGENTS.md", { cwd: Flag.OPENCODE_CONFIG_DIR })) } - files.push(path.join(Global.Path.config, "AGENTS.md")) + files.push(Path.pretty("AGENTS.md", { cwd: Global.Path.config })) if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { files.push(Path.expand("~/.claude/CLAUDE.md")) } @@ -78,7 +78,7 @@ export namespace InstructionPrompt { const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree) if (matches.length > 0) { matches.forEach((p) => { - paths.add(path.resolve(p)) + paths.add(Path.pretty(p)) }) break } @@ -87,7 +87,7 @@ export namespace InstructionPrompt { for (const file of globalFiles()) { if (await Filesystem.exists(file)) { - paths.add(path.resolve(file)) + paths.add(Path.pretty(file)) break } } @@ -96,15 +96,16 @@ export namespace InstructionPrompt { for (let instruction of config.instructions) { if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue instruction = Path.expand(instruction) - const matches = path.isAbsolute(instruction) - ? await Glob.scan(path.basename(instruction), { - cwd: path.dirname(instruction), + const file = Path.isAbsolute(instruction) ? Path.pretty(instruction) : undefined + const matches = file + ? await Glob.scan(path.basename(file), { + cwd: path.dirname(file), absolute: true, include: "file", }).catch(() => []) : await resolveRelative(instruction) matches.forEach((p) => { - paths.add(path.resolve(p)) + paths.add(Path.pretty(p)) }) } } @@ -158,7 +159,7 @@ export namespace InstructionPrompt { export async function find(dir: string) { for (const file of FILES) { - const filepath = path.resolve(path.join(dir, file)) + const filepath = Path.pretty(file, { cwd: dir }) if (await Filesystem.exists(filepath)) return filepath } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a3a377328c66..a4803caf259c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -32,7 +32,7 @@ import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" import { $ } from "bun" -import { pathToFileURL, fileURLToPath } from "url" +import { fileURLToPath } from "url" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" @@ -202,7 +202,7 @@ export namespace SessionPrompt { if (seen.has(name)) return seen.add(name) const file = Path.expand(name) - const filepath = path.isAbsolute(file) ? file : path.resolve(Instance.worktree, name) + const filepath = Path.pretty(file, { cwd: Instance.worktree }) const stats = await fs.stat(filepath).catch(() => undefined) if (!stats) { @@ -219,7 +219,7 @@ export namespace SessionPrompt { if (stats.isDirectory()) { parts.push({ type: "file", - url: pathToFileURL(filepath).href, + url: String(Path.uri(filepath)), filename: name, mime: "application/x-directory", }) @@ -228,7 +228,7 @@ export namespace SessionPrompt { parts.push({ type: "file", - url: pathToFileURL(filepath).href, + url: String(Path.uri(filepath)), filename: name, mime: "text/plain", }) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index aa8e3ef348aa..0fa2eefb3c39 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -77,7 +77,15 @@ export const BashTool = Tool.define("bash", async () => { ), }), async execute(params, ctx) { - const cwd = params.workdir || Instance.directory + const root = Path.physical(Instance.directory) + const worktree = Instance.worktree === "/" ? undefined : Path.physical(Instance.worktree) + const inside = async (input: string) => { + const file = await Path.physical(input) + if (Path.contains(await root, file)) return true + if (!worktree) return false + return Path.contains(await worktree, file) + } + const cwd = await Path.physical(params.workdir || Instance.directory, { cwd: Instance.directory }) if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } @@ -87,7 +95,7 @@ export const BashTool = Tool.define("bash", async () => { throw new Error("Failed to parse command") } const dirs = new Set() - if (!Instance.containsPath(cwd)) dirs.add(cwd) + if (!(await inside(cwd))) dirs.add(cwd) const patterns = new Set() const always = new Set() @@ -119,7 +127,7 @@ export const BashTool = Tool.define("bash", async () => { if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue const resolved = await Path.physical(arg, { cwd }) log.info("resolved path", { arg, resolved }) - if (!Instance.containsPath(resolved)) { + if (!(await inside(resolved))) { const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved) dirs.add(dir) } diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index d6b6ebb33bc9..a159de579ff0 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -3,6 +3,8 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" +import { Global } from "../../src/global" +import { Path } from "../../src/path/path" import { PermissionNext } from "../../src/permission" // Helper to evaluate permission for a tool with wildcard pattern @@ -55,6 +57,13 @@ test("plan agent denies edits except .opencode/plans/*", async () => { expect(evalPerm(plan, "edit")).toBe("deny") // But specific path is allowed expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow") + expect( + PermissionNext.evaluate( + "edit", + String(Path.rel(Instance.worktree, path.join(Global.Path.data, "plans", "foo.md"))), + plan!.permission, + ).action, + ).toBe("allow") }, }) }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index bb86fe8b0b5c..ceb4aa46e9c6 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -11,6 +11,8 @@ import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util/filesystem" import { BunProc } from "../../src/bun" +import { Path } from "../../src/path/path" +import { win as alias } from "../lib/windows-path" // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! @@ -329,6 +331,48 @@ test("handles file inclusion with replacement tokens", async () => { }) }) +test("handles file inclusion via file URI reference", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "included.txt") + await Filesystem.write(file, "uri-user") + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + username: `{file:${Path.uri(file)}}`, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).toBe("uri-user") + }, + }) +}) + +test("handles file inclusion via Windows alias reference", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "included.txt") + await Filesystem.write(file, "alias-user") + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + username: `{file:${alias(file).find((item) => item.name === "git")!.path}}`, + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).toBe("alias-user") + }, + }) +}) + test("validates config schema and throws on invalid fields", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index 7eeca3a58995..fb8ceef7dcef 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -34,6 +34,24 @@ describe("path", () => { }) }) + describe("isAbsolute()", () => { + test("treats file URIs as absolute", () => { + expect(Path.isAbsolute("file:///tmp/dir/file.txt", { platform: "linux" })).toBe(true) + expect(Path.isAbsolute("file:///C:/tmp/file.txt", { platform: "win32" })).toBe(true) + }) + + test("matches all Windows alias roots", () => { + for (const item of alias("C:\\Users\\Dev\\tmp\\file.txt")) { + expect(Path.isAbsolute(item.path, { platform: "win32" })).toBe(true) + } + }) + + test("keeps relative inputs relative", () => { + expect(Path.isAbsolute("src/file.ts", { platform: "linux" })).toBe(false) + expect(Path.isAbsolute("src/file.ts", { platform: "win32" })).toBe(false) + }) + }) + describe("key()", () => { test("matches slash and case variants on Windows", () => { const a = Path.key("C:\\Repo\\File.ts", { platform: "win32" }) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 2431479f407f..0e07ba98d3be 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -5,6 +5,8 @@ import { InstructionPrompt } from "../../src/session/instruction" import { Instance } from "../../src/project/instance" import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" +import { Path } from "../../src/path/path" +import { win as alias } from "../lib/windows-path" describe("InstructionPrompt.resolve", () => { test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => { @@ -190,3 +192,55 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { } }) }) + +describe("InstructionPrompt.systemPaths instruction boundaries", () => { + test("resolves file URI instructions through Path", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "rules.md") + await Bun.write(file, "# Rules") + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + instructions: [String(Path.uri(file))], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const paths = await InstructionPrompt.systemPaths() + expect(paths.has(path.join(tmp.path, "rules.md"))).toBe(true) + }, + }) + }) + + test("resolves Windows alias instructions through Path", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "rules.md") + await Bun.write(file, "# Rules") + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + instructions: [alias(file).find((item) => item.name === "git")!.path], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const paths = await InstructionPrompt.systemPaths() + expect(paths.has(path.join(tmp.path, "rules.md"))).toBe(true) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5d923d139e24..845a6f981be9 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -10,6 +10,8 @@ import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" +import { Path } from "../../src/path/path" +import { win as alias } from "../lib/windows-path" Log.init({ print: false }) @@ -166,6 +168,51 @@ describe("session.prompt special characters", () => { }, }) }) + + test("resolves file URI prompt file references through Path", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "prompt-uri.txt"), "uri content\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const file = path.join(tmp.path, "prompt-uri.txt") + const parts = await SessionPrompt.resolvePromptParts(`Read @${Path.uri(file)}`) + const item = parts.find((part) => part.type === "file") + if (!item || item.type !== "file") throw new Error("expected file part") + + expect(fileURLToPath(item.url)).toBe(file) + }, + }) + }) + + test("resolves Windows alias prompt file references through Path", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "prompt-alias.txt"), "alias content\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const file = path.join(tmp.path, "prompt-alias.txt") + const ref = alias(file).find((item) => item.name === "git")!.path + const parts = await SessionPrompt.resolvePromptParts(`Read @${ref}`) + const item = parts.find((part) => part.type === "file") + if (!item || item.type !== "file") throw new Error("expected file part") + + expect(fileURLToPath(item.url)).toBe(file) + }, + }) + }) }) describe("session.prompt local file permissions", () => { diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 63a50e3e1de9..0cd248167c8f 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import fs from "fs/promises" import os from "os" import path from "path" import { BashTool } from "../../src/tool/bash" @@ -23,6 +24,10 @@ const ctx = { const projectRoot = path.join(__dirname, "../..") +async function link(target: string, alias: string) { + await fs.symlink(target, alias, process.platform === "win32" ? "junction" : "dir") +} + describe("tool.bash", () => { test("basic", async () => { await Instance.provide({ @@ -154,6 +159,43 @@ describe("tool.bash permissions", () => { }) }) + test("asks for external_directory permission when workdir alias resolves outside project", async () => { + await using outer = await tmpdir() + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await link(outer.path, path.join(dir, "link")) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const testCtx = { + ...ctx, + ask: async (req: Omit) => { + requests.push(req) + }, + } + const alias = path.join(tmp.path, "link") + await bash.execute( + { + command: "echo hello", + workdir: alias, + description: "Echo hello", + }, + testCtx, + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + const expected = (await resolveExternalDirectory(alias, { kind: "directory" })).glob + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(expected) + expect(extDirReq!.always).toContain(expected) + }, + }) + }) + test("asks for external_directory permission when file arg is outside project", async () => { await using outerTmp = await tmpdir({ init: async (dir) => { From 530291f55e77023b75dc66eea8a4e6459289c9cd Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:57:30 +1000 Subject: [PATCH 27/42] cli: preserve logical cwd when resolving --dir --- packages/opencode/src/cli/cmd/dir.ts | 17 ++++ packages/opencode/src/cli/cmd/run.ts | 15 +-- packages/opencode/src/cli/cmd/tui/attach.ts | 11 ++- packages/opencode/test/cli/dir.test.ts | 34 +++++++ packages/opencode/test/cli/tui/attach.test.ts | 97 +++++++++++++++++++ 5 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/dir.ts create mode 100644 packages/opencode/test/cli/dir.test.ts create mode 100644 packages/opencode/test/cli/tui/attach.test.ts diff --git a/packages/opencode/src/cli/cmd/dir.ts b/packages/opencode/src/cli/cmd/dir.ts new file mode 100644 index 000000000000..5b8db0b6353e --- /dev/null +++ b/packages/opencode/src/cli/cmd/dir.ts @@ -0,0 +1,17 @@ +import { Path } from "@/path/path" + +type Opts = { + cwd?: string + remote?: boolean +} + +export function cwd(input?: string) { + return input ?? process.env.PWD ?? process.cwd() +} + +export function dir(input: string, opts: Opts = {}) { + const root = cwd(opts.cwd) + const next = Path.pretty(input, { cwd: root }) + if (!opts.remote || Path.isAbsolute(input)) return next + return input +} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 9da405de2ab6..c702cbbd2f8f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -27,6 +27,7 @@ import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" import { Path } from "@/path/path" +import * as Dir from "./dir" type ToolProps = { input: Tool.InferParameters @@ -303,18 +304,20 @@ export const RunCommand = cmd({ }) }, handler: async (args) => { + const root = Dir.cwd() let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") const directory = (() => { if (!args.dir) return undefined - if (args.attach) return args.dir + const next = Dir.dir(args.dir, { cwd: root, remote: !!args.attach }) + if (args.attach) return next try { - process.chdir(args.dir) - return process.cwd() + process.chdir(next) + return next } catch { - UI.error("Failed to change directory to " + args.dir) + UI.error("Failed to change directory to " + next) process.exit(1) } })() @@ -324,7 +327,7 @@ export const RunCommand = cmd({ const list = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of list) { - const resolvedPath = Path.pretty(filePath, { cwd: process.cwd() }) + const resolvedPath = Path.pretty(filePath, { cwd: args.attach ? root : directory ?? process.cwd() }) if (!(await Filesystem.exists(resolvedPath))) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -663,7 +666,7 @@ export const RunCommand = cmd({ return await execute(sdk) } - await bootstrap(process.cwd(), async () => { + await bootstrap(directory ?? process.cwd(), async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) return Server.Default().fetch(request) diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index e892f9922d1b..2f5660723dd2 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" import { existsSync } from "fs" +import * as Dir from "../dir" export const AttachCommand = cmd({ command: "attach ", @@ -43,6 +44,7 @@ export const AttachCommand = cmd({ const unguard = win32InstallCtrlCGuard() try { win32DisableProcessedInput() + const root = Dir.cwd() if (args.fork && !args.continue && !args.session) { UI.error("--fork requires --continue or --session") @@ -52,12 +54,13 @@ export const AttachCommand = cmd({ const directory = (() => { if (!args.dir) return undefined + const next = Dir.dir(args.dir, { cwd: root }) try { - process.chdir(args.dir) - return process.cwd() + process.chdir(next) + return next } catch { // If the directory doesn't exist locally (remote attach), pass it through. - return args.dir + return Dir.dir(args.dir, { cwd: root, remote: true }) } })() const headers = (() => { @@ -67,7 +70,7 @@ export const AttachCommand = cmd({ return { Authorization: auth } })() const config = await Instance.provide({ - directory: directory && existsSync(directory) ? directory : process.cwd(), + directory: directory && existsSync(directory) ? directory : root, fn: () => TuiConfig.get(), }) await tui({ diff --git a/packages/opencode/test/cli/dir.test.ts b/packages/opencode/test/cli/dir.test.ts new file mode 100644 index 000000000000..162f545f414c --- /dev/null +++ b/packages/opencode/test/cli/dir.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { dir } from "../../src/cli/cmd/dir" +import { Path } from "../../src/path/path" +import { tmpdir } from "../fixture/fixture" + +describe("cli dir", () => { + test("resolves relative dirs from the logical cwd", async () => { + await using tmp = await tmpdir() + const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") + const type = process.platform === "win32" ? "junction" : "dir" + await fs.mkdir(path.join(tmp.path, "child")) + await fs.symlink(tmp.path, link, type) + + try { + expect(dir("child", { cwd: link })).toBe(path.join(link, "child")) + } finally { + await fs.rm(link, { recursive: true, force: true }).catch(() => undefined) + } + }) + + test("keeps remote relative dirs raw", () => { + expect(dir("remote/app", { cwd: "/tmp/root", remote: true })).toBe("remote/app") + }) + + test("normalizes remote file uris", async () => { + await using tmp = await tmpdir() + const child = path.join(tmp.path, "child") + await fs.mkdir(child) + + expect(dir(String(Path.uri(child)), { remote: true })).toBe(child) + }) +}) diff --git a/packages/opencode/test/cli/tui/attach.test.ts b/packages/opencode/test/cli/tui/attach.test.ts new file mode 100644 index 000000000000..c089c822948a --- /dev/null +++ b/packages/opencode/test/cli/tui/attach.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Path } from "../../../src/path/path" +import { tmpdir } from "../../fixture/fixture" + +const seen = { + tui: [] as (string | undefined)[], + inst: [] as string[], +} + +mock.module("../../../src/cli/cmd/tui/app", () => ({ + tui: async (input: { directory?: string }) => { + seen.tui.push(input.directory) + }, +})) + +mock.module("@/config/tui", () => ({ + TuiConfig: { + get: () => ({}), + }, +})) + +mock.module("@/project/instance", () => ({ + Instance: { + provide: async (input: { directory: string; fn: () => Promise | unknown }) => { + seen.inst.push(input.directory) + return input.fn() + }, + }, +})) + +mock.module("../../../src/cli/cmd/tui/win32", () => ({ + win32DisableProcessedInput: () => {}, + win32InstallCtrlCGuard: () => undefined, +})) + +mock.module("@/cli/ui", () => ({ + UI: { + error: () => {}, + }, +})) + +describe("tui attach", () => { + beforeEach(() => { + seen.tui.length = 0 + seen.inst.length = 0 + }) + + async function call(dir?: string) { + const { AttachCommand } = await import("../../../src/cli/cmd/tui/attach") + const args: Parameters>[0] = { + _: [], + $0: "opencode", + url: "http://localhost:4096", + dir, + continue: false, + session: undefined, + fork: false, + password: undefined, + } + return AttachCommand.handler(args) + } + + test("normalizes local file uri directories", async () => { + await using tmp = await tmpdir() + const cwd = process.cwd() + const child = path.join(tmp.path, "child") + await fs.mkdir(child) + + try { + await call(String(Path.uri(child))) + expect(seen.inst[0]).toBe(child) + expect(seen.tui[0]).toBe(child) + } finally { + process.chdir(cwd) + } + }) + + test("keeps remote relative directories raw", async () => { + await using tmp = await tmpdir() + const cwd = process.cwd() + const pwd = process.env.PWD + + try { + process.chdir(tmp.path) + process.env.PWD = tmp.path + await call("remote/app") + expect(seen.inst[0]).toBe(tmp.path) + expect(seen.tui[0]).toBe("remote/app") + } finally { + process.chdir(cwd) + if (pwd === undefined) delete process.env.PWD + else process.env.PWD = pwd + } + }) +}) From af5539e80e484658162c96aeb92d36a307737a8f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:08:17 +1000 Subject: [PATCH 28/42] path: unify repo and remote path semantics --- .../src/components/session/session-header.tsx | 21 +- packages/app/src/context/global-sync.tsx | 9 +- .../src/context/global-sync/child-store.ts | 9 +- packages/app/src/context/platform.test.ts | 33 + packages/app/src/context/platform.tsx | 21 +- packages/opencode/src/cli/cmd/dir.ts | 12 +- packages/opencode/src/cli/cmd/tui/attach.ts | 15 +- .../cli/cmd/tui/component/dialog-status.tsx | 8 +- .../cmd/tui/component/prompt/autocomplete.tsx | 40 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 11 +- .../src/cli/cmd/tui/routes/session/index.tsx | 10 +- .../cli/cmd/tui/routes/session/sidebar.tsx | 8 +- .../opencode/src/cli/cmd/tui/util/path.ts | 4 +- packages/opencode/src/file/index.ts | 47 +- packages/opencode/src/file/watcher.ts | 6 +- packages/opencode/src/filesystem/index.ts | 35 +- packages/opencode/src/path/path.ts | 699 +++++++++++------- packages/opencode/src/path/schema.ts | 23 + packages/opencode/src/server/server.ts | 3 + packages/opencode/src/tool/ls.ts | 30 +- packages/opencode/src/worktree/index.ts | 35 +- packages/opencode/test/cli/dir.test.ts | 33 + packages/opencode/test/cli/tui/attach.test.ts | 60 +- packages/opencode/test/file/index.test.ts | 40 +- packages/opencode/test/path/path.test.ts | 122 +++ .../opencode/test/server/path-alias.test.ts | 1 + packages/opencode/test/tool/ls.test.ts | 44 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 18 +- packages/ui/src/components/session-review.tsx | 6 +- packages/ui/src/components/session-turn.tsx | 4 +- packages/ui/src/context/data.tsx | 6 +- packages/util/src/path.test.ts | 12 + packages/util/src/path.ts | 25 +- 33 files changed, 1011 insertions(+), 439 deletions(-) create mode 100644 packages/app/src/context/platform.test.ts create mode 100644 packages/opencode/test/tool/ls.test.ts diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 2b2e2469f7dd..e0ff146ec37e 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -14,7 +14,7 @@ import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { usePlatform } from "@/context/platform" +import { serverOS, usePlatform } from "@/context/platform" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" @@ -44,7 +44,6 @@ const OPEN_APPS = [ ] as const type OpenApp = (typeof OPEN_APPS)[number] -type OS = "macos" | "windows" | "linux" | "unknown" const MAC_APPS = [ { @@ -111,16 +110,6 @@ const LINUX_APPS = [ }, ] as const -const detectOS = (platform: ReturnType): OS => { - if (platform.platform === "desktop" && platform.os) return platform.os - if (typeof navigator !== "object") return "unknown" - const value = navigator.platform || navigator.userAgent - if (/Mac/i.test(value)) return "macos" - if (/Win/i.test(value)) return "windows" - if (/Linux/i.test(value)) return "linux" - return "unknown" -} - const showRequestError = (language: ReturnType, err: unknown) => { showToast({ variant: "error", @@ -151,7 +140,10 @@ export function SessionHeader() { return getFilename(projectDirectory()) }) const hotkey = createMemo(() => command.keybind("file.open")) - const os = createMemo(() => detectOS(platform)) + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const os = createMemo(() => + serverOS(sync.data.path.directory ? sync.data.path : undefined, canOpen() ? platform : undefined), + ) const [exists, setExists] = createStore>>({ finder: true, @@ -170,7 +162,7 @@ export function SessionHeader() { }) createEffect(() => { - if (platform.platform !== "desktop") return + if (!canOpen()) return if (!platform.checkAppExists) return const list = apps() @@ -214,7 +206,6 @@ export function SessionHeader() { app: undefined as OpenApp | undefined, }) - const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo( () => options().find((o) => o.id === prefs.app) ?? diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 6e3597a3ac87..701e880ac49c 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -69,7 +69,14 @@ function createGlobalSync() { const [globalStore, setGlobalStore] = createStore({ ready: false, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, + path: { + os: "linux", + state: "", + config: "", + worktree: "", + directory: "", + home: "", + }, project: projectCache.value, session_todo: {}, provider: { all: [], connected: [], default: {} }, diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index d967567c56ad..735624f32a41 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -170,7 +170,14 @@ export function createChildStoreManager(input: { icon: initialIcon, provider: { all: [], connected: [], default: {} }, config: {}, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, + path: { + os: "linux", + state: "", + config: "", + worktree: "", + directory: "", + home: "", + }, status: "loading" as const, agent: [], command: [], diff --git a/packages/app/src/context/platform.test.ts b/packages/app/src/context/platform.test.ts new file mode 100644 index 000000000000..2fb39fb44892 --- /dev/null +++ b/packages/app/src/context/platform.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { clientOS, serverOS, type Platform } from "./platform" + +describe("platform helpers", () => { + test("prefers server os when available", () => { + const platform: Platform = { + platform: "desktop", + os: "linux", + openLink() {}, + async restart() {}, + back() {}, + forward() {}, + async notify() {}, + } + + expect(serverOS({ os: "windows" }, platform)).toBe("windows") + }) + + test("falls back to the client os", () => { + const platform: Platform = { + platform: "desktop", + os: "macos", + openLink() {}, + async restart() {}, + back() {}, + forward() {}, + async notify() {}, + } + + expect(clientOS(platform)).toBe("macos") + expect(serverOS(undefined, platform)).toBe("macos") + }) +}) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b8ed58e343a4..f80a99d0fd1f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -3,18 +3,23 @@ import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import type { Accessor } from "solid-js" import { ServerConnection } from "./server" +export type OS = "macos" | "windows" | "linux" + type PickerPaths = string | string[] | null type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean } type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } +type KnownOS = OS | "unknown" +type ServerPath = { os?: OS | null } + export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" /** Desktop OS (Tauri only) */ - os?: "macos" | "windows" | "linux" + os?: OS /** App version */ version?: string @@ -91,6 +96,20 @@ export type Platform = { export type DisplayBackend = "auto" | "wayland" +export function clientOS(platform?: Pick): KnownOS { + if (platform?.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +export function serverOS(path?: ServerPath | null, platform?: Pick): KnownOS { + return path?.os ?? clientOS(platform) +} + export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ name: "Platform", init: (props: { value: Platform }) => { diff --git a/packages/opencode/src/cli/cmd/dir.ts b/packages/opencode/src/cli/cmd/dir.ts index 5b8db0b6353e..ec74a7a780ec 100644 --- a/packages/opencode/src/cli/cmd/dir.ts +++ b/packages/opencode/src/cli/cmd/dir.ts @@ -1,5 +1,5 @@ -import { Path } from "@/path/path" - +import { Path } from "@/path/path" + type Opts = { cwd?: string remote?: boolean @@ -10,8 +10,8 @@ export function cwd(input?: string) { } export function dir(input: string, opts: Opts = {}) { - const root = cwd(opts.cwd) - const next = Path.pretty(input, { cwd: root }) - if (!opts.remote || Path.isAbsolute(input)) return next - return input + if (!opts.remote) return Path.pretty(input, { cwd: cwd(opts.cwd) }) + const pf = Path.guess(input) + if (!pf) return input + return Path.pretty(input, { platform: pf }) } diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 2f5660723dd2..109ed07c8b0a 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -7,6 +7,15 @@ import { Instance } from "@/project/instance" import { existsSync } from "fs" import * as Dir from "../dir" +function local(url: string) { + try { + const host = new URL(url).hostname + return host === "localhost" || host === "127.0.0.1" || host === "::1" + } catch { + return false + } +} + export const AttachCommand = cmd({ command: "attach ", describe: "attach to a running opencode server", @@ -45,6 +54,7 @@ export const AttachCommand = cmd({ try { win32DisableProcessedInput() const root = Dir.cwd() + const nearby = local(args.url) if (args.fork && !args.continue && !args.session) { UI.error("--fork requires --continue or --session") @@ -54,7 +64,8 @@ export const AttachCommand = cmd({ const directory = (() => { if (!args.dir) return undefined - const next = Dir.dir(args.dir, { cwd: root }) + const next = Dir.dir(args.dir, { cwd: root, remote: !nearby }) + if (!nearby) return next try { process.chdir(next) return next @@ -70,7 +81,7 @@ export const AttachCommand = cmd({ return { Authorization: auth } })() const config = await Instance.provide({ - directory: directory && existsSync(directory) ? directory : root, + directory: nearby && directory && existsSync(directory) ? directory : root, fn: () => TuiConfig.get(), }) await tui({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index ab163607e9f8..39321739b4fb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -3,7 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog } from "@tui/ui/dialog" import { useSync } from "@tui/context/sync" import { For, Match, Switch, Show, createMemo } from "solid-js" -import { Global } from "@/global" +import { Path } from "@/path/path" import { formatPath, plugin } from "../util/path" export type DialogStatusProps = {} @@ -12,6 +12,7 @@ export function DialogStatus() { const sync = useSync() const { theme } = useTheme() const dialog = useDialog() + const platform = createMemo(() => Path.platform(sync.data.path.os)) const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) @@ -92,7 +93,10 @@ export function DialogStatus() { • - {item.id} {formatPath(item.root, { home: Global.Path.home })} + {item.id}{" "} + + {formatPath(item.root, { home: sync.data.path.home, platform: platform() })} + )} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3240afab326a..f53d84d8726c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -1,5 +1,4 @@ import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" -import { pathToFileURL } from "bun" import fuzzysort from "fuzzysort" import { firstBy } from "remeda" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" @@ -11,6 +10,7 @@ import { SplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" +import { Path } from "@/path/path" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" @@ -238,21 +238,28 @@ export function Autocomplete(props: { const aScore = frecency.getFrecency(a) const bScore = frecency.getFrecency(b) if (aScore !== bScore) return bScore - aScore - const aDepth = a.split("/").length - const bDepth = b.split("/").length + const aDepth = Path.repoDepth(a) + const bDepth = Path.repoDepth(b) if (aDepth !== bDepth) return aDepth - bDepth return a.localeCompare(b) }) - const width = props.anchor().width - 4 - options.push( - ...sortedFiles.map((item): AutocompleteOption => { - const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "") - const fullPath = `${baseDir}/${item}` - const urlObj = pathToFileURL(fullPath) - let filename = item - if (lineRange && !item.endsWith("/")) { - filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + const width = props.anchor().width - 4 + options.push( + ...sortedFiles.map((item): AutocompleteOption => { + const urlObj = new URL( + String( + Path.uri(item, { + cwd: sync.data.path.directory || process.cwd(), + platform: sync.data.path.os, + }), + ), + ) + const key = String(Path.repo(item)) + const isDir = Path.repoIsDir(key) + let filename = key + if (lineRange && !isDir) { + filename = `${key}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` urlObj.searchParams.set("start", String(lineRange.startLine)) if (lineRange.endLine !== undefined) { urlObj.searchParams.set("end", String(lineRange.endLine)) @@ -260,12 +267,11 @@ export function Autocomplete(props: { } const url = urlObj.href - const isDir = item.endsWith("/") return { display: Locale.truncateMiddle(filename, width), value: filename, isDirectory: isDir, - path: item, + path: key, onSelect: () => { insertPart(filename, { type: "file", @@ -279,7 +285,7 @@ export function Autocomplete(props: { end: 0, value: "", }, - path: item, + path: key, }, }) }, @@ -461,8 +467,8 @@ export function Autocomplete(props: { const input = props.input() const currentCursorOffset = input.cursorOffset - const displayText = selected.display.trimEnd() - const path = displayText.startsWith("@") ? displayText.slice(1) : displayText + const value = (selected.value ?? selected.display).trimEnd() + const path = value.startsWith("@") ? value.slice(1) : value input.cursorOffset = store.index const startCursor = input.logicalCursor diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 3b296a927aa4..401d452e6439 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -16,6 +16,7 @@ import type { SessionStatus, ProviderListResponse, ProviderAuthMethod, + Path, VcsInfo, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" @@ -27,7 +28,6 @@ import { useExit } from "./exit" import { useArgs } from "./args" import { batch, onMount } from "solid-js" import { Log } from "@/util/log" -import type { Path } from "@opencode-ai/sdk" import type { Workspace } from "@opencode-ai/sdk/v2" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ @@ -101,7 +101,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp_resource: {}, formatter: [], vcs: undefined, - path: { state: "", config: "", worktree: "", directory: "" }, + path: { + os: process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux", + home: "", + state: "", + config: "", + worktree: "", + directory: "", + }, workspaceList: [], }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1247c8f0bc28..483fee61d91c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -16,6 +16,7 @@ import { Dynamic } from "solid-js/web" import path from "path" import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" +import { Path } from "@/path/path" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" @@ -1780,6 +1781,7 @@ function Bash(props: ToolProps) { const [expanded, setExpanded] = createSignal(false) const lines = createMemo(() => output().split("\n")) const overflow = createMemo(() => lines().length > 10) + const platform = createMemo(() => Path.platform(sync.data.path.os)) const limited = createMemo(() => { if (expanded() || !overflow()) return output() return [...lines().slice(0, 10), "…"].join("\n") @@ -1792,11 +1794,15 @@ function Bash(props: ToolProps) { const base = sync.data.path.directory if (!base) return undefined - const absolute = path.resolve(base, workdir) + const absolute = Path.pretty(workdir, { + cwd: base, + platform: platform(), + }) if (absolute === base) return undefined return formatPath(absolute, { - home: Global.Path.home, + home: sync.data.path.home, + platform: platform(), }) }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 0bf14f628fcc..34c0110b8def 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -3,8 +3,8 @@ import { createMemo, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" import type { AssistantMessage } from "@opencode-ai/sdk/v2" -import { Global } from "@/global" import { Installation } from "@/installation" +import { Path } from "@/path/path" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" import { formatPath, splitPath } from "../../util/path" @@ -26,6 +26,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { // Sort MCP servers alphabetically for consistent display order const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b))) + const platform = createMemo(() => Path.platform(sync.data.path.os)) // Count connected and error MCP servers for collapsed header display const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length) @@ -60,7 +61,8 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const kv = useKV() const dir = createMemo(() => { return formatPath(sync.data.path.directory || process.cwd(), { - home: Global.Path.home, + home: sync.data.path.home, + platform: platform(), }) }) const dirparts = createMemo(() => splitPath(dir())) @@ -205,7 +207,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { • - {item.id} {formatPath(item.root, { home: Global.Path.home })} + {item.id} {formatPath(item.root, { home: sync.data.path.home, platform: platform() })} )} diff --git a/packages/opencode/src/cli/cmd/tui/util/path.ts b/packages/opencode/src/cli/cmd/tui/util/path.ts index 00a08ec7b86b..a5d718b7708c 100644 --- a/packages/opencode/src/cli/cmd/tui/util/path.ts +++ b/packages/opencode/src/cli/cmd/tui/util/path.ts @@ -4,12 +4,12 @@ import { Path } from "@/path/path" type Opts = { cwd?: string home?: string - platform?: NodeJS.Platform + platform?: NodeJS.Platform | "windows" | "macos" | "linux" relative?: boolean } function pf(opts: Opts = {}) { - return opts.platform ?? process.platform + return Path.platform(opts.platform) } function lib(platform: NodeJS.Platform) { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index ca60089c8133..d1b8ed7f3810 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -331,17 +331,12 @@ export namespace File { return ["image", "audio", "video", "font", "model", "multipart"].includes(top) } - const hidden = (item: string) => { - const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") - return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1) - } - const sortHiddenLast = (items: string[], prefer: boolean) => { if (prefer) return items const visible: string[] = [] const hiddenItems: string[] = [] for (const item of items) { - if (hidden(item)) hiddenItems.push(item) + if (Path.hidden(item)) hiddenItems.push(item) else visible.push(item) } return [...visible, ...hiddenItems] @@ -389,21 +384,19 @@ export namespace File { const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) - const top = await fs.promises - .readdir(instance.directory, { withFileTypes: true }) - .catch(() => [] as fs.Dirent[]) + const top = await fs.promises.readdir(instance.directory, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) for (const entry of top) { if (!entry.isDirectory()) continue if (shouldIgnoreName(entry.name)) continue - dirs.add(entry.name + "/") + dirs.add(String(Path.repo(`${entry.name}/`))) const base = path.join(instance.directory, entry.name) const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) for (const child of children) { if (!child.isDirectory()) continue if (shouldIgnoreNested(child.name)) continue - dirs.add(entry.name + "/" + child.name + "/") + dirs.add(String(Path.repo(`${entry.name}/${child.name}/`))) } } @@ -411,16 +404,18 @@ export namespace File { } else { const seen = new Set() for await (const file of Ripgrep.files({ cwd: instance.directory })) { - next.files.push(file) - let current = file - while (true) { - const dir = path.dirname(current) - if (dir === ".") break - if (dir === current) break - current = dir - if (seen.has(dir)) continue - seen.add(dir) - next.dirs.push(dir + "/") + const key = String(Path.repo(file)) + next.files.push(key) + let dir = String(Path.repoParent(key)) + while (dir !== ".") { + const item = String(Path.repo(`${dir}/`)) + if (seen.has(item)) { + dir = String(Path.repoParent(dir)) + continue + } + seen.add(item) + next.dirs.push(item) + dir = String(Path.repoParent(dir)) } } } @@ -531,10 +526,10 @@ export namespace File { } return changed.map((item) => { - const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path) + const full = Path.pretty(item.path, { cwd: instance.directory }) return { ...item, - path: path.relative(instance.directory, full), + path: String(Path.repo(Path.rel(instance.directory, full))), } }) }) @@ -647,14 +642,14 @@ export namespace File { for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { if (exclude.includes(entry.name)) continue const absolute = path.join(resolved, entry.name) - const file = path.relative(instance.directory, absolute) + const file = String(Path.repo(Path.rel(instance.directory, absolute))) const type = entry.isDirectory() ? "directory" : "file" nodes.push({ name: entry.name, path: file, absolute, type, - ignored: ignored(type === "directory" ? file + "/" : file), + ignored: ignored(type === "directory" ? String(Path.repo(`${file}/`)) : file), }) } @@ -678,7 +673,7 @@ export namespace File { log.info("search", { query, kind }) const result = getFiles() - const preferHidden = query.startsWith(".") || query.includes("/.") + const preferHidden = query.startsWith(".") || Path.hidden(query) if (!query) { if (kind === "file") return result.files.slice(0, limit) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 14b0f7867a76..201873b3b306 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -11,7 +11,6 @@ import { Cause, Effect, Layer, ServiceMap } from "effect" import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" import { readdir } from "fs/promises" -import path from "path" import z from "zod" import { Config } from "../config/config" import { FileIgnore } from "./ignore" @@ -53,10 +52,7 @@ export namespace FileWatcher { } function protecteds(dir: string) { - return Protected.paths().filter((item) => { - const rel = path.relative(dir, item) - return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel) - }) + return Protected.paths().filter((item) => Path.contains(dir, item) && !Path.eq(dir, item)) } export const hasNativeBinding = () => !!watcher() diff --git a/packages/opencode/src/filesystem/index.ts b/packages/opencode/src/filesystem/index.ts index d8f7d6053e71..30e40fb578d1 100644 --- a/packages/opencode/src/filesystem/index.ts +++ b/packages/opencode/src/filesystem/index.ts @@ -1,10 +1,9 @@ import { NodeFileSystem } from "@effect/platform-node" -import { dirname, join, relative, resolve as pathResolve } from "path" -import { realpathSync } from "fs" -import { lookup } from "mime-types" +import { dirname, join } from "path" import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "../util/glob" +import { Filesystem as LocalFilesystem } from "../util/filesystem" export namespace AppFileSystem { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { @@ -154,44 +153,26 @@ export namespace AppFileSystem { // Pure helpers that don't need Effect (path manipulation, sync operations) export function mimeType(p: string): string { - return lookup(p) || "application/octet-stream" + return LocalFilesystem.mimeType(p) } export function normalizePath(p: string): string { - if (process.platform !== "win32") return p - try { - return realpathSync.native(p) - } catch { - return p - } + return LocalFilesystem.normalizePath(p) } export function resolve(p: string): string { - const resolved = pathResolve(windowsPath(p)) - try { - return normalizePath(realpathSync(resolved)) - } catch (e: any) { - if (e?.code === "ENOENT") return normalizePath(resolved) - throw e - } + return LocalFilesystem.resolve(p) } export function windowsPath(p: string): string { - if (process.platform !== "win32") return p - return p - .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) + return LocalFilesystem.windowsPath(p) } export function overlaps(a: string, b: string) { - const relA = relative(a, b) - const relB = relative(b, a) - return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") + return LocalFilesystem.overlaps(a, b) } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + return LocalFilesystem.contains(parent, child) } } diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index fdaa1ae85d8f..96c2450519ce 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -1,301 +1,466 @@ -import { readdirSync } from "fs" -import { readdir, realpath } from "fs/promises" -import os from "os" -import path from "path" +import { readdirSync } from "fs" +import { readdir, realpath } from "fs/promises" +import os from "os" +import path from "path" + +import { FileURI, PathKey, PosixPath, PrettyPath, RelativePath, RepoPath } from "./schema" -import { FileURI, PathKey, PosixPath, PrettyPath, RelativePath } from "./schema" +type OS = "windows" | "macos" | "linux" +type Platform = NodeJS.Platform | OS type Opts = { cwd?: string - platform?: NodeJS.Platform + platform?: Platform } type HomeOpts = { home?: string - platform?: NodeJS.Platform + platform?: Platform } - -type DisplayOpts = Opts & { - home?: string | false - relative?: boolean -} - + +type DisplayOpts = Opts & { + home?: string | false + relative?: boolean +} + +/** + * Path exposes a few intentionally different string forms instead of one + * "normalized" answer. + * + * - `pretty` is the main absolute, native-separator form used inside the app. + * - `key` is the same path folded for equality and map/set lookups. + * - `canonical` keeps repo and protocol-facing values stable by using POSIX form + * for Windows absolute paths while leaving relative inputs alone. + * - `physical` asks the filesystem for the real on-disk path and best-effort + * casing, even when some trailing segments do not exist yet. + * + * Windows support also accepts common drive aliases like `/c/...`, + * `/cygdrive/c/...`, `/mnt/c/...`, plus `file://` URIs, so callers can feed in + * editor, shell, and URI-derived values without pre-normalizing them first. + */ + type Lib = typeof path.posix -function pf(opts?: { platform?: NodeJS.Platform }) { - return opts?.platform ?? process.platform -} - -function lib(platform: NodeJS.Platform): Lib { - return platform === "win32" ? path.win32 : path.posix -} - -function home(opts: HomeOpts = {}) { - return opts.home ?? process.env.OPENCODE_TEST_HOME ?? os.homedir() -} - -function fixDrive(input: string) { - return input.replace(/^[a-z]:/, (match) => match.toUpperCase()) -} - -function clean(input: string, platform: NodeJS.Platform) { - const text = lib(platform).normalize(input) - if (platform !== "win32") return text - return fixDrive(text).replaceAll("/", "\\") -} - -function raw(input: string, platform: NodeJS.Platform) { - if (input.startsWith("file://")) return fromURIText(input, platform) - if (platform !== "win32") return input +function platformText(input?: Platform) { + if (!input) return process.platform + if (input === "windows") return "win32" + if (input === "macos") return "darwin" + if (input === "linux") return "linux" return input - .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) - .replace(/^\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) - .replace(/^\/cygdrive\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) - .replace(/^\/mnt\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) } +function pf(opts?: { platform?: Platform }) { + return platformText(opts?.platform) +} + +function lib(platform: NodeJS.Platform): Lib { + return platform === "win32" ? path.win32 : path.posix +} + +function home(opts: HomeOpts = {}) { + return opts.home ?? process.env.OPENCODE_TEST_HOME ?? os.homedir() +} + +function fixDrive(input: string) { + return input.replace(/^[a-z]:/, (match) => match.toUpperCase()) +} + +function clean(input: string, platform: NodeJS.Platform) { + const text = lib(platform).normalize(input) + if (platform !== "win32") return text + return fixDrive(text).replaceAll("/", "\\") +} + +function raw(input: string, platform: NodeJS.Platform) { + if (input.startsWith("file://")) return fromURIText(input, platform) + if (platform !== "win32") return input + return input + .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) + .replace(/^\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) + .replace(/^\/cygdrive\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) + .replace(/^\/mnt\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) +} + function winabs(input: string) { return ( input.startsWith("file://") || - /^[a-zA-Z]:/.test(input) || + /^[\\/]{2}[^\\/]/.test(input) || + /^[a-zA-Z]:(?:[\\/]|$)/.test(input) || /^\/([a-zA-Z]:|[a-zA-Z])(?:[\\/]|$)/.test(input) || /^\/cygdrive\/[a-zA-Z](?:[\\/]|$)/.test(input) || /^\/mnt\/[a-zA-Z](?:[\\/]|$)/.test(input) ) } -function base(input: string, platform: NodeJS.Platform) { - const mod = lib(platform) - const text = raw(input, platform) - return clean(mod.isAbsolute(text) ? text : mod.resolve(text), platform) -} - -function prettyText(input: string, opts: Opts = {}) { - const platform = pf(opts) - const mod = lib(platform) - const text = raw(input, platform) - const cwd = opts.cwd ? base(opts.cwd, platform) : undefined - return clean(cwd ? mod.resolve(cwd, text) : mod.resolve(text), platform) -} - -function expandText(input: string, opts: HomeOpts = {}) { - const platform = pf(opts) - const mod = lib(platform) - const dir = home(opts) - if (input === "~" || input === "$HOME") return prettyText(dir, { platform }) - if (input.startsWith(`~${mod.sep}`)) return prettyText(input.slice(2), { cwd: dir, platform }) - if (input.startsWith(`$HOME${mod.sep}`)) return prettyText(input.slice(6), { cwd: dir, platform }) - if (mod.sep !== "/") { - if (input.startsWith("~/")) return prettyText(input.slice(2), { cwd: dir, platform }) - if (input.startsWith("$HOME/")) return prettyText(input.slice(6), { cwd: dir, platform }) - } - return input -} - -function inside(parent: string, child: string, platform: NodeJS.Platform) { - const mod = lib(platform) - const rel = mod.relative(parent, child) - if (!rel) return true - if (mod.isAbsolute(rel)) return false - return !rel.startsWith("..") -} - -function displayText(input: string, opts: DisplayOpts = {}) { - const platform = pf(opts) - const mod = lib(platform) - const text = prettyText(input, opts) - if (opts.relative) { - const cwd = prettyText(opts.cwd ?? process.cwd(), { platform }) - if (inside(cwd, text, platform)) return mod.relative(cwd, text) || "." - } - if (opts.home === false) return text - const root = opts.home - const dir = prettyText(home({ home: root, platform }), { platform }) - if (!inside(dir, text, platform)) return text - if (text === dir) return "~" - return `~${mod.sep}${mod.relative(dir, text)}` -} - -function encode(input: string) { - return encodeURIComponent(input) -} - -function fromURIText(input: string, platform: NodeJS.Platform) { - const url = new URL(input) - if (url.protocol !== "file:") throw new TypeError(`Expected file URI: ${input}`) - const text = decodeURIComponent(url.pathname) - if (platform !== "win32") { - return `${url.host ? `//${url.host}` : ""}${text}` - } - if (url.host) { - return `\\\\${url.host}${text.replaceAll("/", "\\")}` - } - return fixDrive(text.replace(/^\/([a-zA-Z]:)/, "$1").replaceAll("/", "\\")) -} - -function toURIText(input: string, platform: NodeJS.Platform) { - if (platform !== "win32") { - return `file://${input.split("/").map((part, idx) => (idx === 0 ? part : encode(part))).join("/")}` +function guessText(input: string): NodeJS.Platform | undefined { + if (input.startsWith("file://")) { + try { + const url = new URL(input) + const host = url.hostname.toLowerCase() + if (host && host !== "localhost") return "win32" + if (/^\/([A-Za-z]:|[A-Za-z])(?:[\/]|$)/.test(url.pathname)) return "win32" + if (/^\/(?:cygdrive|mnt)\/[A-Za-z](?:[\/]|$)/.test(url.pathname)) return "win32" + return "linux" + } catch {} } - if (input.startsWith("\\\\")) { - const parts = input.slice(2).split("\\") - const host = parts.shift() ?? "" - const body = parts.map(encode).join("/") - return `file://${host}/${body}` - } - const text = input.replaceAll("\\", "/") - const body = text - .slice(2) - .split("/") - .map((part, idx) => (idx === 0 ? part : encode(part))) - .join("/") - return `file:///${fixDrive(text.slice(0, 2))}${body}` + if (winabs(input)) return "win32" + if (input.startsWith("/")) return "linux" } - -async function physicalAsync(input: string, opts: Opts = {}) { - const platform = pf(opts) - const mod = lib(platform) - const text = prettyText(input, opts) - const hit = await realpath(text).catch(() => undefined) - if (hit) return PrettyPath.make(clean(hit, platform)) - - const parts: string[] = [] - let dir = text - - while (true) { - const parent = mod.dirname(dir) - if (parent === dir) return text - parts.unshift(mod.basename(dir)) - const next = await realpath(parent).catch(() => undefined) - if (next) return PrettyPath.make(clean(mod.join(next, ...parts), platform)) - dir = parent - } + +function base(input: string, platform: NodeJS.Platform) { + const mod = lib(platform) + const text = raw(input, platform) + return clean(mod.isAbsolute(text) ? text : mod.resolve(text), platform) +} + +function prettyText(input: string, opts: Opts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const text = raw(input, platform) + const cwd = opts.cwd ? base(opts.cwd, platform) : undefined + return clean(cwd ? mod.resolve(cwd, text) : mod.resolve(text), platform) +} + +function expandText(input: string, opts: HomeOpts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const dir = home(opts) + if (input === "~" || input === "$HOME") return prettyText(dir, { platform }) + if (input.startsWith(`~${mod.sep}`)) return prettyText(input.slice(2), { cwd: dir, platform }) + if (input.startsWith(`$HOME${mod.sep}`)) return prettyText(input.slice(6), { cwd: dir, platform }) + if (mod.sep !== "/") { + if (input.startsWith("~/")) return prettyText(input.slice(2), { cwd: dir, platform }) + if (input.startsWith("$HOME/")) return prettyText(input.slice(6), { cwd: dir, platform }) + } + return input +} + +function inside(parent: string, child: string, platform: NodeJS.Platform) { + const mod = lib(platform) + const rel = mod.relative(parent, child) + if (!rel) return true + if (mod.isAbsolute(rel)) return false + return !rel.startsWith("..") +} + +function displayText(input: string, opts: DisplayOpts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const text = prettyText(input, opts) + if (opts.relative) { + const cwd = prettyText(opts.cwd ?? process.cwd(), { platform }) + if (inside(cwd, text, platform)) return mod.relative(cwd, text) || "." + } + if (opts.home === false) return text + const root = opts.home + const dir = prettyText(home({ home: root, platform }), { platform }) + if (!inside(dir, text, platform)) return text + if (text === dir) return "~" + return `~${mod.sep}${mod.relative(dir, text)}` +} + +function encode(input: string) { + return encodeURIComponent(input) +} + +function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +function repoText(input: string) { + const dir = /[\\/]$/.test(input) + const text = path.posix.normalize(input.replaceAll("\\", "/") || ".").replace(/^(?:\.\/)+/, "") + if (text === ".") return "." + const clean = text.replace(/\/+$/, "") + return dir ? `${clean}/` : clean +} + +function repoLeaf(input: string) { + const text = repoText(input).replace(/\/+$/, "") + if (text === ".") return "." + return path.posix.basename(text) +} + +function repoParentText(input: string) { + const text = repoText(input).replace(/\/+$/, "") + if (text === ".") return "." + return repoText(path.posix.dirname(text) || ".") +} + +function hiddenText(input: string) { + const parts = input.replaceAll("\\", "/").replace(/\/+$/, "").split("/") + return parts.some( + (part, idx) => (part.startsWith(".") && part.length > 1) || (part === "." && idx > 0 && idx === parts.length - 1), + ) } - + +function fromURIText(input: string, platform: NodeJS.Platform) { + const url = new URL(input) + if (url.protocol !== "file:") throw new TypeError(`Expected file URI: ${input}`) + const host = url.hostname.toLowerCase() + const text = decode(url.pathname) + const local = !host || host === "localhost" + if (platform !== "win32") { + return `${local ? "" : `//${url.host}`}${text}` + } + if (!local) { + return `\\\\${url.host}${text.replaceAll("/", "\\")}` + } + return fixDrive(text.replace(/^\/([a-zA-Z]:)/, "$1").replaceAll("/", "\\")) +} + +function toURIText(input: string, platform: NodeJS.Platform) { + if (platform !== "win32") { + return `file://${input.split("/").map((part, idx) => (idx === 0 ? part : encode(part))).join("/")}` + } + if (input.startsWith("\\\\")) { + const parts = input.slice(2).split("\\") + const host = parts.shift() ?? "" + const body = parts.map(encode).join("/") + return `file://${host}/${body}` + } + const text = input.replaceAll("\\", "/") + const body = text + .slice(2) + .split("/") + .map((part, idx) => (idx === 0 ? part : encode(part))) + .join("/") + return `file:///${fixDrive(text.slice(0, 2))}${body}` +} + +async function physicalAsync(input: string, opts: Opts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const text = prettyText(input, opts) + const hit = await realpath(text).catch(() => undefined) + if (hit) return PrettyPath.make(clean(hit, platform)) + + const parts: string[] = [] + let dir = text + + while (true) { + const parent = mod.dirname(dir) + if (parent === dir) return text + parts.unshift(mod.basename(dir)) + const next = await realpath(parent).catch(() => undefined) + if (next) return PrettyPath.make(clean(mod.join(next, ...parts), platform)) + dir = parent + } +} + export namespace Path { export type Options = Opts - export function isAbsolute(input: string, opts: Omit = {}) { - const platform = pf(opts) - return lib(platform).isAbsolute(raw(input, platform)) + export function platform(input?: Platform) { + return platformText(input) } - export function pretty(input: string, opts: Opts = {}) { - return PrettyPath.make(prettyText(input, opts)) + export function guess(input: string) { + return guessText(input) } - export function key(input: string, opts: Opts = {}) { - const platform = pf(opts) - const text = pretty(input, opts) - if (platform !== "win32") return PathKey.make(text) - return PathKey.make(text.toLowerCase()) - } - - export function posix(input: string, opts: Opts = {}) { - return PosixPath.make(pretty(input, opts).replaceAll("\\", "/")) - } - - export function canonical(input: string, opts: Opts = {}) { - const platform = pf(opts) - if (platform !== "win32") return input - if (!winabs(input)) return input - return posix(input, opts) - } - - export function expand(input: string, opts: HomeOpts = {}) { - return expandText(input, opts) - } - - export function display(input: string, opts: DisplayOpts = {}) { - return displayText(input, opts) - } - - export function rel(from: string, to: string, opts: Opts = {}) { - const platform = pf(opts) - const mod = lib(platform) - const text = mod.relative(pretty(from, opts), pretty(to, opts)) || "." - return RelativePath.make(text) - } - - export function uri(input: string, opts: Opts = {}) { - return FileURI.make(toURIText(pretty(input, opts), pf(opts))) - } - - export function fromURI(input: string, opts: Omit = {}) { - return PrettyPath.make(clean(fromURIText(input, pf(opts)), pf(opts))) - } - - export function eq(a: string, b: string, opts: Opts = {}) { - return key(a, opts) === key(b, opts) - } - - export function contains(parent: string, child: string, opts: Opts = {}) { - const platform = pf(opts) - const mod = lib(platform) - const a = raw(parent, platform) - const b = raw(child, platform) - if (mod.isAbsolute(a) !== mod.isAbsolute(b)) return false - const rel = mod.relative(pretty(parent, opts), pretty(child, opts)) - return rel === "" || (!rel.startsWith("..") && !mod.isAbsolute(rel)) - } - - export function externalGlob(dir: string, opts: Opts = {}) { - return `${posix(dir, opts).replace(/\/+$/, "")}/*` - } - - export function match(input: string, value: PathKey, opts: Opts = {}) { - return key(input, opts) === value - } - - export async function truecase(input: string, opts: Omit = {}) { - const platform = pf(opts) - const text = pretty(input, opts) - if (platform !== "win32") return text - - const mod = path.win32 - const root = mod.parse(text).root - const rest = text.slice(root.length).split("\\").filter(Boolean) - let out = root - - for (const [idx, seg] of rest.entries()) { - const list = await readdir(out).catch(() => undefined) - if (!list) return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) - - const hit = list.find((item) => item.toLowerCase() === seg.toLowerCase()) - if (!hit) return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) - - out = mod.join(out, hit) - } - - return PrettyPath.make(clean(out, platform)) - } - - export function truecaseSync(input: string, opts: Omit = {}) { + export function isAbsolute(input: string, opts: Omit = {}) { const platform = pf(opts) - const text = pretty(input, opts) - if (platform !== "win32") return text - - const mod = path.win32 - const root = mod.parse(text).root - const rest = text.slice(root.length).split("\\").filter(Boolean) - let out = root - - for (const [idx, seg] of rest.entries()) { - let list: string[] | undefined - try { - list = readdirSync(out) - } catch { - return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) - } - - const hit = list.find((item) => item.toLowerCase() === seg.toLowerCase()) - if (!hit) return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) - - out = mod.join(out, hit) - } - - return PrettyPath.make(clean(out, platform)) - } - - export const physical = physicalAsync -} + return lib(platform).isAbsolute(raw(input, platform)) + } + + /** + * Returns the absolute app-facing path form. + * + * This resolves relative input against `cwd`, expands accepted Windows drive + * aliases, and preserves the host platform's separator style. + */ + export function pretty(input: string, opts: Opts = {}) { + return PrettyPath.make(prettyText(input, opts)) + } + + /** + * Returns the lookup/equality form for a path. + * + * On Windows the key is lower-cased so path comparisons match the + * filesystem's case-insensitive behavior. + */ + export function key(input: string, opts: Opts = {}) { + const platform = pf(opts) + const text = pretty(input, opts) + if (platform !== "win32") return PathKey.make(text) + return PathKey.make(text.toLowerCase()) + } + + export function posix(input: string, opts: Opts = {}) { + return PosixPath.make(pretty(input, opts).replaceAll("\\", "/")) + } + + /** + * Returns a stable serialized form. + * + * Windows absolute paths are rewritten to POSIX-style text so values can move + * across shells, config files, and URIs without backslash ambiguity. Relative + * paths are left unchanged because their meaning depends on the caller's base. + */ + export function canonical(input: string, opts: Opts = {}) { + const platform = pf(opts) + if (platform !== "win32") return input + if (!winabs(input)) return input + return posix(input, opts) + } + + export function expand(input: string, opts: HomeOpts = {}) { + return expandText(input, opts) + } + + /** + * Returns a user-facing path string. + * + * This optionally collapses paths under home to `~` and can render paths + * relative to `cwd`, but it never changes the underlying filesystem target. + */ + export function display(input: string, opts: DisplayOpts = {}) { + return displayText(input, opts) + } + + export function rel(from: string, to: string, opts: Opts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const text = mod.relative(pretty(from, opts), pretty(to, opts)) || "." + return RelativePath.make(text) + } + + export function repo(input: string) { + return RepoPath.make(repoText(input)) + } + + export function repoParent(input: string) { + return RepoPath.make(repoParentText(input)) + } + + export function repoName(input: string) { + return repoLeaf(input) + } + + export function repoDepth(input: string) { + const text = repoText(input).replace(/\/+$/, "") + if (text === ".") return 0 + return text.split("/").filter(Boolean).length + } + + export function repoIsDir(input: string) { + const text = repoText(input) + return text !== "." && text.endsWith("/") + } + + export function hidden(input: string) { + return hiddenText(input) + } + + /** + * Converts a path to a `file://` URI after first normalizing it with + * `pretty`, including UNC and Windows drive handling. + */ + export function uri(input: string, opts: Opts = {}) { + return FileURI.make(toURIText(pretty(input, opts), pf(opts))) + } + + /** + * Parses a `file://` URI into the same absolute native form returned by + * `pretty`, restoring UNC hosts and Windows drive letters when needed. + */ + export function fromURI(input: string, opts: Omit = {}) { + return PrettyPath.make(clean(fromURIText(input, pf(opts)), pf(opts))) + } + + export function eq(a: string, b: string, opts: Opts = {}) { + return key(a, opts) === key(b, opts) + } + + /** + * Checks directory containment after normalizing both inputs into the same + * platform rules. Relative and absolute paths never match each other. + */ + export function contains(parent: string, child: string, opts: Opts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const a = raw(parent, platform) + const b = raw(child, platform) + if (mod.isAbsolute(a) !== mod.isAbsolute(b)) return false + const rel = mod.relative(pretty(parent, opts), pretty(child, opts)) + return rel === "" || (!rel.startsWith("..") && !mod.isAbsolute(rel)) + } + + export function externalGlob(dir: string, opts: Opts = {}) { + return `${posix(dir, opts).replace(/\/+$/, "")}/*` + } + + export function match(input: string, value: PathKey, opts: Opts = {}) { + return key(input, opts) === value + } + + /** + * Rebuilds the path using the filesystem's recorded casing on Windows. + * + * Unlike `physical`, this does not resolve symlinks; it walks segments and + * keeps any missing tail as provided once the walk can no longer continue. + */ + export async function truecase(input: string, opts: Omit = {}) { + const platform = pf(opts) + const text = pretty(input, opts) + if (platform !== "win32") return text + + const mod = path.win32 + const root = mod.parse(text).root + const rest = text.slice(root.length).split("\\").filter(Boolean) + let out = root + + for (const [idx, seg] of rest.entries()) { + const list = await readdir(out).catch(() => undefined) + if (!list) return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) + + const hit = list.find((item) => item.toLowerCase() === seg.toLowerCase()) + if (!hit) return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) + + out = mod.join(out, hit) + } + + return PrettyPath.make(clean(out, platform)) + } + + export function truecaseSync(input: string, opts: Omit = {}) { + const platform = pf(opts) + const text = pretty(input, opts) + if (platform !== "win32") return text + + const mod = path.win32 + const root = mod.parse(text).root + const rest = text.slice(root.length).split("\\").filter(Boolean) + let out = root + + for (const [idx, seg] of rest.entries()) { + let list: string[] | undefined + try { + list = readdirSync(out) + } catch { + return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) + } + + const hit = list.find((item) => item.toLowerCase() === seg.toLowerCase()) + if (!hit) return PrettyPath.make(clean(mod.join(out, ...rest.slice(idx)), platform)) + + out = mod.join(out, hit) + } + + return PrettyPath.make(clean(out, platform)) + } + + /** + * Returns the best available on-disk path. + * + * This resolves symlinks via `realpath()` when possible, then falls back to + * resolving the deepest existing parent so callers still get a useful path for + * files or directories that are about to be created. + */ + export const physical = physicalAsync +} diff --git a/packages/opencode/src/path/schema.ts b/packages/opencode/src/path/schema.ts index 603808a0548a..17d8374439fb 100644 --- a/packages/opencode/src/path/schema.ts +++ b/packages/opencode/src/path/schema.ts @@ -2,6 +2,14 @@ import { Schema } from "effect" import { withStatics } from "@/util/schema" +/** + * These brands document which path shape a string is expected to already be in. + * The `make` helpers are intentionally unsafe; normalization lives in + * `src/path/path.ts`, while the brands keep different path forms from being + * mixed up by accident. + */ + +// Absolute, normalized, native-separator path used for most internal work. const prettyPathSchema = Schema.String.pipe(Schema.brand("PrettyPath")) export type PrettyPath = typeof prettyPathSchema.Type @@ -12,6 +20,7 @@ export const PrettyPath = prettyPathSchema.pipe( })), ) +// Equality/map key form. Windows values are case-folded before branding. const pathKeySchema = Schema.String.pipe(Schema.brand("PathKey")) export type PathKey = typeof pathKeySchema.Type @@ -22,6 +31,7 @@ export const PathKey = pathKeySchema.pipe( })), ) +// POSIX-slash form used where platform-neutral serialization matters. const posixPathSchema = Schema.String.pipe(Schema.brand("PosixPath")) export type PosixPath = typeof posixPathSchema.Type @@ -32,6 +42,7 @@ export const PosixPath = posixPathSchema.pipe( })), ) +// Relative path produced from one already-normalized path to another. const relativePathSchema = Schema.String.pipe(Schema.brand("RelativePath")) export type RelativePath = typeof relativePathSchema.Type @@ -42,6 +53,18 @@ export const RelativePath = relativePathSchema.pipe( })), ) +// Repository-relative path with stable forward slashes and optional trailing `/`. +const repoPathSchema = Schema.String.pipe(Schema.brand("RepoPath")) + +export type RepoPath = typeof repoPathSchema.Type + +export const RepoPath = repoPathSchema.pipe( + withStatics((schema: typeof repoPathSchema) => ({ + make: (input: string) => schema.makeUnsafe(input), + })), +) + +// `file://` URI string derived from a normalized filesystem path. const fileUriSchema = Schema.String.pipe(Schema.brand("FileURI")) export type FileURI = typeof fileUriSchema.Type diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 762ccb1e0830..2542c3c6f43b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -52,6 +52,7 @@ globalThis.AI_SDK_LOG_WARNINGS = false export namespace Server { const log = Log.create({ service: "server" }) + const os = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux" export const Default = lazy(() => createApp({})) @@ -289,6 +290,7 @@ export namespace Server { schema: resolver( z .object({ + os: z.enum(["macos", "windows", "linux"]), home: z.string(), state: z.string(), config: z.string(), @@ -306,6 +308,7 @@ export namespace Server { }), async (c) => { return c.json({ + os, home: Global.Path.home, state: Global.Path.state, config: Global.Path.config, diff --git a/packages/opencode/src/tool/ls.ts b/packages/opencode/src/tool/ls.ts index e3b896aba8f0..4aaec36d64c2 100644 --- a/packages/opencode/src/tool/ls.ts +++ b/packages/opencode/src/tool/ls.ts @@ -58,27 +58,24 @@ export const ListTool = Tool.define("list", { const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || []) const files = [] for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) { - files.push(file) + files.push(String(Path.repo(file))) if (files.length >= LIMIT) break } - // Build directory structure - const dirs = new Set() + const dirs = new Set(["."]) const filesByDir = new Map() for (const file of files) { - const dir = path.dirname(file) - const parts = dir === "." ? [] : dir.split("/") - - // Add all parent directories - for (let i = 0; i <= parts.length; i++) { - const dirPath = i === 0 ? "." : parts.slice(0, i).join("/") - dirs.add(dirPath) + const dir = String(Path.repoParent(file)) + let current = dir + while (true) { + dirs.add(current) + if (current === ".") break + current = String(Path.repoParent(current)) } - // Add file to its directory if (!filesByDir.has(dir)) filesByDir.set(dir, []) - filesByDir.get(dir)!.push(path.basename(file)) + filesByDir.get(dir)!.push(Path.repoName(file)) } function renderDir(dirPath: string, depth: number): string { @@ -86,20 +83,18 @@ export const ListTool = Tool.define("list", { let output = "" if (depth > 0) { - output += `${indent}${path.basename(dirPath)}/\n` + output += `${indent}${Path.repoName(dirPath)}/\n` } const childIndent = " ".repeat(depth + 1) const children = Array.from(dirs) - .filter((d) => path.dirname(d) === dirPath && d !== dirPath) + .filter((d) => d !== "." && d !== dirPath && String(Path.repoParent(d)) === dirPath) .sort() - // Render subdirectories first for (const child of children) { output += renderDir(child, depth + 1) } - // Render files const files = filesByDir.get(dirPath) || [] for (const file of files.sort()) { output += `${childIndent}${file}\n` @@ -108,7 +103,8 @@ export const ListTool = Tool.define("list", { return output } - const output = `${searchPath}/\n` + renderDir(".", 0) + const root = searchPath.endsWith(path.sep) ? searchPath : searchPath + path.sep + const output = `${root}\n` + renderDir(".", 0) return { title: String(Path.rel(Instance.worktree, searchPath)), diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 6ed0e4820241..93fe03c3add3 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -15,6 +15,7 @@ import { Process } from "../util/process" import { git } from "../util/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" +import { Path } from "@/path/path" export namespace Worktree { const log = Log.create({ service: "worktree" }) @@ -238,12 +239,12 @@ export namespace Worktree { } async function prune(root: string, entries: string[]) { - const base = await canonical(root) + const base = await Path.physical(root) await Promise.all( entries.map(async (entry) => { - const target = await canonical(path.resolve(root, entry)) - if (target === base) return - if (!target.startsWith(`${base}${path.sep}`)) return + const target = await Path.physical(path.resolve(root, entry)) + if (Path.eq(target, base)) return + if (!Path.contains(base, target)) return await fs.rm(target, { recursive: true, force: true }).catch(() => undefined) }), ) @@ -260,11 +261,8 @@ export namespace Worktree { return git(["clean", "-ffdx"], { cwd: root }) } - async function canonical(input: string) { - const abs = path.resolve(input) - const real = await fs.realpath(abs).catch(() => abs) - const normalized = path.normalize(real) - return process.platform === "win32" ? normalized.toLowerCase() : normalized + async function key(input: string) { + return Path.key(await Path.physical(input)) } async function candidate(root: string, base?: string) { @@ -436,7 +434,8 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const directory = await canonical(input.directory) + const target = await Path.physical(input.directory) + const directory = Path.key(target) const locate = async (stdout: Uint8Array | undefined) => { const lines = outputText(stdout) .split("\n") @@ -458,8 +457,7 @@ export namespace Worktree { return (async () => { for (const item of entries) { if (!item.path) continue - const key = await canonical(item.path) - if (key === directory) return item + if ((await key(item.path)) === directory) return item } })() } @@ -490,10 +488,10 @@ export namespace Worktree { const entry = await locate(list.stdout) if (!entry?.path) { - const directoryExists = await exists(directory) + const directoryExists = await exists(target) if (directoryExists) { - await stop(directory) - await clean(directory) + await stop(target) + await clean(target) } return true } @@ -534,8 +532,8 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const directory = await canonical(input.directory) - const primary = await canonical(Instance.worktree) + const directory = await key(input.directory) + const primary = await key(Instance.worktree) if (directory === primary) { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } @@ -565,8 +563,7 @@ export namespace Worktree { const entry = await (async () => { for (const item of entries) { if (!item.path) continue - const key = await canonical(item.path) - if (key === directory) return item + if ((await key(item.path)) === directory) return item } })() if (!entry?.path) { diff --git a/packages/opencode/test/cli/dir.test.ts b/packages/opencode/test/cli/dir.test.ts index 162f545f414c..9fb309e6c953 100644 --- a/packages/opencode/test/cli/dir.test.ts +++ b/packages/opencode/test/cli/dir.test.ts @@ -31,4 +31,37 @@ describe("cli dir", () => { expect(dir(String(Path.uri(child)), { remote: true })).toBe(child) }) + + test("normalizes remote UNC file uris without using the local os", () => { + expect(dir("file://server/share/code/../repo", { remote: true })).toBe("\\\\server\\share\\repo") + }) + + test("treats localhost remote file uris as local roots", () => { + expect(dir("file://localhost/C:/Users/me/code/../repo", { remote: true })).toBe("C:\\Users\\me\\repo") + expect(dir("file://localhost/srv/code/../repo", { remote: true })).toBe("/srv/repo") + }) + + test("preserves undecodable segments in remote file uris", () => { + expect(dir("file:///tmp/%ZZ/repo", { remote: true })).toBe("/tmp/%ZZ/repo") + }) + + test("normalizes remote windows absolute paths without using the local os", () => { + expect(dir("/C:/Users/me/code/../repo", { remote: true })).toBe("C:\\Users\\me\\repo") + }) + + test("normalizes remote native windows absolute paths without using the local os", () => { + expect(dir("C:\\Users\\me\\code\\..\\repo", { remote: true })).toBe("C:\\Users\\me\\repo") + }) + + test("normalizes remote windows drive roots without using the local os", () => { + expect(dir("/C:", { remote: true })).toBe("C:\\") + }) + + test("normalizes remote UNC backslash paths without using the local os", () => { + expect(dir("\\\\server\\share\\code\\..\\repo", { remote: true })).toBe("\\\\server\\share\\repo") + }) + + test("preserves remote posix absolute paths without using the local os", () => { + expect(dir("/srv/code/../repo", { remote: true })).toBe("/srv/repo") + }) }) diff --git a/packages/opencode/test/cli/tui/attach.test.ts b/packages/opencode/test/cli/tui/attach.test.ts index c089c822948a..093b1316a49d 100644 --- a/packages/opencode/test/cli/tui/attach.test.ts +++ b/packages/opencode/test/cli/tui/attach.test.ts @@ -47,12 +47,12 @@ describe("tui attach", () => { seen.inst.length = 0 }) - async function call(dir?: string) { + async function call(dir?: string, url = "http://localhost:4096") { const { AttachCommand } = await import("../../../src/cli/cmd/tui/attach") const args: Parameters>[0] = { _: [], $0: "opencode", - url: "http://localhost:4096", + url, dir, continue: false, session: undefined, @@ -94,4 +94,60 @@ describe("tui attach", () => { else process.env.PWD = pwd } }) + + test("keeps remote absolute directories off the local filesystem", async () => { + await using tmp = await tmpdir() + const cwd = process.cwd() + const pwd = process.env.PWD + const child = path.join(tmp.path, "child") + + try { + await fs.mkdir(child) + process.chdir(tmp.path) + process.env.PWD = tmp.path + await call(child, "http://10.0.0.8:4096") + expect(seen.inst[0]).toBe(tmp.path) + expect(seen.tui[0]).toBe(child) + } finally { + process.chdir(cwd) + if (pwd === undefined) delete process.env.PWD + else process.env.PWD = pwd + } + }) + + test("passes remote UNC directories through without using them for the local instance", async () => { + await using tmp = await tmpdir() + const cwd = process.cwd() + const pwd = process.env.PWD + + try { + process.chdir(tmp.path) + process.env.PWD = tmp.path + await call("file://server/share/code/../repo", "http://10.0.0.8:4096") + expect(seen.inst[0]).toBe(tmp.path) + expect(seen.tui[0]).toBe("\\\\server\\share\\repo") + } finally { + process.chdir(cwd) + if (pwd === undefined) delete process.env.PWD + else process.env.PWD = pwd + } + }) + + test("preserves undecodable remote file URI segments across the attach boundary", async () => { + await using tmp = await tmpdir() + const cwd = process.cwd() + const pwd = process.env.PWD + + try { + process.chdir(tmp.path) + process.env.PWD = tmp.path + await call("file:///tmp/%ZZ/repo", "http://10.0.0.8:4096") + expect(seen.inst[0]).toBe(tmp.path) + expect(seen.tui[0]).toBe("/tmp/%ZZ/repo") + } finally { + process.chdir(cwd) + if (pwd === undefined) delete process.env.PWD + else process.env.PWD = pwd + } + }) }) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 8f4cbe8688c9..2180612ed437 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -3,6 +3,7 @@ import { $ } from "bun" import path from "path" import fs from "fs/promises" import { File } from "../../src/file" +import { Path } from "../../src/path/path" import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -627,8 +628,21 @@ describe("file/index Filesystem patterns", () => { const nodes = await File.list("sub") expect(nodes.length).toBe(2) expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"]) - // Paths should be relative to project root (normalize for Windows) - expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true) + expect(nodes.every((n) => n.path.startsWith("sub/"))).toBe(true) + }, + }) + }) + + test("returns repo-normalized paths for nested entries", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.mkdir(path.join(tmp.path, "src")) + await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + expect(nodes.find((n) => n.name === "src")?.path).toBe("src") }, }) }) @@ -669,8 +683,10 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8") await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8") await fs.mkdir(path.join(tmp.path, "src")) + await fs.mkdir(path.join(tmp.path, "src", ".cache")) await fs.mkdir(path.join(tmp.path, ".hidden")) await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8") + await fs.writeFile(path.join(tmp.path, "src", ".cache", "tmp.ts"), "tmp", "utf-8") await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8") return tmp } @@ -699,9 +715,8 @@ describe("file/index Filesystem patterns", () => { const result = await File.search({ query: "", type: "directory" }) expect(result.length).toBeGreaterThan(0) - // Find first hidden dir index - const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1)) - const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1)) + const firstHidden = result.findIndex((d) => Path.hidden(d)) + const lastVisible = result.findLastIndex((d) => !Path.hidden(d)) if (firstHidden >= 0 && lastVisible >= 0) { expect(firstHidden).toBeGreaterThan(lastVisible) } @@ -785,6 +800,21 @@ describe("file/index Filesystem patterns", () => { }, }) }) + + test("query with a hidden prefix keeps hidden results first", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await File.init() + + const result = await File.search({ query: "src/.", type: "directory" }) + expect(result.length).toBeGreaterThan(0) + expect(result[0]).toContain("src/.cache") + }, + }) + }) }) describe("File.read() - diff/patch", () => { diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index fb8ceef7dcef..a84aaf7c5d90 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -32,6 +32,18 @@ describe("path", () => { expect(String(Path.pretty(item.path, { platform: "win32" }))).toBe(file) } }) + + test("normalizes Windows UNC alias forms", () => { + const file = "\\\\server\\share\\tmp\\file.txt" + for (const input of [file, "//server/share/tmp/file.txt", "file://server/share/tmp/file.txt"]) { + expect(String(Path.pretty(input, { platform: "win32" }))).toBe(file) + } + }) + + test("treats localhost file URIs as local files", () => { + expect(String(Path.pretty("file://localhost/C:/tmp/file.txt", { platform: "win32" }))).toBe("C:\\tmp\\file.txt") + expect(String(Path.pretty("file://localhost/tmp/file.txt", { platform: "linux" }))).toBe("/tmp/file.txt") + }) }) describe("isAbsolute()", () => { @@ -46,6 +58,12 @@ describe("path", () => { } }) + test("treats UNC paths as absolute on Windows", () => { + expect(Path.isAbsolute("\\\\server\\share\\repo", { platform: "win32" })).toBe(true) + expect(Path.isAbsolute("//server/share/repo", { platform: "win32" })).toBe(true) + expect(Path.isAbsolute("file://server/share/repo", { platform: "win32" })).toBe(true) + }) + test("keeps relative inputs relative", () => { expect(Path.isAbsolute("src/file.ts", { platform: "linux" })).toBe(false) expect(Path.isAbsolute("src/file.ts", { platform: "win32" })).toBe(false) @@ -70,6 +88,16 @@ describe("path", () => { expect(Path.match(item.path, key, { platform: "win32" })).toBe(true) } }) + + test("matches UNC alias forms on Windows", () => { + const file = "\\\\server\\share\\tmp\\file.txt" + const key = Path.key(file, { platform: "win32" }) + for (const input of ["//server/share/tmp/file.txt", "file://server/share/tmp/file.txt"]) { + expect(Path.key(input, { platform: "win32" })).toBe(key) + expect(Path.eq(file, input, { platform: "win32" })).toBe(true) + expect(Path.match(input, key, { platform: "win32" })).toBe(true) + } + }) }) describe("contains()", () => { @@ -87,6 +115,18 @@ describe("path", () => { } }) + test("matches UNC parents across alias forms on Windows", () => { + const dir = "\\\\server\\share\\repo" + for (const file of ["//server/share/repo/src/file.ts", "file://server/share/repo/src/file.ts"]) { + expect(Path.contains(dir, file, { platform: "win32" })).toBe(true) + } + }) + + test("rejects Windows cross-drive and cross-share mixes", () => { + expect(Path.contains("C:\\repo", "D:\\repo\\file.ts", { platform: "win32" })).toBe(false) + expect(Path.contains("\\\\server\\share\\repo", "\\\\server\\other\\repo\\file.ts", { platform: "win32" })).toBe(false) + }) + test("rejects absolute-relative path mixes", () => { expect(Path.contains("/repo", "repo/src/file.ts", { platform: "linux" })).toBe(false) expect(Path.contains("repo", "/repo/src/file.ts", { platform: "linux" })).toBe(false) @@ -103,6 +143,10 @@ describe("path", () => { expect(Path.externalGlob(item.path, { platform: "win32" })).toBe("C:/Users/Dev/tmp/*") } }) + + test("normalizes UNC directory globs", () => { + expect(Path.externalGlob("\\\\server\\share\\tmp\\", { platform: "win32" })).toBe("//server/share/tmp/*") + }) }) describe("canonical()", () => { @@ -117,6 +161,20 @@ describe("path", () => { expect(Path.canonical(item.glob, { platform: "win32" })).toBe("C:/Users/Dev/tmp/file.txt/*") } }) + + test("canonicalizes UNC alias paths to posix form", () => { + expect(Path.canonical("\\\\server\\share\\tmp\\file.txt", { platform: "win32" })).toBe("//server/share/tmp/file.txt") + expect(Path.canonical("file://server/share/tmp/file.txt", { platform: "win32" })).toBe("//server/share/tmp/file.txt") + }) + }) + + describe("guess()", () => { + test("detects remote Windows roots across slash variants", () => { + expect(Path.guess("C:\\repo\\file.ts")).toBe("win32") + expect(Path.guess("\\\\server\\share\\repo")).toBe("win32") + expect(Path.guess("/mnt/c/repo/file.ts")).toBe("win32") + expect(Path.guess("/srv/repo/file.ts")).toBe("linux") + }) }) describe("uri()", () => { @@ -133,6 +191,21 @@ describe("path", () => { expect(String(uri)).toBe("file:///C:/tmp/dir/a%20b.txt") expect(String(Path.fromURI(uri, { platform: "win32" }))).toBe(file) }) + + test("round-trips Windows UNC file URIs", () => { + const file = "\\\\server\\share\\tmp\\a b.txt" + const uri = Path.uri(file, { platform: "win32" }) + expect(String(uri)).toBe("file://server/share/tmp/a%20b.txt") + expect(String(Path.fromURI(uri, { platform: "win32" }))).toBe(file) + }) + + test("resolves repo-relative Windows paths with Windows URI rules", () => { + expect(String(Path.uri("src/file.ts", { cwd: "C:\\repo", platform: "windows" }))).toBe("file:///C:/repo/src/file.ts") + }) + + test("preserves undecodable URI segments", () => { + expect(String(Path.fromURI("file:///tmp/%ZZ/file.txt", { platform: "linux" }))).toBe("/tmp/%ZZ/file.txt") + }) }) describe("posix()", () => { @@ -149,6 +222,55 @@ describe("path", () => { test("returns branded relative paths", () => { expect(String(Path.rel("/repo", "/repo/src/file.ts", { platform: "linux" }))).toBe("src/file.ts") }) + + test("falls back to the absolute target across Windows drives", () => { + expect(String(Path.rel("C:\\repo", "D:\\repo\\file.ts", { platform: "win32" }))).toBe("D:\\repo\\file.ts") + }) + }) + + describe("repo()", () => { + test("normalizes repo keys to forward slashes", () => { + expect(String(Path.repo("src\\nested\\file.ts"))).toBe("src/nested/file.ts") + expect(String(Path.repo("./src//nested/"))).toBe("src/nested/") + }) + + test("preserves directory markers", () => { + expect(Path.repoIsDir("src\\nested\\")).toBe(true) + expect(Path.repoIsDir("src\\nested")).toBe(false) + }) + }) + + describe("repoParent()", () => { + test("returns repo parents with root dot", () => { + expect(String(Path.repoParent("src\\nested\\file.ts"))).toBe("src/nested") + expect(String(Path.repoParent("src\\nested\\"))).toBe("src") + expect(String(Path.repoParent("file.ts"))).toBe(".") + }) + }) + + describe("repoName()", () => { + test("returns repo basenames for files and directories", () => { + expect(Path.repoName("src\\nested\\file.ts")).toBe("file.ts") + expect(Path.repoName("src\\nested\\")).toBe("nested") + }) + }) + + describe("repoDepth()", () => { + test("counts repo path segments", () => { + expect(Path.repoDepth("src\\nested\\file.ts")).toBe(3) + expect(Path.repoDepth("src\\nested\\")).toBe(2) + expect(Path.repoDepth(".")).toBe(0) + }) + }) + + describe("hidden()", () => { + test("detects hidden segments across slash variants", () => { + expect(Path.hidden(".env")).toBe(true) + expect(Path.hidden("src/.git/config")).toBe(true) + expect(Path.hidden("src/.")).toBe(true) + expect(Path.hidden("src\\.cache\\tmp")).toBe(true) + expect(Path.hidden("src/visible/file.ts")).toBe(false) + }) }) describe("expand()", () => { diff --git a/packages/opencode/test/server/path-alias.test.ts b/packages/opencode/test/server/path-alias.test.ts index b543fe3765b0..20a5c23f7352 100644 --- a/packages/opencode/test/server/path-alias.test.ts +++ b/packages/opencode/test/server/path-alias.test.ts @@ -41,6 +41,7 @@ test("server ingress keeps alias directories", async () => { expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ + os: process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux", directory: alias, }) } finally { diff --git a/packages/opencode/test/tool/ls.test.ts b/packages/opencode/test/tool/ls.test.ts new file mode 100644 index 000000000000..4a3d45b35c42 --- /dev/null +++ b/packages/opencode/test/tool/ls.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { ListTool } from "../../src/tool/ls" +import { Instance } from "../../src/project/instance" +import { MessageID, SessionID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.ls", () => { + test("renders repo-key trees under a native root path", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, "src", "nested"), { recursive: true }) + await fs.writeFile(path.join(dir, "src", "nested", "file.ts"), "export {}\n") + await fs.writeFile(path.join(dir, "src", "root.ts"), "export {}\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await ListTool.init() + const result = await tool.execute({ path: tmp.path }, ctx) + + expect(result.output.startsWith(`${tmp.path}${path.sep}\n`)).toBe(true) + expect(result.output).toContain(" src/\n") + expect(result.output).toContain(" nested/\n") + expect(result.output).toContain(" file.ts\n") + expect(result.output).toContain(" root.ts\n") + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 41aa248171cb..d3fde5a3e96f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -18,6 +18,13 @@ export type EventInstallationUpdateAvailable = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + export type Project = { id: string worktree: string @@ -47,13 +54,6 @@ export type EventProjectUpdated = { properties: Project } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - export type EventServerInstanceDisposed = { type: "server.instance.disposed" properties: { @@ -195,6 +195,7 @@ export type EventLspClientDiagnostics = { properties: { serverID: string path: string + pathKey: string } } @@ -960,8 +961,8 @@ export type EventWorktreeFailed = { export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventProjectUpdated | EventFileEdited + | EventProjectUpdated | EventServerInstanceDisposed | EventFileWatcherUpdated | EventPermissionAsked @@ -1881,6 +1882,7 @@ export type McpStatus = | McpStatusNeedsClientRegistration export type Path = { + os: "macos" | "windows" | "linux" home: string state: string config: string diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index c47fd338336e..83c8fc450278 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -9,9 +9,10 @@ import { IconButton } from "./icon-button" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Tooltip } from "./tooltip" import { ScrollView } from "./scroll-view" +import { useData } from "../context" import { useFileComponent } from "../context/file" import { useI18n } from "../context/i18n" -import { getDirectory, getFilename, pathEqual, pathKey } from "@opencode-ai/util/path" +import { getDirectory, getFilename, getRelativeDisplayPath, pathEqual, pathKey } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js" import { onCleanup } from "solid-js" @@ -137,6 +138,7 @@ const key = (path: string) => pathKey(path) || path export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined let focusToken = 0 + const data = useData() const i18n = useI18n() const fileComponent = useFileComponent() const anchors = new Map() @@ -290,7 +292,7 @@ export const SessionReview = (props: SessionReviewProps) => { let wrapper: HTMLDivElement | undefined const item = createMemo(() => diffs().get(key(file))!) - const dir = createMemo(() => getDirectory(file)) + const dir = createMemo(() => getDirectory(getRelativeDisplayPath(file, data.directory))) const expanded = createMemo(() => open().some((item) => pathEqual(item, file))) const force = () => !!store.force[file] diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 11092f2caa5e..0edda4c13b93 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -4,7 +4,7 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { Binary } from "@opencode-ai/util/binary" -import { getDirectory, getFilename, pathEqual, pathKey } from "@opencode-ai/util/path" +import { getDirectory, getFilename, getRelativeDisplayPath, pathEqual, pathKey } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -467,7 +467,7 @@ export function SessionTurn( {(diff) => { const active = createMemo(() => expanded().some((item) => pathEqual(item, diff.file))) - const dir = createMemo(() => getDirectory(diff.file)) + const dir = createMemo(() => getDirectory(getRelativeDisplayPath(diff.file, data.directory))) const [visible, setVisible] = createSignal(false) createEffect( diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index e116199eb233..b5b67a94e4e9 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,8 +1,9 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2" +import type { FileDiff, Message, Part, Path, ProviderListResponse, Session, SessionStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" type Data = { + path?: Path provider?: ProviderListResponse session: Session[] session_status: { @@ -41,6 +42,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ get directory() { return props.directory }, + get path() { + return props.data.path + }, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, } diff --git a/packages/util/src/path.test.ts b/packages/util/src/path.test.ts index 5a73fac2dfe3..3c05ba7265be 100644 --- a/packages/util/src/path.test.ts +++ b/packages/util/src/path.test.ts @@ -49,6 +49,12 @@ describe("path display helpers", () => { expect(getParentPath("\\\\server\\share")).toBe("//server/share") }) + test("keeps windows root forms stable for lexical navigation", () => { + expect(getPathRoot("C:")).toBe("C:/") + expect(getParentPath("C:")).toBe("C:/") + expect(getParentPath("C:/")).toBe("C:/") + }) + test("builds picker display text with tilde and native separators", () => { expect(getPathDisplay("/Users/dev/repo", "", "/Users/dev")).toBe("~/repo") expect(getPathDisplaySeparator("~/repo", "/Users/dev")).toBe("/") @@ -79,17 +85,22 @@ describe("path display helpers", () => { test("relativizes display paths from the workspace root", () => { expect(getRelativeDisplayPath("/repo/src/app.ts", "/repo")).toBe("/src/app.ts") expect(getRelativeDisplayPath("C:\\repo\\src\\", "C:\\repo")).toBe("\\src\\") + expect(getRelativeDisplayPath("src/app.ts", "C:\\repo")).toBe("src\\app.ts") + expect(getRelativeDisplayPath("src\\app.ts", "/repo")).toBe("src/app.ts") + expect(getRelativeDisplayPath("C:/other/app.ts", "C:\\repo")).toBe("C:\\other\\app.ts") expect(getRelativeDisplayPath("/other/app.ts", "/repo")).toBe("/other/app.ts") }) test("resolves and relativizes workspace paths across platforms", () => { expect(resolveWorkspacePath("/repo", "src/app.ts")).toBe("/repo/src/app.ts") expect(resolveWorkspacePath("C:\\repo", "src\\app.ts")).toBe("C:\\repo\\src\\app.ts") + expect(resolveWorkspacePath("\\\\server\\share\\repo", "src\\app.ts")).toBe("\\\\server\\share\\repo\\src\\app.ts") expect(resolveWorkspacePath("/repo", "/tmp/app.ts")).toBe("/tmp/app.ts") expect(getWorkspaceRelativePath("/repo/src/app.ts", "/repo")).toBe("src/app.ts") expect(getWorkspaceRelativePath("C:/repo/src/app.ts", "C:\\repo")).toBe("src/app.ts") expect(getWorkspaceRelativePath("c:\\repo\\src\\app.ts", "C:\\repo")).toBe("src\\app.ts") + expect(getWorkspaceRelativePath("//server/share/repo/src/app.ts", "\\\\server\\share\\repo")).toBe("src/app.ts") expect(getWorkspaceRelativePath("/tmp/app.ts", "/repo")).toBe("/tmp/app.ts") }) @@ -98,6 +109,7 @@ describe("path display helpers", () => { expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") expect(decodeFilePath("src/file%23name%20here.ts")).toBe("src/file#name here.ts") + expect(decodeFilePath("src/%ZZ/file.ts")).toBe("src/%ZZ/file.ts") expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt") expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname") }) diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index 41925432812b..4291e4d762e7 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -1,3 +1,12 @@ +/** + * UI/string-oriented path helpers shared across packages. + * + * This file normalizes separators, display text, and lightweight comparisons, + * but it is not the source of truth for filesystem-aware resolution, + * `file://` parsing, or Windows alias handling. Those semantics live in + * `packages/opencode/src/path/path.ts`. + */ + export function getFilename(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[\/\\]+$/, "") @@ -50,6 +59,13 @@ const trailing = (path: string, home: string) => { return path + separator } +const resep = (path: string, separator: "/" | "\\", trailing: boolean) => { + const text = normalizePath(path).replace(/\/+$/, "") + if (!text) return trailing ? separator : "" + const value = separator === "/" ? text : text.replaceAll("/", "\\") + return trailing ? value + separator : value +} + export function normalizePath(path: string) { if (!path) return "" if (isUncPath(path)) return `//${path.slice(2).replace(/[\\/]+/g, "/")}` @@ -300,17 +316,18 @@ export function getPathScope(input: string, start: string | undefined, home: str export function getRelativeDisplayPath(path: string, root?: string) { if (!path) return "" if (!root) return path - if (root === "/" || root === "\\") return path + if (root === "/" || root === "\\") return resep(path, getPathSeparator(root), /[\\/]+$/.test(path)) - const separator = getPathSeparator(path || root) + const separator = getPathSeparator(root) const trailing = /[\\/]+$/.test(path) const full = normalizePath(path).replace(/\/+$/, "") const base = normalizePath(root).replace(/\/+$/, "") - if (!base) return path + if (!base) return resep(path, separator, trailing) + if (!getPathRoot(full)) return resep(full, separator, trailing) if (fold(full) === fold(base)) return trailing ? separator : "" const prefix = `${base}/` - if (!fold(full).startsWith(fold(prefix))) return path + if (!fold(full).startsWith(fold(prefix))) return resep(full, separator, trailing) const relative = full.slice(base.length).replace(/^\/+/, "") if (!relative) return trailing ? separator : "" From 4ad5755408a5fe7e373de2163503eef6185a1500 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:02:49 +1000 Subject: [PATCH 29/42] wip --- .../dialog-select-directory.test.ts | 12 +- .../components/dialog-select-directory.tsx | 44 ++------ packages/app/src/context/layout.tsx | 29 +++-- packages/app/src/context/server.tsx | 32 ++++-- packages/app/src/pages/layout.tsx | 104 +++++++++++------- packages/app/src/utils/persist-path.test.ts | 18 +-- packages/app/src/utils/persist-path.ts | 45 ++++---- .../cmd/tui/component/prompt/autocomplete.tsx | 74 +++++++------ .../cli/cmd/tui/component/prompt/frecency.tsx | 32 ++++-- .../src/cli/cmd/tui/context/directory.ts | 10 +- .../src/cli/cmd/tui/routes/session/index.tsx | 19 ++-- .../cli/cmd/tui/routes/session/permission.tsx | 17 ++- .../cli/cmd/tui/routes/session/sidebar.tsx | 11 +- .../opencode/src/cli/cmd/tui/util/path.ts | 45 ++++++++ packages/opencode/test/cli/tui/path.test.ts | 33 +++++- packages/util/src/path.test.ts | 22 +++- packages/util/src/path.ts | 41 ++++++- 17 files changed, 370 insertions(+), 218 deletions(-) diff --git a/packages/app/src/components/dialog-select-directory.test.ts b/packages/app/src/components/dialog-select-directory.test.ts index 8670db63d520..ddb42caa6dfe 100644 --- a/packages/app/src/components/dialog-select-directory.test.ts +++ b/packages/app/src/components/dialog-select-directory.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test" import { getParentPath, + joinPath, getPathDisplay, getPathDisplaySeparator, getPathRoot, getPathScope, getPathSearchText, + trimPrettyPath, } from "@opencode-ai/util/path" describe("dialog select directory display", () => { @@ -35,15 +37,21 @@ describe("dialog select directory display", () => { test("keeps UNC scoped search rooted at the share", () => { expect(getPathScope("\\\\server\\share", "C:/Users/dev", "C:/Users/dev")).toEqual({ - directory: "//server/share", + directory: "\\\\server\\share", path: "", }) expect(getPathScope("\\\\server\\share\\repo", "C:/Users/dev", "C:/Users/dev")).toEqual({ - directory: "//server/share", + directory: "\\\\server\\share", path: "repo", }) }) + test("keeps pretty paths native while joining search results", () => { + expect(trimPrettyPath("C:/Users/dev/repo/")).toBe("C:\\Users\\dev\\repo") + expect(joinPath("C:\\Users\\dev", "repo/src")).toBe("C:\\Users\\dev\\repo\\src") + expect(joinPath("\\\\server\\share", "repo")).toBe("\\\\server\\share\\repo") + }) + test("indexes UNC paths in slash and native forms", () => { const search = getPathSearchText("//server/share/repo", "C:\\Users\\dev") expect(search).toContain("//server/share/repo") diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index f009b1ba3183..425cccbba868 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -6,14 +6,16 @@ import type { ListRef } from "@opencode-ai/ui/list" import { getDirectory, getFilename, + joinPath, + normalizeInputPath, getParentPath, getPathDisplay, getPathDisplaySeparator, getPathRoot, getPathScope, getPathSearchText, - normalizePath, pathKey, + trimPrettyPath, } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" @@ -34,36 +36,13 @@ type Row = { group: "recent" | "folders" } -function trim(path: string) { - const normalized = normalize(path) - if (!normalized) return "" - const root = getPathRoot(normalized) - if (root && normalized === root) return root - return normalized.replace(/\/+$/, "") || root || "/" -} - -function normalize(path: string) { - const normalized = normalizePath(path) - if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}/` - return normalized -} - -function join(base: string | undefined, rel: string) { - const root = trim(base ?? "") - const path = trim(rel).replace(/^\/+/, "") - if (!root) return path - if (!path) return root - if (root.endsWith("/")) return root + path - return `${root}/${path}` -} - function cleanInput(value: string) { const first = (value ?? "").split(/\r?\n/)[0] ?? "" return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() } function toRow(absolute: string, home: string, group: Row["group"]): Row { - const full = trim(absolute) + const full = trimPrettyPath(absolute) return { absolute: full, search: getPathSearchText(full, home), group } } @@ -96,12 +75,13 @@ function useDirectorySearch(args: { let current = 0 const dirs = async (dir: string) => { - const key = trim(dir) + const path = trimPrettyPath(dir) + const key = pathKey(path) || path const existing = cache.get(key) if (existing) return existing const request = args.sdk.client.file - .list({ directory: key, path: "" }) + .list({ directory: path, path }) .then((x) => x.data ?? []) .catch(() => []) .then((nodes) => @@ -109,7 +89,7 @@ function useDirectorySearch(args: { .filter((n) => n.type === "directory") .map((n) => ({ name: n.name, - absolute: trim(n.absolute), + absolute: trimPrettyPath(n.absolute), })), ) @@ -131,9 +111,9 @@ function useDirectorySearch(args: { const scopedInput = getPathScope(value, args.start(), args.home()) if (!scopedInput) return [] as string[] - const raw = normalize(value) + const raw = normalizeInputPath(value) const isPath = raw.startsWith("~") || !!getPathRoot(raw) || /[\\/]/.test(value) - const query = normalize(scopedInput.path) + const query = normalizeInputPath(scopedInput.path) const find = () => args.sdk.client.find @@ -144,7 +124,7 @@ function useDirectorySearch(args: { if (!isPath) { const results = await find() if (!active()) return [] - return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) + return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") @@ -170,7 +150,7 @@ function useDirectorySearch(args: { const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() if (!active()) return [] const deduped = unique(out) - const base = raw.startsWith("~") ? trim(scopedInput.directory) : "" + const base = raw.startsWith("~") ? scopedInput.directory : "" const expand = !raw.endsWith("/") if (!expand || !tail) { const items = base ? unique([base, ...deduped]) : deduped diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index d6db9625f5d1..00b22e798b2e 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -18,16 +18,17 @@ const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] a const DEFAULT_PANEL_WIDTH = 344 const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_TERMINAL_HEIGHT = 280 -const reviewPath = (path: string) => pathKey(path) || path +const reviewKey = (path: string) => pathKey(path) || path +const workspaceKey = (path: string) => pathKey(path) || path const reviewPaths = (paths: readonly string[]) => { const seen = new Set() const out: string[] = [] for (const path of paths) { - const key = reviewPath(path) - if (seen.has(key)) continue - seen.add(key) - out.push(key) + const id = reviewKey(path) + if (seen.has(id)) continue + seen.add(id) + out.push(path) } return out @@ -608,14 +609,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("sidebar", "width", width) }, workspaces(directory: string) { - return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false + return () => store.sidebar.workspaces[workspaceKey(directory)] ?? store.sidebar.workspacesDefault ?? false }, setWorkspaces(directory: string, value: boolean) { - setStore("sidebar", "workspaces", directory, value) + setStore("sidebar", "workspaces", workspaceKey(directory), value) }, toggleWorkspaces(directory: string) { - const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false - setStore("sidebar", "workspaces", directory, !current) + const key = workspaceKey(directory) + const current = store.sidebar.workspaces[key] ?? store.sidebar.workspacesDefault ?? false + setStore("sidebar", "workspaces", key, !current) }, }, terminal: { @@ -818,7 +820,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("sessionView", session, "reviewOpen", next) }, openPath(path: string) { - path = reviewPath(path) const session = key() const current = store.sessionView[session] if (!current) { @@ -834,7 +835,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return } - if (current.reviewOpen.some((item) => pathEqual(item, path))) return + const index = current.reviewOpen.findIndex((item) => pathEqual(item, path)) + if (index !== -1) { + if (current.reviewOpen[index] === path) return + setStore("sessionView", session, "reviewOpen", index, path) + return + } + setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path) }, closePath(path: string) { diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index e970a54636d7..f1ee73e098b9 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -9,6 +9,23 @@ import { useCheckServerHealth } from "@/utils/server-health" type StoredProject = { worktree: string; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http const HEALTH_POLL_INTERVAL_MS = 10_000 +const worktreeKey = (input: string) => pathKey(input) || input + +function projectIndex(list: StoredProject[], worktree: string) { + return list.findIndex((item) => pathEqual(item.worktree, worktree)) +} + +function upsertProject(list: StoredProject[], worktree: string) { + const index = projectIndex(list, worktree) + if (index === -1) return [{ worktree, expanded: true }, ...list] + + const current = list[index] + if (current?.worktree === worktree && current.expanded) return list + + const next = [...list] + next[index] = { ...current, worktree, expanded: true } + return next +} export function normalizeServerUrl(input: string) { const trimmed = input.trim() @@ -35,8 +52,6 @@ function isLocalHost(url: string) { if (host === "localhost" || host === "127.0.0.1") return "local" } -const dir = (input: string) => pathKey(input) || input - export namespace ServerConnection { type Base = { displayName?: string } @@ -215,9 +230,9 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const list = store.projects[origin()] ?? [] const seen = new Set() return list.filter((project) => { - const key = pathKey(project.worktree) - if (seen.has(key)) return false - seen.add(key) + const id = worktreeKey(project.worktree) + if (seen.has(id)) return false + seen.add(id) return true }) }) @@ -253,10 +268,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( open(directory: string) { const key = origin() if (!key) return - const current = store.projects[key] ?? [] - const worktree = dir(directory) - if (current.some((x) => pathEqual(x.worktree, worktree))) return - setStore("projects", key, [{ worktree, expanded: true }, ...current]) + setStore("projects", key, upsertProject(store.projects[key] ?? [], directory)) }, close(directory: string) { const key = origin() @@ -301,7 +313,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( touch(directory: string) { const key = origin() if (!key) return - setStore("lastProject", key, dir(directory)) + setStore("lastProject", key, directory) }, }, } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8be67fb13430..a50d8b5c50aa 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -95,13 +95,33 @@ import { import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" +type StoredRoute = { + directory: string + id: string + at: number +} + +const workspacePathKey = (input: string) => workspaceKey(input) || input + +function mergeWorkspaceOrder(root: string, list: string[]) { + const seen = new Set([workspacePathKey(root)]) + const out: string[] = [] + + for (const directory of list) { + const id = workspacePathKey(directory) + if (seen.has(id)) continue + seen.add(id) + out.push(directory) + } + + return out +} + export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( { ...Persist.global("layout.page", ["layout.page.v1"]), migrate: migrateLayoutPageState }, createStore({ - lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } }, - activeProject: undefined as string | undefined, - activeWorkspace: undefined as string | undefined, + lastProjectSession: {} as Record, workspaceOrder: {} as Record, workspaceName: {} as Record, workspaceBranchName: {} as Record>, @@ -141,11 +161,14 @@ export default function Layout(props: ParentProps) { } const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const currentDir = createMemo(() => decode64(params.dir) ?? "") - const normalize = (input: string) => workspaceKey(input) || input const [state, setState] = createStore({ autoselect: !initialDirectory, busyWorkspaces: {} as Record, + drag: { + project: undefined as string | undefined, + workspace: undefined as string | undefined, + }, hoverSession: undefined as string | undefined, hoverProject: undefined as string | undefined, scrollSessionKey: undefined as string | undefined, @@ -158,7 +181,7 @@ export default function Layout(props: ParentProps) { const editor = createInlineEditorController() const setBusy = (directory: string, value: boolean) => { - const key = workspaceKey(directory) + const key = workspacePathKey(directory) if (value) { setState("busyWorkspaces", key, true) return @@ -170,7 +193,7 @@ export default function Layout(props: ParentProps) { }), ) } - const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)] + const isBusy = (directory: string) => !!state.busyWorkspaces[workspacePathKey(directory)] const navLeave = { current: undefined as number | undefined } const sortNow = () => state.sortNow let sizet: number | undefined @@ -583,7 +606,7 @@ export default function Layout(props: ParentProps) { }) const workspaceName = (directory: string, projectId?: string, branch?: string) => { - const direct = store.workspaceName[normalize(directory)] + const direct = store.workspaceName[workspacePathKey(directory)] if (direct) return direct if (!projectId) return if (!branch) return @@ -591,7 +614,7 @@ export default function Layout(props: ParentProps) { } const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { - const key = normalize(directory) + const key = workspacePathKey(directory) setStore("workspaceName", key, next) if (!projectId) return if (!branch) return @@ -618,7 +641,7 @@ export default function Layout(props: ParentProps) { const activeDir = currentDir() return workspaceIds(project).filter((directory) => { - const expanded = store.workspaceExpanded[normalize(directory)] ?? workspaceEqual(directory, project.worktree) + const expanded = store.workspaceExpanded[workspacePathKey(directory)] ?? workspaceEqual(directory, project.worktree) const active = workspaceEqual(directory, activeDir) return expanded || active }) @@ -1183,12 +1206,12 @@ export default function Layout(props: ParentProps) { } function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { - setStore("lastProjectSession", normalize(root), { directory: normalize(directory), id, at: Date.now() }) + setStore("lastProjectSession", workspacePathKey(root), { directory, id, at: Date.now() }) return root } function clearLastProjectSession(root: string) { - const key = normalize(root) + const key = workspacePathKey(root) if (!store.lastProjectSession[key]) return setStore( "lastProjectSession", @@ -1201,7 +1224,7 @@ export default function Layout(props: ParentProps) { function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { rememberSessionRoute(directory, id, root) notification.session.markViewed(id) - const key = normalize(directory) + const key = workspacePathKey(directory) const expanded = untrack(() => store.workspaceExpanded[key]) if (expanded === false) { setStore("workspaceExpanded", key, true) @@ -1216,11 +1239,11 @@ export default function Layout(props: ParentProps) { server.projects.touch(root) const project = layout.projects.list().find((item) => workspaceEqual(item.worktree, root)) let dirs = project - ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[normalize(root)]) + ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[workspacePathKey(root)]) : [root] const canOpen = (value: string | undefined) => { if (!value) return false - return dirs.some((item) => workspaceKey(item) === workspaceKey(value)) + return dirs.some((item) => workspacePathKey(item) === workspacePathKey(value)) } const refreshDirs = async (target?: string) => { if (!target || workspaceEqual(target, root) || canOpen(target)) return canOpen(target) @@ -1228,7 +1251,7 @@ export default function Layout(props: ParentProps) { .list({ directory: root }) .then((x) => x.data ?? []) .catch(() => [] as string[]) - dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[normalize(root)]) + dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[workspacePathKey(root)]) return canOpen(target) } const openSession = async (target: { directory: string; id: string }) => { @@ -1250,7 +1273,7 @@ export default function Layout(props: ParentProps) { return true } - const projectSession = store.lastProjectSession[normalize(root)] + const projectSession = store.lastProjectSession[workspacePathKey(root)] if (projectSession?.id) { await refreshDirs(projectSession.directory) const opened = await openSession(projectSession) @@ -1436,7 +1459,10 @@ export default function Layout(props: ParentProps) { if (!result) return - if (workspaceKey(store.lastProjectSession[normalize(root)]?.directory ?? "") === workspaceKey(directory)) { + if ( + workspacePathKey(store.lastProjectSession[workspacePathKey(root)]?.directory ?? "") === + workspacePathKey(directory) + ) { clearLastProjectSession(root) } @@ -1448,7 +1474,7 @@ export default function Layout(props: ParentProps) { project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => !workspaceEqual(sandbox, directory)) }), ) - setStore("workspaceOrder", normalize(root), (order) => + setStore("workspaceOrder", workspacePathKey(root), (order) => (order ?? []).filter((workspace) => !workspaceEqual(workspace, directory)), ) @@ -1458,12 +1484,12 @@ export default function Layout(props: ParentProps) { if (shouldLeave) return const nextCurrent = currentDir() - const nextKey = workspaceKey(nextCurrent) + const nextKey = workspacePathKey(nextCurrent) const project = layout.projects.list().find((item) => workspaceEqual(item.worktree, root)) const dirs = project - ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[normalize(root)]) + ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[workspacePathKey(root)]) : [root] - const valid = dirs.some((item) => workspaceKey(item) === nextKey) + const valid = dirs.some((item) => workspacePathKey(item) === nextKey) if (params.dir && workspaceEqual(projectRoot(nextCurrent), root) && !valid) { navigateWithSidebarReset(`/${base64Encode(root)}/session`) @@ -1756,7 +1782,7 @@ export default function Layout(props: ParentProps) { const id = getDraggableId(event) if (!id) return setHoverProject(undefined) - setStore("activeProject", id) + setState("drag", "project", id) } function handleDragOver(event: DragEvent) { @@ -1772,7 +1798,7 @@ export default function Layout(props: ParentProps) { } function handleDragEnd() { - setStore("activeProject", undefined) + setState("drag", "project", undefined) } function workspaceIds(project: LocalProject | undefined) { @@ -1787,7 +1813,7 @@ export default function Layout(props: ParentProps) { : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false - const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[normalize(project.worktree)]) + const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[workspacePathKey(project.worktree)]) if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)] if (!extra) return ordered if (pending) return ordered @@ -1804,7 +1830,7 @@ export default function Layout(props: ParentProps) { function handleWorkspaceDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return - setStore("activeWorkspace", id) + setState("drag", "workspace", id) } function handleWorkspaceDragOver(event: DragEvent) { @@ -1826,13 +1852,16 @@ export default function Layout(props: ParentProps) { result.splice(toIndex, 0, item) setStore( "workspaceOrder", - project.worktree, - result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)), + workspacePathKey(project.worktree), + mergeWorkspaceOrder( + project.worktree, + result.filter((directory) => workspacePathKey(directory) !== workspacePathKey(project.worktree)), + ), ) } function handleWorkspaceDragEnd() { - setStore("activeWorkspace", undefined) + setState("drag", "workspace", undefined) } const createWorkspace = async (project: LocalProject) => { @@ -1853,18 +1882,13 @@ export default function Layout(props: ParentProps) { setWorkspaceName(created.directory, created.branch, project.id, created.branch) const local = project.worktree - const key = normalize(created.directory) - const root = normalize(local) + const key = workspacePathKey(created.directory) setBusy(created.directory, true) WorktreeState.pending(created.directory) setStore("workspaceExpanded", key, true) - setStore("workspaceOrder", normalize(project.worktree), (prev) => { - const existing = (prev ?? []).map(normalize) - const next = existing.filter((item) => { - return item !== root && item !== key - }) - return [key, ...next] + setStore("workspaceOrder", workspacePathKey(project.worktree), (prev) => { + return mergeWorkspaceOrder(local, [created.directory, ...(prev ?? [])]) }) globalSync.child(created.directory) @@ -1890,8 +1914,8 @@ export default function Layout(props: ParentProps) { setEditor, InlineEditor, isBusy, - workspaceExpanded: (directory, local) => store.workspaceExpanded[normalize(directory)] ?? local, - setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", normalize(directory), value), + workspaceExpanded: (directory, local) => store.workspaceExpanded[workspacePathKey(directory)] ?? local, + setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", workspacePathKey(directory), value), showResetWorkspaceDialog: (root, directory) => dialog.show(() => ), showDeleteWorkspaceDialog: (root, directory) => @@ -2194,7 +2218,7 @@ export default function Layout(props: ParentProps) { store.activeWorkspace} + activeWorkspace={() => state.drag.workspace} workspaceLabel={workspaceLabel} /> @@ -2239,7 +2263,7 @@ export default function Layout(props: ParentProps) { } const projects = () => layout.projects.list() - const projectOverlay = () => store.activeProject} /> + const projectOverlay = () => state.drag.project} /> const sidebarContent = (mobile?: boolean) => ( { }) describe("migrateLayoutPageState", () => { - test("normalizes keyed and embedded workspace state", () => { + test("keeps path values while normalizing workspace keys", () => { expect( migrateLayoutPageState({ activeProject: "C:\\Repo\\", @@ -54,13 +54,13 @@ describe("migrateLayoutPageState", () => { }, }), ).toEqual({ - activeProject: "c:/repo", - activeWorkspace: "c:/repo/feature", + activeProject: "C:\\Repo\\", + activeWorkspace: "C:/Repo/Feature/", lastProjectSession: { - "c:/repo": { directory: "c:/repo/feature", id: "new", at: 2 }, + "c:/repo": { directory: "c:\\repo\\feature\\", id: "new", at: 2 }, }, workspaceOrder: { - "c:/repo": ["c:/repo/feature", "c:/repo/other"], + "c:/repo": ["C:/Repo/Feature/", "c:\\repo\\other\\"], }, workspaceName: { "c:/repo/feature": "feature latest", @@ -73,7 +73,7 @@ describe("migrateLayoutPageState", () => { }) describe("migrateServerState", () => { - test("normalizes persisted server project worktrees and last project values", () => { + test("keeps persisted server path values while deduping by key", () => { expect( migrateServerState({ projects: { @@ -90,12 +90,12 @@ describe("migrateServerState", () => { ).toEqual({ projects: { local: [ - { worktree: "c:/repo", expanded: true }, - { worktree: "/tmp/demo", expanded: true }, + { worktree: "C:\\Repo\\", expanded: true }, + { worktree: "/tmp/demo///", expanded: true }, ], }, lastProject: { - local: "c:/repo", + local: "C:/Repo/", }, }) }) diff --git a/packages/app/src/utils/persist-path.ts b/packages/app/src/utils/persist-path.ts index b9c21c336bd9..89e26dd128f9 100644 --- a/packages/app/src/utils/persist-path.ts +++ b/packages/app/src/utils/persist-path.ts @@ -9,12 +9,12 @@ const num = (value: unknown) => (typeof value === "number" && Number.isFinite(va const flag = (value: unknown) => (typeof value === "boolean" ? value : undefined) -const dir = (value: string) => pathKey(value) || value +const pathId = (value: string) => pathKey(value) || value -function dirs(value: unknown, skip?: string) { +function paths(value: unknown, skip?: string) { if (!Array.isArray(value)) return - const omit = skip ? dir(skip) : undefined + const omit = skip ? pathId(skip) : undefined const seen = new Set() const out: string[] = [] @@ -22,16 +22,16 @@ function dirs(value: unknown, skip?: string) { const cur = text(item) if (!cur) continue - const next = dir(cur) - if (next === omit || seen.has(next)) continue - seen.add(next) - out.push(next) + const id = pathId(cur) + if (id === omit || seen.has(id)) continue + seen.add(id) + out.push(cur) } return out } -function byDir( +function byKey( value: unknown, move: (value: unknown, key: string) => T | undefined, merge?: (prev: T, next: T) => T, @@ -39,8 +39,8 @@ function byDir( if (!record(value)) return const out: Record = {} - for (const [key, item] of Object.entries(value)) { - const id = dir(key) + for (const [name, item] of Object.entries(value)) { + const id = pathId(name) const next = move(item, id) if (next === undefined) continue @@ -67,7 +67,7 @@ function route(value: unknown): Route | undefined { const at = num(value.at) return { ...value, - directory: dir(directory), + directory, id, ...(at !== undefined ? { at } : {}), } @@ -86,7 +86,7 @@ function project(value: unknown): Project | undefined { return { ...value, - worktree: dir(worktree), + worktree, expanded: flag(value.expanded) ?? false, } } @@ -101,9 +101,10 @@ function projects(value: unknown) { const next = project(item) if (!next) continue - const at = seen.get(next.worktree) + const id = pathId(next.worktree) + const at = seen.get(id) if (at === undefined) { - seen.set(next.worktree, out.length) + seen.set(id, out.length) out.push(next) continue } @@ -131,7 +132,7 @@ export function migrateLayoutPaths(value: unknown) { } } - const workspaces = byDir(sidebar.workspaces, (item) => flag(item)) + const workspaces = byKey(sidebar.workspaces, (item) => flag(item)) if (!workspaces) return value return { @@ -146,21 +147,17 @@ export function migrateLayoutPaths(value: unknown) { export function migrateLayoutPageState(value: unknown) { if (!record(value)) return value - const lastProjectSession = byDir(value.lastProjectSession, (item) => route(item), (prev, next) => { + const lastProjectSession = byKey(value.lastProjectSession, (item) => route(item), (prev, next) => { if ((next.at ?? -Infinity) >= (prev.at ?? -Infinity)) return next return prev }) - const workspaceOrder = byDir(value.workspaceOrder, (item, key) => dirs(item, key) ?? []) - const workspaceName = byDir(value.workspaceName, (item) => text(item)) - const workspaceExpanded = byDir(value.workspaceExpanded, (item) => flag(item)) - const activeProject = text(value.activeProject) - const activeWorkspace = text(value.activeWorkspace) + const workspaceOrder = byKey(value.workspaceOrder, (item, id) => paths(item, id) ?? []) + const workspaceName = byKey(value.workspaceName, (item) => text(item)) + const workspaceExpanded = byKey(value.workspaceExpanded, (item) => flag(item)) return { ...value, - activeProject: activeProject ? dir(activeProject) : activeProject, - activeWorkspace: activeWorkspace ? dir(activeWorkspace) : activeWorkspace, ...(lastProjectSession ? { lastProjectSession } : {}), ...(workspaceOrder ? { workspaceOrder } : {}), ...(workspaceName ? { workspaceName } : {}), @@ -176,7 +173,7 @@ export function migrateServerState(value: unknown) { Object.entries(value.lastProject).flatMap(([key, item]) => { const next = text(item) if (!next) return [] - return [[key, dir(next)] as const] + return [[key, next] as const] }), ) : undefined diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index f53d84d8726c..cbddb468b727 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -13,6 +13,7 @@ import { Locale } from "@/util/locale" import { Path } from "@/path/path" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" +import { serverPathOpts } from "../../util/path" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -244,19 +245,22 @@ export function Autocomplete(props: { return a.localeCompare(b) }) - const width = props.anchor().width - 4 - options.push( - ...sortedFiles.map((item): AutocompleteOption => { - const urlObj = new URL( - String( - Path.uri(item, { - cwd: sync.data.path.directory || process.cwd(), - platform: sync.data.path.os, - }), - ), - ) - const key = String(Path.repo(item)) - const isDir = Path.repoIsDir(key) + const width = props.anchor().width - 4 + const platform = Path.platform(sync.data.path.os) + const cwd = sync.data.path.directory + options.push( + ...sortedFiles.flatMap((item) => { + if (!cwd && !Path.isAbsolute(item, { platform })) return [] + + const urlObj = new URL( + String( + Path.uri(item, { + ...serverPathOpts(sync.data.path), + }), + ), + ) + const key = String(Path.repo(item)) + const isDir = Path.repoIsDir(key) let filename = key if (lineRange && !isDir) { filename = `${key}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` @@ -267,29 +271,31 @@ export function Autocomplete(props: { } const url = urlObj.href - return { - display: Locale.truncateMiddle(filename, width), - value: filename, - isDirectory: isDir, - path: key, - onSelect: () => { - insertPart(filename, { - type: "file", - mime: "text/plain", - filename, - url, - source: { + return [ + { + display: Locale.truncateMiddle(filename, width), + value: filename, + isDirectory: isDir, + path: key, + onSelect: () => { + insertPart(filename, { type: "file", - text: { - start: 0, - end: 0, - value: "", + mime: "text/plain", + filename, + url, + source: { + type: "file", + text: { + start: 0, + end: 0, + value: "", + }, + path: key, }, - path: key, - }, - }) - }, - } + }) + }, + } satisfies AutocompleteOption, + ] }), ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx index 3ea8826ef8bd..d9319c27e365 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx @@ -5,6 +5,15 @@ import { onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../../context/helper" import { appendFile, writeFile } from "fs/promises" +import { useSync } from "../../context/sync" +import { serverPathKey } from "../../util/path" + +type Entry = { + key?: string + path: string + frequency: number + lastOpen: number +} function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number { if (!entry) return 0 @@ -18,6 +27,7 @@ const MAX_FRECENCY_ENTRIES = 1000 export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({ name: "Frecency", init: () => { + const sync = useSync() const frecencyPath = path.join(Global.Path.state, "frecency.jsonl") onMount(async () => { const text = await Filesystem.readText(frecencyPath).catch(() => "") @@ -26,19 +36,19 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont .filter(Boolean) .map((line) => { try { - return JSON.parse(line) as { path: string; frequency: number; lastOpen: number } + return JSON.parse(line) as Entry } catch { return null } }) - .filter((line): line is { path: string; frequency: number; lastOpen: number } => line !== null) + .filter((line): line is Entry => line !== null) const latest = lines.reduce( (acc, entry) => { - acc[entry.path] = entry + acc[entry.key ?? entry.path] = entry return acc }, - {} as Record, + {} as Record, ) const sorted = Object.values(latest) @@ -48,7 +58,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont setStore( "data", Object.fromEntries( - sorted.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]), + sorted.map((entry) => [entry.key ?? entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]), ), ) @@ -63,26 +73,26 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont }) function updateFrecency(filePath: string) { - const absolutePath = path.resolve(process.cwd(), filePath) + const key = serverPathKey(filePath, sync.data.path) const newEntry = { - frequency: (store.data[absolutePath]?.frequency || 0) + 1, + frequency: (store.data[key]?.frequency || 0) + 1, lastOpen: Date.now(), } - setStore("data", absolutePath, newEntry) - appendFile(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {}) + setStore("data", key, newEntry) + appendFile(frecencyPath, JSON.stringify({ key, path: filePath, ...newEntry }) + "\n").catch(() => {}) if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) { const sorted = Object.entries(store.data) .sort(([, a], [, b]) => b.lastOpen - a.lastOpen) .slice(0, MAX_FRECENCY_ENTRIES) setStore("data", Object.fromEntries(sorted)) - const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n" + const content = sorted.map(([key, entry]) => JSON.stringify({ key, path: key, ...entry })).join("\n") + "\n" writeFile(frecencyPath, content).catch(() => {}) } } return { - getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(process.cwd(), filePath)]), + getFrecency: (filePath: string) => calculateFrecency(store.data[serverPathKey(filePath, sync.data.path)]), updateFrecency, data: () => store.data, } diff --git a/packages/opencode/src/cli/cmd/tui/context/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts index 71ebcb67b743..71939697d053 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -1,16 +1,12 @@ import { createMemo } from "solid-js" import { useSync } from "./sync" -import { Global } from "@/global" -import { formatPath } from "../util/path" +import { formatServerPath } from "../util/path" export function useDirectory() { const sync = useSync() return createMemo(() => { - const directory = sync.data.path.directory || process.cwd() - const result = formatPath(directory, { - home: Global.Path.home, - }) - if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch + const result = formatServerPath(sync.data.path.directory, sync.data.path) + if (sync.data.vcs?.branch) return result ? result + ":" + sync.data.vcs.branch : sync.data.vcs.branch return result }) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 483fee61d91c..fbaa57ed99f2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -75,14 +75,13 @@ import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" -import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" -import { formatPath } from "../../util/path" +import { formatPath, formatServerPath } from "../../util/path" addDefaultParsers(parsers.parsers) @@ -1118,7 +1117,7 @@ export function Session() { {(file) => ( - {file.filename} + {normalizePath(file.filename)} 0}> +{file.additions} @@ -2132,10 +2131,10 @@ function ApplyPatch(props: ToolProps) { } function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { - if (file.type === "delete") return "# Deleted " + file.relativePath - if (file.type === "add") return "# Created " + file.relativePath - if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath - return "← Patched " + file.relativePath + if (file.type === "delete") return "# Deleted " + normalizePath(file.relativePath) + if (file.type === "add") return "# Created " + normalizePath(file.relativePath) + if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + normalizePath(file.relativePath) + return "← Patched " + normalizePath(file.relativePath) } return ( @@ -2255,11 +2254,7 @@ function Diagnostics(props: { diagnostics?: Record[] } function normalizePath(input?: string) { - return formatPath(input, { - cwd: process.cwd(), - home: Global.Path.home, - relative: true, - }) + return formatServerPath(input, use().sync.data.path, { relative: true }) } function input(input: Record, omit?: string[]): string { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 8c39bab7b99f..d6acdb730ddc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -13,19 +13,15 @@ import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" -import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { useTuiConfig } from "../../context/tui-config" -import { formatPath } from "../../util/path" +import { formatServerPath } from "../../util/path" type PermissionStage = "permission" | "always" | "reject" function normalizePath(input?: string) { - return formatPath(input, { - cwd: process.cwd(), - home: Global.Path.home, - relative: true, - }) + const sync = useSync() + return formatServerPath(input, sync.data.path, { relative: true }) } function filetype(input?: string) { @@ -158,7 +154,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { {(pattern) => ( {"- "} - {pattern} + {normalizePath(pattern)} )} @@ -354,8 +350,9 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined const pattern = props.request.patterns?.[0] + const mod = sync.data.path.os === "windows" ? path.win32 : path.posix const derived = - typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined + typeof pattern === "string" ? (pattern.includes("*") ? mod.dirname(pattern) : pattern) : undefined const raw = parent ?? filepath ?? derived const dir = normalizePath(raw) @@ -369,7 +366,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { Patterns - {(p) => {"- " + p}} + {(p) => {"- " + normalizePath(p)}} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 34c0110b8def..e6f1aa7bf3db 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -7,7 +7,7 @@ import { Installation } from "@/installation" import { Path } from "@/path/path" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" -import { formatPath, splitPath } from "../../util/path" +import { formatPath, formatServerPath, splitPath } from "../../util/path" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() @@ -60,12 +60,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const kv = useKV() const dir = createMemo(() => { - return formatPath(sync.data.path.directory || process.cwd(), { - home: sync.data.path.home, - platform: platform(), - }) + return formatServerPath(sync.data.path.directory, sync.data.path) }) - const dirparts = createMemo(() => splitPath(dir())) + const dirparts = createMemo(() => splitPath(dir(), { platform: platform() })) const hasProviders = createMemo(() => sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), @@ -253,7 +250,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { return ( - {item.file} + {formatServerPath(item.file, sync.data.path)} diff --git a/packages/opencode/src/cli/cmd/tui/util/path.ts b/packages/opencode/src/cli/cmd/tui/util/path.ts index a5d718b7708c..2cf1cd1a7b17 100644 --- a/packages/opencode/src/cli/cmd/tui/util/path.ts +++ b/packages/opencode/src/cli/cmd/tui/util/path.ts @@ -8,6 +8,12 @@ type Opts = { relative?: boolean } +type Server = { + directory?: string + home?: string + os?: Opts["platform"] +} + function pf(opts: Opts = {}) { return Path.platform(opts.platform) } @@ -16,6 +22,45 @@ function lib(platform: NodeJS.Platform) { return platform === "win32" ? path.win32 : path.posix } +function text(input?: string) { + return input || undefined +} + +export function serverPathOpts(input: Server, opts: Pick = {}): Opts { + const cwd = text(input.directory) + return { + cwd, + home: text(input.home), + platform: input.os, + relative: opts.relative && !!cwd, + } +} + +export function formatServerPath(input: string | undefined, server: Server, opts: Pick = {}) { + if (!input) return "" + + const cfg = serverPathOpts(server, opts) + const platform = pf(cfg) + if (cfg.cwd || Path.isAbsolute(input, { platform })) { + return formatPath(input, cfg) + } + + if (platform === "win32") return path.win32.normalize(input) + return path.posix.normalize(input.replaceAll("\\", "/")) +} + +export function serverPathKey(input: string, server: Server) { + const opts = serverPathOpts(server) + const platform = pf(opts) + if (opts.cwd || Path.isAbsolute(input, { platform })) { + return String(Path.key(input, { cwd: opts.cwd, platform })) + } + + const text = String(Path.repo(input)) + if (platform !== "win32") return text + return text.toLowerCase() +} + export function formatPath(input?: string, opts: Opts = {}) { if (!input) return "" return Path.display(input, { diff --git a/packages/opencode/test/cli/tui/path.test.ts b/packages/opencode/test/cli/tui/path.test.ts index 315fb8cebd77..9b7f7474ef97 100644 --- a/packages/opencode/test/cli/tui/path.test.ts +++ b/packages/opencode/test/cli/tui/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { formatPath, plugin, splitPath } from "../../../src/cli/cmd/tui/util/path" +import { formatPath, formatServerPath, plugin, serverPathKey, splitPath } from "../../../src/cli/cmd/tui/util/path" describe("tui path", () => { test("formats Windows absolute paths without a leading slash", () => { @@ -21,6 +21,37 @@ describe("tui path", () => { ).toBe("src\\file.ts") }) + test("formats server-relative paths without local cwd fallback", () => { + expect( + formatServerPath("src/file.ts", { + os: "linux", + }), + ).toBe("src/file.ts") + + expect( + formatServerPath("src/file.ts", { + directory: "C:\\Users\\me\\code\\opencode", + home: "C:\\Users\\me", + os: "windows", + }, { relative: true }), + ).toBe("src\\file.ts") + }) + + test("keys server paths with server cwd semantics", () => { + expect( + serverPathKey("src\\FILE.ts", { + directory: "C:\\Users\\me\\code\\opencode", + os: "windows", + }), + ).toBe("c:\\users\\me\\code\\opencode\\src\\file.ts") + + expect( + serverPathKey("src\\FILE.ts", { + os: "windows", + }), + ).toBe("src/file.ts") + }) + test("shortens home with directory boundaries", () => { expect( formatPath("C:\\Users\\me\\code\\opencode", { diff --git a/packages/util/src/path.test.ts b/packages/util/src/path.test.ts index 3c05ba7265be..d227f9b9e4b7 100644 --- a/packages/util/src/path.test.ts +++ b/packages/util/src/path.test.ts @@ -4,6 +4,8 @@ import { encodeFilePath, getDirectory, getFilename, + joinPath, + normalizeInputPath, getParentPath, getPathDisplay, getPathDisplaySeparator, @@ -16,6 +18,8 @@ import { resolveWorkspacePath, stripFileProtocol, stripQueryAndHash, + trimPath, + trimPrettyPath, unquoteGitPath, } from "./path" @@ -55,6 +59,20 @@ describe("path display helpers", () => { expect(getParentPath("C:/")).toBe("C:/") }) + test("normalizes input paths separately from pretty stored paths", () => { + expect(normalizeInputPath("C:")).toBe("C:/") + expect(trimPath("\\\\server\\share\\repo\\")).toBe("//server/share/repo") + expect(trimPrettyPath("C:/Users/dev/repo/")).toBe("C:\\Users\\dev\\repo") + expect(trimPrettyPath("\\\\server\\share\\repo\\")).toBe("\\\\server\\share\\repo") + }) + + test("joins pretty paths with native separators", () => { + expect(joinPath("/Users/dev", "repo/src")).toBe("/Users/dev/repo/src") + expect(joinPath("C:\\Users\\dev", "repo/src")).toBe("C:\\Users\\dev\\repo\\src") + expect(joinPath("\\\\server\\share", "repo")).toBe("\\\\server\\share\\repo") + expect(joinPath("C:\\Users\\dev", "C:/tmp/demo")).toBe("C:\\tmp\\demo") + }) + test("builds picker display text with tilde and native separators", () => { expect(getPathDisplay("/Users/dev/repo", "", "/Users/dev")).toBe("~/repo") expect(getPathDisplaySeparator("~/repo", "/Users/dev")).toBe("/") @@ -66,11 +84,11 @@ describe("path display helpers", () => { test("scopes picker input from home or absolute roots", () => { expect(getPathScope("\\\\server\\share\\repo", "C:/Users/dev", "C:/Users/dev")).toEqual({ - directory: "//server/share", + directory: "\\\\server\\share", path: "repo", }) expect(getPathScope("~/code", "C:/Users/dev", "C:/Users/dev")).toEqual({ - directory: "C:/Users/dev", + directory: "C:\\Users\\dev", path: "code", }) }) diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index 4291e4d762e7..72dda884a9ed 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -26,13 +26,42 @@ const normalizeDrive = (path: string) => { return normalized } -const trimPath = (path: string) => { +const trimDisplay = (path: string) => { + const separator = getPathSeparator(path) + return separator === "/" ? path : path.replaceAll("/", "\\") +} + +export function normalizeInputPath(path: string) { + return normalizeDrive(path) +} + +export function trimPath(path: string) { const normalized = normalizeDrive(path) if (normalized === "/" || normalized === "//") return normalized if (/^[A-Za-z]:\/$/.test(normalized)) return normalized return normalized.replace(/\/+$/, "") } +export function trimPrettyPath(path: string) { + const trimmed = trimPath(path) + if (!trimmed) return "" + return trimDisplay(trimmed) +} + +export function joinPath(base: string | undefined, path: string) { + if (getPathRoot(path)) return trimPrettyPath(path) + + const root = trimPrettyPath(base ?? "") + const leaf = trimPath(path).replace(/^\/+/, "") + if (!root) return trimDisplay(leaf) + if (!leaf) return root + + const separator = getPathSeparator(root) + const value = separator === "/" ? leaf : leaf.replaceAll("/", "\\") + if (root.endsWith(separator)) return root + value + return `${root}${separator}${value}` +} + const mode = (path: string) => { const normalized = normalizeDrive(path.trim()) if (!normalized) return "relative" as const @@ -297,18 +326,18 @@ export function getPathSearchText(path: string, home: string) { } export function getPathScope(input: string, start: string | undefined, home: string) { - const base = start ? trimPath(start) : "" + const base = start ? trimPrettyPath(start) : "" if (!base) return - const normalized = normalizeDrive(input) + const normalized = normalizeInputPath(input) if (!normalized) return { directory: base, path: "" } - if (normalized === "~") return { directory: trimPath(home || base), path: "" } - if (normalized.startsWith("~/")) return { directory: trimPath(home || base), path: normalized.slice(2) } + if (normalized === "~") return { directory: trimPrettyPath(home || base), path: "" } + if (normalized.startsWith("~/")) return { directory: trimPrettyPath(home || base), path: normalized.slice(2) } const root = getPathRoot(normalized) if (!root) return { directory: base, path: normalized } return { - directory: trimPath(root), + directory: trimPrettyPath(root), path: normalized.slice(root.length).replace(/^\/+/, ""), } } From 4fd3802a20bf85999b3d822b2e7b9458c48ca1dd Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:43:08 +1000 Subject: [PATCH 30/42] path: strengthen typed backend path models --- .../src/control-plane/adaptors/worktree.ts | 3 +- packages/opencode/src/control-plane/types.ts | 5 +- .../src/control-plane/workspace.sql.ts | 3 +- .../opencode/src/control-plane/workspace.ts | 5 +- packages/opencode/src/file/index.ts | 65 +++++++++++-------- packages/opencode/src/path/migrate.ts | 5 +- packages/opencode/src/project/project.sql.ts | 5 +- packages/opencode/src/project/project.ts | 34 +++++----- packages/opencode/src/session/index.ts | 18 +++-- packages/opencode/src/session/session.sql.ts | 3 +- packages/opencode/src/session/summary.ts | 3 +- packages/opencode/src/snapshot/index.ts | 3 +- packages/opencode/src/tool/edit.ts | 2 +- .../session-proxy-middleware.test.ts | 3 +- .../test/control-plane/workspace-sync.test.ts | 3 +- packages/opencode/test/file/index.test.ts | 2 +- packages/opencode/test/path/migrate.test.ts | 25 +++---- .../test/project/migrate-global.test.ts | 15 +++-- .../opencode/test/project/project.test.ts | 42 ++++++------ .../opencode/test/server/session-list.test.ts | 5 +- .../test/storage/json-migration.test.ts | 4 +- packages/opencode/test/tool/edit.test.ts | 3 +- 22 files changed, 147 insertions(+), 109 deletions(-) diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index f84890950115..525b0ba0de33 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,4 +1,5 @@ import z from "zod" +import { Path } from "@/path/path" import { Worktree } from "@/worktree" import { type Adaptor, WorkspaceInfo } from "../types" @@ -17,7 +18,7 @@ export const WorktreeAdaptor: Adaptor = { ...info, name: worktree.name, branch: worktree.branch, - directory: worktree.directory, + directory: await Path.truecase(worktree.directory), } }, async create(info) { diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index ab628a693814..abb983f6cda6 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,4 +1,5 @@ import z from "zod" +import type { PrettyPath } from "@/path/schema" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" @@ -11,7 +12,9 @@ export const WorkspaceInfo = z.object({ extra: z.unknown().nullable(), projectID: ProjectID.zod, }) -export type WorkspaceInfo = z.infer +export type WorkspaceInfo = Omit, "directory"> & { + directory: PrettyPath | null +} export type Adaptor = { configure(input: WorkspaceInfo): WorkspaceInfo | Promise diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index 272907da1500..df3655bdb95f 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -1,6 +1,7 @@ import { sqliteTable, text } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" +import type { PrettyPath } from "../path/schema" import type { WorkspaceID } from "./schema" export const WorkspaceTable = sqliteTable("workspace", { @@ -8,7 +9,7 @@ export const WorkspaceTable = sqliteTable("workspace", { type: text().notNull(), branch: text(), name: text(), - directory: text(), + directory: text().$type(), extra: text({ mode: "json" }), project_id: text() .$type() diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 32835407ccdf..1c57810e906a 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -12,6 +12,7 @@ import { WorkspaceInfo } from "./types" import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" import { Path } from "@/path/path" +import { PrettyPath } from "@/path/schema" export namespace Workspace { export const Event = { @@ -32,10 +33,10 @@ export namespace Workspace { export const Info = WorkspaceInfo.meta({ ref: "Workspace", }) - export type Info = z.infer + export type Info = WorkspaceInfo function fix(input: string) { - if (!input) return input + if (!input) return PrettyPath.make(input) return Path.truecaseSync(input) } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index d1b8ed7f3810..b1ce8278f2eb 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -15,6 +15,7 @@ import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" +import type { PrettyPath, RepoPath } from "@/path/schema" export namespace File { export const Info = z @@ -28,7 +29,9 @@ export namespace File { ref: "File", }) - export type Info = z.infer + export type Info = Omit, "path"> & { + path: RepoPath + } export const Node = z .object({ @@ -41,7 +44,10 @@ export namespace File { .meta({ ref: "FileNode", }) - export type Node = z.infer + export type Node = Omit, "path" | "absolute"> & { + path: RepoPath + absolute: PrettyPath + } export const Content = z .object({ @@ -91,11 +97,11 @@ export namespace File { return runPromiseInstance(Service.use((svc) => svc.status())) } - export async function read(file: string): Promise { + export async function read(file: RepoPath | string): Promise { return runPromiseInstance(Service.use((svc) => svc.read(file))) } - export async function list(dir?: string) { + export async function list(dir?: RepoPath | string) { return runPromiseInstance(Service.use((svc) => svc.list(dir))) } @@ -310,7 +316,7 @@ export namespace File { heif: "image/heif", } - type Entry = { files: string[]; dirs: string[] } + type Entry = { files: RepoPath[]; dirs: RepoPath[] } const ext = (file: string) => path.extname(file).toLowerCase().slice(1) const name = (file: string) => path.basename(file).toLowerCase() @@ -331,10 +337,10 @@ export namespace File { return ["image", "audio", "video", "font", "model", "multipart"].includes(top) } - const sortHiddenLast = (items: string[], prefer: boolean) => { + const sortHiddenLast = (items: T[], prefer: boolean) => { if (prefer) return items - const visible: string[] = [] - const hiddenItems: string[] = [] + const visible: T[] = [] + const hiddenItems: T[] = [] for (const item of items) { if (Path.hidden(item)) hiddenItems.push(item) else visible.push(item) @@ -345,14 +351,14 @@ export namespace File { export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect - readonly read: (file: string) => Effect.Effect - readonly list: (dir?: string) => Effect.Effect + readonly read: (file: RepoPath | string) => Effect.Effect + readonly list: (dir?: RepoPath | string) => Effect.Effect readonly search: (input: { query: string limit?: number dirs?: boolean type?: "file" | "directory" - }) => Effect.Effect + }) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/File") {} @@ -379,7 +385,7 @@ export namespace File { yield* Effect.promise(async () => { if (isGlobalHome) { - const dirs = new Set() + const dirs = new Set() const protectedNames = Protected.names() const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) @@ -389,14 +395,14 @@ export namespace File { for (const entry of top) { if (!entry.isDirectory()) continue if (shouldIgnoreName(entry.name)) continue - dirs.add(String(Path.repo(`${entry.name}/`))) + dirs.add(Path.repo(`${entry.name}/`)) const base = path.join(instance.directory, entry.name) const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) for (const child of children) { if (!child.isDirectory()) continue if (shouldIgnoreNested(child.name)) continue - dirs.add(String(Path.repo(`${entry.name}/${child.name}/`))) + dirs.add(Path.repo(`${entry.name}/${child.name}/`)) } } @@ -404,18 +410,18 @@ export namespace File { } else { const seen = new Set() for await (const file of Ripgrep.files({ cwd: instance.directory })) { - const key = String(Path.repo(file)) + const key = Path.repo(file) next.files.push(key) - let dir = String(Path.repoParent(key)) + let dir = Path.repoParent(key) while (dir !== ".") { - const item = String(Path.repo(`${dir}/`)) + const item = Path.repo(`${dir}/`) if (seen.has(item)) { - dir = String(Path.repoParent(dir)) + dir = Path.repoParent(dir) continue } seen.add(item) next.dirs.push(item) - dir = String(Path.repoParent(dir)) + dir = Path.repoParent(dir) } } } @@ -449,7 +455,7 @@ export namespace File { }) ).text() - const changed: File.Info[] = [] + const changed: Array & { path: string }> = [] if (diffOutput.trim()) { for (const line of diffOutput.trim().split("\n")) { @@ -529,13 +535,13 @@ export namespace File { const full = Path.pretty(item.path, { cwd: instance.directory }) return { ...item, - path: String(Path.repo(Path.rel(instance.directory, full))), + path: Path.repo(Path.rel(instance.directory, full)), } }) }) }) - const read = Effect.fn("File.read")(function* (file: string) { + const read = Effect.fn("File.read")(function* (file: RepoPath | string) { return yield* Effect.promise(async (): Promise => { using _ = log.time("read", { file }) const full = path.join(instance.directory, file) @@ -616,7 +622,7 @@ export namespace File { }) }) - const list = Effect.fn("File.list")(function* (dir?: string) { + const list = Effect.fn("File.list")(function* (dir?: RepoPath | string) { return yield* Effect.promise(async () => { const exclude = [".git", ".DS_Store"] let ignored = (_: string) => false @@ -641,15 +647,15 @@ export namespace File { const nodes: File.Node[] = [] for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { if (exclude.includes(entry.name)) continue - const absolute = path.join(resolved, entry.name) - const file = String(Path.repo(Path.rel(instance.directory, absolute))) + const absolute = Path.pretty(path.join(resolved, entry.name)) + const file = Path.repo(Path.rel(instance.directory, absolute)) const type = entry.isDirectory() ? "directory" : "file" nodes.push({ name: entry.name, path: file, absolute, type, - ignored: ignored(type === "directory" ? String(Path.repo(`${file}/`)) : file), + ignored: ignored(type === "directory" ? Path.repo(`${file}/`) : file), }) } @@ -685,10 +691,13 @@ export namespace File { const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) - const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted + const output = + kind === "directory" + ? sortHiddenLast(sorted, preferHidden).slice(0, limit) + : sorted log.info("search", { query, kind, results: output.length }) - return output + return output as RepoPath[] }) }) diff --git a/packages/opencode/src/path/migrate.ts b/packages/opencode/src/path/migrate.ts index 4e27ac76d0f9..a8911dbf1934 100644 --- a/packages/opencode/src/path/migrate.ts +++ b/packages/opencode/src/path/migrate.ts @@ -1,6 +1,7 @@ import path from "path" import { ProjectTable } from "@/project/project.sql" import { Path } from "@/path/path" +import { PrettyPath } from "@/path/schema" import { Global } from "@/global" import { Database, eq } from "@/storage/db" import { Filesystem } from "@/util/filesystem" @@ -35,7 +36,7 @@ export namespace PathMigration { } function fix(input: string) { - if (!input || input === "/") return input + if (!input || input === "/") return PrettyPath.make(input) return Path.truecaseSync(input) } @@ -50,7 +51,7 @@ export namespace PathMigration { function uniq(list: string[], worktree?: string) { const seen = new Set() - const out: string[] = [] + const out: PrettyPath[] = [] for (const item of list) { const dir = fix(item) if (dir && worktree && Path.eq(dir, worktree)) continue diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts index efbc400b5eec..0f3aa4c1abb3 100644 --- a/packages/opencode/src/project/project.sql.ts +++ b/packages/opencode/src/project/project.sql.ts @@ -1,16 +1,17 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" import { Timestamps } from "../storage/schema.sql" import type { ProjectID } from "./schema" +import type { PrettyPath } from "../path/schema" export const ProjectTable = sqliteTable("project", { id: text().$type().primaryKey(), - worktree: text().notNull(), + worktree: text().$type().notNull(), vcs: text(), name: text(), icon_url: text(), icon_color: text(), ...Timestamps, time_initialized: integer(), - sandboxes: text({ mode: "json" }).notNull().$type(), + sandboxes: text({ mode: "json" }).notNull().$type(), commands: text({ mode: "json" }).$type<{ start?: string }>(), }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 8add25bcc6a4..a9372507dba9 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -16,12 +16,13 @@ import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" import { Path } from "@/path/path" +import { PrettyPath } from "@/path/schema" export namespace Project { const log = Log.create({ service: "project" }) function fix(input: string) { - if (!input || input === "/") return input + if (!input || input === "/") return PrettyPath.make(input) return Path.truecaseSync(input) } @@ -30,9 +31,9 @@ export namespace Project { return Path.eq(a, b) } - function uniq(list: string[]) { + function uniq(list: readonly string[]) { const seen = new Set() - const out: string[] = [] + const out: PrettyPath[] = [] for (const item of list) { const dir = fix(item) const key = dir === "/" ? dir : Path.key(dir) @@ -44,10 +45,10 @@ export namespace Project { } async function gitpath(cwd: string, name: string) { - if (!name) return cwd + if (!name) return fix(cwd) // git output includes trailing newlines; keep path whitespace intact. name = name.replace(/[\r\n]+$/, "") - if (!name) return cwd + if (!name) return fix(cwd) name = Filesystem.windowsPath(name) @@ -83,7 +84,10 @@ export namespace Project { .meta({ ref: "Project", }) - export type Info = z.infer + export type Info = Omit, "worktree" | "sandboxes"> & { + worktree: PrettyPath + sandboxes: PrettyPath[] + } export const Event = { Updated: BusEvent.define("project.updated", Info), @@ -123,12 +127,12 @@ export namespace Project { directory = await Path.truecase(directory) log.info("fromDirectory", { directory }) - const data = await iife(async () => { + const data: { id: ProjectID; worktree: PrettyPath; sandbox: PrettyPath; vcs: Info["vcs"] } = await iife(async () => { const matches = Filesystem.up({ targets: [".git"], start: directory }) const dotgit = await matches.next().then((x) => x.value) await matches.return() if (dotgit) { - let sandbox: string = await Path.truecase(path.dirname(dotgit)) + let sandbox = await Path.truecase(path.dirname(dotgit)) const gitBinary = which("git") @@ -236,20 +240,20 @@ export namespace Project { return { id: ProjectID.global, - worktree: "/", - sandbox: "/", + worktree: PrettyPath.make("/"), + sandbox: PrettyPath.make("/"), vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), } }) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) - const existing = row + const existing: Info = row ? fromRow(row) : { id: data.id, worktree: data.worktree, - vcs: data.vcs as Info["vcs"], - sandboxes: [] as string[], + vcs: data.vcs, + sandboxes: [], time: { created: Date.now(), updated: Date.now(), @@ -262,7 +266,7 @@ export namespace Project { ...existing, worktree: data.worktree, sandboxes: uniq(existing.sandboxes), - vcs: data.vcs as Info["vcs"], + vcs: data.vcs, time: { ...existing.time, updated: Date.now(), @@ -426,7 +430,7 @@ export namespace Project { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return [] const data = fromRow(row) - const valid: string[] = [] + const valid: PrettyPath[] = [] for (const dir of data.sandboxes) { const s = Filesystem.stat(dir) if (s?.isDirectory() && !valid.some((item) => same(item, dir))) valid.push(dir) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 8f7c2833272e..bc8d7eba73aa 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -33,6 +33,7 @@ import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" import { iife } from "@/util/iife" import { Path } from "@/path/path" +import { PrettyPath } from "@/path/schema" export namespace Session { const log = Log.create({ service: "session" }) @@ -41,7 +42,7 @@ export namespace Session { const childTitlePrefix = "Child session - " function fix(input: string) { - if (!input) return input + if (!input) return PrettyPath.make(input) return Path.truecaseSync(input) } @@ -56,6 +57,7 @@ export namespace Session { } type SessionRow = typeof SessionTable.$inferSelect + type SessionInsert = typeof SessionTable.$inferInsert export function fromRow(row: SessionRow): Info { const summary = @@ -91,7 +93,7 @@ export namespace Session { } } - export function toRow(info: Info) { + export function toRow(info: z.output | Info): SessionInsert { return { id: info.id, project_id: info.projectID, @@ -167,7 +169,9 @@ export namespace Session { .meta({ ref: "Session", }) - export type Info = z.output + export type Info = Omit, "directory"> & { + directory: PrettyPath + } export const ProjectInfo = z .object({ @@ -178,14 +182,18 @@ export namespace Session { .meta({ ref: "ProjectSummary", }) - export type ProjectInfo = z.output + export type ProjectInfo = Omit, "worktree"> & { + worktree: PrettyPath + } export const GlobalInfo = Info.extend({ project: ProjectInfo.nullable(), }).meta({ ref: "GlobalSession", }) - export type GlobalInfo = z.output + export type GlobalInfo = Omit, "project"> & { + project: ProjectInfo | null + } export const Event = { Created: BusEvent.define( diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index ea1c4dafb912..09ae011b8c63 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -6,6 +6,7 @@ import type { PermissionNext } from "../permission" import type { ProjectID } from "../project/schema" import type { SessionID, MessageID, PartID } from "./schema" import type { WorkspaceID } from "../control-plane/schema" +import type { PrettyPath } from "../path/schema" import { Timestamps } from "../storage/schema.sql" type PartData = Omit @@ -22,7 +23,7 @@ export const SessionTable = sqliteTable( workspace_id: text().$type(), parent_id: text().$type(), slug: text().notNull(), - directory: text().notNull(), + directory: text().$type().notNull(), title: text().notNull(), version: text().notNull(), share_url: text(), diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 678a00851819..aaeb99e152e0 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" import { Bus } from "@/bus" +import { Path } from "@/path/path" export namespace SessionSummary { function unquoteGitPath(input: string) { @@ -124,7 +125,7 @@ export namespace SessionSummary { if (file === item.file) return item return { ...item, - file, + file: Path.repo(file), } }) const changed = next.some((item, i) => item.file !== diffs[i]?.file) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 887bce33416d..040cf3a28bbc 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -6,6 +6,7 @@ import z from "zod" import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" import { AppFileSystem } from "@/filesystem" +import { Path } from "@/path/path" import { Config } from "../config/config" import { Global } from "../global" import { Log } from "../util/log" @@ -314,7 +315,7 @@ export namespace Snapshot { const additions = binary ? 0 : parseInt(adds) const deletions = binary ? 0 : parseInt(dels) result.push({ - file, + file: Path.repo(file), before, after, additions: Number.isFinite(additions) ? additions : 0, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 2a9dbb8c0974..671fa000ecef 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -123,7 +123,7 @@ export const EditTool = Tool.define("edit", { }) const filediff: Snapshot.FileDiff = { - file: filePath, + file: Path.repo(Path.rel(Instance.directory, filePath)), before: contentOld, after: contentNew, additions: 0, diff --git a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts index d4d152a1c6e9..c57d7eb2eb31 100644 --- a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts +++ b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts @@ -11,6 +11,7 @@ import { resetDatabase } from "../fixture/db" import * as adaptors from "../../src/control-plane/adaptors" import type { Adaptor } from "../../src/control-plane/types" import { Flag } from "../../src/flag/flag" +import { PrettyPath } from "../../src/path/schema" afterEach(async () => { mock.restore() @@ -83,7 +84,7 @@ async function setup(state: State) { branch: "main", project_id: project.id, type: "worktree", - directory: tmp.path, + directory: PrettyPath.make(tmp.path), name: "local", }, ]) diff --git a/packages/opencode/test/control-plane/workspace-sync.test.ts b/packages/opencode/test/control-plane/workspace-sync.test.ts index 0f8d608fb39b..af091b141c95 100644 --- a/packages/opencode/test/control-plane/workspace-sync.test.ts +++ b/packages/opencode/test/control-plane/workspace-sync.test.ts @@ -9,6 +9,7 @@ import { GlobalBus } from "../../src/bus/global" import { resetDatabase } from "../fixture/db" import * as adaptors from "../../src/control-plane/adaptors" import type { Adaptor } from "../../src/control-plane/types" +import { PrettyPath } from "../../src/path/schema" afterEach(async () => { mock.restore() @@ -71,7 +72,7 @@ describe("control-plane/workspace.startSyncing", () => { branch: "main", project_id: project.id, type: "worktree", - directory: tmp.path, + directory: PrettyPath.make(tmp.path), name: "local", }, ]) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 2180612ed437..bca7866c0d01 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -642,7 +642,7 @@ describe("file/index Filesystem patterns", () => { directory: tmp.path, fn: async () => { const nodes = await File.list() - expect(nodes.find((n) => n.name === "src")?.path).toBe("src") + expect(String(nodes.find((n) => n.name === "src")?.path)).toBe("src") }, }) }) diff --git a/packages/opencode/test/path/migrate.test.ts b/packages/opencode/test/path/migrate.test.ts index ef9ef768ae01..9ec7d7e63d62 100644 --- a/packages/opencode/test/path/migrate.test.ts +++ b/packages/opencode/test/path/migrate.test.ts @@ -11,6 +11,7 @@ import { SessionID } from "../../src/session/schema" import { tmpdir } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" import { PathMigration } from "../../src/path/migrate" +import { PrettyPath } from "../../src/path/schema" import { Filesystem } from "../../src/util/filesystem" afterEach(async () => { @@ -45,7 +46,7 @@ describe("PathMigration.run", () => { id: sessionID, project_id: project.id, slug: sessionID, - directory: raw(tmp.path), + directory: PrettyPath.make(raw(tmp.path)), title: "test", version: "0.0.0-test", time_created: now, @@ -59,7 +60,7 @@ describe("PathMigration.run", () => { type: "worktree", branch: null, name: "local", - directory: raw(tmp.path), + directory: PrettyPath.make(raw(tmp.path)), extra: null, project_id: project.id, }) @@ -68,8 +69,8 @@ describe("PathMigration.run", () => { db .update(ProjectTable) .set({ - worktree: raw(tmp.path), - sandboxes: [raw(box), path.join(raw(box), "again", ".."), raw(tmp.path)], + worktree: PrettyPath.make(raw(tmp.path)), + sandboxes: [PrettyPath.make(raw(box)), PrettyPath.make(path.join(raw(box), "again", "..")), PrettyPath.make(raw(tmp.path))], }) .where(eq(ProjectTable.id, project.id)) .run() @@ -89,10 +90,10 @@ describe("PathMigration.run", () => { const srow = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) const wrow = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get()) - expect(prow?.worktree).toBe(tmp.path) - expect(prow?.sandboxes).toEqual([box]) - expect(srow?.directory).toBe(tmp.path) - expect(wrow?.directory).toBe(tmp.path) + expect(String(prow?.worktree)).toBe(tmp.path) + expect(prow?.sandboxes.map(String)).toEqual([box]) + expect(String(srow?.directory)).toBe(tmp.path) + expect(String(wrow?.directory)).toBe(tmp.path) }) test("normalizes new session rows on write", async () => { @@ -111,7 +112,7 @@ describe("PathMigration.run", () => { }, }) - expect(row.directory).toBe(tmp.path) + expect(String(row.directory)).toBe(tmp.path) }) test("is idempotent and only reruns when forced", async () => { @@ -122,7 +123,7 @@ describe("PathMigration.run", () => { Database.use((db) => db .update(ProjectTable) - .set({ worktree: raw(tmp.path) }) + .set({ worktree: PrettyPath.make(raw(tmp.path)) }) .where(eq(ProjectTable.id, project.id)) .run(), ) @@ -140,7 +141,7 @@ describe("PathMigration.run", () => { Database.use((db) => db .update(ProjectTable) - .set({ worktree: raw(tmp.path) }) + .set({ worktree: PrettyPath.make(raw(tmp.path)) }) .where(eq(ProjectTable.id, project.id)) .run(), ) @@ -155,6 +156,6 @@ describe("PathMigration.run", () => { const next = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, project.id)).get()) expect(forced.change.project).toBe(1) - expect(next?.worktree).toBe(tmp.path) + expect(String(next?.worktree)).toBe(tmp.path) }) }) diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 0ab3db9e0cb9..a2c7c406f4b8 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -5,6 +5,7 @@ import { SessionTable } from "../../src/session/session.sql" import { ProjectTable } from "../../src/project/project.sql" import { ProjectID } from "../../src/project/schema" import { SessionID } from "../../src/session/schema" +import { PrettyPath } from "../../src/path/schema" import { Log } from "../../src/util/log" import { PathMigration } from "../../src/path/migrate" import { $ } from "bun" @@ -26,7 +27,7 @@ function seed(opts: { id: SessionID; dir: string; project: ProjectID }) { id: opts.id, project_id: opts.project, slug: opts.id, - directory: opts.dir, + directory: PrettyPath.make(opts.dir), title: "test", version: "0.0.0-test", time_created: now, @@ -40,12 +41,12 @@ function ensureGlobal() { Database.use((db) => db .insert(ProjectTable) - .values({ - id: ProjectID.global, - worktree: "/", - time_created: Date.now(), - time_updated: Date.now(), - sandboxes: [], + .values({ + id: ProjectID.global, + worktree: PrettyPath.make("/"), + time_created: Date.now(), + time_updated: Date.now(), + sandboxes: [], }) .onConflictDoNothing() .run(), diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 5375d955ac1d..853ab83b220b 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -77,7 +77,7 @@ describe("Project.fromDirectory", () => { expect(project).toBeDefined() expect(project.id).toBe(ProjectID.global) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(String(project.worktree)).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") const fileExists = await Filesystem.exists(opencodeFile) @@ -93,7 +93,7 @@ describe("Project.fromDirectory", () => { expect(project).toBeDefined() expect(project.id).not.toBe(ProjectID.global) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(String(project.worktree)).toBe(tmp.path) const opencodeFile = path.join(tmp.path, ".git", "opencode") const fileExists = await Filesystem.exists(opencodeFile) @@ -107,8 +107,8 @@ describe("Project.fromDirectory", () => { const { project, sandbox } = await p.fromDirectory(tmp.path.toUpperCase()) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) + expect(String(project.worktree)).toBe(tmp.path) + expect(String(sandbox)).toBe(tmp.path) }) test("keeps git vcs when rev-list exits non-zero with empty output", async () => { @@ -120,7 +120,7 @@ describe("Project.fromDirectory", () => { const { project } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") expect(project.id).toBe(ProjectID.global) - expect(project.worktree).toBe(tmp.path) + expect(String(project.worktree)).toBe(tmp.path) }) }) @@ -131,8 +131,8 @@ describe("Project.fromDirectory", () => { await withMode("top-fail", async () => { const { project, sandbox } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) + expect(String(project.worktree)).toBe(tmp.path) + expect(String(sandbox)).toBe(tmp.path) }) }) @@ -143,8 +143,8 @@ describe("Project.fromDirectory", () => { await withMode("common-dir-fail", async () => { const { project, sandbox } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) + expect(String(project.worktree)).toBe(tmp.path) + expect(String(sandbox)).toBe(tmp.path) }) }) }) @@ -156,9 +156,9 @@ describe("Project.fromDirectory with worktrees", () => { const { project, sandbox } = await p.fromDirectory(tmp.path) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - expect(project.sandboxes).not.toContain(tmp.path) + expect(String(project.worktree)).toBe(tmp.path) + expect(String(sandbox)).toBe(tmp.path) + expect(project.sandboxes.map(String)).not.toContain(tmp.path) }) test("should set worktree to root when called from a worktree", async () => { @@ -171,10 +171,10 @@ describe("Project.fromDirectory with worktrees", () => { const { project, sandbox } = await p.fromDirectory(worktreePath) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(worktreePath) - expect(project.sandboxes).toContain(worktreePath) - expect(project.sandboxes).not.toContain(tmp.path) + expect(String(project.worktree)).toBe(tmp.path) + expect(String(sandbox)).toBe(worktreePath) + expect(project.sandboxes.map(String)).toContain(worktreePath) + expect(project.sandboxes.map(String)).not.toContain(tmp.path) } finally { await $`git worktree remove ${worktreePath}` .cwd(tmp.path) @@ -242,10 +242,10 @@ describe("Project.fromDirectory with worktrees", () => { await p.fromDirectory(worktree1) const { project } = await p.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(String(project.worktree)).toBe(tmp.path) + expect(project.sandboxes.map(String)).toContain(worktree1) + expect(project.sandboxes.map(String)).toContain(worktree2) + expect(project.sandboxes.map(String)).not.toContain(tmp.path) } finally { await $`git worktree remove ${worktree1}` .cwd(tmp.path) @@ -427,6 +427,6 @@ describe("Project sandbox paths", () => { await Project.addSandbox(project.id, dir) const updated = await Project.removeSandbox(project.id, `${dir}${path.sep}child${path.sep}..`) - expect(updated.sandboxes).not.toContain(dir) + expect(updated.sandboxes.map(String)).not.toContain(dir) }) }) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 58cb51caf821..5d15529a576c 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -4,6 +4,7 @@ import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { Database, eq } from "../../src/storage/db" import { SessionTable } from "../../src/session/session.sql" +import { PrettyPath } from "../../src/path/schema" import { Log } from "../../src/util/log" const projectRoot = path.join(__dirname, "../..") @@ -61,13 +62,13 @@ describe("Session.list", () => { Database.use((db) => db .update(SessionTable) - .set({ directory: projectRoot.toUpperCase() }) + .set({ directory: PrettyPath.make(projectRoot.toUpperCase()) }) .where(eq(SessionTable.id, session.id)) .run(), ) const result = await Session.get(session.id) - expect(result.directory).toBe(projectRoot) + expect(String(result.directory)).toBe(projectRoot) }, }) }) diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index a714f1147345..649fb44afbc6 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -126,9 +126,9 @@ describe("JSON to SQLite migration", () => { const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) - expect(projects[0].worktree).toBe("/test/path") + expect(String(projects[0].worktree)).toBe("/test/path") expect(projects[0].name).toBe("Test Project") - expect(projects[0].sandboxes).toEqual(["/test/sandbox"]) + expect(projects[0].sandboxes.map(String)).toEqual(["/test/sandbox"]) }) test("uses filename for project id when JSON has different value", async () => { diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 5599ea0f9684..18aec15b5cf6 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -5,6 +5,7 @@ import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import { FileTime } from "../../src/file/time" +import { Path } from "../../src/path/path" import { SessionID, MessageID } from "../../src/session/schema" import type { PermissionNext } from "../../src/permission" import { win } from "../lib/windows-path" @@ -451,7 +452,7 @@ describe("tool.edit", () => { ) expect(result.metadata.filediff).toBeDefined() - expect(result.metadata.filediff.file).toBe(filepath) + expect(String(result.metadata.filediff.file)).toBe(String(Path.repo(path.basename(filepath)))) expect(result.metadata.filediff.additions).toBeGreaterThan(0) }, }) From 965cc9c1d451295456ab473583fe11ac7288a74a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:43:17 +1000 Subject: [PATCH 31/42] app: migrate path state and file tab ids --- packages/app/src/components/file-tree.tsx | 12 +- packages/app/src/context/comments.tsx | 4 +- packages/app/src/context/file.tsx | 3 +- packages/app/src/context/file/path.test.ts | 17 +- packages/app/src/context/file/path.ts | 60 ++++-- .../app/src/context/file/view-cache.test.ts | 22 +++ packages/app/src/context/file/view-cache.ts | 85 ++++++-- packages/app/src/context/layout.test.ts | 34 +++- packages/app/src/context/layout.tsx | 100 +++++----- packages/app/src/context/prompt.tsx | 4 +- packages/app/src/context/server.tsx | 25 +-- packages/app/src/context/terminal.test.ts | 19 +- packages/app/src/context/terminal.tsx | 22 ++- packages/app/src/pages/layout.tsx | 89 ++++----- .../app/src/pages/layout/sidebar-project.tsx | 47 ++--- packages/app/src/pages/session.tsx | 3 +- packages/app/src/pages/session/handoff.ts | 8 +- .../app/src/pages/session/helpers.test.ts | 33 ++-- packages/app/src/pages/session/helpers.ts | 15 +- .../app/src/pages/session/session-layout.ts | 5 +- packages/app/src/utils/persist-path.test.ts | 53 +++++ packages/app/src/utils/persist-path.ts | 187 ++++++++++++++++-- packages/app/src/utils/persist.test.ts | 50 ++++- packages/app/src/utils/persist.ts | 156 +++++++++++---- packages/app/src/utils/session-key.test.ts | 16 ++ packages/app/src/utils/session-key.ts | 34 ++++ packages/desktop-electron/src/main/index.ts | 3 +- .../desktop-electron/src/main/migrate.test.ts | 102 ++++++++++ packages/desktop-electron/src/main/migrate.ts | 14 +- 29 files changed, 941 insertions(+), 281 deletions(-) create mode 100644 packages/app/src/context/file/view-cache.test.ts create mode 100644 packages/app/src/utils/session-key.test.ts create mode 100644 packages/app/src/utils/session-key.ts create mode 100644 packages/desktop-electron/src/main/migrate.test.ts diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index a7a8b235bbf2..46f700cfa7ee 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -3,7 +3,7 @@ import { filePathEqual, filePathKey } from "@/context/file/path" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" -import { encodeFilePath } from "@opencode-ai/util/path" +import { encodeFilePath, getPathRoot } from "@opencode-ai/util/path" import { createEffect, createMemo, @@ -23,8 +23,11 @@ import type { FileNode } from "@opencode-ai/sdk/v2" const MAX_DEPTH = 128 -function pathToFileUrl(filepath: string): string { - return `file://${encodeFilePath(filepath)}` +function pathToFileUrl(filepath: string) { + if (!getPathRoot(filepath)) return + const path = encodeFilePath(filepath) + if (path.startsWith("//")) return `file:${path}` + return `file://${path}` } type Kind = "add" | "del" | "mix" @@ -160,7 +163,8 @@ const FileTreeNode = ( onDragStart={(event: DragEvent) => { if (!local.draggable) return event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + const url = pathToFileUrl(local.node.absolute) + if (url) event.dataTransfer?.setData("text/uri-list", url) if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" withFileDragImage(event) }} diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 8b8874844450..3060065a432c 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -190,10 +190,8 @@ export function createCommentSessionForTest(comments: Record({ comments: {}, }), diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 34af345b5897..28e6381ca083 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -8,6 +8,7 @@ import { useSDK } from "./sdk" import { useSync } from "./sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { sessionKey } from "@/utils/session-key" import { createPathHelpers } from "./file/path" import { approxBytes, @@ -61,7 +62,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const scope = createMemo(() => sdk.directory) const path = createPathHelpers(scope) - const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = layout.tabs(() => sessionKey(params.dir ?? "", params.id)) const inflight = new Map>() const [store, setStore] = createStore<{ diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 30035470c453..b933defc8c9a 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { createPathHelpers, dedupeFilePaths, filePathEqual, filePathKey } from "./path" +import { createPathHelpers, dedupeFilePaths, filePathEqual, filePathKey, isFileTab } from "./path" describe("file path helpers", () => { test("normalizes file inputs against workspace root", () => { @@ -8,10 +8,16 @@ describe("file path helpers", () => { expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts") expect(path.normalize("./src/app.ts")).toBe("src/app.ts") expect(path.normalizeDir("src/components///")).toBe("src/components") - expect(path.tab("src/app.ts")).toBe("file://src/app.ts") - expect(path.normalizeTab("file://src/app.ts")).toBe("file://src/app.ts") + expect(path.tab("src/app.ts")).toBe("tab:file:src/app.ts") + expect(isFileTab(path.tab("src/app.ts"))).toBe(true) + expect(isFileTab("file:///repo/src/app.ts")).toBe(false) + expect(path.normalizeTab("file://src/app.ts")).toBe("tab:file:src/app.ts") + expect(path.normalizeTab("tab:file:src/app.ts")).toBe("tab:file:src/app.ts") + expect(path.normalizeTab("file:///repo/src/app.ts")).toBe("file:///repo/src/app.ts") expect(path.normalizeTab("review")).toBe("review") + expect(path.pathFromTab("tab:file:src/app.ts")).toBe("src/app.ts") expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts") + expect(path.pathFromTab("file:///repo/src/app.ts")).toBeUndefined() expect(path.pathFromTab("other://src/app.ts")).toBeUndefined() }) @@ -25,17 +31,18 @@ describe("file path helpers", () => { test("renders display paths with native separators", () => { const posix = createPathHelpers(() => "/repo") - expect(posix.display("file://src/app.ts")).toBe("src/app.ts") + expect(posix.display("tab:file:src/app.ts")).toBe("src/app.ts") const win = createPathHelpers(() => "C:\\repo") expect(win.display("src/app.ts")).toBe("src\\app.ts") expect(win.display("file://src/app.ts")).toBe("src\\app.ts") + expect(win.display("tab:file:src/app.ts")).toBe("src\\app.ts") expect(win.display("C:/repo/src/app.ts")).toBe("src\\app.ts") expect(win.display("src/app/")).toBe("src\\app\\") }) test("normalizes app file keys across slash variants", () => { - expect(filePathKey("src\\app.ts")).toBe("src/app.ts") + expect(String(filePathKey("src\\app.ts"))).toBe("src/app.ts") expect(filePathEqual("src\\app.ts", "src/app.ts")).toBe(true) expect(dedupeFilePaths(["src\\app.ts", "src/app.ts", "src/util.ts"])).toEqual(["src/app.ts", "src/util.ts"]) }) diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 32fcd98f2538..1ca22cb84cb5 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -1,6 +1,7 @@ import { decodeFilePath, getPathSeparator, + getPathRoot, getWorkspaceRelativePath, pathEqual, pathKey, @@ -10,13 +11,41 @@ import { encodeFilePath, } from "@opencode-ai/util/path" -export const filePathKey = (input: string) => pathKey(input) +export type PrettyPath = string +export type WorkspacePath = PrettyPath +export type ReviewPath = PrettyPath +export type FilePath = PrettyPath -export const filePathEqual = (a: string | undefined, b: string | undefined) => pathEqual(a, b) +export type WorkspaceKey = string & { _brand: "WorkspaceKey" } +export type FilePathKey = string & { _brand: "FilePathKey" } +type LegacyFileTabId = `file://${string}` +export const FILE_TAB_PREFIX = "tab:file:" as const +export type FileTabId = `${typeof FILE_TAB_PREFIX}${string}` -export function dedupeFilePaths(paths: readonly string[]) { - const seen = new Set() - const out: string[] = [] +export const workspacePathKey = (input: WorkspacePath) => (pathKey(input) || input) as WorkspaceKey + +export const filePathKey = (input: FilePath) => pathKey(input) as FilePathKey + +const legacyTabPath = (input: string) => { + if (!input.startsWith("file://")) return + const path = decodeFilePath(stripQueryAndHash(stripFileProtocol(input))) + if (getPathRoot(path)) return + if (path.startsWith("/") || path.startsWith("\\")) return + return path +} + +const stripTab = (input: string) => (input.startsWith(FILE_TAB_PREFIX) ? input.slice(FILE_TAB_PREFIX.length) : input) + +export const isFileTab = (input: string): input is FileTabId | LegacyFileTabId => { + if (input.startsWith(FILE_TAB_PREFIX)) return true + return legacyTabPath(input) !== undefined +} + +export const filePathEqual = (a: FilePath | undefined, b: FilePath | undefined) => pathEqual(a, b) + +export function dedupeFilePaths(paths: readonly FilePath[]) { + const seen = new Set() + const out: FilePath[] = [] for (const path of paths) { const key = filePathKey(path) @@ -28,11 +57,11 @@ export function dedupeFilePaths(paths: readonly string[]) { return out } -export function createPathHelpers(scope: () => string) { - const normalize = (input: string) => { +export function createPathHelpers(scope: () => WorkspacePath) { + const normalize = (input: string): FilePath => { const root = scope() - let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))) + let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(stripTab(input))))) path = getWorkspaceRelativePath(path, root) if (path.startsWith("./") || path.startsWith(".\\")) { @@ -45,28 +74,29 @@ export function createPathHelpers(scope: () => string) { return path } - const display = (input: string) => { + const display = (input: string): FilePath => { const path = normalize(input) if (getPathSeparator(scope()) === "/") return path return path.replace(/\//g, "\\") } - const tab = (input: string) => { + const tab = (input: FilePath): FileTabId => { const path = normalize(input) - return `file://${encodeFilePath(path)}` + return `${FILE_TAB_PREFIX}${encodeFilePath(path)}` } const normalizeTab = (input: string) => { - if (!input.startsWith("file://")) return input - return tab(input) + const path = pathFromTab(input) + if (!path) return input + return tab(path) } const pathFromTab = (tabValue: string) => { - if (!tabValue.startsWith("file://")) return + if (!isFileTab(tabValue)) return return normalize(tabValue) } - const normalizeDir = (input: string) => normalize(input).replace(/[\\/]+$/, "") + const normalizeDir = (input: string): FilePath => normalize(input).replace(/[\\/]+$/, "") return { normalize, diff --git a/packages/app/src/context/file/view-cache.test.ts b/packages/app/src/context/file/view-cache.test.ts new file mode 100644 index 000000000000..0c31fba020cb --- /dev/null +++ b/packages/app/src/context/file/view-cache.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { migrateFileViewState } from "./view-cache" + +describe("migrateFileViewState", () => { + test("normalizes persisted file-view payload keys once", () => { + expect( + migrateFileViewState("C:\\repo", { + file: { + "src\\a.ts": { scrollTop: 10 }, + "file://C:/repo/src/a.ts": { scrollLeft: 20 }, + "C:/repo/src/b.ts": { selectedLines: { start: 4, end: 2, side: "additions" } }, + invalid: null, + }, + }), + ).toEqual({ + file: { + "src/a.ts": { scrollTop: 10, scrollLeft: 20 }, + "src/b.ts": { selectedLines: { start: 4, end: 2, side: "additions" } }, + }, + }) + }) +}) diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index 4c060174ab8c..233d7adc4fe7 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -2,11 +2,18 @@ import { createEffect, createRoot } from "solid-js" import { createStore, produce } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import { createScopedCache } from "@/utils/scoped-cache" +import { createPathHelpers, filePathKey } from "./path" import type { FileViewState, SelectedLineRange } from "./types" const WORKSPACE_KEY = "__workspace__" const MAX_FILE_VIEW_SESSIONS = 20 const MAX_VIEW_FILES = 500 +const fileKey = (path: string) => filePathKey(path) || path + +const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const num = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined) function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { if (range.start <= range.end) return { ...range } @@ -33,11 +40,62 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected ) } -function createViewSession(dir: string, id: string | undefined) { - const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` +function state(value: unknown): FileViewState | undefined { + if (!record(value)) return + return { + ...(num(value.scrollTop) !== undefined ? { scrollTop: num(value.scrollTop) } : {}), + ...(num(value.scrollLeft) !== undefined ? { scrollLeft: num(value.scrollLeft) } : {}), + ...(value.selectedLines === null ? { selectedLines: null } : record(value.selectedLines) ? { selectedLines: value.selectedLines as SelectedLineRange } : {}), + } +} + +function merge(prev: FileViewState, next: FileViewState) { + return { + ...prev, + ...next, + ...(next.selectedLines !== undefined ? { selectedLines: next.selectedLines } : {}), + } +} +export function migrateFileViewState(dir: string, value: unknown) { + if (!record(value)) return value + if (!record(value.file)) return value + + const path = createPathHelpers(() => dir) + let changed = false + const file: Record = {} + + for (const [name, item] of Object.entries(value.file)) { + const next = state(item) + if (!next) { + changed = true + continue + } + + const normalized = path.normalize(name) + const key = fileKey(normalized) + if (!key) { + changed = true + continue + } + + if (key !== name || file[key]) changed = true + file[key] = file[key] ? merge(file[key], next) : next + } + + if (!changed) return value + return { + ...value, + file, + } +} + +function createViewSession(dir: string, id: string | undefined) { const [view, setView, _, ready] = persisted( - Persist.scoped(dir, id, "file-view", [legacyViewKey]), + { + ...Persist.scoped(dir, id, "file-view", Persist.legacyScoped(dir, id, "file", "v1")), + migrate: (value) => migrateFileViewState(dir, value), + }, createStore<{ file: Record }>({ @@ -70,42 +128,45 @@ function createViewSession(dir: string, id: string | undefined) { pruneView() }) - const scrollTop = (path: string) => view.file[path]?.scrollTop - const scrollLeft = (path: string) => view.file[path]?.scrollLeft - const selectedLines = (path: string) => view.file[path]?.selectedLines + const scrollTop = (path: string) => view.file[fileKey(path)]?.scrollTop + const scrollLeft = (path: string) => view.file[fileKey(path)]?.scrollLeft + const selectedLines = (path: string) => view.file[fileKey(path)]?.selectedLines const setScrollTop = (path: string, top: number) => { + const key = fileKey(path) setView( produce((draft) => { - const file = draft.file[path] ?? (draft.file[path] = {}) + const file = draft.file[key] ?? (draft.file[key] = {}) if (file.scrollTop === top) return file.scrollTop = top }), ) - pruneView(path) + pruneView(key) } const setScrollLeft = (path: string, left: number) => { + const key = fileKey(path) setView( produce((draft) => { - const file = draft.file[path] ?? (draft.file[path] = {}) + const file = draft.file[key] ?? (draft.file[key] = {}) if (file.scrollLeft === left) return file.scrollLeft = left }), ) - pruneView(path) + pruneView(key) } const setSelectedLines = (path: string, range: SelectedLineRange | null) => { + const key = fileKey(path) const next = range ? normalizeSelectedLines(range) : null setView( produce((draft) => { - const file = draft.file[path] ?? (draft.file[path] = {}) + const file = draft.file[key] ?? (draft.file[key] = {}) if (equalSelectedLines(file.selectedLines, next)) return file.selectedLines = next }), ) - pruneView(path) + pruneView(key) } return { diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts index 582d5edbd29f..ce8cfcdbe285 100644 --- a/packages/app/src/context/layout.test.ts +++ b/packages/app/src/context/layout.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" -import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout" +import { base64Encode } from "@opencode-ai/util/encode" +import { createSessionKeyReader, ensureSessionKey, normalizeStoredSessionTabs, pruneSessionKeys } from "./layout" describe("layout session-key helpers", () => { test("couples touch and scroll seed in order", () => { @@ -19,17 +20,20 @@ describe("layout session-key helpers", () => { const seen: string[] = [] createRoot((dispose) => { - const [key, setKey] = createSignal("dir/one") - const read = createSessionKeyReader(key, (value) => seen.push(value)) + const [key, setKey] = createSignal(`${base64Encode("C:\\Repo\\")}/one`) + const read = createSessionKeyReader(key, (value) => { + seen.push(value) + return ensureSessionKey(value, () => {}, () => {}) + }) - expect(read()).toBe("dir/one") - setKey("dir/two") - expect(read()).toBe("dir/two") + expect(read()).toBe(`${base64Encode("c:/repo")}/one`) + setKey(`${base64Encode("C:\\Repo\\")}/two`) + expect(read()).toBe(`${base64Encode("c:/repo")}/two`) dispose() }) - expect(seen).toEqual(["dir/one", "dir/two"]) + expect(seen).toEqual([`${base64Encode("C:\\Repo\\")}/one`, `${base64Encode("C:\\Repo\\")}/two`]) }) }) @@ -67,3 +71,19 @@ describe("pruneSessionKeys", () => { expect(drop).toEqual([]) }) }) + +describe("normalizeStoredSessionTabs", () => { + test("upgrades legacy file tabs without touching real file URIs", () => { + const key = `${base64Encode("/repo")}/session` + + expect( + normalizeStoredSessionTabs(key, { + all: ["context", "file://src/a.ts", "tab:file:src/a.ts", "file:///repo/src/b.ts"], + active: "file://src/a.ts", + }), + ).toEqual({ + all: ["context", "tab:file:src/a.ts", "file:///repo/src/b.ts"], + active: "tab:file:src/a.ts", + }) + }) +}) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 00b22e798b2e..a2306f2fd6e5 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -10,19 +10,20 @@ import { Project } from "@opencode-ai/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" import { migrateLayoutPaths } from "@/utils/persist-path" import { decode64 } from "@/utils/base64" +import { sessionDirKey, sessionParts } from "@/utils/session-key" import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" -import { createPathHelpers } from "./file/path" +import { createPathHelpers, type ReviewPath, type WorkspaceKey, type WorkspacePath } from "./file/path" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const DEFAULT_PANEL_WIDTH = 344 const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_TERMINAL_HEIGHT = 280 -const reviewKey = (path: string) => pathKey(path) || path -const workspaceKey = (path: string) => pathKey(path) || path -const reviewPaths = (paths: readonly string[]) => { +const reviewKey = (path: ReviewPath) => pathKey(path) || path +const workspaceKey = (path: WorkspacePath) => (pathKey(path) || path) as WorkspaceKey +const reviewPaths = (paths: readonly ReviewPath[]) => { const seen = new Set() - const out: string[] = [] + const out: ReviewPath[] = [] for (const path of paths) { const id = reviewKey(path) @@ -33,7 +34,7 @@ const reviewPaths = (paths: readonly string[]) => { return out } -const sameReviewPaths = (a: readonly string[] | undefined, b: readonly string[] | undefined) => { +const sameReviewPaths = (a: readonly ReviewPath[] | undefined, b: readonly ReviewPath[] | undefined) => { if (!a && !b) return true if (!a || !b) return false if (a.length !== b.length) return false @@ -61,7 +62,7 @@ type SessionTabs = { type SessionView = { scroll: Record - reviewOpen?: string[] + reviewOpen?: ReviewPath[] pendingMessage?: string pendingMessageAt?: number } @@ -72,22 +73,22 @@ type TabHandoff = { at: number } -export type LocalProject = Partial & { worktree: string; expanded: boolean } +export type LocalProject = Partial & { worktree: WorkspacePath; expanded: boolean } export type ReviewDiffStyle = "unified" | "split" export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { - touch(key) - seed(key) - return key + const next = sessionParts(key).key + touch(next) + seed(next) + return next } -export function createSessionKeyReader(sessionKey: string | Accessor, ensure: (key: string) => void) { +export function createSessionKeyReader(sessionKey: string | Accessor, ensure: (key: string) => string) { const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey return () => { const value = key() - ensure(value) - return value + return ensure(value) } } @@ -144,7 +145,7 @@ const normalizeSessionTabList = (path: ReturnType | un }) } -const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { +export const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { const path = sessionPath(key) return { all: normalizeSessionTabList(path, tabs.all), @@ -237,7 +238,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( sidebar: { opened: false, width: DEFAULT_PANEL_WIDTH, - workspaces: {} as Record, + workspaces: {} as Record, workspacesDefault: false, }, terminal: { @@ -283,17 +284,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const dropSessionState = (keys: string[]) => { for (const key of keys) { - const parts = key.split("/") - const dir = parts[0] - const session = parts[1] + const parts = sessionParts(key) + const dir = parts.directory + const session = parts.id if (!dir) continue for (const entry of SESSION_STATE_KEYS) { const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key) void removePersisted(target, platform) - const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}` - void removePersisted({ key: legacyKey }, platform) + for (const legacy of Persist.legacyScoped(dir, session, entry.legacy, entry.version)) { + void removePersisted({ key: legacy }, platform) + } } } } @@ -390,7 +392,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return available[Math.floor(Math.random() * available.length)] } - function enrich(project: { worktree: string; expanded: boolean }) { + function enrich(project: { worktree: WorkspacePath; expanded: boolean }) { const [childStore] = globalSync.child(project.worktree, { bootstrap: false }) const projectID = childStore.project const metadata = projectID @@ -431,32 +433,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } const roots = createMemo(() => { - const map = new Map() + const map = new Map() for (const project of globalSync.data.project) { const sandboxes = project.sandboxes ?? [] for (const sandbox of sandboxes) { - map.set(pathKey(sandbox), project.worktree) + map.set(workspaceKey(sandbox), project.worktree) } } return map }) - const rootFor = (directory: string) => { + const rootFor = (directory: WorkspacePath) => { const map = roots() if (map.size === 0) return directory - const visited = new Set() + const visited = new Set() const chain = [directory] while (chain.length) { const current = chain[chain.length - 1] if (!current) return directory - const key = pathKey(current) + const key = workspaceKey(current) const next = map.get(key) if (!next) return current - const id = pathKey(next) + const id = workspaceKey(next) if (visited.has(id)) return directory visited.add(id) chain.push(next) @@ -565,7 +567,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( handoff: { tabs: createMemo(() => store.handoff?.tabs), setTabs(dir: string, id: string) { - setStore("handoff", "tabs", { dir, id, at: Date.now() }) + setStore("handoff", "tabs", { dir: sessionDirKey(dir), id, at: Date.now() }) }, clearTabs() { if (!store.handoff?.tabs) return @@ -574,22 +576,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, projects: { list, - open(directory: string) { + open(directory: WorkspacePath) { const root = rootFor(directory) if (server.projects.list().some((x) => pathEqual(x.worktree, root))) return globalSync.project.loadSessions(root) server.projects.open(root) }, - close(directory: string) { + close(directory: WorkspacePath) { server.projects.close(directory) }, - expand(directory: string) { + expand(directory: WorkspacePath) { server.projects.expand(directory) }, - collapse(directory: string) { + collapse(directory: WorkspacePath) { server.projects.collapse(directory) }, - move(directory: string, toIndex: number) { + move(directory: WorkspacePath, toIndex: number) { server.projects.move(directory, toIndex) }, }, @@ -608,13 +610,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( resize(width: number) { setStore("sidebar", "width", width) }, - workspaces(directory: string) { + workspaces(directory: WorkspacePath) { return () => store.sidebar.workspaces[workspaceKey(directory)] ?? store.sidebar.workspacesDefault ?? false }, - setWorkspaces(directory: string, value: boolean) { + setWorkspaces(directory: WorkspacePath, value: boolean) { setStore("sidebar", "workspaces", workspaceKey(directory), value) }, - toggleWorkspaces(directory: string) { + toggleWorkspaces(directory: WorkspacePath) { const key = workspaceKey(directory) const current = store.sidebar.workspaces[key] ?? store.sidebar.workspacesDefault ?? false setStore("sidebar", "workspaces", key, !current) @@ -700,22 +702,23 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, pendingMessage: { set(sessionKey: string, messageID: string) { + const key = sessionParts(sessionKey).key const at = Date.now() - touch(sessionKey) - const current = store.sessionView[sessionKey] + touch(key) + const current = store.sessionView[key] if (!current) { - setStore("sessionView", sessionKey, { + setStore("sessionView", key, { scroll: {}, pendingMessage: messageID, pendingMessageAt: at, }) - prune(usage.active ?? sessionKey) + prune(usage.active ?? key) return } setStore( "sessionView", - sessionKey, + key, produce((draft) => { draft.pendingMessage = messageID draft.pendingMessageAt = at @@ -723,14 +726,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) }, consume(sessionKey: string) { - const current = store.sessionView[sessionKey] + const key = sessionParts(sessionKey).key + const current = store.sessionView[key] const message = current?.pendingMessage const at = current?.pendingMessageAt if (!message || !at) return setStore( "sessionView", - sessionKey, + key, produce((draft) => { delete draft.pendingMessage delete draft.pendingMessageAt @@ -804,7 +808,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, review: { open: createMemo(() => reviewPaths(s().reviewOpen ?? [])), - setOpen(open: string[]) { + setOpen(open: ReviewPath[]) { const session = key() const next = reviewPaths(open) const current = store.sessionView[session] @@ -819,7 +823,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (sameReviewPaths(current.reviewOpen, next)) return setStore("sessionView", session, "reviewOpen", next) }, - openPath(path: string) { + openPath(path: ReviewPath) { const session = key() const current = store.sessionView[session] if (!current) { @@ -844,7 +848,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path) }, - closePath(path: string) { + closePath(path: ReviewPath) { const session = key() const current = store.sessionView[session]?.reviewOpen if (!current) return @@ -861,7 +865,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }), ) }, - togglePath(path: string) { + togglePath(path: ReviewPath) { const session = key() const current = store.sessionView[session]?.reviewOpen if (!current || !current.some((item) => pathEqual(item, path))) { diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index c13e875d611d..e6f00aac6e29 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -231,10 +231,8 @@ export function createPromptSessionForTest(input?: Partial) { } function createPromptSession(dir: string, id: string | undefined) { - const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2` - const [store, setStore, _, ready] = persisted( - Persist.scoped(dir, id, "prompt", [legacy]), + Persist.scoped(dir, id, "prompt", Persist.legacyScoped(dir, id, "prompt", "v2")), createStore({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: undefined, diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index f1ee73e098b9..e24476e215e5 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,21 +1,22 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { pathEqual, pathKey } from "@opencode-ai/util/path" +import { pathEqual } from "@opencode-ai/util/path" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" +import { type WorkspaceKey, type WorkspacePath, workspacePathKey } from "@/context/file/path" import { Persist, persisted } from "@/utils/persist" import { migrateServerState } from "@/utils/persist-path" import { useCheckServerHealth } from "@/utils/server-health" -type StoredProject = { worktree: string; expanded: boolean } +type StoredProject = { worktree: WorkspacePath; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http const HEALTH_POLL_INTERVAL_MS = 10_000 -const worktreeKey = (input: string) => pathKey(input) || input +const worktreeKey = (input: WorkspacePath) => workspacePathKey(input) as WorkspaceKey -function projectIndex(list: StoredProject[], worktree: string) { +function projectIndex(list: StoredProject[], worktree: WorkspacePath) { return list.findIndex((item) => pathEqual(item.worktree, worktree)) } -function upsertProject(list: StoredProject[], worktree: string) { +function upsertProject(list: StoredProject[], worktree: WorkspacePath) { const index = projectIndex(list, worktree) if (index === -1) return [{ worktree, expanded: true }, ...list] @@ -121,7 +122,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( createStore({ list: [] as StoredServer[], projects: {} as Record, - lastProject: {} as Record, + lastProject: {} as Record, }), ) @@ -265,12 +266,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( remove, projects: { list: projectsList, - open(directory: string) { + open(directory: WorkspacePath) { const key = origin() if (!key) return setStore("projects", key, upsertProject(store.projects[key] ?? [], directory)) }, - close(directory: string) { + close(directory: WorkspacePath) { const key = origin() if (!key) return const current = store.projects[key] ?? [] @@ -280,21 +281,21 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( current.filter((x) => !pathEqual(x.worktree, directory)), ) }, - expand(directory: string) { + expand(directory: WorkspacePath) { const key = origin() if (!key) return const current = store.projects[key] ?? [] const index = current.findIndex((x) => pathEqual(x.worktree, directory)) if (index !== -1) setStore("projects", key, index, "expanded", true) }, - collapse(directory: string) { + collapse(directory: WorkspacePath) { const key = origin() if (!key) return const current = store.projects[key] ?? [] const index = current.findIndex((x) => pathEqual(x.worktree, directory)) if (index !== -1) setStore("projects", key, index, "expanded", false) }, - move(directory: string, toIndex: number) { + move(directory: WorkspacePath, toIndex: number) { const key = origin() if (!key) return const current = store.projects[key] ?? [] @@ -310,7 +311,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (!key) return return store.lastProject[key] }, - touch(directory: string) { + touch(directory: WorkspacePath) { const key = origin() if (!key) return setStore("lastProject", key, directory) diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 6e07e0312412..72ee3f1f5327 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -28,15 +28,20 @@ describe("getWorkspaceTerminalCacheKey", () => { }) describe("getLegacyTerminalStorageKeys", () => { - test("keeps workspace storage path when no legacy session id", () => { - expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"]) + test("includes workspace path aliases when no legacy session id", () => { + const keys = getLegacyTerminalStorageKeys("/repo") + + expect(keys).toContain("/repo/terminal.v1") + expect(keys).toContain("/repo//terminal.v1") }) - test("includes legacy session path before workspace path", () => { - expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([ - "/repo/terminal/session-123.v1", - "/repo/terminal.v1", - ]) + test("includes equivalent directory spellings for session and workspace keys", () => { + const keys = getLegacyTerminalStorageKeys("C:/Repo", "session-123") + + expect(keys).toContain("C:/Repo/terminal/session-123.v1") + expect(keys).toContain("C:/Repo/terminal.v1") + expect(keys).toContain("c:/repo/terminal/session-123.v1") + expect(keys).toContain("C:\\Repo/terminal/session-123.v1") }) }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index e65c16788461..d07c36a34cfb 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -87,8 +87,13 @@ export function getWorkspaceTerminalCacheKey(dir: string) { } export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { - if (!legacySessionID) return [`${dir}/terminal.v1`] - return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] + const keys = legacySessionID + ? [ + ...Persist.legacyScoped(dir, legacySessionID, "terminal", "v1"), + ...Persist.legacyScoped(dir, undefined, "terminal", "v1"), + ] + : Persist.legacyScoped(dir, undefined, "terminal", "v1") + return Array.from(new Set(keys)) } type TerminalSession = ReturnType @@ -117,17 +122,20 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat entry?.value.clear() } - removePersisted(Persist.workspace(dir, "terminal"), platform) - const legacy = new Set(getLegacyTerminalStorageKeys(dir)) for (const id of sessionIDs ?? []) { for (const key of getLegacyTerminalStorageKeys(dir, id)) { legacy.add(key) } } - for (const key of legacy) { - removePersisted({ key }, platform) - } + + removePersisted( + { + ...Persist.workspace(dir, "terminal"), + legacy: Array.from(legacy), + }, + platform, + ) } function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a50d8b5c50aa..e5f2418bc25a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { clearWorkspaceTerminals } from "@/context/terminal" +import { type WorkspaceKey, type WorkspacePath } from "@/context/file/path" import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { clearSessionPrefetchInflight, @@ -96,16 +97,16 @@ import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from import { SidebarContent } from "./layout/sidebar-shell" type StoredRoute = { - directory: string + directory: WorkspacePath id: string at: number } -const workspacePathKey = (input: string) => workspaceKey(input) || input +const workspacePathKey = (input: WorkspacePath) => (workspaceKey(input) || input) as WorkspaceKey -function mergeWorkspaceOrder(root: string, list: string[]) { - const seen = new Set([workspacePathKey(root)]) - const out: string[] = [] +function mergeWorkspaceOrder(root: WorkspacePath, list: WorkspacePath[]) { + const seen = new Set([workspacePathKey(root)]) + const out: WorkspacePath[] = [] for (const directory of list) { const id = workspacePathKey(directory) @@ -121,11 +122,11 @@ export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( { ...Persist.global("layout.page", ["layout.page.v1"]), migrate: migrateLayoutPageState }, createStore({ - lastProjectSession: {} as Record, - workspaceOrder: {} as Record, - workspaceName: {} as Record, + lastProjectSession: {} as Record, + workspaceOrder: {} as Record, + workspaceName: {} as Record, workspaceBranchName: {} as Record>, - workspaceExpanded: {} as Record, + workspaceExpanded: {} as Record, gettingStartedDismissed: false, }), ) @@ -164,23 +165,23 @@ export default function Layout(props: ParentProps) { const [state, setState] = createStore({ autoselect: !initialDirectory, - busyWorkspaces: {} as Record, + busyWorkspaces: {} as Record, drag: { - project: undefined as string | undefined, - workspace: undefined as string | undefined, + project: undefined as WorkspacePath | undefined, + workspace: undefined as WorkspacePath | undefined, }, hoverSession: undefined as string | undefined, - hoverProject: undefined as string | undefined, + hoverProject: undefined as WorkspacePath | undefined, scrollSessionKey: undefined as string | undefined, nav: undefined as HTMLElement | undefined, sortNow: Date.now(), sizing: false, - peek: undefined as string | undefined, + peek: undefined as WorkspacePath | undefined, peeked: false, }) const editor = createInlineEditorController() - const setBusy = (directory: string, value: boolean) => { + const setBusy = (directory: WorkspacePath, value: boolean) => { const key = workspacePathKey(directory) if (value) { setState("busyWorkspaces", key, true) @@ -193,7 +194,7 @@ export default function Layout(props: ParentProps) { }), ) } - const isBusy = (directory: string) => !!state.busyWorkspaces[workspacePathKey(directory)] + const isBusy = (directory: WorkspacePath) => !!state.busyWorkspaces[workspacePathKey(directory)] const navLeave = { current: undefined as number | undefined } const sortNow = () => state.sortNow let sizet: number | undefined @@ -240,7 +241,7 @@ export default function Layout(props: ParentProps) { const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) - const setHoverProject = (value: string | undefined) => { + const setHoverProject = (value: WorkspacePath | undefined) => { setState("hoverProject", value) if (value !== undefined) return aim.reset() @@ -605,7 +606,7 @@ export default function Layout(props: ParentProps) { } }) - const workspaceName = (directory: string, projectId?: string, branch?: string) => { + const workspaceName = (directory: WorkspacePath, projectId?: string, branch?: string) => { const direct = store.workspaceName[workspacePathKey(directory)] if (direct) return direct if (!projectId) return @@ -613,7 +614,7 @@ export default function Layout(props: ParentProps) { return store.workspaceBranchName[projectId]?.[branch] } - const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { + const setWorkspaceName = (directory: WorkspacePath, next: string, projectId?: string, branch?: string) => { const key = workspacePathKey(directory) setStore("workspaceName", key, next) if (!projectId) return @@ -624,7 +625,7 @@ export default function Layout(props: ParentProps) { setStore("workspaceBranchName", projectId, branch, next) } - const workspaceLabel = (directory: string, branch?: string, projectId?: string) => + const workspaceLabel = (directory: WorkspacePath, branch?: string, projectId?: string) => workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) const workspaceSetting = createMemo(() => { @@ -656,7 +657,7 @@ export default function Layout(props: ParentProps) { const project = findProjectByDirectory(projects, directory) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue - setStore("workspaceExpanded", directory, false) + setStore("workspaceExpanded", directory as WorkspaceKey, false) } }) @@ -686,12 +687,12 @@ export default function Layout(props: ParentProps) { const prefetchPendingLimit = 10 const span = 4 const prefetchToken = { value: 0 } - const prefetchQueues = new Map() + const prefetchQueues = new Map() const PREFETCH_MAX_SESSIONS_PER_DIR = 10 - const prefetchedByDir = new Map>() + const prefetchedByDir = new Map>() - const lruFor = (directory: string) => { + const lruFor = (directory: WorkspacePath) => { const existing = prefetchedByDir.get(directory) if (existing) return existing const created = new Set() @@ -699,7 +700,7 @@ export default function Layout(props: ParentProps) { return created } - const markPrefetched = (directory: string, sessionID: string) => { + const markPrefetched = (directory: WorkspacePath, sessionID: string) => { const lru = lruFor(directory) return pickSessionCacheEvictions({ seen: lru, @@ -736,7 +737,7 @@ export default function Layout(props: ParentProps) { } }) - const queueFor = (directory: string) => { + const queueFor = (directory: WorkspacePath) => { const existing = prefetchQueues.get(directory) if (existing) return existing @@ -765,7 +766,7 @@ export default function Layout(props: ParentProps) { return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) } - async function prefetchMessages(directory: string, sessionID: string, token: number) { + async function prefetchMessages(directory: WorkspacePath, sessionID: string, token: number) { const [store, setStore] = globalSync.child(directory, { bootstrap: false }) return runSessionPrefetch({ @@ -833,7 +834,7 @@ export default function Layout(props: ParentProps) { }) } - const pumpPrefetch = (directory: string) => { + const pumpPrefetch = (directory: WorkspacePath) => { const q = queueFor(directory) if (q.running >= prefetchConcurrency) return @@ -1177,7 +1178,7 @@ export default function Layout(props: ParentProps) { dialog.show(() => ) } - function projectRoot(directory: string) { + function projectRoot(directory: WorkspacePath) { const project = findProjectByDirectory(layout.projects.list(), directory) if (project) return project.worktree @@ -1194,7 +1195,7 @@ export default function Layout(props: ParentProps) { return meta?.worktree ?? directory } - function activeProjectRoot(directory: string) { + function activeProjectRoot(directory: WorkspacePath) { return currentProject()?.worktree ?? projectRoot(directory) } @@ -1205,12 +1206,12 @@ export default function Layout(props: ParentProps) { return root } - function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { + function rememberSessionRoute(directory: WorkspacePath, id: string, root = activeProjectRoot(directory)) { setStore("lastProjectSession", workspacePathKey(root), { directory, id, at: Date.now() }) return root } - function clearLastProjectSession(root: string) { + function clearLastProjectSession(root: WorkspacePath) { const key = workspacePathKey(root) if (!store.lastProjectSession[key]) return setStore( @@ -1221,7 +1222,7 @@ export default function Layout(props: ParentProps) { ) } - function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { + function syncSessionRoute(directory: WorkspacePath, id: string, root = activeProjectRoot(directory)) { rememberSessionRoute(directory, id, root) notification.session.markViewed(id) const key = workspacePathKey(directory) @@ -1233,7 +1234,7 @@ export default function Layout(props: ParentProps) { return root } - async function navigateToProject(directory: string | undefined) { + async function navigateToProject(directory: WorkspacePath | undefined) { if (!directory) return const root = projectRoot(directory) server.projects.touch(root) @@ -1241,7 +1242,7 @@ export default function Layout(props: ParentProps) { let dirs = project ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[workspacePathKey(root)]) : [root] - const canOpen = (value: string | undefined) => { + const canOpen = (value: WorkspacePath | undefined) => { if (!value) return false return dirs.some((item) => workspacePathKey(item) === workspacePathKey(value)) } @@ -1313,7 +1314,7 @@ export default function Layout(props: ParentProps) { navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`) } - function openProject(directory: string, navigate = true) { + function openProject(directory: WorkspacePath, navigate = true) { layout.projects.open(directory) if (navigate) return navigateToProject(directory) } @@ -1362,13 +1363,13 @@ export default function Layout(props: ParentProps) { globalSync.project.meta(project.worktree, { name }) } - const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => { + const renameWorkspace = (directory: WorkspacePath, next: string, projectId?: string, branch?: string) => { const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) if (current === next) return setWorkspaceName(directory, next, projectId, branch) } - function closeProject(directory: string) { + function closeProject(directory: WorkspacePath) { const list = layout.projects.list() const index = list.findIndex((x) => workspaceEqual(x.worktree, directory)) const active = workspaceEqual(currentProject()?.worktree, directory) @@ -1431,7 +1432,7 @@ export default function Layout(props: ParentProps) { } } - const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => { + const deleteWorkspace = async (root: WorkspacePath, directory: WorkspacePath, leaveDeletedWorkspace = false) => { if (workspaceEqual(directory, root)) return const current = currentDir() @@ -1496,7 +1497,7 @@ export default function Layout(props: ParentProps) { } } - const resetWorkspace = async (root: string, directory: string) => { + const resetWorkspace = async (root: WorkspacePath, directory: WorkspacePath) => { if (workspaceEqual(directory, root)) return setBusy(directory, true) @@ -1574,7 +1575,7 @@ export default function Layout(props: ParentProps) { }) } - function DialogDeleteWorkspace(props: { root: string; directory: string }) { + function DialogDeleteWorkspace(props: { root: WorkspacePath; directory: WorkspacePath }) { const name = createMemo(() => getFilename(props.directory)) const [data, setData] = createStore({ status: "loading" as "loading" | "ready" | "error", @@ -1632,7 +1633,7 @@ export default function Layout(props: ParentProps) { ) } - function DialogResetWorkspace(props: { root: string; directory: string }) { + function DialogResetWorkspace(props: { root: WorkspacePath; directory: WorkspacePath }) { const name = createMemo(() => getFilename(props.directory)) const [state, setState] = createStore({ status: "loading" as "loading" | "ready" | "error", @@ -1710,7 +1711,7 @@ export default function Layout(props: ParentProps) { const activeRoute = { session: "", - sessionProject: "", + sessionProject: "" as WorkspacePath | "", } createEffect( @@ -1752,7 +1753,7 @@ export default function Layout(props: ParentProps) { document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) }) - const loadedSessionDirs = new Set() + const loadedSessionDirs = new Set() createEffect( on( diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index a3c0fa2b48f1..9be3701f10dc 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -6,6 +6,7 @@ import { ContextMenu } from "@opencode-ai/ui/context-menu" import { HoverCard } from "@opencode-ai/ui/hover-card" import { Icon } from "@opencode-ai/ui/icon" import { createSortable } from "@thisbeyond/solid-dnd" +import { type WorkspacePath } from "@/context/file/path" import { useLayout, type LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" @@ -14,29 +15,29 @@ import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items import { childMapByParent, displayName, projectContains, sortedRootSessions, workspaceEqual } from "./helpers" export type ProjectSidebarContext = { - currentDir: Accessor + currentDir: Accessor sidebarOpened: Accessor sidebarHovering: Accessor - hoverProject: Accessor + hoverProject: Accessor nav: Accessor - onProjectMouseEnter: (worktree: string, event: MouseEvent) => void - onProjectMouseLeave: (worktree: string) => void - onProjectFocus: (worktree: string) => void - navigateToProject: (directory: string) => void + onProjectMouseEnter: (worktree: WorkspacePath, event: MouseEvent) => void + onProjectMouseLeave: (worktree: WorkspacePath) => void + onProjectFocus: (worktree: WorkspacePath) => void + navigateToProject: (directory: WorkspacePath) => void openSidebar: () => void - closeProject: (directory: string) => void + closeProject: (directory: WorkspacePath) => void showEditProjectDialog: (project: LocalProject) => void toggleProjectWorkspaces: (project: LocalProject) => void workspacesEnabled: (project: LocalProject) => boolean - workspaceIds: (project: LocalProject) => string[] - workspaceLabel: (directory: string, branch?: string, projectId?: string) => string + workspaceIds: (project: LocalProject) => WorkspacePath[] + workspaceLabel: (directory: WorkspacePath, branch?: string, projectId?: string) => string sessionProps: Omit setHoverSession: (id: string | undefined) => void } export const ProjectDragOverlay = (props: { projects: Accessor - activeProject: Accessor + activeProject: Accessor }): JSX.Element => { const project = createMemo(() => props.projects().find((p) => workspaceEqual(p.worktree, props.activeProject()))) return ( @@ -59,15 +60,15 @@ const ProjectTile = (props: { active: Accessor overlay: Accessor suppressHover: Accessor - dirs: Accessor - onProjectMouseEnter: (worktree: string, event: MouseEvent) => void - onProjectMouseLeave: (worktree: string) => void - onProjectFocus: (worktree: string) => void - navigateToProject: (directory: string) => void + dirs: Accessor + onProjectMouseEnter: (worktree: WorkspacePath, event: MouseEvent) => void + onProjectMouseLeave: (worktree: WorkspacePath) => void + onProjectFocus: (worktree: WorkspacePath) => void + navigateToProject: (directory: WorkspacePath) => void showEditProjectDialog: (project: LocalProject) => void toggleProjectWorkspaces: (project: LocalProject) => void workspacesEnabled: (project: LocalProject) => boolean - closeProject: (directory: string) => void + closeProject: (directory: WorkspacePath) => void setMenu: (value: boolean) => void setOpen: (value: boolean) => void setSuppressHover: (value: boolean) => void @@ -185,12 +186,12 @@ const ProjectPreviewPanel = (props: { mobile?: boolean selected: Accessor workspaceEnabled: Accessor - workspaces: Accessor - label: (directory: string) => string + workspaces: Accessor + label: (directory: WorkspacePath) => string projectSessions: Accessor> projectChildren: Accessor> - workspaceSessions: (directory: string) => ReturnType - workspaceChildren: (directory: string) => Map + workspaceSessions: (directory: WorkspacePath) => ReturnType + workspaceChildren: (directory: WorkspacePath) => Map setOpen: (value: boolean) => void ctx: ProjectSidebarContext language: ReturnType @@ -306,7 +307,7 @@ export const SortableProject = (props: { setState("open", false) }) - const label = (directory: string) => { + const label = (directory: WorkspacePath) => { const [data] = globalSync.child(directory, { bootstrap: false }) const kind = directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") @@ -317,11 +318,11 @@ export const SortableProject = (props: { const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const projectChildren = createMemo(() => childMapByParent(projectStore().session)) - const workspaceSessions = (directory: string) => { + const workspaceSessions = (directory: WorkspacePath) => { const [data] = globalSync.child(directory, { bootstrap: false }) return sortedRootSessions(data, props.sortNow()) } - const workspaceChildren = (directory: string) => { + const workspaceChildren = (directory: WorkspacePath) => { const [data] = globalSync.child(directory, { bootstrap: false }) return childMapByParent(data.session) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 64a144c93544..fdc691795e7c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -51,6 +51,7 @@ import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" import { extractPromptFromParts } from "@/utils/prompt" +import { sessionDirKey } from "@/utils/session-key" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" @@ -360,7 +361,7 @@ export default function Page() { if (pending.id !== id) return layout.handoff.clearTabs() - if (pending.dir !== (params.dir ?? "")) return + if (pending.dir !== sessionDirKey(params.dir ?? "")) return const from = workspaceTabs().tabs() if (from.all.length === 0 && !from.active) return diff --git a/packages/app/src/pages/session/handoff.ts b/packages/app/src/pages/session/handoff.ts index 61bdca934292..ae1d7440e5e1 100644 --- a/packages/app/src/pages/session/handoff.ts +++ b/packages/app/src/pages/session/handoff.ts @@ -1,4 +1,5 @@ import type { SelectedLineRange } from "@/context/file" +import { sessionParts } from "@/utils/session-key" type HandoffSession = { prompt: string @@ -23,11 +24,12 @@ const touch = (map: Map, key: K, value: V) => { } export const setSessionHandoff = (key: string, patch: Partial) => { - const prev = store.session.get(key) ?? { prompt: "", files: {} } - touch(store.session, key, { ...prev, ...patch }) + const next = sessionParts(key).key + const prev = store.session.get(next) ?? { prompt: "", files: {} } + touch(store.session, next, { ...prev, ...patch }) } -export const getSessionHandoff = (key: string) => store.session.get(key) +export const getSessionHandoff = (key: string) => store.session.get(sessionParts(key).key) export const setTerminalHandoff = (key: string, value: string[]) => { touch(store.terminal, key, value) diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 047946fc1efc..965eafdb144b 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -16,7 +16,7 @@ describe("createOpenReviewFile", () => { showAllFiles: () => calls.push("show"), tabForPath: (path) => { calls.push(`tab:${path}`) - return `file://${path}` + return `tab:file:${path}` }, openTab: (tab) => calls.push(`open:${tab}`), setActive: (tab) => calls.push(`active:${tab}`), @@ -25,7 +25,7 @@ describe("createOpenReviewFile", () => { openReviewFile("src/a.ts") - expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"]) + expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:tab:file:src/a.ts", "active:tab:file:src/a.ts"]) }) }) @@ -35,12 +35,12 @@ describe("createOpenSessionFileTab", () => { const openTab = createOpenSessionFileTab({ normalizeTab: (value) => { calls.push(`normalize:${value}`) - return `file://${value}` + return value.startsWith("file://") ? `tab:file:${value.slice("file://".length)}` : `tab:file:${value}` }, openTab: (tab) => calls.push(`open:${tab}`), pathFromTab: (tab) => { calls.push(`path:${tab}`) - return tab.slice("file://".length) + return tab.slice("tab:file:".length) }, loadFile: (path) => calls.push(`load:${path}`), openReviewPanel: () => calls.push("review"), @@ -51,11 +51,11 @@ describe("createOpenSessionFileTab", () => { expect(calls).toEqual([ "normalize:src/a.ts", - "open:file://src/a.ts", - "path:file://src/a.ts", + "open:tab:file:src/a.ts", + "path:tab:file:src/a.ts", "load:src/a.ts", "review", - "active:file://src/a.ts", + "active:tab:file:src/a.ts", ]) }) }) @@ -101,18 +101,25 @@ describe("createSessionTabs", () => { createRoot((dispose) => { const [state] = createStore({ active: undefined as string | undefined, - all: ["file://src/a.ts", "context"], + all: ["file://src/a.ts", "tab:file:src/a.ts", "context"], }) const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) const result = createSessionTabs({ tabs, - pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), - normalizeTab: (tab) => (tab.startsWith("file://") ? `norm:${tab.slice("file://".length)}` : tab), + pathFromTab: (tab) => { + if (tab.startsWith("file://")) return tab.slice("file://".length) + if (tab.startsWith("tab:file:")) return tab.slice("tab:file:".length) + }, + normalizeTab: (tab) => { + if (tab.startsWith("file://")) return `tab:file:${tab.slice("file://".length)}` + return tab + }, }) - expect(result.activeTab()).toBe("norm:src/a.ts") - expect(result.activeFileTab()).toBe("norm:src/a.ts") - expect(result.closableTab()).toBe("norm:src/a.ts") + expect(result.openedTabs()).toEqual(["tab:file:src/a.ts"]) + expect(result.activeTab()).toBe("tab:file:src/a.ts") + expect(result.activeFileTab()).toBe("tab:file:src/a.ts") + expect(result.closableTab()).toBe("tab:file:src/a.ts") dispose() }) }) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index c3571f3ffce7..5c5e12db72a4 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -1,5 +1,6 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" +import { type FilePath, type FileTabId } from "@/context/file/path" import { same } from "@/utils/same" const emptyTabs: string[] = [] @@ -11,7 +12,7 @@ type Tabs = { type TabsInput = { tabs: Accessor - pathFromTab: (tab: string) => string | undefined + pathFromTab: (tab: string) => FilePath | undefined normalizeTab: (tab: string) => string review?: Accessor hasReview?: Accessor @@ -95,12 +96,12 @@ export const focusTerminalById = (id: string) => { export const createOpenReviewFile = (input: { showAllFiles: () => void - tabForPath: (path: string) => string + tabForPath: (path: FilePath) => FileTabId openTab: (tab: string) => void setActive: (tab: string) => void - loadFile: (path: string) => any | Promise + loadFile: (path: FilePath) => any | Promise }) => { - return (path: string) => { + return (path: FilePath) => { batch(() => { input.showAllFiles() const maybePromise = input.loadFile(path) @@ -118,12 +119,12 @@ export const createOpenReviewFile = (input: { export const createOpenSessionFileTab = (input: { normalizeTab: (tab: string) => string openTab: (tab: string) => void - pathFromTab: (tab: string) => string | undefined - loadFile: (path: string) => void + pathFromTab: (tab: string) => FilePath | undefined + loadFile: (path: FilePath) => void openReviewPanel: () => void setActive: (tab: string) => void }) => { - return (value: string) => { + return (value: FilePath | FileTabId) => { const next = input.normalizeTab(value) input.openTab(next) diff --git a/packages/app/src/pages/session/session-layout.ts b/packages/app/src/pages/session/session-layout.ts index 113411150da4..200df257155a 100644 --- a/packages/app/src/pages/session/session-layout.ts +++ b/packages/app/src/pages/session/session-layout.ts @@ -1,11 +1,12 @@ import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" import { useLayout } from "@/context/layout" +import { sessionKey } from "@/utils/session-key" export const useSessionKey = () => { const params = useParams() - const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) - return { params, sessionKey } + const key = createMemo(() => sessionKey(params.dir ?? "", params.id)) + return { params, sessionKey: key } } export const useSessionLayout = () => { diff --git a/packages/app/src/utils/persist-path.test.ts b/packages/app/src/utils/persist-path.test.ts index 305c2f206c3a..5565433918af 100644 --- a/packages/app/src/utils/persist-path.test.ts +++ b/packages/app/src/utils/persist-path.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { base64Encode } from "@opencode-ai/util/encode" import { migrateLayoutPageState, migrateLayoutPaths, migrateServerState } from "./persist-path" describe("migrateLayoutPaths", () => { @@ -29,6 +30,58 @@ describe("migrateLayoutPaths", () => { }, }) }) + + test("migrates session-keyed maps and handoff directory aliases", () => { + const legacy = base64Encode("C:\\Repo\\") + const next = base64Encode("c:/repo") + + expect( + migrateLayoutPaths({ + sidebar: { + workspaces: {}, + }, + sessionTabs: { + [legacy]: { all: ["file://src\\a.ts"], active: "file://src\\a.ts" }, + [next]: { all: ["review"], active: "review" }, + }, + sessionView: { + [`${legacy}/one`]: { scroll: { "file://src\\a.ts": { x: 1, y: 2 } } }, + [`${next}/one`]: { scroll: { review: { x: 3, y: 4 } }, pendingMessage: "m", pendingMessageAt: 2 }, + }, + handoff: { + tabs: { + dir: legacy, + id: "one", + at: 5, + }, + }, + }), + ).toEqual({ + sidebar: { + workspaces: {}, + }, + sessionTabs: { + [next]: { all: ["review", "tab:file:src/a.ts"], active: "review" }, + }, + sessionView: { + [`${next}/one`]: { + scroll: { + review: { x: 3, y: 4 }, + "tab:file:src/a.ts": { x: 1, y: 2 }, + }, + pendingMessage: "m", + pendingMessageAt: 2, + }, + }, + handoff: { + tabs: { + dir: next, + id: "one", + at: 5, + }, + }, + }) + }) }) describe("migrateLayoutPageState", () => { diff --git a/packages/app/src/utils/persist-path.ts b/packages/app/src/utils/persist-path.ts index 89e26dd128f9..6c512b422551 100644 --- a/packages/app/src/utils/persist-path.ts +++ b/packages/app/src/utils/persist-path.ts @@ -1,4 +1,6 @@ import { pathKey } from "@opencode-ai/util/path" +import { createPathHelpers, type WorkspaceKey, type WorkspacePath } from "@/context/file/path" +import { sessionDirKey, sessionParts } from "@/utils/session-key" const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) @@ -9,7 +11,7 @@ const num = (value: unknown) => (typeof value === "number" && Number.isFinite(va const flag = (value: unknown) => (typeof value === "boolean" ? value : undefined) -const pathId = (value: string) => pathKey(value) || value +const pathId = (value: WorkspacePath) => (pathKey(value) || value) as WorkspaceKey function paths(value: unknown, skip?: string) { if (!Array.isArray(value)) return @@ -33,12 +35,12 @@ function paths(value: unknown, skip?: string) { function byKey( value: unknown, - move: (value: unknown, key: string) => T | undefined, + move: (value: unknown, key: WorkspaceKey) => T | undefined, merge?: (prev: T, next: T) => T, ) { if (!record(value)) return - const out: Record = {} + const out: Record = {} for (const [name, item] of Object.entries(value)) { const id = pathId(name) const next = move(item, id) @@ -51,8 +53,69 @@ function byKey( return out } +function bySession(value: unknown, move: (value: unknown, key: string) => T | undefined, merge?: (prev: T, next: T) => T) { + if (!record(value)) return + + const out: Record = {} + for (const [name, item] of Object.entries(value)) { + const key = sessionParts(name).key + const next = move(item, key) + if (next === undefined) continue + + const prev = out[key] + out[key] = prev !== undefined && merge ? merge(prev, next) : next + } + + return out +} + +function list(value: readonly string[]) { + const seen = new Set() + const out: string[] = [] + + for (const item of value) { + if (seen.has(item)) continue + seen.add(item) + out.push(item) + } + + return out +} + +const sessionPath = (key: string) => { + const dir = sessionParts(key).directory + if (!dir) return + return createPathHelpers(() => dir) +} + +function tabs(value: readonly string[], key: string) { + const path = sessionPath(key) + if (!path) return list(value) + + const seen = new Set() + const out: string[] = [] + + for (const item of value) { + const next = path.normalizeTab(item) + if (seen.has(next)) continue + seen.add(next) + out.push(next) + } + + return out +} + +function scroll(value: unknown, key: string) { + if (!record(value)) return {} + + const path = sessionPath(key) + if (!path) return value + + return Object.fromEntries(Object.entries(value).map(([name, item]) => [path.normalizeTab(name), item])) +} + type Route = Record & { - directory: string + directory: WorkspacePath id: string at?: number } @@ -74,10 +137,22 @@ function route(value: unknown): Route | undefined { } type Project = Record & { - worktree: string + worktree: WorkspacePath expanded: boolean } +type SessionTabs = { + active?: string + all: string[] +} + +type SessionView = Record & { + scroll: Record + reviewOpen?: string[] + pendingMessage?: string + pendingMessageAt?: number +} + function project(value: unknown): Project | undefined { if (!record(value)) return @@ -115,32 +190,108 @@ function projects(value: unknown) { return out } +function sessionTabs(value: unknown, key: string): SessionTabs | undefined { + if (!record(value)) return + if (!Array.isArray(value.all)) return + + const all = tabs(value.all.filter((item): item is string => typeof item === "string"), key) + const active = text(value.active) + + return { + all, + active: active ? sessionPath(key)?.normalizeTab(active) ?? active : undefined, + } +} + +function mergeSessionTabs(prev: SessionTabs, next: SessionTabs): SessionTabs { + return { + all: list([...next.all, ...prev.all]), + active: next.active ?? prev.active, + } +} + +function sessionView(value: unknown, key: string): SessionView | undefined { + if (!record(value)) return + + return { + ...value, + scroll: scroll(value.scroll, key), + ...(Array.isArray(value.reviewOpen) + ? { reviewOpen: value.reviewOpen.filter((item): item is string => typeof item === "string") } + : {}), + ...(text(value.pendingMessage) ? { pendingMessage: text(value.pendingMessage) } : {}), + ...(num(value.pendingMessageAt) !== undefined ? { pendingMessageAt: num(value.pendingMessageAt) } : {}), + } +} + +function mergeSessionView(prev: SessionView, next: SessionView): SessionView { + const pending = (next.pendingMessageAt ?? -Infinity) >= (prev.pendingMessageAt ?? -Infinity) + return { + ...prev, + ...next, + scroll: { ...prev.scroll, ...next.scroll }, + ...(next.reviewOpen ? { reviewOpen: next.reviewOpen } : prev.reviewOpen ? { reviewOpen: prev.reviewOpen } : {}), + ...(pending + ? { + ...(next.pendingMessage ? { pendingMessage: next.pendingMessage } : prev.pendingMessage ? { pendingMessage: prev.pendingMessage } : {}), + ...(next.pendingMessageAt !== undefined + ? { pendingMessageAt: next.pendingMessageAt } + : prev.pendingMessageAt !== undefined + ? { pendingMessageAt: prev.pendingMessageAt } + : {}), + } + : { + ...(prev.pendingMessage ? { pendingMessage: prev.pendingMessage } : {}), + ...(prev.pendingMessageAt !== undefined ? { pendingMessageAt: prev.pendingMessageAt } : {}), + }), + } +} + export function migrateLayoutPaths(value: unknown) { if (!record(value)) return value const sidebar = value.sidebar - if (!record(sidebar)) return value - - if (typeof sidebar.workspaces === "boolean") { - return { - ...value, - sidebar: { + const nextSidebar = (() => { + if (!record(sidebar)) return sidebar + if (typeof sidebar.workspaces === "boolean") { + return { ...sidebar, workspaces: {}, workspacesDefault: sidebar.workspaces, + } + } + + const workspaces = byKey(sidebar.workspaces, (item) => flag(item)) + if (!workspaces) return sidebar + return { + ...sidebar, + workspaces, + } + })() + const sessionTabsMap = bySession(value.sessionTabs, (item, key) => sessionTabs(item, key), mergeSessionTabs) + const sessionViewMap = bySession(value.sessionView, (item, key) => sessionView(item, key), mergeSessionView) + const handoff = (() => { + if (!record(value.handoff)) return value.handoff + if (!record(value.handoff.tabs)) return value.handoff + const dir = text(value.handoff.tabs.dir) + if (!dir) return value.handoff + return { + ...value.handoff, + tabs: { + ...value.handoff.tabs, + dir: sessionDirKey(dir), }, } - } + })() - const workspaces = byKey(sidebar.workspaces, (item) => flag(item)) - if (!workspaces) return value + if (nextSidebar === sidebar && !sessionTabsMap && !sessionViewMap && handoff === value.handoff) return value return { ...value, - sidebar: { - ...sidebar, - workspaces, - }, + ...(nextSidebar !== sidebar ? { sidebar: nextSidebar } : {}), + ...(sessionTabsMap ? { sessionTabs: sessionTabsMap } : {}), + ...(sessionViewMap ? { sessionView: sessionViewMap } : {}), + ...(handoff !== value.handoff ? { handoff } : {}), } } diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index fd3fc5f7d7a9..cda017729c5b 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -1,6 +1,8 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" type PersistTestingType = typeof import("./persist").PersistTesting +type PersistType = typeof import("./persist").Persist +type RemovePersistedType = typeof import("./persist").removePersisted class MemoryStorage implements Storage { private values = new Map() @@ -45,6 +47,8 @@ class MemoryStorage implements Storage { const storage = new MemoryStorage() let persistTesting: PersistTestingType +let Persist: PersistType +let removePersisted: RemovePersistedType beforeAll(async () => { mock.module("@/context/platform", () => ({ @@ -53,6 +57,8 @@ beforeAll(async () => { const mod = await import("./persist") persistTesting = mod.PersistTesting + Persist = mod.Persist + removePersisted = mod.removePersisted }) beforeEach(() => { @@ -117,6 +123,48 @@ describe("persist localStorage resilience", () => { expect(persistTesting.workspaceStorage("C:\\Users\\foo\\")).toBe( persistTesting.workspaceStorage("c:/users/foo"), ) - expect(persistTesting.workspaceLegacyStorage("C:\\Users\\foo\\")).toHaveLength(1) + expect(persistTesting.workspaceLegacyStorage("C:\\Users\\foo\\").length).toBeGreaterThan(1) + }) + + test("workspace legacy storage probes slash and drive-letter variants", () => { + const names = persistTesting.workspaceLegacyStorage("C:/Users/foo") + + expect(names).toContain(persistTesting.workspaceStorageName("C:\\Users\\foo")) + expect(names).toContain(persistTesting.workspaceStorageName("c:/Users/foo/")) + }) + + test("legacy scoped keys cover equivalent directory aliases", () => { + expect(persistTesting.legacyScoped("C:/Users/foo", "s1", "file", "v1")).toEqual( + expect.arrayContaining([ + "C:/Users/foo/file/s1.v1", + "C:\\Users\\foo/file/s1.v1", + "c:/users/foo/file/s1.v1", + ]), + ) + }) + + test("removePersisted clears current and legacy aliases across lookup stores", () => { + const dir = "C:/Users/foo" + const legacy = persistTesting.legacyScoped(dir, "s1", "terminal", "v1") + const target = { + ...Persist.workspace(dir, "terminal"), + legacy, + } + + const current = persistTesting.workspaceStorage(dir) + const extra = persistTesting.workspaceLegacyStorage(dir)[0]! + const direct = persistTesting.localStorageDirect() + + persistTesting.localStorageWithPrefix(current).setItem("workspace:terminal", '{"current":1}') + persistTesting.localStorageWithPrefix(extra).setItem(legacy[0]!, '{"extra":1}') + direct.setItem("workspace:terminal", '{"legacy-current":1}') + direct.setItem(legacy[1]!, '{"legacy":1}') + + removePersisted(target) + + expect(persistTesting.localStorageWithPrefix(current).getItem("workspace:terminal")).toBeNull() + expect(persistTesting.localStorageWithPrefix(extra).getItem(legacy[0]!)).toBeNull() + expect(direct.getItem("workspace:terminal")).toBeNull() + expect(direct.getItem(legacy[1]!)).toBeNull() }) }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index e32f2ca99fab..a738794c63a6 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -33,6 +33,13 @@ type CacheEntry = { value: string; bytes: number } const cache = new Map() const cacheTotal = { bytes: 0 } +type PersistStore = SyncStorage | AsyncStorage +type PersistStores = { + current: PersistStore + legacy?: PersistStore + extra: PersistStore[] +} + function cacheDelete(key: string) { const entry = cache.get(key) if (!entry) return @@ -214,6 +221,59 @@ function workspaceDirectory(dir: string) { return pathKey(dir) || dir } +function trimDir(dir: string) { + if (!dir) return dir + if (/^[A-Za-z]:[\\/]?$/.test(dir)) return dir.slice(0, 2) + const trimmed = dir.replace(/[\\/]+$/, "") + return trimmed || dir +} + +function windowsPath(dir: string) { + return /^[A-Za-z]:/.test(dir) || /^[\\/]{2}/.test(dir) || dir.includes("\\") +} + +function driveLetters(dir: string) { + if (!/^[A-Za-z]:/.test(dir)) return [dir] + return [dir[0].toLowerCase() + dir.slice(1), dir[0].toUpperCase() + dir.slice(1)] +} + +function pathForms(dir: string) { + const base = trimDir(dir) + if (!base) return [] + if (!windowsPath(base)) return [base] + return [base, base.replace(/\\/g, "/"), base.replace(/\//g, "\\")] +} + +function withTrailing(dir: string) { + if (!dir) return [] + if (!windowsPath(dir)) return dir === "/" ? [dir] : [dir, `${dir}/`] + if (/^[A-Za-z]:$/.test(dir)) return [`${dir}/`, `${dir}\\`] + if (/^[\\/]{2}[^\\/]+[\\/][^\\/]+$/.test(dir)) return [dir, `${dir}/`, `${dir}\\`] + const tail = dir.includes("\\") ? "\\" : "/" + return [dir, `${dir}${tail}`] +} + +function workspaceDirectories(dir: string) { + const seen = new Set() + + const add = (value: string) => { + if (!value) return + seen.add(value) + } + + for (const value of [dir, workspaceDirectory(dir)]) { + for (const form of pathForms(value)) { + for (const letter of driveLetters(form)) { + for (const item of withTrailing(letter)) { + add(item) + } + } + } + } + + return Array.from(seen) +} + function workspaceStorageName(dir: string) { const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") const sum = checksum(dir) ?? "0" @@ -225,10 +285,16 @@ function workspaceStorage(dir: string) { } function workspaceLegacyStorage(dir: string) { - const legacy = workspaceStorageName(dir) const current = workspaceStorage(dir) - if (legacy === current) return [] - return [legacy] + return workspaceDirectories(dir) + .map(workspaceStorageName) + .filter((item, index, list) => item !== current && list.indexOf(item) === index) +} + +function legacyScoped(dir: string, session: string | undefined, key: string, version: string) { + return workspaceDirectories(dir) + .map((root) => `${root}/${key}${session ? "/" + session : ""}.${version}`) + .filter((item, index, list) => list.indexOf(item) === index) } function localStorageWithPrefix(prefix: string): SyncStorage { @@ -318,17 +384,45 @@ function localStorageDirect(): SyncStorage { } } +function stores(config: PersistTarget, platform?: Platform): PersistStores { + const isDesktop = platform?.platform === "desktop" && !!platform.storage + + return { + current: (() => { + if (isDesktop) return platform.storage!(config.storage) + if (!config.storage) return localStorageDirect() + return localStorageWithPrefix(config.storage) + })(), + legacy: (() => { + if (!isDesktop) return localStorageDirect() + if (!config.storage) return platform.storage!() + return platform.storage!(LEGACY_STORAGE) + })(), + extra: (() => { + if (!config.legacyStorage?.length) return [] + if (!isDesktop) return config.legacyStorage.map((storage) => localStorageWithPrefix(storage)) + return config.legacyStorage.flatMap((storage) => { + const item = platform.storage!(storage) + return item ? [item] : [] + }) + })(), + } +} + export const PersistTesting = { + legacyScoped, localStorageDirect, localStorageWithPrefix, normalize, workspaceDirectory, + workspaceDirectories, workspaceStorage, workspaceStorageName, workspaceLegacyStorage, } export const Persist = { + legacyScoped, global(key: string, legacy?: string[]): PersistTarget { return { storage: GLOBAL_STORAGE, key, legacy } }, @@ -354,19 +448,18 @@ export const Persist = { }, } -export function removePersisted(target: { storage?: string; key: string }, platform?: Platform) { - const isDesktop = platform?.platform === "desktop" && !!platform.storage +export function removePersisted(target: PersistTarget, platform?: Platform) { + const keys = [target.key, ...(target.legacy ?? [])] + const seen = new Set() + const all = stores(target, platform) - if (isDesktop) { - return platform.storage?.(target.storage)?.removeItem(target.key) - } - - if (!target.storage) { - localStorageDirect().removeItem(target.key) - return + for (const store of [all.current, all.legacy, ...all.extra]) { + if (!store || seen.has(store)) continue + seen.add(store) + for (const key of keys) { + store.removeItem(key) + } } - - localStorageWithPrefix(target.storage).removeItem(target.key) } export function persisted( @@ -378,36 +471,15 @@ export function persisted( const defaults = snapshot(store[0]) const legacy = config.legacy ?? [] - const extraLegacyStorage = config.legacyStorage ?? [] const isDesktop = platform.platform === "desktop" && !!platform.storage - - const currentStorage = (() => { - if (isDesktop) return platform.storage?.(config.storage) - if (!config.storage) return localStorageDirect() - return localStorageWithPrefix(config.storage) - })() - - const legacyStorage = (() => { - if (!isDesktop) return localStorageDirect() - if (!config.storage) return platform.storage?.() - return platform.storage?.(LEGACY_STORAGE) - })() - - const extraLegacyStores = (() => { - if (extraLegacyStorage.length === 0) return [] - if (!isDesktop) return extraLegacyStorage.map((storage) => localStorageWithPrefix(storage)) - return extraLegacyStorage.flatMap((storage) => { - const item = platform.storage?.(storage) - return item ? [item] : [] - }) - })() + const all = stores(config, platform) const storage = (() => { if (!isDesktop) { - const current = currentStorage as SyncStorage - const legacyStore = legacyStorage as SyncStorage - const extra = extraLegacyStores as SyncStorage[] + const current = all.current as SyncStorage + const legacyStore = all.legacy as SyncStorage + const extra = all.extra as SyncStorage[] const api: SyncStorage = { getItem: (key) => { @@ -452,9 +524,9 @@ export function persisted( return api } - const current = currentStorage as AsyncStorage - const legacyStore = legacyStorage as AsyncStorage | undefined - const extra = extraLegacyStores as AsyncStorage[] + const current = all.current as AsyncStorage + const legacyStore = all.legacy as AsyncStorage | undefined + const extra = all.extra as AsyncStorage[] const api: AsyncStorage = { getItem: async (key) => { diff --git a/packages/app/src/utils/session-key.test.ts b/packages/app/src/utils/session-key.test.ts new file mode 100644 index 000000000000..e64c653dadbe --- /dev/null +++ b/packages/app/src/utils/session-key.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { base64Encode } from "@opencode-ai/util/encode" +import { sessionDirKey, sessionKey, sessionParts } from "./session-key" + +describe("session-key", () => { + test("normalizes equivalent workspace aliases to one session key", () => { + expect(sessionDirKey(base64Encode("C:\\Repo\\"))).toBe(sessionDirKey(base64Encode("c:/repo"))) + expect(sessionKey(base64Encode("C:\\Repo\\"), "one")).toBe(sessionKey(base64Encode("c:/repo"), "one")) + expect(sessionParts(sessionKey(base64Encode("C:\\Repo\\"), "one"))).toEqual({ + dir: base64Encode("c:/repo"), + directory: "c:/repo", + id: "one", + key: `${base64Encode("c:/repo")}/one`, + }) + }) +}) diff --git a/packages/app/src/utils/session-key.ts b/packages/app/src/utils/session-key.ts new file mode 100644 index 000000000000..f22899f89668 --- /dev/null +++ b/packages/app/src/utils/session-key.ts @@ -0,0 +1,34 @@ +import { base64Encode } from "@opencode-ai/util/encode" +import { pathKey } from "@opencode-ai/util/path" +import { decode64 } from "./base64" + +function decodeDir(input: string) { + const value = decode64(input) + if (!value) return + if (base64Encode(value) !== input) return + return value +} + +export function sessionDirKey(input: string) { + const dir = decodeDir(input) + if (!dir) return input + return base64Encode(pathKey(dir) || dir) +} + +export function sessionKey(dir: string, id?: string) { + const key = sessionDirKey(dir) + if (!id) return key + return `${key}/${id}` +} + +export function sessionParts(input: string) { + const split = input.indexOf("/") + const dir = split === -1 ? input : input.slice(0, split) + const id = split === -1 ? undefined : input.slice(split + 1) + return { + dir: sessionDirKey(dir), + directory: decodeDir(dir), + id, + key: sessionKey(dir, id), + } +} diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 484e4feb2077..71589135ef55 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -31,6 +31,7 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" +import { migrate } from "./migrate" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" @@ -82,7 +83,7 @@ function setupApp() { }) void app.whenReady().then(async () => { - // migrate() + migrate() app.setAsDefaultProtocolClient("opencode") setDockIcon() setupAutoUpdater() diff --git a/packages/desktop-electron/src/main/migrate.test.ts b/packages/desktop-electron/src/main/migrate.test.ts new file mode 100644 index 000000000000..81af9270ed85 --- /dev/null +++ b/packages/desktop-electron/src/main/migrate.test.ts @@ -0,0 +1,102 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" + +type Migrate = typeof import("./migrate").migrate + +const files = new Map() +const dirs = new Map() +const stores = new Map>() +const calls = { getStore: 0 } + +const app = { isPackaged: true } +const log = { + log: mock(() => undefined), + warn: mock(() => undefined), +} + +function getStore(name = "opencode.settings") { + calls.getStore += 1 + const data = stores.get(name) ?? new Map() + stores.set(name, data) + return { + has(key: string) { + return data.has(key) + }, + get(key: string) { + return data.get(key) + }, + set(key: string, value: unknown) { + data.set(key, value) + }, + } +} + +let migrate: Migrate + +beforeAll(async () => { + mock.module("electron", () => ({ app })) + mock.module("electron-log/main.js", () => ({ default: log })) + mock.module("node:fs", () => ({ + existsSync(path: string) { + return dirs.has(path) || files.has(path) + }, + readdirSync(path: string) { + const items = dirs.get(path) + if (!items) throw new Error(`missing dir ${path}`) + return items + }, + readFileSync(path: string) { + const value = files.get(path) + if (value === undefined) throw new Error(`missing file ${path}`) + return value + }, + })) + mock.module("./constants", () => ({ CHANNEL: "prod" })) + mock.module("./store", () => ({ getStore })) + + const mod = await import("./migrate") + migrate = mod.migrate +}) + +beforeEach(() => { + files.clear() + dirs.clear() + stores.clear() + calls.getStore = 0 + log.log.mockClear() + log.warn.mockClear() + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming" +}) + +describe("migrate", () => { + test("does not touch stores before migration runs", () => { + expect(calls.getStore).toBe(0) + }) + + test("migrates tauri dat files once without overwriting electron values", () => { + const dir = "C:\\Users\\test\\AppData\\Roaming\\ai.opencode.desktop" + dirs.set(dir, ["default.dat", "opencode.settings.dat", "note.txt"]) + files.set(`${dir}\\default.dat`, JSON.stringify({ fresh: '{"x":1}', keep: '{"old":true}' })) + files.set(`${dir}\\opencode.settings.dat`, JSON.stringify({ theme: '{"dark":false}' })) + + stores.set("default.dat", new Map([["keep", '{"new":true}']])) + + migrate() + + expect(stores.get("default.dat")?.get("fresh")).toBe('{"x":1}') + expect(stores.get("default.dat")?.get("keep")).toBe('{"new":true}') + expect(stores.get("opencode.settings")?.get("theme")).toBe('{"dark":false}') + expect(stores.get("opencode.settings")?.get("tauriMigrated")).toBe(true) + + files.set(`${dir}\\default.dat`, JSON.stringify({ later: '{"y":2}' })) + migrate() + + expect(stores.get("default.dat")?.get("later")).toBeUndefined() + }) + + test("marks missing tauri data as already migrated", () => { + migrate() + + expect(stores.get("opencode.settings")?.get("tauriMigrated")).toBe(true) + expect(log.warn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts index bad1349eeba1..984444068b21 100644 --- a/packages/desktop-electron/src/main/migrate.ts +++ b/packages/desktop-electron/src/main/migrate.ts @@ -4,7 +4,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" import { CHANNEL } from "./constants" -import { getStore, store } from "./store" +import { getStore } from "./store" const TAURI_MIGRATED_KEY = "tauriMigrated" @@ -67,6 +67,8 @@ function migrateFile(datPath: string, filename: string) { } export function migrate() { + const store = getStore() + if (store.get(TAURI_MIGRATED_KEY)) { log.log("tauri migration: already done, skipping") return @@ -81,7 +83,15 @@ export function migrate() { return } - for (const filename of readdirSync(dir)) { + let items: string[] + try { + items = readdirSync(dir) + } catch (err) { + log.warn("tauri migration: failed to read directory", dir, err) + return + } + + for (const filename of items) { if (!filename.endsWith(".dat")) continue migrateFile(join(dir, filename), filename) } From 4634bd51bf720568341e4b82462f4cb9a8508650 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:04:41 +1000 Subject: [PATCH 32/42] path: propagate strong backend path ids --- .../src/control-plane/adaptors/worktree.ts | 2 +- .../opencode/src/effect/instance-context.ts | 5 +- .../opencode/src/effect/instance-registry.ts | 8 ++- packages/opencode/src/effect/instances.ts | 3 +- packages/opencode/src/file/time.ts | 71 +++++++++++-------- packages/opencode/src/lsp/client.ts | 48 ++++++++----- packages/opencode/src/lsp/index.ts | 20 +++--- packages/opencode/src/path/path.ts | 4 +- packages/opencode/src/project/instance.ts | 14 ++-- packages/opencode/src/project/state.ts | 13 ++-- packages/opencode/src/session/instruction.ts | 17 ++--- packages/opencode/src/session/message-v2.ts | 18 ++++- packages/opencode/src/snapshot/index.ts | 22 +++--- packages/opencode/src/worktree/index.ts | 31 ++++---- packages/opencode/test/fixture/instance.ts | 5 +- .../opencode/test/snapshot/snapshot.test.ts | 7 +- 16 files changed, 173 insertions(+), 115 deletions(-) diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 525b0ba0de33..9e626f8b8e51 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -25,7 +25,7 @@ export const WorktreeAdaptor: Adaptor = { const config = Config.parse(info) const bootstrap = await Worktree.createFromInfo({ name: config.name, - directory: config.directory, + directory: Path.pretty(config.directory), branch: config.branch, }) return bootstrap() diff --git a/packages/opencode/src/effect/instance-context.ts b/packages/opencode/src/effect/instance-context.ts index fd4590190496..625bcab2b294 100644 --- a/packages/opencode/src/effect/instance-context.ts +++ b/packages/opencode/src/effect/instance-context.ts @@ -1,10 +1,11 @@ import { ServiceMap } from "effect" +import type { PrettyPath } from "@/path/schema" import type { Project } from "@/project/project" export declare namespace InstanceContext { export interface Shape { - readonly directory: string - readonly worktree: string + readonly directory: PrettyPath + readonly worktree: PrettyPath readonly project: Project.Info } } diff --git a/packages/opencode/src/effect/instance-registry.ts b/packages/opencode/src/effect/instance-registry.ts index 59c556e0447e..ed11423519de 100644 --- a/packages/opencode/src/effect/instance-registry.ts +++ b/packages/opencode/src/effect/instance-registry.ts @@ -1,12 +1,14 @@ -const disposers = new Set<(directory: string) => Promise>() +import type { PrettyPath } from "@/path/schema" -export function registerDisposer(disposer: (directory: string) => Promise) { +const disposers = new Set<(directory: PrettyPath | string) => Promise>() + +export function registerDisposer(disposer: (directory: PrettyPath | string) => Promise) { disposers.add(disposer) return () => { disposers.delete(disposer) } } -export async function disposeInstance(directory: string) { +export async function disposeInstance(directory: PrettyPath | string) { await Promise.allSettled([...disposers].map((disposer) => disposer(directory))) } diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index c05458d5df97..0a5d901bec5d 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -4,6 +4,7 @@ import { FileTime } from "@/file/time" import { FileWatcher } from "@/file/watcher" import { Format } from "@/format" import { PermissionNext } from "@/permission" +import { Path } from "@/path/path" import { Instance } from "@/project/instance" import { Vcs } from "@/project/vcs" import { ProviderAuth } from "@/provider/auth" @@ -63,6 +64,6 @@ export class Instances extends ServiceMap.Service { - return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) + return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(Path.pretty(directory))))) } } diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 3d94bc122215..54b3baa59017 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,4 +1,7 @@ import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" +import { Path } from "@/path/path" +import type { PathKey, PrettyPath } from "@/path/schema" +import { Instance } from "@/project/instance" import { runPromiseInstance } from "@/effect/runtime" import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" @@ -9,16 +12,21 @@ export namespace FileTime { const log = Log.create({ service: "file.time" }) export type Stamp = { + readonly file: PrettyPath readonly read: Date readonly mtime: number | undefined readonly ctime: number | undefined readonly size: number | undefined } - const stamp = Effect.fnUntraced(function* (file: string) { + const pretty = (file: string) => Path.pretty(file, { cwd: Instance.directory }) + const key = (file: string) => Path.key(file, { cwd: Instance.directory }) + + const stamp = Effect.fnUntraced(function* (file: PrettyPath) { const stat = Filesystem.stat(file) const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size return { + file, read: yield* DateTime.nowAsDate, mtime: stat?.mtime?.getTime(), ctime: stat?.ctime?.getTime(), @@ -26,20 +34,20 @@ export namespace FileTime { } }) - const session = (reads: Map>, sessionID: SessionID) => { + const session = (reads: Map>, sessionID: SessionID) => { const value = reads.get(sessionID) if (value) return value - const next = new Map() + const next = new Map() reads.set(sessionID, next) return next } export interface Interface { - readonly read: (sessionID: SessionID, file: string) => Effect.Effect - readonly get: (sessionID: SessionID, file: string) => Effect.Effect - readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect - readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect + readonly read: (sessionID: SessionID, file: PrettyPath | string) => Effect.Effect + readonly get: (sessionID: SessionID, file: PrettyPath | string) => Effect.Effect + readonly assert: (sessionID: SessionID, file: PrettyPath | string) => Effect.Effect + readonly withLock: (file: PrettyPath | string, fn: () => Promise) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/FileTime") {} @@ -48,63 +56,66 @@ export namespace FileTime { Service, Effect.gen(function* () { const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK - const reads = new Map>() - const locks = new Map() + const reads = new Map>() + const locks = new Map() - const getLock = (filepath: string) => { - const lock = locks.get(filepath) + const getLock = (file: PrettyPath | string) => { + const id = key(file) + const lock = locks.get(id) if (lock) return lock const next = Semaphore.makeUnsafe(1) - locks.set(filepath, next) + locks.set(id, next) return next } - const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { - log.info("read", { sessionID, file }) - session(reads, sessionID).set(file, yield* stamp(file)) + const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: PrettyPath | string) { + const path = pretty(file) + log.info("read", { sessionID, file: path }) + session(reads, sessionID).set(key(path), yield* stamp(path)) }) - const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { - return reads.get(sessionID)?.get(file)?.read + const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: PrettyPath | string) { + return reads.get(sessionID)?.get(key(file))?.read }) - const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { + const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, file: PrettyPath | string) { if (disableCheck) return - const time = reads.get(sessionID)?.get(filepath) - if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) + const path = pretty(file) + const time = reads.get(sessionID)?.get(key(path)) + if (!time) throw new Error(`You must read file ${path} before overwriting it. Use the Read tool first`) - const next = yield* stamp(filepath) + const next = yield* stamp(path) const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size if (!changed) return throw new Error( - `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, + `File ${path} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, ) }) - const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Promise) { - return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) + const withLock = Effect.fn("FileTime.withLock")(function* (file: PrettyPath | string, fn: () => Promise) { + return yield* Effect.promise(fn).pipe(getLock(file).withPermits(1)) }) return Service.of({ read, get, assert, withLock }) }), ) - export function read(sessionID: SessionID, file: string) { + export function read(sessionID: SessionID, file: PrettyPath | string) { return runPromiseInstance(Service.use((s) => s.read(sessionID, file))) } - export function get(sessionID: SessionID, file: string) { + export function get(sessionID: SessionID, file: PrettyPath | string) { return runPromiseInstance(Service.use((s) => s.get(sessionID, file))) } - export async function assert(sessionID: SessionID, filepath: string) { - return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath))) + export async function assert(sessionID: SessionID, file: PrettyPath | string) { + return runPromiseInstance(Service.use((s) => s.assert(sessionID, file))) } - export async function withLock(filepath: string, fn: () => Promise): Promise { - return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn))) + export async function withLock(file: PrettyPath | string, fn: () => Promise): Promise { + return runPromiseInstance(Service.use((s) => s.withLock(file, fn))) } } diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 5495d9d95ee6..21e7fe776483 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -1,6 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Path } from "@/path/path" +import type { FileURI, PathKey, PrettyPath } from "@/path/schema" import path from "path" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" @@ -19,6 +20,16 @@ const DIAGNOSTICS_DEBOUNCE_MS = 150 export namespace LSPClient { const log = Log.create({ service: "lsp.client" }) + type Entry = { + path: PrettyPath + diagnostics: Diagnostic[] + } + + type Open = { + path: PrettyPath + version: number + } + export type Info = NonNullable>> export type Diagnostic = VSCodeDiagnostic @@ -41,19 +52,20 @@ export namespace LSPClient { ), } - export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { + export async function create(input: { serverID: string; server: LSPServer.Handle; root: PrettyPath | string }) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") + const root = Path.pretty(input.root, { cwd: Instance.directory }) const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout as any), new StreamMessageWriter(input.server.process.stdin as any), ) - const diagnostics = new Map() + const diagnostics = new Map() connection.onNotification("textDocument/publishDiagnostics", (params) => { - const path = String(Path.fromURI(params.uri)) - const pathKey = String(Path.key(path)) + const path = Path.fromURI(params.uri) + const pathKey = Path.key(path) const filePath = diagnostics.get(pathKey)?.path ?? files.get(pathKey)?.path ?? path l.info("textDocument/publishDiagnostics", { path: filePath, @@ -77,7 +89,7 @@ export namespace LSPClient { connection.onRequest("workspace/workspaceFolders", async () => [ { name: "workspace", - uri: String(Path.uri(input.root)), + uri: String(Path.uri(root)), }, ]) connection.listen() @@ -85,12 +97,12 @@ export namespace LSPClient { l.info("sending initialize") await withTimeout( connection.sendRequest("initialize", { - rootUri: String(Path.uri(input.root)), + rootUri: String(Path.uri(root)), processId: input.server.process.pid, workspaceFolders: [ { name: "workspace", - uri: String(Path.uri(input.root)), + uri: String(Path.uri(root)), }, ], initializationOptions: { @@ -136,11 +148,11 @@ export namespace LSPClient { }) } - const files = new Map() + const files = new Map() const result = { - root: input.root, - rootKey: String(Path.key(input.root)), + root, + rootKey: Path.key(root), get serverID() { return input.serverID }, @@ -148,15 +160,15 @@ export namespace LSPClient { return connection }, notify: { - async open(input: { path: string }) { - const file = String(Path.pretty(input.path, { cwd: Instance.directory })) - const pathKey = String(Path.key(file)) + async open(input: { path: PrettyPath | string }) { + const file = Path.pretty(input.path, { cwd: Instance.directory }) + const pathKey = Path.key(file) const doc = files.get(pathKey) const text = await Filesystem.readText(file) const extension = path.extname(file) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" const open = doc?.path ?? file - const uri = String(Path.uri(open)) + const uri: FileURI = Path.uri(open) if (doc) { log.info("workspace/didChangeWatchedFiles", { path: open }) @@ -210,11 +222,11 @@ export namespace LSPClient { }, }, get diagnostics() { - return new Map([...diagnostics.values()].map((item) => [item.path, item.diagnostics])) + return new Map([...diagnostics.values()].map((item) => [String(item.path), item.diagnostics])) }, - async waitForDiagnostics(input: { path: string }) { - const path = String(Path.pretty(input.path, { cwd: Instance.directory })) - const pathKey = String(Path.key(path)) + async waitForDiagnostics(input: { path: PrettyPath | string }) { + const path = Path.pretty(input.path, { cwd: Instance.directory }) + const pathKey = Path.key(path) log.info("waiting for diagnostics", { path }) let unsub: () => void let debounceTimer: ReturnType | undefined diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 4fa9a344b267..4439f6c344f2 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -1,6 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Path } from "@/path/path" +import type { PathKey, PrettyPath } from "@/path/schema" import { Log } from "../util/log" import { LSPClient } from "./client" import path from "path" @@ -15,15 +16,15 @@ import { spawn as lspspawn } from "./launch" export namespace LSP { const log = Log.create({ service: "lsp" }) - function pretty(input: string) { - return String(Path.pretty(input, { cwd: Instance.directory })) + function pretty(input: string): PrettyPath { + return Path.pretty(input, { cwd: Instance.directory }) } function uri(input: string) { - return String(Path.uri(pretty(input))) + return Path.uri(pretty(input)) } - function key(serverID: string, root: string) { + function key(serverID: string, root: PrettyPath) { return `${serverID}:${Path.key(root)}` } @@ -193,7 +194,7 @@ export namespace LSP { const extension = path.parse(file).ext || file const result: LSPClient.Info[] = [] - async function schedule(server: LSPServer.Info, root: string, key: string) { + async function schedule(server: LSPServer.Info, root: PrettyPath, key: string) { const handle = await server .spawn(root) .then((value) => { @@ -243,7 +244,8 @@ export namespace LSP { const id = key(server.id, dir) if (s.broken.has(id)) continue - const match = s.clients.find((x) => x.rootKey === Path.key(dir) && x.serverID === server.id) + const rootKey = Path.key(dir) + const match = s.clients.find((x) => x.rootKey === rootKey && x.serverID === server.id) if (match) { result.push(match) continue @@ -305,13 +307,13 @@ export namespace LSP { } export async function diagnostics() { - const results = new Map() + const results = new Map() for (const result of await runAll(async (client) => client.diagnostics)) { for (const [path, diagnostics] of result.entries()) { - const key = String(Path.key(path)) + const key = Path.key(path) const item = results.get(key) if (!item) { - results.set(key, { path, diagnostics: [...diagnostics] }) + results.set(key, { path: Path.pretty(path), diagnostics: [...diagnostics] }) continue } item.diagnostics.push(...diagnostics) diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index 96c2450519ce..55e4fba8656a 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -232,7 +232,7 @@ function toURIText(input: string, platform: NodeJS.Platform) { return `file:///${fixDrive(text.slice(0, 2))}${body}` } -async function physicalAsync(input: string, opts: Opts = {}) { +async function physicalAsync(input: string, opts: Opts = {}): Promise { const platform = pf(opts) const mod = lib(platform) const text = prettyText(input, opts) @@ -244,7 +244,7 @@ async function physicalAsync(input: string, opts: Opts = {}) { while (true) { const parent = mod.dirname(dir) - if (parent === dir) return text + if (parent === dir) return PrettyPath.make(text) parts.unshift(mod.basename(dir)) const next = await realpath(parent).catch(() => undefined) if (next) return PrettyPath.make(clean(mod.join(next, ...parts), platform)) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index fa3100640f6a..9058ffded1c2 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,7 +1,7 @@ import { GlobalBus } from "@/bus/global" import { disposeInstance } from "@/effect/instance-registry" import { Path } from "@/path/path" -import type { PathKey } from "@/path/schema" +import type { PathKey, PrettyPath } from "@/path/schema" import { Filesystem } from "@/util/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util/log" @@ -10,8 +10,8 @@ import { Project } from "./project" import { State } from "./state" interface Context { - directory: string - worktree: string + directory: PrettyPath + worktree: PrettyPath project: Project.Info } const context = Context.create("instance") @@ -21,7 +21,7 @@ const disposal = { all: undefined as Promise | undefined, } -function emit(directory: string) { +function emit(directory: PrettyPath | string) { GlobalBus.emit("event", { directory, payload: { @@ -33,7 +33,7 @@ function emit(directory: string) { }) } -function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { +function boot(input: { directory: PrettyPath; init?: () => Promise; project?: Project.Info; worktree?: PrettyPath }) { return iife(async () => { const ctx = input.project && input.worktree @@ -87,10 +87,10 @@ export const Instance = { get current() { return context.use() }, - get directory() { + get directory(): PrettyPath | string { return context.use().directory }, - get worktree() { + get worktree(): PrettyPath | string { return context.use().worktree }, get project() { diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index a9dce565b5eb..4650705b17d7 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -1,3 +1,5 @@ +import { Path } from "@/path/path" +import type { PathKey, PrettyPath } from "@/path/schema" import { Log } from "@/util/log" export namespace State { @@ -7,14 +9,14 @@ export namespace State { } const log = Log.create({ service: "state" }) - const recordsByKey = new Map>() + const recordsByKey = new Map>() - export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { + export function create(root: () => PrettyPath | string, init: () => S, dispose?: (state: Awaited) => Promise) { return () => { - const key = root() + const key = Path.key(root()) let entries = recordsByKey.get(key) if (!entries) { - entries = new Map() + entries = new Map() recordsByKey.set(key, entries) } const exists = entries.get(init) @@ -28,7 +30,8 @@ export namespace State { } } - export async function dispose(key: string) { + export async function dispose(directory: PrettyPath | string) { + const key = Path.key(directory) const entries = recordsByKey.get(key) if (!entries) return diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index e7c278edc4f9..27439c2e9a5b 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -4,6 +4,7 @@ import { Filesystem } from "../util/filesystem" import { Config } from "../config/config" import { Path } from "../path/path" import { Instance } from "../project/instance" +import type { PathKey, PrettyPath } from "@/path/schema" import { Flag } from "@/flag/flag" import { Log } from "../util/log" import { Glob } from "../util/glob" @@ -45,24 +46,24 @@ async function resolveRelative(instruction: string): Promise { export namespace InstructionPrompt { const state = Instance.state(() => { return { - claims: new Map>(), + claims: new Map>(), } }) - function isClaimed(messageID: string, filepath: string) { + function isClaimed(messageID: string, filepath: PrettyPath | string) { const claimed = state().claims.get(messageID) if (!claimed) return false - return claimed.has(filepath) + return claimed.has(Path.key(filepath)) } - function claim(messageID: string, filepath: string) { + function claim(messageID: string, filepath: PrettyPath | string) { const current = state() let claimed = current.claims.get(messageID) if (!claimed) { claimed = new Set() current.claims.set(messageID, claimed) } - claimed.add(filepath) + claimed.add(Path.key(filepath)) } export function clear(messageID: string) { @@ -149,7 +150,7 @@ export namespace InstructionPrompt { const loaded = part.state.metadata?.loaded if (!loaded || !Array.isArray(loaded)) continue for (const p of loaded) { - if (typeof p === "string") paths.add(p) + if (typeof p === "string") paths.add(Path.pretty(p)) } } } @@ -164,7 +165,7 @@ export namespace InstructionPrompt { } } - export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) { + export async function resolve(messages: MessageV2.WithParts[], filepath: PrettyPath | string, messageID: string) { const system = new Set(Array.from(await systemPaths(), (item) => Filesystem.resolve(item))) const already = new Set(Array.from(loaded(messages), (item) => Filesystem.resolve(item))) const results: { filepath: string; content: string }[] = [] @@ -181,7 +182,7 @@ export namespace InstructionPrompt { claim(messageID, hit) const content = await Filesystem.readText(hit).catch(() => undefined) if (content) { - results.push({ filepath: hit, content: "Instructions from: " + hit + "\n" + content }) + results.push({ filepath: Path.pretty(hit), content: "Instructions from: " + hit + "\n" + content }) } } current = path.dirname(current) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 41e2d4efc7e8..8dd40b5ab46a 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -11,6 +11,7 @@ import { MessageTable, PartTable, SessionTable } from "./session.sql" import { ProviderTransform } from "@/provider/transform" import { STATUS_CODES } from "http" import { Storage } from "@/storage/storage" +import type { FileURI, PrettyPath } from "@/path/schema" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" import { type SystemError } from "bun" @@ -149,6 +150,9 @@ export namespace MessageV2 { }).meta({ ref: "FileSource", }) + export type FileSource = Omit, "path"> & { + path: PrettyPath + } export const SymbolSource = FilePartSourceBase.extend({ type: z.literal("symbol"), @@ -159,6 +163,9 @@ export namespace MessageV2 { }).meta({ ref: "SymbolSource", }) + export type SymbolSource = Omit, "path"> & { + path: PrettyPath + } export const ResourceSource = FilePartSourceBase.extend({ type: z.literal("resource"), @@ -167,10 +174,14 @@ export namespace MessageV2 { }).meta({ ref: "ResourceSource", }) + export type ResourceSource = Omit, "uri"> & { + uri: string | FileURI + } export const FilePartSource = z.discriminatedUnion("type", [FileSource, SymbolSource, ResourceSource]).meta({ ref: "FilePartSource", }) + export type FilePartSource = FileSource | SymbolSource | ResourceSource export const FilePart = PartBase.extend({ type: z.literal("file"), @@ -441,7 +452,12 @@ export namespace MessageV2 { }).meta({ ref: "AssistantMessage", }) - export type Assistant = z.infer + export type Assistant = Omit, "path"> & { + path: { + cwd: PrettyPath | string + root: PrettyPath | string + } + } export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({ ref: "Message", diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 040cf3a28bbc..39879824a373 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -7,6 +7,7 @@ import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" import { AppFileSystem } from "@/filesystem" import { Path } from "@/path/path" +import type { PathKey, PrettyPath } from "@/path/schema" import { Config } from "../config/config" import { Global } from "../global" import { Log } from "../util/log" @@ -102,7 +103,7 @@ export namespace Snapshot { const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd] const git = Effect.fnUntraced( - function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + function* (cmd: string[], opts?: { cwd?: PrettyPath; env?: Record }) { const proc = ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, @@ -127,9 +128,9 @@ export namespace Snapshot { ) // Snapshot-specific error handling on top of AppFileSystem - const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) - const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) - const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) + const exists = (file: PrettyPath | string) => fs.exists(file).pipe(Effect.orDie) + const read = (file: PrettyPath | string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) + const remove = (file: PrettyPath | string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) const enabled = Effect.fnUntraced(function* () { if (project.vcs !== "git") return false @@ -213,7 +214,7 @@ export namespace Snapshot { .split("\n") .map((x) => x.trim()) .filter(Boolean) - .map((x) => path.join(worktree, x).replaceAll("\\", "/")), + .map((x) => Path.pretty(x, { cwd: worktree })), } }) @@ -238,15 +239,16 @@ export namespace Snapshot { }) const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { - const seen = new Set() + const seen = new Set() for (const item of patches) { for (const file of item.files) { - if (seen.has(file)) continue - seen.add(file) + const id = Path.key(file) + if (seen.has(id)) continue + seen.add(id) + const rel = Path.rel(worktree, file) log.info("reverting", { file, hash: item.hash }) - const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree }) + const result = yield* git([...core, ...args(["checkout", item.hash, "--", rel])], { cwd: worktree }) if (result.code !== 0) { - const rel = path.relative(worktree, file) const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree }) if (tree.code === 0 && tree.text.trim()) { log.info("file existed in snapshot but checkout failed, keeping", { file }) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 93fe03c3add3..134aab07bc92 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -16,6 +16,7 @@ import { git } from "../util/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Path } from "@/path/path" +import type { PathKey, PrettyPath } from "@/path/schema" export namespace Worktree { const log = Log.create({ service: "worktree" }) @@ -46,7 +47,9 @@ export namespace Worktree { ref: "Worktree", }) - export type Info = z.infer + export type Info = Omit, "directory"> & { + directory: PrettyPath | string + } export const CreateInput = z .object({ @@ -70,7 +73,9 @@ export namespace Worktree { ref: "WorktreeRemoveInput", }) - export type RemoveInput = z.infer + export type RemoveInput = Omit, "directory"> & { + directory: PrettyPath | string + } export const ResetInput = z .object({ @@ -80,7 +85,9 @@ export namespace Worktree { ref: "WorktreeResetInput", }) - export type ResetInput = z.infer + export type ResetInput = Omit, "directory"> & { + directory: PrettyPath | string + } export const NotGitError = NamedError.create( "WorktreeNotGitError", @@ -261,7 +268,7 @@ export namespace Worktree { return git(["clean", "-ffdx"], { cwd: root }) } - async function key(input: string) { + async function key(input: string): Promise { return Path.key(await Path.physical(input)) } @@ -269,7 +276,7 @@ export namespace Worktree { for (const attempt of Array.from({ length: 26 }, (_, i) => i)) { const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName() const branch = `opencode/${name}` - const directory = path.join(root, name) + const directory = Path.pretty(path.join(root, name)) if (await exists(directory)) continue @@ -279,7 +286,7 @@ export namespace Worktree { }) if (branchCheck.exitCode === 0) continue - return Info.parse({ name, branch, directory }) + return Info.parse({ name, branch, directory }) as Info } throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) @@ -440,10 +447,10 @@ export namespace Worktree { const lines = outputText(stdout) .split("\n") .map((line) => line.trim()) - const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => { + const entries = lines.reduce<{ path?: PrettyPath; branch?: string }[]>((acc, line) => { if (!line) return acc if (line.startsWith("worktree ")) { - acc.push({ path: line.slice("worktree ".length).trim() }) + acc.push({ path: Path.pretty(line.slice("worktree ".length).trim()) }) return acc } const current = acc[acc.length - 1] @@ -462,7 +469,7 @@ export namespace Worktree { })() } - const clean = (target: string) => + const clean = (target: PrettyPath) => fs .rm(target, { recursive: true, @@ -475,7 +482,7 @@ export namespace Worktree { throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) }) - const stop = async (target: string) => { + const stop = async (target: PrettyPath) => { if (!(await exists(target))) return await git(["fsmonitor--daemon", "stop"], { cwd: target }) } @@ -546,10 +553,10 @@ export namespace Worktree { const lines = outputText(list.stdout) .split("\n") .map((line) => line.trim()) - const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => { + const entries = lines.reduce<{ path?: PrettyPath; branch?: string }[]>((acc, line) => { if (!line) return acc if (line.startsWith("worktree ")) { - acc.push({ path: line.slice("worktree ".length).trim() }) + acc.push({ path: Path.pretty(line.slice("worktree ".length).trim()) }) return acc } const current = acc[acc.length - 1] diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts index ce880d70d929..e79088371f40 100644 --- a/packages/opencode/test/fixture/instance.ts +++ b/packages/opencode/test/fixture/instance.ts @@ -1,5 +1,6 @@ import { ConfigProvider, Layer, ManagedRuntime } from "effect" import { InstanceContext } from "../../src/effect/instance-context" +import { Path } from "../../src/path/path" import { Instance } from "../../src/project/instance" /** ConfigProvider that enables the experimental file watcher. */ @@ -29,8 +30,8 @@ export function withServices( fn: async () => { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ - directory: Instance.directory, - worktree: Instance.worktree, + directory: Path.pretty(Instance.directory), + worktree: Path.pretty(Instance.worktree), project: Instance.project, }), ) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 20305028764c..3395f6be0e62 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -7,10 +7,9 @@ import { Instance } from "../../src/project/instance" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" -// Git always outputs /-separated paths internally. Snapshot.patch() joins them -// with path.join (which produces \ on Windows) then normalizes back to /. -// This helper does the same for expected values so assertions match cross-platform. -const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") +// Snapshot.patch() now returns native pretty paths, so expected values should +// match the host separator and casing rules. +const fwd = (...parts: string[]) => path.join(...parts) async function bootstrap() { return tmpdir({ From 6258245844baddffdf1504d3d43bd93d85c47ad3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:04:49 +1000 Subject: [PATCH 33/42] app: propagate strong path ids through state --- packages/app/src/context/comments.tsx | 49 +++++++------ packages/app/src/context/file.tsx | 54 ++++++++------ packages/app/src/context/file/path.ts | 4 +- .../app/src/context/file/tree-store.test.ts | 4 +- packages/app/src/context/file/tree-store.ts | 71 +++++++++++-------- packages/app/src/context/file/types.ts | 3 +- packages/app/src/context/file/view-cache.ts | 30 ++++---- packages/app/src/context/global-sync.tsx | 27 +++---- .../app/src/context/global-sync/bootstrap.ts | 5 +- .../src/context/global-sync/child-store.ts | 24 +++---- .../src/context/global-sync/event-reducer.ts | 4 +- packages/app/src/context/global-sync/queue.ts | 4 +- .../context/global-sync/session-prefetch.ts | 20 +++--- packages/app/src/context/global-sync/types.ts | 5 +- .../app/src/context/notification-state.ts | 6 +- packages/app/src/context/notification.tsx | 19 ++--- packages/app/src/context/prompt.tsx | 14 ++-- packages/app/src/pages/session/file-tabs.tsx | 10 +-- packages/app/src/pages/session/helpers.ts | 23 +++--- packages/app/src/pages/session/review-tab.tsx | 13 ++-- .../src/pages/session/session-side-panel.tsx | 13 ++-- 21 files changed, 220 insertions(+), 182 deletions(-) diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 3060065a432c..f8a588fd1515 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -6,17 +6,17 @@ import { Persist, persisted } from "@/utils/persist" import { createScopedCache } from "@/utils/scoped-cache" import { uuid } from "@/utils/uuid" import type { SelectedLineRange } from "@/context/file" -import { filePathEqual, filePathKey } from "@/context/file/path" +import { filePathEqual, filePathKey, type FilePath, type FilePathKey } from "@/context/file/path" export type LineComment = { id: string - file: string + file: FilePath selection: SelectedLineRange comment: string time: number } -type CommentFocus = { file: string; id: string } +type CommentFocus = { file: FilePath; id: string } const WORKSPACE_KEY = "__workspace__" const MAX_COMMENT_SESSIONS = 20 @@ -35,16 +35,18 @@ function decodeSessionKey(key: string) { } type CommentStore = { - comments: Record + comments: Record } -const commentFile = (file: string) => filePathKey(file) || file +const normalizeFile = (file: FilePath) => (filePathKey(file) || file) as FilePath -function matchFile(comments: Record, file: string) { - return Object.keys(comments).find((key) => filePathEqual(key, file)) +const commentKey = (file: FilePath) => normalizeFile(file) as FilePathKey + +function matchFile(comments: Record, file: FilePath) { + return Object.keys(comments).find((key) => filePathEqual(key, file)) as FilePathKey | undefined } -function aggregate(comments: Record) { +function aggregate(comments: Record) { return Object.values(comments) .flatMap((items) => items.map(cloneComment)) .slice() @@ -65,21 +67,21 @@ function cloneSelection(selection: SelectedLineRange): SelectedLineRange { function cloneComment(comment: LineComment): LineComment { return { ...comment, - file: commentFile(comment.file), + file: normalizeFile(comment.file), selection: cloneSelection(comment.selection), } } function group(comments: LineComment[]) { - return comments.reduce>((acc, comment) => { - const file = commentFile(comment.file) - const list = acc[file] - const next = cloneComment({ ...comment, file }) + return comments.reduce>((acc, comment) => { + const key = matchFile(acc, comment.file) ?? commentKey(comment.file) + const list = acc[key] + const next = cloneComment(comment) if (list) { list.push(next) return acc } - acc[file] = [next] + acc[key] = [next] return acc }, {}) } @@ -92,7 +94,7 @@ function createCommentSessionState(store: Store, setStore: SetStor const all = () => aggregate(store.comments) - const normalizeFocus = (value: CommentFocus | null) => (value ? { ...value, file: commentFile(value.file) } : null) + const normalizeFocus = (value: CommentFocus | null) => (value ? { ...value, file: normalizeFile(value.file) } : null) const setRef = ( key: "focus" | "active", @@ -106,13 +108,14 @@ function createCommentSessionState(store: Store, setStore: SetStor const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => setRef("active", value) - const list = (file: string) => { + const list = (file: FilePath) => { const key = matchFile(store.comments, file) return (key ? store.comments[key] : undefined)?.map(cloneComment) ?? [] } const add = (input: Omit) => { - const file = matchFile(store.comments, input.file) ?? commentFile(input.file) + const key = matchFile(store.comments, input.file) ?? commentKey(input.file) + const file = store.comments[key]?.[0]?.file ?? normalizeFile(input.file) const next: LineComment = { id: uuid(), time: Date.now(), @@ -122,14 +125,14 @@ function createCommentSessionState(store: Store, setStore: SetStor } batch(() => { - setStore("comments", file, (items) => [...(items ?? []), next]) + setStore("comments", key, (items) => [...(items ?? []), next]) setFocus({ file, id: next.id }) }) return next } - const remove = (file: string, id: string) => { + const remove = (file: FilePath, id: string) => { const key = matchFile(store.comments, file) if (!key) return @@ -139,7 +142,7 @@ function createCommentSessionState(store: Store, setStore: SetStor }) } - const update = (file: string, id: string, comment: string) => { + const update = (file: FilePath, id: string, comment: string) => { const key = matchFile(store.comments, file) if (!key) return @@ -246,11 +249,11 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont return { ready: () => session().ready(), - list: (file: string) => session().list(file), + list: (file: FilePath) => session().list(file), all: () => session().all(), add: (input: Omit) => session().add(input), - remove: (file: string, id: string) => session().remove(file, id), - update: (file: string, id: string, comment: string) => session().update(file, id, comment), + remove: (file: FilePath, id: string) => session().remove(file, id), + update: (file: FilePath, id: string, comment: string) => session().update(file, id, comment), replace: (comments: LineComment[]) => session().replace(comments), clear: () => session().clear(), focus: () => session().focus(), diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 28e6381ca083..d6e60ad913a7 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -9,7 +9,7 @@ import { useSync } from "./sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { sessionKey } from "@/utils/session-key" -import { createPathHelpers } from "./file/path" +import { createPathHelpers, filePathKey, workspacePathKey, type FilePath, type FilePathKey } from "./file/path" import { approxBytes, evictContentLru, @@ -63,16 +63,19 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const scope = createMemo(() => sdk.directory) const path = createPathHelpers(scope) const tabs = layout.tabs(() => sessionKey(params.dir ?? "", params.id)) + const fileKey = (file: FilePath) => (filePathKey(file) || file) as FilePathKey + const loadKey = (directory: string, file: FilePath) => `${workspacePathKey(directory)}\n${fileKey(file)}` const inflight = new Map>() const [store, setStore] = createStore<{ - file: Record + file: Record }>({ file: {}, }) const tree = createFileTreeStore({ scope, + normalize: path.normalize, normalizeDir: path.normalizeDir, list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []), onError: (message) => { @@ -86,10 +89,11 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const evictContent = (keep?: Set) => { evictContentLru(keep, (target) => { - if (!store.file[target]) return + const key = fileKey(target as FilePath) + if (!store.file[key]) return setStore( "file", - target, + key, produce((draft) => { draft.content = undefined draft.loaded = false @@ -111,16 +115,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const viewCache = createFileViewCache() const view = createMemo(() => viewCache.load(scope(), params.id)) - const ensure = (file: string) => { + const ensure = (file: FilePath) => { if (!file) return - if (store.file[file]) return - setStore("file", file, { path: file, name: getFilename(file) }) + const key = fileKey(file) + if (store.file[key]) return + setStore("file", key, { path: file, name: getFilename(file) }) } - const setLoading = (file: string) => { + const setLoading = (file: FilePath) => { + const key = fileKey(file) setStore( "file", - file, + key, produce((draft) => { draft.loading = true draft.error = undefined @@ -128,10 +134,11 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ ) } - const setLoaded = (file: string, content: FileState["content"]) => { + const setLoaded = (file: FilePath, content: FileState["content"]) => { + const key = fileKey(file) setStore( "file", - file, + key, produce((draft) => { draft.loaded = true draft.loading = false @@ -140,10 +147,11 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ ) } - const setLoadError = (file: string, message: string) => { + const setLoadError = (file: FilePath, message: string) => { + const key = fileKey(file) setStore( "file", - file, + key, produce((draft) => { draft.loading = false draft.error = message @@ -161,10 +169,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ if (!file) return Promise.resolve() const directory = scope() - const key = `${directory}\n${file}` + const key = loadKey(directory, file) ensure(file) - const current = store.file[file] + const current = store.file[fileKey(file)] if (!options?.force && current?.loaded) return Promise.resolve() const pending = inflight.get(key) @@ -202,12 +210,12 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ ) const stop = sdk.event.listen((e) => { - invalidateFromWatcher(e.details, { - normalize: path.normalize, - hasFile: (file) => Boolean(store.file[file]), - isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file), - loadFile: (file) => { - void load(file, { force: true }) + invalidateFromWatcher(e.details, { + normalize: path.normalize, + hasFile: (file) => Boolean(store.file[fileKey(file)]), + isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file), + loadFile: (file) => { + void load(file, { force: true }) }, node: tree.node, isDirLoaded: tree.isLoaded, @@ -219,7 +227,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const get = (input: string) => { const file = path.normalize(input) - const state = store.file[file] + const state = store.file[fileKey(file)] const content = state?.content if (!content) return state if (hasFileContent(file)) { @@ -230,7 +238,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return state } - function withPath(input: string, action: (file: string) => unknown) { + function withPath(input: string, action: (file: FilePath) => unknown) { return action(path.normalize(input)) } const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file)) diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 1ca22cb84cb5..5a8b43b4d7f8 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -15,12 +15,14 @@ export type PrettyPath = string export type WorkspacePath = PrettyPath export type ReviewPath = PrettyPath export type FilePath = PrettyPath +export type FileUri = `file://${string}` export type WorkspaceKey = string & { _brand: "WorkspaceKey" } export type FilePathKey = string & { _brand: "FilePathKey" } -type LegacyFileTabId = `file://${string}` +type LegacyFileTabId = FileUri export const FILE_TAB_PREFIX = "tab:file:" as const export type FileTabId = `${typeof FILE_TAB_PREFIX}${string}` +export type SessionTabId = "context" | "review" | FileTabId | FileUri export const workspacePathKey = (input: WorkspacePath) => (pathKey(input) || input) as WorkspaceKey diff --git a/packages/app/src/context/file/tree-store.test.ts b/packages/app/src/context/file/tree-store.test.ts index de72f0320f42..0a612050984b 100644 --- a/packages/app/src/context/file/tree-store.test.ts +++ b/packages/app/src/context/file/tree-store.test.ts @@ -3,9 +3,11 @@ import { createFileTreeStore } from "./tree-store" describe("file tree store path handling", () => { test("normalizes node and directory keys across slash variants", async () => { + const normalize = (input: string) => input.replace(/\\/g, "/").replace(/[\\/]+$/, "") const tree = createFileTreeStore({ scope: () => "/repo", - normalizeDir: (input) => input.replace(/\/+$/, ""), + normalize, + normalizeDir: normalize, list: async (input) => { if (!input) { return [ diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts index de12f9687b7a..562991d847c8 100644 --- a/packages/app/src/context/file/tree-store.ts +++ b/packages/app/src/context/file/tree-store.ts @@ -1,54 +1,61 @@ import { createStore, produce, reconcile } from "solid-js/store" import type { FileNode } from "@opencode-ai/sdk/v2" -import { filePathKey } from "./path" +import { filePathKey, type FilePath, type FilePathKey, type WorkspacePath } from "./path" + +type TreeNode = FileNode & { path: FilePath } type DirectoryState = { expanded: boolean loaded?: boolean loading?: boolean error?: string - children?: string[] + children?: FilePathKey[] } type TreeStoreOptions = { - scope: () => string - normalizeDir: (input: string) => string - list: (input: string) => Promise + scope: () => WorkspacePath + normalize: (input: string) => FilePath + normalizeDir: (input: string) => FilePath + list: (input: FilePath) => Promise onError: (message: string) => void } export function createFileTreeStore(options: TreeStoreOptions) { + const ROOT = "" as FilePathKey + const dirKey = (path: string) => (filePathKey(options.normalizeDir(path)) || options.normalizeDir(path)) as FilePathKey + const nodeKey = (path: string) => (filePathKey(options.normalize(path)) || options.normalize(path)) as FilePathKey + const nodePath = (node: FileNode) => (node.type === "directory" ? options.normalizeDir(node.path) : options.normalize(node.path)) const [tree, setTree] = createStore<{ - node: Record - dir: Record + node: Record + dir: Record }>({ node: {}, - dir: { "": { expanded: true } }, + dir: { [ROOT]: { expanded: true } }, }) - const inflight = new Map>() - const key = (path: string) => filePathKey(options.normalizeDir(path)) || path - const normalizeNode = (node: FileNode): FileNode => ({ + const inflight = new Map>() + const normalizeNode = (node: FileNode): TreeNode => ({ ...node, - path: key(node.path), + path: nodePath(node), }) const reset = () => { inflight.clear() setTree("node", reconcile({})) setTree("dir", reconcile({})) - setTree("dir", "", { expanded: true }) + setTree("dir", ROOT, { expanded: true }) } const ensureDir = (path: string) => { - const dir = key(path) + const dir = dirKey(path) if (tree.dir[dir]) return setTree("dir", dir, { expanded: false }) } const listDir = (input: string, opts?: { force?: boolean }) => { - const dir = key(input) - ensureDir(dir) + const path = options.normalizeDir(input) + const dir = dirKey(path) + ensureDir(path) const current = tree.dir[dir] if (!opts?.force && current?.loaded) return Promise.resolve() @@ -68,18 +75,18 @@ export function createFileTreeStore(options: TreeStoreOptions) { const directory = options.scope() const promise = options - .list(dir) + .list(path) .then((items) => { if (options.scope() !== directory) return const nodes = items.map(normalizeNode) const prevChildren = tree.dir[dir]?.children ?? [] - const nextChildren = nodes.map((node) => node.path) + const nextChildren = nodes.map((node) => nodeKey(node.path)) const nextSet = new Set(nextChildren) setTree( "node", produce((draft) => { - const removedDirs: string[] = [] + const removedDirs: FilePathKey[] = [] for (const child of prevChildren) { if (nextSet.has(child)) continue @@ -89,7 +96,7 @@ export function createFileTreeStore(options: TreeStoreOptions) { } if (removedDirs.length > 0) { - const keys = Object.keys(draft) + const keys = Object.keys(draft) as FilePathKey[] for (const key of keys) { for (const removed of removedDirs) { if (!key.startsWith(removed + "/")) continue @@ -100,7 +107,7 @@ export function createFileTreeStore(options: TreeStoreOptions) { } for (const node of nodes) { - draft[node.path] = node + draft[nodeKey(node.path)] = node } }), ) @@ -136,28 +143,30 @@ export function createFileTreeStore(options: TreeStoreOptions) { } const expandDir = (input: string) => { - const dir = key(input) - ensureDir(dir) + const path = options.normalizeDir(input) + const dir = dirKey(path) + ensureDir(path) setTree("dir", dir, "expanded", true) - void listDir(dir) + void listDir(path) } const collapseDir = (input: string) => { - const dir = key(input) - ensureDir(dir) + const path = options.normalizeDir(input) + const dir = dirKey(path) + ensureDir(path) setTree("dir", dir, "expanded", false) } const dirState = (input: string) => { - const dir = key(input) + const dir = dirKey(input) return tree.dir[dir] } const children = (input: string) => { - const dir = key(input) + const dir = dirKey(input) const ids = tree.dir[dir]?.children if (!ids) return [] - const out: FileNode[] = [] + const out: TreeNode[] = [] for (const id of ids) { const node = tree.node[id] if (node) out.push(node) @@ -171,8 +180,8 @@ export function createFileTreeStore(options: TreeStoreOptions) { collapseDir, dirState, children, - node: (path: string) => tree.node[key(path)], - isLoaded: (path: string) => Boolean(tree.dir[key(path)]?.loaded), + node: (path: string) => tree.node[nodeKey(path)], + isLoaded: (path: string) => Boolean(tree.dir[dirKey(path)]?.loaded), reset, } } diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts index 7ce8a37c25e6..e9087032c0b9 100644 --- a/packages/app/src/context/file/types.ts +++ b/packages/app/src/context/file/types.ts @@ -1,4 +1,5 @@ import type { FileContent } from "@opencode-ai/sdk/v2" +import type { FilePath } from "./path" export type FileSelection = { startLine: number @@ -21,7 +22,7 @@ export type FileViewState = { } export type FileState = { - path: string + path: FilePath name: string loaded?: boolean loading?: boolean diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index 233d7adc4fe7..bc7e9664e3ad 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -2,13 +2,13 @@ import { createEffect, createRoot } from "solid-js" import { createStore, produce } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import { createScopedCache } from "@/utils/scoped-cache" -import { createPathHelpers, filePathKey } from "./path" +import { createPathHelpers, filePathKey, type FilePath, type FilePathKey, type WorkspacePath } from "./path" import type { FileViewState, SelectedLineRange } from "./types" const WORKSPACE_KEY = "__workspace__" const MAX_FILE_VIEW_SESSIONS = 20 const MAX_VIEW_FILES = 500 -const fileKey = (path: string) => filePathKey(path) || path +const fileKey = (path: FilePath) => (filePathKey(path) || path) as FilePathKey const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) @@ -57,13 +57,13 @@ function merge(prev: FileViewState, next: FileViewState) { } } -export function migrateFileViewState(dir: string, value: unknown) { +export function migrateFileViewState(dir: WorkspacePath, value: unknown) { if (!record(value)) return value if (!record(value.file)) return value const path = createPathHelpers(() => dir) let changed = false - const file: Record = {} + const file: Record = {} for (const [name, item] of Object.entries(value.file)) { const next = state(item) @@ -90,14 +90,14 @@ export function migrateFileViewState(dir: string, value: unknown) { } } -function createViewSession(dir: string, id: string | undefined) { +function createViewSession(dir: WorkspacePath, id: string | undefined) { const [view, setView, _, ready] = persisted( { ...Persist.scoped(dir, id, "file-view", Persist.legacyScoped(dir, id, "file", "v1")), migrate: (value) => migrateFileViewState(dir, value), }, createStore<{ - file: Record + file: Record }>({ file: {}, }), @@ -105,7 +105,7 @@ function createViewSession(dir: string, id: string | undefined) { const meta = { pruned: false } - const pruneView = (keep?: string) => { + const pruneView = (keep?: FilePathKey) => { const keys = Object.keys(view.file) if (keys.length <= MAX_VIEW_FILES) return @@ -114,7 +114,7 @@ function createViewSession(dir: string, id: string | undefined) { setView( produce((draft) => { - for (const key of drop) { + for (const key of drop as FilePathKey[]) { delete draft.file[key] } }), @@ -128,11 +128,11 @@ function createViewSession(dir: string, id: string | undefined) { pruneView() }) - const scrollTop = (path: string) => view.file[fileKey(path)]?.scrollTop - const scrollLeft = (path: string) => view.file[fileKey(path)]?.scrollLeft - const selectedLines = (path: string) => view.file[fileKey(path)]?.selectedLines + const scrollTop = (path: FilePath) => view.file[fileKey(path)]?.scrollTop + const scrollLeft = (path: FilePath) => view.file[fileKey(path)]?.scrollLeft + const selectedLines = (path: FilePath) => view.file[fileKey(path)]?.selectedLines - const setScrollTop = (path: string, top: number) => { + const setScrollTop = (path: FilePath, top: number) => { const key = fileKey(path) setView( produce((draft) => { @@ -144,7 +144,7 @@ function createViewSession(dir: string, id: string | undefined) { pruneView(key) } - const setScrollLeft = (path: string, left: number) => { + const setScrollLeft = (path: FilePath, left: number) => { const key = fileKey(path) setView( produce((draft) => { @@ -156,7 +156,7 @@ function createViewSession(dir: string, id: string | undefined) { pruneView(key) } - const setSelectedLines = (path: string, range: SelectedLineRange | null) => { + const setSelectedLines = (path: FilePath, range: SelectedLineRange | null) => { const key = fileKey(path) const next = range ? normalizeSelectedLines(range) : null setView( @@ -198,7 +198,7 @@ export function createFileViewCache() { ) return { - load: (dir: string, id: string | undefined) => { + load: (dir: WorkspacePath, id: string | undefined) => { const key = `${dir}\n${id ?? WORKSPACE_KEY}` return cache.get(key).value }, diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 701e880ac49c..9e4bdbde96a6 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -8,7 +8,7 @@ import type { Todo, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { getFilename, pathKey } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/util/path" import { createContext, getOwner, @@ -36,6 +36,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 { workspacePathKey, type WorkspacePath } from "@/context/file/path" type GlobalStore = { ready: boolean @@ -87,7 +88,7 @@ function createGlobalSync() { let active = true let projectWritten = false - const dir = (directory: string) => pathKey(directory) || directory + const dir = (directory: WorkspacePath | string) => workspacePathKey(directory as WorkspacePath) onCleanup(() => { active = false @@ -175,7 +176,7 @@ function createGlobalSync() { translate: language.t, }) - const sdkFor = (directory: string) => { + const sdkFor = (directory: WorkspacePath | string) => { directory = dir(directory) const cached = sdkCache.get(directory) if (cached) return cached @@ -187,7 +188,7 @@ function createGlobalSync() { return sdk } - async function loadSessions(directory: string) { + async function loadSessions(directory: WorkspacePath | string) { directory = dir(directory) const pending = sessionLoads.get(directory) if (pending) return pending @@ -255,7 +256,7 @@ function createGlobalSync() { return promise } - async function bootstrapInstance(directory: string) { + async function bootstrapInstance(directory: WorkspacePath | string) { directory = dir(directory) if (!directory) return const pending = booting.get(directory) @@ -353,14 +354,14 @@ function createGlobalSync() { void bootstrap() }) - const projectApi = { - loadSessions, - meta(directory: string, patch: ProjectMeta) { - children.projectMeta(directory, patch) - }, - icon(directory: string, value: string | undefined) { - children.projectIcon(directory, value) - }, + const projectApi = { + loadSessions, + meta(directory: WorkspacePath, patch: ProjectMeta) { + children.projectMeta(directory, patch) + }, + icon(directory: WorkspacePath, value: string | undefined) { + children.projectIcon(directory, value) + }, } const updateConfig = async (config: Config) => { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade0a..76a9c0e7b889 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -17,6 +17,7 @@ import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import { cmp, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" +import type { WorkspacePath } from "@/context/file/path" type GlobalStore = { ready: boolean @@ -112,12 +113,12 @@ function groupBySession(input: T[]) } export async function bootstrapDirectory(input: { - directory: string + directory: WorkspacePath sdk: OpencodeClient store: Store setStore: SetStoreFunction vcsCache: VcsCache - loadSessions: (directory: string) => Promise | void + loadSessions: (directory: WorkspacePath) => Promise | void translate: (key: string, vars?: Record) => string }) { if (input.store.status !== "complete") input.setStore("status", "loading") diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 735624f32a41..63d8fe81b711 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,8 +1,8 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" -import { pathKey } from "@opencode-ai/util/path" import { Persist, persisted } from "@/utils/persist" import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import { workspacePathKey, type WorkspacePath } from "@/context/file/path" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -32,23 +32,23 @@ export function createChildStoreManager(input: { const pins = new Map() const ownerPins = new WeakMap>() const disposers = new Map void>() - const dir = (directory: string) => pathKey(directory) || directory + const dir = (directory: WorkspacePath | string) => workspacePathKey(directory as WorkspacePath) - const mark = (directory: string) => { + const mark = (directory: WorkspacePath | string) => { directory = dir(directory) if (!directory) return lifecycle.set(directory, { lastAccessAt: Date.now() }) runEviction(directory) } - const pin = (directory: string) => { + const pin = (directory: WorkspacePath | string) => { directory = dir(directory) if (!directory) return pins.set(directory, (pins.get(directory) ?? 0) + 1) mark(directory) } - const unpin = (directory: string) => { + const unpin = (directory: WorkspacePath | string) => { directory = dir(directory) if (!directory) return const next = (pins.get(directory) ?? 0) - 1 @@ -60,9 +60,9 @@ export function createChildStoreManager(input: { runEviction() } - const pinned = (directory: string) => (pins.get(dir(directory)) ?? 0) > 0 + const pinned = (directory: WorkspacePath | string) => (pins.get(dir(directory)) ?? 0) > 0 - const pinForOwner = (directory: string) => { + const pinForOwner = (directory: WorkspacePath | string) => { directory = dir(directory) const current = getOwner() if (!current) return @@ -83,7 +83,7 @@ export function createChildStoreManager(input: { }) } - function disposeDirectory(directory: string) { + function disposeDirectory(directory: WorkspacePath | string) { directory = dir(directory) if ( !canDisposeDirectory({ @@ -128,7 +128,7 @@ export function createChildStoreManager(input: { } } - function ensureChild(directory: string) { + function ensureChild(directory: WorkspacePath | string) { directory = dir(directory) if (!directory) console.error("No directory provided") if (!children[directory]) { @@ -231,7 +231,7 @@ export function createChildStoreManager(input: { return childStore } - function child(directory: string, options: ChildOptions = {}) { + function child(directory: WorkspacePath | string, options: ChildOptions = {}) { directory = dir(directory) const childStore = ensureChild(directory) pinForOwner(directory) @@ -242,7 +242,7 @@ export function createChildStoreManager(input: { return childStore } - function projectMeta(directory: string, patch: ProjectMeta) { + function projectMeta(directory: WorkspacePath | string, patch: ProjectMeta) { directory = dir(directory) const [store, setStore] = ensureChild(directory) const cached = metaCache.get(directory) @@ -260,7 +260,7 @@ export function createChildStoreManager(input: { setStore("projectMeta", next) } - function projectIcon(directory: string, value: string | undefined) { + function projectIcon(directory: WorkspacePath | string, value: string | undefined) { directory = dir(directory) const [store, setStore] = ensureChild(directory) const cached = iconCache.get(directory) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 8787aa5fb2b2..7c2de0240683 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -14,7 +14,7 @@ import type { import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" -import { filePathKey } from "@/context/file/path" +import { filePathKey, type WorkspacePath } from "@/context/file/path" export function normalizeSessionDiffs(diffs: FileDiff[]) { const order: string[] = [] @@ -102,7 +102,7 @@ export function applyDirectoryEvent(input: { store: Store setStore: SetStoreFunction push: (directory: string) => void - directory: string + directory: WorkspacePath loadLsp: () => void vcsCache?: VcsCache setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index c3468583b930..dd715fbc414c 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -1,7 +1,9 @@ +import type { WorkspacePath } from "@/context/file/path" + type QueueInput = { paused: () => boolean bootstrap: () => Promise - bootstrapInstance: (directory: string) => Promise | void + bootstrapInstance: (directory: WorkspacePath) => Promise | void } export function createRefreshQueue(input: QueueInput) { diff --git a/packages/app/src/context/global-sync/session-prefetch.ts b/packages/app/src/context/global-sync/session-prefetch.ts index b5cea9f3bb3b..5b7e486d8888 100644 --- a/packages/app/src/context/global-sync/session-prefetch.ts +++ b/packages/app/src/context/global-sync/session-prefetch.ts @@ -1,8 +1,8 @@ -import { pathKey } from "@opencode-ai/util/path" +import { workspacePathKey, type WorkspaceKey, type WorkspacePath } from "@/context/file/path" -const dir = (directory: string) => pathKey(directory) || directory +const dir = (directory: WorkspacePath | WorkspaceKey) => workspacePathKey(directory as WorkspacePath) -const key = (directory: string, sessionID: string) => `${dir(directory)}\n${sessionID}` +const key = (directory: WorkspacePath | WorkspaceKey, sessionID: string) => `${dir(directory)}\n${sessionID}` export const SESSION_PREFETCH_TTL = 15_000 @@ -31,11 +31,11 @@ const rev = new Map() const version = (id: string) => rev.get(id) ?? 0 -export function getSessionPrefetch(directory: string, sessionID: string) { +export function getSessionPrefetch(directory: WorkspacePath, sessionID: string) { return cache.get(key(directory, sessionID)) } -export function getSessionPrefetchPromise(directory: string, sessionID: string) { +export function getSessionPrefetchPromise(directory: WorkspacePath, sessionID: string) { return inflight.get(key(directory, sessionID)) } @@ -43,12 +43,12 @@ export function clearSessionPrefetchInflight() { inflight.clear() } -export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) { +export function isSessionPrefetchCurrent(directory: WorkspacePath, sessionID: string, value: number) { return version(key(directory, sessionID)) === value } export function runSessionPrefetch(input: { - directory: string + directory: WorkspacePath sessionID: string task: (value: number) => Promise }) { @@ -67,7 +67,7 @@ export function runSessionPrefetch(input: { } export function setSessionPrefetch(input: { - directory: string + directory: WorkspacePath sessionID: string limit: number cursor?: string @@ -82,7 +82,7 @@ export function setSessionPrefetch(input: { }) } -export function clearSessionPrefetch(directory: string, sessionIDs: Iterable) { +export function clearSessionPrefetch(directory: WorkspacePath, sessionIDs: Iterable) { for (const sessionID of sessionIDs) { if (!sessionID) continue const id = key(directory, sessionID) @@ -92,7 +92,7 @@ export function clearSessionPrefetch(directory: string, sessionIDs: Iterable Promise<{ data?: Session[] }> + list: (query: { directory: WorkspacePath; roots: true; limit?: number }) => Promise<{ data?: Session[] }> } export type RootLoadResult = { diff --git a/packages/app/src/context/notification-state.ts b/packages/app/src/context/notification-state.ts index 425085da65f5..1741f84d9e42 100644 --- a/packages/app/src/context/notification-state.ts +++ b/packages/app/src/context/notification-state.ts @@ -1,8 +1,8 @@ -import { pathKey } from "@opencode-ai/util/path" import { EventSessionError } from "@opencode-ai/sdk/v2" +import { type WorkspacePath, workspacePathKey } from "@/context/file/path" type NotificationBase = { - directory?: string + directory?: WorkspacePath session?: string metadata?: unknown time: number @@ -52,7 +52,7 @@ function createNotificationIndex(): NotificationIndex { } } -export const projectKey = (directory: string) => pathKey(directory) || directory +export const projectKey = (directory: WorkspacePath) => workspacePathKey(directory) export function normalizeNotification(notification: Notification): Notification { if (!notification.directory) return notification diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 8da2bfa09117..454592a67bf9 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -14,6 +14,7 @@ import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSound, soundSrc } from "@/utils/sound" +import { type WorkspacePath } from "@/context/file/path" import { buildNotificationIndex, migrateNotifications, @@ -139,7 +140,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi }) } - const lookup = async (directory: string, sessionID?: string) => { + const lookup = async (directory: WorkspacePath, sessionID?: string) => { if (!sessionID) return undefined const [syncStore] = globalSync.child(directory, { bootstrap: false }) const match = Binary.search(syncStore.session, sessionID, (s) => s.id) @@ -150,7 +151,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi .catch(() => undefined) } - const viewedInCurrentSession = (directory: string, sessionID?: string) => { + const viewedInCurrentSession = (directory: WorkspacePath, sessionID?: string) => { const activeDirectory = currentDirectory() const activeSession = currentSession() if (!activeDirectory) return false @@ -160,7 +161,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi return sessionID === activeSession } - const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { + const handleSessionIdle = (directory: WorkspacePath, event: { properties: { sessionID?: string } }, time: number) => { const sessionID = event.properties.sessionID void lookup(directory, sessionID).then((session) => { if (meta.disposed) return @@ -187,7 +188,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi } const handleSessionError = ( - directory: string, + directory: WorkspacePath, event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, time: number, ) => { @@ -272,19 +273,19 @@ export const { use: useNotification, provider: NotificationProvider } = createSi }, }, project: { - all(directory: string) { + all(directory: WorkspacePath) { return index.project.all[projectKey(directory)] ?? empty }, - unseen(directory: string) { + unseen(directory: WorkspacePath) { return index.project.unseen[projectKey(directory)] ?? empty }, - unseenCount(directory: string) { + unseenCount(directory: WorkspacePath) { return index.project.unseenCount[projectKey(directory)] ?? 0 }, - unseenHasError(directory: string) { + unseenHasError(directory: WorkspacePath) { return index.project.unseenHasError[projectKey(directory)] ?? false }, - markViewed(directory: string) { + markViewed(directory: WorkspacePath) { const key = projectKey(directory) const unseen = index.project.unseen[key] ?? empty if (!unseen.length) return diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index e6f00aac6e29..7468c8eeb72a 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -4,7 +4,7 @@ import { useParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import type { FileSelection } from "@/context/file" -import { filePathEqual, filePathKey } from "@/context/file/path" +import { filePathEqual, filePathKey, type FilePath } from "@/context/file/path" import { Persist, persisted } from "@/utils/persist" interface PartBase { @@ -19,7 +19,7 @@ export interface TextPart extends PartBase { export interface FileAttachmentPart extends PartBase { type: "file" - path: string + path: FilePath selection?: FileSelection } @@ -41,7 +41,7 @@ export type Prompt = ContentPart[] export type FileContextItem = { type: "file" - path: string + path: FilePath selection?: FileSelection comment?: string commentID?: string @@ -181,12 +181,12 @@ function createPromptSessionState(store: Store, setStore: SetStoreF remove(key: string) { setStore("context", "items", (items) => items.filter((x) => x.key !== key)) }, - removeComment(path: string, commentID: string) { + removeComment(path: FilePath, commentID: string) { setStore("context", "items", (items) => items.filter((item) => !(item.type === "file" && filePathEqual(item.path, path) && item.commentID === commentID)), ) }, - updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { + updateComment(path: FilePath, commentID: string, next: Partial & { comment?: string }) { setStore("context", "items", (items) => items.map((item) => { if (item.type !== "file" || !filePathEqual(item.path, path) || item.commentID !== commentID) return item @@ -311,8 +311,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( items: () => session().context.items(), add: (item: ContextItem) => session().context.add(item), remove: (key: string) => session().context.remove(key), - removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID), - updateComment: (path: string, commentID: string, next: Partial & { comment?: string }) => + removeComment: (path: FilePath, commentID: string) => session().context.removeComment(path, commentID), + updateComment: (path: FilePath, commentID: string, next: Partial & { comment?: string }) => session().context.updateComment(path, commentID, next), replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), }, diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index cdb0a8a8ab99..50d2bcd64f42 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -12,7 +12,7 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { showToast } from "@opencode-ai/ui/toast" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" -import { filePathEqual } from "@/context/file/path" +import { filePathEqual, type FilePath, type FileTabId } from "@/context/file/path" import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -53,7 +53,7 @@ function FileCommentMenu(props: { ) } -export function FileTabContent(props: { tab: string }) { +export function FileTabContent(props: { tab: FileTabId }) { const file = useFile() const comments = useComments() const language = useLanguage() @@ -107,7 +107,7 @@ export function FileTabContent(props: { tab: string }) { } const addCommentToContext = (input: { - file: string + file: FilePath selection: SelectedLineRange comment: string preview?: string @@ -141,7 +141,7 @@ export function FileTabContent(props: { tab: string }) { const updateCommentInContext = (input: { id: string - file: string + file: FilePath selection: SelectedLineRange comment: string }) => { @@ -154,7 +154,7 @@ export function FileTabContent(props: { tab: string }) { }) } - const removeCommentFromContext = (input: { id: string; file: string }) => { + const removeCommentFromContext = (input: { id: string; file: FilePath }) => { comments.remove(input.file, input.id) prompt.context.removeComment(input.file, input.id) } diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 5c5e12db72a4..f4a73069b22d 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -3,7 +3,7 @@ import { createStore } from "solid-js/store" import { type FilePath, type FileTabId } from "@/context/file/path" import { same } from "@/utils/same" -const emptyTabs: string[] = [] +const emptyTabs: FileTabId[] = [] type Tabs = { active: Accessor @@ -26,13 +26,14 @@ export const createSessionTabs = (input: TabsInput) => { const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context")) const openedTabs = createMemo( () => { - const seen = new Set() + const seen = new Set() return input .tabs() .all() .flatMap((tab) => { if (tab === "context" || tab === "review") return [] - const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab + const value = input.pathFromTab(tab) ? (input.normalizeTab(tab) as FileTabId) : undefined + if (!value) return [] if (seen.has(value)) return [] seen.add(value) return [value] @@ -55,15 +56,19 @@ export const createSessionTabs = (input: TabsInput) => { }) const activeFileTab = createMemo(() => { const active = activeTab() - if (!openedTabs().includes(active)) return - return active - }) + if (active === "context" || active === "review" || active === "empty") return + const tab = active as FileTabId + if (!openedTabs().includes(tab)) return + return tab + }) as Accessor const closableTab = createMemo(() => { const active = activeTab() if (active === "context") return active - if (!openedTabs().includes(active)) return - return active - }) + if (active === "review" || active === "empty") return + const tab = active as FileTabId + if (!openedTabs().includes(tab)) return + return tab + }) as Accessor<"context" | FileTabId | undefined> return { contextOpen, diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index c073e621472c..2be29b4fd77f 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -7,6 +7,7 @@ import type { SessionReviewCommentUpdate, } from "@opencode-ai/ui/session-review" import type { SelectedLineRange } from "@/context/file" +import type { FilePath, ReviewPath } from "@/context/file/path" import { useSDK } from "@/context/sdk" import { useLayout } from "@/context/layout" import type { LineComment } from "@/context/comments" @@ -20,15 +21,15 @@ export interface SessionReviewTabProps { view: () => ReturnType["view"]> diffStyle: DiffStyle onDiffStyleChange?: (style: DiffStyle) => void - onViewFile?: (file: string) => void - onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void + onViewFile?: (file: FilePath) => void + onLineComment?: (comment: { file: ReviewPath; selection: SelectedLineRange; comment: string; preview?: string }) => void onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void lineCommentActions?: SessionReviewCommentActions comments?: LineComment[] - focusedComment?: { file: string; id: string } | null - onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void - focusedFile?: string + focusedComment?: { file: FilePath; id: string } | null + onFocusedCommentChange?: (focus: { file: FilePath; id: string } | null) => void + focusedFile?: ReviewPath onScrollRef?: (el: HTMLDivElement) => void classes?: { root?: string @@ -46,7 +47,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) { const sdk = useSDK() const layout = useLayout() - const readFile = async (path: string) => { + const readFile = async (path: FilePath) => { return sdk.client.file .read({ path }) .then((x) => x.data) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index d966fa7b15fb..beb47d2f973e 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -17,7 +17,7 @@ import { DialogSelectFile } from "@/components/dialog-select-file" import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" -import { filePathKey } from "@/context/file/path" +import { filePathKey, type FilePath, type FilePathKey, type ReviewPath } from "@/context/file/path" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" @@ -29,8 +29,8 @@ import { useSessionLayout } from "@/pages/session/session-layout" export function SessionSidePanel(props: { reviewPanel: () => JSX.Element - activeDiff?: string - focusReviewDiff: (path: string) => void + activeDiff?: ReviewPath + focusReviewDiff: (path: ReviewPath) => void reviewSnap: boolean size: Sizing }) { @@ -80,7 +80,7 @@ export function SessionSidePanel(props: { return "mix" as const } - const out = new Map() + const out = new Map() for (const diff of diffs()) { const file = filePathKey(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" @@ -91,7 +91,8 @@ export function SessionSidePanel(props: { for (const [idx] of parts.slice(0, -1).entries()) { const dir = parts.slice(0, idx + 1).join("/") if (!dir) continue - out.set(dir, merge(out.get(dir), kind)) + const key = filePathKey(dir) + out.set(key, merge(out.get(key), kind)) } } return out @@ -179,7 +180,7 @@ export function SessionSidePanel(props: { setSessionHandoff(sessionKey(), { files: tabs() .all() - .reduce>((acc, tab) => { + .reduce>((acc, tab) => { const path = file.pathFromTab(tab) if (!path) return acc From 4e7dde339fe402fce61d4a2ff99c7871cec68f30 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:19:59 +1000 Subject: [PATCH 34/42] wip --- .../components/dialog-select-directory.tsx | 8 +- packages/app/src/components/file-tree.test.ts | 7 +- packages/app/src/components/file-tree.tsx | 93 +++----- packages/app/src/context/comments.tsx | 88 +++++-- packages/app/src/context/file.tsx | 51 ++-- packages/app/src/context/file/path.test.ts | 7 +- packages/app/src/context/file/path.ts | 46 +++- .../app/src/context/file/tree-store.test.ts | 3 + packages/app/src/context/file/tree-store.ts | 83 ++++--- packages/app/src/context/file/view-cache.ts | 7 +- packages/app/src/context/file/watcher.test.ts | 63 ++--- packages/app/src/context/file/watcher.ts | 45 ++-- packages/app/src/context/global-sdk.tsx | 4 +- packages/app/src/context/global-sync.test.ts | 25 +- packages/app/src/context/global-sync.tsx | 64 ++--- .../context/global-sync/child-store.test.ts | 17 +- .../src/context/global-sync/child-store.ts | 70 +++--- .../src/context/global-sync/event-reducer.ts | 2 +- .../app/src/context/global-sync/eviction.ts | 2 +- packages/app/src/context/global-sync/queue.ts | 15 +- .../context/global-sync/session-prefetch.ts | 6 +- packages/app/src/context/global-sync/types.ts | 10 +- packages/app/src/context/layout.tsx | 111 +++------ packages/app/src/context/local.tsx | 4 +- .../src/context/permission-auto-respond.ts | 4 +- packages/app/src/context/prompt.tsx | 55 ++++- packages/app/src/context/sdk.tsx | 4 +- packages/app/src/context/server.tsx | 71 +++--- packages/app/src/context/sync.tsx | 4 +- packages/app/src/pages/layout.tsx | 64 +++-- packages/app/src/pages/layout/helpers.test.ts | 16 +- packages/app/src/pages/layout/helpers.ts | 13 +- packages/app/src/pages/session/handoff.ts | 6 +- packages/app/src/pages/session/helpers.ts | 28 ++- packages/app/src/utils/persist-path.ts | 37 ++- packages/app/src/utils/persist.ts | 4 +- packages/app/src/utils/session-key.test.ts | 13 +- packages/app/src/utils/session-key.ts | 25 +- packages/app/src/utils/worktree.test.ts | 10 + packages/app/src/utils/worktree.ts | 60 ++--- packages/opencode/src/acp/agent.ts | 2 +- packages/opencode/src/cli/cmd/export.ts | 10 +- packages/opencode/src/cli/cmd/github.ts | 9 - packages/opencode/src/cli/cmd/models.ts | 14 +- packages/opencode/src/cli/cmd/session.ts | 8 +- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- packages/opencode/src/cli/cmd/tui/thread.ts | 5 +- .../src/control-plane/adaptors/worktree.ts | 38 +-- packages/opencode/src/control-plane/schema.ts | 7 +- packages/opencode/src/control-plane/types.ts | 12 +- .../control-plane/workspace-server/server.ts | 36 ++- .../opencode/src/control-plane/workspace.ts | 10 +- .../opencode/src/effect/instance-registry.ts | 6 +- packages/opencode/src/file/index.ts | 39 +-- packages/opencode/src/file/time.ts | 44 ++-- packages/opencode/src/filesystem/index.ts | 50 +--- packages/opencode/src/id/id.ts | 59 +++-- packages/opencode/src/lsp/client.ts | 15 +- packages/opencode/src/lsp/index.ts | 70 +++--- packages/opencode/src/lsp/server.ts | 44 ++-- packages/opencode/src/path/migrate.ts | 22 +- packages/opencode/src/path/path.ts | 224 ++++++++++++------ packages/opencode/src/path/schema.ts | 81 ++++++- packages/opencode/src/permission/schema.ts | 12 +- packages/opencode/src/project/instance.ts | 11 +- packages/opencode/src/project/project.ts | 44 ++-- packages/opencode/src/project/schema.ts | 14 +- packages/opencode/src/project/state.ts | 4 +- packages/opencode/src/provider/schema.ts | 20 +- packages/opencode/src/pty/schema.ts | 7 +- packages/opencode/src/question/schema.ts | 12 +- .../src/server/routes/experimental.ts | 6 +- packages/opencode/src/server/server.ts | 34 ++- packages/opencode/src/session/index.ts | 29 +-- packages/opencode/src/session/instruction.ts | 57 ++--- packages/opencode/src/session/message-v2.ts | 84 ++++--- packages/opencode/src/session/prompt.ts | 7 +- packages/opencode/src/session/schema.ts | 19 +- packages/opencode/src/tool/apply_patch.ts | 5 +- packages/opencode/src/tool/edit.ts | 2 +- packages/opencode/src/tool/schema.ts | 7 +- packages/opencode/src/tool/task.ts | 6 +- packages/opencode/src/util/filesystem.ts | 73 +----- packages/opencode/src/util/schema.ts | 15 ++ packages/opencode/src/worktree/index.ts | 137 +++++------ .../opencode/test/cli/tui/transcript.test.ts | 9 +- packages/opencode/test/config/config.test.ts | 2 +- .../session-proxy-middleware.test.ts | 4 +- .../workspace-server-sse.test.ts | 13 + .../test/control-plane/workspace-sync.test.ts | 4 +- .../opencode/test/file/path-traversal.test.ts | 34 +-- packages/opencode/test/file/time.test.ts | 61 ++--- .../test/filesystem/filesystem.test.ts | 15 +- packages/opencode/test/lsp/client.test.ts | 10 +- packages/opencode/test/path/migrate.test.ts | 3 +- packages/opencode/test/path/path.test.ts | 42 ++++ packages/opencode/test/path/schema.test.ts | 17 ++ .../opencode/test/project/instance.test.ts | 10 +- .../opencode/test/server/path-alias.test.ts | 24 ++ .../opencode/test/session/instruction.test.ts | 30 +-- .../opencode/test/session/message-v2.test.ts | 42 ++++ .../test/session/revert-compact.test.ts | 13 +- .../test/session/structured-output.test.ts | 3 +- packages/opencode/test/tool/edit.test.ts | 28 ++- packages/opencode/test/tool/write.test.ts | 9 +- .../opencode/test/util/filesystem.test.ts | 83 ++----- packages/ui/src/components/session-review.tsx | 2 +- packages/ui/src/components/session-turn.tsx | 2 +- 108 files changed, 1747 insertions(+), 1371 deletions(-) create mode 100644 packages/opencode/test/path/schema.test.ts diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 425cccbba868..552cd08e31ce 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -14,13 +14,13 @@ import { getPathRoot, getPathScope, getPathSearchText, - pathKey, trimPrettyPath, } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" +import { workspacePathKey } from "@/context/file/path" import { useLayout } from "@/context/layout" import { useLanguage } from "@/context/language" @@ -49,7 +49,7 @@ function toRow(absolute: string, home: string, group: Row["group"]): Row { function uniqueRows(rows: Row[]) { const seen = new Set() return rows.filter((row) => { - const key = pathKey(row.absolute) || row.absolute + const key = workspacePathKey(row.absolute) if (seen.has(key)) return false seen.add(key) return true @@ -59,7 +59,7 @@ function uniqueRows(rows: Row[]) { function unique(paths: string[]) { const seen = new Set() return paths.filter((path) => { - const key = pathKey(path) || path + const key = workspacePathKey(path) if (seen.has(key)) return false seen.add(key) return true @@ -76,7 +76,7 @@ function useDirectorySearch(args: { const dirs = async (dir: string) => { const path = trimPrettyPath(dir) - const key = pathKey(path) || path + const key = workspacePathKey(path) const existing = cache.get(key) if (existing) return existing diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts index 29e20b4807c5..c0a7f7816a6c 100644 --- a/packages/app/src/components/file-tree.test.ts +++ b/packages/app/src/components/file-tree.test.ts @@ -1,4 +1,5 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" +import { filePathKey } from "@/context/file/path" let shouldListRoot: typeof import("./file-tree").shouldListRoot let shouldListExpanded: typeof import("./file-tree").shouldListExpanded @@ -53,8 +54,8 @@ describe("file tree fetch discipline", () => { }) test("allowed auto-expand picks only collapsed dirs", () => { - const expanded = new Set() - const filter = { dirs: new Set(["src", "src/components"]) } + const expanded = new Set>() + const filter = { dirs: new Set([filePathKey("src"), filePathKey("src/components")]) } const first = dirsToExpand({ level: 0, @@ -62,7 +63,7 @@ describe("file tree fetch discipline", () => { expanded: (dir) => expanded.has(dir), }) - expect(first).toEqual(["src", "src/components"]) + expect(first.map(String)).toEqual(["src", "src/components"]) for (const dir of first) expanded.add(dir) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 46f700cfa7ee..d29c79966719 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -1,5 +1,13 @@ import { useFile } from "@/context/file" -import { filePathEqual, filePathKey } from "@/context/file/path" +import { + filePathAncestorKeys, + filePathEqual, + filePathFromKey, + filePathKey, + filePathName, + filePathParentKey, + type FilePathKey, +} from "@/context/file/path" import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" @@ -33,8 +41,8 @@ function pathToFileUrl(filepath: string) { type Kind = "add" | "del" | "mix" type Filter = { - files: Set - dirs: Set + files: Set + dirs: Set } export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) { @@ -57,8 +65,8 @@ export function shouldListExpanded(input: { export function dirsToExpand(input: { level: number - filter?: { dirs: Set } - expanded: (dir: string) => boolean + filter?: { dirs: Set } + expanded: (dir: FilePathKey) => boolean }) { if (input.level !== 0) return [] if (!input.filter) return [] @@ -210,17 +218,16 @@ export default function FileTree(props: { onFileClick?: (file: FileNode) => void _filter?: Filter - _marks?: Set - _deeps?: Map + _marks?: Set + _deeps?: Map _kinds?: ReadonlyMap - _chain?: readonly string[] + _chain?: readonly FilePathKey[] }) { const file = useFile() const level = props.level ?? 0 const draggable = () => props.draggable ?? true - const key = (p: string) => - filePathKey(file.normalize(p)) || file.normalize(p) + const key = (p: string) => filePathKey(file.normalize(p)) const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] const filter = createMemo(() => { @@ -229,18 +236,8 @@ export default function FileTree(props: { const allowed = props.allowed if (!allowed) return - const files = new Set(allowed.map((item) => filePathKey(item) || item)) - const dirs = new Set() - - for (const item of allowed) { - const path = filePathKey(item) || item - const parts = path.split("/") - const parents = parts.slice(0, -1) - for (const [idx] of parents.entries()) { - const dir = parents.slice(0, idx + 1).join("/") - if (dir) dirs.add(dir) - } - } + const files = new Set(allowed.map(filePathKey)) + const dirs = new Set(allowed.flatMap((item) => filePathAncestorKeys(filePathKey(item)))) return { files, dirs } }) @@ -248,9 +245,9 @@ export default function FileTree(props: { const marks = createMemo(() => { if (props._marks) return props._marks - const out = new Set() - for (const item of props.modified ?? []) out.add(filePathKey(item) || item) - for (const item of props.kinds?.keys() ?? []) out.add(filePathKey(item) || item) + const out = new Set() + for (const item of props.modified ?? []) out.add(filePathKey(item)) + for (const item of props.kinds?.keys() ?? []) out.add(filePathKey(item)) if (out.size === 0) return return out }) @@ -263,12 +260,12 @@ export default function FileTree(props: { const deeps = createMemo(() => { if (props._deeps) return props._deeps - const out = new Map() + const out = new Map() const root = props.path if (!(file.tree.state(root)?.expanded ?? false)) return out - const seen = new Set() + const seen = new Set() const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] const push = (dir: string, lvl: number) => { @@ -296,7 +293,7 @@ export default function FileTree(props: { continue } - out.set(top.dir, top.max) + out.set(key(top.dir), top.max) stack.pop() const parent = stack[stack.length - 1] @@ -333,17 +330,7 @@ export default function FileTree(props: { const nodes = file.tree.children(props.path) const current = filter() if (!current) return nodes - - const parent = (path: string) => { - const idx = path.lastIndexOf("/") - if (idx === -1) return "" - return path.slice(0, idx) - } - - const leaf = (path: string) => { - const idx = path.lastIndexOf("/") - return idx === -1 ? path : path.slice(idx + 1) - } + const parent = key(props.path) const out = nodes.filter((node) => { if (node.type === "file") return current.files.has(filePathKey(node.path)) @@ -353,31 +340,29 @@ export default function FileTree(props: { const seen = new Set(out.map((node) => filePathKey(node.path))) for (const dir of current.dirs) { - if (parent(dir) !== props.path) continue - const key = filePathKey(dir) - if (seen.has(key)) continue + if (filePathParentKey(dir) !== parent) continue + if (seen.has(dir)) continue out.push({ - name: leaf(dir), - path: dir, - absolute: dir, + name: filePathName(dir), + path: filePathFromKey(dir), + absolute: filePathFromKey(dir), type: "directory", ignored: false, }) - seen.add(key) + seen.add(dir) } for (const item of current.files) { - if (parent(item) !== props.path) continue - const key = filePathKey(item) - if (seen.has(key)) continue + if (filePathParentKey(item) !== parent) continue + if (seen.has(item)) continue out.push({ - name: leaf(item), - path: item, - absolute: item, + name: filePathName(item), + path: filePathFromKey(item), + absolute: filePathFromKey(item), type: "file", ignored: false, }) - seen.add(key) + seen.add(item) } out.sort((a, b) => { @@ -395,7 +380,7 @@ export default function FileTree(props: { {(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false - const deep = () => deeps().get(node.path) ?? -1 + const deep = () => deeps().get(key(node.path)) ?? -1 const kind = () => visibleKind(node, kinds(), marks()) const active = () => !!kind() && !node.ignored diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index f8a588fd1515..40b336eb8402 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -6,7 +6,7 @@ import { Persist, persisted } from "@/utils/persist" import { createScopedCache } from "@/utils/scoped-cache" import { uuid } from "@/utils/uuid" import type { SelectedLineRange } from "@/context/file" -import { filePathEqual, filePathKey, type FilePath, type FilePathKey } from "@/context/file/path" +import { filePathKey, type FilePath, type FilePathKey } from "@/context/file/path" export type LineComment = { id: string @@ -38,13 +38,16 @@ type CommentStore = { comments: Record } -const normalizeFile = (file: FilePath) => (filePathKey(file) || file) as FilePath +const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) -const commentKey = (file: FilePath) => normalizeFile(file) as FilePathKey +const text = (value: unknown) => (typeof value === "string" ? value : undefined) -function matchFile(comments: Record, file: FilePath) { - return Object.keys(comments).find((key) => filePathEqual(key, file)) as FilePathKey | undefined -} +const num = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined) + +const normalizeFile = (file: FilePath) => filePathKey(file) as FilePath + +const commentKey = (file: FilePath) => filePathKey(file) function aggregate(comments: Record) { return Object.values(comments) @@ -72,9 +75,58 @@ function cloneComment(comment: LineComment): LineComment { } } +function lineComment(value: unknown): LineComment | undefined { + if (!record(value)) return + const id = text(value.id) + const file = text(value.file) + const comment = text(value.comment) + const time = num(value.time) + if (!id || !file || comment === undefined || time === undefined) return + if (!record(value.selection)) return + const start = num(value.selection.start) + const end = num(value.selection.end) + if (start === undefined || end === undefined) return + const side = value.selection.side === "additions" || value.selection.side === "deletions" ? value.selection.side : undefined + const endSide = + value.selection.endSide === "additions" || value.selection.endSide === "deletions" + ? value.selection.endSide + : undefined + return cloneComment({ + id, + file, + comment, + time, + selection: { start, end, ...(side ? { side } : {}), ...(endSide ? { endSide } : {}) }, + }) +} + +function normalizeCommentMap(value: unknown) { + if (!record(value)) return {} as Record + + return Object.entries(value).reduce>((acc, [name, items]) => { + if (!Array.isArray(items)) return acc + const key = commentKey(name) + const next = items.map(lineComment).filter((item): item is LineComment => !!item) + if (next.length === 0) return acc + acc[key] = [...(acc[key] ?? []), ...next] + return acc + }, {}) +} + +export function migrateCommentStore(value: unknown) { + if (!record(value)) return value + if (!record(value.comments)) return value + + const comments = normalizeCommentMap(value.comments) + return { + ...value, + comments, + } +} + function group(comments: LineComment[]) { return comments.reduce>((acc, comment) => { - const key = matchFile(acc, comment.file) ?? commentKey(comment.file) + const key = commentKey(comment.file) const list = acc[key] const next = cloneComment(comment) if (list) { @@ -109,12 +161,11 @@ function createCommentSessionState(store: Store, setStore: SetStor setRef("active", value) const list = (file: FilePath) => { - const key = matchFile(store.comments, file) - return (key ? store.comments[key] : undefined)?.map(cloneComment) ?? [] + return store.comments[commentKey(file)]?.map(cloneComment) ?? [] } const add = (input: Omit) => { - const key = matchFile(store.comments, input.file) ?? commentKey(input.file) + const key = commentKey(input.file) const file = store.comments[key]?.[0]?.file ?? normalizeFile(input.file) const next: LineComment = { id: uuid(), @@ -133,18 +184,18 @@ function createCommentSessionState(store: Store, setStore: SetStor } const remove = (file: FilePath, id: string) => { - const key = matchFile(store.comments, file) - if (!key) return + const key = commentKey(file) batch(() => { setStore("comments", key, (items) => (items ?? []).filter((item) => item.id !== id)) - setFocus((current) => (current && filePathEqual(current.file, file) && current.id === id ? null : current)) + setFocus((current) => + current && commentKey(current.file) === key && current.id === id ? null : current, + ) }) } const update = (file: FilePath, id: string, comment: string) => { - const key = matchFile(store.comments, file) - if (!key) return + const key = commentKey(file) setStore("comments", key, (items) => (items ?? []).map((item) => { @@ -188,13 +239,16 @@ function createCommentSessionState(store: Store, setStore: SetStor } export function createCommentSessionForTest(comments: Record = {}) { - const [store, setStore] = createStore({ comments }) + const [store, setStore] = createStore({ comments: normalizeCommentMap(comments) }) return createCommentSessionState(store, setStore) } function createCommentSession(dir: string, id: string | undefined) { const [store, setStore, _, ready] = persisted( - Persist.scoped(dir, id, "comments", Persist.legacyScoped(dir, id, "comments", "v1")), + { + ...Persist.scoped(dir, id, "comments", Persist.legacyScoped(dir, id, "comments", "v1")), + migrate: migrateCommentStore, + }, createStore({ comments: {}, }), diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index d6e60ad913a7..fd0d9975070d 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -63,7 +63,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const scope = createMemo(() => sdk.directory) const path = createPathHelpers(scope) const tabs = layout.tabs(() => sessionKey(params.dir ?? "", params.id)) - const fileKey = (file: FilePath) => (filePathKey(file) || file) as FilePathKey + const fileKey = (file: FilePath) => filePathKey(file) const loadKey = (directory: string, file: FilePath) => `${workspacePathKey(directory)}\n${fileKey(file)}` const inflight = new Map>() @@ -164,10 +164,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ }) } - const load = (input: string, options?: { force?: boolean }) => { - const file = path.normalize(input) - if (!file) return Promise.resolve() - + const loadFile = (file: FilePath, options?: { force?: boolean }) => { const directory = scope() const key = loadKey(directory, file) ensure(file) @@ -203,30 +200,38 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return promise } + const load = (input: string, options?: { force?: boolean }) => loadFile(path.normalize(input), options) + const search = (query: string, dirs: "true" | "false") => sdk.client.find.files({ query, dirs }).then( (x) => (x.data ?? []).map(path.normalize), () => [], ) + const openFile = (key: FilePathKey) => { + for (const tab of tabs.all()) { + const file = path.pathFromTab(tab) + if (!file || fileKey(file) !== key) continue + return file + } + } + const stop = sdk.event.listen((e) => { - invalidateFromWatcher(e.details, { - normalize: path.normalize, - hasFile: (file) => Boolean(store.file[fileKey(file)]), - isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file), - loadFile: (file) => { - void load(file, { force: true }) + invalidateFromWatcher(e.details, { + normalize: path.normalize, + file: (key) => store.file[key]?.path, + open: openFile, + dir: tree.dirPathByKey, + loadFile: (file) => { + void loadFile(file, { force: true }) }, - node: tree.node, - isDirLoaded: tree.isLoaded, refreshDir: (dir) => { void tree.listDir(dir, { force: true }) }, }) }) - const get = (input: string) => { - const file = path.normalize(input) + const getFile = (file: FilePath) => { const state = store.file[fileKey(file)] const content = state?.content if (!content) return state @@ -238,16 +243,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return state } - function withPath(input: string, action: (file: FilePath) => unknown) { + const get = (input: string) => getFile(path.normalize(input)) + + function withFile(input: string, action: (file: FilePath) => T) { return action(path.normalize(input)) } - const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file)) - const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file)) - const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file)) - const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top)) - const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left)) + const scrollTop = (input: string) => withFile(input, (file) => view().scrollTop(file)) + const scrollLeft = (input: string) => withFile(input, (file) => view().scrollLeft(file)) + const selectedLines = (input: string) => withFile(input, (file) => view().selectedLines(file)) + const setScrollTop = (input: string, top: number) => withFile(input, (file) => view().setScrollTop(file, top)) + const setScrollLeft = (input: string, left: number) => withFile(input, (file) => view().setScrollLeft(file, left)) const setSelectedLines = (input: string, range: SelectedLineRange | null) => - withPath(input, (file) => view().setSelectedLines(file, range)) + withFile(input, (file) => view().setSelectedLines(file, range)) onCleanup(() => { stop() diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index b933defc8c9a..c2148fc81e6d 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { createPathHelpers, dedupeFilePaths, filePathEqual, filePathKey, isFileTab } from "./path" +import { createPathHelpers, dedupeFilePaths, filePathAncestorKeys, filePathEqual, filePathKey, filePathName, filePathParentKey, isFileTab } from "./path" describe("file path helpers", () => { test("normalizes file inputs against workspace root", () => { @@ -44,6 +44,9 @@ describe("file path helpers", () => { test("normalizes app file keys across slash variants", () => { expect(String(filePathKey("src\\app.ts"))).toBe("src/app.ts") expect(filePathEqual("src\\app.ts", "src/app.ts")).toBe(true) - expect(dedupeFilePaths(["src\\app.ts", "src/app.ts", "src/util.ts"])).toEqual(["src/app.ts", "src/util.ts"]) + expect(dedupeFilePaths(["src\\app.ts", "src/app.ts", "src/util.ts"])).toEqual(["src\\app.ts", "src/util.ts"]) + expect(String(filePathParentKey(filePathKey("src/app.ts")))).toBe("src") + expect(filePathAncestorKeys(filePathKey("src/deep/app.ts")).map(String)).toEqual(["src", "src/deep"]) + expect(filePathName(filePathKey("src/deep/app.ts"))).toBe("app.ts") }) }) diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 5a8b43b4d7f8..1a49c13b64e8 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -22,9 +22,14 @@ export type FilePathKey = string & { _brand: "FilePathKey" } type LegacyFileTabId = FileUri export const FILE_TAB_PREFIX = "tab:file:" as const export type FileTabId = `${typeof FILE_TAB_PREFIX}${string}` -export type SessionTabId = "context" | "review" | FileTabId | FileUri +export type SessionTabId = "context" | "review" | FileTabId +export type StoredSessionTabId = SessionTabId | FileUri +export const ROOT_FILE_PATH = "" as FilePath +export const ROOT_FILE_PATH_KEY = "" as FilePathKey -export const workspacePathKey = (input: WorkspacePath) => (pathKey(input) || input) as WorkspaceKey +export const workspacePathKey = (input: WorkspacePath) => pathKey(input) as WorkspaceKey + +export const reviewPathKey = (input: ReviewPath) => pathKey(input) export const filePathKey = (input: FilePath) => pathKey(input) as FilePathKey @@ -45,6 +50,35 @@ export const isFileTab = (input: string): input is FileTabId | LegacyFileTabId = export const filePathEqual = (a: FilePath | undefined, b: FilePath | undefined) => pathEqual(a, b) +export const filePathFromKey = (input: FilePathKey) => input as FilePath + +export function filePathParentKey(input: FilePathKey) { + const split = input.lastIndexOf("/") + if (split === -1) return ROOT_FILE_PATH_KEY + return input.slice(0, split) as FilePathKey +} + +export function filePathName(input: FilePathKey) { + const split = input.lastIndexOf("/") + if (split === -1) return input + return input.slice(split + 1) +} + +export function filePathAncestorKeys(input: FilePathKey) { + const out: FilePathKey[] = [] + + for (let key = filePathParentKey(input); key !== ROOT_FILE_PATH_KEY; key = filePathParentKey(key)) { + out.unshift(key) + } + + return out +} + +export function filePathDescendsFrom(input: FilePathKey, parent: FilePathKey) { + if (parent === ROOT_FILE_PATH_KEY) return input !== ROOT_FILE_PATH_KEY + return input.startsWith(parent + "/") +} + export function dedupeFilePaths(paths: readonly FilePath[]) { const seen = new Set() const out: FilePath[] = [] @@ -53,7 +87,7 @@ export function dedupeFilePaths(paths: readonly FilePath[]) { const key = filePathKey(path) if (seen.has(key)) continue seen.add(key) - out.push(key || path) + out.push(path) } return out @@ -87,6 +121,10 @@ export function createPathHelpers(scope: () => WorkspacePath) { return `${FILE_TAB_PREFIX}${encodeFilePath(path)}` } + const key = (input: string) => filePathKey(normalize(input)) + + const dirKey = (input: string) => filePathKey(normalizeDir(input)) + const normalizeTab = (input: string) => { const path = pathFromTab(input) if (!path) return input @@ -103,6 +141,8 @@ export function createPathHelpers(scope: () => WorkspacePath) { return { normalize, display, + key, + dirKey, tab, normalizeTab, pathFromTab, diff --git a/packages/app/src/context/file/tree-store.test.ts b/packages/app/src/context/file/tree-store.test.ts index 0a612050984b..67fba1bc1e51 100644 --- a/packages/app/src/context/file/tree-store.test.ts +++ b/packages/app/src/context/file/tree-store.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { filePathKey } from "./path" import { createFileTreeStore } from "./tree-store" describe("file tree store path handling", () => { @@ -25,12 +26,14 @@ describe("file tree store path handling", () => { expect(tree.children("").map((node) => node.path)).toEqual(["src/core", "src/app.ts"]) expect(tree.node("src\\app.ts")?.path).toBe("src/app.ts") + expect(tree.dirPathByKey(filePathKey(""))).toBe("") tree.expandDir("src\\core") expect(tree.dirState("src/core")?.expanded).toBe(true) await tree.listDir("src/core") expect(tree.children("src\\core").map((node) => node.path)).toEqual(["src/core/util.ts"]) + expect(tree.dirPathByKey(filePathKey("src\\core"))).toBe("src/core") tree.collapseDir("src/core") expect(tree.dirState("src\\core")?.expanded).toBe(false) diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts index 562991d847c8..1aa4b3f66615 100644 --- a/packages/app/src/context/file/tree-store.ts +++ b/packages/app/src/context/file/tree-store.ts @@ -1,10 +1,19 @@ import { createStore, produce, reconcile } from "solid-js/store" import type { FileNode } from "@opencode-ai/sdk/v2" -import { filePathKey, type FilePath, type FilePathKey, type WorkspacePath } from "./path" +import { + ROOT_FILE_PATH, + ROOT_FILE_PATH_KEY, + filePathDescendsFrom, + filePathKey, + type FilePath, + type FilePathKey, + type WorkspacePath, +} from "./path" type TreeNode = FileNode & { path: FilePath } type DirectoryState = { + path: FilePath expanded: boolean loaded?: boolean loading?: boolean @@ -21,16 +30,15 @@ type TreeStoreOptions = { } export function createFileTreeStore(options: TreeStoreOptions) { - const ROOT = "" as FilePathKey - const dirKey = (path: string) => (filePathKey(options.normalizeDir(path)) || options.normalizeDir(path)) as FilePathKey - const nodeKey = (path: string) => (filePathKey(options.normalize(path)) || options.normalize(path)) as FilePathKey + const dirKey = (path: FilePath) => filePathKey(path) + const nodeKey = (path: FilePath) => filePathKey(path) const nodePath = (node: FileNode) => (node.type === "directory" ? options.normalizeDir(node.path) : options.normalize(node.path)) const [tree, setTree] = createStore<{ node: Record dir: Record }>({ node: {}, - dir: { [ROOT]: { expanded: true } }, + dir: { [ROOT_FILE_PATH_KEY]: { path: ROOT_FILE_PATH, expanded: true } }, }) const inflight = new Map>() @@ -39,17 +47,32 @@ export function createFileTreeStore(options: TreeStoreOptions) { path: nodePath(node), }) + const dropDirs = (dirs: readonly FilePathKey[]) => { + if (dirs.length === 0) return + + setTree( + "dir", + produce((draft) => { + for (const key of Object.keys(draft) as FilePathKey[]) { + if (key === ROOT_FILE_PATH_KEY) continue + if (!dirs.some((dir) => key === dir || filePathDescendsFrom(key, dir))) continue + delete draft[key] + } + }), + ) + } + const reset = () => { inflight.clear() setTree("node", reconcile({})) setTree("dir", reconcile({})) - setTree("dir", ROOT, { expanded: true }) + setTree("dir", ROOT_FILE_PATH_KEY, { path: ROOT_FILE_PATH, expanded: true }) } - const ensureDir = (path: string) => { + const ensureDir = (path: FilePath) => { const dir = dirKey(path) if (tree.dir[dir]) return - setTree("dir", dir, { expanded: false }) + setTree("dir", dir, { path, expanded: false }) } const listDir = (input: string, opts?: { force?: boolean }) => { @@ -82,12 +105,11 @@ export function createFileTreeStore(options: TreeStoreOptions) { const prevChildren = tree.dir[dir]?.children ?? [] const nextChildren = nodes.map((node) => nodeKey(node.path)) const nextSet = new Set(nextChildren) + const removedDirs: FilePathKey[] = [] setTree( "node", produce((draft) => { - const removedDirs: FilePathKey[] = [] - for (const child of prevChildren) { if (nextSet.has(child)) continue const existing = draft[child] @@ -95,15 +117,9 @@ export function createFileTreeStore(options: TreeStoreOptions) { delete draft[child] } - if (removedDirs.length > 0) { - const keys = Object.keys(draft) as FilePathKey[] - for (const key of keys) { - for (const removed of removedDirs) { - if (!key.startsWith(removed + "/")) continue - delete draft[key] - break - } - } + for (const key of Object.keys(draft) as FilePathKey[]) { + if (!removedDirs.some((dir) => filePathDescendsFrom(key, dir))) continue + delete draft[key] } for (const node of nodes) { @@ -112,10 +128,13 @@ export function createFileTreeStore(options: TreeStoreOptions) { }), ) + dropDirs(removedDirs) + setTree( "dir", dir, produce((draft) => { + draft.path = path draft.loaded = true draft.loading = false draft.children = nextChildren @@ -158,20 +177,17 @@ export function createFileTreeStore(options: TreeStoreOptions) { } const dirState = (input: string) => { - const dir = dirKey(input) + const dir = dirKey(options.normalizeDir(input)) return tree.dir[dir] } const children = (input: string) => { - const dir = dirKey(input) - const ids = tree.dir[dir]?.children - if (!ids) return [] - const out: TreeNode[] = [] - for (const id of ids) { + const dir = dirKey(options.normalizeDir(input)) + return (tree.dir[dir]?.children ?? []).flatMap((id) => { const node = tree.node[id] - if (node) out.push(node) - } - return out + if (!node) return [] + return [node] + }) } return { @@ -180,8 +196,15 @@ export function createFileTreeStore(options: TreeStoreOptions) { collapseDir, dirState, children, - node: (path: string) => tree.node[nodeKey(path)], - isLoaded: (path: string) => Boolean(tree.dir[dirKey(path)]?.loaded), + node: (path: string) => tree.node[nodeKey(options.normalize(path))], + nodeByKey: (key: FilePathKey) => tree.node[key], + dirByKey: (key: FilePathKey) => tree.dir[key], + dirPathByKey: (key: FilePathKey) => { + const dir = tree.dir[key] + if (!dir?.loaded) return + return dir.path + }, + isLoaded: (path: string) => Boolean(tree.dir[dirKey(options.normalizeDir(path))]?.loaded), reset, } } diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index bc7e9664e3ad..00a218ca32ad 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -8,7 +8,7 @@ import type { FileViewState, SelectedLineRange } from "./types" const WORKSPACE_KEY = "__workspace__" const MAX_FILE_VIEW_SESSIONS = 20 const MAX_VIEW_FILES = 500 -const fileKey = (path: FilePath) => (filePathKey(path) || path) as FilePathKey +const fileKey = (path: FilePath) => filePathKey(path) const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) @@ -74,11 +74,6 @@ export function migrateFileViewState(dir: WorkspacePath, value: unknown) { const normalized = path.normalize(name) const key = fileKey(normalized) - if (!key) { - changed = true - continue - } - if (key !== name || file[key]) changed = true file[key] = file[key] ? merge(file[key], next) : next } diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts index 936dcf6616f1..aaf5dac2ba74 100644 --- a/packages/app/src/context/file/watcher.test.ts +++ b/packages/app/src/context/file/watcher.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { filePathKey } from "./path" import { invalidateFromWatcher } from "./watcher" describe("file watcher invalidation", () => { @@ -15,10 +16,9 @@ describe("file watcher invalidation", () => { }, { normalize: (input) => input, - hasFile: (path) => path === "src/new.ts", + file: (key) => (key === filePathKey("src/new.ts") ? "src/new.ts" : undefined), + dir: (key) => (key === filePathKey("src") ? "src" : undefined), loadFile: (path) => loads.push(path), - node: () => undefined, - isDirLoaded: (path) => path === "src", refreshDir: (path) => refresh.push(path), }, ) @@ -41,10 +41,9 @@ describe("file watcher invalidation", () => { }, { normalize: (input) => input, - hasFile: (path) => path === "src/new.ts", + file: (key) => (key === filePathKey("src/new.ts") ? "src/new.ts" : undefined), + dir: (key) => (key === filePathKey("src") ? "src" : undefined), loadFile: (path) => loads.push(path), - node: () => undefined, - isDirLoaded: (path) => path === "src", refreshDir: (path) => refresh.push(path), }, ) @@ -66,17 +65,10 @@ describe("file watcher invalidation", () => { }, { normalize: (input) => input, - hasFile: () => false, - isOpen: (path) => path === "src/open.ts", + file: () => undefined, + open: (key) => (key === filePathKey("src/open.ts") ? "src/open.ts" : undefined), + dir: () => undefined, loadFile: (path) => loads.push(path), - node: () => ({ - path: "src/open.ts", - type: "file", - name: "open.ts", - absolute: "/repo/src/open.ts", - ignored: false, - }), - isDirLoaded: () => false, refreshDir: () => {}, }, ) @@ -97,10 +89,9 @@ describe("file watcher invalidation", () => { }, { normalize: (input) => input, - hasFile: () => false, + file: () => undefined, + dir: (key) => (key === filePathKey("src") ? "src" : undefined), loadFile: () => {}, - node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }), - isDirLoaded: (path) => path === "src", refreshDir: (path) => refresh.push(path), }, ) @@ -115,16 +106,9 @@ describe("file watcher invalidation", () => { }, { normalize: (input) => input, - hasFile: () => false, + file: () => undefined, + dir: () => undefined, loadFile: () => {}, - node: () => ({ - path: "src/file.ts", - type: "file", - name: "file.ts", - absolute: "/repo/src/file.ts", - ignored: false, - }), - isDirLoaded: () => true, refreshDir: (path) => refresh.push(path), }, ) @@ -145,13 +129,9 @@ describe("file watcher invalidation", () => { }, { normalize: (input) => input, - hasFile: () => false, + file: () => undefined, + dir: (key) => (key === filePathKey("src/nested") ? "src/nested" : undefined), loadFile: () => {}, - node: (path) => - path === "src/nested" - ? { path: "src/nested", type: "directory", name: "nested", absolute: "/repo/src/nested", ignored: false } - : undefined, - isDirLoaded: (path) => path === "src/nested", refreshDir: (path) => refresh.push(path), }, ) @@ -172,12 +152,11 @@ describe("file watcher invalidation", () => { }, { normalize: (input) => input, - hasFile: () => true, + file: () => "src/a.ts", + dir: () => "src", loadFile: () => { throw new Error("should not load") }, - node: () => undefined, - isDirLoaded: () => true, refreshDir: (path) => refresh.push(path), }, ) @@ -192,12 +171,11 @@ describe("file watcher invalidation", () => { }, { normalize: (input) => input, - hasFile: () => true, + file: () => "src/a.ts", + dir: () => "src", loadFile: () => { throw new Error("should not load") }, - node: () => undefined, - isDirLoaded: () => true, refreshDir: (path) => refresh.push(path), }, ) @@ -209,10 +187,9 @@ describe("file watcher invalidation", () => { }, { normalize: (input) => input, - hasFile: () => false, + file: () => undefined, + dir: () => "src", loadFile: () => {}, - node: () => undefined, - isDirLoaded: () => true, refreshDir: (path) => refresh.push(path), }, ) diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index f5b07c3f09d3..6352b886173f 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -1,5 +1,4 @@ -import type { FileNode } from "@opencode-ai/sdk/v2" -import { getParentPath, pathKey } from "@opencode-ai/util/path" +import { filePathKey, filePathParentKey, type FilePath, type FilePathKey } from "./path" type WatcherEvent = { type: string @@ -7,13 +6,12 @@ type WatcherEvent = { } type WatcherOps = { - normalize: (input: string) => string - hasFile: (path: string) => boolean - isOpen?: (path: string) => boolean - loadFile: (path: string) => void - node: (path: string) => FileNode | undefined - isDirLoaded: (path: string) => boolean - refreshDir: (path: string) => void + normalize: (input: string) => FilePath + file: (key: FilePathKey) => FilePath | undefined + open?: (key: FilePathKey) => FilePath | undefined + dir: (key: FilePathKey) => FilePath | undefined + loadFile: (path: FilePath) => void + refreshDir: (path: FilePath) => void } export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { @@ -26,36 +24,21 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { if (!kind) return const path = ops.normalize(rawPath) - if (!path) return - const key = pathKey(path) || path + const key = filePathKey(path) if (key.startsWith(".git/")) return - const file = (() => { - if (ops.hasFile(path) || ops.isOpen?.(path)) return path - if (key === path) return - if (ops.hasFile(key) || ops.isOpen?.(key)) return key - })() - - if (file) { - ops.loadFile(file) - } + const file = ops.file(key) ?? ops.open?.(key) + if (file) ops.loadFile(file) if (kind === "change") { - const dir = (() => { - if (path === "") return "" - const node = ops.node(path) ?? (key === path ? undefined : ops.node(key)) - if (node?.type !== "directory") return - return node.path - })() - if (dir === undefined) return - if (!ops.isDirLoaded(dir)) return + const dir = ops.dir(key) + if (!dir) return ops.refreshDir(dir) return } if (kind !== "add" && kind !== "unlink") return - const parent = getParentPath(key) - if (!ops.isDirLoaded(parent)) return - + const parent = ops.dir(filePathParentKey(key)) + if (!parent) return ops.refreshDir(parent) } diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 753a268964f0..bee661a23cbd 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -1,9 +1,9 @@ import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" -import { pathKey } from "@opencode-ai/util/path" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup } from "solid-js" import z from "zod" +import { workspacePathKey } from "@/context/file/path" import { createSdkForServer } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" @@ -56,7 +56,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo let timer: ReturnType | undefined let last = 0 - const dir = (directory: string) => (directory === "global" ? directory : pathKey(directory) || directory) + const dir = (directory: string) => (directory === "global" ? directory : workspacePathKey(directory)) const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts index 93e9c4175557..c627e1ddbe4b 100644 --- a/packages/app/src/context/global-sync.test.ts +++ b/packages/app/src/context/global-sync.test.ts @@ -1,25 +1,28 @@ import { describe, expect, test } from "bun:test" +import { workspacePathKey } from "@/context/file/path" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +const key = (input: string) => workspacePathKey(input) + describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { const now = 5_000 const picks = pickDirectoriesToEvict({ - stores: ["a", "b", "c", "d"], + stores: [key("a"), key("b"), key("c"), key("d")], state: new Map([ - ["a", { lastAccessAt: 1_000 }], - ["b", { lastAccessAt: 4_900 }], - ["c", { lastAccessAt: 4_800 }], - ["d", { lastAccessAt: 3_000 }], + [key("a"), { lastAccessAt: 1_000 }], + [key("b"), { lastAccessAt: 4_900 }], + [key("c"), { lastAccessAt: 4_800 }], + [key("d"), { lastAccessAt: 3_000 }], ]), - pins: new Set(["a"]), + pins: new Set([key("a")]), max: 2, ttl: 1_500, now, }) - expect(picks).toEqual(["d", "c"]) + expect(picks.map(String)).toEqual(["d", "c"]) }) }) @@ -81,7 +84,7 @@ describe("canDisposeDirectory", () => { test("rejects pinned or inflight directories", () => { expect( canDisposeDirectory({ - directory: "dir", + directory: key("dir"), hasStore: true, pinned: true, booting: false, @@ -90,7 +93,7 @@ describe("canDisposeDirectory", () => { ).toBe(false) expect( canDisposeDirectory({ - directory: "dir", + directory: key("dir"), hasStore: true, pinned: false, booting: true, @@ -99,7 +102,7 @@ describe("canDisposeDirectory", () => { ).toBe(false) expect( canDisposeDirectory({ - directory: "dir", + directory: key("dir"), hasStore: true, pinned: false, booting: false, @@ -111,7 +114,7 @@ describe("canDisposeDirectory", () => { test("accepts idle unpinned directory store", () => { expect( canDisposeDirectory({ - directory: "dir", + directory: key("dir"), hasStore: true, pinned: false, booting: false, diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 9e4bdbde96a6..46f1159d855c 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -36,7 +36,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 { workspacePathKey, type WorkspacePath } from "@/context/file/path" +import { workspacePathKey, type WorkspaceKey, type WorkspacePath } from "@/context/file/path" type GlobalStore = { ready: boolean @@ -58,10 +58,10 @@ function createGlobalSync() { const owner = getOwner() if (!owner) throw new Error("GlobalSync must be created within owner") - const sdkCache = new Map() - const booting = new Map>() - const sessionLoads = new Map>() - const sessionMeta = new Map() + const sdkCache = new Map() + const booting = new Map>() + const sessionLoads = new Map>() + const sessionMeta = new Map() const [projectCache, setProjectCache, projectInit] = persisted( Persist.global("globalSync.project", ["globalSync.project.v1"]), @@ -88,7 +88,10 @@ function createGlobalSync() { let active = true let projectWritten = false - const dir = (directory: WorkspacePath | string) => workspacePathKey(directory as WorkspacePath) + const eventKey = (name: string) => { + if (name === "global") return "global" as const + return workspacePathKey(name as WorkspacePath) + } onCleanup(() => { active = false @@ -157,7 +160,7 @@ function createGlobalSync() { const queue = createRefreshQueue({ paused, bootstrap, - bootstrapInstance, + bootstrapInstance: bootstrapInstanceKey, }) const children = createChildStoreManager({ @@ -165,7 +168,7 @@ function createGlobalSync() { isBooting: (directory) => booting.has(directory), isLoadingSessions: (directory) => sessionLoads.has(directory), onBootstrap: (directory) => { - void bootstrapInstance(directory) + void bootstrapInstanceKey(directory) }, onDispose: (directory) => { queue.clear(directory) @@ -176,8 +179,7 @@ function createGlobalSync() { translate: language.t, }) - const sdkFor = (directory: WorkspacePath | string) => { - directory = dir(directory) + const sdkFor = (directory: WorkspaceKey) => { const cached = sdkCache.get(directory) if (cached) return cached const sdk = globalSDK.createClient({ @@ -188,8 +190,7 @@ function createGlobalSync() { return sdk } - async function loadSessions(directory: WorkspacePath | string) { - directory = dir(directory) + async function loadSessionsKey(directory: WorkspaceKey) { const pending = sessionLoads.get(directory) if (pending) return pending @@ -256,9 +257,11 @@ function createGlobalSync() { return promise } - async function bootstrapInstance(directory: WorkspacePath | string) { - directory = dir(directory) - if (!directory) return + function loadSessions(directory: WorkspacePath) { + return loadSessionsKey(workspacePathKey(directory)) + } + + async function bootstrapInstanceKey(directory: WorkspaceKey) { const pending = booting.get(directory) if (pending) return pending @@ -288,7 +291,7 @@ function createGlobalSync() { } const unsub = globalSDK.event.listen((e) => { - const directory = dir(e.name) + const directory = eventKey(e.name) const event = e.details if (directory === "global") { @@ -299,7 +302,7 @@ function createGlobalSync() { setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { - for (const directory of Object.keys(children.children)) { + for (const directory of Object.keys(children.children) as WorkspaceKey[]) { queue.push(directory) } } @@ -315,7 +318,7 @@ function createGlobalSync() { directory, store, setStore, - push: queue.push, + push: (next) => queue.push(workspacePathKey(next)), setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { @@ -331,8 +334,8 @@ function createGlobalSync() { queue.dispose() }) onCleanup(() => { - for (const directory of Object.keys(children.children)) { - children.disposeDirectory(directory) + for (const directory of Object.keys(children.children) as WorkspaceKey[]) { + children.dispose(directory) } }) @@ -354,16 +357,19 @@ function createGlobalSync() { void bootstrap() }) - const projectApi = { - loadSessions, - meta(directory: WorkspacePath, patch: ProjectMeta) { - children.projectMeta(directory, patch) - }, - icon(directory: WorkspacePath, value: string | undefined) { - children.projectIcon(directory, value) - }, + const projectApi = { + loadSessions, + meta(directory: WorkspacePath, patch: ProjectMeta) { + children.projectMeta(workspacePathKey(directory), patch) + }, + icon(directory: WorkspacePath, value: string | undefined) { + children.projectIcon(workspacePathKey(directory), value) + }, } + const child = (directory: WorkspacePath, options?: { bootstrap?: boolean }) => + children.child(workspacePathKey(directory), options) + const updateConfig = async (config: Config) => { setGlobalStore("reload", "pending") return globalSDK.client.global.config @@ -389,7 +395,7 @@ function createGlobalSync() { get error() { return globalStore.error }, - child: children.child, + child, bootstrap, updateConfig, project: projectApi, diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 98a99e0e2e5c..a9171271e19b 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { createRoot, getOwner } from "solid-js" import { createStore } from "solid-js/store" +import { workspacePathKey } from "@/context/file/path" import type { State } from "./types" import { createChildStoreManager } from "./child-store" @@ -25,15 +26,15 @@ describe("createChildStoreManager", () => { }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { - manager.children[directory] = child() - manager.pin(directory) + manager.children[workspacePathKey(directory)] = child() + manager.pin(workspacePathKey(directory)) }) const directory = "/active" - manager.children[directory] = child() - manager.mark(directory) + manager.children[workspacePathKey(directory)] = child() + manager.mark(workspacePathKey(directory)) - expect(manager.children[directory]).toBeDefined() + expect(manager.children[workspacePathKey(directory)]).toBeDefined() }) test("reuses canonical workspace keys for equivalent directories", () => { @@ -54,9 +55,9 @@ describe("createChildStoreManager", () => { }) const store = child() - manager.children["c:/repo"] = store + manager.children[workspacePathKey("c:/repo")] = store - expect(manager.child("C:\\Repo\\", { bootstrap: false })).toBe(store) - expect(Object.keys(manager.children)).toEqual(["c:/repo"]) + expect(manager.child(workspacePathKey("C:\\Repo\\"), { bootstrap: false })).toBe(store) + expect(Object.keys(manager.children)).toEqual([workspacePathKey("c:/repo")]) }) }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 63d8fe81b711..9156f9b9e06f 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -2,7 +2,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import type { VcsInfo } from "@opencode-ai/sdk/v2/client" -import { workspacePathKey, type WorkspacePath } from "@/context/file/path" +import type { WorkspaceKey } from "@/context/file/path" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -18,39 +18,34 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" export function createChildStoreManager(input: { owner: Owner - isBooting: (directory: string) => boolean - isLoadingSessions: (directory: string) => boolean - onBootstrap: (directory: string) => void - onDispose: (directory: string) => void + isBooting: (directory: WorkspaceKey) => boolean + isLoadingSessions: (directory: WorkspaceKey) => boolean + onBootstrap: (directory: WorkspaceKey) => void + onDispose: (directory: WorkspaceKey) => void translate: (key: string, vars?: Record) => string }) { - const children: Record, SetStoreFunction]> = {} - const vcsCache = new Map() - const metaCache = new Map() - const iconCache = new Map() - const lifecycle = new Map() - const pins = new Map() - const ownerPins = new WeakMap>() - const disposers = new Map void>() - const dir = (directory: WorkspacePath | string) => workspacePathKey(directory as WorkspacePath) + type ChildStore = [Store, SetStoreFunction] - const mark = (directory: WorkspacePath | string) => { - directory = dir(directory) - if (!directory) return + const children: Record = {} as Record + const vcsCache = new Map() + const metaCache = new Map() + const iconCache = new Map() + const lifecycle = new Map() + const pins = new Map() + const ownerPins = new WeakMap>() + const disposers = new Map void>() + + const mark = (directory: WorkspaceKey) => { lifecycle.set(directory, { lastAccessAt: Date.now() }) runEviction(directory) } - const pin = (directory: WorkspacePath | string) => { - directory = dir(directory) - if (!directory) return + const pin = (directory: WorkspaceKey) => { pins.set(directory, (pins.get(directory) ?? 0) + 1) mark(directory) } - const unpin = (directory: WorkspacePath | string) => { - directory = dir(directory) - if (!directory) return + const unpin = (directory: WorkspaceKey) => { const next = (pins.get(directory) ?? 0) - 1 if (next > 0) { pins.set(directory, next) @@ -60,10 +55,9 @@ export function createChildStoreManager(input: { runEviction() } - const pinned = (directory: WorkspacePath | string) => (pins.get(dir(directory)) ?? 0) > 0 + const pinned = (directory: WorkspaceKey) => (pins.get(directory) ?? 0) > 0 - const pinForOwner = (directory: WorkspacePath | string) => { - directory = dir(directory) + const pinForOwner = (directory: WorkspaceKey) => { const current = getOwner() if (!current) return if (current === input.owner) return @@ -83,8 +77,7 @@ export function createChildStoreManager(input: { }) } - function disposeDirectory(directory: WorkspacePath | string) { - directory = dir(directory) + function dispose(directory: WorkspaceKey) { if ( !canDisposeDirectory({ directory, @@ -111,8 +104,8 @@ export function createChildStoreManager(input: { return true } - function runEviction(skip?: string) { - const stores = Object.keys(children) + function runEviction(skip?: WorkspaceKey) { + const stores = Object.keys(children) as WorkspaceKey[] if (stores.length === 0) return const list = pickDirectoriesToEvict({ stores, @@ -124,13 +117,11 @@ export function createChildStoreManager(input: { }).filter((directory) => directory !== skip) if (list.length === 0) return for (const directory of list) { - if (!disposeDirectory(directory)) continue + if (!dispose(directory)) continue } } - function ensureChild(directory: WorkspacePath | string) { - directory = dir(directory) - if (!directory) console.error("No directory provided") + function ensureChild(directory: WorkspaceKey) { if (!children[directory]) { const vcs = runWithOwner(input.owner, () => persisted( @@ -231,8 +222,7 @@ export function createChildStoreManager(input: { return childStore } - function child(directory: WorkspacePath | string, options: ChildOptions = {}) { - directory = dir(directory) + function child(directory: WorkspaceKey, options: ChildOptions = {}) { const childStore = ensureChild(directory) pinForOwner(directory) const shouldBootstrap = options.bootstrap ?? true @@ -242,8 +232,7 @@ export function createChildStoreManager(input: { return childStore } - function projectMeta(directory: WorkspacePath | string, patch: ProjectMeta) { - directory = dir(directory) + function projectMeta(directory: WorkspaceKey, patch: ProjectMeta) { const [store, setStore] = ensureChild(directory) const cached = metaCache.get(directory) if (!cached) return @@ -260,8 +249,7 @@ export function createChildStoreManager(input: { setStore("projectMeta", next) } - function projectIcon(directory: WorkspacePath | string, value: string | undefined) { - directory = dir(directory) + function projectIcon(directory: WorkspaceKey, value: string | undefined) { const [store, setStore] = ensureChild(directory) const cached = iconCache.get(directory) if (!cached) return @@ -280,7 +268,7 @@ export function createChildStoreManager(input: { pin, unpin, pinned, - disposeDirectory, + dispose, runEviction, vcsCache, metaCache, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 7c2de0240683..1f91fe575f60 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -21,7 +21,7 @@ export function normalizeSessionDiffs(diffs: FileDiff[]) { const map = new Map() for (const diff of diffs) { - const file = filePathKey(diff.file) || diff.file + const file = filePathKey(diff.file) if (!map.has(file)) order.push(file) map.set(file, { ...diff, file }) } diff --git a/packages/app/src/context/global-sync/eviction.ts b/packages/app/src/context/global-sync/eviction.ts index 676a6ee17e1f..a3aff3c6614d 100644 --- a/packages/app/src/context/global-sync/eviction.ts +++ b/packages/app/src/context/global-sync/eviction.ts @@ -7,7 +7,7 @@ export function pickDirectoriesToEvict(input: EvictPlan) { .filter((dir) => !input.pins.has(dir)) .slice() .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0)) - const output: string[] = [] + const output: EvictPlan["stores"] = [] for (const dir of sorted) { const last = input.state.get(dir)?.lastAccessAt ?? 0 const idle = input.now - last >= input.ttl diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index dd715fbc414c..e1566444a1b1 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -1,13 +1,13 @@ -import type { WorkspacePath } from "@/context/file/path" +import type { WorkspaceKey } from "@/context/file/path" type QueueInput = { paused: () => boolean bootstrap: () => Promise - bootstrapInstance: (directory: WorkspacePath) => Promise | void + bootstrapInstance: (directory: WorkspaceKey) => Promise | void } export function createRefreshQueue(input: QueueInput) { - const queued = new Set() + const queued = new Set() let root = false let running = false let timer: ReturnType | undefined @@ -15,8 +15,8 @@ export function createRefreshQueue(input: QueueInput) { const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) const take = (count: number) => { - if (queued.size === 0) return [] as string[] - const items: string[] = [] + if (queued.size === 0) return [] as WorkspaceKey[] + const items: WorkspaceKey[] = [] for (const item of queued) { queued.delete(item) items.push(item) @@ -33,8 +33,7 @@ export function createRefreshQueue(input: QueueInput) { }, 0) } - const push = (directory: string) => { - if (!directory) return + const push = (directory: WorkspaceKey) => { queued.add(directory) if (input.paused()) return schedule() @@ -73,7 +72,7 @@ export function createRefreshQueue(input: QueueInput) { return { push, refresh, - clear(directory: string) { + clear(directory: WorkspaceKey) { queued.delete(directory) }, dispose() { diff --git a/packages/app/src/context/global-sync/session-prefetch.ts b/packages/app/src/context/global-sync/session-prefetch.ts index 5b7e486d8888..784880f1715f 100644 --- a/packages/app/src/context/global-sync/session-prefetch.ts +++ b/packages/app/src/context/global-sync/session-prefetch.ts @@ -1,8 +1,8 @@ -import { workspacePathKey, type WorkspaceKey, type WorkspacePath } from "@/context/file/path" +import { workspacePathKey, type WorkspacePath } from "@/context/file/path" -const dir = (directory: WorkspacePath | WorkspaceKey) => workspacePathKey(directory as WorkspacePath) +const dir = (directory: WorkspacePath) => workspacePathKey(directory) -const key = (directory: WorkspacePath | WorkspaceKey, sessionID: string) => `${dir(directory)}\n${sessionID}` +const key = (directory: WorkspacePath, sessionID: string) => `${dir(directory)}\n${sessionID}` export const SESSION_PREFETCH_TTL = 15_000 diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index ce03c0d51df7..dc10bf18f974 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -19,7 +19,7 @@ import type { } from "@opencode-ai/sdk/v2/client" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" -import type { WorkspacePath } from "@/context/file/path" +import type { WorkspaceKey, WorkspacePath } from "@/context/file/path" export type ProjectMeta = { name?: string @@ -100,16 +100,16 @@ export type DirState = { } export type EvictPlan = { - stores: string[] - state: Map - pins: Set + stores: WorkspaceKey[] + state: Map + pins: Set max: number ttl: number now: number } export type DisposeCheck = { - directory: string + directory: WorkspaceKey hasStore: boolean pinned: boolean booting: boolean diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index a2306f2fd6e5..577d04910470 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -1,7 +1,7 @@ import { createStore, produce } from "solid-js/store" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" -import { pathEqual, pathKey } from "@opencode-ai/util/path" +import { pathEqual } from "@opencode-ai/util/path" import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" import { useServer } from "./server" @@ -9,24 +9,21 @@ import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" import { migrateLayoutPaths } from "@/utils/persist-path" -import { decode64 } from "@/utils/base64" -import { sessionDirKey, sessionParts } from "@/utils/session-key" +import { normalizeSessionKey, sessionDirKey, sessionParts, sessionPathHelpers } from "@/utils/session-key" import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" -import { createPathHelpers, type ReviewPath, type WorkspaceKey, type WorkspacePath } from "./file/path" +import { reviewPathKey, workspacePathKey, type ReviewPath, type WorkspaceKey, type WorkspacePath } from "./file/path" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const DEFAULT_PANEL_WIDTH = 344 const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_TERMINAL_HEIGHT = 280 -const reviewKey = (path: ReviewPath) => pathKey(path) || path -const workspaceKey = (path: WorkspacePath) => (pathKey(path) || path) as WorkspaceKey const reviewPaths = (paths: readonly ReviewPath[]) => { const seen = new Set() const out: ReviewPath[] = [] for (const path of paths) { - const id = reviewKey(path) + const id = reviewPathKey(path) if (seen.has(id)) continue seen.add(id) out.push(path) @@ -78,7 +75,7 @@ export type LocalProject = Partial & { worktree: WorkspacePath; expande export type ReviewDiffStyle = "unified" | "split" export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { - const next = sessionParts(key).key + const next = normalizeSessionKey(key) touch(next) seed(next) return next @@ -122,20 +119,12 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): return { all, active: tab } } -const sessionPath = (key: string) => { - const dir = key.split("/")[0] - if (!dir) return - const root = decode64(dir) - if (!root) return - return createPathHelpers(() => root) -} - -const normalizeSessionTab = (path: ReturnType | undefined, tab: string) => { +const normalizeSessionTab = (path: ReturnType, tab: string) => { if (!path) return tab return path.normalizeTab(tab) } -const normalizeSessionTabList = (path: ReturnType | undefined, all: string[]) => { +const normalizeSessionTabList = (path: ReturnType, all: string[]) => { const seen = new Set() return all.flatMap((tab) => { const value = normalizeSessionTab(path, tab) @@ -146,7 +135,7 @@ const normalizeSessionTabList = (path: ReturnType | un } export const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { - const path = sessionPath(key) + const path = sessionPathHelpers(key) return { all: normalizeSessionTabList(path, tabs.all), active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active, @@ -437,7 +426,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( for (const project of globalSync.data.project) { const sandboxes = project.sandboxes ?? [] for (const sandbox of sandboxes) { - map.set(workspaceKey(sandbox), project.worktree) + map.set(workspacePathKey(sandbox), project.worktree) } } return map @@ -454,11 +443,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const current = chain[chain.length - 1] if (!current) return directory - const key = workspaceKey(current) + const key = workspacePathKey(current) const next = map.get(key) if (!next) return current - const id = workspaceKey(next) + const id = workspacePathKey(next) if (visited.has(id)) return directory visited.add(id) chain.push(next) @@ -469,7 +458,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( createEffect(() => { const projects = server.projects.list() - const seen = new Set(projects.map((project) => pathKey(project.worktree))) + const seen = new Set(projects.map((project) => workspacePathKey(project.worktree))) batch(() => { for (const project of projects) { @@ -478,7 +467,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( server.projects.close(project.worktree) - const key = pathKey(root) + const key = workspacePathKey(root) if (!seen.has(key)) { server.projects.open(root) seen.add(key) @@ -611,13 +600,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("sidebar", "width", width) }, workspaces(directory: WorkspacePath) { - return () => store.sidebar.workspaces[workspaceKey(directory)] ?? store.sidebar.workspacesDefault ?? false + return () => store.sidebar.workspaces[workspacePathKey(directory)] ?? store.sidebar.workspacesDefault ?? false }, setWorkspaces(directory: WorkspacePath, value: boolean) { - setStore("sidebar", "workspaces", workspaceKey(directory), value) + setStore("sidebar", "workspaces", workspacePathKey(directory), value) }, toggleWorkspaces(directory: WorkspacePath) { - const key = workspaceKey(directory) + const key = workspacePathKey(directory) const current = store.sidebar.workspaces[key] ?? store.sidebar.workspacesDefault ?? false setStore("sidebar", "workspaces", key, !current) }, @@ -629,67 +618,39 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, review: { - diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), + diffStyle: createMemo(() => store.review.diffStyle), setDiffStyle(diffStyle: ReviewDiffStyle) { - if (!store.review) { - setStore("review", { diffStyle, panelOpened: true }) - return - } setStore("review", "diffStyle", diffStyle) }, }, fileTree: { - opened: createMemo(() => store.fileTree?.opened ?? true), - width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH), - tab: createMemo(() => store.fileTree?.tab ?? "changes"), + opened: createMemo(() => store.fileTree.opened), + width: createMemo(() => store.fileTree.width), + tab: createMemo(() => store.fileTree.tab), setTab(tab: "changes" | "all") { - if (!store.fileTree) { - setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab }) - return - } setStore("fileTree", "tab", tab) }, open() { - if (!store.fileTree) { - setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" }) - return - } setStore("fileTree", "opened", true) }, close() { - if (!store.fileTree) { - setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" }) - return - } setStore("fileTree", "opened", false) }, toggle() { - if (!store.fileTree) { - setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" }) - return - } setStore("fileTree", "opened", (x) => !x) }, resize(width: number) { - if (!store.fileTree) { - setStore("fileTree", { opened: true, width, tab: "changes" }) - return - } setStore("fileTree", "width", width) }, }, session: { - width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH), + width: createMemo(() => store.session.width), resize(width: number) { - if (!store.session) { - setStore("session", { width }) - return - } setStore("session", "width", width) }, }, mobileSidebar: { - opened: createMemo(() => store.mobileSidebar?.opened ?? false), + opened: createMemo(() => store.mobileSidebar.opened), show() { setStore("mobileSidebar", "opened", true) }, @@ -702,7 +663,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, pendingMessage: { set(sessionKey: string, messageID: string) { - const key = sessionParts(sessionKey).key + const key = normalizeSessionKey(sessionKey) const at = Date.now() touch(key) const current = store.sessionView[key] @@ -726,7 +687,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) }, consume(sessionKey: string) { - const key = sessionParts(sessionKey).key + const key = normalizeSessionKey(sessionKey) const current = store.sessionView[key] const message = current?.pendingMessage const at = current?.pendingMessageAt @@ -748,30 +709,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( view(sessionKey: string | Accessor) { const key = createSessionKeyReader(sessionKey, ensureKey) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) - const terminalOpened = createMemo(() => store.terminal?.opened ?? false) - const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) + const terminalOpened = createMemo(() => store.terminal.opened) + const reviewPanelOpened = createMemo(() => store.review.panelOpened) function setTerminalOpened(next: boolean) { - const current = store.terminal - if (!current) { - setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next }) - return - } - - const value = current.opened ?? false - if (value === next) return + if (store.terminal.opened === next) return setStore("terminal", "opened", next) } function setReviewPanelOpened(next: boolean) { - const current = store.review - if (!current) { - setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) - return - } - - const value = current.panelOpened ?? true - if (value === next) return + if (store.review.panelOpened === next) return setStore("review", "panelOpened", next) } @@ -880,7 +827,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, tabs(sessionKey: string | Accessor) { const key = createSessionKeyReader(sessionKey, ensureKey) - const path = createMemo(() => sessionPath(key())) + const path = createMemo(() => sessionPathHelpers(key())) const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) const normalize = (tab: string) => normalizeSessionTab(path(), tab) const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 87f852abbfdd..52e17c4f29a6 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,9 +1,9 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { base64Encode } from "@opencode-ai/util/encode" -import { pathKey } from "@opencode-ai/util/path" import { useParams } from "@solidjs/router" import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" +import { workspacePathKey } from "@/context/file/path" import { useModels } from "@/context/models" import { useProviders } from "@/hooks/use-providers" import { modelEnabled, modelProbe } from "@/testing/model-selection" @@ -27,7 +27,7 @@ type Saved = { const WORKSPACE_KEY = "__workspace__" const handoff = new Map() -const handoffKey = (dir: string, id: string) => `${pathKey(dir) || dir}\n${id}` +const handoffKey = (dir: string, id: string) => `${workspacePathKey(dir)}\n${id}` const migrate = (value: unknown) => { if (!value || typeof value !== "object") return { session: {} } diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index 679c2910e6a8..b1eaf2f834f3 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -1,8 +1,8 @@ import { base64Encode } from "@opencode-ai/util/encode" -import { pathKey } from "@opencode-ai/util/path" +import { workspacePathKey } from "@/context/file/path" import { decode64 } from "@/utils/base64" -const dir = (directory: string) => pathKey(directory) || directory +const dir = (directory: string) => workspacePathKey(directory) export function acceptKey(sessionID: string, directory?: string) { if (!directory) return sessionID diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 7468c8eeb72a..fa263eef35a0 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -117,6 +117,38 @@ function contextItemKey(item: ContextItem) { return `${key}:c=${digest.slice(0, 8)}` } +function normalizeContextItem(item: ContextItem | (ContextItem & { key?: string })) { + if (item.type !== "file") return { ...item, key: contextItemKey(item) } + const path = filePathKey(item.path) as FilePath + const next = { ...item, path } + return { ...next, key: contextItemKey(next) } +} + +function isContextItem(value: unknown): value is ContextItem | (ContextItem & { key?: string }) { + return ( + !!value && + typeof value === "object" && + "type" in value && + value.type === "file" && + "path" in value && + typeof value.path === "string" + ) +} + +function migratePromptStore(value: unknown) { + if (!value || typeof value !== "object") return value + if (!("context" in value)) return value + const context = (value as { context?: { items?: unknown } }).context + if (!context || !Array.isArray(context.items)) return value + return { + ...value, + context: { + ...context, + items: context.items.filter(isContextItem).map(normalizeContextItem), + }, + } +} + function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { return item.type === "file" && !!item.comment?.trim() } @@ -174,31 +206,33 @@ function createPromptSessionState(store: Store, setStore: SetStoreF context: { items: createMemo(() => store.context.items), add(item: ContextItem) { - const key = contextItemKey(item) + const next = normalizeContextItem(item) + const key = next.key if (store.context.items.find((x) => x.key === key)) return - setStore("context", "items", (items) => [...items, { key, ...item }]) + setStore("context", "items", (items) => [...items, next]) }, remove(key: string) { setStore("context", "items", (items) => items.filter((x) => x.key !== key)) }, removeComment(path: FilePath, commentID: string) { + const key = filePathKey(path) setStore("context", "items", (items) => - items.filter((item) => !(item.type === "file" && filePathEqual(item.path, path) && item.commentID === commentID)), + items.filter((item) => !(item.type === "file" && filePathKey(item.path) === key && item.commentID === commentID)), ) }, updateComment(path: FilePath, commentID: string, next: Partial & { comment?: string }) { + const key = filePathKey(path) setStore("context", "items", (items) => items.map((item) => { - if (item.type !== "file" || !filePathEqual(item.path, path) || item.commentID !== commentID) return item - const value = { ...item, ...next } - return { ...value, key: contextItemKey(value) } + if (item.type !== "file" || filePathKey(item.path) !== key || item.commentID !== commentID) return item + return normalizeContextItem({ ...item, ...next }) }), ) }, replaceComments(items: FileContextItem[]) { setStore("context", "items", (current) => [ ...current.filter((item) => !isCommentItem(item)), - ...items.map((item) => ({ ...item, key: contextItemKey(item) })), + ...items.map(normalizeContextItem), ]) }, }, @@ -212,7 +246,7 @@ export function createPromptSessionForTest(input?: Partial) { prompt: clonePrompt(input?.prompt ?? DEFAULT_PROMPT), cursor: input?.cursor, context: { - items: input?.context?.items?.map((item) => ({ ...item })) ?? [], + items: input?.context?.items?.map(normalizeContextItem) ?? [], }, }) @@ -232,7 +266,10 @@ export function createPromptSessionForTest(input?: Partial) { function createPromptSession(dir: string, id: string | undefined) { const [store, setStore, _, ready] = persisted( - Persist.scoped(dir, id, "prompt", Persist.legacyScoped(dir, id, "prompt", "v2")), + { + ...Persist.scoped(dir, id, "prompt", Persist.legacyScoped(dir, id, "prompt", "v2")), + migrate: migratePromptStore, + }, createStore({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: undefined, diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx index 1663b7786418..4892f6b72781 100644 --- a/packages/app/src/context/sdk.tsx +++ b/packages/app/src/context/sdk.tsx @@ -1,8 +1,8 @@ import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" -import { pathKey } from "@opencode-ai/util/path" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" +import { workspacePathKey } from "@/context/file/path" import { useGlobalSDK } from "./global-sdk" type SDKEventMap = { @@ -15,7 +15,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const globalSDK = useGlobalSDK() const directory = createMemo(props.directory) - const key = createMemo(() => pathKey(directory()) || directory()) + const key = createMemo(() => workspacePathKey(directory())) const client = createMemo(() => globalSDK.createClient({ directory: directory(), diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index e24476e215e5..b6832eb91485 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,8 +1,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { pathEqual } from "@opencode-ai/util/path" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { type WorkspaceKey, type WorkspacePath, workspacePathKey } from "@/context/file/path" +import { type WorkspacePath, workspacePathKey } from "@/context/file/path" import { Persist, persisted } from "@/utils/persist" import { migrateServerState } from "@/utils/persist-path" import { useCheckServerHealth } from "@/utils/server-health" @@ -10,10 +9,10 @@ import { useCheckServerHealth } from "@/utils/server-health" type StoredProject = { worktree: WorkspacePath; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http const HEALTH_POLL_INTERVAL_MS = 10_000 -const worktreeKey = (input: WorkspacePath) => workspacePathKey(input) as WorkspaceKey function projectIndex(list: StoredProject[], worktree: WorkspacePath) { - return list.findIndex((item) => pathEqual(item.worktree, worktree)) + const key = workspacePathKey(worktree) + return list.findIndex((item) => workspacePathKey(item.worktree) === key) } function upsertProject(list: StoredProject[], worktree: WorkspacePath) { @@ -227,16 +226,15 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) const origin = createMemo(() => projectsKey(state.active)) - const projectsList = createMemo(() => { - const list = store.projects[origin()] ?? [] - const seen = new Set() - return list.filter((project) => { - const id = worktreeKey(project.worktree) - if (seen.has(id)) return false - seen.add(id) - return true - }) - }) + const projectsList = createMemo(() => store.projects[origin()] ?? []) + const projects = () => { + const key = origin() + if (!key) return + return { + key, + list: store.projects[key] ?? [], + } + } const current: Accessor = createMemo( () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], ) @@ -267,44 +265,41 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( projects: { list: projectsList, open(directory: WorkspacePath) { - const key = origin() - if (!key) return - setStore("projects", key, upsertProject(store.projects[key] ?? [], directory)) + const current = projects() + if (!current) return + setStore("projects", current.key, upsertProject(current.list, directory)) }, close(directory: WorkspacePath) { - const key = origin() - if (!key) return - const current = store.projects[key] ?? [] + const current = projects() + if (!current) return + const id = workspacePathKey(directory) setStore( "projects", - key, - current.filter((x) => !pathEqual(x.worktree, directory)), + current.key, + current.list.filter((item) => workspacePathKey(item.worktree) !== id), ) }, expand(directory: WorkspacePath) { - const key = origin() - if (!key) return - const current = store.projects[key] ?? [] - const index = current.findIndex((x) => pathEqual(x.worktree, directory)) - if (index !== -1) setStore("projects", key, index, "expanded", true) + const current = projects() + if (!current) return + const index = projectIndex(current.list, directory) + if (index !== -1) setStore("projects", current.key, index, "expanded", true) }, collapse(directory: WorkspacePath) { - const key = origin() - if (!key) return - const current = store.projects[key] ?? [] - const index = current.findIndex((x) => pathEqual(x.worktree, directory)) - if (index !== -1) setStore("projects", key, index, "expanded", false) + const current = projects() + if (!current) return + const index = projectIndex(current.list, directory) + if (index !== -1) setStore("projects", current.key, index, "expanded", false) }, move(directory: WorkspacePath, toIndex: number) { - const key = origin() - if (!key) return - const current = store.projects[key] ?? [] - const fromIndex = current.findIndex((x) => pathEqual(x.worktree, directory)) + const current = projects() + if (!current) return + const fromIndex = projectIndex(current.list, directory) if (fromIndex === -1 || fromIndex === toIndex) return - const result = [...current] + const result = [...current.list] const [item] = result.splice(fromIndex, 1) result.splice(toIndex, 0, item) - setStore("projects", key, result) + setStore("projects", current.key, result) }, last() { const key = origin() diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index c5a5659a2bd8..07c4ca5207f6 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,9 +1,9 @@ import { batch, createMemo } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { pathKey } from "@opencode-ai/util/path" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" +import { workspacePathKey } from "@/context/file/path" import { clearSessionPrefetch, getSessionPrefetch, @@ -30,7 +30,7 @@ function runInflight(map: Map>, key: string, task: () => P return promise } -const dir = (directory: string) => pathKey(directory) || directory +const dir = (directory: string) => workspacePathKey(directory) const keyFor = (directory: string, id: string) => `${dir(directory)}\n${id}` diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index e5f2418bc25a..f044c46f596f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -36,7 +36,7 @@ import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { clearWorkspaceTerminals } from "@/context/terminal" -import { type WorkspaceKey, type WorkspacePath } from "@/context/file/path" +import { workspacePathKey, type WorkspaceKey, type WorkspacePath } from "@/context/file/path" import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { clearSessionPrefetchInflight, @@ -78,7 +78,6 @@ import { latestRootSession, sortedRootSessions, workspaceEqual, - workspaceKey, } from "./layout/helpers" import { collectNewSessionDeepLinks, @@ -102,8 +101,6 @@ type StoredRoute = { at: number } -const workspacePathKey = (input: WorkspacePath) => (workspaceKey(input) || input) as WorkspaceKey - function mergeWorkspaceOrder(root: WorkspacePath, list: WorkspacePath[]) { const seen = new Set([workspacePathKey(root)]) const out: WorkspacePath[] = [] @@ -588,6 +585,15 @@ export default function Layout(props: ParentProps) { return findProjectByDirectory(projects, root) }) + const keyOf = (directory: WorkspacePath) => workspacePathKey(directory) + const routeFor = (root: WorkspacePath) => store.lastProjectSession[keyOf(root)] + const orderFor = (root: WorkspacePath, dirs: WorkspacePath[]) => + effectiveWorkspaceOrder(root, dirs, store.workspaceOrder[keyOf(root)]) + const includes = (dirs: WorkspacePath[], directory: WorkspacePath | undefined) => { + if (!directory) return false + return dirs.some((item) => workspaceEqual(item, directory)) + } + const [autoselecting] = createResource(async () => { await ready.promise await layout.ready.promise @@ -607,7 +613,7 @@ export default function Layout(props: ParentProps) { }) const workspaceName = (directory: WorkspacePath, projectId?: string, branch?: string) => { - const direct = store.workspaceName[workspacePathKey(directory)] + const direct = store.workspaceName[keyOf(directory)] if (direct) return direct if (!projectId) return if (!branch) return @@ -615,7 +621,7 @@ export default function Layout(props: ParentProps) { } const setWorkspaceName = (directory: WorkspacePath, next: string, projectId?: string, branch?: string) => { - const key = workspacePathKey(directory) + const key = keyOf(directory) setStore("workspaceName", key, next) if (!projectId) return if (!branch) return @@ -642,7 +648,7 @@ export default function Layout(props: ParentProps) { const activeDir = currentDir() return workspaceIds(project).filter((directory) => { - const expanded = store.workspaceExpanded[workspacePathKey(directory)] ?? workspaceEqual(directory, project.worktree) + const expanded = store.workspaceExpanded[keyOf(directory)] ?? workspaceEqual(directory, project.worktree) const active = workspaceEqual(directory, activeDir) return expanded || active }) @@ -1207,12 +1213,12 @@ export default function Layout(props: ParentProps) { } function rememberSessionRoute(directory: WorkspacePath, id: string, root = activeProjectRoot(directory)) { - setStore("lastProjectSession", workspacePathKey(root), { directory, id, at: Date.now() }) + setStore("lastProjectSession", keyOf(root), { directory, id, at: Date.now() }) return root } function clearLastProjectSession(root: WorkspacePath) { - const key = workspacePathKey(root) + const key = keyOf(root) if (!store.lastProjectSession[key]) return setStore( "lastProjectSession", @@ -1225,7 +1231,7 @@ export default function Layout(props: ParentProps) { function syncSessionRoute(directory: WorkspacePath, id: string, root = activeProjectRoot(directory)) { rememberSessionRoute(directory, id, root) notification.session.markViewed(id) - const key = workspacePathKey(directory) + const key = keyOf(directory) const expanded = untrack(() => store.workspaceExpanded[key]) if (expanded === false) { setStore("workspaceExpanded", key, true) @@ -1239,20 +1245,15 @@ export default function Layout(props: ParentProps) { const root = projectRoot(directory) server.projects.touch(root) const project = layout.projects.list().find((item) => workspaceEqual(item.worktree, root)) - let dirs = project - ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[workspacePathKey(root)]) - : [root] - const canOpen = (value: WorkspacePath | undefined) => { - if (!value) return false - return dirs.some((item) => workspacePathKey(item) === workspacePathKey(value)) - } + let dirs = project ? orderFor(root, [root, ...(project.sandboxes ?? [])]) : [root] + const canOpen = (value: WorkspacePath | undefined) => includes(dirs, value) const refreshDirs = async (target?: string) => { if (!target || workspaceEqual(target, root) || canOpen(target)) return canOpen(target) const listed = await globalSDK.client.worktree .list({ directory: root }) .then((x) => x.data ?? []) .catch(() => [] as string[]) - dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[workspacePathKey(root)]) + dirs = orderFor(root, [root, ...listed]) return canOpen(target) } const openSession = async (target: { directory: string; id: string }) => { @@ -1274,7 +1275,7 @@ export default function Layout(props: ParentProps) { return true } - const projectSession = store.lastProjectSession[workspacePathKey(root)] + const projectSession = routeFor(root) if (projectSession?.id) { await refreshDirs(projectSession.directory) const opened = await openSession(projectSession) @@ -1436,8 +1437,8 @@ export default function Layout(props: ParentProps) { if (workspaceEqual(directory, root)) return const current = currentDir() - const currentKey = workspaceKey(current) - const deletedKey = workspaceKey(directory) + const currentKey = workspacePathKey(current) + const deletedKey = workspacePathKey(directory) const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey) if (!leaveDeletedWorkspace && shouldLeave) { navigateWithSidebarReset(`/${base64Encode(root)}/session`) @@ -1461,8 +1462,7 @@ export default function Layout(props: ParentProps) { if (!result) return if ( - workspacePathKey(store.lastProjectSession[workspacePathKey(root)]?.directory ?? "") === - workspacePathKey(directory) + workspacePathKey(routeFor(root)?.directory ?? "") === workspacePathKey(directory) ) { clearLastProjectSession(root) } @@ -1475,7 +1475,7 @@ export default function Layout(props: ParentProps) { project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => !workspaceEqual(sandbox, directory)) }), ) - setStore("workspaceOrder", workspacePathKey(root), (order) => + setStore("workspaceOrder", keyOf(root), (order) => (order ?? []).filter((workspace) => !workspaceEqual(workspace, directory)), ) @@ -1487,9 +1487,7 @@ export default function Layout(props: ParentProps) { const nextCurrent = currentDir() const nextKey = workspacePathKey(nextCurrent) const project = layout.projects.list().find((item) => workspaceEqual(item.worktree, root)) - const dirs = project - ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[workspacePathKey(root)]) - : [root] + const dirs = project ? orderFor(root, [root, ...(project.sandboxes ?? [])]) : [root] const valid = dirs.some((item) => workspacePathKey(item) === nextKey) if (params.dir && workspaceEqual(projectRoot(nextCurrent), root) && !valid) { @@ -1596,7 +1594,7 @@ export default function Layout(props: ParentProps) { }) const handleDelete = () => { - const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory) + const leaveDeletedWorkspace = !!params.dir && workspacePathKey(currentDir()) === workspacePathKey(props.directory) if (leaveDeletedWorkspace) { navigateWithSidebarReset(`/${base64Encode(props.root)}/session`) } @@ -1814,7 +1812,7 @@ export default function Layout(props: ParentProps) { : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false - const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[workspacePathKey(project.worktree)]) + const ordered = orderFor(local, dirs) if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)] if (!extra) return ordered if (pending) return ordered @@ -1853,7 +1851,7 @@ export default function Layout(props: ParentProps) { result.splice(toIndex, 0, item) setStore( "workspaceOrder", - workspacePathKey(project.worktree), + keyOf(project.worktree), mergeWorkspaceOrder( project.worktree, result.filter((directory) => workspacePathKey(directory) !== workspacePathKey(project.worktree)), @@ -1888,7 +1886,7 @@ export default function Layout(props: ParentProps) { setBusy(created.directory, true) WorktreeState.pending(created.directory) setStore("workspaceExpanded", key, true) - setStore("workspaceOrder", workspacePathKey(project.worktree), (prev) => { + setStore("workspaceOrder", keyOf(project.worktree), (prev) => { return mergeWorkspaceOrder(local, [created.directory, ...(prev ?? [])]) }) @@ -1915,8 +1913,8 @@ export default function Layout(props: ParentProps) { setEditor, InlineEditor, isBusy, - workspaceExpanded: (directory, local) => store.workspaceExpanded[workspacePathKey(directory)] ?? local, - setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", workspacePathKey(directory), value), + workspaceExpanded: (directory, local) => store.workspaceExpanded[keyOf(directory)] ?? local, + setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", keyOf(directory), value), showResetWorkspaceDialog: (root, directory) => dialog.show(() => ), showDeleteWorkspaceDialog: (root, directory) => diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 29d1d2778cc7..6f09def07f7c 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { pathEqual, pathKey } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +import { workspacePathKey } from "@/context/file/path" import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks, @@ -17,7 +18,6 @@ import { latestRootSession, projectContains, workspaceEqual, - workspaceKey, } from "./helpers" const session = (input: Partial & Pick) => @@ -120,13 +120,13 @@ describe("layout workspace helpers", () => { }) test("preserves normalized roots in workspace key", () => { - expect(workspaceKey("/")).toBe("/") - expect(workspaceKey("///")).toBe("/") - expect(workspaceKey("\\")).toBe("/") - expect(workspaceKey("C:\\")).toBe("c:/") - expect(workspaceKey("C:/")).toBe("c:/") - expect(workspaceKey("C:///")).toBe("c:/") - expect(workspaceKey("\\\\Server\\Share\\")).toBe("//server/share") + expect(String(workspacePathKey("/"))).toBe("/") + expect(String(workspacePathKey("///"))).toBe("/") + expect(String(workspacePathKey("\\"))).toBe("/") + expect(String(workspacePathKey("C:\\"))).toBe("c:/") + expect(String(workspacePathKey("C:/"))).toBe("c:/") + expect(String(workspacePathKey("C:///"))).toBe("c:/") + expect(String(workspacePathKey("\\\\Server\\Share\\"))).toBe("//server/share") }) test("keeps local first while preserving known order", () => { diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index d001a654d9c4..780db87e4dce 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,7 +1,6 @@ -import { getFilename, pathEqual, pathKey } from "@opencode-ai/util/path" +import { getFilename, pathEqual } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" - -export const workspaceKey = pathKey +import { workspacePathKey } from "@/context/file/path" export const workspaceEqual = pathEqual @@ -34,7 +33,7 @@ function sortSessions(now: number) { } const isRootVisibleSession = (session: Session, directory: string) => - workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + workspacePathKey(session.directory) === workspacePathKey(directory) && !session.parentID && !session.time?.archived export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) @@ -78,11 +77,11 @@ export const errorMessage = (err: unknown, fallback: string) => { } export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => { - const root = workspaceKey(local) + const root = workspacePathKey(local) const live = new Map() for (const dir of dirs) { - const key = workspaceKey(dir) + const key = workspacePathKey(dir) if (key === root) continue if (!live.has(key)) live.set(key, dir) } @@ -91,7 +90,7 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted const result = [local] for (const dir of persisted) { - const key = workspaceKey(dir) + const key = workspacePathKey(dir) if (key === root) continue const match = live.get(key) if (!match) continue diff --git a/packages/app/src/pages/session/handoff.ts b/packages/app/src/pages/session/handoff.ts index ae1d7440e5e1..1122ddbf2201 100644 --- a/packages/app/src/pages/session/handoff.ts +++ b/packages/app/src/pages/session/handoff.ts @@ -1,5 +1,5 @@ import type { SelectedLineRange } from "@/context/file" -import { sessionParts } from "@/utils/session-key" +import { normalizeSessionKey } from "@/utils/session-key" type HandoffSession = { prompt: string @@ -24,12 +24,12 @@ const touch = (map: Map, key: K, value: V) => { } export const setSessionHandoff = (key: string, patch: Partial) => { - const next = sessionParts(key).key + const next = normalizeSessionKey(key) const prev = store.session.get(next) ?? { prompt: "", files: {} } touch(store.session, next, { ...prev, ...patch }) } -export const getSessionHandoff = (key: string) => store.session.get(sessionParts(key).key) +export const getSessionHandoff = (key: string) => store.session.get(normalizeSessionKey(key)) export const setTerminalHandoff = (key: string, value: string[]) => { touch(store.terminal, key, value) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index f4a73069b22d..55188afdde1a 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -1,6 +1,6 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" -import { type FilePath, type FileTabId } from "@/context/file/path" +import { FILE_TAB_PREFIX, type FilePath, type FileTabId } from "@/context/file/path" import { same } from "@/utils/same" const emptyTabs: FileTabId[] = [] @@ -23,6 +23,12 @@ export const getSessionKey = (dir: string | undefined, id: string | undefined) = export const createSessionTabs = (input: TabsInput) => { const review = input.review ?? (() => false) const hasReview = input.hasReview ?? (() => false) + const fileTab = (tab: string) => { + if (!input.pathFromTab(tab)) return + const next = input.normalizeTab(tab) + if (!next.startsWith(FILE_TAB_PREFIX)) return + return next as FileTabId + } const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context")) const openedTabs = createMemo( () => { @@ -32,7 +38,7 @@ export const createSessionTabs = (input: TabsInput) => { .all() .flatMap((tab) => { if (tab === "context" || tab === "review") return [] - const value = input.pathFromTab(tab) ? (input.normalizeTab(tab) as FileTabId) : undefined + const value = fileTab(tab) if (!value) return [] if (seen.has(value)) return [] seen.add(value) @@ -46,7 +52,9 @@ export const createSessionTabs = (input: TabsInput) => { const active = input.tabs().active() if (active === "context") return active if (active === "review" && review()) return active - if (active && input.pathFromTab(active)) return input.normalizeTab(active) + + const file = active ? fileTab(active) : undefined + if (file) return file const first = openedTabs()[0] if (first) return first @@ -57,18 +65,16 @@ export const createSessionTabs = (input: TabsInput) => { const activeFileTab = createMemo(() => { const active = activeTab() if (active === "context" || active === "review" || active === "empty") return - const tab = active as FileTabId - if (!openedTabs().includes(tab)) return - return tab - }) as Accessor + if (!openedTabs().includes(active)) return + return active + }) const closableTab = createMemo(() => { const active = activeTab() if (active === "context") return active if (active === "review" || active === "empty") return - const tab = active as FileTabId - if (!openedTabs().includes(tab)) return - return tab - }) as Accessor<"context" | FileTabId | undefined> + if (!openedTabs().includes(active)) return + return active + }) return { contextOpen, diff --git a/packages/app/src/utils/persist-path.ts b/packages/app/src/utils/persist-path.ts index 6c512b422551..0e959dda9506 100644 --- a/packages/app/src/utils/persist-path.ts +++ b/packages/app/src/utils/persist-path.ts @@ -1,6 +1,5 @@ -import { pathKey } from "@opencode-ai/util/path" -import { createPathHelpers, type WorkspaceKey, type WorkspacePath } from "@/context/file/path" -import { sessionDirKey, sessionParts } from "@/utils/session-key" +import { workspacePathKey, type WorkspaceKey, type WorkspacePath } from "@/context/file/path" +import { normalizeSessionKey, sessionDirKey, sessionPathHelpers } from "@/utils/session-key" const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) @@ -11,12 +10,10 @@ const num = (value: unknown) => (typeof value === "number" && Number.isFinite(va const flag = (value: unknown) => (typeof value === "boolean" ? value : undefined) -const pathId = (value: WorkspacePath) => (pathKey(value) || value) as WorkspaceKey - function paths(value: unknown, skip?: string) { if (!Array.isArray(value)) return - const omit = skip ? pathId(skip) : undefined + const omit = skip ? workspacePathKey(skip) : undefined const seen = new Set() const out: string[] = [] @@ -24,7 +21,7 @@ function paths(value: unknown, skip?: string) { const cur = text(item) if (!cur) continue - const id = pathId(cur) + const id = workspacePathKey(cur) if (id === omit || seen.has(id)) continue seen.add(id) out.push(cur) @@ -42,7 +39,7 @@ function byKey( const out: Record = {} for (const [name, item] of Object.entries(value)) { - const id = pathId(name) + const id = workspacePathKey(name) const next = move(item, id) if (next === undefined) continue @@ -58,7 +55,7 @@ function bySession(value: unknown, move: (value: unknown, key: string) => T | const out: Record = {} for (const [name, item] of Object.entries(value)) { - const key = sessionParts(name).key + const key = normalizeSessionKey(name) const next = move(item, key) if (next === undefined) continue @@ -82,14 +79,7 @@ function list(value: readonly string[]) { return out } -const sessionPath = (key: string) => { - const dir = sessionParts(key).directory - if (!dir) return - return createPathHelpers(() => dir) -} - -function tabs(value: readonly string[], key: string) { - const path = sessionPath(key) +function tabs(value: readonly string[], path: ReturnType) { if (!path) return list(value) const seen = new Set() @@ -105,10 +95,9 @@ function tabs(value: readonly string[], key: string) { return out } -function scroll(value: unknown, key: string) { +function scroll(value: unknown, path: ReturnType) { if (!record(value)) return {} - const path = sessionPath(key) if (!path) return value return Object.fromEntries(Object.entries(value).map(([name, item]) => [path.normalizeTab(name), item])) @@ -176,7 +165,7 @@ function projects(value: unknown) { const next = project(item) if (!next) continue - const id = pathId(next.worktree) + const id = workspacePathKey(next.worktree) const at = seen.get(id) if (at === undefined) { seen.set(id, out.length) @@ -194,12 +183,13 @@ function sessionTabs(value: unknown, key: string): SessionTabs | undefined { if (!record(value)) return if (!Array.isArray(value.all)) return - const all = tabs(value.all.filter((item): item is string => typeof item === "string"), key) + const path = sessionPathHelpers(key) + const all = tabs(value.all.filter((item): item is string => typeof item === "string"), path) const active = text(value.active) return { all, - active: active ? sessionPath(key)?.normalizeTab(active) ?? active : undefined, + active: active ? path?.normalizeTab(active) ?? active : undefined, } } @@ -212,10 +202,11 @@ function mergeSessionTabs(prev: SessionTabs, next: SessionTabs): SessionTabs { function sessionView(value: unknown, key: string): SessionView | undefined { if (!record(value)) return + const path = sessionPathHelpers(key) return { ...value, - scroll: scroll(value.scroll, key), + scroll: scroll(value.scroll, path), ...(Array.isArray(value.reviewOpen) ? { reviewOpen: value.reviewOpen.filter((item): item is string => typeof item === "string") } : {}), diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index a738794c63a6..c3b73b830d28 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -1,5 +1,5 @@ import { Platform, usePlatform } from "@/context/platform" -import { pathKey } from "@opencode-ai/util/path" +import { workspacePathKey } from "@/context/file/path" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" import { checksum } from "@opencode-ai/util/encode" import { createResource, type Accessor } from "solid-js" @@ -218,7 +218,7 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => } function workspaceDirectory(dir: string) { - return pathKey(dir) || dir + return workspacePathKey(dir) } function trimDir(dir: string) { diff --git a/packages/app/src/utils/session-key.test.ts b/packages/app/src/utils/session-key.test.ts index e64c653dadbe..6979db092960 100644 --- a/packages/app/src/utils/session-key.test.ts +++ b/packages/app/src/utils/session-key.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { base64Encode } from "@opencode-ai/util/encode" -import { sessionDirKey, sessionKey, sessionParts } from "./session-key" +import { normalizeSessionKey, sessionDirKey, sessionKey, sessionParts, sessionPathHelpers } from "./session-key" describe("session-key", () => { test("normalizes equivalent workspace aliases to one session key", () => { @@ -13,4 +13,15 @@ describe("session-key", () => { key: `${base64Encode("c:/repo")}/one`, }) }) + + test("builds path helpers from normalized session keys", () => { + const path = sessionPathHelpers(sessionKey(base64Encode("C:\\Repo\\"), "one")) + expect(path?.normalizeTab("file://src\\a.ts")).toBe("tab:file:src/a.ts") + }) + + test("normalizes equivalent inputs through one helper", () => { + expect(normalizeSessionKey(sessionKey(base64Encode("C:\\Repo\\"), "one"))).toBe( + normalizeSessionKey(sessionKey(base64Encode("c:/repo"), "one")), + ) + }) }) diff --git a/packages/app/src/utils/session-key.ts b/packages/app/src/utils/session-key.ts index f22899f89668..52f599b66221 100644 --- a/packages/app/src/utils/session-key.ts +++ b/packages/app/src/utils/session-key.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode-ai/util/encode" -import { pathKey } from "@opencode-ai/util/path" +import { createPathHelpers, workspacePathKey } from "@/context/file/path" import { decode64 } from "./base64" function decodeDir(input: string) { @@ -9,10 +9,19 @@ function decodeDir(input: string) { return value } +const splitSessionKey = (input: string) => { + const split = input.indexOf("/") + if (split === -1) return { dir: input, id: undefined } + return { + dir: input.slice(0, split), + id: input.slice(split + 1), + } +} + export function sessionDirKey(input: string) { const dir = decodeDir(input) if (!dir) return input - return base64Encode(pathKey(dir) || dir) + return base64Encode(workspacePathKey(dir)) } export function sessionKey(dir: string, id?: string) { @@ -22,9 +31,7 @@ export function sessionKey(dir: string, id?: string) { } export function sessionParts(input: string) { - const split = input.indexOf("/") - const dir = split === -1 ? input : input.slice(0, split) - const id = split === -1 ? undefined : input.slice(split + 1) + const { dir, id } = splitSessionKey(input) return { dir: sessionDirKey(dir), directory: decodeDir(dir), @@ -32,3 +39,11 @@ export function sessionParts(input: string) { key: sessionKey(dir, id), } } + +export const normalizeSessionKey = (input: string) => sessionParts(input).key + +export function sessionPathHelpers(input: string) { + const dir = sessionParts(input).directory + if (!dir) return + return createPathHelpers(() => dir) +} diff --git a/packages/app/src/utils/worktree.test.ts b/packages/app/src/utils/worktree.test.ts index 8161e7ad8367..d0a5ac83eb26 100644 --- a/packages/app/src/utils/worktree.test.ts +++ b/packages/app/src/utils/worktree.test.ts @@ -11,6 +11,16 @@ describe("Worktree", () => { expect(Worktree.get(key)).toEqual({ status: "ready" }) }) + test("dedupes windows path aliases by workspace key", () => { + Worktree.pending("C:\\Repo\\Feature\\") + + const waiting = Worktree.wait("c:/repo/feature") + Worktree.ready("C:/Repo/Feature") + + expect(Worktree.get("c:/repo/feature")).toEqual({ status: "ready" }) + return expect(waiting).resolves.toEqual({ status: "ready" }) + }) + test("pending does not overwrite a terminal state", () => { const key = dir("pending") Worktree.failed(key, "boom") diff --git a/packages/app/src/utils/worktree.ts b/packages/app/src/utils/worktree.ts index 581afd5535e2..d3296e3b2b1f 100644 --- a/packages/app/src/utils/worktree.ts +++ b/packages/app/src/utils/worktree.ts @@ -1,4 +1,4 @@ -const normalize = (directory: string) => directory.replace(/[\\/]+$/, "") +import { workspacePathKey, type WorkspaceKey, type WorkspacePath } from "@/context/file/path" type State = | { @@ -12,9 +12,11 @@ type State = message: string } -const state = new Map() +const key = (directory: WorkspacePath) => workspacePathKey(directory) + +const state = new Map() const waiters = new Map< - string, + WorkspaceKey, { promise: Promise resolve: (state: State) => void @@ -29,45 +31,43 @@ function deferred() { return { promise, resolve: box.resolve } } +function settle(directory: WorkspacePath, next: Extract) { + const id = key(directory) + state.set(id, next) + + const waiter = waiters.get(id) + if (!waiter) return + waiters.delete(id) + waiter.resolve(next) +} + export const Worktree = { - get(directory: string) { - return state.get(normalize(directory)) + get(directory: WorkspacePath) { + return state.get(key(directory)) }, - pending(directory: string) { - const key = normalize(directory) - const current = state.get(key) + pending(directory: WorkspacePath) { + const id = key(directory) + const current = state.get(id) if (current && current.status !== "pending") return - state.set(key, { status: "pending" }) + state.set(id, { status: "pending" }) }, - ready(directory: string) { - const key = normalize(directory) - const next = { status: "ready" } as const - state.set(key, next) - const waiter = waiters.get(key) - if (!waiter) return - waiters.delete(key) - waiter.resolve(next) + ready(directory: WorkspacePath) { + settle(directory, { status: "ready" }) }, - failed(directory: string, message: string) { - const key = normalize(directory) - const next = { status: "failed", message } as const - state.set(key, next) - const waiter = waiters.get(key) - if (!waiter) return - waiters.delete(key) - waiter.resolve(next) + failed(directory: WorkspacePath, message: string) { + settle(directory, { status: "failed", message }) }, - wait(directory: string) { - const key = normalize(directory) - const current = state.get(key) + wait(directory: WorkspacePath) { + const id = key(directory) + const current = state.get(id) if (current && current.status !== "pending") return Promise.resolve(current) - const existing = waiters.get(key) + const existing = waiters.get(id) if (existing) return existing.promise const waiter = deferred() - waiters.set(key, waiter) + waiters.set(id, waiter) return waiter.promise }, } diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 0b0495ee9c33..abd5721018cd 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -99,7 +99,7 @@ export namespace ACP { const msg = lastAssistant.info if (!msg.providerID || !msg.modelID) return - const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory) + const size = await getContextLimit(sdk, ProviderID.parse(msg.providerID), ModelID.parse(msg.modelID), directory) if (!size) { // Cannot calculate usage without known context size diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 4088b4818d2b..5d7ffb08eae1 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -18,7 +18,15 @@ export const ExportCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined + let sessionID = (() => { + if (!args.sessionID) return + try { + return SessionID.parse(args.sessionID) + } catch (err) { + UI.error(err instanceof Error ? err.message : `Invalid session id: ${args.sessionID}`) + process.exit(1) + } + })() process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) if (!sessionID) { diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index edd9d7561094..dd68d1cd6096 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -955,15 +955,6 @@ export const GithubRunCommand = cmd({ mime: f.mime, url: `data:${f.mime};base64,${f.content}`, filename: f.filename, - source: { - type: "file" as const, - text: { - value: f.replacement, - start: f.start, - end: f.end, - }, - path: f.filename, - }, }, ]), ], diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 3e3672926d08..464bd3a13130 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -51,13 +51,21 @@ export const ModelsCommand = cmd({ } if (args.provider) { - const provider = providers[ProviderID.make(args.provider)] + let providerID: ProviderID + try { + providerID = ProviderID.parse(args.provider) + } catch (err) { + UI.error(err instanceof Error ? err.message : `Invalid provider id: ${args.provider}`) + return + } + + const provider = providers[providerID] if (!provider) { UI.error(`Provider not found: ${args.provider}`) return } - printModels(ProviderID.make(args.provider), args.verbose) + printModels(providerID, args.verbose) return } @@ -70,7 +78,7 @@ export const ModelsCommand = cmd({ }) for (const providerID of providerIDs) { - printModels(ProviderID.make(providerID), args.verbose) + printModels(ProviderID.parse(providerID), args.verbose) } }, }) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 8acd7480c941..324c799dbe9e 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -58,7 +58,13 @@ export const SessionDeleteCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const sessionID = SessionID.make(args.sessionID) + let sessionID: SessionID + try { + sessionID = SessionID.parse(args.sessionID) + } catch (err) { + UI.error(err instanceof Error ? err.message : `Invalid session id: ${args.sessionID}`) + process.exit(1) + } try { await Session.get(sessionID) } catch { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index fbaa57ed99f2..f877eeb523ae 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -74,7 +74,6 @@ import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" -import { Filesystem } from "@/util/filesystem" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" @@ -2233,7 +2232,7 @@ function Skill(props: ToolProps) { function Diagnostics(props: { diagnostics?: Record[]>; filePath: string }) { const { theme } = useTheme() const errors = createMemo(() => { - const normalized = Filesystem.normalizePath(props.filePath) + const normalized = Path.truecaseSync(props.filePath) const arr = props.diagnostics?.[normalized] ?? [] return arr.filter((x) => x.severity === 1).slice(0, 3) }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index b72ebf140455..b8fb76bc8894 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -14,6 +14,7 @@ import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" +import { Path } from "@/path/path" declare global { const OPENCODE_WORKER_PATH: string @@ -115,9 +116,9 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from the logical shell root so alias // roots from PWD or --project stay intact. - const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) + const root = Path.truecaseSync(process.env.PWD ?? process.cwd()) const next = args.project - ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) + ? Path.truecaseSync(Path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) : root const file = await target() try { diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 9e626f8b8e51..08f38bb658b9 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,7 +1,6 @@ import z from "zod" -import { Path } from "@/path/path" import { Worktree } from "@/worktree" -import { type Adaptor, WorkspaceInfo } from "../types" +import { type Adaptor, type WorkspaceFetchInput, WorkspaceInfo } from "../types" const Config = WorkspaceInfo.extend({ name: WorkspaceInfo.shape.name.unwrap(), @@ -11,6 +10,16 @@ const Config = WorkspaceInfo.extend({ type Config = z.infer +function config(info: WorkspaceInfo) { + return Config.parse(info) +} + +function request(input: WorkspaceFetchInput, init?: RequestInit) { + const url = input instanceof URL ? input : input instanceof Request ? new URL(input.url) : new URL(input, "http://opencode.internal") + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)) + return { url, headers } +} + export const WorktreeAdaptor: Adaptor = { async configure(info) { const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined) @@ -18,30 +27,27 @@ export const WorktreeAdaptor: Adaptor = { ...info, name: worktree.name, branch: worktree.branch, - directory: await Path.truecase(worktree.directory), + directory: worktree.directory, } }, async create(info) { - const config = Config.parse(info) + const cfg = config(info) const bootstrap = await Worktree.createFromInfo({ - name: config.name, - directory: Path.pretty(config.directory), - branch: config.branch, + name: cfg.name, + directory: cfg.directory, + branch: cfg.branch, }) return bootstrap() }, async remove(info) { - const config = Config.parse(info) - await Worktree.remove({ directory: config.directory }) + await Worktree.remove({ directory: config(info).directory }) }, - async fetch(info, input: RequestInfo | URL, init?: RequestInit) { - const config = Config.parse(info) + async fetch(info, input, init) { + const cfg = config(info) const { WorkspaceServer } = await import("../workspace-server/server") - const url = input instanceof Request || input instanceof URL ? input : new URL(input, "http://opencode.internal") - const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)) - headers.set("x-opencode-directory", config.directory) + const req = request(input, init) + req.headers.set("x-opencode-directory", cfg.directory) - const request = new Request(url, { ...init, headers }) - return WorkspaceServer.App().fetch(request) + return WorkspaceServer.App().fetch(new Request(req.url, { ...init, headers: req.headers })) }, } diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 7618f46ad4d4..68646408af1b 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,5 +1,4 @@ import { Schema } from "effect" -import z from "zod" import { withStatics } from "@/util/schema" import { Identifier } from "@/id/id" @@ -11,7 +10,11 @@ export type WorkspaceID = typeof workspaceIdSchema.Type export const WorkspaceID = workspaceIdSchema.pipe( withStatics((schema: typeof workspaceIdSchema) => ({ make: (id: string) => schema.makeUnsafe(id), + parse: (id: string) => schema.makeUnsafe(Identifier.parse("workspace", id)), + assert: (id: string): asserts id is WorkspaceID => { + Identifier.assert("workspace", id) + }, ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)), - zod: Identifier.schema("workspace").pipe(z.custom()), + zod: Identifier.schema("workspace"), })), ) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index abb983f6cda6..b806c29e6675 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,5 +1,5 @@ import z from "zod" -import type { PrettyPath } from "@/path/schema" +import { PrettyPath } from "@/path/schema" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" @@ -8,17 +8,17 @@ export const WorkspaceInfo = z.object({ type: z.string(), branch: z.string().nullable(), name: z.string().nullable(), - directory: z.string().nullable(), + directory: PrettyPath.zod.nullable(), extra: z.unknown().nullable(), projectID: ProjectID.zod, }) -export type WorkspaceInfo = Omit, "directory"> & { - directory: PrettyPath | null -} +export type WorkspaceInfo = z.infer + +export type WorkspaceFetchInput = string | URL | Request export type Adaptor = { configure(input: WorkspaceInfo): WorkspaceInfo | Promise create(input: WorkspaceInfo, from?: WorkspaceInfo): Promise remove(config: WorkspaceInfo): Promise - fetch(config: WorkspaceInfo, input: RequestInfo | URL, init?: RequestInit): Promise + fetch(config: WorkspaceInfo, input: WorkspaceFetchInput, init?: RequestInit): Promise } diff --git a/packages/opencode/src/control-plane/workspace-server/server.ts b/packages/opencode/src/control-plane/workspace-server/server.ts index 1d8dbec121c9..16f0f940aa1b 100644 --- a/packages/opencode/src/control-plane/workspace-server/server.ts +++ b/packages/opencode/src/control-plane/workspace-server/server.ts @@ -6,6 +6,13 @@ import { WorkspaceServerRoutes } from "./routes" import { WorkspaceContext } from "../workspace-context" import { WorkspaceID } from "../schema" import { Path } from "../../path/path" +import { HTTPException } from "hono/http-exception" + +function badInput(err: unknown) { + return new HTTPException(400, { + message: err instanceof Error ? err.message : "Invalid request", + }) +} export namespace WorkspaceServer { export function App() { @@ -25,24 +32,29 @@ export namespace WorkspaceServer { const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") const raw = c.req.query("directory") || c.req.header("x-opencode-directory") if (rawWorkspaceID == null) { - throw new Error("workspaceID parameter is required") + throw new HTTPException(400, { message: "workspace parameter is required" }) } if (raw == null) { - throw new Error("directory parameter is required") + throw new HTTPException(400, { message: "directory parameter is required" }) } - const directory = Path.pretty( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) + const workspaceID = (() => { + try { + return WorkspaceID.parse(rawWorkspaceID) + } catch (err) { + throw badInput(err) + } + })() + const directory = (() => { + try { + return Path.from(raw, { encoded: true, label: "directory parameter" }) + } catch (err) { + throw badInput(err) + } + })() return WorkspaceContext.provide({ - workspaceID: WorkspaceID.make(rawWorkspaceID), + workspaceID, async fn() { return Instance.provide({ directory, diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 1c57810e906a..2bcf8a1fc3cd 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -12,7 +12,6 @@ import { WorkspaceInfo } from "./types" import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" import { Path } from "@/path/path" -import { PrettyPath } from "@/path/schema" export namespace Workspace { export const Event = { @@ -35,18 +34,13 @@ export namespace Workspace { }) export type Info = WorkspaceInfo - function fix(input: string) { - if (!input) return PrettyPath.make(input) - return Path.truecaseSync(input) - } - function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { id: row.id, type: row.type, branch: row.branch, name: row.name, - directory: row.directory ? fix(row.directory) : null, + directory: row.directory ? Path.truecaseSync(row.directory) : null, extra: row.extra, projectID: row.project_id, } @@ -113,7 +107,7 @@ export namespace Workspace { if (row) { const info = fromRow(row) const adaptor = await getAdaptor(row.type) - adaptor.remove(info) + await adaptor.remove(info) Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) return info } diff --git a/packages/opencode/src/effect/instance-registry.ts b/packages/opencode/src/effect/instance-registry.ts index ed11423519de..1c8e1c83c23f 100644 --- a/packages/opencode/src/effect/instance-registry.ts +++ b/packages/opencode/src/effect/instance-registry.ts @@ -1,14 +1,14 @@ import type { PrettyPath } from "@/path/schema" -const disposers = new Set<(directory: PrettyPath | string) => Promise>() +const disposers = new Set<(directory: PrettyPath) => Promise>() -export function registerDisposer(disposer: (directory: PrettyPath | string) => Promise) { +export function registerDisposer(disposer: (directory: PrettyPath) => Promise) { disposers.add(disposer) return () => { disposers.delete(disposer) } } -export async function disposeInstance(directory: PrettyPath | string) { +export async function disposeInstance(directory: PrettyPath) { await Promise.allSettled([...disposers].map((disposer) => disposer(directory))) } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index b1ce8278f2eb..f0e397b6f9fe 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -2,6 +2,7 @@ import { BusEvent } from "@/bus/bus-event" import { Path } from "@/path/path" import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" +import { Instance } from "@/project/instance" import { git } from "@/util/git" import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" import { formatPatch, structuredPatch } from "diff" @@ -18,6 +19,8 @@ import { Ripgrep } from "./ripgrep" import type { PrettyPath, RepoPath } from "@/path/schema" export namespace File { + const repo = (input: RepoPath | string) => (Path.isAbsolute(input) ? Path.repoFrom(Instance.directory, input) : Path.repo(input)) + export const Info = z .object({ path: z.string(), @@ -98,11 +101,11 @@ export namespace File { } export async function read(file: RepoPath | string): Promise { - return runPromiseInstance(Service.use((svc) => svc.read(file))) + return runPromiseInstance(Service.use((svc) => svc.read(repo(file)))) } export async function list(dir?: RepoPath | string) { - return runPromiseInstance(Service.use((svc) => svc.list(dir))) + return runPromiseInstance(Service.use((svc) => svc.list(dir ? repo(dir) : undefined))) } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { @@ -351,14 +354,14 @@ export namespace File { export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect - readonly read: (file: RepoPath | string) => Effect.Effect - readonly list: (dir?: RepoPath | string) => Effect.Effect + readonly read: (file: RepoPath) => Effect.Effect + readonly list: (dir?: RepoPath) => Effect.Effect readonly search: (input: { query: string limit?: number dirs?: boolean type?: "file" | "directory" - }) => Effect.Effect + }) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/File") {} @@ -489,7 +492,7 @@ export namespace File { if (untrackedOutput.trim()) { for (const file of untrackedOutput.trim().split("\n")) { try { - const content = await Filesystem.readText(path.join(instance.directory, file)) + const content = await Filesystem.readText(Path.join(instance.directory, file)) changed.push({ path: file, added: content.split("\n").length, @@ -532,19 +535,19 @@ export namespace File { } return changed.map((item) => { - const full = Path.pretty(item.path, { cwd: instance.directory }) - return { - ...item, - path: Path.repo(Path.rel(instance.directory, full)), - } - }) + const full = Path.join(instance.directory, item.path) + return { + ...item, + path: Path.repoFrom(instance.directory, full), + } + }) }) }) - const read = Effect.fn("File.read")(function* (file: RepoPath | string) { + const read = Effect.fn("File.read")(function* (file: RepoPath) { return yield* Effect.promise(async (): Promise => { using _ = log.time("read", { file }) - const full = path.join(instance.directory, file) + const full = Path.join(instance.directory, file) if (!(await allow(full))) { throw new Error(`Access denied: path escapes project directory`) @@ -622,7 +625,7 @@ export namespace File { }) }) - const list = Effect.fn("File.list")(function* (dir?: RepoPath | string) { + const list = Effect.fn("File.list")(function* (dir?: RepoPath) { return yield* Effect.promise(async () => { const exclude = [".git", ".DS_Store"] let ignored = (_: string) => false @@ -639,7 +642,7 @@ export namespace File { ignored = ig.ignores.bind(ig) } - const resolved = dir ? path.join(instance.directory, dir) : instance.directory + const resolved = dir ? Path.join(instance.directory, dir) : instance.directory if (!(await allow(resolved))) { throw new Error(`Access denied: path escapes project directory`) } @@ -647,8 +650,8 @@ export namespace File { const nodes: File.Node[] = [] for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { if (exclude.includes(entry.name)) continue - const absolute = Path.pretty(path.join(resolved, entry.name)) - const file = Path.repo(Path.rel(instance.directory, absolute)) + const absolute = Path.join(resolved, entry.name) + const file = Path.repoFrom(instance.directory, absolute) const type = entry.isDirectory() ? "directory" : "file" nodes.push({ name: entry.name, diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 54b3baa59017..642a8969a0f7 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,7 +1,6 @@ import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" import { Path } from "@/path/path" import type { PathKey, PrettyPath } from "@/path/schema" -import { Instance } from "@/project/instance" import { runPromiseInstance } from "@/effect/runtime" import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" @@ -19,8 +18,7 @@ export namespace FileTime { readonly size: number | undefined } - const pretty = (file: string) => Path.pretty(file, { cwd: Instance.directory }) - const key = (file: string) => Path.key(file, { cwd: Instance.directory }) + const key = (file: PrettyPath) => Path.key(file) const stamp = Effect.fnUntraced(function* (file: PrettyPath) { const stat = Filesystem.stat(file) @@ -44,10 +42,10 @@ export namespace FileTime { } export interface Interface { - readonly read: (sessionID: SessionID, file: PrettyPath | string) => Effect.Effect - readonly get: (sessionID: SessionID, file: PrettyPath | string) => Effect.Effect - readonly assert: (sessionID: SessionID, file: PrettyPath | string) => Effect.Effect - readonly withLock: (file: PrettyPath | string, fn: () => Promise) => Effect.Effect + readonly read: (sessionID: SessionID, file: PrettyPath) => Effect.Effect + readonly get: (sessionID: SessionID, file: PrettyPath) => Effect.Effect + readonly assert: (sessionID: SessionID, file: PrettyPath) => Effect.Effect + readonly withLock: (file: PrettyPath, fn: () => Promise) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/FileTime") {} @@ -59,7 +57,7 @@ export namespace FileTime { const reads = new Map>() const locks = new Map() - const getLock = (file: PrettyPath | string) => { + const getLock = (file: PrettyPath) => { const id = key(file) const lock = locks.get(id) if (lock) return lock @@ -69,33 +67,31 @@ export namespace FileTime { return next } - const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: PrettyPath | string) { - const path = pretty(file) - log.info("read", { sessionID, file: path }) - session(reads, sessionID).set(key(path), yield* stamp(path)) + const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: PrettyPath) { + log.info("read", { sessionID, file }) + session(reads, sessionID).set(key(file), yield* stamp(file)) }) - const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: PrettyPath | string) { + const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: PrettyPath) { return reads.get(sessionID)?.get(key(file))?.read }) - const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, file: PrettyPath | string) { + const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, file: PrettyPath) { if (disableCheck) return - const path = pretty(file) - const time = reads.get(sessionID)?.get(key(path)) - if (!time) throw new Error(`You must read file ${path} before overwriting it. Use the Read tool first`) + const time = reads.get(sessionID)?.get(key(file)) + if (!time) throw new Error(`You must read file ${file} before overwriting it. Use the Read tool first`) - const next = yield* stamp(path) + const next = yield* stamp(file) const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size if (!changed) return throw new Error( - `File ${path} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, + `File ${file} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, ) }) - const withLock = Effect.fn("FileTime.withLock")(function* (file: PrettyPath | string, fn: () => Promise) { + const withLock = Effect.fn("FileTime.withLock")(function* (file: PrettyPath, fn: () => Promise) { return yield* Effect.promise(fn).pipe(getLock(file).withPermits(1)) }) @@ -103,19 +99,19 @@ export namespace FileTime { }), ) - export function read(sessionID: SessionID, file: PrettyPath | string) { + export function read(sessionID: SessionID, file: PrettyPath) { return runPromiseInstance(Service.use((s) => s.read(sessionID, file))) } - export function get(sessionID: SessionID, file: PrettyPath | string) { + export function get(sessionID: SessionID, file: PrettyPath) { return runPromiseInstance(Service.use((s) => s.get(sessionID, file))) } - export async function assert(sessionID: SessionID, file: PrettyPath | string) { + export async function assert(sessionID: SessionID, file: PrettyPath) { return runPromiseInstance(Service.use((s) => s.assert(sessionID, file))) } - export async function withLock(file: PrettyPath | string, fn: () => Promise): Promise { + export async function withLock(file: PrettyPath, fn: () => Promise): Promise { return runPromiseInstance(Service.use((s) => s.withLock(file, fn))) } } diff --git a/packages/opencode/src/filesystem/index.ts b/packages/opencode/src/filesystem/index.ts index 30e40fb578d1..53695824eb1d 100644 --- a/packages/opencode/src/filesystem/index.ts +++ b/packages/opencode/src/filesystem/index.ts @@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { dirname, join } from "path" import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect" import type { PlatformError } from "effect/PlatformError" +import { Path } from "../path/path" import { Glob } from "../util/glob" import { Filesystem as LocalFilesystem } from "../util/filesystem" @@ -88,46 +89,31 @@ export namespace AppFileSystem { const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) { const result: string[] = [] - let current = start - while (true) { - const search = join(current, target) + for (const dir of Path.up(start, { stop })) { + const search = join(dir, target) if (yield* fs.exists(search)) result.push(search) - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent } return result }) const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) { const result: string[] = [] - let current = options.start - while (true) { + for (const dir of Path.up(options.start, { stop: options.stop })) { for (const target of options.targets) { - const search = join(current, target) + const search = join(dir, target) if (yield* fs.exists(search)) result.push(search) } - if (options.stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent } return result }) const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) { const result: string[] = [] - let current = start - while (true) { - const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe( + for (const dir of Path.up(start, { stop })) { + const matches = yield* glob(pattern, { cwd: dir, absolute: true, include: "file", dot: true }).pipe( Effect.catch(() => Effect.succeed([] as string[])), ) result.push(...matches) - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent } return result }) @@ -151,28 +137,8 @@ export namespace AppFileSystem { export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) - // Pure helpers that don't need Effect (path manipulation, sync operations) + // Pure helper that doesn't need Effect. export function mimeType(p: string): string { return LocalFilesystem.mimeType(p) } - - export function normalizePath(p: string): string { - return LocalFilesystem.normalizePath(p) - } - - export function resolve(p: string): string { - return LocalFilesystem.resolve(p) - } - - export function windowsPath(p: string): string { - return LocalFilesystem.windowsPath(p) - } - - export function overlaps(a: string, b: string) { - return LocalFilesystem.overlaps(a, b) - } - - export function contains(parent: string, child: string) { - return LocalFilesystem.contains(parent, child) - } } diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 6673297cbfac..1a41a2629ee1 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -2,7 +2,7 @@ import z from "zod" import { randomBytes } from "crypto" export namespace Identifier { - const prefixes = { + export const prefixes = { session: "ses", message: "msg", permission: "per", @@ -14,8 +14,43 @@ export namespace Identifier { workspace: "wrk", } as const - export function schema(prefix: keyof typeof prefixes) { - return z.string().startsWith(prefixes[prefix]) + export type Prefix = keyof typeof prefixes + + function label(prefix: Prefix) { + return `${prefix} id` + } + + function issue(prefix: Prefix, input: string) { + if (!input) return `Expected ${label(prefix)}, received empty string` + if (!input.startsWith(prefixes[prefix])) { + return `Expected ${label(prefix)} starting with "${prefixes[prefix]}", received "${input}"` + } + } + + export function is(prefix: Prefix, input: string): boolean { + return !issue(prefix, input) + } + + export function assert(prefix: Prefix, input: string): asserts input is string { + const err = issue(prefix, input) + if (err) throw new TypeError(err) + } + + export function parse(prefix: Prefix, input: string): string { + assert(prefix, input) + return input + } + + export function schema(prefix: Prefix) { + return z.string().transform((input, ctx) => { + const err = issue(prefix, input) + if (!err) return input as T + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: err, + }) + return z.NEVER + }) } const LENGTH = 26 @@ -24,23 +59,17 @@ export namespace Identifier { let lastTimestamp = 0 let counter = 0 - export function ascending(prefix: keyof typeof prefixes, given?: string) { + export function ascending(prefix: Prefix, given?: string) { return generateID(prefix, false, given) } - export function descending(prefix: keyof typeof prefixes, given?: string) { + export function descending(prefix: Prefix, given?: string) { return generateID(prefix, true, given) } - function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string { - if (!given) { - return create(prefix, descending) - } - - if (!given.startsWith(prefixes[prefix])) { - throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) - } - return given + function generateID(prefix: Prefix, descending: boolean, given?: string): string { + if (!given) return create(prefix, descending) + return parse(prefix, given) } function randomBase62(length: number): string { @@ -53,7 +82,7 @@ export namespace Identifier { return result } - export function create(prefix: keyof typeof prefixes, descending: boolean, timestamp?: number): string { + export function create(prefix: Prefix, descending: boolean, timestamp?: number): string { const currentTimestamp = timestamp ?? Date.now() if (currentTimestamp !== lastTimestamp) { diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 21e7fe776483..c0c174a3d12b 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -12,7 +12,6 @@ import z from "zod" import type { LSPServer } from "./server" import { NamedError } from "@opencode-ai/util/error" import { withTimeout } from "../util/timeout" -import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" const DIAGNOSTICS_DEBOUNCE_MS = 150 @@ -52,10 +51,10 @@ export namespace LSPClient { ), } - export async function create(input: { serverID: string; server: LSPServer.Handle; root: PrettyPath | string }) { + export async function create(input: { serverID: string; server: LSPServer.Handle; root: PrettyPath }) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") - const root = Path.pretty(input.root, { cwd: Instance.directory }) + const root = input.root const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout as any), @@ -160,8 +159,8 @@ export namespace LSPClient { return connection }, notify: { - async open(input: { path: PrettyPath | string }) { - const file = Path.pretty(input.path, { cwd: Instance.directory }) + async open(input: { path: PrettyPath }) { + const file = input.path const pathKey = Path.key(file) const doc = files.get(pathKey) const text = await Filesystem.readText(file) @@ -224,9 +223,9 @@ export namespace LSPClient { get diagnostics() { return new Map([...diagnostics.values()].map((item) => [String(item.path), item.diagnostics])) }, - async waitForDiagnostics(input: { path: PrettyPath | string }) { - const path = Path.pretty(input.path, { cwd: Instance.directory }) - const pathKey = Path.key(path) + async waitForDiagnostics(input: { path: PrettyPath }) { + const path = input.path + const pathKey = Path.key(path) log.info("waiting for diagnostics", { path }) let unsub: () => void let debounceTimer: ReturnType | undefined diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 4439f6c344f2..04d1ea1b6f9c 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -20,8 +20,8 @@ export namespace LSP { return Path.pretty(input, { cwd: Instance.directory }) } - function uri(input: string) { - return Path.uri(pretty(input)) + function uri(input: PrettyPath) { + return Path.uri(input) } function key(serverID: string, root: PrettyPath) { @@ -188,8 +188,7 @@ export namespace LSP { }) } - async function getClients(file: string) { - file = pretty(file) + async function getClients(file: PrettyPath) { const s = await state() const extension = path.parse(file).ext || file const result: LSPClient.Info[] = [] @@ -279,30 +278,32 @@ export namespace LSP { } export async function hasClients(file: string) { - file = pretty(file) + const filePath = pretty(file) const s = await state() - const extension = path.parse(file).ext || file + const extension = path.parse(filePath).ext || filePath for (const server of Object.values(s.servers)) { if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file) + const root = await server.root(filePath) if (!root) continue - if (s.broken.has(key(server.id, pretty(root)))) continue + const dir = pretty(root) + if (s.broken.has(key(server.id, dir))) continue return true } return false } export async function touchFile(input: string, waitForDiagnostics?: boolean) { - log.info("touching file", { file: input }) - const clients = await getClients(input) + const file = pretty(input) + log.info("touching file", { file }) + const clients = await getClients(file) await Promise.all( clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) + const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: file }) : Promise.resolve() + await client.notify.open({ path: file }) return wait }), ).catch((err) => { - log.error("failed to touch file", { err, file: input }) + log.error("failed to touch file", { err, file }) }) } @@ -322,19 +323,20 @@ export namespace LSP { return Object.fromEntries([...results.values()].map((item) => [item.path, item.diagnostics])) } - export function diagnosticsFor(file: string, input: Record) { - const value = Path.key(file, { cwd: Instance.directory }) + export function diagnosticsFor(file: PrettyPath, input: Record) { + const value = Path.key(file) for (const [path, diagnostics] of Object.entries(input)) { if (Path.match(path, value)) return { path, diagnostics } } } export async function hover(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => { + const file = pretty(input.file) + return run(file, (client) => { return client.connection .sendRequest("textDocument/hover", { textDocument: { - uri: uri(input.file), + uri: uri(file), }, position: { line: input.line, @@ -398,7 +400,7 @@ export namespace LSP { } export async function documentSymbol(uri: string) { - const file = String(Path.fromURI(uri)) + const file = Path.fromURI(uri) return run(file, (client) => client.connection .sendRequest("textDocument/documentSymbol", { @@ -413,10 +415,11 @@ export namespace LSP { } export async function definition(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => + const file = pretty(input.file) + return run(file, (client) => client.connection .sendRequest("textDocument/definition", { - textDocument: { uri: uri(input.file) }, + textDocument: { uri: uri(file) }, position: { line: input.line, character: input.character }, }) .catch(() => null), @@ -424,10 +427,11 @@ export namespace LSP { } export async function references(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => + const file = pretty(input.file) + return run(file, (client) => client.connection .sendRequest("textDocument/references", { - textDocument: { uri: uri(input.file) }, + textDocument: { uri: uri(file) }, position: { line: input.line, character: input.character }, context: { includeDeclaration: true }, }) @@ -436,10 +440,11 @@ export namespace LSP { } export async function implementation(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => + const file = pretty(input.file) + return run(file, (client) => client.connection .sendRequest("textDocument/implementation", { - textDocument: { uri: uri(input.file) }, + textDocument: { uri: uri(file) }, position: { line: input.line, character: input.character }, }) .catch(() => null), @@ -447,10 +452,11 @@ export namespace LSP { } export async function prepareCallHierarchy(input: { file: string; line: number; character: number }) { - return run(input.file, (client) => + const file = pretty(input.file) + return run(file, (client) => client.connection .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: uri(input.file) }, + textDocument: { uri: uri(file) }, position: { line: input.line, character: input.character }, }) .catch(() => []), @@ -458,10 +464,11 @@ export namespace LSP { } export async function incomingCalls(input: { file: string; line: number; character: number }) { - return run(input.file, async (client) => { + const file = pretty(input.file) + return run(file, async (client) => { const items = (await client.connection .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: uri(input.file) }, + textDocument: { uri: uri(file) }, position: { line: input.line, character: input.character }, }) .catch(() => [])) as any[] @@ -471,10 +478,11 @@ export namespace LSP { } export async function outgoingCalls(input: { file: string; line: number; character: number }) { - return run(input.file, async (client) => { + const file = pretty(input.file) + return run(file, async (client) => { const items = (await client.connection .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: uri(input.file) }, + textDocument: { uri: uri(file) }, position: { line: input.line, character: input.character }, }) .catch(() => [])) as any[] @@ -489,7 +497,7 @@ export namespace LSP { return Promise.all(tasks) } - async function run(file: string, input: (client: LSPClient.Info) => Promise): Promise { + async function run(file: PrettyPath, input: (client: LSPClient.Info) => Promise): Promise { const clients = await getClients(file) const tasks = clients.map((x) => input(x)) return Promise.all(tasks) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 4870e129ddb8..f0512c848652 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -35,11 +35,14 @@ export namespace LSPServer { const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { return async (file) => { + const start = path.dirname(file) + const stop = Instance.directory + if (excludePatterns) { const excludedFiles = Filesystem.up({ targets: excludePatterns, - start: path.dirname(file), - stop: Instance.directory, + start, + stop, }) const excluded = await excludedFiles.next() await excludedFiles.return() @@ -47,12 +50,12 @@ export namespace LSPServer { } const files = Filesystem.up({ targets: includePatterns, - start: path.dirname(file), - stop: Instance.directory, + start, + stop, }) const first = await files.next() await files.return() - if (!first.value) return Instance.directory + if (!first.value) return stop return path.dirname(first.value) } } @@ -852,32 +855,27 @@ export namespace LSPServer { export const RustAnalyzer: Info = { id: "rust", - root: async (root) => { - const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) - if (crateRoot === undefined) { - return undefined - } - const worktree = Filesystem.resolve(Instance.worktree) - let currentDir = Filesystem.resolve(crateRoot) + root: async (file) => { + const crates = Filesystem.up({ + targets: ["Cargo.toml", "Cargo.lock"], + start: path.dirname(file), + }) + const first = await crates.next() + await crates.return() + + const crateRoot = first.value ? path.dirname(first.value) : Instance.directory + const worktree = Instance.worktree === "/" ? undefined : Path.truecaseSync(Instance.worktree) - while (!Path.eq(currentDir, path.dirname(currentDir))) { - // Stop at filesystem root - const cargoTomlPath = path.join(currentDir, "Cargo.toml") + for (const dir of Path.up(crateRoot, { stop: worktree })) { + const cargoTomlPath = path.join(dir, "Cargo.toml") try { const cargoTomlContent = await Filesystem.readText(cargoTomlPath) if (cargoTomlContent.includes("[workspace]")) { - return currentDir + return dir } } catch (err) { // File doesn't exist or can't be read, continue searching up } - - const parentDir = path.dirname(currentDir) - if (Path.eq(parentDir, currentDir)) break // Reached filesystem root - currentDir = parentDir - - // Stop if we've gone above the app root - if (!Filesystem.contains(worktree, currentDir)) break } return crateRoot diff --git a/packages/opencode/src/path/migrate.ts b/packages/opencode/src/path/migrate.ts index a8911dbf1934..8cbaed9c7bf6 100644 --- a/packages/opencode/src/path/migrate.ts +++ b/packages/opencode/src/path/migrate.ts @@ -1,7 +1,7 @@ import path from "path" import { ProjectTable } from "@/project/project.sql" import { Path } from "@/path/path" -import { PrettyPath } from "@/path/schema" +import type { PrettyPath } from "@/path/schema" import { Global } from "@/global" import { Database, eq } from "@/storage/db" import { Filesystem } from "@/util/filesystem" @@ -35,15 +35,7 @@ export namespace PathMigration { } } - function fix(input: string) { - if (!input || input === "/") return PrettyPath.make(input) - return Path.truecaseSync(input) - } - - function key(input: string) { - if (!input || input === "/") return input - return Path.key(input) - } + const stored = Path.truecaseSync function same(a: string[], b: string[]) { return a.length === b.length && a.every((item, idx) => item === b[idx]) @@ -53,9 +45,9 @@ export namespace PathMigration { const seen = new Set() const out: PrettyPath[] = [] for (const item of list) { - const dir = fix(item) + const dir = stored(item) if (dir && worktree && Path.eq(dir, worktree)) continue - const id = key(dir) + const id = Path.key(dir) if (seen.has(id)) continue seen.add(id) out.push(dir) @@ -115,7 +107,7 @@ export namespace PathMigration { } for (const row of db.select().from(ProjectTable).all()) { - const worktree = fix(row.worktree) + const worktree = stored(row.worktree) const sandboxes = uniq(row.sandboxes, worktree) if (worktree === row.worktree && same(sandboxes, row.sandboxes)) continue db.update(ProjectTable).set({ worktree, sandboxes }).where(eq(ProjectTable.id, row.id)).run() @@ -123,14 +115,14 @@ export namespace PathMigration { } for (const row of db.select().from(SessionTable).all()) { - const directory = fix(row.directory) + const directory = stored(row.directory) if (directory === row.directory) continue db.update(SessionTable).set({ directory }).where(eq(SessionTable.id, row.id)).run() change.session++ } for (const row of db.select().from(WorkspaceTable).all()) { - const directory = row.directory ? fix(row.directory) : null + const directory = row.directory ? stored(row.directory) : null if (directory === row.directory) continue db.update(WorkspaceTable).set({ directory }).where(eq(WorkspaceTable.id, row.id)).run() change.workspace++ diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index 55e4fba8656a..a49145297d1c 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -18,10 +18,19 @@ type HomeOpts = { platform?: Platform } -type DisplayOpts = Opts & { - home?: string | false - relative?: boolean -} +type DisplayOpts = Opts & { + home?: string | false + relative?: boolean +} + +type ParseOpts = Opts & { + encoded?: boolean + label?: string +} + +type UpOpts = Opts & { + stop?: string +} /** * Path exposes a few intentionally different string forms instead of one @@ -65,11 +74,15 @@ function fixDrive(input: string) { return input.replace(/^[a-z]:/, (match) => match.toUpperCase()) } -function clean(input: string, platform: NodeJS.Platform) { - const text = lib(platform).normalize(input) - if (platform !== "win32") return text - return fixDrive(text).replaceAll("/", "\\") -} +function clean(input: string, platform: NodeJS.Platform) { + const text = lib(platform).normalize(input) + if (platform !== "win32") return text + return fixDrive(text).replaceAll("/", "\\") +} + +function sentinel(input: string) { + return input === "/" +} function raw(input: string, platform: NodeJS.Platform) { if (input.startsWith("file://")) return fromURIText(input, platform) @@ -163,13 +176,35 @@ function encode(input: string) { return encodeURIComponent(input) } -function decode(input: string) { - try { - return decodeURIComponent(input) - } catch { - return input - } -} +function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +function decodeText(input: string, label: string) { + try { + return decodeURIComponent(input) + } catch { + throw new TypeError(`Invalid percent-encoding in ${label}: ${input}`) + } +} + +function parseText(input: string, opts: ParseOpts = {}) { + const label = opts.label ?? "path" + if (!input) throw new TypeError(`Expected ${label}, received empty string`) + if (input.includes("\0")) throw new TypeError(`Expected ${label} without null bytes`) + const text = opts.encoded ? decodeText(input, label) : input + return prettyText(text, opts) +} + +function stopText(input: string | undefined, platform: NodeJS.Platform) { + if (!input) return + if (sentinel(input)) return input + return prettyText(input, { platform }) +} function repoText(input: string) { const dir = /[\\/]$/.test(input) @@ -233,9 +268,9 @@ function toURIText(input: string, platform: NodeJS.Platform) { } async function physicalAsync(input: string, opts: Opts = {}): Promise { - const platform = pf(opts) - const mod = lib(platform) - const text = prettyText(input, opts) + const platform = pf(opts) + const mod = lib(platform) + const text = prettyText(input, opts) const hit = await realpath(text).catch(() => undefined) if (hit) return PrettyPath.make(clean(hit, platform)) @@ -249,8 +284,25 @@ async function physicalAsync(input: string, opts: Opts = {}): Promise undefined) if (next) return PrettyPath.make(clean(mod.join(next, ...parts), platform)) dir = parent - } -} + } +} + +function* upText(input: string, opts: UpOpts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const stop = stopText(opts.stop, platform) + let dir = prettyText(input, opts) + + if (stop && stop !== "/" && !inside(stop, dir, platform)) return + + while (true) { + yield PrettyPath.make(dir) + if (stop && dir === stop) return + const parent = mod.dirname(dir) + if (parent === dir) return + dir = parent + } +} export namespace Path { export type Options = Opts @@ -274,9 +326,13 @@ export namespace Path { * This resolves relative input against `cwd`, expands accepted Windows drive * aliases, and preserves the host platform's separator style. */ - export function pretty(input: string, opts: Opts = {}) { - return PrettyPath.make(prettyText(input, opts)) - } + export function pretty(input: string, opts: Opts = {}) { + return PrettyPath.make(prettyText(input, opts)) + } + + export function from(input: string, opts: ParseOpts = {}) { + return PrettyPath.make(parseText(input, opts)) + } /** * Returns the lookup/equality form for a path. @@ -284,12 +340,13 @@ export namespace Path { * On Windows the key is lower-cased so path comparisons match the * filesystem's case-insensitive behavior. */ - export function key(input: string, opts: Opts = {}) { - const platform = pf(opts) - const text = pretty(input, opts) - if (platform !== "win32") return PathKey.make(text) - return PathKey.make(text.toLowerCase()) - } + export function key(input: string, opts: Opts = {}) { + if (sentinel(input)) return PathKey.make(input) + const platform = pf(opts) + const text = pretty(input, opts) + if (platform !== "win32") return PathKey.make(text) + return PathKey.make(text.toLowerCase()) + } export function posix(input: string, opts: Opts = {}) { return PosixPath.make(pretty(input, opts).replaceAll("\\", "/")) @@ -319,16 +376,29 @@ export namespace Path { * This optionally collapses paths under home to `~` and can render paths * relative to `cwd`, but it never changes the underlying filesystem target. */ - export function display(input: string, opts: DisplayOpts = {}) { - return displayText(input, opts) - } - - export function rel(from: string, to: string, opts: Opts = {}) { - const platform = pf(opts) - const mod = lib(platform) - const text = mod.relative(pretty(from, opts), pretty(to, opts)) || "." - return RelativePath.make(text) - } + export function display(input: string, opts: DisplayOpts = {}) { + return displayText(input, opts) + } + + export function join(dir: string, child: string, opts: Omit = {}) { + return pretty(child, { ...opts, cwd: dir }) + } + + export function parent(input: string, opts: Omit = {}) { + const platform = pf(opts) + return pretty(lib(platform).dirname(pretty(input, opts)), { platform }) + } + + export function rel(from: string, to: string, opts: Opts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const text = mod.relative(pretty(from, opts), pretty(to, opts)) || "." + return RelativePath.make(text) + } + + export function repoFrom(root: string, file: string, opts: Omit = {}) { + return repo(rel(root, file, opts)) + } export function repo(input: string) { return RepoPath.make(repoText(input)) @@ -373,42 +443,59 @@ export namespace Path { return PrettyPath.make(clean(fromURIText(input, pf(opts)), pf(opts))) } - export function eq(a: string, b: string, opts: Opts = {}) { - return key(a, opts) === key(b, opts) - } + export function eq(a: string, b: string, opts: Opts = {}) { + return key(a, opts) === key(b, opts) + } /** * Checks directory containment after normalizing both inputs into the same * platform rules. Relative and absolute paths never match each other. */ - export function contains(parent: string, child: string, opts: Opts = {}) { - const platform = pf(opts) - const mod = lib(platform) - const a = raw(parent, platform) - const b = raw(child, platform) - if (mod.isAbsolute(a) !== mod.isAbsolute(b)) return false - const rel = mod.relative(pretty(parent, opts), pretty(child, opts)) - return rel === "" || (!rel.startsWith("..") && !mod.isAbsolute(rel)) - } + export function contains(parent: string, child: string, opts: Opts = {}) { + const platform = pf(opts) + const mod = lib(platform) + const a = raw(parent, platform) + const b = raw(child, platform) + if (mod.isAbsolute(a) !== mod.isAbsolute(b)) return false + return inside(pretty(parent, opts), pretty(child, opts), platform) + } + + export function overlaps(a: string, b: string, opts: Opts = {}) { + return contains(a, b, opts) || contains(b, a, opts) + } + + /** + * Yields the starting directory and each parent up to `stop` or the + * filesystem root. When `stop` is provided, traversal is bounded to that + * ancestor and includes it. + */ + export function up(input: string, opts: UpOpts = {}) { + return upText(input, opts) + } export function externalGlob(dir: string, opts: Opts = {}) { return `${posix(dir, opts).replace(/\/+$/, "")}/*` } - export function match(input: string, value: PathKey, opts: Opts = {}) { - return key(input, opts) === value - } - - /** - * Rebuilds the path using the filesystem's recorded casing on Windows. + export function match(input: string, value: PathKey, opts: Opts = {}) { + return key(input, opts) === value + } + + export async function physicalKey(input: string, opts: Opts = {}) { + return key(await physicalAsync(input, opts), opts) + } + + /** + * Rebuilds the path using the filesystem's recorded casing on Windows. * * Unlike `physical`, this does not resolve symlinks; it walks segments and * keeps any missing tail as provided once the walk can no longer continue. */ - export async function truecase(input: string, opts: Omit = {}) { - const platform = pf(opts) - const text = pretty(input, opts) - if (platform !== "win32") return text + export async function truecase(input: string, opts: Omit = {}) { + if (sentinel(input)) return PrettyPath.make(input) + const platform = pf(opts) + const text = pretty(input, opts) + if (platform !== "win32") return text const mod = path.win32 const root = mod.parse(text).root @@ -426,12 +513,13 @@ export namespace Path { } return PrettyPath.make(clean(out, platform)) - } - - export function truecaseSync(input: string, opts: Omit = {}) { - const platform = pf(opts) - const text = pretty(input, opts) - if (platform !== "win32") return text + } + + export function truecaseSync(input: string, opts: Omit = {}) { + if (sentinel(input)) return PrettyPath.make(input) + const platform = pf(opts) + const text = pretty(input, opts) + if (platform !== "win32") return text const mod = path.win32 const root = mod.parse(text).root diff --git a/packages/opencode/src/path/schema.ts b/packages/opencode/src/path/schema.ts index 17d8374439fb..facf20d1fc34 100644 --- a/packages/opencode/src/path/schema.ts +++ b/packages/opencode/src/path/schema.ts @@ -1,6 +1,7 @@ import { Schema } from "effect" +import path from "path" -import { withStatics } from "@/util/schema" +import { withStatics, zodFrom } from "@/util/schema" /** * These brands document which path shape a string is expected to already be in. @@ -12,11 +13,64 @@ import { withStatics } from "@/util/schema" // Absolute, normalized, native-separator path used for most internal work. const prettyPathSchema = Schema.String.pipe(Schema.brand("PrettyPath")) +function platform() { + return process.platform === "win32" ? path.win32 : path.posix +} + +function parsePretty(input: string) { + if (!input) throw new TypeError("Expected absolute filesystem path, received empty string") + if (!platform().isAbsolute(input)) { + throw new TypeError(`Expected absolute filesystem path, received "${input}"`) + } + return prettyPathSchema.makeUnsafe(input) +} + +function parseKey(input: string) { + parsePretty(input) + return pathKeySchema.makeUnsafe(input) +} + +function parsePosix(input: string) { + if (!input) throw new TypeError("Expected POSIX path, received empty string") + if (input.includes("\\")) throw new TypeError(`Expected POSIX path without backslashes, received "${input}"`) + return posixPathSchema.makeUnsafe(input) +} + +function parseRelative(input: string) { + if (!input) throw new TypeError("Expected relative path, received empty string") + if (platform().isAbsolute(input)) throw new TypeError(`Expected relative path, received "${input}"`) + return relativePathSchema.makeUnsafe(input) +} + +function parseRepo(input: string) { + if (!input) throw new TypeError("Expected repository path, received empty string") + if (input.includes("\\")) throw new TypeError(`Expected repository path with forward slashes, received "${input}"`) + if (path.posix.isAbsolute(input)) throw new TypeError(`Expected repository-relative path, received "${input}"`) + return repoPathSchema.makeUnsafe(input) +} + +function parseURI(input: string) { + if (!input) throw new TypeError("Expected file URI, received empty string") + let url: URL + try { + url = new URL(input) + } catch { + throw new TypeError(`Expected file URI, received "${input}"`) + } + if (url.protocol !== "file:") throw new TypeError(`Expected file URI, received "${input}"`) + return fileUriSchema.makeUnsafe(input) +} + export type PrettyPath = typeof prettyPathSchema.Type export const PrettyPath = prettyPathSchema.pipe( withStatics((schema: typeof prettyPathSchema) => ({ make: (input: string) => schema.makeUnsafe(input), + parse: parsePretty, + assert: (input: string): asserts input is PrettyPath => { + parsePretty(input) + }, + zod: zodFrom(parsePretty), })), ) @@ -28,6 +82,11 @@ export type PathKey = typeof pathKeySchema.Type export const PathKey = pathKeySchema.pipe( withStatics((schema: typeof pathKeySchema) => ({ make: (input: string) => schema.makeUnsafe(input), + parse: parseKey, + assert: (input: string): asserts input is PathKey => { + parseKey(input) + }, + zod: zodFrom(parseKey), })), ) @@ -39,6 +98,11 @@ export type PosixPath = typeof posixPathSchema.Type export const PosixPath = posixPathSchema.pipe( withStatics((schema: typeof posixPathSchema) => ({ make: (input: string) => schema.makeUnsafe(input), + parse: parsePosix, + assert: (input: string): asserts input is PosixPath => { + parsePosix(input) + }, + zod: zodFrom(parsePosix), })), ) @@ -50,6 +114,11 @@ export type RelativePath = typeof relativePathSchema.Type export const RelativePath = relativePathSchema.pipe( withStatics((schema: typeof relativePathSchema) => ({ make: (input: string) => schema.makeUnsafe(input), + parse: parseRelative, + assert: (input: string): asserts input is RelativePath => { + parseRelative(input) + }, + zod: zodFrom(parseRelative), })), ) @@ -61,6 +130,11 @@ export type RepoPath = typeof repoPathSchema.Type export const RepoPath = repoPathSchema.pipe( withStatics((schema: typeof repoPathSchema) => ({ make: (input: string) => schema.makeUnsafe(input), + parse: parseRepo, + assert: (input: string): asserts input is RepoPath => { + parseRepo(input) + }, + zod: zodFrom(parseRepo), })), ) @@ -72,5 +146,10 @@ export type FileURI = typeof fileUriSchema.Type export const FileURI = fileUriSchema.pipe( withStatics((schema: typeof fileUriSchema) => ({ make: (input: string) => schema.makeUnsafe(input), + parse: parseURI, + assert: (input: string): asserts input is FileURI => { + parseURI(input) + }, + zod: zodFrom(parseURI), })), ) diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index bfa2b495779d..1a1fbbfad0ce 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" +import { zodFrom } from "@/util/schema" import { Newtype } from "@/util/schema" export class PermissionID extends Newtype()("PermissionID", Schema.String) { @@ -9,9 +9,17 @@ export class PermissionID extends Newtype()("PermissionID", Schema return this.makeUnsafe(id) } + static parse(id: string): PermissionID { + return this.makeUnsafe(Identifier.parse("permission", id)) + } + + static assert(id: string) { + Identifier.assert("permission", id) + } + static ascending(id?: string): PermissionID { return this.makeUnsafe(Identifier.ascending("permission", id)) } - static readonly zod = Identifier.schema("permission") as unknown as z.ZodType + static readonly zod = zodFrom((id) => this.parse(id)) } diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 9058ffded1c2..734626d155ab 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -2,7 +2,6 @@ import { GlobalBus } from "@/bus/global" import { disposeInstance } from "@/effect/instance-registry" import { Path } from "@/path/path" import type { PathKey, PrettyPath } from "@/path/schema" -import { Filesystem } from "@/util/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util/log" import { Context } from "../util/context" @@ -21,7 +20,7 @@ const disposal = { all: undefined as Promise | undefined, } -function emit(directory: PrettyPath | string) { +function emit(directory: PrettyPath) { GlobalBus.emit("event", { directory, payload: { @@ -87,10 +86,10 @@ export const Instance = { get current() { return context.use() }, - get directory(): PrettyPath | string { + get directory(): PrettyPath { return context.use().directory }, - get worktree(): PrettyPath | string { + get worktree(): PrettyPath { return context.use().worktree }, get project() { @@ -102,11 +101,11 @@ export const Instance = { * Paths within the worktree but outside the working directory should not trigger external_directory permission. */ containsPath(filepath: string) { - if (Filesystem.contains(Instance.directory, filepath)) return true + if (Path.contains(Instance.directory, filepath)) return true // Non-git projects set worktree to "/" which would match ANY absolute path. // Skip worktree check in this case to preserve external_directory permissions. if (Instance.worktree === "/") return false - return Filesystem.contains(Instance.worktree, filepath) + return Path.contains(Instance.worktree, filepath) }, /** * Captures the current instance ALS context and returns a wrapper that diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index a9372507dba9..f37ec5869cb5 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -21,13 +21,11 @@ import { PrettyPath } from "@/path/schema" export namespace Project { const log = Log.create({ service: "project" }) - function fix(input: string) { - if (!input || input === "/") return PrettyPath.make(input) + function stored(input: string) { return Path.truecaseSync(input) } function same(a: string, b: string) { - if (a === "/" || b === "/") return a === b return Path.eq(a, b) } @@ -35,8 +33,8 @@ export namespace Project { const seen = new Set() const out: PrettyPath[] = [] for (const item of list) { - const dir = fix(item) - const key = dir === "/" ? dir : Path.key(dir) + const dir = stored(item) + const key = Path.key(dir) if (seen.has(key)) continue seen.add(key) out.push(dir) @@ -45,21 +43,19 @@ export namespace Project { } async function gitpath(cwd: string, name: string) { - if (!name) return fix(cwd) + if (!name) return stored(cwd) // git output includes trailing newlines; keep path whitespace intact. name = name.replace(/[\r\n]+$/, "") - if (!name) return fix(cwd) + if (!name) return stored(cwd) - name = Filesystem.windowsPath(name) - - if (path.isAbsolute(name)) return await Path.truecase(name) + if (Path.isAbsolute(name)) return await Path.truecase(name) return await Path.truecase(Path.pretty(name, { cwd })) } export const Info = z .object({ id: ProjectID.zod, - worktree: z.string(), + worktree: PrettyPath.zod, vcs: z.literal("git").optional(), name: z.string().optional(), icon: z @@ -79,15 +75,12 @@ export namespace Project { updated: z.number(), initialized: z.number().optional(), }), - sandboxes: z.array(z.string()), + sandboxes: z.array(PrettyPath.zod), }) .meta({ ref: "Project", }) - export type Info = Omit, "worktree" | "sandboxes"> & { - worktree: PrettyPath - sandboxes: PrettyPath[] - } + export type Info = z.infer export const Event = { Updated: BusEvent.define("project.updated", Info), @@ -101,8 +94,8 @@ export namespace Project { ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined return { - id: ProjectID.make(row.id), - worktree: fix(row.worktree), + id: ProjectID.parse(row.id), + worktree: stored(row.worktree), vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, icon, @@ -119,7 +112,7 @@ export namespace Project { function readCachedId(dir: string) { return Filesystem.readText(path.join(dir, "opencode")) .then((x) => x.trim()) - .then(ProjectID.make) + .then(ProjectID.parse) .catch(() => undefined) } @@ -154,7 +147,7 @@ export namespace Project { .then(async (result) => { const common = await gitpath(sandbox, await result.text()) // Avoid going to parent of sandbox when git-common-dir is empty. - return same(common, sandbox) ? sandbox : fix(path.dirname(common)) + return same(common, sandbox) ? sandbox : stored(path.dirname(common)) }) .catch(() => undefined) @@ -197,7 +190,7 @@ export namespace Project { } } - id = roots[0] ? ProjectID.make(roots[0]) : undefined + id = roots[0] ? ProjectID.parse(roots[0]) : undefined if (id) { // Write to common dir so the cache is shared across worktrees. await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined) @@ -273,7 +266,7 @@ export namespace Project { }, } if (!same(data.sandbox, result.worktree) && !result.sandboxes.some((item) => same(item, data.sandbox))) { - result.sandboxes.push(fix(data.sandbox)) + result.sandboxes.push(stored(data.sandbox)) } result.sandboxes = uniq(result.sandboxes).filter((item) => existsSync(item)) const insert = { @@ -399,7 +392,6 @@ export namespace Project { commands: Info.shape.commands.optional(), }), async (input) => { - const id = ProjectID.make(input.projectID) const result = Database.use((db) => db .update(ProjectTable) @@ -410,7 +402,7 @@ export namespace Project { commands: input.commands, time_updated: Date.now(), }) - .where(eq(ProjectTable.id, id)) + .where(eq(ProjectTable.id, input.projectID)) .returning() .get(), ) @@ -441,7 +433,7 @@ export namespace Project { export async function addSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) - const dir = fix(directory) + const dir = stored(directory) const sandboxes = uniq(row.sandboxes) if (!sandboxes.some((item) => same(item, dir))) sandboxes.push(dir) const result = Database.use((db) => @@ -466,7 +458,7 @@ export namespace Project { export async function removeSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) - const dir = fix(directory) + const dir = stored(directory) const sandboxes = uniq(row.sandboxes).filter((item) => !same(item, dir)) const result = Database.use((db) => db diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index e904ff5a8433..55c35fcb5057 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,7 +1,11 @@ import { Schema } from "effect" -import z from "zod" -import { withStatics } from "@/util/schema" +import { withStatics, zodFrom } from "@/util/schema" + +function parse(id: string) { + if (!id) throw new TypeError("Expected project id, received empty string") + return id +} const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) @@ -11,6 +15,10 @@ export const ProjectID = projectIdSchema.pipe( withStatics((schema: typeof projectIdSchema) => ({ global: schema.makeUnsafe("global"), make: (id: string) => schema.makeUnsafe(id), - zod: z.string().pipe(z.custom()), + parse: (id: string) => schema.makeUnsafe(parse(id)), + assert: (id: string): asserts id is ProjectID => { + parse(id) + }, + zod: zodFrom((id) => schema.makeUnsafe(parse(id))), })), ) diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index 4650705b17d7..18b085c3a1d7 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -11,7 +11,7 @@ export namespace State { const log = Log.create({ service: "state" }) const recordsByKey = new Map>() - export function create(root: () => PrettyPath | string, init: () => S, dispose?: (state: Awaited) => Promise) { + export function create(root: () => PrettyPath, init: () => S, dispose?: (state: Awaited) => Promise) { return () => { const key = Path.key(root()) let entries = recordsByKey.get(key) @@ -30,7 +30,7 @@ export namespace State { } } - export async function dispose(directory: PrettyPath | string) { + export async function dispose(directory: PrettyPath) { const key = Path.key(directory) const entries = recordsByKey.get(key) if (!entries) return diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index 71c8a1029cd3..c4bc9497ce23 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,7 +1,11 @@ import { Schema } from "effect" -import z from "zod" -import { withStatics } from "@/util/schema" +import { withStatics, zodFrom } from "@/util/schema" + +function parse(id: string, label: string) { + if (!id) throw new TypeError(`Expected ${label}, received empty string`) + return id +} const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) @@ -10,7 +14,11 @@ export type ProviderID = typeof providerIdSchema.Type export const ProviderID = providerIdSchema.pipe( withStatics((schema: typeof providerIdSchema) => ({ make: (id: string) => schema.makeUnsafe(id), - zod: z.string().pipe(z.custom()), + parse: (id: string) => schema.makeUnsafe(parse(id, "provider id")), + assert: (id: string): asserts id is ProviderID => { + parse(id, "provider id") + }, + zod: zodFrom((id) => schema.makeUnsafe(parse(id, "provider id"))), // Well-known providers opencode: schema.makeUnsafe("opencode"), anthropic: schema.makeUnsafe("anthropic"), @@ -33,6 +41,10 @@ export type ModelID = typeof modelIdSchema.Type export const ModelID = modelIdSchema.pipe( withStatics((schema: typeof modelIdSchema) => ({ make: (id: string) => schema.makeUnsafe(id), - zod: z.string().pipe(z.custom()), + parse: (id: string) => schema.makeUnsafe(parse(id, "model id")), + assert: (id: string): asserts id is ModelID => { + parse(id, "model id") + }, + zod: zodFrom((id) => schema.makeUnsafe(parse(id, "model id"))), })), ) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 47b3196f0bd0..8f21e01edf99 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,5 +1,4 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" import { withStatics } from "@/util/schema" @@ -11,7 +10,11 @@ export type PtyID = typeof ptyIdSchema.Type export const PtyID = ptyIdSchema.pipe( withStatics((schema: typeof ptyIdSchema) => ({ make: (id: string) => schema.makeUnsafe(id), + parse: (id: string) => schema.makeUnsafe(Identifier.parse("pty", id)), + assert: (id: string): asserts id is PtyID => { + Identifier.assert("pty", id) + }, ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("pty", id)), - zod: Identifier.schema("pty").pipe(z.custom()), + zod: Identifier.schema("pty"), })), ) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 38b930af11d5..657a8e757a5f 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" +import { zodFrom } from "@/util/schema" import { Newtype } from "@/util/schema" export class QuestionID extends Newtype()("QuestionID", Schema.String) { @@ -9,9 +9,17 @@ export class QuestionID extends Newtype()("QuestionID", Schema.Strin return this.makeUnsafe(id) } + static parse(id: string): QuestionID { + return this.makeUnsafe(Identifier.parse("question", id)) + } + + static assert(id: string) { + Identifier.assert("question", id) + } + static ascending(id?: string): QuestionID { return this.makeUnsafe(Identifier.ascending("question", id)) } - static readonly zod = Identifier.schema("question") as unknown as z.ZodType + static readonly zod = zodFrom((id) => this.parse(id)) } diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 43be6f245e0e..c627ad85ee71 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -72,13 +72,13 @@ export const ExperimentalRoutes = lazy(() => validator( "query", z.object({ - provider: z.string(), - model: z.string(), + provider: ProviderID.zod, + model: ModelID.zod, }), ), async (c) => { const { provider, model } = c.req.valid("query") - const tools = await ToolRegistry.tools({ providerID: ProviderID.make(provider), modelID: ModelID.make(model) }) + const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }) return c.json( tools.map((t) => ({ id: t.id, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 2542c3c6f43b..ae127406aa51 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -50,6 +50,12 @@ import { lazy } from "@/util/lazy" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false +function badInput(err: unknown) { + return new HTTPException(400, { + message: err instanceof Error ? err.message : "Invalid request", + }) +} + export namespace Server { const log = Log.create({ service: "server" }) const os = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux" @@ -196,19 +202,25 @@ export namespace Server { .use(async (c, next) => { if (c.req.path === "/log") return next() const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Path.pretty( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) + const rawDirectory = c.req.query("directory") || c.req.header("x-opencode-directory") + const workspaceID = (() => { + try { + return rawWorkspaceID ? WorkspaceID.parse(rawWorkspaceID) : undefined + } catch (err) { + throw badInput(err) + } + })() + const directory = (() => { + try { + if (!rawDirectory) return Path.pretty(process.cwd()) + return Path.from(rawDirectory, { encoded: true, label: "directory parameter" }) + } catch (err) { + throw badInput(err) + } + })() return WorkspaceContext.provide({ - workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined, + workspaceID, async fn() { return Instance.provide({ directory, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index bc8d7eba73aa..a4ab867b175c 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -41,11 +41,6 @@ export namespace Session { const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " - function fix(input: string) { - if (!input) return PrettyPath.make(input) - return Path.truecaseSync(input) - } - function createDefaultTitle(isChild = false) { return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() } @@ -76,7 +71,7 @@ export namespace Session { slug: row.slug, projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, - directory: fix(row.directory), + directory: Path.truecaseSync(row.directory), parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -93,14 +88,14 @@ export namespace Session { } } - export function toRow(info: z.output | Info): SessionInsert { + export function toRow(info: Info): SessionInsert { return { id: info.id, project_id: info.projectID, workspace_id: info.workspaceID, parent_id: info.parentID, slug: info.slug, - directory: fix(info.directory), + directory: Path.truecaseSync(info.directory), title: info.title, version: info.version, share_url: info.share?.url, @@ -133,7 +128,7 @@ export namespace Session { slug: z.string(), projectID: ProjectID.zod, workspaceID: WorkspaceID.zod.optional(), - directory: z.string(), + directory: PrettyPath.zod, parentID: SessionID.zod.optional(), summary: z .object({ @@ -169,22 +164,18 @@ export namespace Session { .meta({ ref: "Session", }) - export type Info = Omit, "directory"> & { - directory: PrettyPath - } + export type Info = z.output export const ProjectInfo = z .object({ id: ProjectID.zod, name: z.string().optional(), - worktree: z.string(), + worktree: PrettyPath.zod, }) .meta({ ref: "ProjectSummary", }) - export type ProjectInfo = Omit, "worktree"> & { - worktree: PrettyPath - } + export type ProjectInfo = z.output export const GlobalInfo = Info.extend({ project: ProjectInfo.nullable(), @@ -313,7 +304,7 @@ export namespace Session { title?: string parentID?: SessionID workspaceID?: WorkspaceID - directory: string + directory: PrettyPath permission?: PermissionNext.Ruleset }) { const dir = await Path.truecase(input.directory) @@ -562,7 +553,7 @@ export namespace Session { }) { const project = Instance.project const conditions = [eq(SessionTable.project_id, project.id)] - const dir = input?.directory ? fix(input.directory) : undefined + const dir = input?.directory ? Path.truecaseSync(input.directory) : undefined if (dir) { conditions.push(eq(SessionTable.directory, dir)) } @@ -601,7 +592,7 @@ export namespace Session { archived?: boolean }) { const conditions: SQL[] = [] - const dir = input?.directory ? fix(input.directory) : undefined + const dir = input?.directory ? Path.truecaseSync(input.directory) : undefined if (dir) { conditions.push(eq(SessionTable.directory, dir)) } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 27439c2e9a5b..8d201eae6353 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -50,20 +50,20 @@ export namespace InstructionPrompt { } }) - function isClaimed(messageID: string, filepath: PrettyPath | string) { + function isClaimed(messageID: string, filepath: PathKey) { const claimed = state().claims.get(messageID) if (!claimed) return false - return claimed.has(Path.key(filepath)) + return claimed.has(filepath) } - function claim(messageID: string, filepath: PrettyPath | string) { + function claim(messageID: string, filepath: PathKey) { const current = state() let claimed = current.claims.get(messageID) if (!claimed) { claimed = new Set() current.claims.set(messageID, claimed) } - claimed.add(Path.key(filepath)) + claimed.add(filepath) } export function clear(messageID: string) { @@ -72,7 +72,7 @@ export namespace InstructionPrompt { export async function systemPaths() { const config = await Config.get() - const paths = new Set() + const paths = new Set() if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { for (const file of FILES) { @@ -142,7 +142,7 @@ export namespace InstructionPrompt { } export function loaded(messages: MessageV2.WithParts[]) { - const paths = new Set() + const paths = new Set() for (const msg of messages) { for (const part of msg.parts) { if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") { @@ -150,7 +150,7 @@ export namespace InstructionPrompt { const loaded = part.state.metadata?.loaded if (!loaded || !Array.isArray(loaded)) continue for (const p of loaded) { - if (typeof p === "string") paths.add(Path.pretty(p)) + if (typeof p === "string") paths.add(Path.key(p)) } } } @@ -165,27 +165,28 @@ export namespace InstructionPrompt { } } - export async function resolve(messages: MessageV2.WithParts[], filepath: PrettyPath | string, messageID: string) { - const system = new Set(Array.from(await systemPaths(), (item) => Filesystem.resolve(item))) - const already = new Set(Array.from(loaded(messages), (item) => Filesystem.resolve(item))) - const results: { filepath: string; content: string }[] = [] - - const target = Filesystem.resolve(filepath) - let current = path.dirname(target) - const root = Filesystem.resolve(Instance.directory) - - while (Filesystem.contains(root, current) && !Path.eq(current, root)) { - const found = await find(current) - const hit = found ? Filesystem.resolve(found) : undefined - - if (hit && !Path.eq(hit, target) && !system.has(hit) && !already.has(hit) && !isClaimed(messageID, hit)) { - claim(messageID, hit) - const content = await Filesystem.readText(hit).catch(() => undefined) - if (content) { - results.push({ filepath: Path.pretty(hit), content: "Instructions from: " + hit + "\n" + content }) - } - } - current = path.dirname(current) + export async function resolve(messages: MessageV2.WithParts[], filepath: PrettyPath, messageID: string) { + const system = new Set(Array.from(await systemPaths(), (item) => Path.key(item))) + const already = loaded(messages) + const results: { filepath: PrettyPath; content: string }[] = [] + + const target = Path.truecaseSync(filepath) + const targetKey = Path.key(target) + const root = Path.truecaseSync(Instance.directory) + const rootKey = Path.key(root) + + for (const dir of Path.up(Path.parent(target), { stop: root })) { + if (Path.key(dir) === rootKey) break + const found = await find(dir) + if (!found) continue + const hit = Path.truecaseSync(found) + const hitKey = Path.key(hit) + + if (hitKey === targetKey || system.has(hitKey) || already.has(hitKey) || isClaimed(messageID, hitKey)) continue + claim(messageID, hitKey) + const content = await Filesystem.readText(hit).catch(() => undefined) + if (!content) continue + results.push({ filepath: Path.pretty(hit), content: "Instructions from: " + hit + "\n" + content }) } return results diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 8dd40b5ab46a..2503e8407425 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -11,12 +11,13 @@ import { MessageTable, PartTable, SessionTable } from "./session.sql" import { ProviderTransform } from "@/provider/transform" import { STATUS_CODES } from "http" import { Storage } from "@/storage/storage" -import type { FileURI, PrettyPath } from "@/path/schema" +import { PrettyPath, type FileURI } from "@/path/schema" import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" import { type SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" +import { Path } from "@/path/path" export namespace MessageV2 { export function isMedia(mime: string) { @@ -146,26 +147,22 @@ export namespace MessageV2 { export const FileSource = FilePartSourceBase.extend({ type: z.literal("file"), - path: z.string(), + path: PrettyPath.zod, }).meta({ ref: "FileSource", }) - export type FileSource = Omit, "path"> & { - path: PrettyPath - } + export type FileSource = z.infer export const SymbolSource = FilePartSourceBase.extend({ type: z.literal("symbol"), - path: z.string(), + path: PrettyPath.zod, range: LSP.Range, name: z.string(), kind: z.number().int(), }).meta({ ref: "SymbolSource", }) - export type SymbolSource = Omit, "path"> & { - path: PrettyPath - } + export type SymbolSource = z.infer export const ResourceSource = FilePartSourceBase.extend({ type: z.literal("resource"), @@ -431,8 +428,8 @@ export namespace MessageV2 { mode: z.string(), agent: z.string(), path: z.object({ - cwd: z.string(), - root: z.string(), + cwd: PrettyPath.zod, + root: PrettyPath.zod, }), summary: z.boolean().optional(), cost: z.number(), @@ -452,12 +449,7 @@ export namespace MessageV2 { }).meta({ ref: "AssistantMessage", }) - export type Assistant = Omit, "path"> & { - path: { - cwd: PrettyPath | string - root: PrettyPath | string - } - } + export type Assistant = z.infer export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({ ref: "Message", @@ -525,20 +517,58 @@ export namespace MessageV2 { }, } - const info = (row: typeof MessageTable.$inferSelect) => - ({ - ...row.data, + function assistantPath(input: MessageV2.Assistant["path"]) { + return { + cwd: Path.from(input.cwd, { label: "assistant cwd" }), + root: Path.from(input.root, { label: "assistant root" }), + } + } + + function partSource(input: MessageV2.FilePartSource): MessageV2.FilePartSource { + if (input.type === "resource") return input + return { + ...input, + path: Path.from(input.path, { label: `${input.type} source path` }), + } + } + + const info = (row: typeof MessageTable.$inferSelect): MessageV2.Info => { + const data = row.data as MessageV2.Info + if (data.role === "user") { + return { + ...data, + id: row.id, + sessionID: row.session_id, + } + } + + return { + ...data, id: row.id, sessionID: row.session_id, - }) as MessageV2.Info + path: assistantPath(data.path), + } + } - const part = (row: typeof PartTable.$inferSelect) => - ({ - ...row.data, + const part = (row: typeof PartTable.$inferSelect): MessageV2.Part => { + const data = row.data as MessageV2.Part + if (data.type !== "file" || !data.source) { + return { + ...data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } + } + + return { + ...data, id: row.id, sessionID: row.session_id, messageID: row.message_id, - }) as MessageV2.Part + source: partSource(data.source), + } + } const older = (row: Cursor) => or( @@ -869,9 +899,7 @@ export namespace MessageV2 { const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), ) - return rows.map( - (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part, - ) + return rows.map(part) }) export const get = fn( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a4803caf259c..a0a754b18251 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -32,7 +32,6 @@ import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" import { $ } from "bun" -import { fileURLToPath } from "url" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" @@ -991,7 +990,7 @@ export namespace SessionPrompt { type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never const assign = (part: Draft): MessageV2.Part => ({ ...part, - id: part.id ? PartID.make(part.id) : PartID.ascending(), + id: part.id ? PartID.parse(part.id) : PartID.ascending(), }) const parts = await Promise.all( @@ -1093,9 +1092,7 @@ export namespace SessionPrompt { break case "file:": log.info("file", { mime: part.mime }) - // have to normalize, symbol search returns absolute paths - // Decode the pathname since URL constructor doesn't automatically decode it - const filepath = fileURLToPath(part.url) + const filepath = Path.fromURI(part.url) const s = Filesystem.stat(filepath) if (s?.isDirectory()) { diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 540643c49186..54e8611608ec 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,5 +1,4 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" import { withStatics } from "@/util/schema" @@ -8,8 +7,12 @@ export const SessionID = Schema.String.pipe( Schema.brand("SessionID"), withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id), + parse: (id: string) => s.makeUnsafe(Identifier.parse("session", id)), + assert: (id: string): asserts id is Schema.Schema.Type => { + Identifier.assert("session", id) + }, descending: (id?: string) => s.makeUnsafe(Identifier.descending("session", id)), - zod: Identifier.schema("session").pipe(z.custom>()), + zod: Identifier.schema>("session"), })), ) @@ -19,8 +22,12 @@ export const MessageID = Schema.String.pipe( Schema.brand("MessageID"), withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id), + parse: (id: string) => s.makeUnsafe(Identifier.parse("message", id)), + assert: (id: string): asserts id is Schema.Schema.Type => { + Identifier.assert("message", id) + }, ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("message", id)), - zod: Identifier.schema("message").pipe(z.custom>()), + zod: Identifier.schema>("message"), })), ) @@ -30,8 +37,12 @@ export const PartID = Schema.String.pipe( Schema.brand("PartID"), withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id), + parse: (id: string) => s.makeUnsafe(Identifier.parse("part", id)), + assert: (id: string): asserts id is Schema.Schema.Type => { + Identifier.assert("part", id) + }, ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("part", id)), - zod: Identifier.schema("part").pipe(z.custom>()), + zod: Identifier.schema>("part"), })), ) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 093629ac7360..9fa262531041 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -13,6 +13,7 @@ import { LSP } from "../lsp" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Path } from "@/path/path" +import type { PrettyPath } from "@/path/schema" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -45,11 +46,11 @@ export const ApplyPatchTool = Tool.define("apply_patch", { // Validate file paths and check permissions const fileChanges: Array<{ - filePath: string + filePath: PrettyPath oldContent: string newContent: string type: "add" | "update" | "delete" | "move" - movePath?: string + movePath?: PrettyPath diff: string additions: number deletions: number diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 671fa000ecef..195158956124 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -123,7 +123,7 @@ export const EditTool = Tool.define("edit", { }) const filediff: Snapshot.FileDiff = { - file: Path.repo(Path.rel(Instance.directory, filePath)), + file: Path.repoFrom(Instance.directory, filePath), before: contentOld, after: contentNew, additions: 0, diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 93f0f9a71f75..34d094e7debe 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,5 +1,4 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" import { withStatics } from "@/util/schema" @@ -11,7 +10,11 @@ export type ToolID = typeof toolIdSchema.Type export const ToolID = toolIdSchema.pipe( withStatics((schema: typeof toolIdSchema) => ({ make: (id: string) => schema.makeUnsafe(id), + parse: (id: string) => schema.makeUnsafe(Identifier.parse("tool", id)), + assert: (id: string): asserts id is ToolID => { + Identifier.assert("tool", id) + }, ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("tool", id)), - zod: Identifier.schema("tool").pipe(z.custom()), + zod: Identifier.schema("tool"), })), ) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 14ecea107589..6c73da3fbfd3 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,7 +4,6 @@ import z from "zod" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" -import { Identifier } from "../id/id" import { Agent } from "../agent/agent" import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" @@ -16,8 +15,7 @@ const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), prompt: z.string().describe("The task for the agent to perform"), subagent_type: z.string().describe("The type of specialized agent to use for this task"), - task_id: z - .string() + task_id: SessionID.zod .describe( "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", ) @@ -66,7 +64,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { const session = await iife(async () => { if (params.task_id) { - const found = await Session.get(SessionID.make(params.task_id)).catch(() => {}) + const found = await Session.get(params.task_id).catch(() => {}) if (found) return found } diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index e06a9bb5440b..5763083f5ba2 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,7 +1,6 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" -import { realpathSync } from "fs" import { dirname, join } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" @@ -100,84 +99,30 @@ export namespace Filesystem { return lookup(p) || "application/octet-stream" } - /** - * On Windows, normalize a path to its canonical casing using the filesystem. - * This is needed because Windows paths are case-insensitive but LSP servers - * may return paths with different casing than what we send them. - */ - export function normalizePath(p: string): string { - if (process.platform !== "win32") return p - try { - return realpathSync.native(p) - } catch { - return p - } - } - - // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. - // Keep logical alias roots stable while best-effort true-casing on Windows. - export function resolve(p: string): string { - return Path.truecaseSync(p) - } - - export function windowsPath(p: string): string { - if (process.platform !== "win32") return p - return ( - p - .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - // Git Bash for Windows paths are typically //... - .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - // Cygwin git paths are typically /cygdrive//... - .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - // WSL paths are typically /mnt//... - .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) - ) - } - - export function overlaps(a: string, b: string) { - return Path.contains(a, b) || Path.contains(b, a) - } - - export function contains(parent: string, child: string) { - return Path.contains(parent, child) - } - export async function findUp(target: string, start: string, stop?: string) { - let current = start const result = [] - while (true) { - const search = join(current, target) + for (const dir of Path.up(start, { stop })) { + const search = join(dir, target) if (await exists(search)) result.push(search) - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent } return result } export async function* up(options: { targets: string[]; start: string; stop?: string }) { - const { targets, start, stop } = options - let current = start - while (true) { - for (const target of targets) { - const search = join(current, target) + for (const dir of Path.up(options.start, { stop: options.stop })) { + for (const target of options.targets) { + const search = join(dir, target) if (await exists(search)) yield search } - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent } } export async function globUp(pattern: string, start: string, stop?: string) { - let current = start const result = [] - while (true) { + for (const dir of Path.up(start, { stop })) { try { const matches = await Glob.scan(pattern, { - cwd: current, + cwd: dir, absolute: true, include: "file", dot: true, @@ -186,10 +131,6 @@ export namespace Filesystem { } catch { // Skip invalid glob patterns } - if (stop === current) break - const parent = dirname(current) - if (parent === current) break - current = parent } return result } diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 6a88dba539b5..fb2eb76e5ce5 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import z from "zod" /** * Attach static methods to a schema object. Designed to be used with `.pipe()`: @@ -16,6 +17,20 @@ export const withStatics = (schema: S): S & M => Object.assign(schema, methods(schema)) +export function zodFrom(parse: (input: string) => T): z.ZodType { + return z.string().transform((input, ctx) => { + try { + return parse(input) + } catch (err) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: err instanceof Error ? err.message : String(err), + }) + return z.NEVER + } + }) +} + declare const NewtypeBrand: unique symbol type NewtypeBrand = { readonly [NewtypeBrand]: Tag } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 134aab07bc92..0f827dde20e0 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -16,7 +16,7 @@ import { git } from "../util/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Path } from "@/path/path" -import type { PathKey, PrettyPath } from "@/path/schema" +import { PrettyPath } from "@/path/schema" export namespace Worktree { const log = Log.create({ service: "worktree" }) @@ -41,15 +41,13 @@ export namespace Worktree { .object({ name: z.string(), branch: z.string(), - directory: z.string(), + directory: PrettyPath.zod, }) .meta({ ref: "Worktree", }) - export type Info = Omit, "directory"> & { - directory: PrettyPath | string - } + export type Info = z.infer export const CreateInput = z .object({ @@ -73,9 +71,7 @@ export namespace Worktree { ref: "WorktreeRemoveInput", }) - export type RemoveInput = Omit, "directory"> & { - directory: PrettyPath | string - } + export type RemoveInput = z.infer export const ResetInput = z .object({ @@ -85,9 +81,7 @@ export namespace Worktree { ref: "WorktreeResetInput", }) - export type ResetInput = Omit, "directory"> & { - directory: PrettyPath | string - } + export type ResetInput = z.infer export const NotGitError = NamedError.create( "WorktreeNotGitError", @@ -245,11 +239,40 @@ export namespace Worktree { ) } - async function prune(root: string, entries: string[]) { + type Entry = { + path: PrettyPath + branch?: string + } + + function entries(stdout: Uint8Array | undefined) { + return outputText(stdout) + .split("\n") + .map((line) => line.trim()) + .reduce((acc, line) => { + if (!line) return acc + if (line.startsWith("worktree ")) { + acc.push({ path: Path.from(line.slice("worktree ".length).trim(), { label: "git worktree path" }) }) + return acc + } + const item = acc[acc.length - 1] + if (!item) return acc + if (line.startsWith("branch ")) item.branch = line.slice("branch ".length).trim() + return acc + }, []) + } + + async function locate(stdout: Uint8Array | undefined, target: PrettyPath) { + const key = await Path.physicalKey(target) + for (const item of entries(stdout)) { + if ((await Path.physicalKey(item.path)) === key) return item + } + } + + async function prune(root: PrettyPath, entries: string[]) { const base = await Path.physical(root) await Promise.all( entries.map(async (entry) => { - const target = await Path.physical(path.resolve(root, entry)) + const target = await Path.physical(Path.join(root, entry)) if (Path.eq(target, base)) return if (!Path.contains(base, target)) return await fs.rm(target, { recursive: true, force: true }).catch(() => undefined) @@ -257,7 +280,7 @@ export namespace Worktree { ) } - async function sweep(root: string) { + async function sweep(root: PrettyPath) { const first = await git(["clean", "-ffdx"], { cwd: root }) if (first.exitCode === 0) return first @@ -268,15 +291,11 @@ export namespace Worktree { return git(["clean", "-ffdx"], { cwd: root }) } - async function key(input: string): Promise { - return Path.key(await Path.physical(input)) - } - - async function candidate(root: string, base?: string) { + async function candidate(root: PrettyPath, base?: string) { for (const attempt of Array.from({ length: 26 }, (_, i) => i)) { const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName() const branch = `opencode/${name}` - const directory = Path.pretty(path.join(root, name)) + const directory = Path.join(root, name) if (await exists(directory)) continue @@ -286,7 +305,7 @@ export namespace Worktree { }) if (branchCheck.exitCode === 0) continue - return Info.parse({ name, branch, directory }) as Info + return { name, branch, directory } } throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) @@ -345,7 +364,7 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const root = path.join(Global.Path.data, "worktree", Instance.project.id) + const root = Path.join(Global.Path.data, path.join("worktree", Instance.project.id)) await fs.mkdir(root, { recursive: true }) const base = name ? slug(name) : "" @@ -441,33 +460,8 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const target = await Path.physical(input.directory) - const directory = Path.key(target) - const locate = async (stdout: Uint8Array | undefined) => { - const lines = outputText(stdout) - .split("\n") - .map((line) => line.trim()) - const entries = lines.reduce<{ path?: PrettyPath; branch?: string }[]>((acc, line) => { - if (!line) return acc - if (line.startsWith("worktree ")) { - acc.push({ path: Path.pretty(line.slice("worktree ".length).trim()) }) - return acc - } - const current = acc[acc.length - 1] - if (!current) return acc - if (line.startsWith("branch ")) { - current.branch = line.slice("branch ".length).trim() - } - return acc - }, []) - - return (async () => { - for (const item of entries) { - if (!item.path) continue - if ((await key(item.path)) === directory) return item - } - })() - } + const directory = Path.from(input.directory, { label: "worktree directory" }) + const target = await Path.physical(directory) const clean = (target: PrettyPath) => fs @@ -492,9 +486,9 @@ export namespace Worktree { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } - const entry = await locate(list.stdout) + const entry = await locate(list.stdout, target) - if (!entry?.path) { + if (!entry) { const directoryExists = await exists(target) if (directoryExists) { await stop(target) @@ -515,8 +509,8 @@ export namespace Worktree { }) } - const stale = await locate(next.stdout) - if (stale?.path) { + const stale = await locate(next.stdout, target) + if (stale) { throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" }) } } @@ -539,9 +533,10 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const directory = await key(input.directory) - const primary = await key(Instance.worktree) - if (directory === primary) { + const directory = Path.from(input.directory, { label: "worktree directory" }) + const targetKey = await Path.physicalKey(directory) + const primary = await Path.physicalKey(Instance.worktree) + if (targetKey === primary) { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } @@ -550,30 +545,8 @@ export namespace Worktree { throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } - const lines = outputText(list.stdout) - .split("\n") - .map((line) => line.trim()) - const entries = lines.reduce<{ path?: PrettyPath; branch?: string }[]>((acc, line) => { - if (!line) return acc - if (line.startsWith("worktree ")) { - acc.push({ path: Path.pretty(line.slice("worktree ".length).trim()) }) - return acc - } - const current = acc[acc.length - 1] - if (!current) return acc - if (line.startsWith("branch ")) { - current.branch = line.slice("branch ".length).trim() - } - return acc - }, []) - - const entry = await (async () => { - for (const item of entries) { - if (!item.path) continue - if ((await key(item.path)) === directory) return item - } - })() - if (!entry?.path) { + const entry = await locate(list.stdout, directory) + if (!entry) { throw new ResetFailedError({ message: "Worktree not found" }) } @@ -623,10 +596,6 @@ export namespace Worktree { } } - if (!entry.path) { - throw new ResetFailedError({ message: "Worktree path not found" }) - } - const worktreePath = entry.path const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath }) diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts index 7a5fa6b8f1cf..958bd2c3a649 100644 --- a/packages/opencode/test/cli/tui/transcript.test.ts +++ b/packages/opencode/test/cli/tui/transcript.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Path } from "../../../src/path/path" import { formatAssistantHeader, formatMessage, @@ -18,7 +19,7 @@ describe("transcript", () => { providerID: "anthropic", mode: "", parentID: "msg_parent", - path: { cwd: "/test", root: "/test" }, + path: { cwd: Path.pretty("/test"), root: Path.pretty("/test") }, cost: 0.001, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1000000, completed: 1005400 }, @@ -223,7 +224,7 @@ describe("transcript", () => { providerID: "anthropic", mode: "", parentID: "msg_parent", - path: { cwd: "/test", root: "/test" }, + path: { cwd: Path.pretty("/test"), root: Path.pretty("/test") }, cost: 0.001, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1000000, completed: 1005400 }, @@ -264,7 +265,7 @@ describe("transcript", () => { providerID: "anthropic", mode: "", parentID: "msg_1", - path: { cwd: "/test", root: "/test" }, + path: { cwd: Path.pretty("/test"), root: Path.pretty("/test") }, cost: 0.001, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1000000000100, completed: 1000000000600 }, @@ -302,7 +303,7 @@ describe("transcript", () => { providerID: "anthropic", mode: "", parentID: "msg_0", - path: { cwd: "/test", root: "/test" }, + path: { cwd: Path.pretty("/test"), root: Path.pretty("/test") }, cost: 0.001, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1000000000100, completed: 1000000000600 }, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index ceb4aa46e9c6..e881ca5894d3 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -47,7 +47,7 @@ async function check(map: (dir: string) => string) { fn: async () => { const cfg = await Config.get() expect(cfg.snapshot).toBe(true) - expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) + expect(Instance.directory).toBe(Path.truecaseSync(tmp.path)) expect(Instance.project.id).not.toBe(ProjectID.global) }, }) diff --git a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts index c57d7eb2eb31..8575bee44788 100644 --- a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts +++ b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts @@ -9,7 +9,7 @@ import { WorkspaceContext } from "../../src/control-plane/workspace-context" import { Database } from "../../src/storage/db" import { resetDatabase } from "../fixture/db" import * as adaptors from "../../src/control-plane/adaptors" -import type { Adaptor } from "../../src/control-plane/types" +import type { Adaptor, WorkspaceFetchInput } from "../../src/control-plane/types" import { Flag } from "../../src/flag/flag" import { PrettyPath } from "../../src/path/schema" @@ -44,7 +44,7 @@ async function setup(state: State) { }, async remove() {}, - async fetch(_config: unknown, input: RequestInfo | URL, init?: RequestInit) { + async fetch(_config: unknown, input: WorkspaceFetchInput, init?: RequestInit) { const url = input instanceof Request || input instanceof URL ? input.toString() diff --git a/packages/opencode/test/control-plane/workspace-server-sse.test.ts b/packages/opencode/test/control-plane/workspace-server-sse.test.ts index a5583b2380b8..6a287fa23f74 100644 --- a/packages/opencode/test/control-plane/workspace-server-sse.test.ts +++ b/packages/opencode/test/control-plane/workspace-server-sse.test.ts @@ -109,4 +109,17 @@ describe("control-plane/workspace-server SSE", () => { await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) } }) + + test("rejects invalid workspace ids before bootstrapping", async () => { + const app = WorkspaceServer.App() + const response = await app.request("/event", { + headers: { + "x-opencode-workspace": "workspace_test_workspace", + "x-opencode-directory": process.cwd(), + }, + }) + + expect(response.status).toBe(400) + expect(await response.text()).toContain('Expected workspace id starting with "wrk"') + }) }) diff --git a/packages/opencode/test/control-plane/workspace-sync.test.ts b/packages/opencode/test/control-plane/workspace-sync.test.ts index af091b141c95..b17289b25dbc 100644 --- a/packages/opencode/test/control-plane/workspace-sync.test.ts +++ b/packages/opencode/test/control-plane/workspace-sync.test.ts @@ -8,7 +8,7 @@ import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import { GlobalBus } from "../../src/bus/global" import { resetDatabase } from "../fixture/db" import * as adaptors from "../../src/control-plane/adaptors" -import type { Adaptor } from "../../src/control-plane/types" +import type { Adaptor, WorkspaceFetchInput } from "../../src/control-plane/types" import { PrettyPath } from "../../src/path/schema" afterEach(async () => { @@ -28,7 +28,7 @@ const TestAdaptor: Adaptor = { throw new Error("not used") }, async remove() {}, - async fetch(_config: unknown, _input: RequestInfo | URL, _init?: RequestInit) { + async fetch(_config: unknown, _input: WorkspaceFetchInput, _init?: RequestInit) { const body = new ReadableStream({ start(controller) { const encoder = new TextEncoder() diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 6826c9a90381..2073974687be 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -1,52 +1,52 @@ import { test, expect, describe } from "bun:test" import path from "path" import fs from "fs/promises" -import { Filesystem } from "../../src/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { Path } from "../../src/path/path" import { tmpdir } from "../fixture/fixture" async function link(target: string, alias: string) { await fs.symlink(target, alias, process.platform === "win32" ? "junction" : "dir") } -describe("Filesystem.contains", () => { +describe("Path.contains", () => { test("allows paths within project", () => { - expect(Filesystem.contains("/project", "/project/src")).toBe(true) - expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) - expect(Filesystem.contains("/project", "/project")).toBe(true) + expect(Path.contains("/project", "/project/src")).toBe(true) + expect(Path.contains("/project", "/project/src/file.ts")).toBe(true) + expect(Path.contains("/project", "/project")).toBe(true) }) test("blocks ../ traversal", () => { - expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) - expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) - expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + expect(Path.contains("/project", "/project/../etc")).toBe(false) + expect(Path.contains("/project", "/project/src/../../etc")).toBe(false) + expect(Path.contains("/project", "/etc/passwd")).toBe(false) }) test("blocks absolute paths outside project", () => { - expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) - expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) - expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) + expect(Path.contains("/project", "/etc/passwd")).toBe(false) + expect(Path.contains("/project", "/tmp/file")).toBe(false) + expect(Path.contains("/home/user/project", "/home/user/other")).toBe(false) }) test("handles prefix collision edge cases", () => { - expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) - expect(Filesystem.contains("/project", "/projectfile")).toBe(false) + expect(Path.contains("/project", "/project-other/file")).toBe(false) + expect(Path.contains("/project", "/projectfile")).toBe(false) }) test("blocks absolute-relative path mixes", () => { const abs = process.platform === "win32" ? "C:\\project" : "/project" const rel = process.platform === "win32" ? "project\\src\\file.ts" : "project/src/file.ts" - expect(Filesystem.contains(abs, rel)).toBe(false) - expect(Filesystem.contains(rel, path.join(abs, "src", "file.ts"))).toBe(false) + expect(Path.contains(abs, rel)).toBe(false) + expect(Path.contains(rel, path.join(abs, "src", "file.ts"))).toBe(false) }) test("blocks different roots", () => { if (process.platform !== "win32") return - expect(Filesystem.contains("C:\\project", "D:\\project\\file.ts")).toBe(false) - expect(Filesystem.contains("C:\\project", "\\\\server\\share\\file.ts")).toBe(false) + expect(Path.contains("C:\\project", "D:\\project\\file.ts")).toBe(false) + expect(Path.contains("C:\\project", "\\\\server\\share\\file.ts")).toBe(false) }) }) diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index fbf8d5cd1e22..99d2633eb486 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { FileTime } from "../../src/file/time" import { Instance } from "../../src/project/instance" import { SessionID } from "../../src/session/schema" +import { Path } from "../../src/path/path" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -34,12 +35,12 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const before = await FileTime.get(sessionID, filepath) + const before = await FileTime.get(sessionID, Path.pretty(filepath)) expect(before).toBeUndefined() - await FileTime.read(sessionID, filepath) + await FileTime.read(sessionID, Path.pretty(filepath)) - const after = await FileTime.get(sessionID, filepath) + const after = await FileTime.get(sessionID, Path.pretty(filepath)) expect(after).toBeInstanceOf(Date) expect(after!.getTime()).toBeGreaterThan(0) }, @@ -54,11 +55,11 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath) - await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath) + await FileTime.read(SessionID.make("ses_00000000000000000000000002"), Path.pretty(filepath)) + await FileTime.read(SessionID.make("ses_00000000000000000000000003"), Path.pretty(filepath)) - const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath) - const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath) + const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), Path.pretty(filepath)) + const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), Path.pretty(filepath)) expect(time1).toBeDefined() expect(time2).toBeDefined() @@ -74,11 +75,11 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(sessionID, filepath) - const first = await FileTime.get(sessionID, filepath) + await FileTime.read(sessionID, Path.pretty(filepath)) + const first = await FileTime.get(sessionID, Path.pretty(filepath)) - await FileTime.read(sessionID, filepath) - const second = await FileTime.get(sessionID, filepath) + await FileTime.read(sessionID, Path.pretty(filepath)) + const second = await FileTime.get(sessionID, Path.pretty(filepath)) expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime()) }, @@ -96,8 +97,8 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(sessionID, filepath) - await FileTime.assert(sessionID, filepath) + await FileTime.read(sessionID, Path.pretty(filepath)) + await FileTime.assert(sessionID, Path.pretty(filepath)) }, }) }) @@ -110,7 +111,7 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file") + await expect(FileTime.assert(sessionID, Path.pretty(filepath))).rejects.toThrow("You must read file") }, }) }) @@ -124,10 +125,10 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(sessionID, filepath) + await FileTime.read(sessionID, Path.pretty(filepath)) await fs.writeFile(filepath, "modified content", "utf-8") await touch(filepath, 2_000) - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read") + await expect(FileTime.assert(sessionID, Path.pretty(filepath))).rejects.toThrow("modified since it was last read") }, }) }) @@ -141,13 +142,13 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(sessionID, filepath) + await FileTime.read(sessionID, Path.pretty(filepath)) await fs.writeFile(filepath, "modified", "utf-8") await touch(filepath, 2_000) let error: Error | undefined try { - await FileTime.assert(sessionID, filepath) + await FileTime.assert(sessionID, Path.pretty(filepath)) } catch (e) { error = e as Error } @@ -168,7 +169,7 @@ describe("file/time", () => { directory: tmp.path, fn: async () => { let executed = false - await FileTime.withLock(filepath, async () => { + await FileTime.withLock(Path.pretty(filepath), async () => { executed = true return "result" }) @@ -184,7 +185,7 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await FileTime.withLock(filepath, async () => { + const result = await FileTime.withLock(Path.pretty(filepath), async () => { return "success" }) expect(result).toBe("success") @@ -203,7 +204,7 @@ describe("file/time", () => { const hold = gate() const ready = gate() - const op1 = FileTime.withLock(filepath, async () => { + const op1 = FileTime.withLock(Path.pretty(filepath), async () => { order.push(1) ready.open() await hold.wait @@ -212,7 +213,7 @@ describe("file/time", () => { await ready.wait - const op2 = FileTime.withLock(filepath, async () => { + const op2 = FileTime.withLock(Path.pretty(filepath), async () => { order.push(3) order.push(4) }) @@ -238,7 +239,7 @@ describe("file/time", () => { const hold = gate() const ready = gate() - const op1 = FileTime.withLock(filepath1, async () => { + const op1 = FileTime.withLock(Path.pretty(filepath1), async () => { started1 = true ready.open() await hold.wait @@ -247,7 +248,7 @@ describe("file/time", () => { await ready.wait - const op2 = FileTime.withLock(filepath2, async () => { + const op2 = FileTime.withLock(Path.pretty(filepath2), async () => { started2 = true hold.open() }) @@ -267,13 +268,13 @@ describe("file/time", () => { directory: tmp.path, fn: async () => { await expect( - FileTime.withLock(filepath, async () => { + FileTime.withLock(Path.pretty(filepath), async () => { throw new Error("Test error") }), ).rejects.toThrow("Test error") let executed = false - await FileTime.withLock(filepath, async () => { + await FileTime.withLock(Path.pretty(filepath), async () => { executed = true }) expect(executed).toBe(true) @@ -292,13 +293,13 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(sessionID, filepath) + await FileTime.read(sessionID, Path.pretty(filepath)) const stats = Filesystem.stat(filepath) expect(stats?.mtime).toBeInstanceOf(Date) expect(stats!.mtime.getTime()).toBeGreaterThan(0) - await FileTime.assert(sessionID, filepath) + await FileTime.assert(sessionID, Path.pretty(filepath)) }, }) }) @@ -312,7 +313,7 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(sessionID, filepath) + await FileTime.read(sessionID, Path.pretty(filepath)) const originalStat = Filesystem.stat(filepath) @@ -322,7 +323,7 @@ describe("file/time", () => { const newStat = Filesystem.stat(filepath) expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime()) - await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow() + await expect(FileTime.assert(sessionID, Path.pretty(filepath))).rejects.toThrow() }, }) }) diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts index ca73b3336bc7..98198007e46a 100644 --- a/packages/opencode/test/filesystem/filesystem.test.ts +++ b/packages/opencode/test/filesystem/filesystem.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test" import { Effect, Layer } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "../../src/filesystem" +import { Path } from "../../src/path/path" import { testEffect } from "../lib/effect" import path from "path" @@ -305,15 +306,15 @@ describe("AppFileSystem", () => { expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream") }) - test("contains checks path containment", () => { - expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true) - expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false) + test("Path.contains checks path containment", () => { + expect(Path.contains("/a/b", "/a/b/c")).toBe(true) + expect(Path.contains("/a/b", "/a/c")).toBe(false) }) - test("overlaps detects overlapping paths", () => { - expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true) - expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true) - expect(AppFileSystem.overlaps("/a", "/b")).toBe(false) + test("Path.overlaps detects overlapping paths", () => { + expect(Path.overlaps("/a/b", "/a/b/c")).toBe(true) + expect(Path.overlaps("/a/b/c", "/a/b")).toBe(true) + expect(Path.overlaps("/a", "/b")).toBe(false) }) }) }) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index e0b31ea5ddc8..fc639edcdef4 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -33,7 +33,7 @@ describe("LSPClient interop", () => { LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, - root: process.cwd(), + root: Path.pretty(process.cwd()), }), }) @@ -57,7 +57,7 @@ describe("LSPClient interop", () => { LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, - root: process.cwd(), + root: Path.pretty(process.cwd()), }), }) @@ -81,7 +81,7 @@ describe("LSPClient interop", () => { LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, - root: process.cwd(), + root: Path.pretty(process.cwd()), }), }) @@ -116,7 +116,7 @@ describe("LSPClient interop", () => { LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, - root: alias, + root: Path.pretty(alias), }), }) @@ -168,7 +168,7 @@ describe("LSPClient interop", () => { LSPClient.create({ serverID: "fake", server: handle as unknown as LSPServer.Handle, - root: tmp.path, + root: Path.pretty(tmp.path), }), }) diff --git a/packages/opencode/test/path/migrate.test.ts b/packages/opencode/test/path/migrate.test.ts index 9ec7d7e63d62..9926253224b3 100644 --- a/packages/opencode/test/path/migrate.test.ts +++ b/packages/opencode/test/path/migrate.test.ts @@ -11,6 +11,7 @@ import { SessionID } from "../../src/session/schema" import { tmpdir } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" import { PathMigration } from "../../src/path/migrate" +import { Path } from "../../src/path/path" import { PrettyPath } from "../../src/path/schema" import { Filesystem } from "../../src/util/filesystem" @@ -103,7 +104,7 @@ describe("PathMigration.run", () => { id: sid(), slug: "slug", projectID: project.id, - directory: raw(tmp.path), + directory: Path.pretty(raw(tmp.path)), title: "test", version: "0.0.0-test", time: { diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index a84aaf7c5d90..cb375879b1cc 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -71,6 +71,12 @@ describe("path", () => { }) describe("key()", () => { + test("keeps the global slash sentinel stable", () => { + expect(String(Path.key("/"))).toBe("/") + expect(Path.eq("/", "/")).toBe(true) + expect(Path.eq("/", process.platform === "win32" ? "C:\\" : "/tmp")).toBe(false) + }) + test("matches slash and case variants on Windows", () => { const a = Path.key("C:\\Repo\\File.ts", { platform: "win32" }) const b = Path.key("c:/repo/file.ts", { platform: "win32" }) @@ -301,6 +307,38 @@ describe("path", () => { }) }) + describe("join()", () => { + test("joins child paths against an existing pretty root", () => { + expect(String(Path.join("/repo", "src/file.ts", { platform: "linux" }))).toBe("/repo/src/file.ts") + }) + }) + + describe("parent()", () => { + test("returns the normalized parent directory", () => { + expect(String(Path.parent("/repo/src/file.ts", { platform: "linux" }))).toBe("/repo/src") + }) + }) + + describe("repoFrom()", () => { + test("converts absolute paths back into repo paths", () => { + expect(String(Path.repoFrom("/repo", "/repo/src/file.ts", { platform: "linux" }))).toBe("src/file.ts") + }) + }) + + describe("up()", () => { + test("walks to the bounded ancestor inclusively", () => { + expect(Array.from(Path.up("/repo/src/nested", { stop: "/repo", platform: "linux" }), String)).toEqual([ + "/repo/src/nested", + "/repo/src", + "/repo", + ]) + }) + + test("returns nothing when start is outside the bound", () => { + expect(Array.from(Path.up("/repo-other/src", { stop: "/repo", platform: "linux" }), String)).toEqual([]) + }) + }) + describe("truecase()", () => { test("keeps missing tails as typed on Windows", async () => { if (process.platform !== "win32") return @@ -351,6 +389,10 @@ describe("path", () => { }) describe("truecaseSync()", () => { + test("keeps the global slash sentinel stable", () => { + expect(String(Path.truecaseSync("/"))).toBe("/") + }) + test("keeps missing tails as typed on Windows", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() diff --git a/packages/opencode/test/path/schema.test.ts b/packages/opencode/test/path/schema.test.ts new file mode 100644 index 000000000000..fdf97643ab13 --- /dev/null +++ b/packages/opencode/test/path/schema.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" + +import { WorkspaceID } from "../../src/control-plane/schema" +import { PrettyPath } from "../../src/path/schema" + +describe("runtime-safe path/id constructors", () => { + test("parses branded ids and rejects invalid prefixes", () => { + expect(String(WorkspaceID.parse("wrk_test_workspace"))).toBe("wrk_test_workspace") + expect(() => WorkspaceID.parse("workspace_test_workspace")).toThrow( + 'Expected workspace id starting with "wrk"', + ) + }) + + test("rejects relative pretty paths", () => { + expect(() => PrettyPath.parse("relative/path")).toThrow('Expected absolute filesystem path, received "relative/path"') + }) +}) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 61d2afb19f62..b34cd25997db 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -39,7 +39,7 @@ test("Instance keeps alias directories and reload disposes stored state", async fn: async () => state(), }) - expect(a.dir).toBe(alias) + expect(String(a.dir)).toBe(alias) await Instance.reload({ directory: `${alias}${path.sep}.${path.sep}`, @@ -51,8 +51,8 @@ test("Instance keeps alias directories and reload disposes stored state", async }) expect(b).not.toBe(a) - expect(b.dir).toBe(alias) - expect(seen).toEqual([alias]) + expect(String(b.dir)).toBe(alias) + expect(seen.map(String)).toEqual([alias]) } finally { await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) } @@ -83,8 +83,8 @@ test("Instance dedupes concurrent equivalent directories by key", async () => { }), ]) - expect(a).toBe(alias) - expect(b).toBe(alias) + expect(String(a)).toBe(alias) + expect(String(b)).toBe(alias) expect(n).toBe(1) } finally { await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) diff --git a/packages/opencode/test/server/path-alias.test.ts b/packages/opencode/test/server/path-alias.test.ts index 20a5c23f7352..d62485a91088 100644 --- a/packages/opencode/test/server/path-alias.test.ts +++ b/packages/opencode/test/server/path-alias.test.ts @@ -48,3 +48,27 @@ test("server ingress keeps alias directories", async () => { await fs.rm(alias, { recursive: true, force: true }).catch(() => undefined) } }) + +test("server ingress rejects invalid workspace ids", async () => { + const app = Server.createApp({}) + const response = await app.request("/path", { + headers: { + "x-opencode-workspace": "workspace_test", + }, + }) + + expect(response.status).toBe(400) + expect(await response.text()).toContain('Expected workspace id starting with "wrk"') +}) + +test("server ingress rejects invalid encoded directories", async () => { + const app = Server.createApp({}) + const response = await app.request("/path", { + headers: { + "x-opencode-directory": "%E0%A4%A", + }, + }) + + expect(response.status).toBe(400) + expect(await response.text()).toContain("Invalid percent-encoding in directory parameter") +}) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 0e07ba98d3be..841e18adcca4 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -20,9 +20,9 @@ describe("InstructionPrompt.resolve", () => { directory: tmp.path, fn: async () => { const system = await InstructionPrompt.systemPaths() - expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) + expect(system.has(Path.pretty(path.join(tmp.path, "AGENTS.md")))).toBe(true) - const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"), "test-message-1") + const results = await InstructionPrompt.resolve([], Path.pretty(path.join(tmp.path, "src", "file.ts")), "test-message-1") expect(results).toEqual([]) }, }) @@ -39,15 +39,15 @@ describe("InstructionPrompt.resolve", () => { directory: tmp.path, fn: async () => { const system = await InstructionPrompt.systemPaths() - expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) + expect(system.has(Path.pretty(path.join(tmp.path, "subdir", "AGENTS.md")))).toBe(false) const results = await InstructionPrompt.resolve( [], - path.join(tmp.path, "subdir", "nested", "file.ts"), + Path.pretty(path.join(tmp.path, "subdir", "nested", "file.ts")), "test-message-2", ) expect(results.length).toBe(1) - expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) + expect(String(results[0].filepath)).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) }, }) }) @@ -64,9 +64,9 @@ describe("InstructionPrompt.resolve", () => { fn: async () => { const filepath = path.join(tmp.path, "subdir", "AGENTS.md") const system = await InstructionPrompt.systemPaths() - expect(system.has(filepath)).toBe(false) + expect(system.has(Path.pretty(filepath))).toBe(false) - const results = await InstructionPrompt.resolve([], filepath, "test-message-2") + const results = await InstructionPrompt.resolve([], Path.pretty(filepath), "test-message-2") expect(results).toEqual([]) }, }) @@ -84,7 +84,7 @@ describe("InstructionPrompt.resolve", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const results = await InstructionPrompt.resolve([], path.join(other, "src", "file.ts"), "test-message-3") + const results = await InstructionPrompt.resolve([], Path.pretty(path.join(other, "src", "file.ts")), "test-message-3") expect(results).toEqual([]) }, }) @@ -131,8 +131,8 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { directory: projectTmp.path, fn: async () => { const paths = await InstructionPrompt.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) + expect(paths.has(Path.pretty(path.join(profileTmp.path, "AGENTS.md")))).toBe(true) + expect(paths.has(Path.pretty(path.join(globalTmp.path, "AGENTS.md")))).toBe(false) }, }) } finally { @@ -158,8 +158,8 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { directory: projectTmp.path, fn: async () => { const paths = await InstructionPrompt.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(Path.pretty(path.join(profileTmp.path, "AGENTS.md")))).toBe(false) + expect(paths.has(Path.pretty(path.join(globalTmp.path, "AGENTS.md")))).toBe(true) }, }) } finally { @@ -184,7 +184,7 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { directory: projectTmp.path, fn: async () => { const paths = await InstructionPrompt.systemPaths() - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(Path.pretty(path.join(globalTmp.path, "AGENTS.md")))).toBe(true) }, }) } finally { @@ -213,7 +213,7 @@ describe("InstructionPrompt.systemPaths instruction boundaries", () => { directory: tmp.path, fn: async () => { const paths = await InstructionPrompt.systemPaths() - expect(paths.has(path.join(tmp.path, "rules.md"))).toBe(true) + expect(paths.has(Path.pretty(path.join(tmp.path, "rules.md")))).toBe(true) }, }) }) @@ -239,7 +239,7 @@ describe("InstructionPrompt.systemPaths instruction boundaries", () => { directory: tmp.path, fn: async () => { const paths = await InstructionPrompt.systemPaths() - expect(paths.has(path.join(tmp.path, "rules.md"))).toBe(true) + expect(paths.has(Path.pretty(path.join(tmp.path, "rules.md")))).toBe(true) }, }) }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 0d5b89730a98..918b8e7cd8f3 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" +import path from "path" import { APICallError } from "ai" import { MessageV2 } from "../../src/session/message-v2" +import { Path } from "../../src/path/path" import type { Provider } from "../../src/provider/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema" @@ -108,6 +110,46 @@ function basePart(messageID: string, id: string) { } describe("session.message-v2.toModelMessage", () => { + test("normalizes assistant path fields at the schema boundary", () => { + const cwd = path.join(process.cwd(), ".") + const root = path.join(process.cwd(), "..", path.basename(process.cwd())) + const parsed = MessageV2.Info.parse({ + ...assistantInfo("msg_assistant", "msg_parent"), + path: { + cwd, + root, + }, + }) + + expect(parsed.role).toBe("assistant") + if (parsed.role !== "assistant") return + expect(parsed.path.cwd).toBe(Path.pretty(cwd)) + expect(parsed.path.root).toBe(Path.pretty(root)) + }) + + test("normalizes file source paths at the schema boundary", () => { + const sourcePath = path.join(process.cwd(), ".") + const parsed = MessageV2.Part.parse({ + ...basePart("msg_user", "prt_file"), + type: "file", + mime: "image/png", + url: "data:image/png;base64,Zm9v", + source: { + type: "file", + path: sourcePath, + text: { + value: "hello", + start: 1, + end: 1, + }, + }, + }) + + expect(parsed.type).toBe("file") + if (parsed.type !== "file" || !parsed.source || parsed.source.type !== "file") return + expect(parsed.source.path).toBe(Path.pretty(sourcePath)) + }) + test("filters out messages with no parts", () => { const input: MessageV2.WithParts[] = [ { diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index fb37a3a8dca1..d002ee4eda30 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -5,6 +5,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionRevert } from "../../src/session/revert" import { SessionCompaction } from "../../src/session/compaction" import { MessageV2 } from "../../src/session/message-v2" +import { Path } from "../../src/path/path" import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { MessageID, PartID } from "../../src/session/schema" @@ -55,8 +56,8 @@ describe("revert + compact workflow", () => { mode: "default", agent: "default", path: { - cwd: tmp.path, - root: tmp.path, + cwd: Path.pretty(tmp.path), + root: Path.pretty(tmp.path), }, cost: 0, tokens: { @@ -115,8 +116,8 @@ describe("revert + compact workflow", () => { mode: "default", agent: "default", path: { - cwd: tmp.path, - root: tmp.path, + cwd: Path.pretty(tmp.path), + root: Path.pretty(tmp.path), }, cost: 0, tokens: { @@ -229,8 +230,8 @@ describe("revert + compact workflow", () => { mode: "default", agent: "default", path: { - cwd: tmp.path, - root: tmp.path, + cwd: Path.pretty(tmp.path), + root: Path.pretty(tmp.path), }, cost: 0, tokens: { diff --git a/packages/opencode/test/session/structured-output.test.ts b/packages/opencode/test/session/structured-output.test.ts index f6131b149b57..aa7517c34a56 100644 --- a/packages/opencode/test/session/structured-output.test.ts +++ b/packages/opencode/test/session/structured-output.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Path } from "../../src/path/path" import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" import { SessionID, MessageID } from "../../src/session/schema" @@ -133,7 +134,7 @@ describe("structured-output.AssistantMessage", () => { providerID: "anthropic", mode: "default", agent: "default", - path: { cwd: "/test", root: "/test" }, + path: { cwd: Path.pretty("/test"), root: Path.pretty("/test") }, cost: 0.001, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: Date.now() }, diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 18aec15b5cf6..c5738bdcac13 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -21,6 +21,8 @@ const ctx = { ask: async () => {}, } +const pretty = (file: string) => Path.pretty(file) + async function touch(file: string, time: number) { const date = new Date(time) await fs.utimes(file, date, date) @@ -119,7 +121,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const edit = await EditTool.init() const result = await edit.execute( @@ -146,7 +148,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const edit = await EditTool.init() await expect( @@ -194,7 +196,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const edit = await EditTool.init() await expect( @@ -244,7 +246,7 @@ describe("tool.edit", () => { directory: tmp.path, fn: async () => { // Read first - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) // Simulate external modification await fs.writeFile(filepath, "modified externally", "utf-8") @@ -274,7 +276,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const edit = await EditTool.init() await edit.execute( @@ -301,7 +303,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const { Bus } = await import("../../src/bus") const { File } = await import("../../src/file") @@ -339,7 +341,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const edit = await EditTool.init() await edit.execute( @@ -365,7 +367,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const edit = await EditTool.init() await edit.execute( @@ -414,7 +416,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, dirpath) + await FileTime.read(ctx.sessionID, pretty(dirpath)) const edit = await EditTool.init() await expect( @@ -439,7 +441,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const edit = await EditTool.init() const result = await edit.execute( @@ -510,7 +512,7 @@ describe("tool.edit", () => { fn: async () => { const edit = await EditTool.init() const filePath = path.join(tmp.path, "test.txt") - await FileTime.read(ctx.sessionID, filePath) + await FileTime.read(ctx.sessionID, pretty(filePath)) await edit.execute( { filePath, @@ -651,7 +653,7 @@ describe("tool.edit", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const edit = await EditTool.init() @@ -666,7 +668,7 @@ describe("tool.edit", () => { ) // Need to read again since FileTime tracks per-session - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const promise2 = edit.execute( { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index b6be32e23786..bf1106ad1587 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -6,6 +6,7 @@ import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import type { PermissionNext } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" +import { Path } from "../../src/path/path" import { win } from "../lib/windows-path" const ctx = { @@ -19,6 +20,8 @@ const ctx = { ask: async () => {}, } +const pretty = (file: string) => Path.pretty(file) + async function link(target: string, alias: string) { await fs.symlink(target, alias, process.platform === "win32" ? "junction" : "dir") } @@ -179,7 +182,7 @@ describe("tool.write", () => { directory: tmp.path, fn: async () => { const { FileTime } = await import("../../src/file/time") - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const write = await WriteTool.init() const result = await write.execute( @@ -208,7 +211,7 @@ describe("tool.write", () => { directory: tmp.path, fn: async () => { const { FileTime } = await import("../../src/file/time") - await FileTime.read(ctx.sessionID, filepath) + await FileTime.read(ctx.sessionID, pretty(filepath)) const write = await WriteTool.init() const result = await write.execute( @@ -386,7 +389,7 @@ describe("tool.write", () => { directory: tmp.path, fn: async () => { const { FileTime } = await import("../../src/file/time") - await FileTime.read(ctx.sessionID, readonlyPath) + await FileTime.read(ctx.sessionID, pretty(readonlyPath)) const write = await WriteTool.init() await expect( diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index e7c29f632668..f11611becec6 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" +import { Path } from "../../src/path/path" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -286,70 +287,36 @@ describe("filesystem", () => { }) }) - describe("windowsPath()", () => { - test("converts Git Bash paths", () => { - if (process.platform === "win32") { - expect(Filesystem.windowsPath("/c/Users/test")).toBe("C:/Users/test") - expect(Filesystem.windowsPath("/d/dev/project")).toBe("D:/dev/project") - } else { - expect(Filesystem.windowsPath("/c/Users/test")).toBe("/c/Users/test") - } - }) - - test("converts Cygwin paths", () => { - if (process.platform === "win32") { - expect(Filesystem.windowsPath("/cygdrive/c/Users/test")).toBe("C:/Users/test") - expect(Filesystem.windowsPath("/cygdrive/x/dev/project")).toBe("X:/dev/project") - } else { - expect(Filesystem.windowsPath("/cygdrive/c/Users/test")).toBe("/cygdrive/c/Users/test") - } - }) - - test("converts WSL paths", () => { - if (process.platform === "win32") { - expect(Filesystem.windowsPath("/mnt/c/Users/test")).toBe("C:/Users/test") - expect(Filesystem.windowsPath("/mnt/z/dev/project")).toBe("Z:/dev/project") - } else { - expect(Filesystem.windowsPath("/mnt/c/Users/test")).toBe("/mnt/c/Users/test") - } - }) - - test("ignores normal Windows paths", () => { - expect(Filesystem.windowsPath("C:/Users/test")).toBe("C:/Users/test") - expect(Filesystem.windowsPath("D:\\dev\\project")).toBe("D:\\dev\\project") - }) - }) - - describe("contains()", () => { + describe("Path.contains()", () => { test("rejects absolute-relative path mixes", () => { const abs = process.platform === "win32" ? "C:\\project" : "/project" const rel = process.platform === "win32" ? "project\\src\\file.ts" : "project/src/file.ts" - expect(Filesystem.contains(abs, rel)).toBe(false) - expect(Filesystem.contains(rel, path.join(abs, "src", "file.ts"))).toBe(false) + expect(Path.contains(abs, rel)).toBe(false) + expect(Path.contains(rel, path.join(abs, "src", "file.ts"))).toBe(false) }) test("rejects different roots", () => { if (process.platform !== "win32") return - expect(Filesystem.contains("C:\\project", "D:\\project\\file.ts")).toBe(false) - expect(Filesystem.contains("C:\\project", "\\\\server\\share\\file.ts")).toBe(false) + expect(Path.contains("C:\\project", "D:\\project\\file.ts")).toBe(false) + expect(Path.contains("C:\\project", "\\\\server\\share\\file.ts")).toBe(false) }) }) - describe("overlaps()", () => { + describe("Path.overlaps()", () => { test("stays false for absolute-relative path mixes", () => { const abs = process.platform === "win32" ? "C:\\project" : "/project" const rel = process.platform === "win32" ? "project\\src" : "project/src" - expect(Filesystem.overlaps(abs, rel)).toBe(false) + expect(Path.overlaps(abs, rel)).toBe(false) }) test("stays false for different roots", () => { if (process.platform !== "win32") return - expect(Filesystem.overlaps("C:\\project", "D:\\project")).toBe(false) - expect(Filesystem.overlaps("C:\\project", "\\\\server\\share\\project")).toBe(false) + expect(Path.overlaps("C:\\project", "D:\\project")).toBe(false) + expect(Path.overlaps("C:\\project", "\\\\server\\share\\project")).toBe(false) }) }) @@ -474,19 +441,19 @@ describe("filesystem", () => { }) }) - describe("resolve()", () => { + describe("Path.truecaseSync()", () => { test("resolves slash-prefixed drive paths on Windows", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() const forward = tmp.path.replaceAll("\\", "/") - expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(tmp.path)) + expect(Path.truecaseSync(`/${forward}`)).toBe(Path.truecaseSync(tmp.path)) }) test("resolves slash-prefixed drive roots on Windows", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() const drive = tmp.path[0].toUpperCase() - expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`)) + expect(Path.truecaseSync(`/${drive}:`)).toBe(Path.truecaseSync(`${drive}:/`)) }) test("resolves Git Bash and MSYS2 paths on Windows", async () => { @@ -495,7 +462,7 @@ describe("filesystem", () => { await using tmp = await tmpdir() const drive = tmp.path[0].toLowerCase() const rest = tmp.path.slice(2).replaceAll("\\", "/") - expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path)) + expect(Path.truecaseSync(`/${drive}${rest}`)).toBe(Path.truecaseSync(tmp.path)) }) test("resolves Git Bash and MSYS2 drive roots on Windows", async () => { @@ -503,7 +470,7 @@ describe("filesystem", () => { if (process.platform !== "win32") return await using tmp = await tmpdir() const drive = tmp.path[0].toLowerCase() - expect(Filesystem.resolve(`/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`)) + expect(Path.truecaseSync(`/${drive}`)).toBe(Path.truecaseSync(`${drive.toUpperCase()}:/`)) }) test("resolves Cygwin paths on Windows", async () => { @@ -511,14 +478,14 @@ describe("filesystem", () => { await using tmp = await tmpdir() const drive = tmp.path[0].toLowerCase() const rest = tmp.path.slice(2).replaceAll("\\", "/") - expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path)) + expect(Path.truecaseSync(`/cygdrive/${drive}${rest}`)).toBe(Path.truecaseSync(tmp.path)) }) test("resolves Cygwin drive roots on Windows", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() const drive = tmp.path[0].toLowerCase() - expect(Filesystem.resolve(`/cygdrive/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`)) + expect(Path.truecaseSync(`/cygdrive/${drive}`)).toBe(Path.truecaseSync(`${drive.toUpperCase()}:/`)) }) test("resolves WSL mount paths on Windows", async () => { @@ -526,14 +493,14 @@ describe("filesystem", () => { await using tmp = await tmpdir() const drive = tmp.path[0].toLowerCase() const rest = tmp.path.slice(2).replaceAll("\\", "/") - expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path)) + expect(Path.truecaseSync(`/mnt/${drive}${rest}`)).toBe(Path.truecaseSync(tmp.path)) }) test("resolves WSL mount roots on Windows", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() const drive = tmp.path[0].toLowerCase() - expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`)) + expect(Path.truecaseSync(`/mnt/${drive}`)).toBe(Path.truecaseSync(`${drive.toUpperCase()}:/`)) }) test("preserves symlinked directory roots", async () => { @@ -542,8 +509,8 @@ describe("filesystem", () => { await fs.mkdir(target) const link = path.join(tmp.path, "alias") await fs.symlink(target, link) - expect(Filesystem.resolve(link)).toBe(path.resolve(link)) - expect(Filesystem.resolve(link)).not.toBe(Filesystem.resolve(target)) + expect(String(Path.truecaseSync(link))).toBe(path.resolve(link)) + expect(String(Path.truecaseSync(link))).not.toBe(String(Path.truecaseSync(target))) }) test("preserves symlink roots for nested paths", async () => { @@ -553,14 +520,14 @@ describe("filesystem", () => { await fs.mkdir(child, { recursive: true }) const link = path.join(tmp.path, "alias") await fs.symlink(target, link) - expect(Filesystem.resolve(path.join(link, "child"))).toBe(path.resolve(link, "child")) + expect(String(Path.truecaseSync(path.join(link, "child")))).toBe(path.resolve(link, "child")) }) test("returns unresolved path when target does not exist", async () => { await using tmp = await tmpdir() const missing = path.join(tmp.path, "does-not-exist-" + Date.now()) - const result = Filesystem.resolve(missing) - expect(result).toBe(Filesystem.normalizePath(path.resolve(missing))) + const result = Path.truecaseSync(missing) + expect(result).toBe(Path.truecaseSync(path.resolve(missing))) }) test("keeps cyclic symlink paths as typed", async () => { @@ -569,7 +536,7 @@ describe("filesystem", () => { const b = path.join(tmp.path, "b") await fs.symlink(b, a) await fs.symlink(a, b) - expect(Filesystem.resolve(path.join(a, "child"))).toBe(path.resolve(a, "child")) + expect(String(Path.truecaseSync(path.join(a, "child")))).toBe(path.resolve(a, "child")) }) }) }) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 83c8fc450278..7d75a13148f4 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -133,7 +133,7 @@ type SessionReviewSelection = { range: SelectedLineRange } -const key = (path: string) => pathKey(path) || path +const key = (path: string) => pathKey(path) export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 0edda4c13b93..a34053c3962c 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -87,7 +87,7 @@ function list(value: T[] | undefined | null, fallback: T[]) { const hidden = new Set(["todowrite", "todoread"]) -const key = (path: string) => pathKey(path) || path +const key = (path: string) => pathKey(path) function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { From 8e658c26193f3eb55f13d935fb0adea7bdbb31c3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:46:38 +1000 Subject: [PATCH 35/42] path: simplify backend path consumers --- packages/opencode/src/filesystem/index.ts | 23 +++---- packages/opencode/src/lsp/index.ts | 10 ++- packages/opencode/src/lsp/server.ts | 59 ++++++---------- packages/opencode/src/session/instruction.ts | 5 +- packages/opencode/src/util/filesystem.ts | 23 ++++--- .../workspace-server-sse.test.ts | 13 ++++ packages/opencode/test/lsp/server.test.ts | 69 ++++++++++--------- 7 files changed, 97 insertions(+), 105 deletions(-) diff --git a/packages/opencode/src/filesystem/index.ts b/packages/opencode/src/filesystem/index.ts index 53695824eb1d..9aa2f95e3550 100644 --- a/packages/opencode/src/filesystem/index.ts +++ b/packages/opencode/src/filesystem/index.ts @@ -1,5 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" -import { dirname, join } from "path" +import { dirname } from "path" import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect" import type { PlatformError } from "effect/PlatformError" import { Path } from "../path/path" @@ -87,24 +87,23 @@ export namespace AppFileSystem { }) }) - const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) { + const collectUp = Effect.fnUntraced(function* (targets: string[], start: string, stop?: string) { const result: string[] = [] for (const dir of Path.up(start, { stop })) { - const search = join(dir, target) - if (yield* fs.exists(search)) result.push(search) + for (const target of targets) { + const file = Path.join(dir, target) + if (yield* fs.exists(file)) result.push(file) + } } return result }) + const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) { + return yield* collectUp([target], start, stop) + }) + const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) { - const result: string[] = [] - for (const dir of Path.up(options.start, { stop: options.stop })) { - for (const target of options.targets) { - const search = join(dir, target) - if (yield* fs.exists(search)) result.push(search) - } - } - return result + return yield* collectUp(options.targets, options.start, options.stop) }) const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) { diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 04d1ea1b6f9c..0aad9dd22d9e 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -239,11 +239,10 @@ export namespace LSP { const root = await server.root(file) if (!root) continue - const dir = pretty(root) - const id = key(server.id, dir) + const id = key(server.id, root) if (s.broken.has(id)) continue - const rootKey = Path.key(dir) + const rootKey = Path.key(root) const match = s.clients.find((x) => x.rootKey === rootKey && x.serverID === server.id) if (match) { result.push(match) @@ -258,7 +257,7 @@ export namespace LSP { continue } - const task = schedule(server, dir, id) + const task = schedule(server, root, id) s.spawning.set(id, task) task.finally(() => { @@ -285,8 +284,7 @@ export namespace LSP { if (server.extensions.length && !server.extensions.includes(extension)) continue const root = await server.root(filePath) if (!root) continue - const dir = pretty(root) - if (s.broken.has(key(server.id, dir))) continue + if (s.broken.has(key(server.id, root))) continue return true } return false diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index f0512c848652..6530f3a68676 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -14,6 +14,7 @@ import { Process } from "../util/process" import { which } from "../util/which" import { Module } from "@opencode-ai/util/module" import { Path } from "../path/path" +import type { PrettyPath } from "../path/schema" import { spawn } from "./launch" export namespace LSPServer { @@ -31,32 +32,24 @@ export namespace LSPServer { initialization?: Record } - type RootFunction = (file: string) => Promise + type RootFunction = (file: PrettyPath) => Promise + + const firstUp = async (targets: string[], start: PrettyPath, stop?: PrettyPath) => { + const files = Filesystem.up({ targets, start, stop }) + const first = await files.next() + await files.return() + return first.value + } const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { return async (file) => { - const start = path.dirname(file) + const start = Path.parent(file) const stop = Instance.directory - if (excludePatterns) { - const excludedFiles = Filesystem.up({ - targets: excludePatterns, - start, - stop, - }) - const excluded = await excludedFiles.next() - await excludedFiles.return() - if (excluded.value) return undefined - } - const files = Filesystem.up({ - targets: includePatterns, - start, - stop, - }) - const first = await files.next() - await files.return() - if (!first.value) return stop - return path.dirname(first.value) + if (excludePatterns && (await firstUp(excludePatterns, start, stop))) return undefined + + const root = await firstUp(includePatterns, start, stop) + return root ? Path.parent(root) : stop } } @@ -71,15 +64,9 @@ export namespace LSPServer { export const Deno: Info = { id: "deno", root: async (file) => { - const files = Filesystem.up({ - targets: ["deno.json", "deno.jsonc"], - start: path.dirname(file), - stop: Instance.directory, - }) - const first = await files.next() - await files.return() - if (!first.value) return undefined - return path.dirname(first.value) + const root = await firstUp(["deno.json", "deno.jsonc"], Path.parent(file), Instance.directory) + if (!root) return undefined + return Path.parent(root) }, extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], async spawn(root) { @@ -856,18 +843,12 @@ export namespace LSPServer { export const RustAnalyzer: Info = { id: "rust", root: async (file) => { - const crates = Filesystem.up({ - targets: ["Cargo.toml", "Cargo.lock"], - start: path.dirname(file), - }) - const first = await crates.next() - await crates.return() - - const crateRoot = first.value ? path.dirname(first.value) : Instance.directory + const root = await firstUp(["Cargo.toml", "Cargo.lock"], Path.parent(file)) + const crateRoot = root ? Path.parent(root) : Instance.directory const worktree = Instance.worktree === "/" ? undefined : Path.truecaseSync(Instance.worktree) for (const dir of Path.up(crateRoot, { stop: worktree })) { - const cargoTomlPath = path.join(dir, "Cargo.toml") + const cargoTomlPath = Path.join(dir, "Cargo.toml") try { const cargoTomlContent = await Filesystem.readText(cargoTomlPath) if (cargoTomlContent.includes("[workspace]")) { diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 8d201eae6353..a40299dc6e06 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,4 +1,3 @@ -import path from "path" import { Global } from "../global" import { Filesystem } from "../util/filesystem" import { Config } from "../config/config" @@ -99,8 +98,8 @@ export namespace InstructionPrompt { instruction = Path.expand(instruction) const file = Path.isAbsolute(instruction) ? Path.pretty(instruction) : undefined const matches = file - ? await Glob.scan(path.basename(file), { - cwd: path.dirname(file), + ? await Glob.scan(Path.repoName(file), { + cwd: Path.parent(file), absolute: true, include: "file", }).catch(() => []) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 5763083f5ba2..ec74e9fe8188 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,12 +1,21 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" -import { dirname, join } from "path" +import { dirname } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Path } from "@/path/path" import { Glob } from "./glob" +async function* searchUp(targets: string[], start: string, stop?: string) { + for (const dir of Path.up(start, { stop })) { + for (const target of targets) { + const file = Path.join(dir, target) + if (await Filesystem.exists(file)) yield file + } + } +} + export namespace Filesystem { // Fast sync version for metadata checks export async function exists(p: string): Promise { @@ -101,20 +110,12 @@ export namespace Filesystem { export async function findUp(target: string, start: string, stop?: string) { const result = [] - for (const dir of Path.up(start, { stop })) { - const search = join(dir, target) - if (await exists(search)) result.push(search) - } + for await (const file of searchUp([target], start, stop)) result.push(file) return result } export async function* up(options: { targets: string[]; start: string; stop?: string }) { - for (const dir of Path.up(options.start, { stop: options.stop })) { - for (const target of options.targets) { - const search = join(dir, target) - if (await exists(search)) yield search - } - } + yield* searchUp(options.targets, options.start, options.stop) } export async function globUp(pattern: string, start: string, stop?: string) { diff --git a/packages/opencode/test/control-plane/workspace-server-sse.test.ts b/packages/opencode/test/control-plane/workspace-server-sse.test.ts index 6a287fa23f74..a5e3c94b12b4 100644 --- a/packages/opencode/test/control-plane/workspace-server-sse.test.ts +++ b/packages/opencode/test/control-plane/workspace-server-sse.test.ts @@ -122,4 +122,17 @@ describe("control-plane/workspace-server SSE", () => { expect(response.status).toBe(400) expect(await response.text()).toContain('Expected workspace id starting with "wrk"') }) + + test("rejects invalid encoded directories before bootstrapping", async () => { + const app = WorkspaceServer.App() + const response = await app.request("/event", { + headers: { + "x-opencode-workspace": "wrk_test_workspace", + "x-opencode-directory": "%E0%A4%A", + }, + }) + + expect(response.status).toBe(400) + expect(await response.text()).toContain("Invalid percent-encoding in directory parameter") + }) }) diff --git a/packages/opencode/test/lsp/server.test.ts b/packages/opencode/test/lsp/server.test.ts index 42e2862ddf53..091be108b6b5 100644 --- a/packages/opencode/test/lsp/server.test.ts +++ b/packages/opencode/test/lsp/server.test.ts @@ -1,34 +1,35 @@ -import { afterEach, describe, expect, test } from "bun:test" -import fs from "fs/promises" -import path from "path" -import { LSPServer } from "../../src/lsp/server" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" - -afterEach(async () => { - await Instance.disposeAll() -}) - -describe("LSPServer.RustAnalyzer.root", () => { - test("stops before prefix-collision siblings outside worktree", async () => { - await using tmp = await tmpdir({ git: true }) - const other = tmp.path + "-other" - - try { - await fs.mkdir(path.join(other, "member", "src"), { recursive: true }) - await Bun.write(path.join(other, "Cargo.toml"), "[workspace]\nmembers = [\"member\"]\n") - await Bun.write(path.join(other, "member", "Cargo.toml"), "[package]\nname = \"member\"\nversion = \"0.1.0\"\n") - await Bun.write(path.join(other, "member", "src", "lib.rs"), "pub fn it_works() {}\n") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const root = await LSPServer.RustAnalyzer.root(path.join(other, "member", "src", "lib.rs")) - expect(root).toBe(path.join(other, "member")) - }, - }) - } finally { - await fs.rm(other, { recursive: true, force: true }) - } - }) -}) +import { afterEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { LSPServer } from "../../src/lsp/server" +import { Path } from "../../src/path/path" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("LSPServer.RustAnalyzer.root", () => { + test("stops before prefix-collision siblings outside worktree", async () => { + await using tmp = await tmpdir({ git: true }) + const other = tmp.path + "-other" + + try { + await fs.mkdir(path.join(other, "member", "src"), { recursive: true }) + await Bun.write(path.join(other, "Cargo.toml"), "[workspace]\nmembers = [\"member\"]\n") + await Bun.write(path.join(other, "member", "Cargo.toml"), "[package]\nname = \"member\"\nversion = \"0.1.0\"\n") + await Bun.write(path.join(other, "member", "src", "lib.rs"), "pub fn it_works() {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await LSPServer.RustAnalyzer.root(Path.pretty(path.join(other, "member", "src", "lib.rs"))) + expect(String(root)).toBe(path.join(other, "member")) + }, + }) + } finally { + await fs.rm(other, { recursive: true, force: true }) + } + }) +}) From 95cf0413379f3ca6eb082ece671b19bf253c6b1a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:46:48 +1000 Subject: [PATCH 36/42] app: simplify path normalization at runtime --- packages/app/src/context/comments.tsx | 25 ++++---------- packages/app/src/context/file/path.test.ts | 13 +++++++- packages/app/src/context/file/path.ts | 2 ++ packages/app/src/context/file/view-cache.ts | 10 +++--- packages/app/src/context/prompt.tsx | 36 ++++++++++----------- packages/app/src/pages/layout.tsx | 16 ++++----- packages/app/src/utils/session-key.test.ts | 16 ++++++++- packages/app/src/utils/session-key.ts | 17 ++++++++++ 8 files changed, 79 insertions(+), 56 deletions(-) diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 40b336eb8402..d7ec8cbf16fd 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -4,9 +4,10 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" import { Persist, persisted } from "@/utils/persist" import { createScopedCache } from "@/utils/scoped-cache" +import { sessionScopeKey, sessionScopeParts } from "@/utils/session-key" import { uuid } from "@/utils/uuid" import type { SelectedLineRange } from "@/context/file" -import { filePathKey, type FilePath, type FilePathKey } from "@/context/file/path" +import { filePathKey, normalizeFilePath, type FilePath, type FilePathKey } from "@/context/file/path" export type LineComment = { id: string @@ -18,22 +19,8 @@ export type LineComment = { type CommentFocus = { file: FilePath; id: string } -const WORKSPACE_KEY = "__workspace__" const MAX_COMMENT_SESSIONS = 20 -function sessionKey(dir: string, id: string | undefined) { - return `${dir}\n${id ?? WORKSPACE_KEY}` -} - -function decodeSessionKey(key: string) { - const split = key.lastIndexOf("\n") - if (split < 0) return { dir: key, id: WORKSPACE_KEY } - return { - dir: key.slice(0, split), - id: key.slice(split + 1), - } -} - type CommentStore = { comments: Record } @@ -45,7 +32,7 @@ const text = (value: unknown) => (typeof value === "string" ? value : undefined) const num = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined) -const normalizeFile = (file: FilePath) => filePathKey(file) as FilePath +const normalizeFile = (file: FilePath) => normalizeFilePath(file) const commentKey = (file: FilePath) => filePathKey(file) @@ -280,9 +267,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont const params = useParams() const cache = createScopedCache( (key) => { - const decoded = decodeSessionKey(key) + const decoded = sessionScopeParts(key) return createRoot((dispose) => ({ - value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id), + value: createCommentSession(decoded.dir, decoded.id), dispose, })) }, @@ -295,7 +282,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont onCleanup(() => cache.clear()) const load = (dir: string, id: string | undefined) => { - const key = sessionKey(dir, id) + const key = sessionScopeKey(dir, id) return cache.get(key).value } diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index c2148fc81e6d..130670192207 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -1,5 +1,15 @@ import { describe, expect, test } from "bun:test" -import { createPathHelpers, dedupeFilePaths, filePathAncestorKeys, filePathEqual, filePathKey, filePathName, filePathParentKey, isFileTab } from "./path" +import { + createPathHelpers, + dedupeFilePaths, + filePathAncestorKeys, + filePathEqual, + filePathKey, + filePathName, + filePathParentKey, + isFileTab, + normalizeFilePath, +} from "./path" describe("file path helpers", () => { test("normalizes file inputs against workspace root", () => { @@ -43,6 +53,7 @@ describe("file path helpers", () => { test("normalizes app file keys across slash variants", () => { expect(String(filePathKey("src\\app.ts"))).toBe("src/app.ts") + expect(normalizeFilePath("src\\app.ts")).toBe("src/app.ts") expect(filePathEqual("src\\app.ts", "src/app.ts")).toBe(true) expect(dedupeFilePaths(["src\\app.ts", "src/app.ts", "src/util.ts"])).toEqual(["src\\app.ts", "src/util.ts"]) expect(String(filePathParentKey(filePathKey("src/app.ts")))).toBe("src") diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 1a49c13b64e8..f7e5f2b523eb 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -52,6 +52,8 @@ export const filePathEqual = (a: FilePath | undefined, b: FilePath | undefined) export const filePathFromKey = (input: FilePathKey) => input as FilePath +export const normalizeFilePath = (input: string) => filePathFromKey(pathKey(input) as FilePathKey) + export function filePathParentKey(input: FilePathKey) { const split = input.lastIndexOf("/") if (split === -1) return ROOT_FILE_PATH_KEY diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index 00a218ca32ad..fdcf19ab38f8 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -2,10 +2,10 @@ import { createEffect, createRoot } from "solid-js" import { createStore, produce } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" import { createScopedCache } from "@/utils/scoped-cache" +import { sessionScopeKey, sessionScopeParts } from "@/utils/session-key" import { createPathHelpers, filePathKey, type FilePath, type FilePathKey, type WorkspacePath } from "./path" import type { FileViewState, SelectedLineRange } from "./types" -const WORKSPACE_KEY = "__workspace__" const MAX_FILE_VIEW_SESSIONS = 20 const MAX_VIEW_FILES = 500 const fileKey = (path: FilePath) => filePathKey(path) @@ -178,11 +178,9 @@ function createViewSession(dir: WorkspacePath, id: string | undefined) { export function createFileViewCache() { const cache = createScopedCache( (key) => { - const split = key.lastIndexOf("\n") - const dir = split >= 0 ? key.slice(0, split) : key - const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY + const scope = sessionScopeParts(key) return createRoot((dispose) => ({ - value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id), + value: createViewSession(scope.dir, scope.id), dispose, })) }, @@ -194,7 +192,7 @@ export function createFileViewCache() { return { load: (dir: WorkspacePath, id: string | undefined) => { - const key = `${dir}\n${id ?? WORKSPACE_KEY}` + const key = sessionScopeKey(dir, id) return cache.get(key).value }, clear: () => cache.clear(), diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index fa263eef35a0..a76480a5912e 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -4,8 +4,9 @@ import { useParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import type { FileSelection } from "@/context/file" -import { filePathEqual, filePathKey, type FilePath } from "@/context/file/path" +import { filePathEqual, filePathKey, normalizeFilePath, type FilePath } from "@/context/file/path" import { Persist, persisted } from "@/utils/persist" +import { sessionScopeKey } from "@/utils/session-key" interface PartBase { content: string @@ -102,7 +103,6 @@ function clonePrompt(prompt: Prompt): Prompt { } function contextItemKey(item: ContextItem) { - if (item.type !== "file") return item.type const start = item.selection?.startLine const end = item.selection?.endLine const key = `${item.type}:${filePathKey(item.path)}:${start}:${end}` @@ -117,14 +117,13 @@ function contextItemKey(item: ContextItem) { return `${key}:c=${digest.slice(0, 8)}` } -function normalizeContextItem(item: ContextItem | (ContextItem & { key?: string })) { - if (item.type !== "file") return { ...item, key: contextItemKey(item) } - const path = filePathKey(item.path) as FilePath +function normalizeContextItem(item: ContextItem & { key?: string }) { + const path = normalizeFilePath(item.path) const next = { ...item, path } return { ...next, key: contextItemKey(next) } } -function isContextItem(value: unknown): value is ContextItem | (ContextItem & { key?: string }) { +function isContextItem(value: unknown): value is ContextItem & { key?: string } { return ( !!value && typeof value === "object" && @@ -141,16 +140,16 @@ function migratePromptStore(value: unknown) { const context = (value as { context?: { items?: unknown } }).context if (!context || !Array.isArray(context.items)) return value return { - ...value, - context: { - ...context, - items: context.items.filter(isContextItem).map(normalizeContextItem), - }, + ...value, + context: { + ...context, + items: context.items.filter(isContextItem).map(normalizeContextItem), + }, } } -function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { - return item.type === "file" && !!item.comment?.trim() +function isCommentItem(item: ContextItem & { key: string }) { + return !!item.comment?.trim() } type PromptStore = { @@ -181,7 +180,6 @@ function createPromptActions( } } -const WORKSPACE_KEY = "__workspace__" const MAX_PROMPT_SESSIONS = 20 type PromptSession = ReturnType @@ -215,16 +213,16 @@ function createPromptSessionState(store: Store, setStore: SetStoreF setStore("context", "items", (items) => items.filter((x) => x.key !== key)) }, removeComment(path: FilePath, commentID: string) { - const key = filePathKey(path) + const file = normalizeFilePath(path) setStore("context", "items", (items) => - items.filter((item) => !(item.type === "file" && filePathKey(item.path) === key && item.commentID === commentID)), + items.filter((item) => !(item.path === file && item.commentID === commentID)), ) }, updateComment(path: FilePath, commentID: string, next: Partial & { comment?: string }) { - const key = filePathKey(path) + const file = normalizeFilePath(path) setStore("context", "items", (items) => items.map((item) => { - if (item.type !== "file" || filePathKey(item.path) !== key || item.commentID !== commentID) return item + if (item.path !== file || item.commentID !== commentID) return item return normalizeContextItem({ ...item, ...next }) }), ) @@ -315,7 +313,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const owner = getOwner() const load = (dir: string, id: string | undefined) => { - const key = `${dir}:${id ?? WORKSPACE_KEY}` + const key = sessionScopeKey(dir, id) const existing = cache.get(key) if (existing) { cache.delete(key) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f044c46f596f..3ede566fea50 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1437,9 +1437,7 @@ export default function Layout(props: ParentProps) { if (workspaceEqual(directory, root)) return const current = currentDir() - const currentKey = workspacePathKey(current) - const deletedKey = workspacePathKey(directory) - const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey) + const shouldLeave = leaveDeletedWorkspace || (!!params.dir && workspaceEqual(current, directory)) if (!leaveDeletedWorkspace && shouldLeave) { navigateWithSidebarReset(`/${base64Encode(root)}/session`) } @@ -1461,9 +1459,8 @@ export default function Layout(props: ParentProps) { if (!result) return - if ( - workspacePathKey(routeFor(root)?.directory ?? "") === workspacePathKey(directory) - ) { + const saved = routeFor(root)?.directory + if (saved && workspaceEqual(saved, directory)) { clearLastProjectSession(root) } @@ -1485,10 +1482,9 @@ export default function Layout(props: ParentProps) { if (shouldLeave) return const nextCurrent = currentDir() - const nextKey = workspacePathKey(nextCurrent) const project = layout.projects.list().find((item) => workspaceEqual(item.worktree, root)) const dirs = project ? orderFor(root, [root, ...(project.sandboxes ?? [])]) : [root] - const valid = dirs.some((item) => workspacePathKey(item) === nextKey) + const valid = includes(dirs, nextCurrent) if (params.dir && workspaceEqual(projectRoot(nextCurrent), root) && !valid) { navigateWithSidebarReset(`/${base64Encode(root)}/session`) @@ -1594,7 +1590,7 @@ export default function Layout(props: ParentProps) { }) const handleDelete = () => { - const leaveDeletedWorkspace = !!params.dir && workspacePathKey(currentDir()) === workspacePathKey(props.directory) + const leaveDeletedWorkspace = !!params.dir && workspaceEqual(currentDir(), props.directory) if (leaveDeletedWorkspace) { navigateWithSidebarReset(`/${base64Encode(props.root)}/session`) } @@ -1854,7 +1850,7 @@ export default function Layout(props: ParentProps) { keyOf(project.worktree), mergeWorkspaceOrder( project.worktree, - result.filter((directory) => workspacePathKey(directory) !== workspacePathKey(project.worktree)), + result.filter((directory) => !workspaceEqual(directory, project.worktree)), ), ) } diff --git a/packages/app/src/utils/session-key.test.ts b/packages/app/src/utils/session-key.test.ts index 6979db092960..4a734c06db4b 100644 --- a/packages/app/src/utils/session-key.test.ts +++ b/packages/app/src/utils/session-key.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test" import { base64Encode } from "@opencode-ai/util/encode" -import { normalizeSessionKey, sessionDirKey, sessionKey, sessionParts, sessionPathHelpers } from "./session-key" +import { + normalizeSessionKey, + sessionDirKey, + sessionKey, + sessionParts, + sessionPathHelpers, + sessionScopeKey, + sessionScopeParts, +} from "./session-key" describe("session-key", () => { test("normalizes equivalent workspace aliases to one session key", () => { @@ -24,4 +32,10 @@ describe("session-key", () => { normalizeSessionKey(sessionKey(base64Encode("c:/repo"), "one")), ) }) + + test("builds cache scope keys with explicit workspace fallback", () => { + expect(sessionScopeKey("/repo")).toBe("/repo\n__workspace__") + expect(sessionScopeParts(sessionScopeKey("/repo", "one"))).toEqual({ dir: "/repo", id: "one" }) + expect(sessionScopeParts(sessionScopeKey("/repo"))).toEqual({ dir: "/repo", id: undefined }) + }) }) diff --git a/packages/app/src/utils/session-key.ts b/packages/app/src/utils/session-key.ts index 52f599b66221..2a9060616cab 100644 --- a/packages/app/src/utils/session-key.ts +++ b/packages/app/src/utils/session-key.ts @@ -2,6 +2,8 @@ import { base64Encode } from "@opencode-ai/util/encode" import { createPathHelpers, workspacePathKey } from "@/context/file/path" import { decode64 } from "./base64" +export const SESSION_SCOPE_WORKSPACE = "__workspace__" + function decodeDir(input: string) { const value = decode64(input) if (!value) return @@ -18,6 +20,21 @@ const splitSessionKey = (input: string) => { } } +export function sessionScopeKey(dir: string, id?: string) { + return `${dir}\n${id ?? SESSION_SCOPE_WORKSPACE}` +} + +export function sessionScopeParts(input: string) { + const split = input.lastIndexOf("\n") + if (split < 0) return { dir: input, id: undefined } + + const id = input.slice(split + 1) + return { + dir: input.slice(0, split), + id: id === SESSION_SCOPE_WORKSPACE ? undefined : id, + } +} + export function sessionDirKey(input: string) { const dir = decodeDir(input) if (!dir) return input From 7a4293093b2b988eec0c5e2431fae5ac2bafd35d Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:49:49 +1000 Subject: [PATCH 37/42] desktop: keep platform state migrations local --- packages/desktop-electron/src/main/index.ts | 2 - .../desktop-electron/src/main/migrate.test.ts | 102 ------------------ packages/desktop-electron/src/main/migrate.ts | 101 ----------------- 3 files changed, 205 deletions(-) delete mode 100644 packages/desktop-electron/src/main/migrate.test.ts delete mode 100644 packages/desktop-electron/src/main/migrate.ts diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 71589135ef55..45e2ba93d508 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -31,7 +31,6 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { migrate } from "./migrate" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" @@ -83,7 +82,6 @@ function setupApp() { }) void app.whenReady().then(async () => { - migrate() app.setAsDefaultProtocolClient("opencode") setDockIcon() setupAutoUpdater() diff --git a/packages/desktop-electron/src/main/migrate.test.ts b/packages/desktop-electron/src/main/migrate.test.ts deleted file mode 100644 index 81af9270ed85..000000000000 --- a/packages/desktop-electron/src/main/migrate.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" - -type Migrate = typeof import("./migrate").migrate - -const files = new Map() -const dirs = new Map() -const stores = new Map>() -const calls = { getStore: 0 } - -const app = { isPackaged: true } -const log = { - log: mock(() => undefined), - warn: mock(() => undefined), -} - -function getStore(name = "opencode.settings") { - calls.getStore += 1 - const data = stores.get(name) ?? new Map() - stores.set(name, data) - return { - has(key: string) { - return data.has(key) - }, - get(key: string) { - return data.get(key) - }, - set(key: string, value: unknown) { - data.set(key, value) - }, - } -} - -let migrate: Migrate - -beforeAll(async () => { - mock.module("electron", () => ({ app })) - mock.module("electron-log/main.js", () => ({ default: log })) - mock.module("node:fs", () => ({ - existsSync(path: string) { - return dirs.has(path) || files.has(path) - }, - readdirSync(path: string) { - const items = dirs.get(path) - if (!items) throw new Error(`missing dir ${path}`) - return items - }, - readFileSync(path: string) { - const value = files.get(path) - if (value === undefined) throw new Error(`missing file ${path}`) - return value - }, - })) - mock.module("./constants", () => ({ CHANNEL: "prod" })) - mock.module("./store", () => ({ getStore })) - - const mod = await import("./migrate") - migrate = mod.migrate -}) - -beforeEach(() => { - files.clear() - dirs.clear() - stores.clear() - calls.getStore = 0 - log.log.mockClear() - log.warn.mockClear() - process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming" -}) - -describe("migrate", () => { - test("does not touch stores before migration runs", () => { - expect(calls.getStore).toBe(0) - }) - - test("migrates tauri dat files once without overwriting electron values", () => { - const dir = "C:\\Users\\test\\AppData\\Roaming\\ai.opencode.desktop" - dirs.set(dir, ["default.dat", "opencode.settings.dat", "note.txt"]) - files.set(`${dir}\\default.dat`, JSON.stringify({ fresh: '{"x":1}', keep: '{"old":true}' })) - files.set(`${dir}\\opencode.settings.dat`, JSON.stringify({ theme: '{"dark":false}' })) - - stores.set("default.dat", new Map([["keep", '{"new":true}']])) - - migrate() - - expect(stores.get("default.dat")?.get("fresh")).toBe('{"x":1}') - expect(stores.get("default.dat")?.get("keep")).toBe('{"new":true}') - expect(stores.get("opencode.settings")?.get("theme")).toBe('{"dark":false}') - expect(stores.get("opencode.settings")?.get("tauriMigrated")).toBe(true) - - files.set(`${dir}\\default.dat`, JSON.stringify({ later: '{"y":2}' })) - migrate() - - expect(stores.get("default.dat")?.get("later")).toBeUndefined() - }) - - test("marks missing tauri data as already migrated", () => { - migrate() - - expect(stores.get("opencode.settings")?.get("tauriMigrated")).toBe(true) - expect(log.warn).not.toHaveBeenCalled() - }) -}) diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts deleted file mode 100644 index 984444068b21..000000000000 --- a/packages/desktop-electron/src/main/migrate.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { app } from "electron" -import log from "electron-log/main.js" -import { existsSync, readdirSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" -import { CHANNEL } from "./constants" -import { getStore } from "./store" - -const TAURI_MIGRATED_KEY = "tauriMigrated" - -// Resolve the directory where Tauri stored its .dat files for the given app identifier. -// Mirrors Tauri's AppLocalData / AppData resolution per OS. -function tauriDir(id: string) { - switch (process.platform) { - case "darwin": - return join(homedir(), "Library", "Application Support", id) - case "win32": - return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), id) - default: - return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), id) - } -} - -// The Tauri app identifier changes between dev/beta/prod builds. -const TAURI_APP_IDS: Record = { - dev: "ai.opencode.desktop.dev", - beta: "ai.opencode.desktop.beta", - prod: "ai.opencode.desktop", -} -function tauriAppId() { - return app.isPackaged ? TAURI_APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" -} - -// Migrate a single Tauri .dat file into the corresponding electron-store. -// `opencode.settings.dat` is special: it maps to the `opencode.settings` store -// (the electron-store name without the `.dat` extension). All other .dat files -// keep their full filename as the electron-store name so they match what the -// renderer already passes via IPC (e.g. `"default.dat"`, `"opencode.global.dat"`). -function migrateFile(datPath: string, filename: string) { - let data: Record - try { - data = JSON.parse(readFileSync(datPath, "utf-8")) - } catch (err) { - log.warn("tauri migration: failed to parse", filename, err) - return - } - - // opencode.settings.dat → the electron settings store ("opencode.settings"). - // All other .dat files keep their full filename as the store name so they match - // what the renderer passes via IPC (e.g. "default.dat", "opencode.global.dat"). - const storeName = filename === "opencode.settings.dat" ? "opencode.settings" : filename - const target = getStore(storeName) - const migrated: string[] = [] - const skipped: string[] = [] - - for (const [key, value] of Object.entries(data)) { - // Don't overwrite values the user has already set in the Electron app. - if (target.has(key)) { - skipped.push(key) - continue - } - target.set(key, value) - migrated.push(key) - } - - log.log("tauri migration: migrated", filename, "→", storeName, { migrated, skipped }) -} - -export function migrate() { - const store = getStore() - - if (store.get(TAURI_MIGRATED_KEY)) { - log.log("tauri migration: already done, skipping") - return - } - - const dir = tauriDir(tauriAppId()) - log.log("tauri migration: starting", { dir }) - - if (!existsSync(dir)) { - log.log("tauri migration: no tauri data directory found, nothing to migrate") - store.set(TAURI_MIGRATED_KEY, true) - return - } - - let items: string[] - try { - items = readdirSync(dir) - } catch (err) { - log.warn("tauri migration: failed to read directory", dir, err) - return - } - - for (const filename of items) { - if (!filename.endsWith(".dat")) continue - migrateFile(join(dir, filename), filename) - } - - log.log("tauri migration: complete") - store.set(TAURI_MIGRATED_KEY, true) -} From 1471390de719ded16bb5d2341dfbcf512c1dd096 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:13:20 +1000 Subject: [PATCH 38/42] fix(path): preserve encoded and raw workspace ingress --- .../src/control-plane/adaptors/worktree.ts | 1 + .../control-plane/workspace-server/server.ts | 2 +- packages/opencode/src/path/path.ts | 47 +++++++++++++---- packages/opencode/src/server/server.ts | 2 +- .../workspace-server-sse.test.ts | 52 +++++++++++++++++++ .../control-plane/worktree-adaptor.test.ts | 49 +++++++++++++++++ .../opencode/test/server/path-alias.test.ts | 50 ++++++++++++++++++ 7 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/test/control-plane/worktree-adaptor.test.ts diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index 08f38bb658b9..5d7ccfc4c234 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -46,6 +46,7 @@ export const WorktreeAdaptor: Adaptor = { const cfg = config(info) const { WorkspaceServer } = await import("../workspace-server/server") const req = request(input, init) + req.headers.set("x-opencode-workspace", cfg.id) req.headers.set("x-opencode-directory", cfg.directory) return WorkspaceServer.App().fetch(new Request(req.url, { ...init, headers: req.headers })) diff --git a/packages/opencode/src/control-plane/workspace-server/server.ts b/packages/opencode/src/control-plane/workspace-server/server.ts index 16f0f940aa1b..074b98185e74 100644 --- a/packages/opencode/src/control-plane/workspace-server/server.ts +++ b/packages/opencode/src/control-plane/workspace-server/server.ts @@ -47,7 +47,7 @@ export namespace WorkspaceServer { })() const directory = (() => { try { - return Path.from(raw, { encoded: true, label: "directory parameter" }) + return Path.ingress(raw, { label: "directory parameter" }) } catch (err) { throw badInput(err) } diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index a49145297d1c..33062ce500ff 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -84,15 +84,19 @@ function sentinel(input: string) { return input === "/" } -function raw(input: string, platform: NodeJS.Platform) { - if (input.startsWith("file://")) return fromURIText(input, platform) - if (platform !== "win32") return input - return input +function raw(input: string, platform: NodeJS.Platform) { + if (input.startsWith("file://")) return fromURIText(input, platform) + if (platform !== "win32") return input + return input .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) .replace(/^\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) .replace(/^\/cygdrive\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) - .replace(/^\/mnt\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) -} + .replace(/^\/mnt\/([a-zA-Z])(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:\\`) +} + +function absolute(input: string, platform: NodeJS.Platform) { + return lib(platform).isAbsolute(raw(input, platform)) +} function winabs(input: string) { return ( @@ -192,6 +196,20 @@ function decodeText(input: string, label: string) { } } +function ingressText(input: string, opts: Omit = {}) { + const label = opts.label ?? "path" + if (!input) throw new TypeError(`Expected ${label}, received empty string`) + if (input.includes("\0")) throw new TypeError(`Expected ${label} without null bytes`) + + const platform = pf(opts) + if (absolute(input, platform)) return prettyText(input, opts) + if (!input.includes("%")) return prettyText(input, opts) + + const text = decodeText(input, label) + if (absolute(text, platform)) return prettyText(text, opts) + return prettyText(input, opts) +} + function parseText(input: string, opts: ParseOpts = {}) { const label = opts.label ?? "path" if (!input) throw new TypeError(`Expected ${label}, received empty string`) @@ -316,9 +334,8 @@ export namespace Path { } export function isAbsolute(input: string, opts: Omit = {}) { - const platform = pf(opts) - return lib(platform).isAbsolute(raw(input, platform)) - } + return absolute(input, pf(opts)) + } /** * Returns the absolute app-facing path form. @@ -333,6 +350,18 @@ export namespace Path { export function from(input: string, opts: ParseOpts = {}) { return PrettyPath.make(parseText(input, opts)) } + + /** + * Parses already-decoded boundary input like headers and query params. + * + * Absolute inputs are treated as raw native paths so literal `%` segments are + * preserved. Non-absolute inputs get one decode attempt so older encoded + * headers can still become absolute paths, and malformed encoded values fail + * clearly. + */ + export function ingress(input: string, opts: Omit = {}) { + return PrettyPath.make(ingressText(input, opts)) + } /** * Returns the lookup/equality form for a path. diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ae127406aa51..5c525221290c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -213,7 +213,7 @@ export namespace Server { const directory = (() => { try { if (!rawDirectory) return Path.pretty(process.cwd()) - return Path.from(rawDirectory, { encoded: true, label: "directory parameter" }) + return Path.ingress(rawDirectory, { label: "directory parameter" }) } catch (err) { throw badInput(err) } diff --git a/packages/opencode/test/control-plane/workspace-server-sse.test.ts b/packages/opencode/test/control-plane/workspace-server-sse.test.ts index a5e3c94b12b4..ad1d905faa1b 100644 --- a/packages/opencode/test/control-plane/workspace-server-sse.test.ts +++ b/packages/opencode/test/control-plane/workspace-server-sse.test.ts @@ -110,6 +110,58 @@ describe("control-plane/workspace-server SSE", () => { } }) + test("accepts literal percent directories before Instance.provide", async () => { + await using tmp = await tmpdir({ git: true }) + const dir = path.join(tmp.path, "100% ready") + await fs.mkdir(dir) + const app = WorkspaceServer.App() + const stop = new AbortController() + const provide = Instance.provide + const spy = spyOn(Instance, "provide").mockImplementation((input) => provide(input)) + + try { + const response = await app.request("/event", { + signal: stop.signal, + headers: { + "x-opencode-workspace": "wrk_test_workspace", + "x-opencode-directory": dir, + }, + }) + + expect(response.status).toBe(200) + expect(spy.mock.calls[0]?.[0]?.directory).toBe(dir) + } finally { + stop.abort() + spy.mockRestore() + } + }) + + test("decodes encoded directory headers before Instance.provide", async () => { + await using tmp = await tmpdir({ git: true }) + const dir = path.join(tmp.path, "100% ready") + await fs.mkdir(dir) + const app = WorkspaceServer.App() + const stop = new AbortController() + const provide = Instance.provide + const spy = spyOn(Instance, "provide").mockImplementation((input) => provide(input)) + + try { + const response = await app.request("/event", { + signal: stop.signal, + headers: { + "x-opencode-workspace": "wrk_test_workspace", + "x-opencode-directory": encodeURIComponent(dir), + }, + }) + + expect(response.status).toBe(200) + expect(spy.mock.calls[0]?.[0]?.directory).toBe(dir) + } finally { + stop.abort() + spy.mockRestore() + } + }) + test("rejects invalid workspace ids before bootstrapping", async () => { const app = WorkspaceServer.App() const response = await app.request("/event", { diff --git a/packages/opencode/test/control-plane/worktree-adaptor.test.ts b/packages/opencode/test/control-plane/worktree-adaptor.test.ts new file mode 100644 index 000000000000..82ed14ede0cb --- /dev/null +++ b/packages/opencode/test/control-plane/worktree-adaptor.test.ts @@ -0,0 +1,49 @@ +import { expect, mock, test } from "bun:test" +import path from "path" + +import { Path } from "../../src/path/path" +import { ProjectID } from "../../src/project/schema" +import { WorkspaceID } from "../../src/control-plane/schema" + +let seen: { workspace: string | null; directory: string | null } | undefined + +mock.module("../../src/control-plane/workspace-server/server", () => ({ + WorkspaceServer: { + App() { + return { + fetch(input: Request) { + seen = { + workspace: input.headers.get("x-opencode-workspace"), + directory: input.headers.get("x-opencode-directory"), + } + return Promise.resolve(new Response("ok")) + }, + } + }, + }, +})) + +const { WorktreeAdaptor } = await import("../../src/control-plane/adaptors/worktree") + +test("worktree adaptor forwards workspace and directory headers", async () => { + seen = undefined + const dir = path.join(process.cwd(), "100% ready") + + const res = await WorktreeAdaptor.fetch( + { + id: WorkspaceID.parse("wrk_test_workspace"), + type: "worktree", + branch: "dev", + name: "test", + directory: Path.pretty(dir), + extra: null, + projectID: ProjectID.make("project_test"), + }, + "/event", + ) + + expect(res.status).toBe(200) + expect(seen).toBeDefined() + expect(seen!.workspace).toBe("wrk_test_workspace") + expect(seen!.directory).toBe(dir) +}) diff --git a/packages/opencode/test/server/path-alias.test.ts b/packages/opencode/test/server/path-alias.test.ts index d62485a91088..511dc36c2128 100644 --- a/packages/opencode/test/server/path-alias.test.ts +++ b/packages/opencode/test/server/path-alias.test.ts @@ -49,6 +49,56 @@ test("server ingress keeps alias directories", async () => { } }) +test("server ingress accepts literal percent directories", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "100% ready") + await fs.mkdir(dir) + + const app = Server.createApp({}) + const response = await app.request("/path", { + headers: { + "x-opencode-directory": dir, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + directory: dir, + }) +}) + +test("server ingress decodes encoded directory headers once", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "100% ready") + await fs.mkdir(dir) + + const app = Server.createApp({}) + const response = await app.request("/path", { + headers: { + "x-opencode-directory": encodeURIComponent(dir), + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + directory: dir, + }) +}) + +test("server ingress keeps query directory behavior", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "100% ready") + await fs.mkdir(dir) + + const app = Server.createApp({}) + const response = await app.request(`/path?directory=${encodeURIComponent(dir)}`) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + directory: dir, + }) +}) + test("server ingress rejects invalid workspace ids", async () => { const app = Server.createApp({}) const response = await app.request("/path", { From 3a57419befad1e5dc45abe0b857f5e6a577e45fb Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:13:30 +1000 Subject: [PATCH 39/42] path: make strong path zod schemas JSON-safe --- packages/opencode/src/path/schema.ts | 32 +++++++++++++++++----- packages/opencode/src/util/schema.ts | 27 +++++++++++++++--- packages/opencode/test/path/schema.test.ts | 13 +++++++++ 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/path/schema.ts b/packages/opencode/src/path/schema.ts index facf20d1fc34..cef89727206a 100644 --- a/packages/opencode/src/path/schema.ts +++ b/packages/opencode/src/path/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import path from "path" -import { withStatics, zodFrom } from "@/util/schema" +import { withStatics, zodString } from "@/util/schema" /** * These brands document which path shape a string is expected to already be in. @@ -63,6 +63,8 @@ function parseURI(input: string) { export type PrettyPath = typeof prettyPathSchema.Type +const pretty = zodString(parsePretty) + export const PrettyPath = prettyPathSchema.pipe( withStatics((schema: typeof prettyPathSchema) => ({ make: (input: string) => schema.makeUnsafe(input), @@ -70,7 +72,8 @@ export const PrettyPath = prettyPathSchema.pipe( assert: (input: string): asserts input is PrettyPath => { parsePretty(input) }, - zod: zodFrom(parsePretty), + input: pretty.input, + zod: pretty.zod, })), ) @@ -79,6 +82,8 @@ const pathKeySchema = Schema.String.pipe(Schema.brand("PathKey")) export type PathKey = typeof pathKeySchema.Type +const key = zodString(parseKey) + export const PathKey = pathKeySchema.pipe( withStatics((schema: typeof pathKeySchema) => ({ make: (input: string) => schema.makeUnsafe(input), @@ -86,7 +91,8 @@ export const PathKey = pathKeySchema.pipe( assert: (input: string): asserts input is PathKey => { parseKey(input) }, - zod: zodFrom(parseKey), + input: key.input, + zod: key.zod, })), ) @@ -95,6 +101,8 @@ const posixPathSchema = Schema.String.pipe(Schema.brand("PosixPath")) export type PosixPath = typeof posixPathSchema.Type +const posix = zodString(parsePosix) + export const PosixPath = posixPathSchema.pipe( withStatics((schema: typeof posixPathSchema) => ({ make: (input: string) => schema.makeUnsafe(input), @@ -102,7 +110,8 @@ export const PosixPath = posixPathSchema.pipe( assert: (input: string): asserts input is PosixPath => { parsePosix(input) }, - zod: zodFrom(parsePosix), + input: posix.input, + zod: posix.zod, })), ) @@ -111,6 +120,8 @@ const relativePathSchema = Schema.String.pipe(Schema.brand("RelativePath")) export type RelativePath = typeof relativePathSchema.Type +const relative = zodString(parseRelative) + export const RelativePath = relativePathSchema.pipe( withStatics((schema: typeof relativePathSchema) => ({ make: (input: string) => schema.makeUnsafe(input), @@ -118,7 +129,8 @@ export const RelativePath = relativePathSchema.pipe( assert: (input: string): asserts input is RelativePath => { parseRelative(input) }, - zod: zodFrom(parseRelative), + input: relative.input, + zod: relative.zod, })), ) @@ -127,6 +139,8 @@ const repoPathSchema = Schema.String.pipe(Schema.brand("RepoPath")) export type RepoPath = typeof repoPathSchema.Type +const repo = zodString(parseRepo) + export const RepoPath = repoPathSchema.pipe( withStatics((schema: typeof repoPathSchema) => ({ make: (input: string) => schema.makeUnsafe(input), @@ -134,7 +148,8 @@ export const RepoPath = repoPathSchema.pipe( assert: (input: string): asserts input is RepoPath => { parseRepo(input) }, - zod: zodFrom(parseRepo), + input: repo.input, + zod: repo.zod, })), ) @@ -143,6 +158,8 @@ const fileUriSchema = Schema.String.pipe(Schema.brand("FileURI")) export type FileURI = typeof fileUriSchema.Type +const uri = zodString(parseURI) + export const FileURI = fileUriSchema.pipe( withStatics((schema: typeof fileUriSchema) => ({ make: (input: string) => schema.makeUnsafe(input), @@ -150,6 +167,7 @@ export const FileURI = fileUriSchema.pipe( assert: (input: string): asserts input is FileURI => { parseURI(input) }, - zod: zodFrom(parseURI), + input: uri.input, + zod: uri.zod, })), ) diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index fb2eb76e5ce5..ebd912f182dc 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -17,20 +17,39 @@ export const withStatics = (schema: S): S & M => Object.assign(schema, methods(schema)) +function issue(ctx: z.core.$RefinementCtx, err: unknown) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: err instanceof Error ? err.message : String(err), + }) +} + export function zodFrom(parse: (input: string) => T): z.ZodType { return z.string().transform((input, ctx) => { try { return parse(input) } catch (err) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: err instanceof Error ? err.message : String(err), - }) + issue(ctx, err) return z.NEVER } }) } +export function zodString(parse: (input: string) => T) { + const input = z.string().superRefine((value, ctx) => { + try { + parse(value) + } catch (err) { + issue(ctx, err) + } + }) + + return { + input, + zod: input as unknown as z.ZodType, + } +} + declare const NewtypeBrand: unique symbol type NewtypeBrand = { readonly [NewtypeBrand]: Tag } diff --git a/packages/opencode/test/path/schema.test.ts b/packages/opencode/test/path/schema.test.ts index fdf97643ab13..c2f2d29e8e57 100644 --- a/packages/opencode/test/path/schema.test.ts +++ b/packages/opencode/test/path/schema.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import z from "zod" import { WorkspaceID } from "../../src/control-plane/schema" import { PrettyPath } from "../../src/path/schema" @@ -14,4 +15,16 @@ describe("runtime-safe path/id constructors", () => { test("rejects relative pretty paths", () => { expect(() => PrettyPath.parse("relative/path")).toThrow('Expected absolute filesystem path, received "relative/path"') }) + + test("exports pretty path input schema to JSON schema", () => { + const schema = z.toJSONSchema(z.object({ path: PrettyPath.zod })) + expect(schema).toMatchObject({ + type: "object", + properties: { + path: { + type: "string", + }, + }, + }) + }) }) From a772ffd2693c02688277297184d7122892a5fc50 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:13:44 +1000 Subject: [PATCH 40/42] fix(tool): keep task schema JSON-compatible --- packages/opencode/src/tool/task.ts | 5 ++-- packages/opencode/test/tool/task.test.ts | 29 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/tool/task.test.ts diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 6c73da3fbfd3..11c604510fdb 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -15,7 +15,8 @@ const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), prompt: z.string().describe("The task for the agent to perform"), subagent_type: z.string().describe("The type of specialized agent to use for this task"), - task_id: SessionID.zod + task_id: z + .string() .describe( "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", ) @@ -64,7 +65,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { const session = await iife(async () => { if (params.task_id) { - const found = await Session.get(params.task_id).catch(() => {}) + const found = await Session.get(SessionID.parse(params.task_id)).catch(() => {}) if (found) return found } diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts new file mode 100644 index 000000000000..50f04145a745 --- /dev/null +++ b/packages/opencode/test/tool/task.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import z from "zod" + +import { Instance } from "../../src/project/instance" +import { TaskTool } from "../../src/tool/task" +import { tmpdir } from "../fixture/fixture" + +describe("task tool schema", () => { + test("exports task_id as a JSON-schema-safe string", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await TaskTool.init() + const schema = z.toJSONSchema(tool.parameters) + + expect(schema).toMatchObject({ + type: "object", + properties: { + task_id: { + type: "string", + }, + }, + }) + }, + }) + }) +}) From 89c474792e13a617e255bde5e1642bfd1e6d7c57 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:13:56 +1000 Subject: [PATCH 41/42] app: preserve path values in workspace events --- packages/app/src/context/global-sdk.tsx | 17 +++++++++-------- packages/app/src/context/notification-state.ts | 5 +---- packages/app/src/context/notification.test.ts | 5 +++-- packages/app/src/context/notification.tsx | 5 +---- packages/app/src/context/sdk.tsx | 8 ++++---- packages/app/src/pages/layout.tsx | 15 ++++++++------- .../app/src/pages/layout/sidebar-project.tsx | 6 ++++-- .../app/src/pages/layout/sidebar-workspace.tsx | 8 ++++---- 8 files changed, 34 insertions(+), 35 deletions(-) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index bee661a23cbd..01119c9cb684 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -44,7 +44,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo [key: string]: Event }>() - type Queued = { directory: string; payload: Event } + type Queued = { directory: string; id: string; payload: Event } const FLUSH_FRAME_MS = 16 const STREAM_YIELD_MS = 8 const RECONNECT_DELAY_MS = 250 @@ -56,7 +56,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo let timer: ReturnType | undefined let last = 0 - const dir = (directory: string) => (directory === "global" ? directory : workspacePathKey(directory)) + const keyDir = (directory: string) => (directory === "global" ? directory : workspacePathKey(directory)) const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` @@ -88,7 +88,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo for (const event of events) { if (skip && event.payload.type === "message.part.delta") { const props = event.payload.properties - if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue + if (skip.has(deltaKey(event.id, props.messageID, props.partID))) continue } emitter.emit(event.directory, event.payload) } @@ -151,22 +151,23 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo for await (const event of events.stream) { resetHeartbeat() streamErrorLogged = false - const directory = dir(event.directory ?? "global") + const directory = event.directory ?? "global" + const id = keyDir(directory) const payload = event.payload - const k = key(directory, payload) + const k = key(id, payload) if (k) { const i = coalesced.get(k) if (i !== undefined) { - queue[i] = { directory, payload } + queue[i] = { directory, id, payload } if (payload.type === "message.part.updated") { const part = payload.properties.part - staleDeltas.add(deltaKey(directory, part.messageID, part.id)) + staleDeltas.add(deltaKey(id, part.messageID, part.id)) } continue } coalesced.set(k, queue.length) } - queue.push({ directory, payload }) + queue.push({ directory, id, payload }) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue diff --git a/packages/app/src/context/notification-state.ts b/packages/app/src/context/notification-state.ts index 1741f84d9e42..5ce2251d0a39 100644 --- a/packages/app/src/context/notification-state.ts +++ b/packages/app/src/context/notification-state.ts @@ -55,10 +55,7 @@ function createNotificationIndex(): NotificationIndex { export const projectKey = (directory: WorkspacePath) => workspacePathKey(directory) export function normalizeNotification(notification: Notification): Notification { - if (!notification.directory) return notification - const directory = projectKey(notification.directory) - if (directory === notification.directory) return notification - return { ...notification, directory } + return notification } export function migrateNotifications(value: unknown) { diff --git a/packages/app/src/context/notification.test.ts b/packages/app/src/context/notification.test.ts index 45aa27706471..13874ccadd6c 100644 --- a/packages/app/src/context/notification.test.ts +++ b/packages/app/src/context/notification.test.ts @@ -19,11 +19,12 @@ describe("notification directory normalization", () => { expect(index.project.unseenCount["c:/repo"]).toBe(2) expect(index.project.unseenHasError["c:/repo"]).toBe(true) + expect(index.project.all["c:/repo"].map((item) => item.directory)).toEqual(["C:/Repo", "c:\\repo\\"]) }) - test("migrates persisted notifications onto normalized directory keys", () => { + test("preserves stored notification directory values during migration", () => { expect(migrateNotifications({ list: [{ type: "turn-complete", directory: "C:\\Repo\\", time: 1, viewed: true }] })).toEqual({ - list: [{ type: "turn-complete", directory: "c:/repo", time: 1, viewed: true }], + list: [{ type: "turn-complete", directory: "C:\\Repo\\", time: 1, viewed: true }], }) }) }) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 454592a67bf9..bbf15b8146de 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -46,10 +46,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const empty: Notification[] = [] - const currentDirectory = createMemo(() => { - const value = decode64(params.dir) - return value ? projectKey(value) : value - }) + const currentDirectory = createMemo(() => decode64(params.dir)) const currentSession = createMemo(() => params.id) diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx index 4892f6b72781..bdeefcfc9229 100644 --- a/packages/app/src/context/sdk.tsx +++ b/packages/app/src/context/sdk.tsx @@ -1,8 +1,8 @@ import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { pathEqual } from "@opencode-ai/util/path" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" -import { workspacePathKey } from "@/context/file/path" import { useGlobalSDK } from "./global-sdk" type SDKEventMap = { @@ -15,7 +15,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const globalSDK = useGlobalSDK() const directory = createMemo(props.directory) - const key = createMemo(() => workspacePathKey(directory())) const client = createMemo(() => globalSDK.createClient({ directory: directory(), @@ -26,8 +25,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const emitter = createGlobalEmitter() createEffect(() => { - const unsub = globalSDK.event.on(key(), (event) => { - emitter.emit(event.type, event) + const unsub = globalSDK.event.listen((event) => { + if (!pathEqual(event.name, directory())) return + emitter.emit(event.details.type, event.details) }) onCleanup(unsub) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 3ede566fea50..d83955a627a2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -461,7 +461,7 @@ export default function Layout(props: ParentProps) { e.details?.type === "permission.replied" ) { const props = e.details.properties as { sessionID: string } - const sessionKey = `${e.name}:${props.sessionID}` + const sessionKey = alertKey(e.name, props.sessionID) dismissSessionAlert(sessionKey) return } @@ -478,7 +478,7 @@ export default function Layout(props: ParentProps) { const [store] = globalSync.child(directory, { bootstrap: false }) const session = store.session.find((s) => s.id === props.sessionID) - const sessionKey = `${directory}:${props.sessionID}` + const sessionKey = alertKey(directory, props.sessionID) const sessionTitle = session?.title ?? language.t("command.session.new") const projectName = getFilename(directory) @@ -537,12 +537,12 @@ export default function Layout(props: ParentProps) { createEffect(() => { const currentSession = params.id if (!currentDir() || !currentSession) return - const sessionKey = `${currentDir()}:${currentSession}` + const sessionKey = alertKey(currentDir(), currentSession) dismissSessionAlert(sessionKey) const [store] = globalSync.child(currentDir(), { bootstrap: false }) const childSessions = store.session.filter((s) => s.parentID === currentSession) for (const child of childSessions) { - dismissSessionAlert(`${currentDir()}:${child.id}`) + dismissSessionAlert(alertKey(currentDir(), child.id)) } }) }) @@ -586,6 +586,7 @@ export default function Layout(props: ParentProps) { }) const keyOf = (directory: WorkspacePath) => workspacePathKey(directory) + const alertKey = (directory: WorkspacePath, sessionID: string) => `${keyOf(directory)}:${sessionID}` const routeFor = (root: WorkspacePath) => store.lastProjectSession[keyOf(root)] const orderFor = (root: WorkspacePath, dirs: WorkspacePath[]) => effectiveWorkspaceOrder(root, dirs, store.workspaceOrder[keyOf(root)]) @@ -712,7 +713,7 @@ export default function Layout(props: ParentProps) { seen: lru, keep: sessionID, limit: PREFETCH_MAX_SESSIONS_PER_DIR, - preserve: directory === params.dir && params.id ? [params.id] : undefined, + preserve: workspaceEqual(directory, currentDir()) && params.id ? [params.id] : undefined, }) } @@ -1736,7 +1737,7 @@ export default function Layout(props: ParentProps) { return } - if (root === activeRoute.sessionProject) return + if (workspaceEqual(root, activeRoute.sessionProject)) return activeRoute.sessionProject = rememberSessionRoute(directory, id, root) }, ), @@ -1809,7 +1810,7 @@ export default function Layout(props: ParentProps) { const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const ordered = orderFor(local, dirs) - if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)] + if (pending && extra) return [local, extra, ...ordered.filter((item) => !workspaceEqual(item, local))] if (!extra) return ordered if (pending) return ordered return [...ordered, extra] diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 9be3701f10dc..2218ad97e2d9 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -292,7 +292,7 @@ export const SortableProject = (props: { const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) const active = createMemo( - () => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree), + () => state.menu || (preview() ? state.open : overlay() && workspaceEqual(props.ctx.hoverProject(), props.project.worktree)), ) createEffect(() => { @@ -310,7 +310,9 @@ export const SortableProject = (props: { const label = (directory: WorkspacePath) => { const [data] = globalSync.child(directory, { bootstrap: false }) const kind = - directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + workspaceEqual(directory, props.project.worktree) + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id) return `${kind} : ${name}` } diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 86ede774e634..3d8bce2f53e2 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" -import { childMapByParent, sortedRootSessions } from "./helpers" +import { childMapByParent, sortedRootSessions, workspaceEqual } from "./helpers" type InlineEditorComponent = (props: { id: string @@ -71,7 +71,7 @@ export const WorkspaceDragOverlay = (props: { const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) const kind = - directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + workspaceEqual(directory, project.worktree) ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) return `${kind} : ${name}` }) @@ -322,8 +322,8 @@ export const SortableWorkspace = (props: { const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const children = createMemo(() => childMapByParent(workspaceStore.session)) - const local = createMemo(() => props.directory === props.project.worktree) - const active = createMemo(() => props.ctx.currentDir() === props.directory) + const local = createMemo(() => workspaceEqual(props.directory, props.project.worktree)) + const active = createMemo(() => workspaceEqual(props.ctx.currentDir(), props.directory)) const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) From c49148269eeb157fe583b3a8ae1db8ccd0d73a02 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:45:38 +1000 Subject: [PATCH 42/42] tui: fix session export typecheck --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2de0c5f1e83e..67280a9f49e9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -58,6 +58,7 @@ import { useDialog } from "../../ui/dialog" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" import type { PromptInfo } from "../../component/prompt/history" +import { Filesystem } from "@/util/filesystem" import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" @@ -2135,7 +2136,8 @@ function ApplyPatch(props: ToolProps) { function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { if (file.type === "delete") return "# Deleted " + normalizePath(file.relativePath) if (file.type === "add") return "# Created " + normalizePath(file.relativePath) - if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + normalizePath(file.relativePath) + if (file.type === "move") + return "# Moved " + normalizePath(file.filePath) + " → " + normalizePath(file.relativePath) return "← Patched " + normalizePath(file.relativePath) }