diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts new file mode 100644 index 0000000000..cbf995f8d0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -0,0 +1,172 @@ +import { Database } from "bun:sqlite" +import os from "node:os" +import path from "node:path" +import z from "zod" +import { Filesystem } from "@/util" +import type { EditorSelection } from "./editor" + +const ZedEditorRowSchema = z.object({ + editor_id: z.number(), + workspace_id: z.number(), + workspace_paths: z.string().nullable(), + timestamp: z.string(), + buffer_path: z.string().nullable(), + selection_start: z.number().nullable(), + selection_end: z.number().nullable(), +}) + +const ZedEditorContentsSchema = z.object({ + contents: z.string().nullable(), +}) + +type ZedEditorRow = z.infer + +export async function resolveZedSelection(dbPath: string): Promise { + const row = queryZedActiveEditor(dbPath, process.cwd()) + if (!row?.buffer_path || row.selection_start == null || row.selection_end == null) return + + const text = + queryZedEditorContents(dbPath, row) ?? + (await Bun.file(row.buffer_path) + .text() + .catch(() => undefined)) + if (text == null) return + + const startOffset = Math.min(row.selection_start, row.selection_end) + const endOffset = Math.max(row.selection_start, row.selection_end) + + return { + text: text.slice(startOffset, endOffset), + filePath: row.buffer_path, + selection: offsetsToSelection(text, startOffset, endOffset), + } +} + +function queryZedActiveEditor(dbPath: string, cwd: string) { + let db: Database | undefined + try { + db = new Database(dbPath, { readonly: true }) + return db + .query( + `select + e.item_id as editor_id, + e.workspace_id as workspace_id, + w.paths as workspace_paths, + w.timestamp as timestamp, + e.buffer_path as buffer_path, + s.start as selection_start, + s.end as selection_end + from items i + join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id + join workspaces w on w.workspace_id = i.workspace_id + join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id + left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id + where i.active = 1 and p.active = 1 and i.kind = 'Editor' and e.buffer_path is not null + order by w.timestamp desc`, + ) + .all() + .flatMap((row) => { + const parsed = ZedEditorRowSchema.safeParse(row) + return parsed.success ? [parsed.data] : [] + }) + .map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) })) + .filter((entry) => entry.score > 0) + .sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row + } catch { + return + } finally { + db?.close() + } +} + +function queryZedEditorContents(dbPath: string, row: ZedEditorRow) { + let db: Database | undefined + try { + db = new Database(dbPath, { readonly: true }) + return ZedEditorContentsSchema.safeParse( + db + .query( + `select contents + from editors + where item_id = $editorID and workspace_id = $workspaceID`, + ) + .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }), + ).data?.contents + } catch { + return + } finally { + db?.close() + } +} + +export function resolveZedDbPath() { + const candidates = [ + process.env.OPENCODE_ZED_DB, + path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"), + 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()) +} + +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) + return score + }, 0) +} + +function zedWorkspacePaths(value: string | null) { + if (!value) return [] + const parsed = parseJson(value) + if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string") + return value.split(/\r?\n/).filter(Boolean) +} + +export function offsetToPosition(text: string, offset: number) { + return offsetsToSelection(text, offset, offset).start +} + +function offsetsToSelection(text: string, startOffset: number, endOffset: number) { + const start = Math.max(0, Math.min(startOffset, text.length)) + const end = Math.max(0, Math.min(endOffset, text.length)) + let line = 1 + let lineStart = 0 + let startPosition = position(line, lineStart, start) + let endPosition = position(line, lineStart, end) + + for (let index = 0; index <= end; index++) { + if (index === start) startPosition = position(line, lineStart, index) + if (index === end) { + endPosition = position(line, lineStart, index) + break + } + if (text[index] === "\n") { + line += 1 + lineStart = index + 1 + } + } + + return { start: startPosition, end: endPosition } +} + +function position(line: number, lineStart: number, offset: number) { + return { + line, + character: offset - lineStart + 1, + } +} + +function pathContains(parent: string, child: string) { + const relative = path.relative(path.resolve(parent), path.resolve(child)) + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) +} + +function parseJson(value: string) { + try { + return JSON.parse(value) as unknown + } catch { + return + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index 4e6c97f6e5..75c5440f5d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -4,7 +4,9 @@ import path from "node:path" import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import z from "zod" +import { isRecord } from "@/util/record" import { createSimpleContext } from "./helper" +import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" const MCP_PROTOCOL_VERSION = "2025-11-25" @@ -90,6 +92,8 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create let reconnect: ReturnType | undefined let attempt = 0 let requestID = 0 + let zedSelection: Promise | undefined + let lastZedSelectionKey: string | undefined const pending = new Map() const send = (payload: JsonRpcMessage) => { @@ -114,7 +118,29 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const connection = resolveEditorConnection() if (!connection) { - setStore("status", "disabled") + const dbPath = resolveZedDbPath() + if (!dbPath) { + setStore("status", "disabled") + scheduleReconnect(1000) + return + } + zedSelection ??= resolveZedSelection(dbPath) + .then((selection) => { + if (closed || socket) return + const key = editorSelectionKey(selection) + if (key !== lastZedSelectionKey) { + lastZedSelectionKey = key + setStore("selection", selection) + setStore("status", selection ? "connected" : "disabled") + } + }) + .catch(() => { + if (closed || socket) return + setStore("status", "disabled") + }) + .finally(() => { + zedSelection = undefined + }) scheduleReconnect(1000) return } @@ -196,7 +222,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return { enabled() { - return Boolean(resolveEditorConnection()) + return Boolean(resolveEditorConnection() || resolveZedDbPath()) }, connected() { return store.status === "connected" @@ -289,6 +315,18 @@ function scoreEditorLock(lock: EditorLockFile, cwd: string) { return workspaceMatch * 1_000_000_000_000 + lock.mtimeMs } +function editorSelectionKey(selection: EditorSelection | undefined) { + if (!selection) return "" + return [ + selection.filePath, + selection.selection.start.line, + selection.selection.start.character, + selection.selection.end.line, + selection.selection.end.character, + selection.text, + ].join("\0") +} + function pathContains(parent: string, child: string) { const relative = path.relative(path.resolve(parent), path.resolve(child)) return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) @@ -313,7 +351,3 @@ function parseMessage(value: unknown) { return } } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} diff --git a/packages/opencode/test/cli/tui/editor-context.test.ts b/packages/opencode/test/cli/tui/editor-context.test.ts new file mode 100644 index 0000000000..c605029ca3 --- /dev/null +++ b/packages/opencode/test/cli/tui/editor-context.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test" +import { offsetToPosition } from "../../../src/cli/cmd/tui/context/editor-zed" + +test("offsetToPosition converts Zed offsets to 1-based editor positions", () => { + expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 }) + expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 }) + expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 }) + expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 }) +})