From bdba19379c2034ca336d0cbb140a7fd95199643f Mon Sep 17 00:00:00 2001 From: ijbo Date: Sun, 5 Jul 2026 00:04:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20local=20version=20history=20=E2=80=94?= =?UTF-8?q?=20automatic=20snapshots,=20diff=20&=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatic IndexedDB snapshots of every workspace file (first save / 3-min interval / 400-char delta), a History panel (sidebar right-click) with a hand-rolled LCS line diff vs the current document, one-click restore with a pre-restore safety snapshot, and Copy-to-clipboard. Zero new dependencies. Hardened per adversarial multi-agent review (9 confirmed findings fixed): - meta/content split in IDB so listing & pruning never deserialize bodies - per-file byte budget (25MB) + count caps (50/file, 400 global) + >5MB skip - diff rendering collapses unchanged runs to 3-line context and caps rows, preventing DOM blowups on large documents - restore reads the AUTHORITATIVE current content (disk / single-linked file, not the localStorage cache) for diffs and safety snapshots - restore reports honestly: quota failures show an error, never a false success toast - deleting a file purges its history (no sensitive retention, no orphan eviction of live history) Tests: 9-test Playwright spec (API, IDB storage, diff correctness, panel, restore round-trip incl. safety snapshot, dedupe, purge); smoke green; visual verify at 1280x800. Co-Authored-By: Claude Fable 5 --- changelogs/CHANGELOG-version-history.md | 40 ++ css/version-history.css | 166 ++++++++ index.html | 1 + js/cloud-share.js | 3 + js/disk-workspace.js | 14 + js/version-history.js | 514 ++++++++++++++++++++++++ js/workspace.js | 17 + src/main.js | 4 + tests/feature/version-history.spec.js | 136 +++++++ 9 files changed, 895 insertions(+) create mode 100644 changelogs/CHANGELOG-version-history.md create mode 100644 css/version-history.css create mode 100644 js/version-history.js create mode 100644 tests/feature/version-history.spec.js 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 = + ''; + 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); + }); +});