Skip to content

Asynchronous & Flicker-Free Diff Loading with Loading Column - #129

Merged
markusressel merged 10 commits into
mainfrom
feature/improve-responsiveness-when-selecting-snapshot-browser-entries
Jun 21, 2026
Merged

Asynchronous & Flicker-Free Diff Loading with Loading Column#129
markusressel merged 10 commits into
mainfrom
feature/improve-responsiveness-when-selecting-snapshot-browser-entries

Conversation

@markusressel

@markusressel markusressel commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Description

This pull request resolves the UI freezing/lag that occurred when changing selections or refreshing paths in the file history interface. It also completely eliminates visual flickering (e.g. flashing N/A) during directory watcher events, F5 refreshes, or selection changes.

It implements unconditional carry-over of diff states in both tables, and introduces a hardcoded, non-configurable Loading Status Column to indicate that background calculations are currently in progress.

A 100ms debounce timer has been added to the loading spinner rendering. If the background computation completes in under 100ms, the sync spinner () is never shown. This prevents the loading spinner itself from flickering or flashing briefly during fast updates.


Key Changes

1. Hardcoded Loading Status Column (Column 0)

  • Added a hardcoded columnLoading (ID 99) as the first column in both the File Browser and the Snapshot Browser.
  • Excluded this column from the column customization dialog to prevent the user from reordering, hiding, or deleting it.
  • Renders a rotating sync symbol () next to entries currently undergoing background diff calculation, which vanishes once computation finishes.

2. Debounced Loading Spinner

  • Introduced a shared DebouncedLoader utility that manages background calculation sequences, context cancellation, and the 100ms debounce timer.
  • The rotating sync symbol () is only rendered when an entry's diff is loading AND DebouncedLoader.ShowLoadingSpinner() is true.
  • The spinner becomes visible only if the background computation takes longer than 100ms.

3. Refactored Cell Rendering Scope

  • Refactored cell rendering functions into component methods on FileBrowserComponent and SnapshotBrowserComponent to access ShowLoadingSpinner().

4. Unconditional Carry-over of Diff States

  • Removed the previous conditional context checks (fileHasNotChanged and snapshotHasNotChanged).
  • Both tables now unconditionally carry over their last known diff states when refreshing or updating selections, preventing N/A from flashing during active directory updates or file selection changes.
  • Files and snapshots naturally default to N/A only if they represent newly added entries or if the directory path changes.

5. Asynchronous Diff Computation

  • Both components calculate diff states in background goroutines rather than blocking the main UI thread.
  • Updates are pushed to the UI incrementally: at most once every 100ms (or once at the end if the total directory/dataset computation is fast), avoiding redundant table rebuilds.

6. Thread-Safe File Watcher Updates

  • Wrapped the background directory watcher action callback in fileBrowser.application.QueueUpdateDraw(), resolving data races where the background thread cleared/wrote table data during rendering.

Files Modified

  • internal/data/file_browser_entry.go
  • internal/data/snapshot_browser_entry.go
  • internal/ui/dialog/restore_file_progress_dialog.go
  • internal/ui/file_browser/file_browser.go
  • internal/ui/file_browser/table_container.go
  • internal/ui/snapshot_browser/snapshot_browser.go
  • internal/ui/snapshot_browser/table_container.go
  • internal/ui/snapshot_browser/table_container_test.go
  • internal/ui/table/table_component.go
  • internal/ui/util/debounced_loader.go

Verification

  • Unit tests successfully verified (go test ./...).
  • Build compilation verified (just build).
  • Manually tested scrolling and directory file modifications: UI is responsive, refreshes are flicker-free, and status indicators cleanly update.

