diff-viewer-file-tree.test.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /** @jsxImportSource @opentui/solid */
  2. import { describe, expect, test } from "bun:test"
  3. import { RGBA } from "@opentui/core"
  4. import { testRender } from "@opentui/solid"
  5. import type { JSX } from "solid-js"
  6. import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
  7. import { KVProvider } from "../../../src/context/kv"
  8. import { ThemeProvider } from "../../../src/context/theme"
  9. import { TuiConfigProvider } from "../../../src/config"
  10. import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
  11. import { TestTuiContexts } from "../../fixture/tui-environment"
  12. import {
  13. allExpandedFileTreeDirectories,
  14. buildFileTree,
  15. } from "../../../src/feature-plugins/system/diff-viewer-file-tree-utils"
  16. const theme = {
  17. background: RGBA.fromHex("#000000"),
  18. backgroundPanel: RGBA.fromHex("#111111"),
  19. backgroundElement: RGBA.fromHex("#333333"),
  20. primary: RGBA.fromHex("#00ffff"),
  21. secondary: RGBA.fromHex("#0088ff"),
  22. selectedListItemText: RGBA.fromHex("#ffffff"),
  23. text: RGBA.fromHex("#ffffff"),
  24. textMuted: RGBA.fromHex("#888888"),
  25. error: RGBA.fromHex("#ff0000"),
  26. }
  27. describe("DiffViewerFileTree", () => {
  28. test.skip("renders sorted hierarchical file rows", async () => {
  29. const app = await testRender(
  30. () =>
  31. withTheme(() => (
  32. <DiffViewerFileTree
  33. width={32}
  34. files={[
  35. { file: "z-file.ts" },
  36. { file: "b/file.ts" },
  37. { file: "a/zeta.ts" },
  38. { file: "b/alpha.ts" },
  39. { file: "a/alpha.ts" },
  40. ]}
  41. loading={false}
  42. error={undefined}
  43. theme={theme}
  44. focused={true}
  45. />
  46. )),
  47. { width: 40, height: 20 },
  48. )
  49. try {
  50. await renderOnceSettled(app)
  51. const lines = visibleLines(app.captureCharFrame())
  52. expect(lines).toEqual([
  53. "▾ a",
  54. "│ ├─ alpha.ts ?",
  55. "│ └─ zeta.ts ?",
  56. "├─ ▾ b",
  57. "│ ├─ alpha.ts ?",
  58. "│ └─ file.ts ?",
  59. ])
  60. } finally {
  61. app.renderer.destroy()
  62. }
  63. })
  64. test("keeps loading and error quiet while rendering an empty settled state", async () => {
  65. const loading = await renderFrame(() => (
  66. <DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />
  67. ))
  68. const failed = await renderFrame(() => (
  69. <DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
  70. ))
  71. const empty = await renderFrame(() => (
  72. <DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
  73. ))
  74. expect(loading).not.toContain("Loading diff...")
  75. expect(loading).not.toContain("No files")
  76. expect(failed).not.toContain("Failed to load diff")
  77. expect(failed).not.toContain("No files")
  78. expect(empty).toContain("No files")
  79. })
  80. test("does not render text markers for highlighted rows", async () => {
  81. const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
  82. const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")!
  83. const focused = visibleLines(
  84. await renderFrame(() => (
  85. <DiffViewerFileTree
  86. width={32}
  87. files={files}
  88. loading={false}
  89. error={undefined}
  90. theme={theme}
  91. focused
  92. highlightedNode={src.id}
  93. />
  94. )),
  95. )
  96. const unfocused = visibleLines(
  97. await renderFrame(() => (
  98. <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />
  99. )),
  100. )
  101. expect(focused).toContain("▾ src/config")
  102. expect(unfocused).toContain("▾ src/config")
  103. expect(focused.some((line) => line.includes("*"))).toBe(false)
  104. expect(unfocused.some((line) => line.includes("*"))).toBe(false)
  105. })
  106. test("renders collapsed and expanded directory rows", async () => {
  107. const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
  108. const tree = buildFileTree(files)
  109. const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
  110. const collapsed = allExpandedFileTreeDirectories(tree)
  111. collapsed.delete(src.id)
  112. expect(
  113. visibleLines(
  114. await renderFrame(() => (
  115. <DiffViewerFileTree
  116. width={32}
  117. files={files}
  118. loading={false}
  119. error={undefined}
  120. theme={theme}
  121. expandedNodes={collapsed}
  122. />
  123. )),
  124. ),
  125. ).toEqual(["▸ src/config"])
  126. expect(
  127. visibleLines(
  128. await renderFrame(() => (
  129. <DiffViewerFileTree
  130. files={files}
  131. width={32}
  132. loading={false}
  133. error={undefined}
  134. theme={theme}
  135. expandedNodes={allExpandedFileTreeDirectories(tree)}
  136. />
  137. )),
  138. ),
  139. ).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
  140. })
  141. })
  142. async function renderFrame(component: () => JSX.Element) {
  143. const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
  144. try {
  145. await renderOnceSettled(app)
  146. return await captureSettledFrame(app)
  147. } finally {
  148. app.renderer.destroy()
  149. }
  150. }
  151. async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
  152. await app.renderOnce()
  153. await new Promise((resolve) => setTimeout(resolve, 25))
  154. await app.renderOnce()
  155. }
  156. async function captureSettledFrame(app: Awaited<ReturnType<typeof testRender>>) {
  157. for (let attempt = 0; attempt < 5; attempt++) {
  158. const frame = app.captureCharFrame()
  159. if (frame.trim().length > 0) return frame
  160. await new Promise((resolve) => setTimeout(resolve, 25))
  161. await app.renderOnce()
  162. }
  163. return app.captureCharFrame()
  164. }
  165. function withTheme(component: () => JSX.Element) {
  166. return (
  167. <TestTuiContexts>
  168. <TuiConfigProvider config={createTuiResolvedConfig()}>
  169. <KVProvider>
  170. <ThemeProvider mode="dark">{component()}</ThemeProvider>
  171. </KVProvider>
  172. </TuiConfigProvider>
  173. </TestTuiContexts>
  174. )
  175. }
  176. function visibleLines(frame: string) {
  177. return frame
  178. .split("\n")
  179. .map((line) => line.trimEnd())
  180. .map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
  181. .map((line) => (line.startsWith(" ") ? line.slice(1) : line))
  182. .filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
  183. }