editor-zed.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import { Database } from "bun:sqlite"
  2. import { statSync } from "node:fs"
  3. import { readFile as readFileAsync } from "node:fs/promises"
  4. import os from "node:os"
  5. import path from "node:path"
  6. import { Option, Schema } from "effect"
  7. import type { EditorSelection } from "./context/editor"
  8. const ZedEditorRowSchema = Schema.Struct({
  9. item_kind: Schema.String,
  10. editor_id: Schema.NullOr(Schema.Number),
  11. workspace_id: Schema.Number,
  12. workspace_paths: Schema.NullOr(Schema.String),
  13. timestamp: Schema.String,
  14. buffer_path: Schema.NullOr(Schema.String),
  15. })
  16. const ZedSelectionRowSchema = Schema.Struct({
  17. selection_start: Schema.NullOr(Schema.Number),
  18. selection_end: Schema.NullOr(Schema.Number),
  19. })
  20. const ZedEditorContentsSchema = Schema.Struct({
  21. contents: Schema.NullOr(Schema.String),
  22. })
  23. const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
  24. const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
  25. const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
  26. const utf8 = new TextEncoder()
  27. type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
  28. type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
  29. export type ZedSelectionResult =
  30. | { type: "selection"; selection: EditorSelection }
  31. | { type: "empty" }
  32. | { type: "unavailable" }
  33. export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
  34. const active = queryZedActiveEditor(dbPath, cwd)
  35. if (active.type !== "row") return active
  36. const row = active.row
  37. if (!row.buffer_path) return { type: "empty" }
  38. const selections = queryZedEditorSelections(dbPath, row)
  39. if (selections.type !== "selections") return selections
  40. const byteRanges = selections.selections
  41. .flatMap((selection) => {
  42. if (selection.selection_start == null || selection.selection_end == null) return []
  43. return [
  44. {
  45. start: Math.min(selection.selection_start, selection.selection_end),
  46. end: Math.max(selection.selection_start, selection.selection_end),
  47. },
  48. ]
  49. })
  50. .sort((left, right) => left.start - right.start || left.end - right.end)
  51. if (byteRanges.length === 0) return { type: "unavailable" }
  52. const contents = queryZedEditorContents(dbPath, row)
  53. const text =
  54. contents.type === "contents" && contents.contents != null
  55. ? contents.contents
  56. : await readFileAsync(row.buffer_path, "utf8").catch(() => undefined)
  57. if (text == null) return { type: "unavailable" }
  58. const ranges = byteRanges.map((range) => {
  59. const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
  60. const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
  61. return {
  62. text: text.slice(startOffset, endOffset),
  63. selection: offsetsToSelection(text, startOffset, endOffset),
  64. }
  65. })
  66. return {
  67. type: "selection",
  68. selection: {
  69. filePath: row.buffer_path,
  70. source: "zed",
  71. ranges,
  72. },
  73. }
  74. }
  75. function queryZedActiveEditor(dbPath: string, cwd: string) {
  76. let db: Database | undefined
  77. try {
  78. db = new Database(dbPath, { readonly: true })
  79. const raw = db
  80. .query(
  81. `select
  82. i.kind as item_kind,
  83. e.item_id as editor_id,
  84. i.workspace_id as workspace_id,
  85. w.paths as workspace_paths,
  86. w.timestamp as timestamp,
  87. e.buffer_path as buffer_path
  88. from items i
  89. join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
  90. join workspaces w on w.workspace_id = i.workspace_id
  91. left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
  92. where i.active = 1 and p.active = 1
  93. order by w.timestamp desc`,
  94. )
  95. .all()
  96. const rows = raw.flatMap((row) => {
  97. const parsed = decodeZedEditorRow(row)
  98. return Option.isSome(parsed) ? [parsed.value] : []
  99. })
  100. if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
  101. const row = rows
  102. .map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
  103. .filter((entry) => entry.score > 0)
  104. .sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
  105. if (!row) return { type: "empty" as const }
  106. if (row.item_kind !== "Editor") return { type: "unavailable" as const }
  107. if (!isZedActiveEditorRow(row)) return { type: "empty" as const }
  108. return { type: "row" as const, row }
  109. } catch {
  110. return { type: "unavailable" as const }
  111. } finally {
  112. db?.close()
  113. }
  114. }
  115. function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
  116. let db: Database | undefined
  117. try {
  118. db = new Database(dbPath, { readonly: true })
  119. const raw = db
  120. .query(
  121. `select
  122. start as selection_start,
  123. end as selection_end
  124. from editor_selections
  125. where editor_id = $editorID and workspace_id = $workspaceID`,
  126. )
  127. .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
  128. const selections = raw.flatMap((selection) => {
  129. const parsed = decodeZedSelectionRow(selection)
  130. return Option.isSome(parsed) ? [parsed.value] : []
  131. })
  132. if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
  133. return { type: "selections" as const, selections }
  134. } catch {
  135. return { type: "unavailable" as const }
  136. } finally {
  137. db?.close()
  138. }
  139. }
  140. function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
  141. let db: Database | undefined
  142. try {
  143. db = new Database(dbPath, { readonly: true })
  144. const parsed = decodeZedEditorContents(
  145. db
  146. .query(
  147. `select contents
  148. from editors
  149. where item_id = $editorID and workspace_id = $workspaceID`,
  150. )
  151. .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
  152. )
  153. if (Option.isNone(parsed)) return { type: "unavailable" as const }
  154. return { type: "contents" as const, contents: parsed.value.contents }
  155. } catch {
  156. return { type: "unavailable" as const }
  157. } finally {
  158. db?.close()
  159. }
  160. }
  161. function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
  162. return row.item_kind === "Editor" && row.editor_id != null
  163. }
  164. export function resolveZedDbPath() {
  165. const candidates = [
  166. process.env.OPENCODE_ZED_DB,
  167. path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
  168. path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
  169. ].filter((item): item is string => Boolean(item))
  170. return candidates.find((item) => isFile(item))
  171. }
  172. export function isZedTerminal() {
  173. return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
  174. }
  175. function isFile(item: string) {
  176. try {
  177. return statSync(item).isFile()
  178. } catch {
  179. return false
  180. }
  181. }
  182. function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
  183. return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
  184. if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
  185. return score
  186. }, 0)
  187. }
  188. function zedWorkspacePaths(value: string | null) {
  189. if (!value) return []
  190. const parsed = parseJson(value)
  191. if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
  192. return value.split(/\r?\n/).filter(Boolean)
  193. }
  194. export function offsetToPosition(text: string, offset: number) {
  195. const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
  196. return offsetsToSelection(text, stringOffset, stringOffset).start
  197. }
  198. function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
  199. if (byteOffset <= 0) return 0
  200. let bytes = 0
  201. for (let index = 0; index < text.length; ) {
  202. const codePoint = text.codePointAt(index)
  203. if (codePoint === undefined) return text.length
  204. const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
  205. bytes += utf8.encode(text.slice(index, nextIndex)).length
  206. if (bytes >= byteOffset) return nextIndex
  207. index = nextIndex
  208. }
  209. return text.length
  210. }
  211. function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
  212. const start = Math.max(0, Math.min(startOffset, text.length))
  213. const end = Math.max(0, Math.min(endOffset, text.length))
  214. let line = 1
  215. let lineStart = 0
  216. let startPosition = position(line, lineStart, start)
  217. let endPosition = position(line, lineStart, end)
  218. for (let index = 0; index <= end; index++) {
  219. if (index === start) startPosition = position(line, lineStart, index)
  220. if (index === end) {
  221. endPosition = position(line, lineStart, index)
  222. break
  223. }
  224. if (text[index] === "\n") {
  225. line += 1
  226. lineStart = index + 1
  227. }
  228. }
  229. return { start: startPosition, end: endPosition }
  230. }
  231. function position(line: number, lineStart: number, offset: number) {
  232. return {
  233. line,
  234. character: offset - lineStart + 1,
  235. }
  236. }
  237. function pathContains(parent: string, child: string) {
  238. const relative = path.relative(path.resolve(parent), path.resolve(child))
  239. return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
  240. }
  241. function parseJson(value: string) {
  242. try {
  243. return JSON.parse(value) as unknown
  244. } catch {
  245. return
  246. }
  247. }