local-version-manager
v1.0.6
Published
A browser-based, framework-agnostic file version control library using the File System Access API. No server. No database. Just the browser.
Maintainers
Readme
local-version-manager
A browser-based, framework-agnostic file version control library powered by the File System Access API.
Automatically versions your files on the user's local disk — no server, no database, no backend required.
[!IMPORTANT]
This is NOT a UI library. It provides the core versioning and filesystem logic. You are responsible for building the UI components (buttons, file lists, previewers) that interact with this API.
When to Use It
| Use Case | Example | |----------|---------| | Contract / document management | Law firm tracking revisions of client agreements | | Design asset versioning | Studio keeping every iteration of a brand logo | | Report archival | Finance team auto-archiving monthly Excel reports | | Local-first web apps | Any app that needs offline, no-cloud file history | | Developer tools | Code snippet or config file versioning in-browser |
Browser Support
| Browser | Supported | |---------|-----------| | Chrome | ✅ v86+ | | Edge | ✅ v86+ | | Opera | ✅ v73+ | | Firefox | ❌ | | Safari | ❌ |
Secure context required: The File System Access API only works on HTTPS or
localhost.
Always verify browser support withVersionManager.isSupported()at runtime.
Installation
npm install local-version-managerOptional Peer Dependencies
Install only if you plan to call getFilePreview() on .docx or .xlsx files:
npm install mammoth xlsxQuick Start
import VersionManager from "local-version-manager";
// 1. Initialize (validates browser support)
const vm = await VersionManager.initialize();
// 2. Select root folder — MUST be inside a user gesture (button click, etc.)
document.querySelector("#openBtn").addEventListener("click", async () => {
await vm.selectRootDirectory();
});
// 3. Upload files (versioning is automatic)
document.querySelector("#fileInput").addEventListener("change", async (e) => {
const results = await vm.uploadFiles(Array.from(e.target.files));
// results → [{ name, versionName, versionNum, isNew }]
});
// 4. List files in current directory
const { files, folders } = await vm.listFiles();
// 5. Get version history
const versions = await vm.getVersions("report.pdf");
// 6. Restore a specific version
await vm.restoreVersion("report.pdf", 1);
// 7. Preview a file
const preview = await vm.getFilePreview("report.pdf");
// preview → { blob, url, strategy, mimeType, fileName }
// Always revoke when done:
vm.revokePreviewUrl(preview.url);Step-by-Step Usage Guide
Step 1 — Initialize
import VersionManager from "local-version-manager";
let vm;
try {
vm = await VersionManager.initialize();
} catch (err) {
// Browser does not support File System Access API
console.error("Unsupported browser:", err.message);
}initialize() is a static factory method. It validates browser support and returns a configured VersionManager instance. Call once, then share the instance across your app.
Step 2 — Select Root Directory
// Must be called inside a browser user-gesture handler
button.addEventListener("click", async () => {
const folderName = await vm.selectRootDirectory();
if (folderName) {
console.log("Working in:", folderName);
}
// Returns null if user cancels the picker
});⚠️ User gesture required:
showDirectoryPicker()(called internally) can only be invoked from a click, keydown, or similar user interaction. Calling it outside a gesture will throw aSecurityError.
Step 3 — Upload Files
// From a file input
const input = document.querySelector("#fileInput");
input.addEventListener("change", async () => {
const results = await vm.uploadFiles(Array.from(input.files));
results.forEach((r) => {
console.log(`${r.name} → saved as ${r.versionName} (v${r.versionNum})`);
});
});
// From drag-and-drop
dropzone.addEventListener("drop", async (e) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
await vm.uploadFiles(files);
});Each call to uploadFiles() writes the file to the current directory and saves a numbered version in .versions/. Uploading the same file twice creates file.1.ext then file.2.ext.
Step 4 — List Files
const { files, folders } = await vm.listFiles();
// files → FileEntry[]
files.forEach((f) => {
console.log(`${f.name} (${f.versionCount} versions, ${f.size} bytes)`);
});
// folders → FolderEntry[]
folders.forEach((d) => {
console.log("Folder:", d.name);
});The hidden .versions/ directory is always excluded from results.
Step 5 — Get Version History
const versions = await vm.getVersions("report.pdf");
// Sorted ascending: [{num:1,...}, {num:2,...}]
versions.forEach((v) => {
console.log(`v${v.num} — ${v.size} bytes — ${v.lastModified}`);
});Step 6 — Restore a Version
// Overwrites the main file with the content of version 1
// Does NOT create a new version entry
const restoredFrom = await vm.restoreVersion("report.pdf", 1);
console.log("Restored from:", restoredFrom); // "report.1.pdf"Step 7 — Preview a File
const preview = await vm.getFilePreview("photo.png");
// preview.strategy → "image" | "pdf" | "text" | "docx" | "xlsx" | "open"
// Render based on strategy (your UI handles this):
if (preview.strategy === "image") {
imgElement.src = preview.url;
} else if (preview.strategy === "pdf") {
iframeElement.src = preview.url;
} else if (preview.strategy === "text") {
// fetch the text from preview.url or use readBlobAsText(preview.blob)
}
// ⚠️ Always revoke to free memory:
vm.revokePreviewUrl(preview.url);
// Preview a specific version:
const oldPreview = await vm.getFilePreview("report.pdf", 2);Vanilla JS Example
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Version Manager Demo</title></head>
<body>
<button id="openBtn">Select Folder</button>
<input type="file" id="fileInput" multiple />
<ul id="fileList"></ul>
<ul id="versionList"></ul>
<div id="preview"></div>
<script type="module">
import VersionManager from "local-version-manager";
const vm = await VersionManager.initialize();
document.querySelector("#openBtn").addEventListener("click", async () => {
const name = await vm.selectRootDirectory();
if (!name) return;
await renderFileList();
});
document.querySelector("#fileInput").addEventListener("change", async (e) => {
await vm.uploadFiles(Array.from(e.target.files));
await renderFileList();
});
async function renderFileList() {
const { files } = await vm.listFiles();
const list = document.querySelector("#fileList");
list.innerHTML = files
.map((f) => `<li data-name="${f.name}">${f.name} (${f.versionCount} versions)</li>`)
.join("");
list.addEventListener("click", async (e) => {
const name = e.target.dataset.name;
if (!name) return;
await renderVersionHistory(name);
});
}
async function renderVersionHistory(fileName) {
const versions = await vm.getVersions(fileName);
const list = document.querySelector("#versionList");
list.innerHTML = versions
.map(
(v) =>
`<li>v${v.num} — ${v.size} bytes
<button onclick="restore('${fileName}', ${v.num})">Restore</button>
<button onclick="preview('${fileName}', ${v.num})">Preview</button>
</li>`
)
.join("");
}
window.restore = async (fileName, num) => {
await vm.restoreVersion(fileName, num);
alert(`Restored ${fileName} to version ${num}`);
};
let activeUrl = null;
window.preview = async (fileName, num) => {
if (activeUrl) vm.revokePreviewUrl(activeUrl);
const p = await vm.getFilePreview(fileName, num);
activeUrl = p.url;
const div = document.querySelector("#preview");
if (p.strategy === "image") div.innerHTML = `<img src="${p.url}" />`;
else if (p.strategy === "pdf") div.innerHTML = `<iframe src="${p.url}" width="100%" height="600"></iframe>`;
else if (p.strategy === "text") {
const text = await p.blob.text();
div.innerHTML = `<pre>${text}</pre>`;
}
};
</script>
</body>
</html>React Integration (Logic Only)
This example shows how to wire the library into React without any UI opinions. All rendering is yours.
// useVersionManager.js — custom hook
import { useState, useEffect, useRef } from "react";
import VersionManager from "local-version-manager";
export function useVersionManager() {
const vmRef = useRef(null);
const [ready, setReady] = useState(false);
const [rootName, setRootName] = useState(null);
const [files, setFiles] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
VersionManager.initialize()
.then((instance) => {
vmRef.current = instance;
setReady(true);
})
.catch((err) => setError(err.message));
}, []);
const selectFolder = async () => {
const name = await vmRef.current.selectRootDirectory();
if (name) {
setRootName(name);
await refreshFiles();
}
};
const refreshFiles = async () => {
const { files: f } = await vmRef.current.listFiles();
setFiles(f);
};
const upload = async (fileList) => {
await vmRef.current.uploadFiles(Array.from(fileList));
await refreshFiles();
};
const getVersions = (fileName) => vmRef.current.getVersions(fileName);
const restore = async (fileName, versionNum) => {
await vmRef.current.restoreVersion(fileName, versionNum);
await refreshFiles();
};
const preview = (fileName, versionNum) =>
vmRef.current.getFilePreview(fileName, versionNum ?? undefined);
const revokeUrl = (url) => vmRef.current.revokePreviewUrl(url);
return { ready, rootName, files, error, selectFolder, upload, getVersions, restore, preview, revokeUrl };
}// App.jsx — consume the hook, own the UI completely
import { useState } from "react";
import { useVersionManager } from "./useVersionManager";
export default function App() {
const { ready, rootName, files, error, selectFolder, upload, getVersions, restore, preview, revokeUrl } =
useVersionManager();
const [versions, setVersions] = useState([]);
const [selectedFile, setSelectedFile] = useState(null);
const [previewData, setPreviewData] = useState(null);
if (!ready) return <p>Checking browser support…</p>;
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
const handleFileClick = async (fileName) => {
setSelectedFile(fileName);
const v = await getVersions(fileName);
setVersions(v);
};
const handlePreview = async (fileName, versionNum) => {
if (previewData?.url) revokeUrl(previewData.url);
const p = await preview(fileName, versionNum);
setPreviewData(p);
};
return (
<div>
{!rootName ? (
<button onClick={selectFolder}>Select Folder</button>
) : (
<p>Working in: <strong>{rootName}</strong></p>
)}
<input
type="file"
multiple
onChange={(e) => upload(e.target.files)}
disabled={!rootName}
/>
{/* File list — your UI */}
<ul>
{files.map((f) => (
<li key={f.name} onClick={() => handleFileClick(f.name)} style={{ cursor: "pointer" }}>
{f.name} ({f.versionCount} versions)
</li>
))}
</ul>
{/* Version history — your UI */}
{selectedFile && (
<ul>
{versions.map((v) => (
<li key={v.num}>
v{v.num} — {v.size} bytes — {v.lastModified.toLocaleString()}
<button onClick={() => restore(selectedFile, v.num)}>Restore</button>
<button onClick={() => handlePreview(selectedFile, v.num)}>Preview</button>
</li>
))}
</ul>
)}
{/* Preview — your UI */}
{previewData && previewData.strategy === "image" && (
<img src={previewData.url} alt="preview" style={{ maxWidth: "100%" }} />
)}
{previewData && previewData.strategy === "pdf" && (
<iframe src={previewData.url} width="100%" height="600" title="PDF Preview" />
)}
{previewData && previewData.strategy === "text" && (
<TextPreview blob={previewData.blob} />
)}
</div>
);
}
// Minimal text renderer helper
function TextPreview({ blob }) {
const [text, setText] = useState("");
blob.text().then(setText);
return <pre style={{ overflow: "auto", maxHeight: 400 }}>{text}</pre>;
}Integration Guide — Connecting Your Own UI
Connect a File Input
// HTML: <input type="file" id="upload" multiple />
document.querySelector("#upload").addEventListener("change", async (e) => {
const results = await vm.uploadFiles(Array.from(e.target.files));
// results contains { name, versionName, versionNum, isNew }
// Trigger your UI refresh here
});Display a File List
async function renderFiles() {
const { files, folders } = await vm.listFiles();
// files: [{ name, baseName, ext, size, lastModified, versionCount, handle }]
// folders: [{ name, handle }]
// Map to your UI components / state:
setFileState(files);
setFolderState(folders);
}Show Version History
async function showHistory(fileName) {
const versions = await vm.getVersions(fileName);
// [{ num, name, size, lastModified, handle }] — sorted oldest → newest
setVersionState(versions);
}Trigger Restore
async function onRestoreClick(fileName, versionNum) {
try {
const restoredFrom = await vm.restoreVersion(fileName, versionNum);
notify(`Restored from ${restoredFrom}`);
await renderFiles(); // refresh your list
} catch (err) {
notify(`Restore failed: ${err.message}`);
}
}Handle Preview Rendering
async function onPreviewClick(fileName, versionNum = null) {
const preview = await vm.getFilePreview(fileName, versionNum);
switch (preview.strategy) {
case "image":
// Set <img src={preview.url} />
break;
case "pdf":
// Set <iframe src={preview.url} />
break;
case "text":
// const text = await preview.blob.text()
break;
case "docx":
// Use renderDocxPreview(preview.blob, mammoth) from the library helpers
break;
case "xlsx":
// Use renderXlsxPreview(preview.blob, XLSX) from the library helpers
break;
case "open":
// File type not previewable in-browser — offer a download link instead
const a = document.createElement("a");
a.href = preview.url;
a.download = preview.fileName;
a.click();
break;
}
// ⚠️ Revoke when done to free memory
vm.revokePreviewUrl(preview.url);
}Navigate Folders
// Enter a sub-folder
await vm.navigateInto("Invoices");
// Navigate back via breadcrumbs
const crumbs = vm.getBreadcrumbs();
// [{ name: "MyFolder", index: 0 }, { name: "Invoices", index: 1 }]
await vm.navigateTo(0); // back to root
// Get current path string
const path = vm.getCurrentPath(); // "MyFolder/Invoices"API Reference
VersionManager.initialize()
Validates browser support and returns a new instance.
static initialize(): Promise<VersionManager>
// Throws if File System Access API is not supportedvm.selectRootDirectory()
Prompts user to pick a folder. Creates .versions/ inside it.
selectRootDirectory(): Promise<string | null>
// Returns folder name, or null if user cancels
// ⚠️ Must be called inside a user gesture (click, keydown, etc.)vm.uploadFiles(files)
Uploads files to the current directory. Every upload creates a new version.
uploadFiles(files: File[]): Promise<UploadResult[]>
type UploadResult = {
name: string; // "report.pdf"
versionName: string; // "report.2.pdf"
versionNum: number; // 2
isNew: boolean; // true only for the first upload
};vm.listFiles()
Returns files and sub-folders in the current directory.
listFiles(): Promise<{ files: FileEntry[]; folders: FolderEntry[] }>
type FileEntry = {
name: string;
baseName: string;
ext: string;
size: number;
lastModified: Date;
versionCount: number;
handle: FileSystemFileHandle;
};
type FolderEntry = {
name: string;
handle: FileSystemDirectoryHandle;
};vm.getVersions(fileName)
Returns full version history, sorted ascending by version number.
getVersions(fileName: string): Promise<VersionEntry[]>
type VersionEntry = {
name: string; // "report.2.pdf"
num: number; // 2
size: number;
lastModified: Date;
handle: FileSystemFileHandle;
};vm.restoreVersion(fileName, versionNumber)
Overwrites the main file with the content of the specified version.
Does NOT create a new version entry.
restoreVersion(fileName: string, versionNumber: number): Promise<string>
// Returns the version file name restored from (e.g. "report.1.pdf")
// Throws if version does not existvm.getFilePreview(fileName, versionNumber?)
Returns a preview object. The caller is responsible for rendering.
getFilePreview(fileName: string, versionNumber?: number): Promise<PreviewResult>
type PreviewResult = {
blob: Blob;
url: string; // Use in <img>, <iframe>, fetch(), etc.
strategy: "image" | "pdf" | "text" | "docx" | "xlsx" | "open";
mimeType: string;
fileName: string;
};⚠️ Always call
vm.revokePreviewUrl(url)when done to avoid memory leaks.
vm.createFolder(folderName)
Creates a sub-folder and its .versions/ mirror.
createFolder(folderName: string): Promise<FileSystemDirectoryHandle>vm.navigateInto(folderName) / vm.navigateTo(index)
Navigate the library's internal directory pointer.
navigateInto(folderName: string): Promise<void>
navigateTo(index: number): Promise<void>Helper Methods
vm.getCurrentPath(): string // "MyFolder/Invoices"
vm.getBreadcrumbs(): Breadcrumb[] // [{ name, index }]
vm.revokePreviewUrl(url: string): voidExported Utility Functions
These helpers are exported and can be imported directly if needed:
import {
splitNameExt, // Split "report.pdf" → { baseName: "report", ext: "pdf" }
buildVersionFileName,// Build "report.2.pdf"
parseVersionFileName,// Parse "report.2.pdf" → { num: 2 }
formatFileSize, // 1048576 → "1.0 MB"
formatTimestamp, // Date → "Mar 21, 10:45 AM"
getMimeType, // "photo.png" → "image/png"
getFileIcon, // "photo.png" → "image"
getPreviewStrategy, // "photo.png" → "image"
readBlobAsText, // blob → Promise<string>
renderDocxPreview, // blob, mammoth → Promise<html string>
renderXlsxPreview, // blob, XLSX → Promise<Sheet[]>
STRATEGIES, // { TEXT, IMAGE, PDF, DOCX, XLSX, OPEN, NONE }
} from "local-version-manager";Best Practices
✅ HTTPS or localhost only
The File System Access API requires a secure context. Ensure your app is served over HTTPS in production.
// Check at runtime
if (!('showDirectoryPicker' in window)) {
alert("File System Access API not supported. Use Chrome or Edge over HTTPS.");
}✅ User gesture requirement
selectRootDirectory() must be called inside a user-triggered event handler:
// ✅ Correct
button.addEventListener("click", () => vm.selectRootDirectory());
// ❌ Wrong — will throw SecurityError
window.onload = () => vm.selectRootDirectory();✅ Always revoke object URLs
Every call to getFilePreview() creates a blob: URL that holds memory. Free it when the preview is unmounted or replaced:
// React cleanup
useEffect(() => {
return () => {
if (previewUrl) vm.revokePreviewUrl(previewUrl);
};
}, [previewUrl]);
// Vanilla JS
const preview = await vm.getFilePreview("file.png");
// ... use preview.url ...
vm.revokePreviewUrl(preview.url); // when no longer needed✅ Handle errors gracefully
try {
await vm.uploadFiles(files);
} catch (err) {
if (err.name === "NotAllowedError") {
// Permission denied — user needs to re-select the folder
} else if (err.name === "AbortError") {
// User cancelled the picker
} else {
console.error("Upload failed:", err);
}
}✅ Re-permission on page reload
The File System Access API requires the user to re-select the folder on every page load (permissions do not persist). Always check if rootHandle is set before calling other methods:
// Safe pattern
if (!rootName) {
await vm.selectRootDirectory();
}Storage Architecture
/user-selected-root/
├── report.pdf ← main file (always the latest content)
├── photo.png
├── Invoices/ ← user subfolder
│ └── invoice-001.pdf
└── .versions/ ← hidden; managed by the library
├── report/
│ ├── report.1.pdf ← first upload
│ └── report.2.pdf ← second upload
├── photo/
│ └── photo.1.png
└── Invoices/
└── invoice-001/
└── invoice-001.1.pdfRules:
- The main file always has the original name and holds the latest content.
- Versions live in
.versions/{baseName}/{baseName}.{N}.{ext}. restoreVersion()overwrites the main file only — no new version is appended.- Each sub-folder has its own isolated version history under
.versions/.
Architecture Overview
local-version-manager/
├── src/
│ ├── index.js ← Public API surface (VersionManager class)
│ ├── core/
│ │ ├── fileSystem.js ← Low-level File System Access API wrappers
│ │ └── versionManager.js ← Mid-level versioning operations
│ └── utils/
│ └── helpers.js ← Pure utilities: parsing, formatting, preview strategy
├── demo/ ← Standalone HTML demo (not published)
├── dist/ ← Built bundles (ESM + UMD, published to npm)
├── package.json
├── vite.config.js
└── README.mdProject Setup & Development
# Install dev dependencies
npm install
# Run the demo app locally (http://localhost:5173)
npm run dev
# Build the distributable bundles
npm run buildPublishing to npm
1. Prerequisites
- Node.js ≥ 16
- An npmjs.com account
2. Version Bump Strategy
Use Semantic Versioning:
| Change type | Command | Example |
|-------------|---------|---------|
| Bug fix | npm version patch | 1.0.0 → 1.0.1 |
| New feature (backward compatible) | npm version minor | 1.0.0 → 1.1.0 |
| Breaking change | npm version major | 1.0.0 → 2.0.0 |
3. Publish
# Log in to npm
npm login
# Build the distribution bundles
npm run build
# Bump version (choose one)
npm version patch # bug fix
npm version minor # new feature
npm version major # breaking change
# Publish to npm registry
npm publish
# Verify it was published
npm view local-version-manager4. Subsequent Releases
# Make your changes, then:
npm version patch # or minor / major
npm run build
npm publishTip: Run
npm pack --dry-runbefore publishing to verify exactly which files will be included in the package.
License
MIT © 2025
