When working with AI coding agents like Claude Code or Cursor, I often find myself reviewing diffs across multiple repositories at the same time. The agent touches several projects in one session, and I need to quickly see "what changed where" without switching between terminals or setting up complex tooling.
Existing tools are either single-repo, require a server, or need a local install. So I built Duff.
🔗 Demo: https://chigichan24.github.io/duff/
📦 Source: https://github.com/chigichan24/duff
What is Duff?
Duff is a Git diff viewer that runs entirely in your browser. No server, no CLI, no extensions — just open the URL and pick your local Git repositories.
- 🔍 View diffs and modified files across multiple repos at once
- 📊 Interactive commit history graph with range selection
- 🖼️ Visual image diff with pixel-level comparison
- 🔄 Auto-refresh to detect changes as you work
- 💾 Workspace persists across sessions via IndexedDB
How it works
The key insight is combining two browser APIs that aren't often used together:
-
File System Access API (
showDirectoryPicker()) gives the browser direct read access to your local filesystem - isomorphic-git provides a pure JavaScript Git implementation that works in the browser
I wrote a custom adapter that bridges the two — translating isomorphic-git's Node-style fs calls into File System Access API operations.
Browser UI → gitService → isomorphic-git → fsaAdapter → File System Access API → your local .git
Technical hurdles I didn't expect
1. instanceof doesn't work the way you'd think
The File System Access API returns FileSystemFileHandle and FileSystemDirectoryHandle objects. My first instinct was:
if (handle instanceof FileSystemFileHandle) { ... }
This works fine in production, but completely breaks in test environments (Playwright mocks). The fix was using the kind property instead:
if (handle.kind === 'file') { ... }
2. Read-only operations aren't actually read-only
I assumed showing a git status would only need read access. Wrong. isomorphic-git's statusMatrix writes to .git/index as part of its comparison process. This meant my FS adapter needed full write support (createWritable) just to display a diff.
3. File System Access API is ~100x slower than native fs
Every file read goes through the browser's security layer. For a Git repo with hundreds of files, this adds up fast. I had to implement multiple layers of caching:
-
Ref resolution cache (5s TTL) — avoid re-resolving
HEAD→ commit hash on every operation - isomorphic-git object cache — shared across all operations to prevent re-parsing packfiles
- Adapter instance cache (WeakMap per handle) — avoid recreating the fs adapter
- No-op update guards — skip React re-renders when nothing actually changed
4. Persistence is tricky
FileSystemDirectoryHandle objects can be stored in IndexedDB (they're structured-cloneable), so your repo selection survives page reloads. But the browser will revoke the permission — you need to call queryPermission() / requestPermission() again on next visit.
Limitations
- Chromium-only: Firefox and Safari don't implement the File System Access API (and have stated they won't, citing security concerns)
- Performance on large repos: The FSA API overhead makes it impractical for very large repositories
- No push/pull: This is a viewer, not a full Git client
Stack
- React 19 + Vite
- isomorphic-git
- diff2html for diff rendering
- pixelmatch for image diffs
- idb-keyval for IndexedDB persistence
- Hosted on GitHub Pages (zero-cost)
Try it
👉 https://chigichan24.github.io/duff/
Open it in Chrome/Edge/Arc, click "Add your first repository", and pick any local Git repo with uncommitted changes.
If you find it useful, a ⭐ on GitHub would mean a lot!
I'd love to hear your feedback — what would make this more useful for your workflow?
Top comments (0)