diff --git a/CHANGELOG.md b/CHANGELOG.md index 305c933d..e942d46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- Backups keep the oldest copy of every file NMSh changed (what it was before NMSh first touched it). Frequent automatic writes, such as cursor shaders regenerating, no longer push it out of `/changes`. - On a slow terminal connection or a busy machine, the terminal's reply to NMSh's startup image check no longer appears in the composer as `_Gi=31;OK\` and no longer turns images into character art. NMSh's startup check now ends as soon as the terminal has answered (instead of always waiting 80 ms), waits up to 0.75 s for a slow one, and still uses an answer that comes later. - A Kitty or Ghostty image drawn before NMSh had taken the screen (a welcome image that loaded during startup) is sent again instead of leaving blank space where the picture belongs. - A native welcome image (Kitty, Ghostty) stays a native image after the first key press, scroll or resize instead of turning into character art. A logo animation still stops at once and its frames are deleted; only the still remains. diff --git a/src/backups/journal.ts b/src/backups/journal.ts index c615a132..041cd864 100644 --- a/src/backups/journal.ts +++ b/src/backups/journal.ts @@ -198,11 +198,17 @@ export class Journal { const key=`${item.source}:${file.path}`; if(!anchored.has(key)){keep.add(item.id);anchored.add(key);} } + // The oldest backup of each file holds what it was before NMSh first changed it: a burst of automatic writes to other + // files (cursor shaders regenerate often) must not push that original out. It goes only when nothing else can. + const originals = new Set(), originalPaths = new Set(); + for (const item of [...existing].reverse()) for (const file of item.files) { + if (!originalPaths.has(file.path)) { originals.add(item.id); originalPaths.add(file.path); } + } const markerText = () => JSON.stringify({keys:[...pruned],count:previousCount+victims.length}); const fits = () => used + size + (victims.length ? Buffer.byteLength(markerText()) - oldMarker : 0) <= maxBytes && count + 1 <= maxEntries; - for (const old of [...existing].reverse()) { + for (const lastResort of [false, true]) for (const old of [...existing].reverse()) { if (fits()) break; - if (keep.has(old.id)) continue; + if (keep.has(old.id) || victims.includes(old) || (!lastResort && originals.has(old.id))) continue; used -= cost(old); count--; victims.push(old); for(const file of old.files)pruned.add(digest(`${old.source}:${file.path}`)); } diff --git a/tests/backupsJournal.test.ts b/tests/backupsJournal.test.ts index 218baedf..6141674c 100644 --- a/tests/backupsJournal.test.ts +++ b/tests/backupsJournal.test.ts @@ -141,3 +141,15 @@ test('the newest source/path anchor is retained even if its write was interrupte assert.ok(journal.list().some(entry=>entry.id===newest.id));assert.throws(()=>journal.read(old.id)); assert.equal(journal.provenanceComplete('cursor',f.path),false); }); +test('a burst of automatic writes to another file never prunes the original backup of a config file', t => { + const f=fixture(t);const journal=new Journal({home:f.home,directory:f.directory,maxEntries:10}); + const shader=join(f.home,'cursor.glsl');writeFileSync(shader,'rev start'); + const write=(path:string,after:string,source:string,label:string)=>{const entry=journal.record([{before:snapshot(path,f.home),after}],source,label);writeFileSync(path,after);journal.complete(entry);return entry;}; + const original=write(f.path,'appearance 1','appearance','First appearance edit'); + write(f.path,'appearance 2','appearance','Second appearance edit'); + for(let n=0;n<40;n++)write(shader,`rev ${n}`,'cursor','Cursor shader'); + const kept=journal.list(); + assert.ok(kept.length<=10); + assert.ok(kept.some(entry=>entry.id===original.id),'the first backup of the config file (its pre-NMSh content) is kept'); + assert.equal(journal.content(journal.read(original.id),0),'before\r\n'); +});