Skip to content

fix(desktop): a manual mirror or sync no longer hangs on a silent spinner - #30

Merged
ralyodio merged 2 commits into
mainfrom
fix/preview-hangs-with-no-feedback
Sep 6, 2026
Merged

fix(desktop): a manual mirror or sync no longer hangs on a silent spinner#30
ralyodio merged 2 commits into
mainfrom
fix/preview-hangs-with-no-feedback

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Pressing Preview — or Sync with Mirror armed — opened the dialog on a spinner and "Scanning both sides…", and that is all it ever showed. Reported as dev → This computer hanging with no feedback and no progress bar.

Three separate things made that spinner the whole experience.

The scan reported nothing. previewTransfer awaited the dry run to completion and only then returned, so for however long a full walk of both trees takes — minutes from a laptop to a server over a WAN — there was no count, no path, no clock. A scan that was working and one that had wedged looked exactly alike.

The result was enormous and unused. Every itemized change was accumulated and returned. A dry run of one ordinary source tree emitted 332,303 of them: ~71MB of changes crossing the IPC boundary by structured clone, for a field the renderer never read — it only ever used the summary and the delete list.

Nothing was bounded or stoppable. The delete list rendered one DOM node per entry, so a first mirror into an empty destination asked Chromium for hundreds of thousands of rows. And Cancel only hid the dialog: the dry run carried on to the end, uninterruptible, then resolved into a window nobody was looking at.

What changed

  • Progress streams while the scan runs, over a new event:preview channel. The dialog shows rsync's own to-chk counters, changes and deletions found so far, the path being compared, and elapsed time. total grows during the run because rsync builds its file list incrementally, so it is presented as an estimate rather than a deadline.
  • The result carries counts, not changes. deleteTotal and changeTotal are exact; the enumerated deletes stop at 5,000 and the list says how many more there are. The confirm button always counts the true total, so a truncated list never understates what a mirror will remove.
  • A preview is cancellable. It is registered under an id the renderer chooses, so Stop scanning kills the rsync process. Starting a second preview supersedes the first, and a stale result is discarded — otherwise a superseded scan could paint its delete list over a different pair's route.
  • A cancelled scan is never ok. It has not established that anything is safe to delete, and ok is what gates the confirm.

Found by driving the widget, not reading it

  • The delete list did not clip. max-h-[210px] sat on the ScrollArea root, whose viewport is size-full; with no definite height it grew to fit and the rows printed over the disclosure below. Now min-h-0 flex-1 inside a bounded flex column, like every other ScrollArea in the app.
  • The footer rendered throughout the scan, so a full-strength Mirror button sat under the spinner offering to run a mirror whose delete list did not exist yet. There are now no confirm controls until there is something to confirm, and focus moves to Cancel when the result arrives — initialFocus alone stopped being enough once the footer waits.

Verification

Driven in headless Chromium against the export served with the app's real CSP, at 1360x860 and at the 960x600 minimum, in both themes — no CSP violations, delete rows in the DOM bounded at ~5,050 instead of 332,303.

pnpm test 520 passing, pnpm -r typecheck clean, pnpm smoke:desktop all three guards ok. Three regression tests cover the streaming, the cap and the cancel; each was confirmed to fail with its bug reintroduced.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VScug5VRbcTuhiAoieeQ52

ralyodio and others added 2 commits September 6, 2026 07:10
…nner

Pressing Preview -- or Sync with Mirror armed -- opened the dialog on a
spinner and "Scanning both sides…", and that is all it ever showed. Three
separate things made that spinner the whole experience.

The scan reported nothing. `previewTransfer` awaited the dry run to
completion and only then returned, so for however long a full walk of both
trees takes -- minutes from a laptop to a server over a WAN -- there was no
count, no path, no clock. A scan that was working and one that had wedged
looked exactly alike, which is why a slow preview read as a hang.

The result was enormous and unused. Every itemized change was accumulated
and returned. A dry run of one ordinary source tree here emitted 332,303 of
them: ~71MB of `changes` crossing the IPC boundary by structured clone, for
a field the renderer never read. It only ever used the summary and the
delete list.

Nothing was bounded or stoppable. The delete list rendered one DOM node per
entry, so a first mirror into an empty destination asked Chromium for
hundreds of thousands of rows. And Cancel only hid the dialog: the dry run
carried on to the end, uninterruptible, then resolved into a window nobody
was looking at.

Now:

- Progress streams while the scan runs, over a new `event:preview` channel.
  The dialog shows rsync's own `to-chk` counters, the changes and deletions
  found so far, the path being compared, and the elapsed time. `total` grows
  during the run because rsync builds its file list incrementally, so it is
  presented as an estimate rather than a deadline.
- The result carries counts, not changes. `deleteTotal` and `changeTotal`
  are exact; the enumerated `deletes` stop at 5,000 and the list says how
  many more there are. The confirm button always counts the true total, so
  a truncated list never understates what a mirror will remove.
- A preview is registered under an id the renderer chooses, so Stop scanning
  kills the rsync process. Starting a second preview supersedes the first,
  and a result whose id is stale is discarded -- otherwise a superseded scan
  could paint its delete list over a different pair's route.
- A cancelled scan is never `ok`. It has not established that anything is
  safe to delete, and `ok` is what gates the confirm.

Two things found by driving the real widget rather than reading it:

- The delete list did not clip. `max-h-[210px]` sat on the ScrollArea root,
  whose viewport is `size-full`; with no definite height it grew to fit and
  the rows printed over the disclosure below. It is now `min-h-0 flex-1`
  inside a bounded flex column, like every other ScrollArea in the app.
- The footer rendered throughout the scan, so a full-strength Mirror button
  sat under the spinner offering to run a mirror whose delete list did not
  exist yet. There are no confirm controls until there is something to
  confirm, and focus moves to Cancel when the result arrives -- `initialFocus`
  alone stopped being enough once the footer waits.

Verified in headless Chromium against the export served with the app's real
CSP, at 1360x860 and at the 960x600 minimum, in both themes. Three
regression tests cover the streaming, the cap, and the cancel; each was
confirmed to fail with its bug reintroduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VScug5VRbcTuhiAoieeQ52
`cancelAll` on before-quit stopped transfers but not previews, so closing
the window mid-scan left an rsync and an ssh walking a remote tree with
nothing left to report to. A dry run is read-only, which is why it went
unnoticed, but it is still two processes and a remote session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VScug5VRbcTuhiAoieeQ52
@ralyodio
ralyodio merged commit d6db449 into main Sep 6, 2026
4 checks passed
@ralyodio
ralyodio deleted the fix/preview-hangs-with-no-feedback branch September 6, 2026 07:18
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