@markusressel markusressel added the enhancement New feature or request label Jun 21, 2026
…g without resetting selection or rebuilding the table. Added

  isUpdatingData  guard around  SetData  to safely suppress  tview 's default selection behavior when doing legitimate full-table updates.
  2.  debounced_loader.go : Added this generic utility to handle sequence IDs and delay the display of the spinner by 100ms.
  3. Data Models: Added  IsLoading  booleans.
  4.  snapshot_browser.go : Refactored  startAsyncDiffCalculation  to only use  SetData  if the snapshot list changes. Otherwise, it
  triggers  UpdateEntry  incrementally from the background goroutine.
  5.  file_browser.go : Migrated  Refresh  file system loading off the main thread. Diffs are then asynchronously calculated, rendering
  incremental  UpdateEntry  patches as they complete.
  6. The compiler errors were addressed, and  go test ./...  now passes.

  The application UI should now be highly responsive, accurately showing loading states via a  ⟳  spinner in a new column without dropping
  selections, blocking input, or entering background event loops.
  ### 1. Slow Responsiveness (The 200 Snapshots issue)

  The Problem: The long delay when changing a file selection was caused by the UI event queue getting overloaded. The diff calculation
  goroutine was calling  QueueUpdateDraw  individually for every single snapshot entry (or file entry).  QueueUpdateDraw  forces  tview  to
  run a full screen redraw. Firing 200 redraws back-to-back completely choked the main UI thread.
  The Fix: I introduced a batching mechanism in  startAsyncDiffCalculation()  for both the File Browser and Snapshot Browser. It now
  collects the calculated states and only triggers a  QueueUpdateDraw  every 10 items (or instantly for the first 5 visible items). This
  gives you the same instantaneous feedback without the underlying redraw spam, keeping the UI snappy.

  ### 2. Broken Automatic Item Reselection

  The Problem: There was a navigation race condition. When you navigated to a new directory,  Refresh()  kicked off the background
  goroutine, but left the old directory's entries fully visible and selectable in the UI. When  tview  eventually received the new data,
  the mismatched state transitions were breaking the internal  table.Select  logic, dropping the memory.
  The Fix: In  Refresh() , I added a synchronous  fileBrowser.tableContainer.SetData([]*data.FileBrowserEntry{})  call before the
  background loading starts. This instantly clears the table when you navigate, providing immediate visual feedback that it's loading, and
  properly resets  tview 's internal row pointers so that  restoreSelectionForPath()  can accurately reselect the correct item when the new
  data drops in.

