From 93fff8bee97a7b6cdb3a0caa6929a9ef0bd0da3b Mon Sep 17 00:00:00 2001 From: intergrav Date: Fri, 17 Jul 2026 15:09:43 +0000 Subject: [PATCH] feat: add restore progress and improve background task handling - split snapshot browsing/restore behavior into thier own modules and improve lifecycle of background ops - stream JSON restore progress into UI - recognize password file/password command, add tests for restore progress and error parsing - also normalize executable names 2 match restray better --- .gitignore | 4 ++-- cmd/restree/app.go | 549 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- cmd/restree/browse.go | 349 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/restree/main.go | 1 + cmd/restree/restic.go | 184 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------- cmd/restree/restic_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ cmd/restree/restore.go | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/restree/tree.go | 2 +- justfile | 2 +- 9 file(s) changed, 753 insertion(s)(+), 518 deletion(s)(-) diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,6 @@ .DS_Store bin/ dist/ result -/Restree -/Restree.exe +/restree +/restree.exe /cmd/restree/restree_windows_*.syso diff --git a/cmd/restree/app.go b/cmd/restree/app.go --- a/cmd/restree/app.go +++ b/cmd/restree/app.go @@ -2,12 +2,8 @@ package main import ( "context" - "encoding/json" - "errors" - "fmt" - "path/filepath" "strings" - "time" + "sync" qt "github.com/mappu/miqt/qt6" ) @@ -24,60 +20,33 @@ loadingRole = int(qt.UserRole) + 3 ) type appUI struct { - window *qt.QMainWindow - repoEdit *qt.QLineEdit - passwordEdit *qt.QLineEdit - loadButton *qt.QPushButton - inputWidget *qt.QWidget - restoreButton *qt.QPushButton - searchEdit *qt.QLineEdit - searchButton *qt.QPushButton - hostCombo *qt.QComboBox - tagCombo *qt.QComboBox - statusBar *qt.QStatusBar - progressBar *qt.QProgressBar - snapshotTree *qt.QTreeWidget - fileTree *qt.QTreeWidget - allSnapshots []snapshot - currentSnapID string - fileLoadContext context.Context - cancelFileLoad context.CancelFunc - snapshotLoadID int - fileLoadID int - activeFileLoads int - restoreLoadID int - restoreActive bool - searchActive bool - updatingFilters bool - envConfig resticConfig - autoLoad bool -} - -type snapshotResult struct { - loadID int - snapshots []snapshot - err error -} - -type fileResult struct { - loadID int - snapID string - dir string - entries []lsEntry - err error -} - -type searchResult struct { - loadID int - query string - entries []lsEntry - err error -} - -type restoreResult struct { - loadID int - target string - err error + window *qt.QMainWindow + repoEdit *qt.QLineEdit + passwordEdit *qt.QLineEdit + loadButton *qt.QPushButton + inputWidget *qt.QWidget + restoreButton *qt.QPushButton + searchEdit *qt.QLineEdit + searchButton *qt.QPushButton + hostCombo *qt.QComboBox + tagCombo *qt.QComboBox + statusBar *qt.QStatusBar + progressBar *qt.QProgressBar + snapshotTree *qt.QTreeWidget + fileTree *qt.QTreeWidget + allSnapshots []snapshot + currentSnapID string + fileLoadContext context.Context + cancelFileLoad context.CancelFunc + cancelSnapshotLoad context.CancelFunc + cancelRestore context.CancelFunc + fileLoadID int + activeFileLoads int + workers sync.WaitGroup + searchActive bool + updatingFilters bool + envConfig resticConfig + autoLoad bool } func buildUI() *appUI { @@ -115,7 +84,7 @@ form.AddWidget2(ui.passwordEdit.QWidget, 1, 1) form.AddWidget2(ui.loadButton.QWidget, 1, 2) hasEnvRepo := envConfig.repo != "" - hasEnvPassword := envConfig.passwordSource + hasEnvPassword := envConfig.passwordConfigured ui.autoLoad = hasEnvRepo && hasEnvPassword if hasEnvRepo { repoLabel.Hide() @@ -233,29 +202,17 @@ } else { ui.setStatus("Choose a repository and load snapshots.") } - ui.loadButton.OnClicked(func() { - ui.loadSnapshots() - }) - ui.repoEdit.OnReturnPressed(func() { - ui.loadSnapshots() - }) - ui.passwordEdit.OnReturnPressed(func() { - ui.loadSnapshots() - }) + ui.loadButton.OnClicked(ui.loadSnapshots) + ui.repoEdit.OnReturnPressed(ui.loadSnapshots) + ui.passwordEdit.OnReturnPressed(ui.loadSnapshots) ui.snapshotTree.OnCurrentItemChanged(func(current *qt.QTreeWidgetItem, _ *qt.QTreeWidgetItem) { if current != nil { ui.loadSnapshotFiles(current.Data(snapshotColumnID, commandIDRole).ToString()) } }) - ui.fileTree.OnItemSelectionChanged(func() { - ui.updateRestoreAction() - }) - ui.fileTree.OnItemExpanded(func(item *qt.QTreeWidgetItem) { - ui.loadExpandedDirectory(item) - }) - ui.restoreButton.OnClicked(func() { - ui.restoreSelection() - }) + ui.fileTree.OnItemSelectionChanged(ui.updateRestoreAction) + ui.fileTree.OnItemExpanded(ui.loadExpandedDirectory) + ui.restoreButton.OnClicked(ui.restoreSelection) ui.searchEdit.OnTextChanged(func(text string) { query := strings.TrimSpace(text) ui.searchButton.SetEnabled(ui.currentSnapID != "" && query != "") @@ -263,12 +220,8 @@ if query == "" && ui.searchActive { ui.loadSnapshotFiles(ui.currentSnapID) } }) - ui.searchEdit.OnReturnPressed(func() { - ui.searchSnapshot() - }) - ui.searchButton.OnClicked(func() { - ui.searchSnapshot() - }) + ui.searchEdit.OnReturnPressed(ui.searchSnapshot) + ui.searchButton.OnClicked(ui.searchSnapshot) ui.hostCombo.OnCurrentTextChanged(func(string) { if ui.updatingFilters { return @@ -285,428 +238,38 @@ return ui } -func (ui *appUI) loadSnapshots() { - if !ui.loadButton.IsEnabled() { - return - } - config := ui.resticConfig() - if config.repo == "" { - ui.setStatus("Repository is required.") - return - } - - ui.snapshotLoadID++ - loadID := ui.snapshotLoadID - - ui.setStatus("Loading snapshots...") - ui.loadButton.SetEnabled(false) - ui.snapshotTree.SetEnabled(false) - ui.restoreButton.SetEnabled(false) - ui.resetSnapshotView() - - results := make(chan snapshotResult, 1) - go func() { - out, err := runRestic(config, "snapshots", "--json") - if err != nil { - results <- snapshotResult{loadID: loadID, err: err} - return - } - - var snapshots []snapshot - if err := json.Unmarshal(out, &snapshots); err != nil { - results <- snapshotResult{loadID: loadID, err: fmt.Errorf("could not parse restic snapshots output: %w", err)} - return - } - results <- snapshotResult{loadID: loadID, snapshots: snapshots} - }() - - pollResult(ui.window.QObject, results, func(result snapshotResult) { - if result.loadID != ui.snapshotLoadID { - return - } - ui.loadButton.SetEnabled(true) - ui.snapshotTree.SetEnabled(true) - if result.err != nil { - ui.setStatus(result.err.Error()) - return - } - ui.showSnapshots(result.snapshots) - }) +func (ui *appUI) setStatus(message string) { + ui.statusBar.SetToolTip(message) + ui.statusBar.ShowMessage(truncateStatus(message)) } -func (ui *appUI) showSnapshots(snapshots []snapshot) { - ui.inputWidget.Hide() - ui.allSnapshots = snapshots - ui.populateSnapshotFilters(snapshots) - ui.applySnapshotFilter() -} - -func (ui *appUI) populateSnapshotFilters(snapshots []snapshot) { - currentHost := ui.hostCombo.CurrentText() - currentTag := ui.tagCombo.CurrentText() - hostsByName := make(map[string]bool) - tagsByName := make(map[string]bool) - for _, snap := range snapshots { - hostsByName[snapshotHostLabel(snap)] = true - if len(snap.Tags) == 0 { - tagsByName[noTagLabel] = true - continue - } - for _, tag := range snap.Tags { - tagsByName[tag] = true - } - } - - ui.updatingFilters = true - defer func() { - ui.updatingFilters = false - }() - - populateFilterCombo(ui.hostCombo, allHostsLabel, sortedKeys(hostsByName), currentHost) - populateFilterCombo(ui.tagCombo, allTagsLabel, sortedKeys(tagsByName), currentTag) -} - -func (ui *appUI) applySnapshotFilter() { - host := ui.hostCombo.CurrentText() - tag := ui.tagCombo.CurrentText() - snapshots := filterSnapshots(ui.allSnapshots, host, tag) - - ui.renderSnapshots(snapshots) - if (host != "" && host != allHostsLabel) || (tag != "" && tag != allTagsLabel) { - ui.setStatus(fmt.Sprintf("Showing %d of %d snapshot(s). Select a snapshot to browse files.", len(snapshots), len(ui.allSnapshots))) +func (ui *appUI) updateProgress() { + if ui.cancelSnapshotLoad != nil || ui.activeFileLoads > 0 || ui.cancelRestore != nil { + ui.progressBar.Show() return } - ui.setStatus(fmt.Sprintf("Loaded %d snapshot(s). Select a snapshot to browse files.", len(snapshots))) -} - -func (ui *appUI) renderSnapshots(snapshots []snapshot) { - ui.clearFileView() - ordered := sortSnapshotsByTime(snapshots) - populateTree(ui.snapshotTree, func() { - ui.snapshotTree.Clear() - for _, snap := range ordered { - values := []string{ - snap.displayID(), - formatTime(snap.Time), - snap.Hostname, - strings.Join(snap.Tags, ", "), - strings.Join(snap.Paths, ", "), - } - item := qt.NewQTreeWidgetItem2(values) - item.SetData(snapshotColumnID, commandIDRole, qt.NewQVariant11(snap.commandID())) - item.SetToolTip(snapshotColumnTime, formatExactTime(snap.Time)) - ui.snapshotTree.AddTopLevelItem(item) - } - }) -} - -func (ui *appUI) resetSnapshotView() { - ui.clearFileView() - ui.allSnapshots = nil - ui.snapshotTree.Clear() - ui.resetSnapshotFilters() + ui.progressBar.Hide() } -func (ui *appUI) clearFileView() { - ui.cancelActiveFileLoad() - ui.currentSnapID = "" - ui.searchActive = false - ui.searchEdit.Clear() - ui.searchEdit.SetEnabled(false) - ui.fileLoadID++ - ui.fileTree.Clear() - ui.updateRestoreAction() -} - -func (ui *appUI) resetSnapshotFilters() { - ui.updatingFilters = true - defer func() { - ui.updatingFilters = false +func (ui *appUI) startWorker(work func()) { + ui.workers.Add(1) + go func() { + defer ui.workers.Done() + work() }() - - ui.hostCombo.Clear() - ui.hostCombo.AddItem(allHostsLabel) - ui.hostCombo.SetEnabled(false) - ui.tagCombo.Clear() - ui.tagCombo.AddItem(allTagsLabel) - ui.tagCombo.SetEnabled(false) } -func (ui *appUI) loadSnapshotFiles(id string) { - if id == "" { - ui.setStatus("Selected snapshot has no ID.") - return +func (ui *appUI) shutdown() { + if ui.cancelSnapshotLoad != nil { + ui.cancelSnapshotLoad() } - - ui.cancelActiveFileLoad() - ui.searchActive = false - ctx, cancel := context.WithCancel(context.Background()) - ui.fileLoadID++ - ui.fileLoadContext = ctx - ui.cancelFileLoad = cancel - ui.currentSnapID = id - ui.searchEdit.Clear() - ui.searchEdit.SetEnabled(true) - ui.fileTree.Clear() - ui.restoreButton.SetEnabled(false) - ui.loadDirectory(ctx, nil, "/") -} - -func (ui *appUI) searchSnapshot() { - query := strings.TrimSpace(ui.searchEdit.Text()) - if ui.currentSnapID == "" || query == "" { - return - } - - ui.cancelActiveFileLoad() - ui.searchActive = true - ctx, cancel := context.WithCancel(context.Background()) - ui.fileLoadID++ - ui.fileLoadContext = ctx - ui.cancelFileLoad = cancel - loadID := ui.fileLoadID - snapshotID := ui.currentSnapID - config := ui.resticConfig() - ui.fileTree.Clear() - ui.restoreButton.SetEnabled(false) - ui.activeFileLoads++ - ui.updateProgress() - ui.setStatus("Searching for \"" + query + "\"...") - - results := make(chan searchResult, 1) - go func() { - out, err := runResticContext(ctx, config, "find", "--json", "--ignore-case", "--snapshot", snapshotID, "*"+query+"*") - if err != nil { - results <- searchResult{loadID: loadID, query: query, err: err} - return - } - entries, err := parseFindEntries(out) - results <- searchResult{loadID: loadID, query: query, entries: entries, err: err} - }() - - pollResult(ui.window.QObject, results, func(result searchResult) { - if result.loadID == ui.fileLoadID && ui.activeFileLoads > 0 { - ui.activeFileLoads-- - ui.updateProgress() - } - if result.loadID != ui.fileLoadID { - return - } - if result.err != nil { - if !errors.Is(result.err, context.Canceled) { - ui.setStatus(result.err.Error()) - } - return - } - populateTree(ui.fileTree, func() { - for _, entry := range result.entries { - entry.Name = entry.Path - ui.fileTree.AddTopLevelItem(fileTreeItem(entry)) - } - }) - ui.updateRestoreAction() - ui.setStatus(fmt.Sprintf("Found %d item(s) matching \"%s\".", len(result.entries), result.query)) - }) -} - -func (ui *appUI) cancelActiveFileLoad() { if ui.cancelFileLoad != nil { ui.cancelFileLoad() - ui.cancelFileLoad = nil - } - ui.activeFileLoads = 0 - ui.updateProgress() - ui.fileLoadContext = nil -} - -func (ui *appUI) loadExpandedDirectory(item *qt.QTreeWidgetItem) { - if item == nil || item.Text(fileColumnFullPath) == "" { - return } - if item.Data(fileColumnPath, loadedRole).ToBool() || item.Data(fileColumnPath, loadingRole).ToBool() { - return - } - ctx := ui.fileLoadContext - if ctx == nil { - return - } - ui.loadDirectory(ctx, item, item.Text(fileColumnFullPath)) -} - -func (ui *appUI) loadDirectory(ctx context.Context, parent *qt.QTreeWidgetItem, dir string) { - if ui.currentSnapID == "" { - return - } - - config := ui.resticConfig() - loadID := ui.fileLoadID - snapshotID := ui.currentSnapID - if parent != nil { - parent.SetData(fileColumnPath, loadingRole, qt.NewQVariant8(true)) - } - ui.activeFileLoads++ - ui.updateProgress() - ui.setStatus("Loading \"" + dir + "\"...") - - results := make(chan fileResult, 1) - go func() { - out, err := runResticContext(ctx, config, "ls", "--json", snapshotID, dir) - if err != nil { - results <- fileResult{loadID: loadID, snapID: snapshotID, dir: dir, err: err} - return - } - - entries, err := parseLSEntries(out) - results <- fileResult{loadID: loadID, snapID: snapshotID, dir: dir, entries: entries, err: err} - }() - - pollResult(ui.window.QObject, results, func(result fileResult) { - if result.loadID == ui.fileLoadID && ui.activeFileLoads > 0 { - ui.activeFileLoads-- - ui.updateProgress() - } - if result.loadID != ui.fileLoadID || result.snapID != ui.currentSnapID { - return - } - if parent != nil { - parent.SetData(fileColumnPath, loadingRole, qt.NewQVariant8(false)) - } - if result.err != nil { - if errors.Is(result.err, context.Canceled) { - return - } - ui.setStatus(result.err.Error()) - return - } - ui.showDirectory(parent, result.dir, result.entries) - }) -} - -func (ui *appUI) showDirectory(parent *qt.QTreeWidgetItem, dir string, entries []lsEntry) { - populateTree(ui.fileTree, func() { - if parent == nil { - ui.fileTree.Clear() - } else { - parent.SetChildIndicatorPolicy(qt.QTreeWidgetItem__DontShowIndicatorWhenChildless) - } - - for _, entry := range entries { - if !isDirectChild(entry.Path, dir) { - continue - } - item := fileTreeItem(entry) - if parent == nil { - ui.fileTree.AddTopLevelItem(item) - } else { - parent.AddChild(item) - } - } - }) - - if parent != nil { - parent.SetData(fileColumnPath, loadedRole, qt.NewQVariant8(true)) + if ui.cancelRestore != nil { + ui.cancelRestore() } - ui.updateRestoreAction() - ui.setStatus("Loaded \"" + dir + "\".") -} - -func (ui *appUI) updateRestoreAction() { - ui.restoreButton.SetEnabled(ui.currentSnapID != "" && len(ui.selectedRestorePaths()) > 0) -} - -func (ui *appUI) selectedRestorePaths() []string { - items := ui.fileTree.SelectedItems() - paths := make([]string, 0, len(items)) - seen := make(map[string]bool) - for _, item := range items { - p := item.Text(fileColumnFullPath) - if p == "" || seen[p] { - continue - } - seen[p] = true - paths = append(paths, p) - } - return trimNestedPaths(paths) -} - -func (ui *appUI) restoreSelection() { - if ui.restoreActive { - ui.setStatus("Restore already in progress.") - return - } - - paths := ui.selectedRestorePaths() - if ui.currentSnapID == "" || len(paths) == 0 { - ui.setStatus("Select files or folders to restore.") - return - } - - baseDir := qt.QFileDialog_GetExistingDirectory3(ui.window.QWidget, "Choose restore destination", "") - if baseDir == "" { - return - } - - target := filepath.Join(baseDir, "restree-restore-"+time.Now().Format("2006-01-02-15-04-05")) - text := fmt.Sprintf("Restore %d selected item(s) into:\n%s", len(paths), target) - choice := qt.QMessageBox_Question6( - ui.window.QWidget, - "Restore selected items", - text, - qt.QMessageBox__Yes|qt.QMessageBox__Cancel, - qt.QMessageBox__Cancel, - ) - if choice != qt.QMessageBox__Yes { - return - } - - ui.restorePaths(ui.currentSnapID, target, paths) -} - -func (ui *appUI) restorePaths(snapshotID string, target string, paths []string) { - config := ui.resticConfig() - ui.restoreLoadID++ - loadID := ui.restoreLoadID - ui.restoreButton.SetEnabled(false) - ui.restoreActive = true - ui.updateProgress() - ui.setStatus(fmt.Sprintf("Restoring %d selected item(s)...", len(paths))) - - results := make(chan restoreResult, 1) - go func() { - args := []string{"restore", snapshotID, "--target", target} - for _, p := range paths { - args = append(args, "--include", p) - } - _, err := runRestic(config, args...) - results <- restoreResult{loadID: loadID, target: target, err: err} - }() - - pollResult(ui.window.QObject, results, func(result restoreResult) { - if result.loadID != ui.restoreLoadID { - return - } - ui.restoreActive = false - ui.updateProgress() - ui.updateRestoreAction() - if result.err != nil { - ui.setStatus(result.err.Error()) - return - } - ui.setStatus("Restore complete: " + result.target) - }) -} - -func (ui *appUI) setStatus(message string) { - ui.statusBar.SetToolTip(message) - ui.statusBar.ShowMessage(truncateStatus(message)) -} - -func (ui *appUI) updateProgress() { - if ui.activeFileLoads > 0 || ui.restoreActive { - ui.progressBar.Show() - return - } - ui.progressBar.Hide() + ui.workers.Wait() } func (ui *appUI) resticConfig() resticConfig { @@ -714,7 +277,7 @@ config := ui.envConfig if config.repo == "" { config.repo = strings.TrimSpace(ui.repoEdit.Text()) } - if !config.passwordSource { + if !config.passwordConfigured { config.password = ui.passwordEdit.Text() } return config diff --git a/cmd/restree/browse.go b/cmd/restree/browse.go new file mode 100644 --- /dev/null +++ b/cmd/restree/browse.go @@ -0,0 +1,349 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + qt "github.com/mappu/miqt/qt6" +) + +type snapshotResult struct { + snapshots []snapshot + err error +} + +type fileResult struct { + loadID int + entries []lsEntry + err error +} + +type searchResult struct { + loadID int + entries []lsEntry + err error +} + +func (ui *appUI) loadSnapshots() { + if !ui.loadButton.IsEnabled() { + return + } + config := ui.resticConfig() + if config.repo == "" { + ui.setStatus("Repository is required.") + return + } + + ctx, cancel := context.WithCancel(context.Background()) + ui.cancelSnapshotLoad = cancel + ui.setStatus("Loading snapshots...") + ui.loadButton.SetEnabled(false) + ui.snapshotTree.SetEnabled(false) + ui.restoreButton.SetEnabled(false) + ui.resetSnapshotView() + ui.updateProgress() + + results := make(chan snapshotResult, 1) + ui.startWorker(func() { + out, err := runResticContext(ctx, config, "snapshots", "--json") + if err != nil { + results <- snapshotResult{err: err} + return + } + + var snapshots []snapshot + if err := json.Unmarshal(out, &snapshots); err != nil { + results <- snapshotResult{err: fmt.Errorf("could not parse restic snapshots output: %w", err)} + return + } + results <- snapshotResult{snapshots: snapshots} + }) + + pollResult(ui.window.QObject, results, func(result snapshotResult) { + cancel() + ui.cancelSnapshotLoad = nil + ui.loadButton.SetEnabled(true) + ui.snapshotTree.SetEnabled(true) + ui.updateProgress() + if result.err != nil { + ui.setStatus(result.err.Error()) + return + } + ui.showSnapshots(result.snapshots) + }) +} + +func (ui *appUI) showSnapshots(snapshots []snapshot) { + ui.inputWidget.Hide() + ui.allSnapshots = snapshots + ui.populateSnapshotFilters(snapshots) + ui.applySnapshotFilter() +} + +func (ui *appUI) populateSnapshotFilters(snapshots []snapshot) { + currentHost := ui.hostCombo.CurrentText() + currentTag := ui.tagCombo.CurrentText() + hostsByName := make(map[string]bool) + tagsByName := make(map[string]bool) + for _, snap := range snapshots { + hostsByName[snapshotHostLabel(snap)] = true + if len(snap.Tags) == 0 { + tagsByName[noTagLabel] = true + continue + } + for _, tag := range snap.Tags { + tagsByName[tag] = true + } + } + + ui.updatingFilters = true + defer func() { + ui.updatingFilters = false + }() + + populateFilterCombo(ui.hostCombo, allHostsLabel, sortedKeys(hostsByName), currentHost) + populateFilterCombo(ui.tagCombo, allTagsLabel, sortedKeys(tagsByName), currentTag) +} + +func (ui *appUI) applySnapshotFilter() { + host := ui.hostCombo.CurrentText() + tag := ui.tagCombo.CurrentText() + snapshots := filterSnapshots(ui.allSnapshots, host, tag) + + ui.renderSnapshots(snapshots) + if (host != "" && host != allHostsLabel) || (tag != "" && tag != allTagsLabel) { + ui.setStatus(fmt.Sprintf("Showing %d of %d snapshot(s). Select a snapshot to browse files.", len(snapshots), len(ui.allSnapshots))) + return + } + ui.setStatus(fmt.Sprintf("Loaded %d snapshot(s). Select a snapshot to browse files.", len(snapshots))) +} + +func (ui *appUI) renderSnapshots(snapshots []snapshot) { + ui.clearFileView() + ordered := sortSnapshotsByTime(snapshots) + populateTree(ui.snapshotTree, func() { + ui.snapshotTree.Clear() + for _, snap := range ordered { + values := []string{ + snap.displayID(), + formatTime(snap.Time), + snap.Hostname, + strings.Join(snap.Tags, ", "), + strings.Join(snap.Paths, ", "), + } + item := qt.NewQTreeWidgetItem2(values) + item.SetData(snapshotColumnID, commandIDRole, qt.NewQVariant11(snap.commandID())) + item.SetToolTip(snapshotColumnTime, formatExactTime(snap.Time)) + ui.snapshotTree.AddTopLevelItem(item) + } + }) +} + +func (ui *appUI) resetSnapshotView() { + ui.clearFileView() + ui.allSnapshots = nil + ui.snapshotTree.Clear() + ui.resetSnapshotFilters() +} + +func (ui *appUI) clearFileView() { + ui.cancelActiveFileLoad() + ui.currentSnapID = "" + ui.searchActive = false + ui.searchEdit.Clear() + ui.searchEdit.SetEnabled(false) + ui.fileLoadID++ + ui.fileTree.Clear() + ui.updateRestoreAction() +} + +func (ui *appUI) resetSnapshotFilters() { + ui.updatingFilters = true + defer func() { + ui.updatingFilters = false + }() + + ui.hostCombo.Clear() + ui.hostCombo.AddItem(allHostsLabel) + ui.hostCombo.SetEnabled(false) + ui.tagCombo.Clear() + ui.tagCombo.AddItem(allTagsLabel) + ui.tagCombo.SetEnabled(false) +} + +func (ui *appUI) loadSnapshotFiles(id string) { + if id == "" { + ui.setStatus("Selected snapshot has no ID.") + return + } + + ui.cancelActiveFileLoad() + ui.searchActive = false + ctx, cancel := context.WithCancel(context.Background()) + ui.fileLoadID++ + ui.fileLoadContext = ctx + ui.cancelFileLoad = cancel + ui.currentSnapID = id + ui.searchEdit.Clear() + ui.searchEdit.SetEnabled(true) + ui.fileTree.Clear() + ui.restoreButton.SetEnabled(false) + ui.loadDirectory(ctx, nil, "/") +} + +func (ui *appUI) searchSnapshot() { + query := strings.TrimSpace(ui.searchEdit.Text()) + if ui.currentSnapID == "" || query == "" { + return + } + + ui.cancelActiveFileLoad() + ui.searchActive = true + ctx, cancel := context.WithCancel(context.Background()) + ui.fileLoadID++ + ui.fileLoadContext = ctx + ui.cancelFileLoad = cancel + loadID := ui.fileLoadID + snapshotID := ui.currentSnapID + config := ui.resticConfig() + ui.fileTree.Clear() + ui.restoreButton.SetEnabled(false) + ui.activeFileLoads++ + ui.updateProgress() + ui.setStatus("Searching for \"" + query + "\"...") + + results := make(chan searchResult, 1) + ui.startWorker(func() { + out, err := runResticContext(ctx, config, "find", "--json", "--ignore-case", "--snapshot", snapshotID, "*"+query+"*") + if err != nil { + results <- searchResult{loadID: loadID, err: err} + return + } + entries, err := parseFindEntries(out) + results <- searchResult{loadID: loadID, entries: entries, err: err} + }) + + pollResult(ui.window.QObject, results, func(result searchResult) { + ui.finishFileLoad(result.loadID) + if result.loadID != ui.fileLoadID { + return + } + if result.err != nil { + ui.setStatus(result.err.Error()) + return + } + populateTree(ui.fileTree, func() { + for _, entry := range result.entries { + entry.Name = entry.Path + ui.fileTree.AddTopLevelItem(fileTreeItem(entry)) + } + }) + ui.updateRestoreAction() + ui.setStatus(fmt.Sprintf("Found %d item(s) matching \"%s\".", len(result.entries), query)) + }) +} + +func (ui *appUI) cancelActiveFileLoad() { + if ui.cancelFileLoad != nil { + ui.cancelFileLoad() + ui.cancelFileLoad = nil + } + ui.activeFileLoads = 0 + ui.updateProgress() + ui.fileLoadContext = nil +} + +func (ui *appUI) finishFileLoad(loadID int) { + if loadID != ui.fileLoadID || ui.activeFileLoads == 0 { + return + } + ui.activeFileLoads-- + ui.updateProgress() +} + +func (ui *appUI) loadExpandedDirectory(item *qt.QTreeWidgetItem) { + if item == nil || item.Text(fileColumnFullPath) == "" { + return + } + if item.Data(fileColumnPath, loadedRole).ToBool() || item.Data(fileColumnPath, loadingRole).ToBool() { + return + } + ctx := ui.fileLoadContext + if ctx == nil { + return + } + ui.loadDirectory(ctx, item, item.Text(fileColumnFullPath)) +} + +func (ui *appUI) loadDirectory(ctx context.Context, parent *qt.QTreeWidgetItem, dir string) { + if ui.currentSnapID == "" { + return + } + + config := ui.resticConfig() + loadID := ui.fileLoadID + snapshotID := ui.currentSnapID + if parent != nil { + parent.SetData(fileColumnPath, loadingRole, qt.NewQVariant8(true)) + } + ui.activeFileLoads++ + ui.updateProgress() + ui.setStatus("Loading \"" + dir + "\"...") + + results := make(chan fileResult, 1) + ui.startWorker(func() { + out, err := runResticContext(ctx, config, "ls", "--json", snapshotID, dir) + if err != nil { + results <- fileResult{loadID: loadID, err: err} + return + } + + entries, err := parseLSEntries(out) + results <- fileResult{loadID: loadID, entries: entries, err: err} + }) + + pollResult(ui.window.QObject, results, func(result fileResult) { + ui.finishFileLoad(result.loadID) + if result.loadID != ui.fileLoadID { + return + } + if parent != nil { + parent.SetData(fileColumnPath, loadingRole, qt.NewQVariant8(false)) + } + if result.err != nil { + ui.setStatus(result.err.Error()) + return + } + ui.showDirectory(parent, dir, result.entries) + }) +} + +func (ui *appUI) showDirectory(parent *qt.QTreeWidgetItem, dir string, entries []lsEntry) { + populateTree(ui.fileTree, func() { + if parent == nil { + ui.fileTree.Clear() + } else { + parent.SetChildIndicatorPolicy(qt.QTreeWidgetItem__DontShowIndicatorWhenChildless) + } + + for _, entry := range entries { + if !isDirectChild(entry.Path, dir) { + continue + } + item := fileTreeItem(entry) + if parent == nil { + ui.fileTree.AddTopLevelItem(item) + } else { + parent.AddChild(item) + } + } + }) + + if parent != nil { + parent.SetData(fileColumnPath, loadedRole, qt.NewQVariant8(true)) + } + ui.updateRestoreAction() + ui.setStatus("Loaded \"" + dir + "\".") +} diff --git a/cmd/restree/main.go b/cmd/restree/main.go --- a/cmd/restree/main.go +++ b/cmd/restree/main.go @@ -18,4 +18,5 @@ ui.loadSnapshots() } qt.QApplication_Exec() + ui.shutdown() } diff --git a/cmd/restree/restic.go b/cmd/restree/restic.go --- a/cmd/restree/restic.go +++ b/cmd/restree/restic.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "context" "encoding/json" @@ -29,48 +30,189 @@ MTime string `json:"mtime"` } type resticConfig struct { - binary string - repo string - password string - passwordSource bool + binary string + repo string + password string + passwordConfigured bool } -func runRestic(config resticConfig, args ...string) ([]byte, error) { - return runResticContext(context.Background(), config, args...) +type restoreStatus struct { + MessageType string `json:"message_type"` + Percent float64 `json:"percent_done"` + TotalFiles uint64 `json:"total_files"` + FilesRestored uint64 `json:"files_restored"` + FilesSkipped uint64 `json:"files_skipped"` + TotalBytes uint64 `json:"total_bytes"` + BytesRestored uint64 `json:"bytes_restored"` } func runResticContext(ctx context.Context, config resticConfig, args ...string) ([]byte, error) { + cmd, err := resticCommand(ctx, config, args...) + if err != nil { + return nil, err + } + out, err := cmd.CombinedOutput() + if err != nil { + return nil, resticError(ctx, args, out, err) + } + return out, nil +} + +func runResticRestore(ctx context.Context, config resticConfig, progress chan restoreStatus, args ...string) error { + jsonArgs := append([]string{"--json"}, args...) + cmd, err := resticCommand(ctx, config, jsonArgs...) + if err != nil { + return err + } + cmd.Env = setEnv(cmd.Env, "RESTIC_PROGRESS_FPS", "4") + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("could not read restic restore output: %w", err) + } + var stderr tailBuffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("could not start restic restore: %w", err) + } + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + if status, ok := parseRestoreStatus(scanner.Bytes()); ok { + sendRestoreStatus(progress, status) + } + } + if scanner.Err() != nil { + _, _ = io.Copy(io.Discard, stdout) + } + + if err := cmd.Wait(); err != nil { + return resticError(ctx, jsonArgs, stderr.data, err) + } + return nil +} + +func resticCommand(ctx context.Context, config resticConfig, args ...string) (*exec.Cmd, error) { if config.repo == "" { return nil, fmt.Errorf("repository is required") } - cmdArgs := append([]string{"--no-lock", "-r", config.repo}, args...) cmd := exec.CommandContext(ctx, config.resticBinary(), cmdArgs...) cmd.Env = os.Environ() if config.password != "" { - cmd.Env = append(cmd.Env, "RESTIC_PASSWORD="+config.password) + cmd.Env = setEnv(cmd.Env, "RESTIC_PASSWORD", config.password) } + return cmd, nil +} - out, err := cmd.CombinedOutput() - if err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() +func resticError(ctx context.Context, args []string, out []byte, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + msg := resticErrorText(out) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("restic %s failed: %s", strings.Join(args, " "), msg) +} + +func resticErrorText(out []byte) string { + var detail string + var summary string + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + var message struct { + MessageType string `json:"message_type"` + Message string `json:"message"` + Item string `json:"item"` + Error struct { + Message string `json:"message"` + } `json:"error"` } - msg := strings.TrimSpace(string(out)) - if msg == "" { - msg = err.Error() + if json.Unmarshal(scanner.Bytes(), &message) != nil { + continue + } + switch message.MessageType { + case "error": + if detail == "" { + detail = message.Error.Message + if message.Item != "" { + detail = message.Item + ": " + detail + } + } + case "exit_error": + summary = message.Message } - return nil, fmt.Errorf("restic %s failed: %s", strings.Join(args, " "), msg) } - return out, nil + if detail != "" { + return detail + } + if summary != "" { + return summary + } + return strings.TrimSpace(string(out)) +} + +type tailBuffer struct { + data []byte +} + +func (buffer *tailBuffer) Write(data []byte) (int, error) { + const limit = 64 * 1024 + length := len(data) + if length >= limit { + buffer.data = append(buffer.data[:0], data[length-limit:]...) + return length, nil + } + if overflow := len(buffer.data) + length - limit; overflow > 0 { + copy(buffer.data, buffer.data[overflow:]) + buffer.data = buffer.data[:len(buffer.data)-overflow] + } + buffer.data = append(buffer.data, data...) + return length, nil +} + +func parseRestoreStatus(line []byte) (restoreStatus, bool) { + var status restoreStatus + if json.Unmarshal(line, &status) != nil || status.MessageType != "status" { + return restoreStatus{}, false + } + return status, true +} + +func sendRestoreStatus(updates chan restoreStatus, status restoreStatus) { + select { + case updates <- status: + return + default: + } + select { + case <-updates: + default: + } + select { + case updates <- status: + default: + } +} + +func setEnv(env []string, key string, value string) []string { + for i, entry := range env { + if name, _, ok := strings.Cut(entry, "="); ok && name == key { + env[i] = key + "=" + value + return env + } + } + return append(env, key+"="+value) } func resticConfigFromEnv() resticConfig { return resticConfig{ - binary: strings.TrimSpace(os.Getenv("RESTRAY_RESTIC")), - repo: strings.TrimSpace(os.Getenv("RESTIC_REPOSITORY")), - password: os.Getenv("RESTIC_PASSWORD"), - passwordSource: resticPasswordConfigured(), + binary: strings.TrimSpace(os.Getenv("RESTRAY_RESTIC")), + repo: strings.TrimSpace(os.Getenv("RESTIC_REPOSITORY")), + password: os.Getenv("RESTIC_PASSWORD"), + passwordConfigured: resticPasswordConfigured(), } } diff --git a/cmd/restree/restic_test.go b/cmd/restree/restic_test.go --- a/cmd/restree/restic_test.go +++ b/cmd/restree/restic_test.go @@ -31,3 +31,46 @@ if err != nil || !reflect.DeepEqual(got, want) { t.Fatalf("parseFindEntries() = %#v, %v; want %#v, nil", got, err, want) } } + +func TestResticErrorText(t *testing.T) { + out := []byte("{\"message_type\":\"error\",\"item\":\"/documents/file\",\"error\":{\"message\":\"could not restore file\"}}\n{\"message_type\":\"exit_error\",\"message\":\"restore failed\"}\n") + if got, want := resticErrorText(out), "/documents/file: could not restore file"; got != want { + t.Fatalf("resticErrorText() = %q, want %q", got, want) + } + if got, want := resticErrorText([]byte("plain error\n")), "plain error"; got != want { + t.Fatalf("plain resticErrorText() = %q, want %q", got, want) + } +} + +func TestParseRestoreStatus(t *testing.T) { + line := []byte(`{"message_type":"status","percent_done":0.4,"total_files":10,"files_restored":4,"files_skipped":1,"total_bytes":1000,"bytes_restored":400,"bytes_skipped":100}`) + want := restoreStatus{ + MessageType: "status", + Percent: 0.4, + TotalFiles: 10, + FilesRestored: 4, + FilesSkipped: 1, + TotalBytes: 1000, + BytesRestored: 400, + } + got, ok := parseRestoreStatus(line) + if !ok || !reflect.DeepEqual(got, want) { + t.Fatalf("parseRestoreStatus() = %#v, %v; want %#v, true", got, ok, want) + } + + if _, ok := parseRestoreStatus([]byte(`{"message_type":"summary"}`)); ok { + t.Fatal("expected summary message to be ignored") + } + if _, ok := parseRestoreStatus([]byte("not json")); ok { + t.Fatal("expected malformed restore output to be ignored") + } +} + +func TestSendRestoreStatusLatestWins(t *testing.T) { + updates := make(chan restoreStatus, 1) + sendRestoreStatus(updates, restoreStatus{Percent: 0.1}) + sendRestoreStatus(updates, restoreStatus{Percent: 0.9}) + if got := <-updates; got.Percent != 0.9 { + t.Fatalf("latest restore status = %#v, want 90%%", got) + } +} diff --git a/cmd/restree/restore.go b/cmd/restree/restore.go new file mode 100644 --- /dev/null +++ b/cmd/restree/restore.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/dustin/go-humanize" + qt "github.com/mappu/miqt/qt6" +) + +func (ui *appUI) updateRestoreAction() { + ui.restoreButton.SetEnabled(ui.cancelRestore == nil && ui.currentSnapID != "" && len(ui.selectedRestorePaths()) > 0) +} + +func (ui *appUI) selectedRestorePaths() []string { + items := ui.fileTree.SelectedItems() + paths := make([]string, 0, len(items)) + seen := make(map[string]bool) + for _, item := range items { + p := item.Text(fileColumnFullPath) + if p == "" || seen[p] { + continue + } + seen[p] = true + paths = append(paths, p) + } + return trimNestedPaths(paths) +} + +func (ui *appUI) restoreSelection() { + if ui.cancelRestore != nil { + ui.setStatus("Restore already in progress.") + return + } + + paths := ui.selectedRestorePaths() + if ui.currentSnapID == "" || len(paths) == 0 { + ui.setStatus("Select files or folders to restore.") + return + } + + baseDir := qt.QFileDialog_GetExistingDirectory3(ui.window.QWidget, "Choose restore destination", "") + if baseDir == "" { + return + } + + target := filepath.Join(baseDir, "restree-restore-"+time.Now().Format("2006-01-02-15-04-05")) + text := fmt.Sprintf("Restore %d selected item(s) into:\n%s", len(paths), target) + choice := qt.QMessageBox_Question6( + ui.window.QWidget, + "Restore selected items", + text, + qt.QMessageBox__Yes|qt.QMessageBox__Cancel, + qt.QMessageBox__Cancel, + ) + if choice != qt.QMessageBox__Yes { + return + } + + ui.restorePaths(ui.currentSnapID, target, paths) +} + +func (ui *appUI) restorePaths(snapshotID string, target string, paths []string) { + config := ui.resticConfig() + ctx, cancel := context.WithCancel(context.Background()) + ui.cancelRestore = cancel + ui.restoreButton.SetEnabled(false) + ui.updateProgress() + ui.setStatus(fmt.Sprintf("Restoring %d selected item(s)...", len(paths))) + + progress := make(chan restoreStatus, 1) + results := make(chan error, 1) + ui.startWorker(func() { + args := []string{"restore", snapshotID, "--target", target} + for _, p := range paths { + args = append(args, "--include", p) + } + results <- runResticRestore(ctx, config, progress, args...) + }) + + ui.pollRestore(progress, results, func(err error) { + cancel() + ui.cancelRestore = nil + ui.progressBar.SetRange(0, 0) + ui.updateProgress() + ui.updateRestoreAction() + if err != nil { + ui.setStatus(err.Error()) + return + } + ui.setStatus("Restore complete: " + target) + }) +} + +func (ui *appUI) pollRestore(progress <-chan restoreStatus, results <-chan error, finish func(error)) { + timer := qt.NewQTimer2(ui.window.QObject) + timer.OnTimeout(func() { + select { + case status := <-progress: + ui.showRestoreProgress(status) + default: + } + select { + case err := <-results: + timer.Stop() + timer.DeleteLater() + finish(err) + default: + } + }) + timer.Start(100) +} + +func (ui *appUI) showRestoreProgress(progress restoreStatus) { + percent := min(max(progress.Percent, 0), 1) + ui.progressBar.SetRange(0, 1000) + ui.progressBar.SetValue(int(percent * 1000)) + + var status string + switch { + case progress.TotalBytes > 0: + status = fmt.Sprintf("%d%% completed, %s of %s", + int(percent*100), + humanize.Bytes(progress.BytesRestored), + humanize.Bytes(progress.TotalBytes)) + case progress.TotalFiles > 0: + status = fmt.Sprintf("Restoring: %d / %d files", progress.FilesRestored, progress.TotalFiles) + default: + status = "Restoring..." + } + if progress.FilesSkipped > 0 { + status += fmt.Sprintf(", %d skipped", progress.FilesSkipped) + } + ui.setStatus(status) +} diff --git a/cmd/restree/tree.go b/cmd/restree/tree.go --- a/cmd/restree/tree.go +++ b/cmd/restree/tree.go @@ -23,7 +23,7 @@ timer.OnTimeout(func() { select { case result := <-results: timer.Stop() - timer.Delete() + timer.DeleteLater() handle(result) default: } diff --git a/justfile b/justfile --- a/justfile +++ b/justfile @@ -153,4 +153,4 @@ echo "Bumped to $new" grep -n "$new" {{pkg}}/main.go flake.nix packaging/windows/installer.nsi packaging/darwin/Info.plist clean: - rm -rf {{bin}} {{dist}} result + rm -rf {{bin}} {{dist}} result {{app}} {{app}}.exe -- tangled.sh