Skip to content

Commit 38a3296

Browse files
authored
Merge pull request #129 from markusressel/feature/improve-responsiveness-when-selecting-snapshot-browser-entries
Asynchronous & Flicker-Free Diff Loading with Loading Column
2 parents 61f226e + dbfb426 commit 38a3296

13 files changed

Lines changed: 845 additions & 132 deletions

internal/data/file_browser_entry.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ type FileBrowserEntry struct {
5555
SnapshotFiles []*SnapshotFile
5656
Type FileBrowserEntryType
5757
DiffState diff_state.DiffState
58+
IsLoading bool
5859
}
5960

6061
func (entry FileBrowserEntry) TableRowId() string {
@@ -77,17 +78,20 @@ func NewFileBrowserEntry(name string, latestFile *RealFile, snapshots []*Snapsho
7778
func (entry *FileBrowserEntry) GetRealPath() string {
7879
if entry.HasReal() {
7980
return entry.RealFile.Path
80-
} else {
81+
} else if len(entry.SnapshotFiles) > 0 && entry.SnapshotFiles[0] != nil {
8182
return entry.SnapshotFiles[0].OriginalPath
8283
}
84+
return ""
8385
}
8486

8587
func (entry *FileBrowserEntry) GetStat() os.FileInfo {
8688
if entry.HasReal() {
8789
return entry.RealFile.Stat
8890
}
89-
90-
return entry.SnapshotFiles[0].Stat
91+
if len(entry.SnapshotFiles) > 0 && entry.SnapshotFiles[0] != nil {
92+
return entry.SnapshotFiles[0].Stat
93+
}
94+
return nil
9195
}
9296

9397
// HasSnapshot indicated whether a snapshot file exists on the dataset for this entry.

internal/data/snapshot_browser_entry.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
type SnapshotBrowserEntry struct {
99
Snapshot *zfs.Snapshot
1010
DiffState diff_state.DiffState
11+
IsLoading bool
1112
}
1213

1314
func (s SnapshotBrowserEntry) TableRowId() string {

internal/ui/file_browser/file_browser.go

Lines changed: 174 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package file_browser
22

33
import (
4+
"context"
45
"fmt"
56
"os"
67
path2 "path"
@@ -99,21 +100,32 @@ type FileBrowserComponent struct {
99100

100101
selectionMemory *uiutil.SelectionMemory[data.FileBrowserEntry]
101102
fileWatcher *util.FileWatcher
103+
104+
diffLoader *uiutil.DebouncedLoader
105+
refreshLoader *uiutil.DebouncedLoader
102106
}
103107

104108
func NewFileBrowser(application *tview.Application) *FileBrowserComponent {
105-
tableContainer := createFileBrowserTable(application)
106-
107109
fileBrowser := &FileBrowserComponent{
108110
Events: util.NewEmitter[Event](),
109111

110112
application: application,
111113

112114
selectionMemory: uiutil.NewSelectionMemory[data.FileBrowserEntry](),
113-
114-
tableContainer: tableContainer,
115115
}
116116

117+
fileBrowser.diffLoader = uiutil.NewDebouncedLoader(application, func() {
118+
for _, entry := range fileBrowser.tableContainer.GetEntries() {
119+
if entry != nil && entry.IsLoading {
120+
fileBrowser.tableContainer.UpdateEntry(entry)
121+
}
122+
}
123+
})
124+
125+
fileBrowser.refreshLoader = uiutil.NewDebouncedLoader(application, func() {})
126+
127+
fileBrowser.tableContainer = fileBrowser.createFileBrowserTable(application)
128+
117129
fileBrowser.createLayout()
118130
fileBrowser.setupTable()
119131

@@ -203,10 +215,14 @@ func (fileBrowser *FileBrowserComponent) Focus() {
203215
fileBrowser.application.SetFocus(fileBrowser.layout)
204216
}
205217

206-
func (fileBrowser *FileBrowserComponent) computeTableEntries() ([]*data.FileBrowserEntry, error) {
218+
func (fileBrowser *FileBrowserComponent) computeTableEntries(ctx context.Context, previousDiffs map[string]diff_state.DiffState) ([]*data.FileBrowserEntry, error) {
207219
path := fileBrowser.path
208220
snapshotEntry := fileBrowser.currentSnapshot
209221

222+
if ctx.Err() != nil {
223+
return nil, ctx.Err()
224+
}
225+
210226
// list files in current path
211227
realFiles, err := util.ListFilesIn(path)
212228
if err != nil {
@@ -224,6 +240,9 @@ func (fileBrowser *FileBrowserComponent) computeTableEntries() ([]*data.FileBrow
224240

225241
// add entries for files which are present on the "real" location (and possibly within a snapshot as well)
226242
for _, realFilePath := range realFiles {
243+
if ctx.Err() != nil {
244+
return nil, ctx.Err()
245+
}
227246
_, realFileName := path2.Split(realFilePath)
228247
realFileStat, err := os.Lstat(realFilePath)
229248
if err != nil {
@@ -264,6 +283,9 @@ func (fileBrowser *FileBrowserComponent) computeTableEntries() ([]*data.FileBrow
264283
if snapshotEntry != nil {
265284
// add remaining entries for files which are only present in the snapshot
266285
for _, snapshotFilePath := range snapshotFilePaths {
286+
if ctx.Err() != nil {
287+
return nil, ctx.Err()
288+
}
267289
_, snapshotFileName := path2.Split(snapshotFilePath)
268290

269291
statSnap, err := os.Lstat(snapshotFilePath)
@@ -286,7 +308,12 @@ func (fileBrowser *FileBrowserComponent) computeTableEntries() ([]*data.FileBrow
286308
}
287309

288310
for _, entry := range fileEntries {
289-
entry.DiffState = fileBrowser.determineDiffState(entry, snapshotEntry)
311+
if oldState, exists := previousDiffs[entry.GetRealPath()]; exists {
312+
entry.DiffState = oldState
313+
} else {
314+
entry.DiffState = diff_state.Unknown
315+
}
316+
entry.IsLoading = true
290317
}
291318

292319
return fileEntries, nil
@@ -358,16 +385,12 @@ func (fileBrowser *FileBrowserComponent) goUp() {
358385
}
359386

360387
func (fileBrowser *FileBrowserComponent) SetPathWithSelection(newPath string, newSelection string) {
361-
fileBrowser.SetPath(newPath, false)
362-
363-
// select the directory entry of the path we were coming from
388+
// Remember the intended selection for the new path before triggering async refresh
364389
parentEntryName := path2.Base(path2.Clean(newSelection))
365-
for _, entry := range fileBrowser.GetEntries() {
366-
if entry.Name == parentEntryName {
367-
fileBrowser.selectFileEntry(entry)
368-
return
369-
}
370-
}
390+
fakeEntry := &data.FileBrowserEntry{Name: parentEntryName}
391+
fileBrowser.selectionMemory.Remember(newPath, 0, fakeEntry)
392+
393+
fileBrowser.SetPath(newPath, false)
371394
}
372395

373396
func (fileBrowser *FileBrowserComponent) SetPath(newPath string, checkExists bool) {
@@ -400,7 +423,7 @@ func (fileBrowser *FileBrowserComponent) SetPath(newPath string, checkExists boo
400423
}
401424

402425
fileBrowser.emit(PathChangedEvent{NewPath: newPath})
403-
fileBrowser.Refresh()
426+
fileBrowser.Refresh(false)
404427
}
405428
}
406429

@@ -480,21 +503,105 @@ func (fileBrowser *FileBrowserComponent) SetSelectedSnapshot(snapshot *data.Snap
480503
}
481504

482505
fileBrowser.currentSnapshot = snapshot
483-
fileBrowser.Refresh()
506+
fileBrowser.Refresh(true)
484507
}
485508

486-
func (fileBrowser *FileBrowserComponent) Refresh() {
487-
fileBrowser.showMessage(status_message.NewInfoStatusMessage("Refreshing..."))
509+
func (fileBrowser *FileBrowserComponent) startAsyncDiffCalculation() {
510+
if fileBrowser.diffLoader != nil {
511+
fileBrowser.diffLoader.Cancel()
512+
}
513+
514+
snapshotEntry := fileBrowser.currentSnapshot
515+
entriesToProcess := slices.Clone(fileBrowser.tableContainer.GetEntries())
516+
517+
if len(entriesToProcess) == 0 {
518+
return
519+
}
520+
521+
ctx, seq := fileBrowser.diffLoader.Start()
522+
523+
go func() {
524+
defer fileBrowser.diffLoader.Stop(seq)
525+
526+
// Debounce rapid scrolling
527+
select {
528+
case <-ctx.Done():
529+
return
530+
case <-time.After(50 * time.Millisecond):
531+
}
532+
533+
// Preemptively set loading state in case computation takes a while
534+
fileBrowser.application.QueueUpdate(func() {
535+
if !fileBrowser.diffLoader.IsCurrentSequence(seq) {
536+
return
537+
}
538+
for _, entry := range entriesToProcess {
539+
if entry != nil {
540+
entry.IsLoading = true
541+
fileBrowser.tableContainer.UpdateEntry(entry)
542+
}
543+
}
544+
})
545+
546+
type diffResult struct {
547+
entry *data.FileBrowserEntry
548+
state diff_state.DiffState
549+
}
550+
var batch []diffResult
551+
lastDrawTime := time.Now()
552+
553+
pushBatch := func(forceDraw bool) {
554+
if len(batch) == 0 {
555+
return
556+
}
557+
batchCopy := batch
558+
batch = nil
559+
560+
updateFunc := func() {
561+
if !fileBrowser.diffLoader.IsCurrentSequence(seq) {
562+
return
563+
}
564+
for _, res := range batchCopy {
565+
res.entry.DiffState = res.state
566+
res.entry.IsLoading = false
567+
fileBrowser.tableContainer.UpdateEntry(res.entry)
568+
}
569+
}
570+
571+
if forceDraw {
572+
fileBrowser.application.QueueUpdateDraw(updateFunc)
573+
} else {
574+
fileBrowser.application.QueueUpdate(updateFunc)
575+
}
576+
}
488577

578+
for i, entry := range entriesToProcess {
579+
if ctx.Err() != nil {
580+
return
581+
}
582+
583+
diffState := fileBrowser.determineDiffState(entry, snapshotEntry)
584+
585+
batch = append(batch, diffResult{entry: entry, state: diffState})
586+
587+
now := time.Now()
588+
isLast := i == len(entriesToProcess)-1
589+
// Draw at most once every 50ms to prevent SSH connection flooding
590+
if isLast || now.Sub(lastDrawTime) > 50*time.Millisecond {
591+
pushBatch(true)
592+
lastDrawTime = now
593+
} else if len(batch) >= 10 {
594+
pushBatch(false)
595+
}
596+
}
597+
}()
598+
}
599+
600+
func (fileBrowser *FileBrowserComponent) Refresh(debounce bool) {
489601
_, _, width, _ := fileBrowser.tableContainer.GetLayout().GetRect()
490602
if width == 0 {
491603
width = 80
492604
}
493-
// title is " Path: Path: <path> "
494-
// theme.CreateTitleText adds 2 spaces.
495-
// Box adds borders (2 chars).
496-
// "Path: " is 6 chars.
497-
// So available is width - 2 (borders) - 2 (spaces) - 6 (prefix) = width - 10.
498605
maxWidth := width - 10
499606
if maxWidth < 20 {
500607
maxWidth = 20
@@ -503,15 +610,44 @@ func (fileBrowser *FileBrowserComponent) Refresh() {
503610
title := fmt.Sprintf("Path: %s", fileBrowser.truncatePath(fileBrowser.path, maxWidth))
504611
fileBrowser.tableContainer.SetTitle(title)
505612

506-
entries, err := fileBrowser.computeTableEntries()
507-
if err != nil {
508-
fileBrowser.showError(err)
509-
} else {
510-
fileBrowser.tableContainer.SetData(entries)
511-
fileBrowser.restoreSelectionForPath()
512-
fileBrowser.updateFileWatcher()
613+
previousDiffs := make(map[string]diff_state.DiffState)
614+
for _, entry := range fileBrowser.tableContainer.GetEntries() {
615+
if entry != nil {
616+
previousDiffs[entry.GetRealPath()] = entry.DiffState
617+
}
513618
}
514-
fileBrowser.showMessage(status_message.NewInfoStatusMessage(""))
619+
620+
ctx, seq := fileBrowser.refreshLoader.Start()
621+
622+
go func() {
623+
// Debounce rapid calls to Refresh (e.g. from fast scrolling in SnapshotBrowser)
624+
if debounce {
625+
select {
626+
case <-ctx.Done():
627+
return
628+
case <-time.After(50 * time.Millisecond):
629+
}
630+
}
631+
632+
entries, err := fileBrowser.computeTableEntries(ctx, previousDiffs)
633+
634+
fileBrowser.application.QueueUpdateDraw(func() {
635+
if !fileBrowser.refreshLoader.IsCurrentSequence(seq) {
636+
return
637+
}
638+
if err != nil {
639+
fileBrowser.showError(err)
640+
} else {
641+
fileBrowser.tableContainer.SetData(entries)
642+
fileBrowser.restoreSelectionForPath()
643+
fileBrowser.updateFileWatcher()
644+
645+
fileBrowser.startAsyncDiffCalculation()
646+
647+
fileBrowser.emit(SelectedTableEntryChangedEvent{fileBrowser.GetSelection()})
648+
}
649+
})
650+
}()
515651
}
516652

517653
func (fileBrowser *FileBrowserComponent) truncatePath(path string, maxWidth int) string {
@@ -634,7 +770,7 @@ func (fileBrowser *FileBrowserComponent) updateFileWatcher() {
634770
}
635771
fileBrowser.fileWatcher = util.NewFileWatcher(path)
636772
action := func(s string) {
637-
fileBrowser.Refresh()
773+
fileBrowser.Refresh(false)
638774
fileBrowser.application.Draw()
639775
}
640776
err := fileBrowser.fileWatcher.Watch(action)
@@ -652,11 +788,13 @@ func (fileBrowser *FileBrowserComponent) showDialog(d dialog.Dialog, actionHandl
652788
}
653789

654790
func (fileBrowser *FileBrowserComponent) openColumnSelectionDialog() {
791+
currentActive := fileBrowser.tableContainer.GetColumnSpec()
792+
655793
d := dialog.NewColumnSelectionDialog(
656794
fileBrowser.application,
657795
"Configure File Browser Columns",
658796
tableColumns,
659-
fileBrowser.tableContainer.GetColumnSpec(),
797+
slices.Clone(currentActive),
660798
func(activeColumns []*table.Column) {
661799
fileBrowser.tableContainer.SetActiveColumns(activeColumns)
662800
},
@@ -682,7 +820,7 @@ func (fileBrowser *FileBrowserComponent) runRestoreFileAction(entry *data.FileBr
682820
fileBrowser.showDialog(d, func(action dialog.DialogActionId) bool {
683821
switch action {
684822
case dialog.DialogCloseActionId:
685-
fileBrowser.Refresh()
823+
fileBrowser.Refresh(false)
686824
}
687825
return false
688826
})
@@ -720,7 +858,7 @@ func (fileBrowser *FileBrowserComponent) showDiff(selection *data.FileBrowserEnt
720858
fileBrowser.showDialog(d, func(action dialog.DialogActionId) bool {
721859
switch action {
722860
case dialog.DialogCloseActionId:
723-
fileBrowser.Refresh()
861+
fileBrowser.Refresh(false)
724862
}
725863
return false
726864
})

0 commit comments

Comments
 (0)