// Arranging a commit's changed paths into a tree. // // A diff names files by their whole path. Read as a list that buries what // changed under directory names repeated on every row, so the paths are // folded back into the tree they came from. A directory holding nothing but // one more directory is shown as a single row, because the intermediate step // carries no information a reader needs. /** * @typedef {Object} FileNode * @property {'file'} type * @property {string} name - the last segment * @property {string} path - the whole path, as the diff names it * @property {unknown} file - the caller's entry for this path */ /** * @typedef {Object} DirNode * @property {'dir'} type * @property {string} name - one or more segments, joined where a chain folded * @property {string} path - the whole path to this directory * @property {Array} children */ /** * Fold a directory holding nothing but one directory into its child, so a * lone chain reads as one row rather than one row per empty step. * @param {DirNode} dir * @returns {DirNode} */ function collapse(dir) { let node = dir; while (node.children.length === 1 && node.children[0].type === 'dir') { const only = /** @type {DirNode} */ (node.children[0]); node = { type: 'dir', name: `${node.name}/${only.name}`, path: only.path, children: only.children, }; } return node; } /** * Directories first, then files, each by name. The same order the repository * listing uses, so a reader moving between the two finds things in the same * place. * @param {Array} nodes */ function sortNodes(nodes) { return nodes.sort((a, b) => { if ((a.type === 'dir') !== (b.type === 'dir')) { return a.type === 'dir' ? -1 : 1; } return a.name.localeCompare(b.name); }); } /** * Build the tree for a set of changed files. * @param {Array<{path: string}>} files - a commit's changed files * @returns {Array} the roots, sorted */ export function buildFileTree(files) { /** @type {DirNode} */ const root = { type: 'dir', name: '', path: '', children: [] }; /** @type {Map} */ const dirs = new Map([['', root]]); for (const file of files) { const segments = file.path.split('/'); const name = /** @type {string} */ (segments.pop()); let parent = root; let walked = ''; for (const segment of segments) { walked = walked ? `${walked}/${segment}` : segment; let dir = dirs.get(walked); if (!dir) { dir = { type: 'dir', name: segment, path: walked, children: [] }; dirs.set(walked, dir); parent.children.push(dir); } parent = dir; } parent.children.push({ type: 'file', name, path: file.path, file }); } /** @param {DirNode} dir */ const finish = (dir) => { const folded = collapse(dir); folded.children = sortNodes( folded.children.map((child) => child.type === 'dir' ? finish(/** @type {DirNode} */ (child)) : child, ), ); return folded; }; return sortNodes( root.children.map((child) => child.type === 'dir' ? finish(/** @type {DirNode} */ (child)) : child, ), ); } /** * The files a filter matches, by substring of the whole path. An empty * filter matches everything, so the tree is whole until someone types. * @param {Array<{path: string}>} files * @param {string} filter */ export function filterFiles(files, filter) { const needle = filter.trim().toLowerCase(); if (!needle) return files; return files.filter((file) => file.path.toLowerCase().includes(needle)); }