From 4c70ea28d2a44941ea65729863d0fa6e965321ce Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 22:33:39 -0400 Subject: [PATCH] fix(tui): scope Zed editor context to containing workspaces (#25211) --- .../src/cli/cmd/tui/context/editor-zed.ts | 13 ++- .../test/cli/tui/editor-context-zed.test.ts | 88 ++++++++++++++++++- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 5b7bf1cf4a..6805f0b666 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -189,13 +189,20 @@ export function resolveZedDbPath() { path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"), ].filter((item): item is string => Boolean(item)) - return candidates.find((item) => Filesystem.stat(item)?.isFile()) + return candidates.find((item) => isFile(item)) +} + +function isFile(item: string) { + try { + return Filesystem.stat(item)?.isFile() === true + } catch { + return false + } } function scoreZedWorkspace(workspacePaths: string | null, cwd: string) { return zedWorkspacePaths(workspacePaths).reduce((score, item) => { - if (pathContains(item, cwd)) return Math.max(score, 2) - if (pathContains(cwd, item)) return Math.max(score, 1) + if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length) return score }, 0) } diff --git a/packages/opencode/test/cli/tui/editor-context-zed.test.ts b/packages/opencode/test/cli/tui/editor-context-zed.test.ts index 9a9bca8c5e..0287b0910f 100644 --- a/packages/opencode/test/cli/tui/editor-context-zed.test.ts +++ b/packages/opencode/test/cli/tui/editor-context-zed.test.ts @@ -1,7 +1,9 @@ import { Database } from "bun:sqlite" +import { mkdir, symlink } from "node:fs/promises" +import os from "node:os" import path from "node:path" -import { expect, test } from "bun:test" -import { offsetToPosition, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed" +import { expect, spyOn, test } from "bun:test" +import { offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed" import { tmpdir } from "../../fixture/fixture" type ZedFixtureOptions = { @@ -66,6 +68,23 @@ test("offsetToPosition converts Zed offsets to 1-based editor positions", () => }) }) +test("resolveZedDbPath skips candidates that cannot be stated", async () => { + await using tmp = await tmpdir() + const loop = path.join(tmp.path, "loop") + await symlink(loop, loop) + const home = spyOn(os, "homedir").mockImplementation(() => tmp.path) + const previous = process.env.OPENCODE_ZED_DB + process.env.OPENCODE_ZED_DB = loop + + try { + expect(resolveZedDbPath()).toBeUndefined() + } finally { + if (previous === undefined) delete process.env.OPENCODE_ZED_DB + else process.env.OPENCODE_ZED_DB = previous + home.mockRestore() + } +}) + test("resolveZedSelection returns active editor selection", async () => { await using tmp = await tmpdir() const fixture = await writeZedFixture(tmp.path) @@ -251,6 +270,71 @@ test("resolveZedSelection returns empty when no workspace matches", async () => expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" }) }) +test("resolveZedSelection matches a Zed workspace that contains the session directory", async () => { + await using tmp = await tmpdir() + const fixture = await writeZedFixture(tmp.path) + + expect(await resolveZedSelection(fixture.dbPath, path.join(tmp.path, "packages", "app"))).toEqual({ + type: "selection", + selection: { + filePath: fixture.filePath, + source: "zed", + ranges: [ + { + text: "two", + selection: { + start: { line: 2, character: 1 }, + end: { line: 2, character: 4 }, + }, + }, + ], + }, + }) +}) + +test("resolveZedSelection prefers the most specific containing Zed workspace", async () => { + await using tmp = await tmpdir() + const fixture = await writeZedFixture(tmp.path) + const child = path.join(tmp.path, "packages") + const childFile = path.join(child, "child.ts") + await mkdir(child, { recursive: true }) + await Bun.write(childFile, "child") + + const db = new Database(fixture.dbPath) + db.run("insert into workspaces values (2, ?, ?)", [JSON.stringify([child]), "2026-01-01"]) + db.run("insert into panes values (2, 2, 1)") + db.run("insert into items values (2, 2, 2, 1, ?)", ["Editor"]) + db.run("insert into editors values (2, 2, ?, ?)", [childFile, "child"]) + db.run("insert into editor_selections values (2, 2, 0, 5)") + db.close() + + expect(await resolveZedSelection(fixture.dbPath, path.join(child, "app"))).toEqual({ + type: "selection", + selection: { + filePath: childFile, + source: "zed", + ranges: [ + { + text: "child", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 6 }, + }, + }, + ], + }, + }) +}) + +test("resolveZedSelection ignores a Zed workspace nested inside the session directory", async () => { + await using tmp = await tmpdir() + const child = path.join(tmp.path, "effect-lab") + await mkdir(child, { recursive: true }) + const fixture = await writeZedFixture(child) + + expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" }) +}) + test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => { await using tmp = await tmpdir() const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false })