From 1edd08bb79f22000c26934d5943ebc7aea8564af Mon Sep 17 00:00:00 2001 From: PathGao Date: Mon, 3 Aug 2026 13:53:37 +0800 Subject: [PATCH] fix(editor): show the preview without making the user save first Leaving the editor re-read the file from disk, so a dirty tab had to resolve a save decision - silently write, or answer a modal - before the rendered view could appear. dayeggpi reported this in #168: "no way to see rendered view until file is saved." The prompt was never about losing data. Nothing is lost by switching view mode; the buffer stays in memory either way. It existed because the exit path called `loadMarkdown(tab.path)`, and reading the disk on a dirty tab would show the wrong text. The untitled branch two lines below already rendered the buffer instead - and #407 extracted `renderTabPreviewFromRaw` for the print path, which is exactly the same operation with a real path. Both exits now use it. That also removes three problems the disk read carried: the exit took the 50KB preview branch, so leaving the editor on a large file re-truncated a complete buffer and refused saves until the background read finished; `loadMarkdown` writes into the *active* tab, so `toggleSplitView(tabId)` on a background tab would have yanked the active one; and `if (!success) return` kept the tab in edit mode when the write failed - which for a read-only file or a lossily decoded buffer meant reading mode was permanently unreachable. One segment of that block is kept, narrowed to `autoSave && !confirmBeforeSave`: the auto-save effect treats a tab as writable only while `isEditing || isSplit`, and clears its pending timer otherwise, so leaving edit mode drops the scheduled write. That flush is the last chance before the window closes, not a condition of the switch. Closing a tab and closing a window still ask. Those buffers are about to cease to exist; this one is not. VS Code, Obsidian and Typora all render the buffer, and none of them asks. Co-Authored-By: Claude Opus 5 --- scripts/viewModeWithoutSaving.test.ts | 447 ++++++++++++++++++++++++++ src/lib/MarkdownViewer.svelte | 205 ++++++------ 2 files changed, 543 insertions(+), 109 deletions(-) create mode 100644 scripts/viewModeWithoutSaving.test.ts diff --git a/scripts/viewModeWithoutSaving.test.ts b/scripts/viewModeWithoutSaving.test.ts new file mode 100644 index 0000000..faf24e0 --- /dev/null +++ b/scripts/viewModeWithoutSaving.test.ts @@ -0,0 +1,447 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +// Issue #168, second report by @dayeggpi: "allow user to switch to rendered +// view without saving/creating file ... no way to see rendered view until file +// is saved". +// +// The cause was that reading mode was drawn from DISK: leaving the editor +// called `loadMarkdown(tab.path)`, so a dirty tab had to be flushed first — +// silently with auto-save on, through a modal otherwise — or the reader would +// have shown the pre-edit file. The modal was never protecting the buffer; the +// buffer survives a view toggle. It was protecting the screen from lying. +// +// These tests run the REAL `toggleEdit` / `toggleSplitView` / the real +// `renderTabPreviewFromRaw` out of MarkdownViewer.svelte against the real +// TabManager, with the disk, the renderer and the modal faked. `loadMarkdown` +// is faked to behave like the real one (dirty short-circuit included) and to +// serve a DIFFERENT text than the buffer, so any route that goes back to the +// file is visible in the rendered output rather than merely in the call log. +// +// The boundary tests at the bottom are the other half of the argument: closing +// a tab and closing the window still ask, because there the buffer really is +// about to disappear. + +// ---------------------------------------------------------------- environment + +const g = globalThis as any; +const runeEffect = (fn: () => void) => { + void fn; +}; +runeEffect.root = (fn: () => unknown) => fn(); +g.$state = (value: unknown) => value; +g.$state.raw = (value: unknown) => value; +g.$state.snapshot = (value: unknown) => value; +g.$derived = (value: unknown) => value; +g.$derived.by = (fn: () => unknown) => fn(); +g.$effect = runeEffect; +g.window = g.window ?? {}; +g.window.__TAURI_INTERNALS__ = g.window.__TAURI_INTERNALS__ ?? { + invoke: () => Promise.reject(new Error('no invoke expected in these tests')), +}; + +const { tabManager } = await import('../src/lib/stores/tabs.svelte.js'); +const { settings } = await import('../src/lib/stores/settings.svelte.js'); +const { createDocumentSession } = await import('../src/lib/sessions/documentSession.svelte.js'); + +const viewer = readFileSync(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url), 'utf8'); + +// ------------------------------------------------------------ source plucking + +/** + * Slice one `async function (...) { ... }` out of the component. + * + * Brace counting, but string/template/comment aware — a naive count trips over + * the first `{` inside a comment or a template literal and silently returns a + * body that stops in the middle, which would make every assertion below + * meaningless in exactly the direction that hides bugs. + */ +function pluck(name: string, required = true): string { + const marker = `async function ${name}(`; + const start = viewer.indexOf(marker); + if (start === -1) { + assert.ok(!required, `expected MarkdownViewer.svelte to define ${name}`); + return ''; + } + let i = viewer.indexOf('{', viewer.indexOf(')', start)); + let depth = 0; + for (; i < viewer.length; i++) { + const c = viewer[i]; + const next = viewer[i + 1]; + if (c === '/' && next === '/') { + i = viewer.indexOf('\n', i); + continue; + } + if (c === '/' && next === '*') { + i = viewer.indexOf('*/', i) + 1; + continue; + } + if (c === "'" || c === '"' || c === '`') { + const quote = c; + for (i++; i < viewer.length; i++) { + if (viewer[i] === '\\') i++; + else if (viewer[i] === quote) break; + } + continue; + } + if (c === '{') depth++; + else if (c === '}' && --depth === 0) return viewer.slice(start, i + 1); + } + assert.fail(`unbalanced braces while slicing ${name}`); +} + +type Harness = { + toggleEdit: () => Promise; + toggleSplitView: (tabId: string) => Promise; +}; + +type Fakes = { + disk: Map; + askCustomCalls: string[]; + saveCalls: string[]; + loadCalls: string[]; + renderedFrom: Array<{ raw: string; path: string }>; + toasts: string[]; + saveFails: boolean; + /** Text the "user" appends while `saveContent` is awaiting (TOCTOU). */ + typeDuringSave: string; +}; + +/** `renderMarkdownPreview`'s stand-in: the output names what it was given. */ +const rendered = (raw: string, path: string) => `${raw}`; + +function buildHarness(fakes: Fakes, isEditing: boolean): Harness { + const source = [ + pluck('renderTabPreviewFromRaw'), + // Absent on the pre-fix baseline, where both toggles inline their own + // save/modal flow. Optional so this file runs — and fails on behaviour + // rather than on a missing symbol — against either version. + pluck('flushBeforeLeavingEditableMode', false), + pluck('renderPreviewLeavingEditableMode', false), + pluck('toggleEdit'), + pluck('toggleSplitView'), + ].join('\n\n'); + + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + + const factory = new Function( + 'deps', + `"use strict"; + const { + tabManager, settings, t, addToast, askCustom, saveContent, + cancelPendingAutoSave, renderMarkdownPreview, loadMarkdown, + documentSession, invoke, isEditing, liveMode, toggleLiveMode, + tick, renderRichContent, + } = deps; + ${js} + return { toggleEdit, toggleSplitView };`, + ); + + return factory({ + tabManager, + settings, + isEditing, + t: (key: string) => key, + addToast: (message: string) => fakes.toasts.push(message), + askCustom: async (message: string) => { + fakes.askCustomCalls.push(message); + return 'save' as const; + }, + // Mirrors documentSession.saveContent for a tab that has a path. + saveContent: async (tabId: string) => { + fakes.saveCalls.push(tabId); + const tab = tabManager.tabs.find((item) => item.id === tabId)!; + if (fakes.saveFails) return false; + const snapshot = tab.rawContent; + await Promise.resolve(); + if (fakes.typeDuringSave) tab.rawContent = snapshot + fakes.typeDuringSave; + fakes.disk.set(tab.path, snapshot); + tab.originalContent = snapshot; + tab.isDirty = tab.rawContent !== snapshot; + return true; + }, + cancelPendingAutoSave: () => {}, + renderMarkdownPreview: async (raw: string, path: string) => { + fakes.renderedFrom.push({ raw, path }); + return rendered(raw, path); + }, + // The disk route, with the real function's dirty short-circuit. + loadMarkdown: async (path: string, options: any = {}) => { + fakes.loadCalls.push(path); + const activeId = tabManager.activeTabId!; + const receiving = tabManager.tabs.find((item) => item.id === activeId)!; + if (receiving.isDirty && receiving.path === path && !options.discardUnsavedBuffer) return; + const content = fakes.disk.get(path) ?? ''; + fakes.renderedFrom.push({ raw: content, path }); + tabManager.updateTabContent(activeId, rendered(content, path)); + tabManager.setTabRawContent(activeId, content); + }, + documentSession: { ensureFullContent: async () => true, isLossySaveRefused: () => false }, + invoke: async () => { + throw new Error('no invoke expected while leaving an editable pane'); + }, + liveMode: false, + toggleLiveMode: () => {}, + tick: async () => {}, + renderRichContent: () => {}, + }); +} + +function freshFakes(): Fakes { + return { + disk: new Map(), + askCustomCalls: [], + saveCalls: [], + loadCalls: [], + renderedFrom: [], + toasts: [], + saveFails: false, + typeDuringSave: '', + }; +} + +const ON_DISK = '# saved heading\n'; +const IN_BUFFER = '# saved heading\n\nan edit that was never written to disk\n'; + +/** + * A tab holding unsaved edits to a real file, in whichever editable pane the + * caller names, with the file on disk still carrying the pre-edit text. + */ +function dirtyTab(mode: 'edit' | 'split', path = '/notes/note.md') { + tabManager.closeAll(); + const fakes = freshFakes(); + fakes.disk.set(path, ON_DISK); + tabManager.addTab(path); + const tab = tabManager.activeTab!; + tabManager.setTabRawContent(tab.id, ON_DISK); + tab.isEditing = mode === 'edit'; + tabManager.setSplitEnabled(tab.id, mode === 'split'); + tabManager.updateTabRawContent(tab.id, IN_BUFFER); + assert.equal(tab.isDirty, true, 'precondition: the tab is dirty'); + return { tab, fakes, harness: buildHarness(fakes, mode === 'edit') }; +} + +function setSettings(autoSave: boolean, confirmBeforeSave: boolean) { + settings.autoSave = autoSave; + settings.confirmBeforeSave = confirmBeforeSave; +} + +// ------------------------------------------------------- leaving edit mode + +test('a dirty file switches to reading mode without a modal and without writing', async () => { + setSettings(false, false); + const { tab, fakes, harness } = dirtyTab('edit'); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.askCustomCalls, [], 'no unsaved-changes modal on a view toggle'); + assert.deepEqual(fakes.saveCalls, [], 'nothing is written to disk'); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isEditing, false, 'the user actually reaches reading mode'); + assert.equal(tab.isDirty, true, 'the edits are still unsaved, and still flagged as such'); + assert.equal(tab.rawContent, IN_BUFFER, 'the buffer is intact'); +}); + +test('reading mode shows the buffer, not the file', async () => { + setSettings(false, false); + const { tab, harness, fakes } = dirtyTab('edit'); + + await harness.toggleEdit(); + + // The whole point of the bug: with a disk read here, this is `ON_DISK`. + assert.equal(tab.content, rendered(IN_BUFFER, tab.path)); + assert.deepEqual( + fakes.renderedFrom, + [{ raw: IN_BUFFER, path: tab.path }], + 'the preview is rendered once, from the buffer, under the tab\'s own path', + ); + assert.deepEqual(fakes.loadCalls, [], 'the file is not re-read to leave the editor'); +}); + +test('confirm-before-save no longer turns a view toggle into a question', async () => { + // This setting promises confirmation before each WRITE. With no write left + // to do, it has nothing to confirm. + setSettings(true, true); + const { tab, fakes, harness } = dirtyTab('edit'); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.askCustomCalls, []); + assert.deepEqual(fakes.saveCalls, []); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered(IN_BUFFER, tab.path)); +}); + +test('an untitled buffer never reaches the Save dialog on a view toggle', async () => { + setSettings(true, false); + tabManager.closeAll(); + const fakes = freshFakes(); + tabManager.addTab(''); + const tab = tabManager.activeTab!; + tab.isEditing = true; + tabManager.updateTabRawContent(tab.id, '# untitled\n'); + const harness = buildHarness(fakes, true); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [], 'saveContent would open the Save dialog for a pathless tab'); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered('# untitled\n', '')); +}); + +test('auto-save still flushes on the way out, because the debounce is about to be dropped', async () => { + // The auto-save effect requires `isEditing || isSplit`, so this is the last + // chance to honour "save automatically" for these edits. + setSettings(true, false); + const { tab, fakes, harness } = dirtyTab('edit'); + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.equal(fakes.disk.get(tab.path), IN_BUFFER, 'the flush actually wrote the buffer'); + assert.deepEqual(fakes.askCustomCalls, [], 'the flush is silent — it is the user\'s own setting'); + assert.equal(tab.isDirty, false); + assert.equal(tab.isEditing, false); + assert.equal(tab.content, rendered(IN_BUFFER, tab.path)); +}); + +test('a file that cannot be written no longer traps the user in the editor', async () => { + // Read-only path, or a buffer the lossy-decode guard refuses: saveContent + // returns false forever, and the old code returned early on that, so + // reading mode was unreachable for the life of the tab. + setSettings(true, false); + const { tab, fakes, harness } = dirtyTab('edit'); + fakes.saveFails = true; + + await harness.toggleEdit(); + + assert.deepEqual(fakes.saveCalls, [tab.id], 'the flush was attempted'); + assert.ok(fakes.toasts.includes('toast.autoSaveFailed'), 'and the failure was reported'); + assert.equal(tab.isEditing, false, 'but the view still switches'); + assert.equal(tab.content, rendered(IN_BUFFER, tab.path)); + assert.equal(tab.isDirty, true, 'the buffer is still there to be rescued'); +}); + +test('edits typed during the flush are shown, and reported as not yet on disk', async () => { + setSettings(true, false); + const { tab, fakes, harness } = dirtyTab('edit'); + fakes.typeDuringSave = 'typed while saving\n'; + + await harness.toggleEdit(); + + assert.equal(tab.isDirty, true); + assert.ok(fakes.toasts.includes('toast.savedNewerEdits'), 'the disk is one revision behind'); + assert.equal(tab.isEditing, false, 'the TOCTOU case is no longer a reason to stay in the editor'); + assert.equal( + tab.content, + rendered(IN_BUFFER + 'typed while saving\n', tab.path), + 'the preview shows the newest text, which is exactly what is NOT on disk', + ); +}); + +// --------------------------------------------------- closing the split view + +test('closing split view on a dirty file neither asks nor writes', async () => { + setSettings(false, false); + const { tab, fakes, harness } = dirtyTab('split'); + + await harness.toggleSplitView(tab.id); + + assert.deepEqual(fakes.askCustomCalls, []); + assert.deepEqual(fakes.saveCalls, []); + assert.deepEqual(fakes.loadCalls, []); + assert.equal(fakes.disk.get(tab.path), ON_DISK, 'the file is untouched'); + assert.equal(tab.isSplit, false); + assert.equal(tab.isDirty, true); + assert.equal(tab.content, rendered(IN_BUFFER, tab.path), 'the surviving pane keeps showing the buffer'); +}); + +test('closing split view honours auto-save the same way leaving edit mode does', async () => { + setSettings(true, false); + const { tab, fakes, harness } = dirtyTab('split'); + + await harness.toggleSplitView(tab.id); + + assert.deepEqual(fakes.saveCalls, [tab.id]); + assert.deepEqual(fakes.askCustomCalls, []); + assert.equal(tab.isSplit, false); + assert.equal(tab.content, rendered(IN_BUFFER, tab.path)); +}); + +// ------------------------------------------------------------- the boundary +// +// A view toggle keeps the buffer, so it may stay quiet. These two do not: the +// buffer is about to be destroyed, and that is the difference the fix rests +// on. They pass before and after — they are the fence, not the repro. + +function makeSession(askClose: (title: string) => Promise<'save' | 'discard' | 'cancel'>) { + return createDocumentSession({ + setShowHome: () => {}, + currentFile: () => tabManager.activeTab?.path ?? '', + resetScrollHistory: () => {}, + renderMarkdown: async (raw: string) => `

