From 58a02305fa155a5238e759efcb92ac77b374252b Mon Sep 17 00:00:00 2001 From: nonatofabio Date: Tue, 28 Apr 2026 22:48:06 -0700 Subject: [PATCH 1/5] Add lastSyncedText baseline + accept/reject hooks (snapshot model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for v1.6 diff-on-reload. DocumentStore now carries a per-tab lastSyncedText alongside rawText: when external edits land via the file watcher, rawText updates but lastSyncedText holds the user's last reviewed version, so a diff can be computed. New surface: hasInFlightDiff — true when lastSyncedText != rawText acceptAllChanges() — promotes rawText to baseline, no file change rejectAllChanges() — writes baseline back to disk, the in-place watcher rewrite is a no-op (rawText already matches), so we don't loop setLastSyncedText(_:) — JS-side per-chunk accept; new baseline setRawText(_:) — JS-side per-chunk reject; writes through Sidecar gains an optional lastSyncedText (omitted when there's no in-flight diff, so v1.5 sidecars round-trip cleanly). Closing a tab or app mid-review restores the review state on reopen. --- Sources/mindle/DocumentStore.swift | 104 ++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 8 deletions(-) diff --git a/Sources/mindle/DocumentStore.swift b/Sources/mindle/DocumentStore.swift index e50f299..7ab5c44 100644 --- a/Sources/mindle/DocumentStore.swift +++ b/Sources/mindle/DocumentStore.swift @@ -25,14 +25,19 @@ struct FileNode: Identifiable, Equatable { } /// One open document inside a window. Active-tab state still lives in -/// the window-scoped @Published vars (`fileURL`, `rawText`, `annotations`) -/// so all existing features keep working untouched; inactive tabs are -/// snapshotted here and rehydrated on activate. +/// the window-scoped @Published vars (`fileURL`, `rawText`, `annotations`, +/// `lastSyncedText`) so all existing features keep working untouched; +/// inactive tabs are snapshotted here and rehydrated on activate. struct DocumentTab: Identifiable, Equatable { let id: UUID var fileURL: URL var rawText: String var annotations: [Annotation] + /// Baseline against which diff-on-reload compares. Equals `rawText` + /// when there are no in-flight external edits. When an external write + /// updates `rawText`, this stays at the previously-reviewed version + /// until the user accepts the change. + var lastSyncedText: String } @MainActor @@ -40,6 +45,11 @@ final class DocumentStore: ObservableObject { @Published var fileURL: URL? @Published var rawText: String = "" @Published var annotations: [Annotation] = [] + /// Baseline for diff-on-reload. When `lastSyncedText != rawText`, the + /// reader view shows track-changes between the two. Accepting clears + /// the diff (lastSyncedText := rawText); rejecting reverts the + /// document on disk (rawText := lastSyncedText, written through). + @Published var lastSyncedText: String = "" @Published var theme: ReaderTheme = .sepia @Published var fontScale: Double = 1.0 @@ -122,7 +132,7 @@ final class DocumentStore: ObservableObject { // returns to it. snapshotActiveTab() - let newTab = DocumentTab(id: UUID(), fileURL: url, rawText: text, annotations: []) + let newTab = DocumentTab(id: UUID(), fileURL: url, rawText: text, annotations: [], lastSyncedText: text) tabs.append(newTab) activeTabID = newTab.id @@ -133,6 +143,7 @@ final class DocumentStore: ObservableObject { self.fileURL = url self.rawText = text + self.lastSyncedText = text self.annotations = [] self.loadSidecar() @@ -218,6 +229,7 @@ final class DocumentStore: ObservableObject { activeTabID = nil fileURL = nil rawText = "" + lastSyncedText = "" annotations = [] closeSearch() focusedAnnotation = nil @@ -234,11 +246,13 @@ final class DocumentStore: ObservableObject { tabs[i].fileURL = url tabs[i].rawText = rawText tabs[i].annotations = annotations + tabs[i].lastSyncedText = lastSyncedText } private func loadTabState(_ tab: DocumentTab) { fileURL = tab.fileURL rawText = tab.rawText + lastSyncedText = tab.lastSyncedText annotations = tab.annotations closeSearch() focusedAnnotation = nil @@ -367,6 +381,64 @@ final class DocumentStore: ObservableObject { focusedAnnotation = id } + // MARK: - Diff review (v1.6) + + /// True while the on-disk text has diverged from the user's last + /// reviewed baseline — i.e., an external edit landed and hasn't + /// been accepted or rejected yet. + var hasInFlightDiff: Bool { lastSyncedText != rawText } + + /// Accept all in-flight changes: the new text becomes the baseline. + /// No file mutation — the disk already has the new text. + func acceptAllChanges() { + guard hasInFlightDiff else { return } + lastSyncedText = rawText + snapshotActiveTab() + saveSidecar() + } + + /// Reject all in-flight changes: write the baseline back to disk. + /// The watcher will fire on the rewrite and reloadFromDisk no-ops + /// (rawText already matches), so we're not racing with ourselves. + func rejectAllChanges() { + guard hasInFlightDiff, let url = fileURL else { return } + let reverted = lastSyncedText + rawText = reverted + do { + try reverted.write(to: url, atomically: true, encoding: .utf8) + } catch { + NSSound.beep() + } + snapshotActiveTab() + saveSidecar() + } + + /// JS-side accept of a single chunk produces a new lastSyncedText + /// that incorporates that chunk's "after" content. Swift just stores + /// it; the WebView re-renders the now-smaller diff. + func setLastSyncedText(_ text: String) { + guard text != lastSyncedText else { return } + lastSyncedText = text + snapshotActiveTab() + saveSidecar() + } + + /// JS-side reject of a single chunk produces a new rawText that + /// reverts that chunk to its "before" content. Swift writes through + /// to disk; the watcher will reflect the rewrite without re-firing + /// the diff render (rawText already matches). + func setRawText(_ text: String) { + guard text != rawText, let url = fileURL else { return } + rawText = text + do { + try text.write(to: url, atomically: true, encoding: .utf8) + } catch { + NSSound.beep() + } + snapshotActiveTab() + saveSidecar() + } + // MARK: - PDF export var canExportPDF: Bool { fileURL != nil } @@ -415,6 +487,12 @@ final class DocumentStore: ObservableObject { var annotations: [Annotation] var theme: ReaderTheme? var fontScale: Double? + /// Persisted only when the user has an unfinished diff review — + /// i.e., `lastSyncedText != rawText`. On reopen, this restores the + /// review state so a closed-and-relaunched window picks up where + /// it left off. Nil when there's no in-flight diff (the common + /// case), so existing v1.5 sidecars decode cleanly. + var lastSyncedText: String? } private func loadSidecar() { @@ -426,25 +504,35 @@ final class DocumentStore: ObservableObject { annotations = decoded.annotations if let t = decoded.theme { theme = t } if let s = decoded.fontScale { fontScale = s } + if let baseline = decoded.lastSyncedText { + lastSyncedText = baseline + } } } func saveSidecar() { guard let url = sidecarURL else { return } - writeSidecar(to: url, annotations: annotations) + let baseline = (lastSyncedText != rawText) ? lastSyncedText : nil + writeSidecar(to: url, annotations: annotations, lastSynced: baseline) } private func saveSidecar(forTab tab: DocumentTab) { let url = tab.fileURL.deletingLastPathComponent() .appendingPathComponent(".\(tab.fileURL.lastPathComponent).mindle.json") - writeSidecar(to: url, annotations: tab.annotations) + let baseline = (tab.lastSyncedText != tab.rawText) ? tab.lastSyncedText : nil + writeSidecar(to: url, annotations: tab.annotations, lastSynced: baseline) } - private func writeSidecar(to url: URL, annotations: [Annotation]) { + private func writeSidecar(to url: URL, annotations: [Annotation], lastSynced: String?) { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 - let sidecar = Sidecar(annotations: annotations, theme: theme, fontScale: fontScale) + let sidecar = Sidecar( + annotations: annotations, + theme: theme, + fontScale: fontScale, + lastSyncedText: lastSynced + ) if let data = try? encoder.encode(sidecar) { try? data.write(to: url, options: .atomic) } From dc926f2b3ab0ffa8467f30838fc1f7bf0eb4383a Mon Sep 17 00:00:00 2001 From: nonatofabio Date: Tue, 28 Apr 2026 22:54:06 -0700 Subject: [PATCH 2/5] Vendor jsdiff 7.0 for diff-on-reload Loaded after the existing markdown-it / highlight.js / katex / mermaid chain in reader.html. Exposes window.Diff with diffLines / diffWords, which the diff render layer uses to walk lastSyncedText vs rawText. --- Resources/web/reader.html | 1 + Resources/web/vendor/diff.min.js | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 Resources/web/vendor/diff.min.js diff --git a/Resources/web/reader.html b/Resources/web/reader.html index 629f1ab..d30a297 100644 --- a/Resources/web/reader.html +++ b/Resources/web/reader.html @@ -12,6 +12,7 @@ +
diff --git a/Resources/web/vendor/diff.min.js b/Resources/web/vendor/diff.min.js new file mode 100644 index 0000000..4d96b76 --- /dev/null +++ b/Resources/web/vendor/diff.min.js @@ -0,0 +1,37 @@ +/*! + + diff v7.0.0 + +BSD 3-Clause License + +Copyright (c) 2009-2015, Kevin Decker +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +@license +*/ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((e="undefined"!=typeof globalThis?globalThis:e||self).Diff={})}(this,function(e){"use strict";function r(){}function w(e,n,t,r,i){for(var o,l=[];n;)l.push(n),o=n.previousComponent,delete n.previousComponent,n=o;l.reverse();for(var a=0,u=l.length,s=0,f=0;ae.length?n:e}),d.value=e.join(c)):d.value=e.join(t.slice(s,s+d.count)),s+=d.count,d.added||(f+=d.count))}return l}r.prototype={diff:function(l,a){var u=2=d&&c<=v+1)return f(w(s,p[0].lastComponent,a,l,s.useLongestToken));var g=-1/0,m=1/0;function i(){for(var e=Math.max(g,-h);e<=Math.min(m,h);e+=2){var n=void 0,t=p[e-1],r=p[e+1],i=(t&&(p[e-1]=void 0),!1),o=(r&&(o=r.oldPos-e,i=r&&0<=o&&o=d&&c<=v+1)return f(w(s,n.lastComponent,a,l,s.useLongestToken));(p[e]=n).oldPos+1>=d&&(m=Math.min(m,e-1)),c<=v+1&&(g=Math.max(g,e+1))}else p[e]=void 0}h++}if(n)!function e(){setTimeout(function(){if(tr)return n();i()||e()},0)}();else for(;h<=t&&Date.now()<=r;){var o=i();if(o)return o}},addToPath:function(e,n,t,r,i){var o=e.lastComponent;return o&&!i.oneChangePerToken&&o.added===n&&o.removed===t?{oldPos:e.oldPos+r,lastComponent:{count:o.count+1,added:n,removed:t,previousComponent:o.previousComponent}}:{oldPos:e.oldPos+r,lastComponent:{count:1,added:n,removed:t,previousComponent:o}}},extractCommon:function(e,n,t,r,i){for(var o=n.length,l=t.length,a=e.oldPos,u=a-r,s=0;u+1n.length&&(t=e.length-n.length);var r=n.length;e.lengthe.length)&&(n=e.length);for(var t=0,r=new Array(n);te.length)return!1;for(var t=0;t"):r.removed&&n.push(""),n.push(r.value.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")),r.added?n.push(""):r.removed&&n.push("")}return n.join("")},e.createPatch=function(e,n,t,r,i,o){return M(e,e,n,t,r,i,o)},e.createTwoFilesPatch=M,e.diffArrays=function(e,n,t){return F.diff(e,n,t)},e.diffChars=function(e,n,t){return I.diff(e,n,t)},e.diffCss=function(e,n,t){return m.diff(e,n,t)},e.diffJson=function(e,n,t){return x.diff(e,n,t)},e.diffLines=y,e.diffSentences=function(e,n,t){return g.diff(e,n,t)},e.diffTrimmedLines=function(e,n,t){return t=function(e,n){if("function"==typeof e)n.callback=e;else if(e)for(var t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}(t,{ignoreWhitespace:!0}),v.diff(e,n,t)},e.diffWords=function(e,n,t){return null==(null==t?void 0:t.ignoreWhitespace)||t.ignoreWhitespace?i.diff(e,n,t):a(e,n,t)},e.diffWordsWithSpace=a,e.formatPatch=E,e.merge=function(e,n,t){e=J(e,t),n=J(n,t);for(var r={},i=((e.index||n.index)&&(r.index=e.index||n.index),(e.newFileName||n.newFileName)&&(q(e)?q(n)?(r.oldFileName=H(r,e.oldFileName,n.oldFileName),r.newFileName=H(r,e.newFileName,n.newFileName),r.oldHeader=H(r,e.oldHeader,n.oldHeader),r.newHeader=H(r,e.newHeader,n.newHeader)):(r.oldFileName=e.oldFileName,r.newFileName=e.newFileName,r.oldHeader=e.oldHeader,r.newHeader=e.newHeader):(r.oldFileName=n.oldFileName||e.oldFileName,r.newFileName=n.newFileName||e.newFileName,r.oldHeader=n.oldHeader||e.oldHeader,r.newHeader=n.newHeader||e.newHeader)),r.hunks=[],0),o=0,l=0,a=0;i Date: Tue, 28 Apr 2026 22:54:18 -0700 Subject: [PATCH 3/5] Render diff-on-reload as track-changes blocks with per-chunk chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mindleLoad now takes an optional third argument — the diff baseline. When the baseline differs from the current text, the JS pipeline: 1. Walks Diff.diffLines(baseline, current) and groups consecutive +/- segments into chunks (each with start/end positions in both baseline and current). 2. Builds a markdown source that interleaves unchanged regions of 'current' with raw-HTML chunk wrappers around each change. 3. Pre-renders each chunk's before/after through markdown-it (so lists, headings, code fences inside a chunk look right) and wraps them in styled
blocks with ✓ Keep / ✗ Revert chips. 4. Attaches click handlers that compute a new baseline (accept) or a new current text (reject) and post to Swift via the diffSetLastSynced / diffSetCurrent message channels. CSS adds the chunk chrome — accent-bordered card, soft red strikethrough for the deleted block, soft green wash for the added block, small serif-friendly buttons. The print pipeline (PDF export) hides the deleted blocks and chrome entirely so exported docs reflect the accepted text only. --- Resources/web/reader.css | 82 +++++++++++++++++++++++++ Resources/web/reader.js | 127 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 207 insertions(+), 2 deletions(-) diff --git a/Resources/web/reader.css b/Resources/web/reader.css index c57cf45..b473dc1 100644 --- a/Resources/web/reader.css +++ b/Resources/web/reader.css @@ -475,3 +475,85 @@ html.mindle-print-mode .mindle-mermaid { color: var(--muted); font-style: italic; } + +/* ------------------------------------------------------------------ + Diff-on-reload chrome (v1.6). + ------------------------------------------------------------------ */ + +.mindle-diff-chunk { + margin: 1.25em 0; + padding: 0.75em 1em; + border-left: 3px solid var(--accent); + background: var(--code-bg); + border-radius: 4px; +} + +.mindle-diff-removed, +.mindle-diff-added { + padding: 0.4em 0.75em; + border-radius: 3px; + margin: 0.25em 0; +} + +.mindle-diff-removed { + background: rgba(192, 57, 43, 0.10); + text-decoration: line-through; + text-decoration-color: rgba(192, 57, 43, 0.55); +} + +.mindle-diff-removed > :first-child { margin-top: 0; } +.mindle-diff-removed > :last-child { margin-bottom: 0; } + +.mindle-diff-added { + background: rgba(39, 174, 96, 0.10); +} + +.mindle-diff-added > :first-child { margin-top: 0; } +.mindle-diff-added > :last-child { margin-bottom: 0; } + +.mindle-diff-controls { + display: flex; + gap: 0.5em; + margin-top: 0.5em; +} + +.mindle-diff-controls button { + font: 11px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + font-weight: 500; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid var(--rule); + background: var(--background); + color: var(--text); + cursor: pointer; +} +.mindle-diff-controls button:hover { + background: var(--surface); +} +.mindle-diff-controls button[data-mindle-diff-action="accept"]:hover { + border-color: rgba(39, 174, 96, 0.55); + color: rgb(39, 130, 80); +} +.mindle-diff-controls button[data-mindle-diff-action="reject"]:hover { + border-color: rgba(192, 57, 43, 0.55); + color: rgb(160, 50, 40); +} + +/* PDF export hides the diff UI entirely — printed output is the + accepted, current text. The chunk wrappers and removed blocks + collapse so only the .mindle-diff-added content survives. */ +html.mindle-print-mode .mindle-diff-chunk { + background: transparent !important; + border-left: none !important; + padding: 0 !important; + margin: 0 !important; +} +html.mindle-print-mode .mindle-diff-removed, +html.mindle-print-mode .mindle-diff-controls { + display: none !important; +} +html.mindle-print-mode .mindle-diff-added { + background: transparent !important; + padding: 0 !important; + margin: 0 !important; +} diff --git a/Resources/web/reader.js b/Resources/web/reader.js index 6d5c8eb..431cb9b 100644 --- a/Resources/web/reader.js +++ b/Resources/web/reader.js @@ -206,15 +206,32 @@ return "```yaml\n" + m[1] + "\n```\n\n" + src.slice(m[0].length); } - window.mindleLoad = async function (markdown, preserveScroll) { + // -------- Diff state (v1.6) -------- + // Track the active doc's two-text state so accept/reject handlers can + // compute a new lastSynced or new current after a click. Updated on + // every mindleLoad call. + let activeCurrent = ""; + let activeLastSynced = ""; + let diffChunks = []; // {id, removeStart, removeEnd, addStart, addEnd, before, after} + + window.mindleLoad = async function (markdown, preserveScroll, lastSynced) { // Live-reload: capture scroll before swapping HTML so we can restore // it once the new render is laid out. Initial loads / tab switches // pass false and start at the top. const savedScroll = preserveScroll ? window.scrollY : 0; - renderedHTML = md.render(unwrapFrontmatter(markdown || "")); + activeCurrent = markdown || ""; + activeLastSynced = (lastSynced != null) ? String(lastSynced) : ""; + const showDiff = activeLastSynced && activeLastSynced !== activeCurrent && window.Diff; + if (showDiff) { + renderedHTML = md.render(buildDiffMarkdownSource(activeLastSynced, activeCurrent)); + } else { + renderedHTML = md.render(unwrapFrontmatter(activeCurrent)); + diffChunks = []; + } // Switching documents clears search state; annotations are replayed below. searchState = { query: "", current: 0, total: 0, matchSets: [] }; await applyAll(); + if (showDiff) attachDiffHandlers(); reportSearchResult(); if (preserveScroll) { // applyAll's mermaid pass can settle in another frame; restore on @@ -223,6 +240,112 @@ } }; + // -------- Diff render helpers -------- + + function buildDiffMarkdownSource(lastSynced, current) { + diffChunks = computeDiffChunks(lastSynced, current); + let source = ""; + let cursor = 0; // position in `current` + for (const chunk of diffChunks) { + if (cursor < chunk.addStart) { + source += current.slice(cursor, chunk.addStart); + } + source += renderChunkBlock(chunk); + cursor = chunk.addEnd; + } + if (cursor < current.length) { + source += current.slice(cursor); + } + return unwrapFrontmatter(source); + } + + function computeDiffChunks(lastSynced, current) { + const parts = window.Diff.diffLines(lastSynced, current); + const chunks = []; + let removePos = 0, addPos = 0, idx = 0; + let i = 0; + while (i < parts.length) { + const p = parts[i]; + if (!p.added && !p.removed) { + removePos += p.value.length; + addPos += p.value.length; + i++; + continue; + } + const removeStart = removePos; + const addStart = addPos; + let before = "", after = ""; + while (i < parts.length && (parts[i].added || parts[i].removed)) { + if (parts[i].removed) { + before += parts[i].value; + removePos += parts[i].value.length; + } else if (parts[i].added) { + after += parts[i].value; + addPos += parts[i].value.length; + } + i++; + } + chunks.push({ + id: "mindle-diff-" + idx++, + removeStart, removeEnd: removePos, + addStart, addEnd: addPos, + before, after + }); + } + return chunks; + } + + function renderChunkBlock(chunk) { + // Pre-render before/after through markdown-it so block markdown + // (lists, code fences, headings) inside a chunk renders correctly. + // The outer wrapper is a raw HTML block — markdown-it leaves it + // alone since blank lines surround it. + const beforeHTML = chunk.before.trim() ? md.render(chunk.before) : ""; + const afterHTML = chunk.after.trim() ? md.render(chunk.after) : ""; + let body = ""; + if (beforeHTML) body += '
' + beforeHTML + '
'; + if (afterHTML) body += '
' + afterHTML + '
'; + const controls = + '
' + + '' + + '' + + '
'; + return "\n\n" + + '
' + + body + controls + + '
' + + "\n\n"; + } + + function attachDiffHandlers() { + doc.querySelectorAll('[data-mindle-diff-action]').forEach((btn) => { + btn.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const action = btn.getAttribute("data-mindle-diff-action"); + const id = btn.getAttribute("data-mindle-diff-id"); + const chunk = diffChunks.find(c => c.id === id); + if (!chunk) return; + if (action === "accept") { + // Promote this chunk's "after" into the baseline. + const newLastSynced = + activeLastSynced.slice(0, chunk.removeStart) + + chunk.after + + activeLastSynced.slice(chunk.removeEnd); + postToSwift("diffSetLastSynced", { text: newLastSynced }); + } else if (action === "reject") { + // Revert this chunk's "after" back to the baseline's "before" + // — Swift will write through to disk. + const newCurrent = + activeCurrent.slice(0, chunk.addStart) + + chunk.before + + activeCurrent.slice(chunk.addEnd); + postToSwift("diffSetCurrent", { text: newCurrent }); + } + }); + }); + } + window.mindleSetTheme = function (theme) { document.documentElement.dataset.theme = theme; // Mermaid diagrams bake the theme into their SVG at render time, so From b642dbf1c2bf46f999980bab0404f82ab5ecbb2a Mon Sep 17 00:00:00 2001 From: nonatofabio Date: Tue, 28 Apr 2026 22:54:28 -0700 Subject: [PATCH 4/5] Wire diff round-trip + Keep/Revert All menu items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebReaderView: - Watches lastSyncedText alongside rawText so a baseline-only change triggers a re-render. Live-reload preservation still applies. - Passes the baseline as mindleLoad's third arg (or null when there is no in-flight diff, so the JS hot path stays cheap). - Adds diffSetLastSynced and diffSetCurrent message handlers; per-chunk accept/reject in JS lands in DocumentStore.setLastSyncedText / setRawText. MindleCommands: - Edit menu gains 'Keep All Changes' (⌘⌥⏎) and 'Revert All Changes' (⌘⌥⌫). Both disabled when there's no in-flight diff. Useful when the agent's revision is bulk-correct or bulk-wrong and the user doesn't want to chip through every chunk. --- Sources/mindle/MindleApp.swift | 8 ++++++ Sources/mindle/WebReaderView.swift | 43 +++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Sources/mindle/MindleApp.swift b/Sources/mindle/MindleApp.swift index 17f6f88..4617028 100644 --- a/Sources/mindle/MindleApp.swift +++ b/Sources/mindle/MindleApp.swift @@ -159,6 +159,14 @@ struct MindleCommands: Commands { } .keyboardShortcut("n", modifiers: [.command, .shift]) .disabled(store == nil) + + Divider() + Button("Keep All Changes") { store?.acceptAllChanges() } + .keyboardShortcut(.return, modifiers: [.command, .option]) + .disabled(!(store?.hasInFlightDiff ?? false)) + Button("Revert All Changes") { store?.rejectAllChanges() } + .keyboardShortcut(.delete, modifiers: [.command, .option]) + .disabled(!(store?.hasInFlightDiff ?? false)) } CommandGroup(after: .textEditing) { diff --git a/Sources/mindle/WebReaderView.swift b/Sources/mindle/WebReaderView.swift index dae9592..b672142 100644 --- a/Sources/mindle/WebReaderView.swift +++ b/Sources/mindle/WebReaderView.swift @@ -12,6 +12,8 @@ struct WebReaderView: NSViewRepresentable { userContent.add(context.coordinator, name: "selectionChanged") userContent.add(context.coordinator, name: "annotationClicked") userContent.add(context.coordinator, name: "searchResult") + userContent.add(context.coordinator, name: "diffSetLastSynced") + userContent.add(context.coordinator, name: "diffSetCurrent") config.userContentController = userContent config.setURLSchemeHandler(ImageSchemeHandler(), forURLScheme: ImageSchemeHandler.scheme) config.defaultWebpagePreferences.allowsContentJavaScript = true @@ -34,16 +36,27 @@ struct WebReaderView: NSViewRepresentable { let coord = context.coordinator guard coord.loaded else { return } - // Only push values that actually changed to avoid resetting DOM/selection - if store.rawText != coord.lastSource { - // Same file, content changed → live reload, preserve scroll. + // Re-render whenever rawText OR lastSyncedText changes — the diff + // pipeline keys off the pair, so a baseline change has to flow + // through to the view even when rawText is unchanged. + if store.rawText != coord.lastSource || store.lastSyncedText != coord.lastSyncedText { + // Same file, rawText changed → live reload, preserve scroll. // Different file (or first load) → fresh load, start at top. + // Baseline-only change (accept/reject) preserves scroll too. let isLiveReload = (coord.lastFileURL == store.fileURL && !coord.lastSource.isEmpty) coord.lastSource = store.rawText + coord.lastSyncedText = store.lastSyncedText coord.lastFileURL = store.fileURL let baseDir = store.fileURL?.deletingLastPathComponent().path ?? "" + // mindleLoad's third arg is the diff baseline: when it differs + // from arg one, the JS layer renders track-changes with chips. + // Pass null when there's no in-flight diff so the JS hot path + // skips the diff machinery entirely. + let baselineLiteral = (store.lastSyncedText == store.rawText) + ? "null" + : jsString(store.lastSyncedText) web.evaluateJavaScript("window.mindleSetBaseDir(\(jsString(baseDir)));") - web.evaluateJavaScript("window.mindleLoad(\(jsString(store.rawText)), \(isLiveReload));") + web.evaluateJavaScript("window.mindleLoad(\(jsString(store.rawText)), \(isLiveReload), \(baselineLiteral));") } if store.theme.rawValue != coord.lastTheme { @@ -109,6 +122,7 @@ struct WebReaderView: NSViewRepresentable { // Track last-sent values to avoid redundant pushes var lastSource: String = "" + var lastSyncedText: String = "" var lastFileURL: URL? var lastTheme: String = "" var lastFontScale: Double = 0 @@ -210,6 +224,7 @@ struct WebReaderView: NSViewRepresentable { loaded = true // Force initial flush by clearing tracked state lastSource = "" + lastSyncedText = "" lastFileURL = nil lastTheme = "" lastFontScale = 0 @@ -261,6 +276,26 @@ struct WebReaderView: NSViewRepresentable { self.parent.store.updateSearchResult(total: total, current: current) } + case "diffSetLastSynced": + // Per-chunk accept: JS computed a new baseline incorporating + // the chunk's "after" content. Swift just stores it; the + // re-render shows the now-shrunk diff. + guard let body = message.body as? [String: Any], + let text = body["text"] as? String else { return } + Task { @MainActor in + self.parent.store.setLastSyncedText(text) + } + + case "diffSetCurrent": + // Per-chunk reject: JS computed a new current text where + // the chunk's "after" was reverted to the baseline's + // "before". Swift writes that through to disk. + guard let body = message.body as? [String: Any], + let text = body["text"] as? String else { return } + Task { @MainActor in + self.parent.store.setRawText(text) + } + default: break } } From 946062882b0f5994151c5a5a51988f47c8e8d56b Mon Sep 17 00:00:00 2001 From: nonatofabio Date: Wed, 29 Apr 2026 09:05:18 -0700 Subject: [PATCH 5/5] Scroll to chunk on accept/reject; remove diagnostic logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a Keep / Revert, snap the next-render scroll position to where the chunk used to live so the user gets visible confirmation that the action landed. Without this, removing the chunk's chrome collapses the doc and preserveScroll lands you in the wrong place — the change landed but felt invisible. Also removes the v1.6 instrumentation that helped diagnose the issue (NSLog calls on accept/reject paths, a JS dlog channel + the matching WKScriptMessageHandler). Pure cleanup — no behavior change beyond the scroll. --- Resources/web/reader.js | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Resources/web/reader.js b/Resources/web/reader.js index 431cb9b..8c6a8d9 100644 --- a/Resources/web/reader.js +++ b/Resources/web/reader.js @@ -214,6 +214,12 @@ let activeLastSynced = ""; let diffChunks = []; // {id, removeStart, removeEnd, addStart, addEnd, before, after} + // Set by attachDiffHandlers when the user clicks ✓ Keep / ✗ Revert, + // so the next render after the round-trip can scroll to where the + // chunk used to be — visual confirmation that the action landed, + // instead of leaving the reader frozen at the previous scrollY. + let pendingScrollAfterRender = null; + window.mindleLoad = async function (markdown, preserveScroll, lastSynced) { // Live-reload: capture scroll before swapping HTML so we can restore // it once the new render is laid out. Initial loads / tab switches @@ -236,7 +242,13 @@ if (preserveScroll) { // applyAll's mermaid pass can settle in another frame; restore on // the next paint so the position lands after layout finalizes. - requestAnimationFrame(() => window.scrollTo(0, savedScroll)); + requestAnimationFrame(() => { + const target = (pendingScrollAfterRender != null) + ? pendingScrollAfterRender + : savedScroll; + pendingScrollAfterRender = null; + window.scrollTo(0, target); + }); } }; @@ -326,6 +338,16 @@ const id = btn.getAttribute("data-mindle-diff-id"); const chunk = diffChunks.find(c => c.id === id); if (!chunk) return; + // Capture where the chunk lives now so we can scroll back to + // the same vertical position on the next render — when the + // chunk chrome has collapsed but the content stays where the + // user was looking. Without this the reader drifts up to a + // shorter doc and the just-actioned content scrolls off-view. + const wrapper = btn.closest(".mindle-diff-chunk"); + if (wrapper) { + pendingScrollAfterRender = + wrapper.getBoundingClientRect().top + window.scrollY - 40; + } if (action === "accept") { // Promote this chunk's "after" into the baseline. const newLastSynced =