~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
117 lines · 3.6 KB Raw History
// 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));
}