${raw.length}

`, + afterLoad: async () => {}, + saveRecentFile: () => {}, + deleteRecentFile: () => {}, + setLoadingTabs: () => {}, + measureInitialViewport: () => {}, + isScrolling: () => false, + renderRichContent: () => {}, + onError: () => {}, + selfWriteGraceMs: 400, + cancelPendingAutoSave: () => {}, + askClose, + onCloseSaveNewerEdits: () => {}, + onCloseAutoSaveFailed: () => {}, + }); +} + +test('closing a tab with unsaved edits still asks, and Cancel still keeps it open', async () => { + setSettings(false, false); + tabManager.closeAll(); + tabManager.addTab('/notes/note.md'); + const tab = tabManager.activeTab!; + tabManager.setTabRawContent(tab.id, ON_DISK); + tabManager.updateTabRawContent(tab.id, IN_BUFFER); + + const asked: string[] = []; + const session = makeSession(async (title) => { + asked.push(title); + return 'cancel'; + }); + + assert.equal(await session.canCloseTab(tab.id), false); + assert.equal(asked.length, 1, 'the close dialog is where the buffer is really at stake'); + assert.equal(tab.rawContent, IN_BUFFER); +}); + +test('closing the window still reviews unsaved tabs', () => { + // `appExit` and the window close handler are untouched by this change; the + // confirmation there is load-bearing because the buffer dies with the + // window. Source-level, because they are wired to Tauri window events. + const exit = viewer.slice(viewer.indexOf('async function appExit()'), viewer.indexOf('async function toggleEdit')); + assert.match(exit, /tabManager\.tabs\.some\(\(t\) => t\.isDirty \|\| \(t\.path === '' && t\.rawContent\.trim\(\) !== ''\)\)/); + assert.match(exit, /modal\.areYouSureYouWantToExit/); + + const closeHandler = viewer.slice(viewer.indexOf('appWindow.onCloseRequested')); + assert.match(closeHandler.slice(0, closeHandler.indexOf('onDragDropEvent')), /await canCloseTab\(dirty\.id\)/); +}); + +test('the view toggles no longer re-read the file to leave an editable pane', () => { + // Belt and braces for the behaviour above: neither toggle may reach for + // the disk again. `renderTabPreviewFromRaw` is the shared "render THIS + // tab's buffer under its own path" helper (it also serves the PDF export). + const toggleEdit = pluck('toggleEdit'); + const leaveEditMode = toggleEdit.slice(0, toggleEdit.indexOf('// Switch to edit')); + assert.doesNotMatch(leaveEditMode, /loadMarkdown/); + assert.doesNotMatch(leaveEditMode, /askCustom/); + + const toggleSplit = pluck('toggleSplitView'); + const closeSplit = toggleSplit.slice(toggleSplit.indexOf('setSplitEnabled(tab.id, false)')); + assert.doesNotMatch(closeSplit, /loadMarkdown/); + assert.doesNotMatch(toggleSplit, /askCustom/); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 09a6b45..be56e27 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -69,7 +69,7 @@ import { const appWindow = getCurrentWindow(); import HomePage from './components/HomePage.svelte'; -import { tabManager } from './stores/tabs.svelte.js'; +import { tabManager, type Tab } from './stores/tabs.svelte.js'; import { snapshotTab } from './utils/tabTransfer.js'; import { adjustPreviewMaxWidth, getPreviewContentWidth, getStoredPreviewFullWidth } from './utils/previewWidth.js'; import { settings } from './stores/settings.svelte.js'; @@ -1577,68 +1577,86 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu return documentSession.canCloseTab(tabId); } - async function toggleEdit(silentSave = false) { + /** + * The last auto-save a tab gets before it stops being auto-saveable. + * + * Shared by the two ways out of an editable pane (leaving edit mode, + * closing split view). It is NOT a condition of the switch: the view + * changes whether or not the write succeeds, and nothing here asks the + * user anything. The only reason it exists is that the background debounce + * requires `isEditing || isSplit` (see the auto-save effect), so the tab is + * about to lose its scheduled writer while still dirty — a user who asked + * for "save automatically" would otherwise be left with edits that no timer + * is going to flush. + * + * Untitled tabs are excluded on purpose: `saveContent` would open the Save + * dialog for them, which is exactly the forced save decision this stopped + * making. + */ + async function flushBeforeLeavingEditableMode(tab: Tab) { + if (!tab.isDirty || tab.path === '') return; + // `confirmBeforeSave` disables the silent background save entirely + // (the Settings label promises confirmation before each write), so it + // disables this flush too. + if (!settings.autoSave || settings.confirmBeforeSave) return; + + cancelPendingAutoSave(tab.id); + const success = await saveContent(tab.id); + if (!success) { + // Reported, not obeyed. A file that cannot be written — read-only + // path, a buffer the lossy-decode guard refuses — used to trap the + // user in the editor with no way to look at their own text. + addToast(t('toast.autoSaveFailed', settings.language), 'error'); + return; + } + if (tab.isDirty) { + // TOCTOU: the user typed during the await, so the file is one + // revision behind and the debounce is about to be dropped. The + // preview shows those newest edits, so nothing is lost or wrong on + // screen; the disk is what the user should hear about. + addToast(t('toast.savedNewerEdits', settings.language), 'info'); + } + } + + /** + * Reading mode's HTML, rendered from the tab's own buffer and its own path. + * Writes through the tab id, so a tab switch during the render cannot land + * one document's HTML on another — and, unlike the `loadMarkdown` call this + * replaces, it neither activates the tab nor re-reads the file. + */ + async function renderPreviewLeavingEditableMode(tab: Tab) { + try { + await renderTabPreviewFromRaw(tab); + } catch (e) { + console.error('Failed to render markdown', e); + } + } + + async function toggleEdit() { const tab = tabManager.activeTab; if (!tab || tab.path === undefined) return; if (isEditing) { - // Switch back to view - if (tab.isDirty && tab.path !== '') { - // `confirmBeforeSave` always wins: when the user has asked - // for confirmation, every dirty toggle must show the modal, - // even if the caller passed `silentSave=true` (hotkey path). - const shouldSilent = - !settings.confirmBeforeSave && (silentSave || settings.autoSave); - if (shouldSilent) { - cancelPendingAutoSave(tab.id); - const success = await saveContent(tab.id); - if (!success) { - addToast(t('toast.autoSaveFailed', settings.language), 'error'); - return; // If save fails, stay in edit mode - } - } else { - const response = await askCustom(t('modal.youHaveUnsavedChangesBeforeReturning', settings.language), { - title: t('modal.unsavedChanges', settings.language), - kind: 'warning', - showSave: true, - }); - - // Cancel only happens on save / discard. If user picks - // Cancel, the pending auto-save timer keeps running. - if (response === 'cancel') return; - if (response === 'save') { - cancelPendingAutoSave(tab.id); - const success = await saveContent(tab.id); - if (!success) return; - } else if (response === 'discard') { - cancelPendingAutoSave(tab.id); - tab.rawContent = tab.originalContent; - tab.isDirty = false; - } - } - } - // If `saveContent` left `tab.isDirty=true` (TOCTOU — user typed - // during the await), staying in edit mode is the safe default: - // a non-editable dirty tab disables auto-save, blocks Cmd+S, - // and risks getting clobbered by the next disk reload. Surface - // a hint and keep the tab editable. - if (tab.path !== '' && tab.isDirty) { - addToast(t('toast.savedNewerEdits', settings.language), 'info'); - return; - } - + // Switch back to view. + // + // Reading mode renders THIS TAB'S BUFFER, never the file on disk, so + // leaving the editor no longer depends on a save. The old code + // re-read `tab.path` here, which is the only reason a dirty tab had + // to be flushed first — silently, or through a modal — and that + // flush is what #168 reports as "no way to see rendered view until + // file is saved". Rendering the buffer is also what every editor the + // user is likely to have open does: VS Code's Markdown preview + // follows the in-memory document (it works on an untitled buffer and + // updates as you type), Typora's rendered view IS the buffer, and + // Obsidian switches to Reading view with no save step. + // + // Nothing is at risk. The buffer stays in memory, the tab keeps its + // dirty dot, and the two places where the buffer really is about to + // disappear — closing the tab (`canCloseTab`) and closing the window + // (`appExit`) — still ask. A view toggle is not one of them. + await flushBeforeLeavingEditableMode(tab); tab.isEditing = false; - if (tab.path !== '') { - await loadMarkdown(tab.path, { preserveEditState: true }); - } else { - // Untitled: render the in-memory buffer for the preview. - try { - const processedInfo = await renderMarkdownPreview(tab.rawContent, ''); - tabManager.updateTabContent(tab.id, processedInfo); - } catch (e) { - console.error('Failed to render markdown', e); - } - } + await renderPreviewLeavingEditableMode(tab); } else { // Switch to edit if (tab.path !== '') { @@ -1922,9 +1940,9 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu * pays the cost once per export instead of once per keystroke, which is * what the narrow effect condition exists to avoid. * - * Reading mode is left alone: its DOM came from `loadMarkdown` rendering - * this same buffer, and re-rendering would throw away the scroll position - * and the fold/find state the user is looking at. + * Reading mode is left alone: its DOM came from `renderTabPreviewFromRaw` + * rendering this same buffer, and re-rendering would throw away the scroll + * position and the fold/find state the user is looking at. */ async function syncPreviewForPrint() { const tab = tabManager.activeTab; @@ -2357,7 +2375,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu } }); - async function toggleSplitView(tabId: string, silentSave = false) { + async function toggleSplitView(tabId: string) { const tab = tabManager.tabs.find((t) => t.id === tabId); if (!tab) return; @@ -2390,52 +2408,15 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu tabManager.setSplitEnabled(tab.id, true); if (liveMode) toggleLiveMode(); } else { - if (tab.isDirty && tab.path !== '') { - // `confirmBeforeSave` always wins: when the user has asked - // for confirmation, every dirty toggle must show the modal, - // even if the caller passed `silentSave=true` (hotkey path). - const shouldSilent = - !settings.confirmBeforeSave && (silentSave || settings.autoSave); - if (shouldSilent) { - cancelPendingAutoSave(tab.id); - const success = await saveContent(tab.id); - if (!success) { - addToast(t('toast.autoSaveFailed', settings.language), 'error'); - return; - } - } else { - const response = await askCustom(t('modal.youHaveUnsavedChangesBeforeClosingSplitView', settings.language), { - title: t('modal.unsavedChanges', settings.language), - kind: 'warning', - showSave: true, - }); - - // Cancel keeps the pending auto-save timer alive. - if (response === 'cancel') return; - if (response === 'save') { - cancelPendingAutoSave(tab.id); - const success = await saveContent(tab.id); - if (!success) return; - } else if (response === 'discard') { - cancelPendingAutoSave(tab.id); - tab.rawContent = tab.originalContent; - tab.isDirty = false; - } - } - } - // Same TOCTOU guard as toggleEdit: if the user typed during - // the save, the tab is still dirty. Keep it in split mode so - // auto-save keeps firing and Cmd+S still works on it; flipping - // it out would make a non-editable dirty tab. - if (tab.path !== '' && tab.isDirty) { - addToast(t('toast.savedNewerEdits', settings.language), 'info'); - return; - } - + // Closing split view is the same move as leaving edit mode, and it + // gets the same treatment: the surviving pane renders the buffer, + // so no save has to happen first and nothing is asked. The split + // preview was already rendering that buffer on every keystroke — + // the old `loadMarkdown` here swapped it for the disk version at + // the last moment, which is why the dirty tab had to be flushed. + await flushBeforeLeavingEditableMode(tab); tabManager.setSplitEnabled(tab.id, false); - if (tab.path !== '') { - await loadMarkdown(tab.path); - } + await renderPreviewLeavingEditableMode(tab); } } @@ -2500,11 +2481,17 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu } if (cmdOrCtrl && !e.shiftKey && !e.altKey && (code === 'Backslash' || code === 'IntlBackslash')) { e.preventDefault(); - if (tabManager.activeTabId) toggleSplitView(tabManager.activeTabId, true); + if (tabManager.activeTabId) toggleSplitView(tabManager.activeTabId); } if (cmdOrCtrl && key === 'e') { e.preventDefault(); - if (!isSplit) toggleEdit(true); + // The `silentSave` argument these two used to pass meant "suppress + // the unsaved-changes modal on the hotkey path". There is no modal + // on a view toggle any more, and a keystroke that says "show me the + // other pane" is not a request to write the file: whether a dirty + // tab is flushed is now decided by the user's auto-save setting + // alone, identically for the hotkey and the toolbar button. + if (!isSplit) toggleEdit(); } if (cmdOrCtrl && key === 's') { // Reading mode used to swallow the shortcut entirely. An untitled