fix(desktop): Edit▸Replace action + saved-indicator false-clean (Phase G) by Jocs · Pull Request #4431 · marktext/marktext · GitHub
Skip to content

fix(desktop): Edit▸Replace action + saved-indicator false-clean (Phase G) - #4431

Merged
Jocs merged 2 commits into
developfrom
fix/desktop-replace-and-saved-indicator
Jun 9, 2026
Merged

Jocs merged 2 commits into
developfrom
fix/desktop-replace-and-saved-indicator

Conversation

@Jocs

@Jocs Jocs commented Jun 9, 2026

Copy link
Copy Markdown
Member

Fixes two desktop-only bugs found by the Phase G audit (packages/desktop). Two single-responsibility commits.

G3 (major) — Edit▸Replace performed an UNDO

editorReplace (src/main/menu/actions/edit.ts) emitted edit(win, 'undo'), so the Edit▸Replace menu item and its accelerator (Cmd+Opt+F / Ctrl+R) performed an UNDO instead of opening the replace bar. The renderer re-emits the IPC action verbatim on the bus, so 'undo' invoked handleUndo and listenReplace (bound to the 'replace' bus event) was never reached from the menu/shortcut path.

Fix: send 'replace' so the search component opens in replace mode (showSearch=true, type='replace'), matching the command-palette edit.replace entry. Verified end-to-end: IPC 'replace' → EDITOR_EDIT_ACTION → bus.emit('replace','replace') → listenReplace(). Covered by the existing find-replace.spec.ts "Replace action shows the search bar in replace mode".

G6 (major, data-loss risk) — saved indicator false-clean

The renderer fed the store a synthetic, desktop-shaped history whose entry id was the engine undo-stack DEPTH. Depth is reused across distinct documents at the same stack height, so:

type A, type B, save, undo B, type C   (a NEW divergent edit)

returned the depth — and therefore the id — to the saved value while the document was actually A+C, not the saved A+B. The store's PG15 check is editEntry.id !== lastSavedHistoryId, so the tab read as clean and could be closed without a save prompt → silent data loss.

Fix: replace the depth-as-id scheme with a monotonic, never-reused id keyed on the live document content (new SyntheticHistory allocator). Each distinct content snapshot gets a fresh id the first time it is seen and reuses that id on every revisit. Undoing back to the saved content restores the saved id (clean); a divergent re-edit produces brand-new content and hence a brand-new id (dirty) — never the saved one. This matches legacy muyajs' monotonic, never-reused history ids while preserving the undo-to-saved clean case. The allocator is re-baselined to the loaded content (id 0) on file-loaded, matching the store's seeded lastSavedHistoryId: 0.

The content signature ignores trailing newlines, because the engine's markdown serialization is unstable across a setContent → edit → undo round-trip purely in trailing newlines (loading 'x\n' may serialize to 'x\n\n\n' while undoing an edit lands on 'x\n'), and the store already normalizes trailing newlines via adjustTrailingNewlines.

Tests

  • New unit spec test/unit/specs/synthetic-history.spec.ts (9 cases): the exact G6 scenario, undo-to-saved, redo-to-saved, unsaved-baseline, trailing-newline insensitivity.
  • New e2e G6: a divergent re-edit at the saved undo depth stays dirty in parity-source-undo-saved.spec.ts, driving the full undo + divergent-re-edit-at-saved-depth flow and asserting the tab stays dirty. Verified to fail against the old depth-as-id scheme and pass with the fix.
  • Updated the existing PG15 e2e + stale comment; hardened the saved-indicator checks with expect.poll.

Verification

pnpm run typecheck, pnpm run lint (0 errors), pnpm run test (464 pass), pnpm run build:unpack, and the relevant e2e (find-replace, parity-source-undo-saved) all green.

Refs: Phase G audit G3, G6.

🤖 Generated with Claude Code

`editorReplace` emitted `edit(win, 'undo')`, so the Edit▸Replace menu
item and its accelerator (Cmd+Opt+F / Ctrl+R) performed an UNDO instead
of opening the replace bar. The renderer re-emits the IPC action verbatim
on the bus, so 'undo' invoked `handleUndo` and `listenReplace` (bound to
the 'replace' bus event) was never reached from the menu/shortcut path.

