From 925d596f4ffe1d4b580280f3252aac22d25a5d7b Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:05:09 +1000 Subject: [PATCH 1/8] fix(opencode): restore Windows modified file tracking --- packages/opencode/src/cli/cmd/tui/thread.ts | 6 ++- packages/opencode/src/project/instance.ts | 27 +++++----- packages/opencode/src/server/server.ts | 17 +++--- packages/opencode/src/util/filesystem.ts | 7 ++- packages/opencode/src/util/which.ts | 4 +- packages/opencode/test/config/config.test.ts | 52 +++++++++++++++++++ .../opencode/test/util/filesystem.test.ts | 41 +++++++++++++++ packages/opencode/test/util/which.test.ts | 18 +++++++ 8 files changed, 148 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index f53cc3925523..07589347368d 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -111,8 +111,10 @@ export const TuiThreadCommand = cmd({ } // Resolve relative paths against PWD to preserve behavior when using --cwd flag - const root = process.env.PWD ?? process.cwd() - const cwd = args.project ? path.resolve(root, args.project) : process.cwd() + const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) + const cwd = Filesystem.resolve( + args.project ? (path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) : root, + ) const file = await target() try { process.chdir(cwd) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 59a896e77bc2..47f64c5d1320 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -32,15 +32,16 @@ function emit(directory: string) { function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { return iife(async () => { + const directory = Filesystem.resolve(input.directory) const ctx = input.project && input.worktree ? { - directory: input.directory, + directory, worktree: input.worktree, project: input.project, } - : await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ - directory: input.directory, + : await Project.fromDirectory(directory).then(({ project, sandbox }) => ({ + directory, worktree: sandbox, project, })) @@ -62,13 +63,14 @@ function track(directory: string, next: Promise) { export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - let existing = cache.get(input.directory) + const directory = Filesystem.resolve(input.directory) + let existing = cache.get(directory) if (!existing) { - Log.Default.info("creating instance", { directory: input.directory }) + Log.Default.info("creating instance", { directory }) existing = track( - input.directory, + directory, boot({ - directory: input.directory, + directory, init: input.init, }), ) @@ -103,11 +105,12 @@ export const Instance = { return State.create(() => Instance.directory, init, dispose) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - Log.Default.info("reloading instance", { directory: input.directory }) - await State.dispose(input.directory) - cache.delete(input.directory) - const next = track(input.directory, boot(input)) - emit(input.directory) + const directory = Filesystem.resolve(input.directory) + Log.Default.info("reloading instance", { directory }) + await State.dispose(directory) + cache.delete(directory) + const next = track(directory, boot({ ...input, directory })) + emit(directory) return await next }, async dispose() { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6ea66be98581..e353198af78b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -38,6 +38,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 { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -198,13 +199,15 @@ export namespace Server { if (c.req.path === "/log") return next() const workspaceID = 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 = (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })() + const directory = Filesystem.resolve( + (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })(), + ) return WorkspaceContext.provide({ workspaceID, diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index a87aaeb98669..e0573cffcdc5 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -2,7 +2,7 @@ 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, relative } from "path" +import { dirname, join, relative, resolve as pathResolve } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "./glob" @@ -113,10 +113,15 @@ export namespace Filesystem { } } + export function resolve(p: string): string { + return normalizePath(pathResolve(windowsPath(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//... diff --git a/packages/opencode/src/util/which.ts b/packages/opencode/src/util/which.ts index 78e651e8e849..81da2572170f 100644 --- a/packages/opencode/src/util/which.ts +++ b/packages/opencode/src/util/which.ts @@ -3,8 +3,8 @@ import whichPkg from "which" export function which(cmd: string, env?: NodeJS.ProcessEnv) { const result = whichPkg.sync(cmd, { nothrow: true, - path: env?.PATH, - pathExt: env?.PATHEXT, + path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path, + pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt, }) return typeof result === "string" ? result : null } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 40ab97449fbc..2a9a47f2d56d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -25,6 +25,34 @@ async function writeConfig(dir: string, config: object, name = "opencode.json") await Filesystem.write(path.join(dir, name), JSON.stringify(config)) } +async function check(map: (dir: string) => string) { + if (process.platform !== "win32") return + await using globalTmp = await tmpdir() + await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + Config.global.reset() + try { + await writeConfig(globalTmp.path, { + $schema: "https://opencode.ai/config.json", + snapshot: false, + }) + await Instance.provide({ + directory: map(tmp.path), + fn: async () => { + const cfg = await Config.get() + expect(cfg.snapshot).toBe(true) + expect(Instance.directory).toBe(tmp.path) + expect(Instance.project.id).not.toBe("global") + }, + }) + } finally { + await Instance.disposeAll() + ;(Global.Path as { config: string }).config = prev + Config.global.reset() + } +} + test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -56,6 +84,30 @@ test("loads JSON config file", async () => { }) }) +test("loads project config from Git Bash paths on Windows", async () => { + await check((dir) => { + const drive = dir[0].toLowerCase() + const rest = dir.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` + }) +}) + +test("loads project config from MSYS2 paths on Windows", async () => { + await check((dir) => { + const drive = dir[0].toLowerCase() + const rest = dir.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` + }) +}) + +test("loads project config from Cygwin paths on Windows", async () => { + await check((dir) => { + const drive = dir[0].toLowerCase() + const rest = dir.slice(2).replaceAll("\\", "/") + return `/cygdrive/${drive}${rest}` + }) +}) + test("ignores legacy tui keys in opencode config", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index a6255db88f56..55b284b542df 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -440,4 +440,45 @@ describe("filesystem", () => { expect(await fs.readFile(filepath, "utf-8")).toBe(content) }) }) + + describe("resolve()", () => { + 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)) + }) + + test("resolves Git Bash paths on Windows", async () => { + if (process.platform !== "win32") return + 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)) + }) + + test("resolves MSYS2 paths on Windows", async () => { + if (process.platform !== "win32") return + 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)) + }) + + test("resolves Cygwin paths on Windows", async () => { + if (process.platform !== "win32") return + 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)) + }) + + test("resolves WSL mount paths on Windows", async () => { + if (process.platform !== "win32") return + 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)) + }) + }) }) diff --git a/packages/opencode/test/util/which.test.ts b/packages/opencode/test/util/which.test.ts index 323173b18129..70c2fb2d9fc9 100644 --- a/packages/opencode/test/util/which.test.ts +++ b/packages/opencode/test/util/which.test.ts @@ -22,6 +22,13 @@ function env(PATH: string): NodeJS.ProcessEnv { } } +function envPath(Path: string): NodeJS.ProcessEnv { + return { + Path, + PathExt: process.env["PathExt"] ?? process.env["PATHEXT"], + } +} + function same(a: string | null, b: string) { if (process.platform === "win32") { expect(a?.toLowerCase()).toBe(b.toLowerCase()) @@ -79,4 +86,15 @@ describe("util.which", () => { expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file) }) + + test("uses Windows Path casing fallback", async () => { + if (process.platform !== "win32") return + + await using tmp = await tmpdir() + const bin = path.join(tmp.path, "bin") + await fs.mkdir(bin) + const file = await cmd(bin, "mixed") + + same(which("mixed", envPath(bin)), file) + }) }) From ee2310c9c540343a2a7758bfcf31682520534cd1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:20:00 +1000 Subject: [PATCH 2/8] fix(opencode): handle Windows drive-root shell paths --- packages/opencode/src/util/filesystem.ts | 9 ++--- packages/opencode/test/config/config.test.ts | 2 +- .../opencode/test/util/filesystem.test.ts | 35 +++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index e0573cffcdc5..fd747418db13 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -117,17 +117,18 @@ export namespace Filesystem { return normalizePath(pathResolve(windowsPath(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. 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()}:/`) // Git Bash for Windows paths are typically //... - .replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) // Cygwin git paths are typically /cygdrive//... - .replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) // WSL paths are typically /mnt//... - .replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`) + .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) ) } export function overlaps(a: string, b: string) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 2a9a47f2d56d..869d87554cf3 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -42,7 +42,7 @@ async function check(map: (dir: string) => string) { fn: async () => { const cfg = await Config.get() expect(cfg.snapshot).toBe(true) - expect(Instance.directory).toBe(tmp.path) + expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) expect(Instance.project.id).not.toBe("global") }, }) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 55b284b542df..09825b508d10 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -449,6 +449,13 @@ describe("filesystem", () => { expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(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}:/`)) + }) + test("resolves Git Bash paths on Windows", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() @@ -457,6 +464,13 @@ describe("filesystem", () => { expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path)) }) + test("resolves Git Bash drive roots on Windows", async () => { + 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()}:/`)) + }) + test("resolves MSYS2 paths on Windows", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() @@ -465,6 +479,13 @@ describe("filesystem", () => { expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path)) }) + test("resolves MSYS2 drive roots on Windows", async () => { + 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()}:/`)) + }) + test("resolves Cygwin paths on Windows", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() @@ -473,6 +494,13 @@ describe("filesystem", () => { expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(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()}:/`)) + }) + test("resolves WSL mount paths on Windows", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() @@ -480,5 +508,12 @@ describe("filesystem", () => { const rest = tmp.path.slice(2).replaceAll("\\", "/") expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(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()}:/`)) + }) }) }) From b5db2813497be6b50639c4fd399fe331a9ba8020 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:27:56 +1000 Subject: [PATCH 3/8] fix(opencode): tighten Windows path boundary coverage --- packages/opencode/src/project/instance.ts | 7 +++---- packages/opencode/src/util/filesystem.ts | 2 +- packages/opencode/test/config/config.test.ts | 11 ++-------- .../opencode/test/util/filesystem.test.ts | 21 ++++--------------- 4 files changed, 10 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 47f64c5d1320..df44a3a229c9 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -32,16 +32,15 @@ function emit(directory: string) { function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { return iife(async () => { - const directory = Filesystem.resolve(input.directory) const ctx = input.project && input.worktree ? { - directory, + directory: input.directory, worktree: input.worktree, project: input.project, } - : await Project.fromDirectory(directory).then(({ project, sandbox }) => ({ - directory, + : await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ + directory: input.directory, worktree: sandbox, project, })) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index fd747418db13..fb1f5ab9e533 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -113,11 +113,11 @@ 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. export function resolve(p: string): string { return normalizePath(pathResolve(windowsPath(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. export function windowsPath(p: string): string { if (process.platform !== "win32") return p return ( diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 869d87554cf3..96fac8cca2ed 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -84,15 +84,8 @@ test("loads JSON config file", async () => { }) }) -test("loads project config from Git Bash paths on Windows", async () => { - await check((dir) => { - const drive = dir[0].toLowerCase() - const rest = dir.slice(2).replaceAll("\\", "/") - return `/${drive}${rest}` - }) -}) - -test("loads project config from MSYS2 paths on Windows", async () => { +test("loads project config from Git Bash and MSYS2 paths on Windows", async () => { + // Git Bash and MSYS2 both use //... paths on Windows. await check((dir) => { const drive = dir[0].toLowerCase() const rest = dir.slice(2).replaceAll("\\", "/") diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 09825b508d10..c757e3424dbe 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -456,7 +456,8 @@ describe("filesystem", () => { expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`)) }) - test("resolves Git Bash paths on Windows", async () => { + test("resolves Git Bash and MSYS2 paths on Windows", async () => { + // Git Bash and MSYS2 both use //... paths on Windows. if (process.platform !== "win32") return await using tmp = await tmpdir() const drive = tmp.path[0].toLowerCase() @@ -464,22 +465,8 @@ describe("filesystem", () => { expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path)) }) - test("resolves Git Bash drive roots on Windows", async () => { - 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()}:/`)) - }) - - test("resolves MSYS2 paths on Windows", async () => { - if (process.platform !== "win32") return - 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)) - }) - - test("resolves MSYS2 drive roots on Windows", async () => { + test("resolves Git Bash and MSYS2 drive roots on Windows", async () => { + // Git Bash and MSYS2 both use / paths on Windows. if (process.platform !== "win32") return await using tmp = await tmpdir() const drive = tmp.path[0].toLowerCase() From 20eff4b70694b087a621d9f1491b195dd01c15b0 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:32:47 +1000 Subject: [PATCH 4/8] fix(opencode): remove redundant Windows path resolution --- packages/opencode/src/cli/cmd/tui/thread.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 07589347368d..f778c96a8034 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -112,9 +112,9 @@ export const TuiThreadCommand = cmd({ // Resolve relative paths against PWD to preserve behavior when using --cwd flag const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) - const cwd = Filesystem.resolve( - args.project ? (path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) : root, - ) + const cwd = args.project + ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) + : root const file = await target() try { process.chdir(cwd) From 9f6755fc4b549640013956c4fbc710a25f8df68c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:06:30 +1000 Subject: [PATCH 5/8] fix(app): use canonical workspace directory identity --- packages/app/e2e/actions.ts | 5 +- packages/app/e2e/utils.ts | 6 ++ packages/app/src/pages/directory-layout.tsx | 84 +++++++++++++++------ 3 files changed, 67 insertions(+), 28 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 5d244ba02f75..8787b70f5315 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { execSync } from "node:child_process" -import { modKey, serverUrl } from "./utils" +import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils" import { dropdownMenuTriggerSelector, dropdownMenuContentSelector, @@ -18,7 +18,6 @@ import { workspaceItemSelector, workspaceMenuTriggerSelector, } from "./selectors" -import type { createSdk } from "./utils" export async function defocus(page: Page) { await page @@ -190,7 +189,7 @@ export async function createTestProject() { stdio: "ignore", }) - return root + return resolveDirectory(root) } export async function cleanupTestProject(directory: string) { diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index e2d61984d542..c5bbba9d88c5 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -14,6 +14,12 @@ export function createSdk(directory?: string) { return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) } +export async function resolveDirectory(directory: string) { + return createSdk(directory) + .path.get() + .then((x) => x.data?.directory ?? directory) +} + export async function getWorktree() { const sdk = createSdk() const result = await sdk.path.get() diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 71b52180f2e7..ae2a9cedc7fc 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,26 +1,27 @@ -import { createEffect, createMemo, Show, type ParentProps } from "solid-js" +import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" import { createStore } from "solid-js/store" -import { useNavigate, useParams } from "@solidjs/router" +import { useLocation, useNavigate, useParams } from "@solidjs/router" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" +import { useGlobalSDK } from "@/context/global-sdk" import { DataProvider } from "@opencode-ai/ui/context" +import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" - function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { - const params = useParams() const navigate = useNavigate() const sync = useSync() + const slug = createMemo(() => base64Encode(props.directory)) return ( navigate(`/${params.dir}/session/${sessionID}`)} - onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} + onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} + onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} > {props.children} @@ -30,31 +31,64 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { export default function Layout(props: ParentProps) { const params = useParams() const navigate = useNavigate() + const location = useLocation() const language = useLanguage() - const [store, setStore] = createStore({ invalid: "" }) - const directory = createMemo(() => { - return decode64(params.dir) ?? "" - }) + const globalSDK = useGlobalSDK() + const directory = createMemo(() => decode64(params.dir) ?? "") + const [state, setState] = createStore({ invalid: "", resolved: "" }) createEffect(() => { if (!params.dir) return - if (directory()) return - if (store.invalid === params.dir) return - setStore("invalid", params.dir) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) + const raw = directory() + if (!raw) { + if (state.invalid === params.dir) return + setState("invalid", params.dir) + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + return + } + + const current = params.dir + setState("resolved", "") + globalSDK + .createClient({ + directory: raw, + throwOnError: true, + }) + .path.get() + .then((x) => { + if (params.dir !== current) return + const next = x.data?.directory ?? raw + batch(() => { + setState("invalid", "") + setState("resolved", next) + }) + if (next === raw) return + const path = location.pathname.slice(current.length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + .catch(() => { + if (params.dir !== current) return + batch(() => { + setState("invalid", "") + setState("resolved", raw) + }) + }) }) + return ( - - - - {props.children} - - + + {(resolved) => ( + + + {props.children} + + + )} ) } From bb01546b9e1bec0727b7bc9e30e8c3f4d6d79cb8 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:01:34 +1000 Subject: [PATCH 6/8] fix(app): keep directory providers mounted during path resolution --- packages/app/src/pages/directory-layout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index ae2a9cedc7fc..fdf321f2dc34 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -53,7 +53,6 @@ export default function Layout(props: ParentProps) { } const current = params.dir - setState("resolved", "") globalSDK .createClient({ directory: raw, From c35e13427daf3ff44ebd069c4b54ba02ecc7e166 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:18:42 +1000 Subject: [PATCH 7/8] test(app): follow canonical workspace slugs --- .../projects/workspace-new-session.spec.ts | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index f33972cc3a31..cb1294259ac6 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -9,6 +9,26 @@ function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" } +async function waitSlug(page: Page, skip: string[] = []) { + let prev = "" + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + if (!slug) return "" + if (skip.includes(slug)) return "" + if (slug !== prev) { + prev = slug + return "" + } + return slug + }, + { timeout: 45_000 }, + ) + .not.toBe("") + return slugFromUrl(page.url()) +} + async function waitWorkspaceReady(page: Page, slug: string) { await openSidebar(page) await expect @@ -31,20 +51,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - await expect - .poll( - () => { - const slug = slugFromUrl(page.url()) - if (!slug) return "" - if (slug === root) return "" - if (seen.includes(slug)) return "" - return slug - }, - { timeout: 45_000 }, - ) - .not.toBe("") - - const slug = slugFromUrl(page.url()) + const slug = await waitSlug(page, [root, ...seen]) const directory = base64Decode(slug) if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) return { slug, directory } @@ -60,12 +67,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) { await expect(button).toBeVisible() await button.click({ force: true }) - await expect.poll(() => slugFromUrl(page.url())).toBe(slug) - await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`)) + const next = await waitSlug(page) + await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + return next } async function createSessionFromWorkspace(page: Page, slug: string, text: string) { - await openWorkspaceNewSession(page, slug) + const next = await openWorkspaceNewSession(page, slug) const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -76,13 +84,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await prompt.press("Enter") - await expect.poll(() => slugFromUrl(page.url())).toBe(slug) + await expect.poll(() => slugFromUrl(page.url())).toBe(next) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`)) - return sessionID + await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) + return { sessionID, slug: next } } async function sessionDirectory(directory: string, sessionID: string) { @@ -114,17 +122,17 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a await waitWorkspaceReady(page, second.slug) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) - sessions.push(firstSession) + sessions.push(firstSession.sessionID) const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`) - sessions.push(secondSession) + sessions.push(secondSession.sessionID) const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`) - sessions.push(thirdSession) + sessions.push(thirdSession.sessionID) - await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory) - await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory) - await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory) + await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory) + await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory) + await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory) } finally { const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)] await Promise.all( From b3879539a451cbc4294beec6b6b75884b3556376 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:35:16 +1000 Subject: [PATCH 8/8] test(app): follow canonical workspace delete redirects --- packages/app/e2e/projects/workspaces.spec.ts | 45 ++++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 41c6bea8f6d9..805b45e98978 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -22,24 +22,34 @@ function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" } -async function setupWorkspaceTest(page: Page, project: { slug: string }) { - const rootSlug = project.slug - await openSidebar(page) - - await setWorkspacesEnabled(page, rootSlug, true) - - await page.getByRole("button", { name: "New workspace" }).first().click() +async function waitSlug(page: Page, skip: string[] = []) { + let prev = "" await expect .poll( () => { const slug = slugFromUrl(page.url()) - return slug.length > 0 && slug !== rootSlug + if (!slug) return "" + if (skip.includes(slug)) return "" + if (slug !== prev) { + prev = slug + return "" + } + return slug }, { timeout: 45_000 }, ) - .toBe(true) + .not.toBe("") + return slugFromUrl(page.url()) +} + +async function setupWorkspaceTest(page: Page, project: { slug: string }) { + const rootSlug = project.slug + await openSidebar(page) + + await setWorkspacesEnabled(page, rootSlug, true) - const slug = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + const slug = await waitSlug(page, [rootSlug]) const dir = base64Decode(slug) await openSidebar(page) @@ -91,18 +101,7 @@ test("can create a workspace", async ({ page, withProject }) => { await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await page.getByRole("button", { name: "New workspace" }).first().click() - - await expect - .poll( - () => { - const currentSlug = slugFromUrl(page.url()) - return currentSlug.length > 0 && currentSlug !== slug - }, - { timeout: 45_000 }, - ) - .toBe(true) - - const workspaceSlug = slugFromUrl(page.url()) + const workspaceSlug = await waitSlug(page, [slug]) const workspaceDir = base64Decode(workspaceSlug) await openSidebar(page) @@ -279,7 +278,7 @@ test("can delete a workspace", async ({ page, withProject }) => { await clickMenuItem(menu, /^Delete$/i, { force: true }) await confirmDialog(page, /^Delete workspace$/i) - await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) + await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory) await expect .poll(