chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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<FileNode|DirNode>} 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<FileNode|DirNode>} 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<FileNode|DirNode>} the roots, sorted
*/
export function buildFileTree(files) {
/** @type {DirNode} */
const root = { type: 'dir', name: '', path: '', children: [] };
/** @type {Map<string, DirNode>} */
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));
}