editor-context-zed.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. import { Database } from "bun:sqlite"
  2. import { mkdir, symlink } from "node:fs/promises"
  3. import os from "node:os"
  4. import path from "node:path"
  5. import { afterEach, expect, spyOn, test } from "bun:test"
  6. import { isZedTerminal, offsetToPosition, resolveZedDbPath, resolveZedSelection } from "@kirincode-ai/tui/editor-zed"
  7. import { tmpdir } from "../../fixture/fixture"
  8. const originalZedTerm = process.env.ZED_TERM
  9. const originalTermProgram = process.env.TERM_PROGRAM
  10. afterEach(() => {
  11. if (originalZedTerm === undefined) delete process.env.ZED_TERM
  12. else process.env.ZED_TERM = originalZedTerm
  13. if (originalTermProgram === undefined) delete process.env.TERM_PROGRAM
  14. else process.env.TERM_PROGRAM = originalTermProgram
  15. })
  16. type ZedFixtureOptions = {
  17. workspacePaths?: string | null
  18. itemKind?: string
  19. editor?: boolean
  20. selectionStart?: number | null
  21. selectionEnd?: number | null
  22. selections?: Array<{ start: number | null; end: number | null }>
  23. contents?: string
  24. }
  25. async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
  26. const dbPath = path.join(dir, "zed.sqlite")
  27. const filePath = path.join(dir, "file.ts")
  28. const contents = options.contents ?? "one\ntwo\nthree"
  29. await Bun.write(filePath, contents)
  30. const db = new Database(dbPath)
  31. db.run("create table workspaces (workspace_id integer, paths text, timestamp text)")
  32. db.run("create table panes (pane_id integer, workspace_id integer, active integer)")
  33. db.run("create table items (item_id integer, workspace_id integer, pane_id integer, active integer, kind text)")
  34. db.run("create table editors (item_id integer, workspace_id integer, buffer_path text, contents text)")
  35. db.run("create table editor_selections (editor_id integer, workspace_id integer, start integer, end integer)")
  36. db.run("insert into workspaces values (1, ?, ?)", [options.workspacePaths ?? JSON.stringify([dir]), "2026-04-27"])
  37. db.run("insert into panes values (1, 1, 1)")
  38. db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"])
  39. if (options.editor !== false) {
  40. db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents])
  41. ;(
  42. options.selections ?? [
  43. {
  44. start: options.selectionStart === undefined ? 4 : options.selectionStart,
  45. end: options.selectionEnd === undefined ? 7 : options.selectionEnd,
  46. },
  47. ]
  48. ).forEach((selection) =>
  49. db.run("insert into editor_selections values (1, 1, ?, ?)", [selection.start, selection.end]),
  50. )
  51. }
  52. db.close()
  53. return { dbPath, filePath }
  54. }
  55. function utf8ByteOffset(text: string, offset: number) {
  56. return new TextEncoder().encode(text.slice(0, offset)).length
  57. }
  58. test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
  59. expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
  60. expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
  61. expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
  62. expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
  63. expect(offsetToPosition("Ж\nabc", utf8ByteOffset("Ж\nabc", "Ж\nabc".indexOf("a")))).toEqual({
  64. line: 2,
  65. character: 1,
  66. })
  67. expect(offsetToPosition("😀\nabc", utf8ByteOffset("😀\nabc", "😀\nabc".indexOf("a")))).toEqual({
  68. line: 2,
  69. character: 1,
  70. })
  71. })
  72. test("resolveZedDbPath skips candidates that cannot be stated", async () => {
  73. await using tmp = await tmpdir()
  74. const loop = path.join(tmp.path, "loop")
  75. await symlink(loop, loop)
  76. const home = spyOn(os, "homedir").mockImplementation(() => tmp.path)
  77. const previous = process.env.KIRINCODE_ZED_DB
  78. process.env.KIRINCODE_ZED_DB = loop
  79. try {
  80. expect(resolveZedDbPath()).toBeUndefined()
  81. } finally {
  82. if (previous === undefined) delete process.env.KIRINCODE_ZED_DB
  83. else process.env.KIRINCODE_ZED_DB = previous
  84. home.mockRestore()
  85. }
  86. })
  87. test("isZedTerminal only returns true for Zed terminal environments", () => {
  88. delete process.env.ZED_TERM
  89. delete process.env.TERM_PROGRAM
  90. expect(isZedTerminal()).toBeFalse()
  91. process.env.ZED_TERM = "true"
  92. expect(isZedTerminal()).toBeTrue()
  93. process.env.ZED_TERM = "false"
  94. process.env.TERM_PROGRAM = "zed"
  95. expect(isZedTerminal()).toBeTrue()
  96. })
  97. test("resolveZedSelection returns active editor selection", async () => {
  98. await using tmp = await tmpdir()
  99. const fixture = await writeZedFixture(tmp.path)
  100. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
  101. type: "selection",
  102. selection: {
  103. filePath: fixture.filePath,
  104. source: "zed",
  105. ranges: [
  106. {
  107. text: "two",
  108. selection: {
  109. start: { line: 2, character: 1 },
  110. end: { line: 2, character: 4 },
  111. },
  112. },
  113. ],
  114. },
  115. })
  116. })
  117. test("resolveZedSelection returns all active editor selections sorted by offset", async () => {
  118. await using tmp = await tmpdir()
  119. const contents = "one\ntwo\nthree\nfour"
  120. const fixture = await writeZedFixture(tmp.path, {
  121. contents,
  122. selections: [
  123. {
  124. start: utf8ByteOffset(contents, contents.indexOf("four")),
  125. end: utf8ByteOffset(contents, contents.indexOf("four") + 4),
  126. },
  127. {
  128. start: utf8ByteOffset(contents, contents.indexOf("two")),
  129. end: utf8ByteOffset(contents, contents.indexOf("two") + 3),
  130. },
  131. ],
  132. })
  133. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
  134. type: "selection",
  135. selection: {
  136. filePath: fixture.filePath,
  137. source: "zed",
  138. ranges: [
  139. {
  140. text: "two",
  141. selection: {
  142. start: { line: 2, character: 1 },
  143. end: { line: 2, character: 4 },
  144. },
  145. },
  146. {
  147. text: "four",
  148. selection: {
  149. start: { line: 4, character: 1 },
  150. end: { line: 4, character: 5 },
  151. },
  152. },
  153. ],
  154. },
  155. })
  156. })
  157. test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", async () => {
  158. await using tmp = await tmpdir()
  159. const contents = "a\nЖЖЖЖЖЖЖЖЖЖ\nb\nTARGET\nz"
  160. const start = contents.indexOf("TARGET")
  161. const fixture = await writeZedFixture(tmp.path, {
  162. contents,
  163. selectionStart: utf8ByteOffset(contents, start),
  164. selectionEnd: utf8ByteOffset(contents, start + "TARGET".length),
  165. })
  166. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
  167. type: "selection",
  168. selection: {
  169. filePath: fixture.filePath,
  170. source: "zed",
  171. ranges: [
  172. {
  173. text: "TARGET",
  174. selection: {
  175. start: { line: 4, character: 1 },
  176. end: { line: 4, character: 7 },
  177. },
  178. },
  179. ],
  180. },
  181. })
  182. })
  183. test("resolveZedSelection handles non-ASCII text inside the selected range", async () => {
  184. await using tmp = await tmpdir()
  185. const contents = "a\npre\nвыбор\nz"
  186. const start = contents.indexOf("выбор")
  187. const fixture = await writeZedFixture(tmp.path, {
  188. contents,
  189. selectionStart: utf8ByteOffset(contents, start),
  190. selectionEnd: utf8ByteOffset(contents, start + "выбор".length),
  191. })
  192. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
  193. type: "selection",
  194. selection: {
  195. filePath: fixture.filePath,
  196. source: "zed",
  197. ranges: [
  198. {
  199. text: "выбор",
  200. selection: {
  201. start: { line: 3, character: 1 },
  202. end: { line: 3, character: 6 },
  203. },
  204. },
  205. ],
  206. },
  207. })
  208. })
  209. test("resolveZedSelection handles emoji before the selected range", async () => {
  210. await using tmp = await tmpdir()
  211. const contents = "😀\nTARGET\nz"
  212. const start = contents.indexOf("TARGET")
  213. const fixture = await writeZedFixture(tmp.path, {
  214. contents,
  215. selectionStart: utf8ByteOffset(contents, start),
  216. selectionEnd: utf8ByteOffset(contents, start + "TARGET".length),
  217. })
  218. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
  219. type: "selection",
  220. selection: {
  221. filePath: fixture.filePath,
  222. source: "zed",
  223. ranges: [
  224. {
  225. text: "TARGET",
  226. selection: {
  227. start: { line: 2, character: 1 },
  228. end: { line: 2, character: 7 },
  229. },
  230. },
  231. ],
  232. },
  233. })
  234. })
  235. test("resolveZedSelection handles reversed Zed byte offsets", async () => {
  236. await using tmp = await tmpdir()
  237. const contents = "a\nЖЖЖ\nTARGET\nz"
  238. const start = contents.indexOf("TARGET")
  239. const fixture = await writeZedFixture(tmp.path, {
  240. contents,
  241. selectionStart: utf8ByteOffset(contents, start + "TARGET".length),
  242. selectionEnd: utf8ByteOffset(contents, start),
  243. })
  244. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
  245. type: "selection",
  246. selection: {
  247. filePath: fixture.filePath,
  248. source: "zed",
  249. ranges: [
  250. {
  251. text: "TARGET",
  252. selection: {
  253. start: { line: 3, character: 1 },
  254. end: { line: 3, character: 7 },
  255. },
  256. },
  257. ],
  258. },
  259. })
  260. })
  261. test("resolveZedSelection returns empty when no workspace matches", async () => {
  262. await using tmp = await tmpdir()
  263. const fixture = await writeZedFixture(tmp.path, {
  264. workspacePaths: JSON.stringify([path.join(path.dirname(tmp.path), "other-workspace")]),
  265. })
  266. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
  267. })
  268. test("resolveZedSelection matches a Zed workspace that contains the session directory", async () => {
  269. await using tmp = await tmpdir()
  270. const fixture = await writeZedFixture(tmp.path)
  271. expect(await resolveZedSelection(fixture.dbPath, path.join(tmp.path, "packages", "app"))).toEqual({
  272. type: "selection",
  273. selection: {
  274. filePath: fixture.filePath,
  275. source: "zed",
  276. ranges: [
  277. {
  278. text: "two",
  279. selection: {
  280. start: { line: 2, character: 1 },
  281. end: { line: 2, character: 4 },
  282. },
  283. },
  284. ],
  285. },
  286. })
  287. })
  288. test("resolveZedSelection prefers the most specific containing Zed workspace", async () => {
  289. await using tmp = await tmpdir()
  290. const fixture = await writeZedFixture(tmp.path)
  291. const child = path.join(tmp.path, "packages")
  292. const childFile = path.join(child, "child.ts")
  293. await mkdir(child, { recursive: true })
  294. await Bun.write(childFile, "child")
  295. const db = new Database(fixture.dbPath)
  296. db.run("insert into workspaces values (2, ?, ?)", [JSON.stringify([child]), "2026-01-01"])
  297. db.run("insert into panes values (2, 2, 1)")
  298. db.run("insert into items values (2, 2, 2, 1, ?)", ["Editor"])
  299. db.run("insert into editors values (2, 2, ?, ?)", [childFile, "child"])
  300. db.run("insert into editor_selections values (2, 2, 0, 5)")
  301. db.close()
  302. expect(await resolveZedSelection(fixture.dbPath, path.join(child, "app"))).toEqual({
  303. type: "selection",
  304. selection: {
  305. filePath: childFile,
  306. source: "zed",
  307. ranges: [
  308. {
  309. text: "child",
  310. selection: {
  311. start: { line: 1, character: 1 },
  312. end: { line: 1, character: 6 },
  313. },
  314. },
  315. ],
  316. },
  317. })
  318. })
  319. test("resolveZedSelection ignores a Zed workspace nested inside the session directory", async () => {
  320. await using tmp = await tmpdir()
  321. const child = path.join(tmp.path, "effect-lab")
  322. await mkdir(child, { recursive: true })
  323. const fixture = await writeZedFixture(child)
  324. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
  325. })
  326. test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => {
  327. await using tmp = await tmpdir()
  328. const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false })
  329. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
  330. })
  331. test("resolveZedSelection returns unavailable when the database cannot be queried", async () => {
  332. await using tmp = await tmpdir()
  333. expect(await resolveZedSelection(path.join(tmp.path, "missing.sqlite"), tmp.path)).toEqual({ type: "unavailable" })
  334. })
  335. test("resolveZedSelection returns unavailable when active selection is missing offsets", async () => {
  336. await using tmp = await tmpdir()
  337. const fixture = await writeZedFixture(tmp.path, { selectionStart: null, selectionEnd: null })
  338. expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "unavailable" })
  339. })