287 lines
9.1 KiB
TypeScript
287 lines
9.1 KiB
TypeScript
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
|
|
}
|
|
}
|