Skip to content

fix(0.2.1): sidecar autosave + close/open UX prompts (Phase 2 + 3)#103

Merged
hartsock merged 2 commits into
mainfrom
fix/0.2.1-phase2-sidecar-buffer
May 29, 2026
Merged

fix(0.2.1): sidecar autosave + close/open UX prompts (Phase 2 + 3)#103
hartsock merged 2 commits into
mainfrom
fix/0.2.1-phase2-sidecar-buffer

Conversation

@hartsock

@hartsock hartsock commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

Phases 2 and 3 of the 0.2.1 tab-reload bugfix series, stacked into one
PR for review.

  • Phase 2 (a5aed07): autosave writes to <path>.scrybe-buffer
    sidecar instead of the real file. Real file changes only on explicit
    save (Ctrl+S, 💾, CLI `scrybe save`, MCP `save`).
  • Phase 3 (0c74538): save-on-close prompt for dirty tabs and
    restore-on-open prompt when a leftover sidecar is found. Both use a
    shared 3-button modal (Primary / Secondary / Cancel) added in
    `index.html`.

Together these close the bug class that bit Shawn's daily-driver
workflow on 2026-05-28 (open tab clobbering external edits via
autosave self-write filter race).

Phase 1 already merged in #102 stops programmatic loads from
triggering self-write events. Phase 2 makes autosave physically
incapable of clobbering external edits. Phase 3 keeps the user
informed when buffer and disk diverge.

What changes

Phase 2 — backend (src-tauri/src/lib.rs)

  • New save_buffer(path, content): writes `.scrybe-buffer`
  • New clear_buffer(path): cleanup; wired into remove_backup
  • New read_buffer_if_exists(path): returns Some(buffer) when a
    non-empty sidecar exists AND differs from disk
  • save_file clears the sidecar after a successful write
  • File watcher filters by watched membership (sidecar writes can
    never emit scrybe://file-changed)
  • 9 new unit tests covering all sidecar lifecycle paths

Phase 2 — frontend (src/main.ts)

  • Autosave debounce calls save_buffer (no more note_autosave,
    no more markClean on autosave — tab stays dirty until explicit
    save)
  • Explicit-save paths (Ctrl+S, CLI, MCP) unchanged

Phase 3 — UI scaffolding (index.html)

  • New #modal-overlay with title, message, 3 buttons. Enter triggers
    primary, Escape returns cancel.

Phase 3 — frontend (src/main.ts)

  • showModal(title, message, primary, secondary) reusable helper
  • closeTab split into a prompt wrapper + doCloseTab (the existing
    tear-down). Dirty tabs prompt "Save changes to ?" with
    Save / Discard / Cancel; save failure aborts close; cancel keeps tab
    open.
  • openFileByPath calls read_buffer_if_exists after read_file. If
    a recoverable buffer exists, the modal prompts "Restore unsaved
    edits?" with Restore / Discard / Cancel. Restored buffers are
    flagged dirty so the dirty indicator + save-on-close fire correctly.

Phase 3 — state (src/state.ts)

  • New markDirty(id) for the restore-then-flag-dirty flow

Misc

  • .gitignore adds *.scrybe-buffer
  • package-lock.json reflects the 0.3.0 → 0.2.1-dev bump that npm
    install regenerated

Out of scope

  • The 3-button modal isn't yet used by scrybe://cli-quit (still uses
    window.confirm). Can be unified later.
  • No three-way merge UI for buffer-vs-disk conflicts — the existing
    conflict bar ("Keep mine / Take theirs") handles the dirty-on-disk-
    change case at the user level.

Test plan

  • cargo fmt -- --check — passes
  • cargo clippy --all-targets -- -D warnings — passes
  • cargo test --package scrybe-app — 16/16 pass
  • npx tsc --noEmit — passes
  • Manual: open a file, type, observe <path>.scrybe-buffer
    appears but <path> is unchanged
  • Manual: close dirty tab → modal appears. Save / Discard /
    Cancel all behave correctly
  • Manual: force-quit Scrybe with a dirty tab, reopen file →
    restore-on-open modal appears. Restore / Discard / Cancel all
    behave correctly
  • Manual: Restore → tab opens dirty, save-on-close fires next
    time

🤖 Generated with Claude Code

hartsock and others added 2 commits May 28, 2026 18:11
Switch the 1 s debounced autosave from `save_file(path, content)` to
`save_buffer(path, content)`. The sidecar lives at
`<path>.scrybe-buffer` and accumulates unsaved keystrokes; the real
file is committed only by explicit save (Ctrl+S, 💾 toolbar button,
`scrybe save <path>` CLI, or the MCP save tool).

Why
---
Phase 1 stopped programmatic loads from triggering a self-write window
in the OS file watcher. That fixed the immediate "edit-while-tab-open
clobber" bug but left the fundamental coupling intact: every keystroke
still raced the watcher's 2 s self-write filter. With this change the
autosave path never touches the real file, so external edits and our
own keystrokes are physically segregated — the bug class goes away by
construction, not by timing.

