DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building a macOS Finder from Scratch: One Tree, One Pointer, Three Renderers

A file browser looks like a pile of UI: an icon grid, a sortable list, Miller columns, a breadcrumb, Back and Forward. Build it the obvious way and each of those becomes its own tangle of state that can drift out of sync. Model the data correctly and the opposite happens — the UI collapses into a set of pure functions of one pointer. Here is a working macOS Finder built that way, in vanilla HTML, CSS and JS with no libraries and no image files.

The filesystem is a nested object

A folder is a node with a children array; a file is a leaf with size, modified and kind. That is the entire data model — a tree of plain objects. The trick that makes everything else cheap is a single pass that hangs a parent pointer on every node, so any node can trace its way back to the root.

(function link(node, parent) {
  node.parent = parent || null;
  (node.children || []).forEach(c => link(c, node));
})(VFS, null);

function chainOf(node) {                 // root ... node
  const a = [];
  for (let n = node; n; n = n.parent) a.unshift(n);
  return a;
}
Enter fullscreen mode Exit fullscreen mode

chainOf climbs those pointers to produce the path from the root down to any node. That one function hands you both the breadcrumb and the column view almost for free.

One pointer is the whole state

The app has almost no state: current (the open folder), a view flag, a sel (the highlighted item) and a sort. A single render() reads current and repaints everything. Change current, call render(), done — the three views can never disagree because there is no per-view state to fall out of sync.

const state = { current: VFS, view: "icon", sel: null, sort: { key: "name", dir: 1 } };

function render() {
  renderCrumbs();                 // from chainOf(current)
  renderStatus();                 // "N items"
  ({ icon: renderIcon, list: renderList, column: renderColumn })[state.view]();
}
Enter fullscreen mode Exit fullscreen mode

Back and Forward are an array and an index

Navigation pushes onto a history array and, exactly like a browser, truncates any forward entries before pushing a new one. Back and Forward just move an index and read the node back out — no recomputation, because a node fully describes a location.

let history = [VFS], hi = 0;
function go(node) {
  if (node === state.current) return;
  history = history.slice(0, hi + 1);   // drop the forward branch
  history.push(node); hi = history.length - 1;
  state.current = node; state.sel = null; render();
}
function back()    { if (hi > 0)                 { state.current = history[--hi]; render(); } }
function forward() { if (hi < history.length - 1) { state.current = history[++hi]; render(); } }
Enter fullscreen mode Exit fullscreen mode

The breadcrumb needs no state of its own — it maps chainOf(current) to buttons, each calling go() on its node. The trail is always correct because it is computed, never stored.

Three renderers of the same folder

Icon, List and Column are not three features — they are three functions of current.children. The icon grid lays tiles; the list is a table whose comparator keeps folders first then sorts by the clicked column. The Miller-column view is where parent pointers pay off: render one pane per folder in chainOf(current), highlight the child that leads to the next pane, and drilling in makes the chain longer, so a new pane appears on the right.

function renderColumn() {
  const chain = chainOf(state.current);          // folders currently open
  const wrap  = el("div", "mac-cols");
  chain.forEach((folder, i) => {
    const next = chain[i + 1];                    // the folder drilled into
    const col  = el("div", "mac-col");
    folder.children.forEach(node => {
      const row = el("div", "mac-col-row" +
        (node === next ? " lead" : node === state.sel ? " sel" : ""));
      row.onclick = () => isFolder(node) ? go(node) : selectFile(node);
      col.append(row);
    });
    wrap.append(col);
  });
  main.append(wrap);
  main.scrollLeft = main.scrollWidth;             // keep the newest pane in view
}
Enter fullscreen mode Exit fullscreen mode

Even Recents and AirDrop are just synthetic pseudo-nodes — a folder whose children are the newest files gathered by walking the tree — so they flow through the same renderers unchanged. A new source costs a node, not a new code path. Every icon is inline SVG, drawn not downloaded.

Get the model right — a tree, a pointer, a history — and the interface becomes almost nothing. Drive it yourself here: https://dev48v.infy.uk/design/day58-macos-finder.html

Top comments (0)