Send 'replace' so the search component opens in replace mode, matching the
command-palette `edit.replace` entry (Phase G — G3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 9, 2026 23:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes two desktop-only regressions in packages/desktop: (1) Edit ▸ Replace incorrectly triggering Undo, and (2) the saved/dirty indicator becoming falsely “clean” in specific undo + divergent re-edit scenarios (data-loss risk), by switching desktop save-tracking to a content-keyed synthetic history id.

Changes:

  • Fix editorReplace to emit the correct 'replace' edit action instead of 'undo'.
  • Introduce SyntheticHistory to allocate monotonic save-tracking ids keyed on document content (with newline normalization) and wire it into the WYSIWYG editor/store bridge.
  • Add/adjust unit + e2e coverage for the G6 false-clean scenario and for PG15 saved-indicator behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/desktop/src/main/menu/actions/edit.ts Fix Edit ▸ Replace menu action to send 'replace'.
packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue Swap depth-based synthetic history id for content-keyed monotonic ids; re-baseline on file load.
packages/desktop/src/renderer/src/components/editorWithTabs/syntheticHistory.ts Add per-tab synthetic history id allocator keyed by normalized content.
packages/desktop/test/unit/specs/synthetic-history.spec.ts New unit tests covering monotonic ids, undo/redo reuse, and G6 regression scenario.
packages/desktop/test/e2e/parity-source-undo-saved.spec.ts Harden PG15 saved-indicator checks and add a new G6 e2e regression test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +42 to +56
// A fast, stable 32-bit string hash (FNV-1a) over the trailing-newline-normalized
// content. Used so the content -> id map stores short keys instead of whole
// documents; collisions only risk a false-clean for two genuinely different
// documents that hash identically, which is astronomically unlikely for FNV-1a
// over editor content.
const hashContent = (content: string): number => {
const normalized = stripTrailingNewlines(content)
let hash = 0x811c9dc5
for (let i = 0; i < normalized.length; i++) {
hash ^= normalized.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
// Coerce to an unsigned 32-bit integer for a stable numeric key.
return hash >>> 0
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — switched the content key to a 64-bit FNV-1a (BigInt key). At 32 bits the birthday bound puts ~50% collision odds near ~77k snapshots, realistic over a long session, and a collision would reintroduce the false-clean. 64 bits makes it negligible. Added a unit test allocating 5000 distinct snapshots and asserting 5000 distinct ids.

Comment on lines +286 to 294
const syntheticHistoryByTab = new Map<string, SyntheticHistory>()
const getSyntheticHistory = (id: string, baselineContent: string): SyntheticHistory => {
let tracker = syntheticHistoryByTab.get(id)
if (!tracker) {
tracker = new SyntheticHistory(baselineContent)
syntheticHistoryByTab.set(id, tracker)
}
return tracker
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added a watcher on the store tab-id set that prunes BOTH syntheticHistoryByTab and the pre-existing engineHistoryByTab when tabs close, so neither map (nor the per-tab content→id map) grows unbounded over a session.

…tory id

The saved/clean indicator (PG15) could falsely show "clean" for a dirty
document, risking data loss on close-without-save.

The renderer fed the store a SYNTHETIC, desktop-shaped history whose entry
id was the engine undo-stack DEPTH. Depth is reused across distinct
documents at the same stack height, so the sequence

  type A, type B, save, undo B, type C  (a NEW divergent edit)

returned the depth — and therefore the id — to the saved value while the
document was actually A+C, not the saved A+B. The store compares
`editEntry.id !== lastSavedHistoryId`, so the tab read as clean and could be
closed without a save prompt.

Replace the depth-as-id scheme with a MONOTONIC, never-reused id keyed on
the live document content (new `SyntheticHistory` allocator): each distinct
content snapshot gets a fresh id the first time it is seen and reuses that
id on every revisit. Undoing back to the saved content restores the saved
id (clean); a divergent re-edit produces brand-new content and hence a
brand-new id (dirty) — never the saved one. This matches legacy muyajs'
monotonic, never-reused history ids while preserving the undo-to-saved
clean case. The allocator is re-baselined to the loaded content (id 0) on
`file-loaded`, matching the store's seeded `lastSavedHistoryId: 0`.

The content signature ignores trailing newlines: the engine's markdown
serialization is unstable across a `setContent` -> edit -> undo round-trip
purely in trailing newlines (loading `'x\n'` may serialize to `'x\n\n\n'`
while undoing an edit lands on `'x\n'`), and the store itself normalizes
trailing newlines via `adjustTrailingNewlines`. Without this, undo-to-saved
would never re-match the baseline.

The content key uses a 64-bit FNV-1a hash (a 32-bit hash hits ~50% collision
odds near ~77k snapshots via the birthday bound — realistic over a long
session — and a collision would reintroduce the false-clean it guards
against). The per-tab engine/synthetic history maps are pruned when tabs
close (watcher on the store's tab id set) so they don't grow unbounded over
a long session.

Add unit coverage for the id/dirty logic (the exact G6 scenario,
undo-to-saved, redo-to-saved, unsaved-baseline, trailing-newline
insensitivity, and distinctness across many snapshots) and an e2e
regression test that drives the full undo + divergent-re-edit-at-saved-depth
flow and asserts the tab stays dirty (verified to fail against the old
depth-as-id scheme) (Phase G — G6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jocs
Jocs force-pushed the fix/desktop-replace-and-saved-indicator branch from 89faea0 to 1b21492 Compare June 9, 2026 23:39
let hash = FNV64_OFFSET
for (let i = 0; i < normalized.length; i++) {
hash ^= BigInt(normalized.charCodeAt(i))
hash = (hash * FNV64_PRIME) & MASK64
let hash = FNV64_OFFSET
for (let i = 0; i < normalized.length; i++) {
hash ^= BigInt(normalized.charCodeAt(i))
hash = (hash * FNV64_PRIME) & MASK64
@Jocs
Jocs merged commit b31d355 into develop Jun 9, 2026
12 checks passed
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Build artifacts for PR #4431:

Run: https://github.com/marktext/marktext/actions/runs/27242945602

Artifact Size Link
marktext-macos-x64 256.8 MB Download
marktext-windows-arm64 256.2 MB Download
marktext-linux 556.4 MB Download
marktext-windows-x64 257.5 MB Download
marktext-macos-arm64 246.6 MB Download

@Jocs
Jocs deleted the fix/desktop-replace-and-saved-indicator branch June 10, 2026 07:08
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.

2 participants