diff --git a/changelogs/CHANGELOG-version-history.md b/changelogs/CHANGELOG-version-history.md
new file mode 100644
index 0000000..eeeb48a
--- /dev/null
+++ b/changelogs/CHANGELOG-version-history.md
@@ -0,0 +1,40 @@
+# Local Version History — Automatic Snapshots, Diff & Restore
+
+- Added **automatic local version history** for every workspace file — snapshots are captured as you edit and stored in an IndexedDB ring buffer, entirely client-side (no server, no new dependencies)
+- **Capture policy:** first save of a file, then every ≥3 minutes of editing, or immediately when ≥400 characters change; identical content is never re-snapshotted
+- **Ring caps:** 50 snapshots per file, 400 across all files (oldest pruned automatically) so storage stays bounded
+- **History panel** (right-click a file in the sidebar → History): timeline of snapshots with timestamps, sizes and char deltas; selecting one shows a GitHub-style **line diff vs the current document** (hand-rolled LCS with prefix/suffix trim and a size guard for huge docs)
+- **Restore** any version with one click — a safety snapshot of the current content is taken first, so restores are themselves undoable; restore routes through the existing save paths so disk-workspace and single-linked files get written too
+- **Copy** any version to the clipboard without restoring
+- New `M.versionHistory` API: `onSave`, `open`, `close`, `snapshotNow(label)`
+- Hooks added at the two authoritative save choke points: `saveToLocalStorage` (keystroke autosave, cloud-share.js) and `setFileContent` (file-switch saves, workspace.js)
+
+---
+
+## Summary
+
+TextAgent autosaved by overwriting — Ctrl+Z died with the session, and one bad paste or AI-accept could silently destroy hours of work. This adds the classic "must-have" of serious editors: automatic local version history with visual diff and one-click restore. Fully client-side (IndexedDB), zero new dependencies (the line diff is ~90 lines of hand-rolled LCS, matching the project's zero-dep ethos), bounded storage via a two-level ring buffer.
+
+---
+
+## 1. Snapshot Engine
+**Files:** `js/version-history.js` (new)
+**What:** IndexedDB store (`textagent-history/snapshots`, indexed by fileId and ts). `onSave(fileId, content)` is called from both save paths; a per-file in-memory cache decides whether a snapshot is due (first-save / 3-min interval / 400-char delta / content-identical dedupe). Two-level pruning keeps ≤50 per file and ≤400 globally.
+**Impact:** Every meaningful edit state is recoverable without the user doing anything.
+
+## 2. History Panel + Diff + Restore
+**Files:** `js/version-history.js`, `css/version-history.css` (new), `index.html`, `js/workspace.js`
+**What:** `ws-ctx-history` context-menu item opens a modal: snapshot timeline on the left, diff-vs-current on the right (LCS line diff with common prefix/suffix trimming and a 4M-cell guard that degrades to a replace block). Restore takes a safety snapshot first, then writes via `wsSaveCurrent()` for the active file (covering disk-folder and single-linked-file write-back) or directly to localStorage + disk APIs for non-active files.
+**Impact:** Visual, trustworthy recovery — you see exactly what changed before restoring.
+
+## 3. Save-path Hooks
+**Files:** `js/cloud-share.js`, `js/workspace.js`, `src/main.js`
+**What:** One-line `M.versionHistory.onSave(...)` calls after the localStorage writes in `saveToLocalStorage()` and `setFileContent()`; module + CSS imported in main.js phase 3. All calls guarded so the app works identically if the module fails to load.
+**Impact:** Zero behavior change to existing save flows; history is purely additive.
+
+---
+
+## Testing
+- New Playwright spec `tests/feature/version-history.spec.js` (7 tests): API surface, IDB snapshot storage, diff correctness (adds/dels/sames), panel open/close, full restore round-trip incl. safety snapshot, context-menu item, dedupe of identical content.
+- Verified visually at 1280×800 via Playwright screenshot: timeline, delta badges, GitHub-style diff (+2 −1), Copy/Restore actions, light theme.
+- Smoke suite 22/22; build clean; adversarial multi-agent review run on the diff before merge.
diff --git a/css/version-history.css b/css/version-history.css
new file mode 100644
index 0000000..a570dc5
--- /dev/null
+++ b/css/version-history.css
@@ -0,0 +1,166 @@
+/* ============================================
+ version-history.css — Local Version History panel
+ Prefixed vh-*; theme-aware via [data-theme] like the thread panels.
+ ============================================ */
+
+.vh-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 10500;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.vh-panel {
+ width: min(960px, 94vw);
+ height: min(640px, 88vh);
+ display: flex;
+ flex-direction: column;
+ background: #161b22;
+ color: #c9d1d9;
+ border: 1px solid rgba(99, 110, 123, 0.35);
+ border-radius: 14px;
+ box-shadow: 0 16px 56px rgba(0, 0, 0, 0.5);
+ overflow: hidden;
+}
+[data-theme="light"] .vh-panel {
+ background: #ffffff;
+ color: #24292f;
+ border-color: rgba(0, 0, 0, 0.12);
+ box-shadow: 0 16px 56px rgba(0, 0, 0, 0.18);
+}
+
+.vh-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid rgba(99, 110, 123, 0.25);
+ flex-shrink: 0;
+}
+.vh-title { font-size: 14px; font-weight: 600; }
+.vh-title i { margin-right: 6px; color: #8b949e; }
+.vh-close {
+ background: none; border: none; cursor: pointer;
+ color: #8b949e; font-size: 15px; padding: 4px 6px; border-radius: 6px;
+}
+.vh-close:hover { color: inherit; background: rgba(110, 118, 129, 0.15); }
+
+.vh-body {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+}
+
+/* Left: snapshot timeline */
+.vh-list {
+ width: 260px;
+ flex-shrink: 0;
+ overflow-y: auto;
+ border-right: 1px solid rgba(99, 110, 123, 0.25);
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.vh-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ text-align: left;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ padding: 8px 10px;
+ cursor: pointer;
+ color: inherit;
+ font: inherit;
+}
+.vh-item:hover { background: rgba(110, 118, 129, 0.12); }
+.vh-item.active {
+ background: rgba(88, 101, 242, 0.14);
+ border-color: rgba(88, 101, 242, 0.45);
+}
+.vh-item-time { font-size: 13px; font-weight: 600; }
+.vh-item-meta { font-size: 11px; color: #8b949e; }
+.vh-delta { color: #d29922; }
+
+/* Right: diff preview */
+.vh-preview {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.vh-preview-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-bottom: 1px solid rgba(99, 110, 123, 0.25);
+ flex-shrink: 0;
+}
+.vh-diffstat { font-size: 12px; color: #8b949e; }
+.vh-add { color: #3fb950; font-weight: 600; }
+.vh-del { color: #f85149; font-weight: 600; }
+.vh-actions { display: flex; gap: 8px; }
+.vh-btn {
+ display: inline-flex; align-items: center; gap: 5px;
+ font-size: 12px; padding: 5px 10px; border-radius: 7px; cursor: pointer;
+ background: rgba(110, 118, 129, 0.12);
+ border: 1px solid rgba(99, 110, 123, 0.3);
+ color: inherit;
+}
+.vh-btn:hover { background: rgba(110, 118, 129, 0.22); }
+.vh-restore {
+ background: rgba(63, 185, 80, 0.15);
+ border-color: rgba(63, 185, 80, 0.4);
+}
+.vh-restore:hover { background: rgba(63, 185, 80, 0.25); }
+
+.vh-diff {
+ flex: 1;
+ overflow: auto;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ padding: 6px 0;
+}
+.vh-line { display: flex; white-space: pre-wrap; word-break: break-word; padding: 0 10px 0 0; }
+.vh-sign {
+ width: 22px; flex-shrink: 0; text-align: center;
+ color: #8b949e; user-select: none;
+}
+.vh-line-add { background: rgba(63, 185, 80, 0.12); }
+.vh-line-add .vh-sign { color: #3fb950; }
+.vh-line-del { background: rgba(248, 81, 73, 0.12); }
+.vh-line-del .vh-sign { color: #f85149; }
+.vh-line-skip {
+ color: #8b949e;
+ font-style: italic;
+ background: rgba(110, 118, 129, 0.06);
+ border-top: 1px dashed rgba(99, 110, 123, 0.25);
+ border-bottom: 1px dashed rgba(99, 110, 123, 0.25);
+}
+
+.vh-empty {
+ padding: 24px 16px;
+ text-align: center;
+ font-size: 12.5px;
+ color: #8b949e;
+ line-height: 1.6;
+}
+
+@media (max-width: 640px) {
+ .vh-panel { width: 100vw; height: 92vh; border-radius: 14px 14px 0 0; }
+ .vh-body { flex-direction: column; }
+ .vh-list {
+ width: 100%;
+ max-height: 32%;
+ border-right: none;
+ border-bottom: 1px solid rgba(99, 110, 123, 0.25);
+ }
+}
diff --git a/index.html b/index.html
index f7a711b..0fa1267 100644
--- a/index.html
+++ b/index.html
@@ -1017,6 +1017,7 @@
Menu
diff --git a/js/cloud-share.js b/js/cloud-share.js
index e57da8a..96c9170 100644
--- a/js/cloud-share.js
+++ b/js/cloud-share.js
@@ -203,6 +203,9 @@
}
localStorage.setItem(AUTOSAVE_TIME_KEY, Date.now().toString());
+ // Version history capture (module decides whether a snapshot is due)
+ if (M.versionHistory) M.versionHistory.onSave(M.wsActiveFileId, M.markdownEditor.value);
+
// Write back to an individually-linked single file (independent of folder mode).
// Checked BEFORE the folder branch so a single-linked file is never written
// through the folder path (which would target the wrong directory).
diff --git a/js/disk-workspace.js b/js/disk-workspace.js
index 7a82450..487b9da 100644
--- a/js/disk-workspace.js
+++ b/js/disk-workspace.js
@@ -203,6 +203,20 @@
return next;
};
+ // Read the current on-disk content of a linked single file (authoritative
+ // source for version-history diffs/restores). Resolves null if unavailable.
+ disk.readSingleFile = async function (id) {
+ var handle = singleFileHandles[id];
+ if (!handle) return null;
+ try {
+ var file = await handle.getFile();
+ return await file.text();
+ } catch (e) {
+ console.warn('readSingleFile failed (permission lost?):', e);
+ return null;
+ }
+ };
+
// Forget a single-file link (e.g. when its workspace file is deleted).
disk.unlinkSingleFile = function (id) {
delete singleFileHandles[id];
diff --git a/js/version-history.js b/js/version-history.js
new file mode 100644
index 0000000..fb1024e
--- /dev/null
+++ b/js/version-history.js
@@ -0,0 +1,514 @@
+// ============================================
+// version-history.js — Local Version History
+// Automatic snapshots of every workspace file into an IndexedDB ring buffer,
+// with a history panel (timeline + line diff) and one-click restore.
+//
+// Capture policy (checked on every autosave via M.versionHistory.onSave):
+// • first save of a file → snapshot
+// • ≥ SNAP_INTERVAL since last snap → snapshot
+// • ≥ SNAP_DELTA chars changed → snapshot (even inside the interval)
+// A safety snapshot of the CURRENT content is always taken before a restore,
+// so restores are themselves undoable.
+//
+// Storage: IndexedDB 'textagent-history', split into two stores so that
+// listing/pruning never deserializes document bodies (review finding):
+// meta { id (auto), fileId, name, ts, size, label? } — tiny records
+// content { id, content } — loaded on demand
+// Ring caps: MAX_PER_FILE + MAX_FILE_BYTES per file, MAX_TOTAL overall.
+// Documents larger than SKIP_ABOVE are never snapshotted (quota safety).
+// ============================================
+(function (M) {
+ 'use strict';
+ if (!M) return;
+
+ // --- Tunables ---
+ var SNAP_INTERVAL = 3 * 60 * 1000; // min ms between time-based snapshots
+ var SNAP_DELTA = 400; // char delta that forces a snapshot
+ var MAX_PER_FILE = 50; // snapshots kept per file
+ var MAX_FILE_BYTES = 25 * 1024 * 1024; // per-file content budget (chars)
+ var MAX_TOTAL = 400; // snapshots kept across all files
+ var SKIP_ABOVE = 5 * 1024 * 1024; // never snapshot docs larger than this
+ var DIFF_MAX_CELLS = 4e6; // LCS DP guard (rows*cols)
+ var DIFF_CONTEXT = 3; // unchanged lines shown around changes
+ var DIFF_MAX_ROWS = 4000; // hard cap on rendered diff rows
+
+ // --- IndexedDB (meta + content stores) ---
+ var DB_NAME = 'textagent-history';
+ var META = 'meta';
+ var CONTENT = 'content';
+
+ function openDB() {
+ return new Promise(function (resolve, reject) {
+ var req = indexedDB.open(DB_NAME, 1);
+ req.onupgradeneeded = function () {
+ var db = req.result;
+ if (!db.objectStoreNames.contains(META)) {
+ var meta = db.createObjectStore(META, { keyPath: 'id', autoIncrement: true });
+ meta.createIndex('byFile', 'fileId', { unique: false });
+ meta.createIndex('byTs', 'ts', { unique: false });
+ }
+ if (!db.objectStoreNames.contains(CONTENT)) {
+ db.createObjectStore(CONTENT, { keyPath: 'id' });
+ }
+ };
+ req.onsuccess = function () { resolve(req.result); };
+ req.onerror = function () { reject(req.error); };
+ });
+ }
+
+ function addSnapshot(rec, content) {
+ return openDB().then(function (db) {
+ return new Promise(function (resolve, reject) {
+ var t = db.transaction([META, CONTENT], 'readwrite');
+ var addReq = t.objectStore(META).add(rec);
+ addReq.onsuccess = function () {
+ t.objectStore(CONTENT).add({ id: addReq.result, content: content });
+ };
+ t.oncomplete = function () { resolve(addReq.result); };
+ t.onerror = function () { reject(t.error); };
+ });
+ });
+ }
+
+ // Meta-only listing — never touches document bodies. Newest first.
+ function listMeta(fileId) {
+ return openDB().then(function (db) {
+ return new Promise(function (resolve, reject) {
+ var t = db.transaction(META, 'readonly');
+ var idx = t.objectStore(META).index('byFile');
+ var out = [];
+ var cur = idx.openCursor(IDBKeyRange.only(fileId));
+ cur.onsuccess = function () {
+ var c = cur.result;
+ if (c) { out.push(c.value); c.continue(); }
+ else { out.sort(function (a, b) { return b.ts - a.ts; }); resolve(out); }
+ };
+ cur.onerror = function () { reject(cur.error); };
+ });
+ });
+ }
+
+ function getContent(id) {
+ return openDB().then(function (db) {
+ return new Promise(function (resolve, reject) {
+ var t = db.transaction(CONTENT, 'readonly');
+ var req = t.objectStore(CONTENT).get(id);
+ req.onsuccess = function () { resolve(req.result ? req.result.content : null); };
+ req.onerror = function () { reject(req.error); };
+ });
+ });
+ }
+
+ function deleteIds(ids) {
+ if (!ids.length) return Promise.resolve();
+ return openDB().then(function (db) {
+ return new Promise(function (resolve, reject) {
+ var t = db.transaction([META, CONTENT], 'readwrite');
+ ids.forEach(function (id) {
+ t.objectStore(META).delete(id);
+ t.objectStore(CONTENT).delete(id);
+ });
+ t.oncomplete = function () { resolve(); };
+ t.onerror = function () { reject(t.error); };
+ });
+ });
+ }
+
+ // Prune using meta only: count cap + per-file byte budget + global cap.
+ function prune(fileId) {
+ return listMeta(fileId).then(function (list) {
+ var drop = [];
+ var bytes = 0;
+ list.forEach(function (m, i) {
+ bytes += m.size || 0;
+ if (i >= MAX_PER_FILE || bytes > MAX_FILE_BYTES) drop.push(m.id);
+ });
+ return deleteIds(drop);
+ }).then(function () {
+ return openDB().then(function (db) {
+ return new Promise(function (resolve) {
+ var t = db.transaction(META, 'readonly');
+ var store = t.objectStore(META);
+ var countReq = store.count();
+ countReq.onsuccess = function () {
+ var over = countReq.result - MAX_TOTAL;
+ if (over <= 0) { resolve([]); return; }
+ var ids = [];
+ var cur = store.index('byTs').openCursor(); // oldest first
+ cur.onsuccess = function () {
+ var c = cur.result;
+ if (c && ids.length < over) { ids.push(c.value.id); c.continue(); }
+ else resolve(ids);
+ };
+ cur.onerror = function () { resolve([]); };
+ };
+ countReq.onerror = function () { resolve([]); };
+ });
+ }).then(deleteIds);
+ }).catch(function (e) { console.warn('[history] prune failed:', e); });
+ }
+
+ // --- Capture policy ---
+ var last = {}; // fileId -> { ts, content }
+ var loading = {};
+ var warnedBig = {};
+
+ function fileName(fileId) {
+ var f = M._wsFindFileById ? M._wsFindFileById(fileId) : null;
+ return f ? f.name : (fileId === '__default__' ? 'Document' : fileId);
+ }
+
+ function shouldSnapshot(fileId, content) {
+ var l = last[fileId];
+ if (!l) return true;
+ if (content === l.content) return false;
+ if (Date.now() - l.ts >= SNAP_INTERVAL) return true;
+ if (Math.abs(content.length - l.content.length) >= SNAP_DELTA) return true;
+ return false;
+ }
+
+ function snapshot(fileId, content, label) {
+ var rec = {
+ fileId: fileId,
+ name: fileName(fileId),
+ ts: Date.now(),
+ size: content.length,
+ label: label || ''
+ };
+ last[fileId] = { ts: rec.ts, content: content };
+ return addSnapshot(rec, content).then(function () { return prune(fileId); })
+ .catch(function (e) { console.warn('[history] snapshot failed:', e); });
+ }
+
+ function onSave(fileId, content) {
+ if (typeof content !== 'string' || content.trim() === '') return;
+ fileId = fileId || '__default__';
+ if (content.length > SKIP_ABOVE) {
+ if (!warnedBig[fileId]) {
+ warnedBig[fileId] = true;
+ console.warn('[history] document too large for version history (>5 MB):', fileId);
+ }
+ return;
+ }
+ if (last[fileId]) {
+ if (shouldSnapshot(fileId, content)) snapshot(fileId, content);
+ return;
+ }
+ if (loading[fileId]) return;
+ loading[fileId] = true;
+ // Lazy baseline: newest meta record + its content (one body read, once per file per session)
+ listMeta(fileId).then(function (list) {
+ if (list.length === 0) return null;
+ return getContent(list[0].id).then(function (c) {
+ if (c != null) last[fileId] = { ts: list[0].ts, content: c };
+ });
+ }).then(function () {
+ if (shouldSnapshot(fileId, content)) snapshot(fileId, content);
+ }).catch(function () {
+ snapshot(fileId, content);
+ }).then(function () { loading[fileId] = false; });
+ }
+
+ // --- Line diff (prefix/suffix trim + LCS on the middle) ---
+ function diffLines(oldText, newText) {
+ var a = oldText.split('\n');
+ var b = newText.split('\n');
+ var start = 0;
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
+ var endA = a.length, endB = b.length;
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
+
+ var head = a.slice(0, start).map(function (l) { return { type: 'same', line: l }; });
+ var tail = a.slice(endA).map(function (l) { return { type: 'same', line: l }; });
+ var midA = a.slice(start, endA);
+ var midB = b.slice(start, endB);
+
+ var mid;
+ if (midA.length * midB.length > DIFF_MAX_CELLS) {
+ mid = midA.map(function (l) { return { type: 'del', line: l }; })
+ .concat(midB.map(function (l) { return { type: 'add', line: l }; }));
+ } else {
+ mid = lcsDiff(midA, midB);
+ }
+ return head.concat(mid, tail);
+ }
+
+ function lcsDiff(a, b) {
+ var n = a.length, m = b.length;
+ if (n === 0) return b.map(function (l) { return { type: 'add', line: l }; });
+ if (m === 0) return a.map(function (l) { return { type: 'del', line: l }; });
+ var dp = new Array(n + 1);
+ for (var i = 0; i <= n; i++) dp[i] = new Int32Array(m + 1);
+ for (i = 1; i <= n; i++) {
+ for (var j = 1; j <= m; j++) {
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ var out = [];
+ i = n; var jj = m;
+ while (i > 0 && jj > 0) {
+ if (a[i - 1] === b[jj - 1]) { out.push({ type: 'same', line: a[i - 1] }); i--; jj--; }
+ else if (dp[i - 1][jj] >= dp[i][jj - 1]) { out.push({ type: 'del', line: a[i - 1] }); i--; }
+ else { out.push({ type: 'add', line: b[jj - 1] }); jj--; }
+ }
+ while (i > 0) { out.push({ type: 'del', line: a[i - 1] }); i--; }
+ while (jj > 0) { out.push({ type: 'add', line: b[jj - 1] }); jj--; }
+ return out.reverse();
+ }
+
+ // Collapse long unchanged runs to context lines and cap total rendered rows,
+ // so a snapshot of a huge document can never freeze the tab (review finding).
+ function renderableDiff(d) {
+ var rows = [];
+ var i = 0;
+ var truncated = false;
+ while (i < d.length) {
+ if (d[i].type !== 'same') { rows.push(d[i]); i++; }
+ else {
+ var runStart = i;
+ while (i < d.length && d[i].type === 'same') i++;
+ var runLen = i - runStart;
+ if (runLen <= DIFF_CONTEXT * 2 + 1) {
+ for (var k = runStart; k < i; k++) rows.push(d[k]);
+ } else {
+ var headEnd = runStart === 0 ? runStart : runStart + DIFF_CONTEXT;
+ var tailStart = i === d.length ? i : i - DIFF_CONTEXT;
+ for (k = runStart; k < headEnd; k++) rows.push(d[k]);
+ rows.push({ type: 'skip', line: (tailStart - headEnd) + ' unchanged lines' });
+ for (k = tailStart; k < i; k++) rows.push(d[k]);
+ }
+ }
+ if (rows.length > DIFF_MAX_ROWS) { truncated = true; break; }
+ }
+ if (truncated) rows.push({ type: 'skip', line: 'diff truncated — too many changes to display' });
+ return rows;
+ }
+
+ // --- Authoritative current content (async — review finding) ---
+ // localStorage is only a cache in disk mode; read the real source of truth.
+ function currentContentFor(fileId) {
+ if (fileId === (M.wsActiveFileId || '__default__')) {
+ return Promise.resolve(M.markdownEditor.value);
+ }
+ if (M._disk && M._disk.hasSingleFile && M._disk.hasSingleFile(fileId) && M._disk.readSingleFile) {
+ return M._disk.readSingleFile(fileId).then(function (c) {
+ return c != null ? c : (localStorage.getItem(M.KEYS.FILE_PREFIX + fileId) || '');
+ });
+ }
+ if (M.wsDiskMode && M._disk && M._disk.isConnected && M._disk.isConnected()) {
+ var f = M._wsFindFileById ? M._wsFindFileById(fileId) : null;
+ if (f) {
+ return M._disk.readFileFromPath(f.name).then(function (c) {
+ return c || (localStorage.getItem(M.KEYS.FILE_PREFIX + fileId) || '');
+ });
+ }
+ }
+ return Promise.resolve(localStorage.getItem(M.KEYS.FILE_PREFIX + fileId) || '');
+ }
+
+ // --- Panel UI ---
+ var panelEl = null;
+
+ function esc(s) {
+ var d = document.createElement('div');
+ d.textContent = s == null ? '' : s;
+ return d.innerHTML;
+ }
+
+ function timeAgo(ts) {
+ var s = Math.floor((Date.now() - ts) / 1000);
+ if (s < 60) return 'just now';
+ var m = Math.floor(s / 60);
+ if (m < 60) return m + 'm ago';
+ var h = Math.floor(m / 60);
+ if (h < 24) return h + 'h ago';
+ var d = Math.floor(h / 24);
+ if (d < 7) return d + 'd ago';
+ return new Date(ts).toLocaleDateString();
+ }
+
+ function closePanel() {
+ if (panelEl) { panelEl.remove(); panelEl = null; }
+ }
+
+ function openPanel(fileId, displayName) {
+ fileId = fileId || M.wsActiveFileId || '__default__';
+ displayName = displayName || fileName(fileId);
+ closePanel();
+
+ var overlay = document.createElement('div');
+ overlay.className = 'vh-overlay';
+ overlay.innerHTML =
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
Select a version to compare it with the current document.
' +
+ '
' +
+ '
';
+ document.body.appendChild(overlay);
+ panelEl = overlay;
+
+ overlay.addEventListener('click', function (e) { if (e.target === overlay) closePanel(); });
+ overlay.querySelector('.vh-close').addEventListener('click', closePanel);
+
+ var listEl = overlay.querySelector('.vh-list');
+ var prevEl = overlay.querySelector('.vh-preview');
+
+ listMeta(fileId).then(function (snaps) {
+ if (!panelEl) return;
+ if (snaps.length === 0) {
+ listEl.innerHTML = 'No versions yet.
Snapshots are captured automatically as you edit.
';
+ return;
+ }
+ listEl.innerHTML = '';
+ snaps.forEach(function (s, i) {
+ var prev = snaps[i + 1];
+ var deltaTxt = '';
+ if (prev) {
+ var d = s.size - prev.size;
+ deltaTxt = d === 0 ? '±0' : (d > 0 ? '+' + d : String(d));
+ }
+ var item = document.createElement('button');
+ item.className = 'vh-item';
+ item.innerHTML =
+ '' + esc(timeAgo(s.ts)) + (s.label ? ' · ' + esc(s.label) : '') + '' +
+ '' + new Date(s.ts).toLocaleString() + ' · ' + s.size + ' chars' +
+ (deltaTxt ? ' · ' + esc(deltaTxt) + '' : '') + '';
+ item.addEventListener('click', function () {
+ listEl.querySelectorAll('.vh-item').forEach(function (el) { el.classList.remove('active'); });
+ item.classList.add('active');
+ prevEl.innerHTML = 'Loading…
';
+ Promise.all([getContent(s.id), currentContentFor(fileId)]).then(function (res) {
+ if (!panelEl) return;
+ if (res[0] == null) {
+ prevEl.innerHTML = 'Snapshot content missing.
';
+ return;
+ }
+ showSnapshot(fileId, s, res[0], res[1], prevEl);
+ }).catch(function (e) {
+ prevEl.innerHTML = 'Failed to load: ' + esc(e.message) + '
';
+ });
+ });
+ listEl.appendChild(item);
+ });
+ }).catch(function (e) {
+ listEl.innerHTML = 'Failed to load history: ' + esc(e.message) + '
';
+ });
+ }
+
+ function showSnapshot(fileId, meta, snapContent, current, prevEl) {
+ var d = diffLines(snapContent, current);
+ var adds = 0, dels = 0;
+ d.forEach(function (r) { if (r.type === 'add') adds++; else if (r.type === 'del') dels++; });
+ var rows = renderableDiff(d);
+
+ var html =
+ '' +
+ 'vs current: +' + adds + ' −' + dels + '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '';
+ rows.forEach(function (r) {
+ if (r.type === 'skip') {
+ html += '
⋯' + esc(r.line) + '
';
+ return;
+ }
+ var cls = r.type === 'add' ? 'vh-line-add' : r.type === 'del' ? 'vh-line-del' : 'vh-line-same';
+ var sign = r.type === 'add' ? '+' : r.type === 'del' ? '−' : ' ';
+ html += '
' + sign + '' + esc(r.line) + '
';
+ });
+ html += '
';
+ prevEl.innerHTML = html;
+
+ prevEl.querySelector('.vh-copy').addEventListener('click', function () {
+ navigator.clipboard.writeText(snapContent).then(function () {
+ if (M.showToast) M.showToast('📋 Version copied to clipboard', 'success');
+ });
+ });
+ prevEl.querySelector('.vh-restore').addEventListener('click', function () {
+ restore(fileId, meta, snapContent, current);
+ });
+ }
+
+ function restore(fileId, meta, snapContent, current) {
+ // Safety snapshot of the authoritative current content, so the restore is undoable.
+ var p = current && current.trim() && current !== snapContent
+ ? snapshot(fileId, current, 'before restore')
+ : Promise.resolve();
+
+ p.then(function () {
+ var isActive = fileId === (M.wsActiveFileId || '__default__');
+ if (isActive) {
+ M.markdownEditor.value = snapContent;
+ if (M.wsSaveCurrent) M.wsSaveCurrent(); // routes to localStorage + disk paths
+ if (M.renderMarkdown) M.renderMarkdown();
+ if (M.updateDocumentStats) M.updateDocumentStats();
+ return true;
+ }
+ // Non-active file: persist explicitly and report honestly (review finding —
+ // a swallowed quota error must not produce a success toast).
+ var persisted = false;
+ try {
+ localStorage.setItem(M.KEYS.FILE_PREFIX + fileId, snapContent);
+ persisted = true;
+ } catch (_) { /* quota — disk paths below may still succeed */ }
+
+ if (M._disk && M._disk.hasSingleFile && M._disk.hasSingleFile(fileId)) {
+ return M._disk.writeSingleFile(fileId, snapContent).then(function (ok) {
+ return persisted || ok === true;
+ }).catch(function () { return persisted; });
+ }
+ if (M.wsDiskMode && M._disk && M._disk.isConnected && M._disk.isConnected()) {
+ var f = M._wsFindFileById ? M._wsFindFileById(fileId) : null;
+ if (f) {
+ return M._disk.writeFileToPath(f.name, snapContent).then(function () {
+ return true;
+ }).catch(function () { return persisted; });
+ }
+ }
+ return persisted;
+ }).then(function (ok) {
+ if (ok) {
+ last[fileId] = { ts: Date.now(), content: snapContent };
+ closePanel();
+ if (M.showToast) M.showToast('🕐 Restored version from ' + new Date(meta.ts).toLocaleString(), 'success');
+ } else {
+ if (M.showToast) M.showToast('❌ Restore failed — storage is full. Free space and try again.', 'error');
+ }
+ });
+ }
+
+ // Purge all history for a deleted file (review finding: "Delete cannot be
+ // undone" should not leave recoverable content behind, and orphans would
+ // evict live history via the global cap).
+ function deleteFileHistory(fileId) {
+ delete last[fileId];
+ return listMeta(fileId).then(function (list) {
+ return deleteIds(list.map(function (m) { return m.id; }));
+ }).catch(function (e) { console.warn('[history] purge failed:', e); });
+ }
+
+ // --- Expose ---
+ M.versionHistory = {
+ onSave: onSave,
+ open: openPanel,
+ close: closePanel,
+ snapshotNow: function (label) {
+ var id = M.wsActiveFileId || '__default__';
+ return snapshot(id, M.markdownEditor.value, label || 'manual');
+ },
+ deleteFileHistory: deleteFileHistory,
+ // test hooks
+ _list: listMeta,
+ _getContent: getContent,
+ _diff: diffLines
+ };
+
+})(window.MDView);
diff --git a/js/workspace.js b/js/workspace.js
index ef739be..a080a9c 100644
--- a/js/workspace.js
+++ b/js/workspace.js
@@ -119,6 +119,8 @@
console.warn('File save failed:', e);
if (M.showToast) M.showToast('⚠️ Save failed — browser storage is full. Free space or connect a folder.', 'error');
}
+ // Version history capture (covers file-switch saves; module dedupes)
+ if (M.versionHistory) M.versionHistory.onSave(id, content);
// Write back to an individually-linked disk file (works independently of folder mode)
if (M._disk && M._disk.hasSingleFile && M._disk.hasSingleFile(id)) {
M._disk.writeSingleFile(id, content).then(function (ok) {
@@ -510,6 +512,18 @@
if (targetId) M.wsDeleteFile(targetId);
});
+ var ctxHistory = document.getElementById('ws-ctx-history');
+ if (ctxHistory) ctxHistory.addEventListener('click', function (e) {
+ e.stopPropagation();
+ var targetId = contextMenuTargetId;
+ hideContextMenu();
+ if (!targetId) return;
+ var file = findFileById(targetId);
+ // version-history.js loads in a later phase — guard until it's ready
+ if (M.versionHistory) M.versionHistory.open(targetId, file ? file.name : '');
+ else if (M.showToast) M.showToast('History is still loading — try again in a moment.', 'info');
+ });
+
if (ctxDuplicate) ctxDuplicate.addEventListener('click', function (e) {
e.stopPropagation();
var targetId = contextMenuTargetId;
@@ -869,6 +883,9 @@
var wasSingleLinked = M._disk && M._disk.hasSingleFile && M._disk.hasSingleFile(id);
workspace.files.splice(idx, 1);
removeFileContent(id);
+ // Deleting a file says "cannot be undone" — purge its version history too,
+ // so sensitive content isn't retained and orphans don't evict live history.
+ if (M.versionHistory && M.versionHistory.deleteFileHistory) M.versionHistory.deleteFileHistory(id);
// Switch to nearest neighbor if deleting active file
if (id === workspace.activeFileId) {
var newIdx = Math.min(idx, workspace.files.length - 1);
diff --git a/src/main.js b/src/main.js
index cf1c63f..a13301f 100644
--- a/src/main.js
+++ b/src/main.js
@@ -31,6 +31,7 @@ import '../css/ai-docgen.css';
import '../css/feature-demos.css';
import '../css/help-mode.css';
import '../css/workspace.css';
+import '../css/version-history.css';
import '../css/linux-terminal.css';
import '../css/toast.css';
import '../css/stock-widget.css';
@@ -251,6 +252,9 @@ async function loadModules() {
// 3k: Web Tools Component — Scrape & Search via Jina (standalone — remove to disable feature)
await import('../js/tools-docgen.js');
+ // 3k2: Local Version History — snapshot ring + restore panel (standalone)
+ await import('../js/version-history.js');
+
// 3l: Agent Cloud Execution (standalone — depends on M.KEYS from storage-keys)
await import('../js/github-auth.js');
await import('../js/agent-cloud.js');
diff --git a/tests/feature/version-history.spec.js b/tests/feature/version-history.spec.js
new file mode 100644
index 0000000..ea98c7e
--- /dev/null
+++ b/tests/feature/version-history.spec.js
@@ -0,0 +1,136 @@
+// @ts-check
+import { test, expect } from '@playwright/test';
+
+/**
+ * Tests for js/version-history.js — local version history:
+ * snapshot capture, IndexedDB ring, history panel, line diff, restore.
+ */
+test.describe('Version History', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ await page.waitForSelector('#markdown-editor', { state: 'visible' });
+ await page.waitForFunction(() => window.MDView && !!window.MDView.versionHistory, null, { timeout: 20000 });
+ });
+
+ test('MDView.versionHistory API is exposed', async ({ page }) => {
+ const api = await page.evaluate(() => {
+ const vh = window.MDView.versionHistory;
+ return {
+ onSave: typeof vh.onSave,
+ open: typeof vh.open,
+ close: typeof vh.close,
+ snapshotNow: typeof vh.snapshotNow,
+ deleteFileHistory: typeof vh.deleteFileHistory,
+ };
+ });
+ expect(api).toEqual({ onSave: 'function', open: 'function', close: 'function', snapshotNow: 'function', deleteFileHistory: 'function' });
+ });
+
+ test('deleteFileHistory purges all snapshots for a file', async ({ page }) => {
+ const counts = await page.evaluate(async () => {
+ const M = window.MDView;
+ const id = M.wsActiveFileId || '__default__';
+ M.markdownEditor.value = '# Purge test';
+ await M.versionHistory.snapshotNow('purge');
+ const before = (await M.versionHistory._list(id)).length;
+ await M.versionHistory.deleteFileHistory(id);
+ const after = (await M.versionHistory._list(id)).length;
+ return { before, after };
+ });
+ expect(counts.before).toBeGreaterThanOrEqual(1);
+ expect(counts.after).toBe(0);
+ });
+
+ test('snapshotNow stores a snapshot in IndexedDB', async ({ page }) => {
+ const count = await page.evaluate(async () => {
+ const M = window.MDView;
+ M.markdownEditor.value = '# Snapshot test\n\ncontent v1';
+ await M.versionHistory.snapshotNow('spec');
+ const list = await M.versionHistory._list(M.wsActiveFileId || '__default__');
+ return list.filter(s => s.label === 'spec').length;
+ });
+ expect(count).toBeGreaterThanOrEqual(1);
+ });
+
+ test('line diff reports adds and deletions correctly', async ({ page }) => {
+ const d = await page.evaluate(() => {
+ const diff = window.MDView.versionHistory._diff('a\nb\nc', 'a\nB\nc\nd');
+ return {
+ adds: diff.filter(x => x.type === 'add').length,
+ dels: diff.filter(x => x.type === 'del').length,
+ sames: diff.filter(x => x.type === 'same').length,
+ };
+ });
+ expect(d).toEqual({ adds: 2, dels: 1, sames: 2 });
+ });
+
+ test('history panel opens with snapshot list and closes', async ({ page }) => {
+ await page.evaluate(async () => {
+ const M = window.MDView;
+ M.markdownEditor.value = '# Panel test';
+ await M.versionHistory.snapshotNow('panel');
+ M.versionHistory.open();
+ });
+ await expect(page.locator('.vh-panel')).toBeVisible();
+ await expect(page.locator('.vh-item').first()).toBeVisible();
+ await page.locator('.vh-close').click();
+ await expect(page.locator('.vh-panel')).toHaveCount(0);
+ });
+
+ test('restore returns the editor to the snapshot content and adds a safety snapshot', async ({ page }) => {
+ const result = await page.evaluate(async () => {
+ const M = window.MDView;
+ const v1 = '# Restore test\n\noriginal line';
+ M.markdownEditor.value = v1;
+ await M.versionHistory.snapshotNow('r1');
+ M.markdownEditor.value = '# Restore test\n\nCHANGED line\nextra line';
+ M.versionHistory.open();
+ await new Promise(r => setTimeout(r, 500));
+ // select the r1 snapshot (newest-first list: find by label via item order is fragile,
+ // so click each until the restore target matches)
+ const items = document.querySelectorAll('.vh-item');
+ items[items.length - 1].scrollIntoView();
+ return { items: items.length, v1 };
+ });
+ expect(result.items).toBeGreaterThanOrEqual(1);
+
+ // Click the item whose label text contains r1, then restore
+ await page.locator('.vh-item', { hasText: 'r1' }).first().click();
+ await page.locator('.vh-restore').click();
+ await page.waitForTimeout(700);
+
+ const after = await page.evaluate(async () => {
+ const M = window.MDView;
+ const list = await M.versionHistory._list(M.wsActiveFileId || '__default__');
+ return {
+ editor: M.markdownEditor.value,
+ hasSafety: list.some(s => s.label === 'before restore'),
+ panelClosed: !document.querySelector('.vh-panel'),
+ };
+ });
+ expect(after.editor).toBe('# Restore test\n\noriginal line');
+ expect(after.hasSafety).toBe(true);
+ expect(after.panelClosed).toBe(true);
+ });
+
+ test('workspace context menu includes a History item', async ({ page }) => {
+ const exists = await page.evaluate(() => !!document.getElementById('ws-ctx-history'));
+ expect(exists).toBe(true);
+ });
+
+ test('onSave dedupes identical content (no snapshot spam)', async ({ page }) => {
+ const counts = await page.evaluate(async () => {
+ const M = window.MDView;
+ const id = M.wsActiveFileId || '__default__';
+ M.markdownEditor.value = '# Dedupe test unique 9182';
+ await M.versionHistory.snapshotNow('dedupe');
+ const before = (await M.versionHistory._list(id)).length;
+ // repeated saves with identical content must not add snapshots
+ for (let i = 0; i < 5; i++) M.versionHistory.onSave(id, M.markdownEditor.value);
+ await new Promise(r => setTimeout(r, 400));
+ const after = (await M.versionHistory._list(id)).length;
+ return { before, after };
+ });
+ expect(counts.after).toBe(counts.before);
+ });
+});