Skip to content

fix(editor): show the preview without making the user save first - #421

Merged
PathGao merged 1 commit into
masterfrom
fix/preview-without-saving
Aug 3, 2026
Merged

fix(editor): show the preview without making the user save first#421
PathGao merged 1 commit into
masterfrom
fix/preview-without-saving

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Addresses the second item of #168 (thanks @dayeggpi):

allow user to switch to rendered view without saving/creating file … no way to see rendered view until file is saved

The prompt was never about losing data

Leaving the editor called loadMarkdown(tab.path, …), which re-reads the file from disk. On a dirty tab that would show the wrong text, so the code first resolved a save decision — write silently, or put up a modal.

Nothing is lost by switching view mode: the buffer stays in memory either way. The save existed to keep the screen from lying, not to protect the document. And the untitled branch two lines below already rendered the buffer instead:

tab.isEditing = false;
if (tab.path !== '') {
    await loadMarkdown(tab.path, { preserveEditState: true });   // reads disk
} else {
    renderMarkdownPreview(tab.rawContent, '');                   // renders buffer
}

#407 already extracted renderTabPreviewFromRaw(tab) for the print path — the same operation with a real path. Both exits now use it. The parts are all there; this is one wire.

git log -S places the modal in 85c6e5e fix: flush dirty tabs on window close — a window-close fix. The correct pattern for "don't lose edits when the buffer disappears" was applied in the same commit to a case where the buffer does not disappear.

Three more problems the disk read carried

  • The exit ran with isEditing already false, so it took the 50 KB preview branch: leaving the editor on a large file re-truncated a complete buffer, flagged it isTruncated (saves refused during that window), then re-read in the background.
  • loadMarkdown always writes into the active tab, so toggleSplitView(tabId) on a background tab would have pulled the active tab's content over. No caller reaches that today; the trap is gone.
  • if (!success) return kept the tab in edit mode when the write failed. saveContent returns false permanently for a read-only path or a lossily decoded buffer, so reading mode was unreachable for that tab, forever. A failed write is not a reason to hide the user's own text.

What was kept, and why

