Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions changelogs/CHANGELOG-version-history.md
Original file line number Diff line number Diff line change
@@ -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.
166 changes: 166 additions & 0 deletions css/version-history.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,7 @@ <h5>Menu</h5>
<div class="ws-context-menu" id="workspace-context-menu">
<button class="ws-ctx-item" id="ws-ctx-rename"><i class="bi bi-pencil"></i> Rename</button>
<button class="ws-ctx-item" id="ws-ctx-duplicate"><i class="bi bi-copy"></i> Duplicate</button>
<button class="ws-ctx-item" id="ws-ctx-history"><i class="bi bi-clock-history"></i> History</button>
<div class="ws-ctx-divider"></div>
<button class="ws-ctx-item danger" id="ws-ctx-delete"><i class="bi bi-trash"></i> Delete</button>
</div>
Expand Down
3 changes: 3 additions & 0 deletions js/cloud-share.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
14 changes: 14 additions & 0 deletions js/disk-workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading
Loading