diff-viewer.test.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /** @jsxImportSource @opentui/solid */
  2. import { expect, test } from "bun:test"
  3. import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
  4. import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
  5. import { testRender, useRenderer } from "@opentui/solid"
  6. import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@kirincode-ai/plugin/tui"
  7. import type { Session } from "@kirincode-ai/sdk/v2"
  8. import { KVProvider } from "../../../src/context/kv"
  9. import { ThemeProvider } from "../../../src/context/theme"
  10. import { TuiConfigProvider } from "../../../src/config"
  11. import { TuiKeybind } from "../../../src/config/keybind"
  12. import { OpencodeKeymapProvider } from "../../../src/keymap"
  13. import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
  14. import { createTuiPluginApi } from "../../fixture/tui-plugin"
  15. import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
  16. import { TestTuiContexts } from "../../fixture/tui-environment"
  17. test("closing the diff viewer returns to the route it opened from", async () => {
  18. const viewer = await renderDiffViewer([])
  19. try {
  20. expect(viewer.current()).toEqual({
  21. name: "diff",
  22. params: { mode: "git", sessionID: "session-1", returnRoute: startRoute },
  23. })
  24. expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "git", context: 12 })
  25. expect(viewer.commands.has("diff.close")).toBe(true)
  26. viewer.commands.get("diff.close")!.run?.({} as never)
  27. expect(viewer.current()).toEqual(startRoute)
  28. } finally {
  29. viewer.app.renderer.destroy()
  30. }
  31. })
  32. test("brackets navigate diff hunks", async () => {
  33. const viewer = await renderDiffViewer(
  34. [
  35. {
  36. file: "src/file.ts",
  37. additions: 3,
  38. deletions: 3,
  39. status: "modified",
  40. patch: `--- a/src/file.ts
  41. +++ b/src/file.ts
  42. @@ -1,3 +1,3 @@
  43. const first = true
  44. -const oldFirst = true
  45. +const newFirst = true
  46. const afterFirst = true
  47. @@ -20,3 +20,3 @@
  48. const second = true
  49. -const oldSecond = true
  50. +const newSecond = true
  51. const afterSecond = true
  52. @@ -40,3 +40,3 @@
  53. const third = true
  54. -const oldThird = true
  55. +const newThird = true
  56. const afterThird = true`,
  57. },
  58. ],
  59. 12,
  60. )
  61. try {
  62. await viewer.app.waitForFrame((frame) => frame.includes("const first"))
  63. await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
  64. await viewer.app.flush()
  65. const scroll = findScrollBox(viewer.app.renderer.root)!
  66. const initial = scroll.scrollTop
  67. expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
  68. expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
  69. viewer.commands.get("diff.next_hunk")!.run?.({} as never)
  70. await viewer.app.renderOnce()
  71. const first = scroll.scrollTop
  72. expect(first).toBeGreaterThan(initial)
  73. viewer.commands.get("diff.next_hunk")!.run?.({} as never)
  74. await viewer.app.renderOnce()
  75. const second = scroll.scrollTop
  76. expect(second).toBeGreaterThan(first)
  77. viewer.commands.get("diff.previous_hunk")!.run?.({} as never)
  78. await viewer.app.renderOnce()
  79. expect(scroll.scrollTop).toBe(first)
  80. viewer.commands.get("diff.next_hunk")!.run?.({} as never)
  81. await viewer.app.renderOnce()
  82. expect(scroll.scrollTop).toBe(second)
  83. scroll.scrollTo(initial)
  84. viewer.commands.get("diff.next_hunk")!.run?.({} as never)
  85. await viewer.app.renderOnce()
  86. expect(scroll.scrollTop).toBe(first)
  87. } finally {
  88. viewer.app.renderer.destroy()
  89. }
  90. })
  91. async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent) {
  92. const commands = new Map<
  93. string,
  94. NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
  95. >()
  96. let current = initialRoute ?? startRoute
  97. let renderDiff: TuiRouteDefinition["render"] | undefined
  98. let vcsDiffInput: unknown
  99. let sessionDiffInput: unknown
  100. const config = createTuiResolvedConfig()
  101. function Harness() {
  102. const renderer = useRenderer()
  103. const keymap = createDefaultOpenTuiKeymap(renderer)
  104. const registerLayer = keymap.registerLayer.bind(keymap)
  105. keymap.registerLayer = (layer) => {
  106. layer.commands?.forEach((command) => commands.set(command.name, command))
  107. return registerLayer(layer)
  108. }
  109. const base = createTuiPluginApi({
  110. keymap,
  111. client: {
  112. vcs: {
  113. diff: async (input: unknown) => {
  114. vcsDiffInput = input
  115. return { data: vcsDiff }
  116. },
  117. },
  118. session: {
  119. diff: async (input: unknown) => {
  120. sessionDiffInput = input
  121. return { data: [] }
  122. },
  123. },
  124. } as unknown as TuiPluginApi["client"],
  125. state: {
  126. session: {
  127. get: () => session,
  128. },
  129. },
  130. })
  131. const api = {
  132. ...base,
  133. route: {
  134. register(routes) {
  135. renderDiff = routes.find((route) => route.name === "diff")?.render
  136. return () => {}
  137. },
  138. navigate(name, params) {
  139. current = params ? { name, params } : { name }
  140. },
  141. get current() {
  142. return current
  143. },
  144. },
  145. } satisfies TuiPluginApi
  146. void diffViewerPlugin.tui(api, undefined, pluginMeta)
  147. if (!initialRoute) commands.get("diff.open")?.run?.({} as never)
  148. return (
  149. <TestTuiContexts>
  150. <OpencodeKeymapProvider keymap={keymap}>
  151. <TuiConfigProvider config={config}>
  152. <KVProvider>
  153. <ThemeProvider mode="dark">
  154. {renderDiff?.({ params: "params" in current ? current.params : undefined })}
  155. </ThemeProvider>
  156. </KVProvider>
  157. </TuiConfigProvider>
  158. </OpencodeKeymapProvider>
  159. </TestTuiContexts>
  160. )
  161. }
  162. const app = await testRender(() => <Harness />, { width: 80, height })
  163. await waitForCommand(app, commands, "diff.close")
  164. return {
  165. app,
  166. commands,
  167. current: () => current,
  168. vcsDiffInput: () => vcsDiffInput,
  169. sessionDiffInput: () => sessionDiffInput,
  170. }
  171. }
  172. const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
  173. function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
  174. if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
  175. return root.getChildren().map(findScrollBox).find(Boolean)
  176. }
  177. function containsDiff(root: Renderable): boolean {
  178. if (root instanceof DiffRenderable) return true
  179. return root.getChildren().some(containsDiff)
  180. }
  181. const session = {
  182. id: "session-1",
  183. slug: "session-1",
  184. projectID: "project-1",
  185. directory: "/repo/session",
  186. title: "Session",
  187. version: "1",
  188. time: {
  189. created: 0,
  190. updated: 0,
  191. },
  192. } satisfies Session
  193. test("branch diff source requests branch VCS diff", async () => {
  194. const viewer = await renderDiffViewer([], 20, {
  195. name: "diff",
  196. params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
  197. })
  198. try {
  199. expect(viewer.current()).toEqual({
  200. name: "diff",
  201. params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
  202. })
  203. expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "branch", context: 12 })
  204. expect(viewer.sessionDiffInput()).toBeUndefined()
  205. } finally {
  206. viewer.app.renderer.destroy()
  207. }
  208. })
  209. test("last-turn diff source requests session diff", async () => {
  210. const viewer = await renderDiffViewer([], 20, {
  211. name: "diff",
  212. params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
  213. })
  214. try {
  215. expect(viewer.current()).toEqual({
  216. name: "diff",
  217. params: { mode: "last-turn", sessionID: "session-1", messageID: "message-1", returnRoute: startRoute },
  218. })
  219. expect(viewer.sessionDiffInput()).toEqual({ sessionID: "session-1", messageID: "message-1" })
  220. expect(viewer.vcsDiffInput()).toBeUndefined()
  221. } finally {
  222. viewer.app.renderer.destroy()
  223. }
  224. })
  225. async function waitForCommand(
  226. app: Awaited<ReturnType<typeof testRender>>,
  227. commands: Map<string, unknown>,
  228. command: string,
  229. ) {
  230. for (let attempt = 0; attempt < 10; attempt++) {
  231. await app.renderOnce()
  232. if (commands.has(command)) return
  233. await new Promise((resolve) => setTimeout(resolve, 25))
  234. }
  235. }
  236. const pluginMeta = {
  237. id: "diff-viewer",
  238. source: "internal",
  239. spec: "diff-viewer",
  240. target: "diff-viewer",
  241. first_time: 0,
  242. last_time: 0,
  243. time_changed: 0,
  244. load_count: 1,
  245. fingerprint: "test",
  246. state: "same",
  247. } satisfies TuiPluginMeta