Segment Verdict
the silent flush Kept, narrowed to autoSave && !confirmBeforeSave. It is load-bearing for an independent reason: 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. This is the last flush before that window closes — not a condition of the switch
silentSave forcing a write when auto-save is off Removed. Its only job was suppressing the modal on the hotkey path; with no modal its residue was "Cmd+E writes the file even though you turned auto-save off" — a hidden write contradicting the user's own setting, and an asymmetry with the toolbar button
the askCustom modal Deleted. 100 % "because we read the disk". Its discard option reverted the buffer to originalContent — a destructive choice offered for a switch that destroys nothing. confirmBeforeSave promises confirmation before each write; with no write there is nothing to confirm
the TOCTOU savedNewerEdits toast Kept, moved inside the flush branch. Its return is gone: the three reasons for it are stale (Cmd+S in reading mode was fixed earlier in this same #168 round; a dirty tab is already protected from disk reload by resolveExternalChange; auto-save being off is now the intended steady state). What survives is the real signal — after a flush, a still-dirty tab means the file is one revision behind

What loadMarkdown also did

Traced line by line; it is not modified. Lost and judged safe: setShowHome(false) (unreachable state), the load-revision bump (canApplyFullLoad already requires a clean tab), setTabDecodedLossy (entering edit mode sets it, and not re-reading means the verdict has not changed). Gated and never fired: resetScrollHistory, navigate, updateLoading. Must not have run: setTabRawContent, which replaces originalContent and clears isDirty — it would have erased the edits, and the dirty short-circuit would have left a stale preview anyway.

One deliberate behaviour delta: a view toggle no longer bumps the file in Recent. The entry exists from the actual open; a mode toggle is not an open.

Closing still asks

canCloseTab and appExit are byte-for-byte untouched, and two tests assert they still prompt — executed against the real canCloseTab. Those buffers are about to cease to exist. This one is not.

Mainstream agrees, and on the shape rather than the detail — VS Code follows the in-memory document and previews an untitled buffer; Obsidian makes reading view a plain toggle on the same key Markpad binds, documenting no save step; Typora has no preview/file distinction at all. No new "this is unsaved" indicator: the tab's dirty dot is the standing signal, and adding a reading-mode banner would be a Markpad-only invention for a state every other editor treats as unremarkable.

Tests

scripts/viewModeWithoutSaving.test.ts extracts the real toggleEdit / toggleSplitView / renderTabPreviewFromRaw from the component with a string- and comment-aware brace slicer, transpiles with the project's own typescript, and runs them against the real TabManager. The fake disk reproduces loadMarkdown's dirty short-circuit and serves text that differs from the buffer, so a disk route shows up in the rendered output, not just in a call log.

baseline 5 pass / 7 fail
final 12 / 12

Sample reds: no unsaved-changes modal on a view toggle → actual ['modal.youHaveUnsavedChangesBeforeReturning'] · the file is not re-read to leave the editor → actual ['/notes/note.md'] · but the view still switchestrue !== false (the unwritable-file trap).

The 5 green on both sides are the boundary: closing a tab still asks and Cancel still keeps it open, closing the window still reviews unsaved tabs, auto-save still flushes, untitled still never hits the Save dialog.

npm run check   436 files, 0 errors
npm test        521 / 521
cargo test      131 / 131

Orphaned keys, not deleted

modal.youHaveUnsavedChangesBeforeReturning and modal.youHaveUnsavedChangesBeforeClosingSplitView now have zero usages but are still defined in 26 locales. i18nCoverage stays green (it fails on keys English lacks, not on unused ones). Left for whoever prunes the dictionary.

Not covered

  • A double toast on a refused lossy save: the flush failure path emits toast.autoSaveFailed on top of the guard's own explanation. The background auto-save suppresses this via isLossySaveRefused; the toggle path never did, before or after. Pre-existing, but slightly more visible now that the transition proceeds instead of stopping.
  • External changes during the self-write grace window are now entirely the watcher's business; the mode toggle no longer incidentally picks them up. Untested interaction.
  • Verification is unit-level against the real store; no end-to-end run.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@PathGao
PathGao merged commit 818492b into master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the fix/preview-without-saving branch August 3, 2026 06:14
PathGao added a commit that referenced this pull request Aug 3, 2026
…#441)

`Tab` carries three same-shaped strings. `rawContent` is the Markdown, and
it is what the editor edits, what reaches disk, and what the exports read.
`content` is the rendered preview HTML, injected via `{@html}`. Two places
read the wrong one.

1. TitleBar.svelte gated Export as HTML / Export as PDF on
   `tabManager.activeTab.content`, while `exportAsHtml` gates on
   `ctx.rawContent`. `content` is only refreshed while the preview is on
   screen — `tab.isSplit || (isEditing && settings.showToc)` — so for an
   untitled buffer being edited with the TOC closed it is still `''` while
   `rawContent` holds the user's text. `showToc` and `newFileDefaultMode`
   default such that Ctrl+T then typing is exactly that state, and the menu
   hid two commands that would have produced a file. Reachable since #421
   let the preview work without saving first.

2. `addTab(path, content = '')` assigned its argument to `content`,
   `rawContent` and `originalContent` alike — coherent when the three were
   one field, but it let Markdown into the field that is injected as HTML.
   Both callers in the app pass `''`, so nothing shipped broken; the value
   is sanitized at the sink either way. The parameter is now `rawContent`
   and `content` starts empty, as at every other Tab construction site.

scripts/renderedHtmlField.test.ts evaluates the real gate, the real
refresh condition and `exportAsHtml`'s real precondition against the real
TabManager. Four of its nine tests are red on master.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant