| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286 |
- import { Database } from "bun:sqlite"
- import { statSync } from "node:fs"
- import { readFile as readFileAsync } from "node:fs/promises"
- import os from "node:os"
- import path from "node:path"
- import { Option, Schema } from "effect"
- import type { EditorSelection } from "./context/editor"
- const ZedEditorRowSchema = Schema.Struct({
- item_kind: Schema.String,
- editor_id: Schema.NullOr(Schema.Number),
- workspace_id: Schema.Number,
- workspace_paths: Schema.NullOr(Schema.String),
- timestamp: Schema.String,
- buffer_path: Schema.NullOr(Schema.String),
- })
- const ZedSelectionRowSchema = Schema.Struct({
- selection_start: Schema.NullOr(Schema.Number),
- selection_end: Schema.NullOr(Schema.Number),
- })
- const ZedEditorContentsSchema = Schema.Struct({
- contents: Schema.NullOr(Schema.String),
- })
- const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
- const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
- const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
- const utf8 = new TextEncoder()
- type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
- type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
- 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" }
- const selections = queryZedEditorSelections(dbPath, row)
- if (selections.type !== "selections") return selections
- const byteRanges = selections.selections
- .flatMap((selection) => {
- if (selection.selection_start == null || selection.selection_end == null) return []
- return [
- {
- start: Math.min(selection.selection_start, selection.selection_end),
- end: Math.max(selection.selection_start, selection.selection_end),
- },
- ]
- })
- .sort((left, right) => left.start - right.start || left.end - right.end)
- if (byteRanges.length === 0) return { type: "unavailable" }
- const contents = queryZedEditorContents(dbPath, row)
- const text =
- contents.type === "contents" && contents.contents != null
- ? contents.contents
- : await readFileAsync(row.buffer_path, "utf8").catch(() => undefined)
- if (text == null) return { type: "unavailable" }
- const ranges = byteRanges.map((range) => {
- const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
- const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
- return {
- text: text.slice(startOffset, endOffset),
- selection: offsetsToSelection(text, startOffset, endOffset),
- }
- })
- return {
- type: "selection",
- selection: {
- filePath: row.buffer_path,
- source: "zed",
- ranges,
- },
- }
- }
- function queryZedActiveEditor(dbPath: string, cwd: string) {
- let db: Database | undefined
- try {
- db = new Database(dbPath, { readonly: true })
- const raw = db
- .query(
- `select
- i.kind as item_kind,
- e.item_id as editor_id,
- i.workspace_id as workspace_id,
- w.paths as workspace_paths,
- w.timestamp as timestamp,
- e.buffer_path as buffer_path
- 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
- left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
- where i.active = 1 and p.active = 1
- order by w.timestamp desc`,
- )
- .all()
- const rows = raw.flatMap((row) => {
- const parsed = decodeZedEditorRow(row)
- return Option.isSome(parsed) ? [parsed.value] : []
- })
- 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 }
- if (row.item_kind !== "Editor") return { type: "unavailable" as const }
- if (!isZedActiveEditorRow(row)) return { type: "empty" as const }
- return { type: "row" as const, row }
- } catch {
- return { type: "unavailable" as const }
- } finally {
- db?.close()
- }
- }
- function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
- let db: Database | undefined
- try {
- db = new Database(dbPath, { readonly: true })
- const raw = db
- .query(
- `select
- start as selection_start,
- end as selection_end
- from editor_selections
- where editor_id = $editorID and workspace_id = $workspaceID`,
- )
- .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
- const selections = raw.flatMap((selection) => {
- const parsed = decodeZedSelectionRow(selection)
- return Option.isSome(parsed) ? [parsed.value] : []
- })
- if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
- return { type: "selections" as const, selections }
- } catch {
- return { type: "unavailable" as const }
- } finally {
- db?.close()
- }
- }
- function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
- let db: Database | undefined
- try {
- db = new Database(dbPath, { readonly: true })
- const parsed = decodeZedEditorContents(
- db
- .query(
- `select contents
- from editors
- where item_id = $editorID and workspace_id = $workspaceID`,
- )
- .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
- )
- if (Option.isNone(parsed)) return { type: "unavailable" as const }
- return { type: "contents" as const, contents: parsed.value.contents }
- } catch {
- return { type: "unavailable" as const }
- } finally {
- db?.close()
- }
- }
- function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
- return row.item_kind === "Editor" && row.editor_id != null
- }
- 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) => isFile(item))
- }
- export function isZedTerminal() {
- return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
- }
- function isFile(item: string) {
- try {
- return statSync(item).isFile()
- } catch {
- return false
- }
- }
- function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
- return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
- if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
- 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) {
- const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
- return offsetsToSelection(text, stringOffset, stringOffset).start
- }
- function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
- if (byteOffset <= 0) return 0
- let bytes = 0
- for (let index = 0; index < text.length; ) {
- const codePoint = text.codePointAt(index)
- if (codePoint === undefined) return text.length
- const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
- bytes += utf8.encode(text.slice(index, nextIndex)).length
- if (bytes >= byteOffset) return nextIndex
- index = nextIndex
- }
- return text.length
- }
- 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
- }
- }
|