fix(tui): stabilize Zed editor context polling (#24656)
This commit is contained in:
@@ -764,6 +764,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
|
return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
|
||||||
})(),
|
})(),
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
|
metadata: {
|
||||||
|
kind: "editor_context",
|
||||||
|
source: editorSelection.source ?? "editor",
|
||||||
|
filePath: editorSelection.filePath,
|
||||||
|
selection: editorSelection.selection,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
|
|||||||
@@ -21,24 +21,37 @@ const ZedEditorContentsSchema = z.object({
|
|||||||
|
|
||||||
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
|
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
|
||||||
|
|
||||||
export async function resolveZedSelection(dbPath: string): Promise<EditorSelection | undefined> {
|
export type ZedSelectionResult =
|
||||||
const row = queryZedActiveEditor(dbPath, process.cwd())
|
| { type: "selection"; selection: EditorSelection }
|
||||||
if (!row?.buffer_path || row.selection_start == null || row.selection_end == null) return
|
| { type: "empty" }
|
||||||
|
| { type: "unavailable" }
|
||||||
|
|
||||||
|
export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
|
||||||
|
const active = queryZedActiveEditor(dbPath, cwd)
|
||||||
|
if (active.type !== "row") return active
|
||||||
|
|
||||||
|
const row = active.row
|
||||||
|
if (!row.buffer_path) return { type: "empty" }
|
||||||
|
if (row.selection_start == null || row.selection_end == null) return { type: "unavailable" }
|
||||||
|
|
||||||
|
const contents = queryZedEditorContents(dbPath, row)
|
||||||
const text =
|
const text =
|
||||||
queryZedEditorContents(dbPath, row) ??
|
contents.type === "contents" && contents.contents != null
|
||||||
(await Bun.file(row.buffer_path)
|
? contents.contents
|
||||||
.text()
|
: await Bun.file(row.buffer_path).text().catch(() => undefined)
|
||||||
.catch(() => undefined))
|
if (text == null) return { type: "unavailable" }
|
||||||
if (text == null) return
|
|
||||||
|
|
||||||
const startOffset = Math.min(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)
|
const endOffset = Math.max(row.selection_start, row.selection_end)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: text.slice(startOffset, endOffset),
|
type: "selection",
|
||||||
filePath: row.buffer_path,
|
selection: {
|
||||||
selection: offsetsToSelection(text, startOffset, endOffset),
|
text: text.slice(startOffset, endOffset),
|
||||||
|
filePath: row.buffer_path,
|
||||||
|
source: "zed",
|
||||||
|
selection: offsetsToSelection(text, startOffset, endOffset),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +59,7 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
|
|||||||
let db: Database | undefined
|
let db: Database | undefined
|
||||||
try {
|
try {
|
||||||
db = new Database(dbPath, { readonly: true })
|
db = new Database(dbPath, { readonly: true })
|
||||||
return db
|
const raw = db
|
||||||
.query(
|
.query(
|
||||||
`select
|
`select
|
||||||
e.item_id as editor_id,
|
e.item_id as editor_id,
|
||||||
@@ -65,15 +78,23 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
|
|||||||
order by w.timestamp desc`,
|
order by w.timestamp desc`,
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
|
const rows = raw
|
||||||
.flatMap((row) => {
|
.flatMap((row) => {
|
||||||
const parsed = ZedEditorRowSchema.safeParse(row)
|
const parsed = ZedEditorRowSchema.safeParse(row)
|
||||||
return parsed.success ? [parsed.data] : []
|
return parsed.success ? [parsed.data] : []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
|
||||||
|
|
||||||
|
const row = rows
|
||||||
.map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
|
.map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
|
||||||
.filter((entry) => entry.score > 0)
|
.filter((entry) => entry.score > 0)
|
||||||
.sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
|
.sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
|
||||||
|
if (!row) return { type: "empty" as const }
|
||||||
|
return { type: "row" as const, row }
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return { type: "unavailable" as const }
|
||||||
} finally {
|
} finally {
|
||||||
db?.close()
|
db?.close()
|
||||||
}
|
}
|
||||||
@@ -83,7 +104,7 @@ function queryZedEditorContents(dbPath: string, row: ZedEditorRow) {
|
|||||||
let db: Database | undefined
|
let db: Database | undefined
|
||||||
try {
|
try {
|
||||||
db = new Database(dbPath, { readonly: true })
|
db = new Database(dbPath, { readonly: true })
|
||||||
return ZedEditorContentsSchema.safeParse(
|
const parsed = ZedEditorContentsSchema.safeParse(
|
||||||
db
|
db
|
||||||
.query(
|
.query(
|
||||||
`select contents
|
`select contents
|
||||||
@@ -91,9 +112,11 @@ function queryZedEditorContents(dbPath: string, row: ZedEditorRow) {
|
|||||||
where item_id = $editorID and workspace_id = $workspaceID`,
|
where item_id = $editorID and workspace_id = $workspaceID`,
|
||||||
)
|
)
|
||||||
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
|
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
|
||||||
).data?.contents
|
)
|
||||||
|
if (!parsed.success) return { type: "unavailable" as const }
|
||||||
|
return { type: "contents" as const, contents: parsed.data.contents }
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return { type: "unavailable" as const }
|
||||||
} finally {
|
} finally {
|
||||||
db?.close()
|
db?.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const PositionSchema = z.object({
|
|||||||
const EditorSelectionSchema = z.object({
|
const EditorSelectionSchema = z.object({
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
filePath: z.string(),
|
filePath: z.string(),
|
||||||
|
source: z.enum(["websocket", "zed"]).optional(),
|
||||||
selection: z.object({
|
selection: z.object({
|
||||||
start: PositionSchema,
|
start: PositionSchema,
|
||||||
end: PositionSchema,
|
end: PositionSchema,
|
||||||
@@ -125,8 +126,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
zedSelection ??= resolveZedSelection(dbPath)
|
zedSelection ??= resolveZedSelection(dbPath)
|
||||||
.then((selection) => {
|
.then((result) => {
|
||||||
if (closed || socket) return
|
if (closed || socket) return
|
||||||
|
if (result.type === "unavailable") return
|
||||||
|
const selection = result.type === "selection" ? result.selection : undefined
|
||||||
const key = editorSelectionKey(selection)
|
const key = editorSelectionKey(selection)
|
||||||
if (key !== lastZedSelectionKey) {
|
if (key !== lastZedSelectionKey) {
|
||||||
lastZedSelectionKey = key
|
lastZedSelectionKey = key
|
||||||
@@ -135,8 +138,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (closed || socket) return
|
// Keep the last known Zed selection for transient polling failures.
|
||||||
setStore("status", "disabled")
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
zedSelection = undefined
|
zedSelection = undefined
|
||||||
@@ -171,7 +173,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
|||||||
const selection =
|
const selection =
|
||||||
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
||||||
if (selection?.success) {
|
if (selection?.success) {
|
||||||
setStore("selection", selection.data)
|
setStore("selection", { ...selection.data, source: "websocket" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
|
import { Database } from "bun:sqlite"
|
||||||
|
import path from "node:path"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { offsetToPosition } from "../../../src/cli/cmd/tui/context/editor-zed"
|
import { offsetToPosition, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed"
|
||||||
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
|
||||||
|
type ZedFixtureOptions = {
|
||||||
|
workspacePaths?: string | null
|
||||||
|
selectionStart?: number | null
|
||||||
|
selectionEnd?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
|
||||||
|
const dbPath = path.join(dir, "zed.sqlite")
|
||||||
|
const filePath = path.join(dir, "file.ts")
|
||||||
|
await Bun.write(filePath, "one\ntwo\nthree")
|
||||||
|
|
||||||
|
const db = new Database(dbPath)
|
||||||
|
db.run("create table workspaces (workspace_id integer, paths text, timestamp text)")
|
||||||
|
db.run("create table panes (pane_id integer, workspace_id integer, active integer)")
|
||||||
|
db.run("create table items (item_id integer, workspace_id integer, pane_id integer, active integer, kind text)")
|
||||||
|
db.run("create table editors (item_id integer, workspace_id integer, buffer_path text, contents text)")
|
||||||
|
db.run("create table editor_selections (editor_id integer, workspace_id integer, start integer, end integer)")
|
||||||
|
db.run("insert into workspaces values (1, ?, ?)", [options.workspacePaths ?? JSON.stringify([dir]), "2026-04-27"])
|
||||||
|
db.run("insert into panes values (1, 1, 1)")
|
||||||
|
db.run("insert into items values (1, 1, 1, 1, 'Editor')")
|
||||||
|
db.run("insert into editors values (1, 1, ?, ?)", [filePath, "one\ntwo\nthree"])
|
||||||
|
db.run(
|
||||||
|
"insert into editor_selections values (1, 1, ?, ?)",
|
||||||
|
[
|
||||||
|
options.selectionStart === undefined ? 4 : options.selectionStart,
|
||||||
|
options.selectionEnd === undefined ? 7 : options.selectionEnd,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return { dbPath, filePath }
|
||||||
|
}
|
||||||
|
|
||||||
test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
|
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", 0)).toEqual({ line: 1, character: 1 })
|
||||||
@@ -7,3 +43,43 @@ test("offsetToPosition converts Zed offsets to 1-based editor positions", () =>
|
|||||||
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
|
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
|
||||||
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
|
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("resolveZedSelection returns active editor selection", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const fixture = await writeZedFixture(tmp.path)
|
||||||
|
|
||||||
|
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
|
||||||
|
type: "selection",
|
||||||
|
selection: {
|
||||||
|
text: "two",
|
||||||
|
filePath: fixture.filePath,
|
||||||
|
source: "zed",
|
||||||
|
selection: {
|
||||||
|
start: { line: 2, character: 1 },
|
||||||
|
end: { line: 2, character: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveZedSelection returns empty when no workspace matches", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const fixture = await writeZedFixture(tmp.path, {
|
||||||
|
workspacePaths: JSON.stringify([path.join(path.dirname(tmp.path), "other-workspace")]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveZedSelection returns unavailable when the database cannot be queried", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
expect(await resolveZedSelection(path.join(tmp.path, "missing.sqlite"), tmp.path)).toEqual({ type: "unavailable" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveZedSelection returns unavailable when active selection is missing offsets", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const fixture = await writeZedFixture(tmp.path, { selectionStart: null, selectionEnd: null })
|
||||||
|
|
||||||
|
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user