196 lines
6.2 KiB
TypeScript
196 lines
6.2 KiB
TypeScript
import { Database } from "bun:sqlite"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import z from "zod"
|
|
import { Filesystem } from "@/util/filesystem"
|
|
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<typeof ZedEditorRowSchema>
|
|
|
|
export type ZedSelectionResult =
|
|
| { type: "selection"; selection: EditorSelection }
|
|
| { 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 =
|
|
contents.type === "contents" && contents.contents != null
|
|
? contents.contents
|
|
: await Bun.file(row.buffer_path).text().catch(() => undefined)
|
|
if (text == null) return { type: "unavailable" }
|
|
|
|
const startOffset = Math.min(row.selection_start, row.selection_end)
|
|
const endOffset = Math.max(row.selection_start, row.selection_end)
|
|
|
|
return {
|
|
type: "selection",
|
|
selection: {
|
|
text: text.slice(startOffset, endOffset),
|
|
filePath: row.buffer_path,
|
|
source: "zed",
|
|
selection: offsetsToSelection(text, startOffset, endOffset),
|
|
},
|
|
}
|
|
}
|
|
|
|
function queryZedActiveEditor(dbPath: string, cwd: string) {
|
|
let db: Database | undefined
|
|
try {
|
|
db = new Database(dbPath, { readonly: true })
|
|
const raw = 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()
|
|
|
|
const rows = raw
|
|
.flatMap((row) => {
|
|
const parsed = ZedEditorRowSchema.safeParse(row)
|
|
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) }))
|
|
.filter((entry) => entry.score > 0)
|
|
.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 {
|
|
return { type: "unavailable" as const }
|
|
} finally {
|
|
db?.close()
|
|
}
|
|
}
|
|
|
|
function queryZedEditorContents(dbPath: string, row: ZedEditorRow) {
|
|
let db: Database | undefined
|
|
try {
|
|
db = new Database(dbPath, { readonly: true })
|
|
const parsed = 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 }),
|
|
)
|
|
if (!parsed.success) return { type: "unavailable" as const }
|
|
return { type: "contents" as const, contents: parsed.data.contents }
|
|
} catch {
|
|
return { type: "unavailable" as const }
|
|
} 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
|
|
}
|
|
}
|