1
0

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

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