What changes
------------
Backend (`src-tauri/src/lib.rs`):

- New `save_buffer(path, content)`: writes the in-progress edits to
  `<path>.scrybe-buffer`. The real file is untouched.
- New `clear_buffer(path)`: removes the sidecar; silent no-op when
  absent. Wired into `remove_backup` so tab close cleans up.
- New `read_buffer_if_exists(path)`: returns `Some(buffer)` only when
  a non-empty sidecar exists AND differs from the real file. The
  caller decides whether to restore — Phase 3 will surface this as a
  "recover unsaved edits?" prompt.
- `save_file` now removes the sidecar after a successful write (the
  buffer is now stale once committed to disk).
- File watcher event loop now filters by `watched` membership, so
  sidecar writes (sibling file in the same dir) cannot accidentally
  emit `scrybe://file-changed`. The macOS watcher is directory-grained
  so this guard matters even though notify is configured non-recursive.

Frontend (`src/main.ts`):

- Autosave debounce calls `save_buffer`, drops `note_autosave` and
  `markClean` (tab stays `isDirty: true` until explicit save flushes
  the sidecar to disk).
- Explicit save (`saveActiveTabNow`, `Ctrl+S`, CLI `scrybe save`) and
  MCP-driven save are unchanged: still `save_file` + `note_autosave` +
  `markClean`.

Tests (`src-tauri/src/lib.rs::tests`):

- 9 new unit tests using `tempfile::tempdir` covering: sidecar write
  vs real file, save_file clears sidecar, clear_buffer (present and
  absent), read_buffer (none / matches / differs / empty), remove_backup
  cleans both files.

`.gitignore`: add `*.scrybe-buffer` so autosave artifacts can't be
committed.

Out of scope for this PR
------------------------
- Recovery-on-open prompt: `read_buffer_if_exists` is wired but no
  caller surfaces a prompt yet. That's Phase 3.
- Dirty-on-close prompt: closing a dirty tab still discards the
  buffer silently. Also Phase 3.
- Three-way merge UI for buffer vs disk conflicts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 made autosave write to a `<path>.scrybe-buffer` sidecar instead
of the real file, but two UX gaps were left for Phase 3:

1. Closing a dirty tab silently dropped the buffer.
2. Opening a file with a leftover sidecar (e.g., from a crash or
   force-close) ignored it.

This commit lands both prompts behind a small 3-button modal that
returns "primary" | "secondary" | "cancel".

UI scaffolding
--------------
`index.html` adds a generic modal overlay (`#modal-overlay`) with a
title, message, primary button (blue), secondary button (red/danger),
and cancel button. Enter triggers primary, Escape returns cancel.

Frontend (`src/main.ts`)
------------------------
- New `showModal(title, message, primaryLabel, secondaryLabel)`:
  returns a Promise<ModalChoice>. Reusable across all 3-button prompts.
- `closeTab` is now split into a prompt wrapper + the existing tear-
  down (now in `doCloseTab`). When the tab is dirty, the modal asks
  "Save changes to <file>?" with Save / Discard / Cancel. Save on
  failure aborts the close so the user can retry. Cancel keeps the tab
  open.
- `openFileByPath` calls `read_buffer_if_exists` after `read_file`. If
  the backend returns a non-null buffer (sidecar exists, non-empty,
  differs from disk), the modal asks "Restore unsaved edits?" with
  Restore / Discard / Cancel. Cancel aborts the open entirely.
- Restored buffers flag the new tab dirty via `state.markDirty` so the
  user sees the dirty indicator and gets the save-on-close prompt next
  time around. Discard calls `clear_buffer` so the prompt doesn't fire
  again on subsequent opens.

State (`src/state.ts`)
----------------------
- New `markDirty(id)` for the restore-then-flag-dirty flow.

Lock-file hygiene
-----------------
`package-lock.json` had 0.3.0 left over from the pre-Phase 1 npm
install; npm refreshed it to 0.2.1-dev on the next run. Bundled here
to keep the repo state clean (no behavior change).

Out of scope
------------
The 3-button modal is local-only — it doesn't replace the
window.confirm() prompt in the `scrybe://cli-quit` handler. That
remains a separate concern (whole-app quit vs per-tab close) and can
be unified later if we want consistent UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hartsock hartsock changed the title fix(autosave): write to sidecar buffer instead of real file (Phase 2) fix(0.2.1): sidecar autosave + close/open UX prompts (Phase 2 + 3) May 28, 2026
@hartsock hartsock merged commit 687e8d1 into main May 29, 2026
6 checks passed
@hartsock hartsock deleted the fix/0.2.1-phase2-sidecar-buffer branch May 29, 2026 19:42
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