diff-viewer-file-tree-utils.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { describe, expect, test } from "bun:test"
  2. import {
  3. allExpandedFileTreeDirectories,
  4. buildFileTree,
  5. fileTreeFileSelection,
  6. flattenFileTree,
  7. moveFileTreeSelection,
  8. moveFileTreeSelectionToFirstChild,
  9. moveFileTreeSelectionToFile,
  10. moveFileTreeSelectionToParent,
  11. movePatchFileIndex,
  12. orderedPatchFileIndexes,
  13. setFileTreeDirectoryExpanded,
  14. showDiffViewerFileTree,
  15. singlePatchFileIndex,
  16. toggleFileTreeDirectory,
  17. } from "../../src/feature-plugins/system/diff-viewer-file-tree-utils"
  18. describe("diff viewer file tree utilities", () => {
  19. test("builds a nested tree with deduplicated directories and file indexes", () => {
  20. const tree = buildFileTree([
  21. { file: "src/config/tui.ts" },
  22. { file: "src/config/keybind.ts" },
  23. { file: "src/session/index.ts" },
  24. ])
  25. expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "src")).toHaveLength(1)
  26. expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "config")).toHaveLength(1)
  27. expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "session")).toHaveLength(1)
  28. expect(
  29. tree.nodes
  30. .filter((node) => node.kind === "file")
  31. .map((node) => ({ name: node.name, fileIndex: node.fileIndex, depth: node.depth })),
  32. ).toEqual([
  33. { name: "tui.ts", fileIndex: 0, depth: 2 },
  34. { name: "keybind.ts", fileIndex: 1, depth: 2 },
  35. { name: "index.ts", fileIndex: 2, depth: 2 },
  36. ])
  37. })
  38. test("sorts directories before files and alphabetically within each group", () => {
  39. const rows = flattenFileTree(
  40. buildFileTree([
  41. { file: "z-file.ts" },
  42. { file: "b/file.ts" },
  43. { file: "a/zeta.ts" },
  44. { file: "b/alpha.ts" },
  45. { file: "a/alpha.ts" },
  46. ]),
  47. )
  48. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  49. "directory:a",
  50. " file:alpha.ts",
  51. " file:zeta.ts",
  52. "directory:b",
  53. " file:alpha.ts",
  54. " file:file.ts",
  55. "file:z-file.ts",
  56. ])
  57. })
  58. test("sorts root-level files without creating directories", () => {
  59. const tree = buildFileTree([{ file: "zeta.ts" }, { file: "alpha.ts" }, { file: "beta.ts" }])
  60. expect(tree.nodes.every((node) => node.kind === "file")).toBe(true)
  61. expect(flattenFileTree(tree).map((row) => row.name)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"])
  62. })
  63. test("collapses unary directory chains while flattening", () => {
  64. const rows = flattenFileTree(
  65. buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
  66. )
  67. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  68. "directory:packages/opencode/src",
  69. " directory:cli",
  70. " file:app.ts",
  71. " directory:server",
  72. " file:server.ts",
  73. ])
  74. })
  75. test("does not collapse a directory into a file row", () => {
  76. const rows = flattenFileTree(buildFileTree([{ file: "packages/opencode/src/app.ts" }]))
  77. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  78. "directory:packages/opencode/src",
  79. " file:app.ts",
  80. ])
  81. })
  82. test("stops collapsing at branches", () => {
  83. const rows = flattenFileTree(
  84. buildFileTree([
  85. { file: "packages/opencode/src/cli/app.ts" },
  86. { file: "packages/opencode/src/server/server.ts" },
  87. { file: "packages/readme.md" },
  88. ]),
  89. )
  90. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  91. "directory:packages",
  92. " directory:opencode/src",
  93. " directory:cli",
  94. " file:app.ts",
  95. " directory:server",
  96. " file:server.ts",
  97. " file:readme.md",
  98. ])
  99. })
  100. test("keeps same directory names under different parents separate", () => {
  101. const rows = flattenFileTree(
  102. buildFileTree([{ file: "components/button.ts" }, { file: "docs/components/usage.md" }]),
  103. )
  104. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  105. "directory:components",
  106. " file:button.ts",
  107. "directory:docs/components",
  108. " file:usage.md",
  109. ])
  110. })
  111. test("flattens all-expanded rows depth-first with depths and file references", () => {
  112. const rows = flattenFileTree(
  113. buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/config/keybind.ts" }, { file: "README.md" }]),
  114. )
  115. expect(rows.map((row) => ({ name: row.name, kind: row.kind, depth: row.depth, fileIndex: row.fileIndex }))).toEqual(
  116. [
  117. { name: "src/config", kind: "directory", depth: 0, fileIndex: undefined },
  118. { name: "keybind.ts", kind: "file", depth: 1, fileIndex: 1 },
  119. { name: "tui.ts", kind: "file", depth: 1, fileIndex: 0 },
  120. { name: "README.md", kind: "file", depth: 0, fileIndex: 2 },
  121. ],
  122. )
  123. })
  124. test("collapses expanded unary children under the first visible directory id", () => {
  125. const tree = buildFileTree([
  126. { file: "packages/opencode/src/cli/app.ts" },
  127. { file: "packages/opencode/src/server/server.ts" },
  128. ])
  129. const packages = tree.nodes.find((node) => node.kind === "directory" && node.name === "packages")!
  130. expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["packages/opencode/src"])
  131. expect(flattenFileTree(tree, new Set([packages.id])).map((row) => row.name)).toEqual([
  132. "packages/opencode/src",
  133. "cli",
  134. "server",
  135. ])
  136. })
  137. test("flattens only expanded directory descendants when expansion is provided", () => {
  138. const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
  139. const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
  140. const config = tree.nodes.find((node) => node.kind === "directory" && node.name === "config")!
  141. expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["src", "README.md"])
  142. expect(flattenFileTree(tree, new Set([src.id])).map((row) => row.name)).toEqual([
  143. "src",
  144. "config",
  145. "session",
  146. "README.md",
  147. ])
  148. expect(flattenFileTree(tree, new Set([src.id, config.id])).map((row) => row.name)).toEqual([
  149. "src",
  150. "config",
  151. "tui.ts",
  152. "session",
  153. "README.md",
  154. ])
  155. })
  156. test("moves selection across visible rows and clamps to bounds", () => {
  157. const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }]))
  158. expect(moveFileTreeSelection(rows, undefined, 1)).toBe(rows[0]!.id)
  159. expect(moveFileTreeSelection(rows, rows[0]!.id, 1)).toBe(rows[1]!.id)
  160. expect(moveFileTreeSelection(rows, rows[1]!.id, 99)).toBe(rows[rows.length - 1]!.id)
  161. expect(moveFileTreeSelection(rows, rows[1]!.id, -99)).toBe(rows[0]!.id)
  162. expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
  163. })
  164. test("moves directory selection to first visible child", () => {
  165. const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }]))
  166. const src = rows.find((row) => row.kind === "directory" && row.name === "src")!
  167. const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
  168. const tui = rows.find((row) => row.name === "tui.ts")!
  169. expect(moveFileTreeSelectionToFirstChild(rows, src.id)).toBe(config.id)
  170. expect(moveFileTreeSelectionToFirstChild(rows, tui.id)).toBe(tui.id)
  171. expect(moveFileTreeSelectionToFirstChild(rows, undefined)).toBeUndefined()
  172. })
  173. test("moves collapsed chain selection to first visible child", () => {
  174. const rows = flattenFileTree(
  175. buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
  176. )
  177. const packages = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
  178. const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
  179. expect(moveFileTreeSelectionToFirstChild(rows, packages.id)).toBe(cli.id)
  180. })
  181. test("moves file and collapsed directory selection to visible parent", () => {
  182. const rows = flattenFileTree(
  183. buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
  184. )
  185. const root = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
  186. const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
  187. const app = rows.find((row) => row.name === "app.ts")!
  188. expect(moveFileTreeSelectionToParent(rows, app.id)).toBe(cli.id)
  189. expect(moveFileTreeSelectionToParent(rows, cli.id)).toBe(root.id)
  190. expect(moveFileTreeSelectionToParent(rows, root.id)).toBe(root.id)
  191. expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
  192. })
  193. test("moves file selection relative to the highlighted row", () => {
  194. const rows = flattenFileTree(
  195. buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
  196. )
  197. const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
  198. const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
  199. const tui = rows.find((row) => row.name === "tui.ts")!
  200. const index = rows.find((row) => row.name === "index.ts")!
  201. const readme = rows.find((row) => row.name === "README.md")!
  202. expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
  203. expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
  204. expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
  205. expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
  206. expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
  207. expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
  208. expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
  209. })
  210. test("selects a file tree node and expands its parents for a patch file", () => {
  211. const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
  212. const selection = fileTreeFileSelection(tree, 1)
  213. expect(selection?.highlightedNode).toBe(
  214. tree.nodes.find((node) => node.kind === "file" && node.name === "index.ts")?.id,
  215. )
  216. expect([...selection!.expandedNodes].map((id) => tree.nodes[id]!.name)).toEqual(["session", "src"])
  217. expect(fileTreeFileSelection(tree, 99)).toBeUndefined()
  218. })
  219. test("prefers the selected file when choosing the single patch file", () => {
  220. expect(singlePatchFileIndex(2, 1, 0, 3)).toBe(2)
  221. expect(singlePatchFileIndex(undefined, 1, 0, 3)).toBe(1)
  222. expect(singlePatchFileIndex(undefined, undefined, 0, 3)).toBe(0)
  223. expect(singlePatchFileIndex(undefined, undefined, undefined, 3)).toBe(3)
  224. })
  225. test("orders patches by the flattened file tree order", () => {
  226. const rows = flattenFileTree(
  227. buildFileTree([
  228. { file: "src/dir-8/juniper-4.ts" },
  229. { file: "src/dir-8/harbor-94.ts" },
  230. { file: "src/dir-8/cedar-16.ts" },
  231. ]),
  232. )
  233. expect(orderedPatchFileIndexes(rows)).toEqual([2, 1, 0])
  234. })
  235. test("shows the diff viewer file tree only when enabled and files exist", () => {
  236. expect(showDiffViewerFileTree(true, 1)).toBe(true)
  237. expect(showDiffViewerFileTree(true, 0)).toBe(false)
  238. expect(showDiffViewerFileTree(false, 1)).toBe(false)
  239. expect(showDiffViewerFileTree(false, 0)).toBe(false)
  240. })
  241. test("moves patch selection through the ordered patch file indexes", () => {
  242. const fileIndexes = [2, 1, 0]
  243. expect(movePatchFileIndex(fileIndexes, undefined, 1)).toBe(2)
  244. expect(movePatchFileIndex(fileIndexes, undefined, -1)).toBe(2)
  245. expect(movePatchFileIndex(fileIndexes, 2, 1)).toBe(1)
  246. expect(movePatchFileIndex(fileIndexes, 1, -1)).toBe(2)
  247. expect(movePatchFileIndex(fileIndexes, 0, 1)).toBe(0)
  248. expect(movePatchFileIndex(fileIndexes, 99, 1)).toBe(2)
  249. expect(movePatchFileIndex(fileIndexes, 99, -1)).toBe(2)
  250. expect(movePatchFileIndex([], undefined, 1)).toBeUndefined()
  251. })
  252. test("toggles only selected directory expansion", () => {
  253. const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
  254. const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
  255. const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
  256. const expanded = allExpandedFileTreeDirectories(tree)
  257. const collapsed = toggleFileTreeDirectory(tree, expanded, src.id)
  258. expect(collapsed.has(src.id)).toBe(false)
  259. expect(flattenFileTree(tree, collapsed).map((row) => row.name)).toEqual(["src/config", "README.md"])
  260. const reopened = toggleFileTreeDirectory(tree, collapsed, src.id)
  261. expect(reopened.has(src.id)).toBe(true)
  262. expect(toggleFileTreeDirectory(tree, reopened, readme.id)).toBe(reopened)
  263. expect(toggleFileTreeDirectory(tree, reopened, undefined)).toBe(reopened)
  264. })
  265. test("sets only selected directory expansion", () => {
  266. const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
  267. const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
  268. const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
  269. const expanded = allExpandedFileTreeDirectories(tree)
  270. const collapsed = setFileTreeDirectoryExpanded(tree, expanded, src.id, false)
  271. expect(collapsed.has(src.id)).toBe(false)
  272. const reopened = setFileTreeDirectoryExpanded(tree, collapsed, src.id, true)
  273. expect(reopened.has(src.id)).toBe(true)
  274. expect(setFileTreeDirectoryExpanded(tree, reopened, readme.id, false)).toBe(reopened)
  275. expect(setFileTreeDirectoryExpanded(tree, reopened, undefined, false)).toBe(reopened)
  276. })
  277. })