Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor(tui): simplify Zed context polling
  • Loading branch information
kitlangton committed Apr 25, 2026
commit 601c06881198273759418639791bf0d8d0a571a1
160 changes: 105 additions & 55 deletions packages/opencode/src/cli/cmd/tui/context/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import path from "node:path"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import z from "zod"
import { Filesystem } from "@/util"
import { isRecord } from "@/util/record"
import { createSimpleContext } from "./helper"

const MCP_PROTOCOL_VERSION = "2025-11-25"
Expand Down Expand Up @@ -52,6 +54,20 @@ const EditorServerInfoSchema = z.object({
.optional(),
})

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 JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
export type EditorMention = z.infer<typeof EditorMentionSchema>
Expand All @@ -71,14 +87,7 @@ type EditorLockFile = {
mtimeMs: number
}

type ZedEditorRow = {
workspace_paths: string | null
timestamp: string
buffer_path: string | null
contents: string | null
selection_start: number | null
selection_end: number | null
}
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>

export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
Expand All @@ -100,6 +109,8 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
const pending = new Map<number, string>()

const send = (payload: JsonRpcMessage) => {
Expand All @@ -124,21 +135,29 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create

const connection = resolveEditorConnection()
if (!connection) {
if (!resolveZedDbPath()) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect(1000)
return
}
void resolveZedSelection()
zedSelection ??= resolveZedSelection(dbPath)
.then((selection) => {
if (closed || socket) return
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
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
}
Expand Down Expand Up @@ -265,30 +284,24 @@ function resolveEditorConnection(): EditorConnection | undefined {
}
}

async function resolveZedSelection() {
const dbPath = resolveZedDbPath()
if (!dbPath) return

async function resolveZedSelection(dbPath: string) {
const row = queryZedActiveEditor(dbPath, process.cwd())
if (!row?.buffer_path || row.selection_start == null || row.selection_end == null) return

const text =
row.contents ??
queryZedEditorContents(dbPath, row) ??
(await Bun.file(row.buffer_path)
.text()
.catch(() => undefined))
if (text == null) return

const start = offsetToPosition(text, Math.min(row.selection_start, row.selection_end))
const end = offsetToPosition(text, Math.max(row.selection_start, row.selection_end))
const startOffset = Math.min(row.selection_start, row.selection_end)
const endOffset = Math.max(row.selection_start, row.selection_end)

return {
text: text.slice(
Math.min(row.selection_start, row.selection_end),
Math.max(row.selection_start, row.selection_end),
),
text: text.slice(startOffset, endOffset),
filePath: row.buffer_path,
selection: { start, end },
selection: offsetsToSelection(text, startOffset, endOffset),
}
}

Expand All @@ -299,10 +312,11 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
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,
e.contents as contents,
s.start as selection_start,
s.end as selection_end
from items i
Expand All @@ -314,7 +328,10 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
order by w.timestamp desc`,
)
.all()
.filter(isZedEditorRow)
.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
Expand All @@ -325,14 +342,34 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
}
}

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()
}
}

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) => statSafe(item)?.isFile())
return candidates.find((item) => Filesystem.stat(item)?.isFile())
}

function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
Expand All @@ -351,11 +388,36 @@ function zedWorkspacePaths(value: string | null) {
}

export function offsetToPosition(text: string, offset: number) {
const before = text.slice(0, Math.max(0, Math.min(offset, text.length)))
const lineStart = before.lastIndexOf("\n")
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: before.split("\n").length,
character: lineStart === -1 ? before.length + 1 : before.length - lineStart,
line,
character: offset - lineStart + 1,
}
}

Expand Down Expand Up @@ -402,19 +464,23 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined {
}
}

function statSafe(filePath: string) {
try {
return statSync(filePath)
} catch {
return
}
}

function scoreEditorLock(lock: EditorLockFile, cwd: string) {
const workspaceMatch = lock.workspaceFolders.some((folder) => pathContains(folder, cwd)) ? 1 : 0
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))
Expand Down Expand Up @@ -447,19 +513,3 @@ function parseJson(value: string) {
return
}
}

function isZedEditorRow(value: unknown): value is ZedEditorRow {
if (!isRecord(value)) return false
return (
(typeof value.workspace_paths === "string" || value.workspace_paths === null) &&
typeof value.timestamp === "string" &&
(typeof value.buffer_path === "string" || value.buffer_path === null) &&
(typeof value.contents === "string" || value.contents === null) &&
(typeof value.selection_start === "number" || value.selection_start === null) &&
(typeof value.selection_end === "number" || value.selection_end === null)
)
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
Loading