Asynchronous & Flicker-Free Diff Loading with Loading Column - #129
Merged
markusressel merged 10 commits intoJun 21, 2026
Merged
Conversation
…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
force-pushed
the
feature/improve-responsiveness-when-selecting-snapshot-browser-entries
branch
from
June 21, 2026 22:39
5934044 to
768dc38
Compare
markusressel
deleted the
feature/improve-responsiveness-when-selecting-snapshot-browser-entries
branch
June 21, 2026 22:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
columnLoading(ID99) as the first column in both the File Browser and the Snapshot Browser.⟳) next to entries currently undergoing background diff calculation, which vanishes once computation finishes.2. Debounced Loading Spinner
DebouncedLoaderutility that manages background calculation sequences, context cancellation, and the100msdebounce timer.⟳) is only rendered when an entry's diff is loading ANDDebouncedLoader.ShowLoadingSpinner()is true.100ms.3. Refactored Cell Rendering Scope
FileBrowserComponentandSnapshotBrowserComponentto accessShowLoadingSpinner().4. Unconditional Carry-over of Diff States
fileHasNotChangedandsnapshotHasNotChanged).N/Afrom flashing during active directory updates or file selection changes.N/Aonly if they represent newly added entries or if the directory path changes.5. Asynchronous Diff Computation
100ms(or once at the end if the total directory/dataset computation is fast), avoiding redundant table rebuilds.6. Thread-Safe File Watcher Updates
actioncallback infileBrowser.application.QueueUpdateDraw(), resolving data races where the background thread cleared/wrote table data during rendering.Files Modified
Verification
go test ./...).just build).