Files
orca/src/shared/source-control-tree.ts
T
Kelvin Amoaba f22a7d43b5 refactor(source-control): move the changed-file tree builder to src/shared (#36433)
Mobile can only import from src/shared, and its PR files view needs the
same compact tree desktop builds. Move source-control-tree.ts and the
relative-path normalizer it depends on, so both apps run one implementation.
2026-10-30 20:25:25 +00:00

289 lines
8.3 KiB
TypeScript

import { normalizeRelativePath, splitPathSegments } from './cross-platform-path'
import type { GitStagingArea, GitStatusEntry } from './git-status-types'
import { compareFileNames } from './file-name-sort'
import { compareGitStatusEntries } from './git-status-sort'
export type SourceControlTreeArea = Extract<GitStagingArea, 'unstaged' | 'staged' | 'untracked'>
// Keep entry shapes generic because committed branch rows lack uncommitted status metadata.
export type SourceControlTreeEntry = {
path: string
}
export type SourceControlTreeFileNode<
Entry extends SourceControlTreeEntry = GitStatusEntry,
Area extends string = SourceControlTreeArea
> = {
type: 'file'
key: string
name: string
path: string
entry: Entry
area: Area
depth: number
}
export type SourceControlTreeDirectoryNode<
Entry extends SourceControlTreeEntry = GitStatusEntry,
Area extends string = SourceControlTreeArea
> = {
type: 'directory'
key: string
name: string
path: string
area: Area
depth: number
fileCount: number
children: SourceControlTreeNode<Entry, Area>[]
}
export type SourceControlTreeNode<
Entry extends SourceControlTreeEntry = GitStatusEntry,
Area extends string = SourceControlTreeArea
> = SourceControlTreeFileNode<Entry, Area> | SourceControlTreeDirectoryNode<Entry, Area>
type MutableDirectoryNode<Entry extends SourceControlTreeEntry, Area extends string> = Omit<
SourceControlTreeDirectoryNode<Entry, Area>,
'children'
> & {
children: SourceControlTreeNode<Entry, Area>[]
directoryChildren: Map<string, MutableDirectoryNode<Entry, Area>>
}
function compareTreeEntriesByPath(a: SourceControlTreeEntry, b: SourceControlTreeEntry): number {
return compareFileNames(a.path, b.path)
}
function makeDirectoryNode<Entry extends SourceControlTreeEntry, Area extends string>(
area: Area,
path: string,
name: string,
depth: number
): MutableDirectoryNode<Entry, Area> {
return {
type: 'directory',
key: `dir::${area}::${path}`,
name,
path,
area,
depth,
fileCount: 0,
children: [],
directoryChildren: new Map()
}
}
function finalizeDirectoryNode<Entry extends SourceControlTreeEntry, Area extends string>(
node: MutableDirectoryNode<Entry, Area>,
compareEntries: (a: Entry, b: Entry) => number
): SourceControlTreeDirectoryNode<Entry, Area> {
const directories: SourceControlTreeDirectoryNode<Entry, Area>[] = []
const files: SourceControlTreeFileNode<Entry, Area>[] = []
for (const child of node.children) {
if (child.type === 'directory') {
directories.push(
finalizeDirectoryNode(child as MutableDirectoryNode<Entry, Area>, compareEntries)
)
} else {
files.push(child)
}
}
// Use the same numeric ordering for directories and file rows.
directories.sort((a, b) => compareFileNames(a.name, b.name))
files.sort((a, b) => compareEntries(a.entry, b.entry))
const fileCount =
files.length + directories.reduce((count, directory) => count + directory.fileCount, 0)
return {
type: 'directory',
key: node.key,
name: node.name,
path: node.path,
area: node.area,
depth: node.depth,
fileCount,
children: [...directories, ...files]
}
}
export function buildSourceControlTree<
Entry extends SourceControlTreeEntry = GitStatusEntry,
Area extends string = SourceControlTreeArea
>(
area: Area,
entries: Entry[],
compareEntries: (a: Entry, b: Entry) => number = compareTreeEntriesByPath
): SourceControlTreeNode<Entry, Area>[] {
const root = makeDirectoryNode<Entry, Area>(area, '', '', -1)
for (const entry of entries) {
const normalizedPath = normalizeRelativePath(entry.path)
const segments = splitPathSegments(normalizedPath)
if (segments.length === 0) {
continue
}
let parent = root
// Build ancestor strings only on misses; slice/join per segment was quadratic in depth during every filter keystroke.
let ancestorPath = ''
for (let index = 0; index < segments.length - 1; index += 1) {
const name = segments[index]
ancestorPath = ancestorPath ? `${ancestorPath}/${name}` : name
let dir = parent.directoryChildren.get(name)
if (!dir) {
dir = makeDirectoryNode<Entry, Area>(area, ancestorPath, name, index)
parent.directoryChildren.set(name, dir)
parent.children.push(dir)
}
parent = dir
}
const fileName = segments.at(-1)!
parent.children.push({
type: 'file',
key: `${area}::${entry.path}`,
name: fileName,
path: normalizedPath,
entry,
area,
depth: segments.length - 1
})
}
return finalizeDirectoryNode(root, compareEntries).children
}
export function buildGitStatusSourceControlTree(
area: SourceControlTreeArea,
entries: GitStatusEntry[]
): SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>[] {
// Preserve flat-list conflict-first ordering for uncommitted trees; branch trees may sort by path.
return buildSourceControlTree(area, entries, compareGitStatusEntries)
}
export function flattenSourceControlTree<Entry extends SourceControlTreeEntry, Area extends string>(
nodes: readonly SourceControlTreeNode<Entry, Area>[],
collapsedDirectoryKeys: ReadonlySet<string>
): SourceControlTreeNode<Entry, Area>[] {
const result: SourceControlTreeNode<Entry, Area>[] = []
const visit = (node: SourceControlTreeNode<Entry, Area>): void => {
result.push(node)
if (node.type === 'directory' && !collapsedDirectoryKeys.has(node.key)) {
for (const child of node.children) {
visit(child)
}
}
}
for (const node of nodes) {
visit(node)
}
return result
}
export function compactSourceControlTree<Entry extends SourceControlTreeEntry, Area extends string>(
nodes: SourceControlTreeNode<Entry, Area>[]
): SourceControlTreeNode<Entry, Area>[] {
const compactNode = (
node: SourceControlTreeNode<Entry, Area>,
depth: number
): SourceControlTreeNode<Entry, Area> => {
if (node.type === 'file') {
return { ...node, depth }
}
const names = [node.name]
let compacted = node
while (compacted.children.length === 1 && compacted.children[0]?.type === 'directory') {
compacted = compacted.children[0]
names.push(compacted.name)
}
// Compact single-folder chains in sparse changed-file trees without changing file identity.
return {
...compacted,
name: names.join('/'),
depth,
children: compacted.children.map((child) => compactNode(child, depth + 1))
}
}
return nodes.map((node) => compactNode(node, 0))
}
export function namespaceSourceControlTreeDirectoryKeys<
Entry extends SourceControlTreeEntry,
Area extends string
>(
nodes: SourceControlTreeNode<Entry, Area>[],
namespace: string
): SourceControlTreeNode<Entry, Area>[] {
const namespaceNode = (
node: SourceControlTreeNode<Entry, Area>
): SourceControlTreeNode<Entry, Area> => {
if (node.type === 'file') {
return node
}
// Pinned conflicts share Git areas but need section-local collapse keys.
return {
...node,
key: `dir::${namespace}::${node.path}`,
children: node.children.map(namespaceNode)
}
}
return nodes.map(namespaceNode)
}
export function applyGitStatusEntryAreasToSourceControlTree(
nodes: SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>[]
): SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>[] {
const applyEntryArea = (
node: SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea>
): SourceControlTreeNode<GitStatusEntry, SourceControlTreeArea> => {
if (node.type === 'file') {
return {
...node,
key: `${node.entry.area}::${node.entry.path}`,
area: node.entry.area
}
}
return {
...node,
children: node.children.map(applyEntryArea)
}
}
return nodes.map(applyEntryArea)
}
export function collectSourceControlTreeFileEntries<
Entry extends SourceControlTreeEntry,
Area extends string
>(node: SourceControlTreeNode<Entry, Area>): Entry[] {
if (node.type === 'file') {
return [node.entry]
}
const entries: Entry[] = []
const collect = (child: SourceControlTreeNode<Entry, Area>): void => {
if (child.type === 'file') {
entries.push(child.entry)
return
}
for (const grandchild of child.children) {
collect(grandchild)
}
}
for (const child of node.children) {
collect(child)
}
return entries
}