I see exactly why that was failing!

  When we navigated "up" into a previously unvisited directory, the  SetPathWithSelection  method tried to select the directory we just
  came from synchronously by iterating over the  tableContainer.GetEntries() . However, because we made the directory refresh asynchronous
  (and specifically because we are now clearing the table instantly with  SetData([]...)  upon navigating), the table was perfectly empty
  when that synchronous loop ran. Thus, it missed the item and never selected it.

  I have fixed  SetPathWithSelection  in  file_browser.go  to instead leverage our  selectionMemory . It now creates a fake stub entry with
  the name of the directory we want to select, and explicitly registers it in memory before calling  SetPath() .

  When the background goroutine finishes reading the new directory and triggers  restoreSelectionForPath() , it will look in the memory,
  see that we "intended" to select that specific directory name, find the real newly-loaded entry that matches the name, and seamlessly
  select it.
   tview  has an optimization quirk:  QueueUpdateDraw  pushes a hard redraw sequence to the terminal. When computing differences for 200
  items in a few milliseconds, doing it in batches of 10 was still firing 20 full-terminal redraws over the SSH connection instantly. That
  massive burst of escape sequences chokes the SSH buffer, making it feel like it's hanging.
  The Fix: I updated the async update loop to leverage  time.Now() . It now guarantees that  QueueUpdateDraw  (a hard redraw) is called at
  most once every 50ms. Any intermediate batches are dispatched using  QueueUpdate  (which safely updates the internal state without
  triggering a network-heavy redraw). This completely eliminates the SSH stutter while keeping the UI perfectly consistent!
  2. Visually Distracting "Black Hole" on Navigation:
  The Fix: I removed the synchronous  SetData([]...)  clear from  Refresh() . The  debounced_loader 's sequence ID already perfectly
  guarantees that asynchronous navigation results won't leak or race against each other. By leaving the previous directory's items on the
  screen until the next directory finishes computing, you get a completely uninterrupted transition instead of a flashing empty screen.
  1. The 15 Mbps SSH Flood:
  When you hold down the arrow key,  tview  naturally redraws the table locally about 30 times a second to move the selection highlight.
  Normally,  tcell  computes a tiny delta (only the two rows that changed color) and sends just a few bytes over SSH.
  However, before computing new diffs,  SnapshotBrowser  and  FileBrowser  were running a synchronous loop resetting  IsLoading = true  and
  calling  UpdateEntry  on all 200 rows instantly! This changed the state of 200 rows simultaneously, forcing  tcell  to push a full-screen
  redraw over the SSH connection 30 times a second. Sending a full terminal buffer 30 times a second perfectly matches the 15 Mbps traffic
  spike.

  2. The 0% CPU & UI Freezing:
  Because the 15 Mbps output saturated the SSH connection's buffer,  tcell  blocked the main UI thread while trying to flush the data to
  the network socket. This is why the CPU usage was near zero—the thread wasn't doing computations, it was hanging on I/O. Because the main
  thread was blocked,  tview  couldn't process your next keystrokes or draw the UI properly, causing the massive stutter.

  3. Were the goroutines cancelled correctly?
  Yes! The contexts were indeed being cancelled instantly on every keystroke. But the damage (the synchronous  IsLoading = true  loop on
  the main thread) was done before the goroutine even had a chance to start or get cancelled. Furthermore, in  FileBrowser , switching
  snapshots was unconditionally triggering an  os.Lstat  disk refresh for the whole directory.

  ### The Implementation Plan (Now Applied)

  To eliminate the network flood and I/O wait, we need to completely decouple the rapid arrow-key scrolling from the UI update cycles.

  1. Moved UI Resets into the Goroutine: I removed the synchronous loop that set  IsLoading = true  on the main thread. This ensures that
  simply moving the selection doesn't instantly mutate 200 table cells.
  2. Added a 50ms Debounce Delay: Inside the background task for both browsers, I added a  select  statement that waits exactly 50ms before
  doing anything.
      • If you hold down the arrow key, the next keystroke cancels the context in ~30ms.
      • The goroutine aborts silently without ever touching the UI or doing any calculations.
      • The UI only registers the tiny selection highlight change, dropping SSH traffic back down to kilobytes.
  3. Debounced FileBrowser Refresh: I applied the same 50ms debounce inside the  FileBrowser 's  Refresh()  function. Now, scrolling
  rapidly through the snapshot list will no longer spam the filesystem with  os.Lstat  directory read requests until you pause on a
  snapshot.
  ### The  QueueUpdateDraw  Deadlock

  When trying to fix the  ForceDraw  spam that was causing the massive 15Mbps network traffic, I mistakenly replaced  ForceDraw()  with
  QueueUpdateDraw()  inside  UpdateLayout()  and  Select() .

  Unbeknownst to me, the version of  tview  used by this project ( v0.42.0 ) includes a massive change to how  QueueUpdate  works: it
  blocks synchronously on a channel until the event loop finishes executing the update.

  Because  UpdateLayout()  and  Select()  are called by  syncScrollbar()  whenever the user presses an arrow key, they are executed on the
  main UI event loop thread. When the main thread called  QueueUpdateDraw() , it told  tview  to queue a screen update and blocked waiting
  for the event loop to process it. But because the event loop was currently frozen waiting for  QueueUpdateDraw()  to return, it caused an
  immediate, unrecoverable deadlock! This perfectly explains why it hung indefinitely right at startup as soon as the initial selection was
  set.

  ### The Fix

  I have completely removed the  QueueUpdateDraw()  calls from both  scrollbar_component.go  and  table_component.go .

  Because  tview  automatically re-evaluates all state properties and calls  Draw()  naturally at the very end of any user input event, we
  actually don't need any manual draw triggers inside internal component property setters.

  By removing these manual draw queues, we achieved two huge victories at once:

  1. The application no longer deadlocks (the main thread simply updates variables and returns, letting  tview  draw natively).
  2. The 15Mbps network spike is fully eradicated (the terminal diff buffer is never forcefully cleared or redundantly redrawn 6 times a
  keystroke).
      • Dropped the  ColumnLoading  definition from  internal/ui/table/table_component.go .
      • Removed it from all default/initial column arrays in  file_browser.go  and  snapshot_browser.go .
      • Stripped out the logic in  openColumnSelectionDialog()  that artificially pinned the loading column to the view.
      • Removed the runtime slice appending logic that prepended  ColumnLoading  during  SetActiveColumns() .
  2. Integrated the Spinner into the "Diff" Column:
      • In  internal/ui/file_browser/table_container.go , I modified the  case columnDiff:  block. Now, if the loader is actively fetching
      state for a row, it overrides the typical  + / - / =  indicator and instead renders a yellow  ⟳ .
      • Did the exact same implementation for  internal/ui/snapshot_browser/table_container.go .
… inside the background loop of file_browser.go.

  • SSH Redraw Throttling: Optimized snapshot_browser.go to use a 50ms throttled draw mechanism, matching the file browser and reducing SSH
  rendering lag.
  • Defensive Panic Checks: Fixed potential array out-of-bounds crashes in file_browser_entry.go and file_browser_entry.go.
  • Thread-Safety Improvements: Made debounced_loader.go more robust against late-firing timer callbacks and added tests in
debounced_loader_test.go to
  verify this behavior.
@markusressel
markusressel force-pushed the feature/improve-responsiveness-when-selecting-snapshot-browser-entries branch from 5934044 to 768dc38 Compare June 21, 2026 22:39
@markusressel markusressel changed the title Asynchronous & Incremental Diff Loading on Snapshot Selection Changes Asynchronous & Flicker-Free Diff Loading with Loading Column Jun 21, 2026
@markusressel
markusressel merged commit 38a3296 into main Jun 21, 2026
8 checks passed
@markusressel
markusressel deleted the feature/improve-responsiveness-when-selecting-snapshot-browser-entries branch June 21, 2026 22:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant