“What changed between these two folders?” is a question you usually answer with a desktop diff tool. ZumiFiles answers it in the browser: point it at two directory handles and it walks both trees, pairs up entries, and marks each one added, removed, modified, or unchanged.
The engine is the File System Access API. The design goal is to be fast on large trees, which means classifying by cheap metadata first and only reading file contents when it's genuinely ambiguous.
Key Technical Concepts Covered
- Recursive traversal: async iteration over directory entries.
- Relative-path keys: pair files across the two trees.
- Cheap-first classification: size and modified-time before bytes.
- A typed diff result: added / removed / modified / unchanged.
Part of the engineering behind ZumiFiles.
Walking a Tree
A FileSystemDirectoryHandle is async-iterable. ZumiFiles recurses into subdirectories and records each file under its path relative to the root, building a flat map keyed by that path:
async function walk(dir, prefix = '', out = new Map()) {
for await (const [name, handle] of dir.entries()) {
const path = prefix ? `${prefix}/${name}` : name;
if (handle.kind === 'directory') {
await walk(handle, path, out);
} else {
const file = await handle.getFile();
out.set(path, { size: file.size, mtime: file.lastModified });
}
}
return out;
}
Pairing and Classifying
With both trees flattened into maps, the diff is a set comparison over the union of keys. A path in only one side is added or removed; a path in both is modified if its size or modified-time differs, and unchanged otherwise:
function diff(left, right) {
const paths = new Set([...left.keys(), ...right.keys()]);
const result = [];
for (const path of paths) {
const l = left.get(path), r = right.get(path);
let state;
if (l && !r) state = 'removed';
else if (!l && r) state = 'added';
else if (l.size !== r.size || l.mtime !== r.mtime) state = 'modified';
else state = 'unchanged';
result.push({ path, state });
}
return result.sort((a, b) => a.path.localeCompare(b.path));
}
Only Read Bytes When You Must
Size-and-mtime is fast but can miss a same-size edit, and can false-positive when a copy resets the timestamp. For the ambiguous cases - same size, different mtime - ZumiFiles can fall back to hashing just those files, so the expensive byte read happens for a handful of entries instead of the whole tree:
async function contentEqual(handleA, handleB) {
const [a, b] = await Promise.all([handleA.getFile(), handleB.getFile()]);
if (a.size !== b.size) return false;
const [ha, hb] = await Promise.all([sha256(a), sha256(b)]);
return ha === hb;
}
Conclusion: A Desktop Diff, Client-Side
Recursive async traversal, relative-path pairing, and a cheap-first classification give ZumiFiles a folder diff that scales to large trees without a backend. Compare metadata first, read bytes only when the answer is genuinely in doubt, and the whole comparison stays fast and fully local.