diff-viewer-file-tree-utils.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. // Paths branch softly through the screen,
  2. // A quiet tree of changed designs;
  3. // Each leaf remembers what has been,
  4. // And waits where careful light aligns.
  5. export type FileTreeItem = {
  6. readonly file: string
  7. readonly status?: "added" | "deleted" | "modified"
  8. }
  9. export type FileTreeNode = {
  10. readonly id: number
  11. readonly name: string
  12. readonly parent: number | undefined
  13. readonly children: number[]
  14. readonly depth: number
  15. readonly kind: "directory" | "file"
  16. readonly fileIndex?: number
  17. }
  18. export type FileTree = {
  19. readonly roots: number[]
  20. readonly nodes: FileTreeNode[]
  21. }
  22. export type FileTreeRow = {
  23. readonly id: number
  24. readonly depth: number
  25. readonly kind: "directory" | "file"
  26. readonly name: string
  27. readonly fileIndex?: number
  28. }
  29. export function buildFileTree(files: readonly FileTreeItem[]): FileTree {
  30. const roots: number[] = []
  31. const nodes: FileTreeNode[] = []
  32. const directoryByPath = new Map<string, number>()
  33. files.forEach((file, fileIndex) => {
  34. const segments = file.file.split("/").filter(Boolean)
  35. if (segments.length === 0) return
  36. const parent = segments.slice(0, -1).reduce(
  37. (state, segment) => {
  38. const directoryPath = state.path ? `${state.path}/${segment}` : segment
  39. const existing = directoryByPath.get(directoryPath)
  40. if (existing !== undefined) return { id: existing, path: directoryPath, depth: state.depth + 1 }
  41. const id = addFileTreeNode(nodes, roots, {
  42. name: segment,
  43. parent: state.id,
  44. depth: state.depth,
  45. kind: "directory",
  46. })
  47. directoryByPath.set(directoryPath, id)
  48. return { id, path: directoryPath, depth: state.depth + 1 }
  49. },
  50. { id: undefined as number | undefined, path: "", depth: 0 },
  51. )
  52. addFileTreeNode(nodes, roots, {
  53. name: segments[segments.length - 1]!,
  54. parent: parent.id,
  55. depth: parent.depth,
  56. kind: "file",
  57. fileIndex,
  58. })
  59. })
  60. const tree = { roots, nodes }
  61. tree.roots.sort((left, right) => compareFileTreeNodes(tree, left, right))
  62. tree.nodes.forEach((node) => node.children.sort((left, right) => compareFileTreeNodes(tree, left, right)))
  63. return tree
  64. }
  65. export function flattenFileTree(tree: FileTree, expanded?: ReadonlySet<number>): FileTreeRow[] {
  66. const rows: FileTreeRow[] = []
  67. const visit = (id: number, depth: number) => {
  68. const node = tree.nodes[id]!
  69. if (node.kind === "file") {
  70. rows.push({
  71. id: node.id,
  72. depth,
  73. kind: node.kind,
  74. name: node.name,
  75. fileIndex: node.fileIndex,
  76. })
  77. return
  78. }
  79. const chain = collapsedFileTreeDirectoryChain(tree, node.id)
  80. const last = chain[chain.length - 1]!
  81. rows.push({
  82. id: node.id,
  83. depth,
  84. kind: node.kind,
  85. name: chain.map((item) => item.name).join("/"),
  86. fileIndex: node.fileIndex,
  87. })
  88. if (!expanded || expanded.has(node.id)) last.children.forEach((child) => visit(child, depth + 1))
  89. }
  90. tree.roots.forEach((root) => visit(root, 0))
  91. return rows
  92. }
  93. function collapsedFileTreeDirectoryChain(tree: FileTree, id: number): FileTreeNode[] {
  94. const node = tree.nodes[id]!
  95. const child = node.children.length === 1 ? tree.nodes[node.children[0]!] : undefined
  96. if (child?.kind !== "directory") return [node]
  97. return [node, ...collapsedFileTreeDirectoryChain(tree, child.id)]
  98. }
  99. export function compareFileTreeNodes(tree: FileTree, left: number, right: number) {
  100. const leftNode = tree.nodes[left]!
  101. const rightNode = tree.nodes[right]!
  102. if (leftNode.kind !== rightNode.kind) return leftNode.kind === "directory" ? -1 : 1
  103. if (leftNode.name < rightNode.name) return -1
  104. if (leftNode.name > rightNode.name) return 1
  105. return left - right
  106. }
  107. export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: number | undefined, offset: number) {
  108. if (rows.length === 0) return undefined
  109. const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
  110. if (index === -1) return rows[0]!.id
  111. return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
  112. }
  113. export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
  114. const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
  115. const row = index === -1 ? undefined : rows[index]
  116. if (row?.kind !== "directory") return selected
  117. const child = rows[index + 1]
  118. return child && child.depth > row.depth ? child.id : selected
  119. }
  120. export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
  121. const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
  122. const row = index === -1 ? undefined : rows[index]
  123. if (!row || row.depth === 0) return selected
  124. return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
  125. }
  126. export function moveFileTreeSelectionToFile(
  127. rows: readonly FileTreeRow[],
  128. selected: number | undefined,
  129. offset: number,
  130. ) {
  131. const fileRows = rows.filter((row) => row.fileIndex !== undefined)
  132. if (fileRows.length === 0) return undefined
  133. const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
  134. if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
  135. const next =
  136. offset < 0
  137. ? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
  138. : fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
  139. return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
  140. }
  141. export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
  142. const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
  143. if (!node) return undefined
  144. return {
  145. highlightedNode: node.id,
  146. expandedNodes: fileTreeParentDirectories(tree, node.id),
  147. }
  148. }
  149. export function singlePatchFileIndex(
  150. selected: number | undefined,
  151. active: number | undefined,
  152. current: number | undefined,
  153. first: number | undefined,
  154. ) {
  155. return selected ?? active ?? current ?? first
  156. }
  157. export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
  158. return rows.flatMap((row) => (row.fileIndex === undefined ? [] : [row.fileIndex]))
  159. }
  160. export function showDiffViewerFileTree(showFileTree: boolean, fileCount: number) {
  161. return showFileTree && fileCount > 0
  162. }
  163. export function movePatchFileIndex(fileIndexes: readonly number[], current: number | undefined, offset: number) {
  164. if (fileIndexes.length === 0) return undefined
  165. const index = current === undefined ? -1 : fileIndexes.indexOf(current)
  166. if (index === -1) return fileIndexes[0]
  167. return fileIndexes[Math.max(0, Math.min(fileIndexes.length - 1, index + offset))]
  168. }
  169. export function allExpandedFileTreeDirectories(tree: FileTree) {
  170. return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id))
  171. }
  172. export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet<number>, selected: number | undefined) {
  173. if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
  174. const next = new Set(expanded)
  175. if (next.has(selected)) next.delete(selected)
  176. else next.add(selected)
  177. return next
  178. }
  179. export function setFileTreeDirectoryExpanded(
  180. tree: FileTree,
  181. expanded: ReadonlySet<number>,
  182. selected: number | undefined,
  183. value: boolean,
  184. ) {
  185. if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
  186. const next = new Set(expanded)
  187. if (value) next.add(selected)
  188. else next.delete(selected)
  189. return next
  190. }
  191. function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<FileTreeNode, "id" | "children">) {
  192. const id = nodes.length
  193. nodes.push({ ...input, id, children: [] })
  194. if (input.parent === undefined) roots.push(id)
  195. else nodes[input.parent]!.children.push(id)
  196. return id
  197. }
  198. function fileTreeParentDirectories(tree: FileTree, id: number) {
  199. const result = new Set<number>()
  200. for (let parent = tree.nodes[id]?.parent; parent !== undefined; parent = tree.nodes[parent]?.parent) {
  201. result.add(parent)
  202. }
  203. return result
  204. }