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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import { describe, expect, test } from "bun:test"
  2. import {
  3. allExpandedFileTreeDirectories,
  4. buildFileTree,
  5. flattenFileTree,
  6. moveFileTreeSelection,
  7. moveFileTreeSelectionToFile,
  8. setFileTreeDirectoryExpanded,
  9. toggleFileTreeDirectory,
  10. } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
  11. describe("diff viewer file tree utilities", () => {
  12. test("builds a nested tree with deduplicated directories and file indexes", () => {
  13. const tree = buildFileTree([
  14. { file: "src/config/tui.ts" },
  15. { file: "src/config/keybind.ts" },
  16. { file: "src/session/index.ts" },
  17. ])
  18. expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "src")).toHaveLength(1)
  19. expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "config")).toHaveLength(1)
  20. expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "session")).toHaveLength(1)
  21. expect(
  22. tree.nodes
  23. .filter((node) => node.kind === "file")
  24. .map((node) => ({ name: node.name, fileIndex: node.fileIndex, depth: node.depth })),
  25. ).toEqual([
  26. { name: "tui.ts", fileIndex: 0, depth: 2 },
  27. { name: "keybind.ts", fileIndex: 1, depth: 2 },
  28. { name: "index.ts", fileIndex: 2, depth: 2 },
  29. ])
  30. })
  31. test("sorts directories before files and alphabetically within each group", () => {
  32. const rows = flattenFileTree(
  33. buildFileTree([
  34. { file: "z-file.ts" },
  35. { file: "b/file.ts" },
  36. { file: "a/zeta.ts" },
  37. { file: "b/alpha.ts" },
  38. { file: "a/alpha.ts" },
  39. ]),
  40. )
  41. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  42. "directory:a",
  43. " file:alpha.ts",
  44. " file:zeta.ts",
  45. "directory:b",
  46. " file:alpha.ts",
  47. " file:file.ts",
  48. "file:z-file.ts",
  49. ])
  50. })
  51. test("sorts root-level files without creating directories", () => {
  52. const tree = buildFileTree([{ file: "zeta.ts" }, { file: "alpha.ts" }, { file: "beta.ts" }])
  53. expect(tree.nodes.every((node) => node.kind === "file")).toBe(true)
  54. expect(flattenFileTree(tree).map((row) => row.name)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"])
  55. })
  56. test("collapses unary directory chains while flattening", () => {
  57. const rows = flattenFileTree(
  58. buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
  59. )
  60. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  61. "directory:packages/opencode/src",
  62. " directory:cli",
  63. " file:app.ts",
  64. " directory:server",
  65. " file:server.ts",
  66. ])
  67. })
  68. test("does not collapse a directory into a file row", () => {
  69. const rows = flattenFileTree(buildFileTree([{ file: "packages/opencode/src/app.ts" }]))
  70. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  71. "directory:packages/opencode/src",
  72. " file:app.ts",
  73. ])
  74. })
  75. test("stops collapsing at branches", () => {
  76. const rows = flattenFileTree(
  77. buildFileTree([
  78. { file: "packages/opencode/src/cli/app.ts" },
  79. { file: "packages/opencode/src/server/server.ts" },
  80. { file: "packages/readme.md" },
  81. ]),
  82. )
  83. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  84. "directory:packages",
  85. " directory:opencode/src",
  86. " directory:cli",
  87. " file:app.ts",
  88. " directory:server",
  89. " file:server.ts",
  90. " file:readme.md",
  91. ])
  92. })
  93. test("keeps same directory names under different parents separate", () => {
  94. const rows = flattenFileTree(
  95. buildFileTree([{ file: "components/button.ts" }, { file: "docs/components/usage.md" }]),
  96. )
  97. expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
  98. "directory:components",
  99. " file:button.ts",
  100. "directory:docs/components",
  101. " file:usage.md",
  102. ])
  103. })
  104. test("flattens all-expanded rows depth-first with depths and file references", () => {
  105. const rows = flattenFileTree(
  106. buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/config/keybind.ts" }, { file: "README.md" }]),
  107. )
  108. expect(rows.map((row) => ({ name: row.name, kind: row.kind, depth: row.depth, fileIndex: row.fileIndex }))).toEqual(
  109. [
  110. { name: "src/config", kind: "directory", depth: 0, fileIndex: undefined },
  111. { name: "keybind.ts", kind: "file", depth: 1, fileIndex: 1 },
  112. { name: "tui.ts", kind: "file", depth: 1, fileIndex: 0 },
  113. { name: "README.md", kind: "file", depth: 0, fileIndex: 2 },
  114. ],
  115. )
  116. })
  117. test("collapses expanded unary children under the first visible directory id", () => {
  118. const tree = buildFileTree([
  119. { file: "packages/opencode/src/cli/app.ts" },
  120. { file: "packages/opencode/src/server/server.ts" },
  121. ])
  122. const packages = tree.nodes.find((node) => node.kind === "directory" && node.name === "packages")!
  123. expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["packages/opencode/src"])
  124. expect(flattenFileTree(tree, new Set([packages.id])).map((row) => row.name)).toEqual([
  125. "packages/opencode/src",
  126. "cli",
  127. "server",
  128. ])
  129. })
  130. test("flattens only expanded directory descendants when expansion is provided", () => {
  131. const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
  132. const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
  133. const config = tree.nodes.find((node) => node.kind === "directory" && node.name === "config")!
  134. expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["src", "README.md"])
  135. expect(flattenFileTree(tree, new Set([src.id])).map((row) => row.name)).toEqual([
  136. "src",
  137. "config",
  138. "session",
  139. "README.md",
  140. ])
  141. expect(flattenFileTree(tree, new Set([src.id, config.id])).map((row) => row.name)).toEqual([
  142. "src",
  143. "config",
  144. "tui.ts",
  145. "session",
  146. "README.md",
  147. ])
  148. })
  149. test("moves selection across visible rows and clamps to bounds", () => {
  150. const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }]))
  151. expect(moveFileTreeSelection(rows, undefined, 1)).toBe(rows[0]!.id)
  152. expect(moveFileTreeSelection(rows, rows[0]!.id, 1)).toBe(rows[1]!.id)
  153. expect(moveFileTreeSelection(rows, rows[1]!.id, 99)).toBe(rows[rows.length - 1]!.id)
  154. expect(moveFileTreeSelection(rows, rows[1]!.id, -99)).toBe(rows[0]!.id)
  155. expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
  156. })
  157. test("moves file selection relative to the highlighted row", () => {
  158. const rows = flattenFileTree(
  159. buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
  160. )
  161. const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
  162. const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
  163. const tui = rows.find((row) => row.name === "tui.ts")!
  164. const index = rows.find((row) => row.name === "index.ts")!
  165. const readme = rows.find((row) => row.name === "README.md")!
  166. expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
  167. expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
  168. expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
  169. expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
  170. expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
  171. expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
  172. expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
  173. })
  174. test("toggles only selected directory expansion", () => {
  175. const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
  176. const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
  177. const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
  178. const expanded = allExpandedFileTreeDirectories(tree)
  179. const collapsed = toggleFileTreeDirectory(tree, expanded, src.id)
  180. expect(collapsed.has(src.id)).toBe(false)
  181. expect(flattenFileTree(tree, collapsed).map((row) => row.name)).toEqual(["src/config", "README.md"])
  182. const reopened = toggleFileTreeDirectory(tree, collapsed, src.id)
  183. expect(reopened.has(src.id)).toBe(true)
  184. expect(toggleFileTreeDirectory(tree, reopened, readme.id)).toBe(reopened)
  185. expect(toggleFileTreeDirectory(tree, reopened, undefined)).toBe(reopened)
  186. })
  187. test("sets only selected directory expansion", () => {
  188. const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
  189. const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
  190. const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
  191. const expanded = allExpandedFileTreeDirectories(tree)
  192. const collapsed = setFileTreeDirectoryExpanded(tree, expanded, src.id, false)
  193. expect(collapsed.has(src.id)).toBe(false)
  194. const reopened = setFileTreeDirectoryExpanded(tree, collapsed, src.id, true)
  195. expect(reopened.has(src.id)).toBe(true)
  196. expect(setFileTreeDirectoryExpanded(tree, reopened, readme.id, false)).toBe(reopened)
  197. expect(setFileTreeDirectoryExpanded(tree, reopened, undefined, false)).toBe(reopened)
  198. })
  199. })