fix(review): tighten xdg and progress handling
- commit
- b703063bc0893da8fb42285719237b4693655baf
- author
- brice · · 2026-04-18 12:30 UTC
- parent
- b944d63
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +158 -59
M cmd/dumber/main.go +20 -17
| @@ -774,23 +774,26 @@ func buildUIDependencies( | |||
| 774 | 774 | Theme: themeManager, | |
| 775 | 775 | ColorResolver: colorResolver, | |
| 776 | 776 | AdwaitaDetector: adwaitaDetector, | |
| 777 | - | XDG: xdg.New(runtimeProfile), | |
| 778 | - | Engine: engine, | |
| 779 | - | FilterManager: filterManager, | |
| 780 | - | HistoryRepo: repos.history, | |
| 781 | - | FavoriteRepo: repos.favorite, | |
| 782 | - | ZoomRepo: repos.zoom, | |
| 783 | - | PermissionRepo: repos.permission, | |
| 784 | - | TabsUC: uc.tabs, | |
| 785 | - | PanesUC: uc.panes, | |
| 786 | - | HistoryUC: uc.history, | |
| 787 | - | FavoritesUC: uc.favorites, | |
| 788 | - | ZoomUC: uc.zoom, | |
| 789 | - | PermissionUC: uc.permission, | |
| 790 | - | NavigateUC: uc.navigate, | |
| 791 | - | CopyURLUC: uc.copyURL, | |
| 792 | - | Clipboard: uc.clipboard, | |
| 793 | - | FaviconService: uc.favicon, | |
| 777 | + | XDG: xdg.New( | |
| 778 | + | runtimeProfile.Mode == runtimeprofile.ModeDev, | |
| 779 | + | bootstrap.ResolveXDGRuntimeDir(runtimeProfile), | |
| 780 | + | ), | |
| 781 | + | Engine: engine, | |
| 782 | + | FilterManager: filterManager, | |
| 783 | + | HistoryRepo: repos.history, | |
| 784 | + | FavoriteRepo: repos.favorite, | |
| 785 | + | ZoomRepo: repos.zoom, | |
| 786 | + | PermissionRepo: repos.permission, | |
| 787 | + | TabsUC: uc.tabs, | |
| 788 | + | PanesUC: uc.panes, | |
| 789 | + | HistoryUC: uc.history, | |
| 790 | + | FavoritesUC: uc.favorites, | |
| 791 | + | ZoomUC: uc.zoom, | |
| 792 | + | PermissionUC: uc.permission, | |
| 793 | + | NavigateUC: uc.navigate, | |
| 794 | + | CopyURLUC: uc.copyURL, | |
| 795 | + | Clipboard: uc.clipboard, | |
| 796 | + | FaviconService: uc.favicon, | |
| 794 | 797 | FaviconAdapterConfig: adapter.FaviconAdapterConfig{ | |
| 795 | 798 | IsInternalURL: favicon.IsInternalURL, | |
| 796 | 799 | InternalDomain: favicon.InternalDomain, | |
| @@ -774,23 +774,26 @@ func buildUIDependencies( | |||||
| 774 | Theme: themeManager, | 774 | Theme: themeManager, | ||
| 775 | ColorResolver: colorResolver, | 775 | ColorResolver: colorResolver, | ||
| 776 | AdwaitaDetector: adwaitaDetector, | 776 | AdwaitaDetector: adwaitaDetector, | ||
| 777 | - | XDG: xdg.New(runtimeProfile), | 777 | + | XDG: xdg.New( |
| 778 | - | Engine: engine, | 778 | + | runtimeProfile.Mode == runtimeprofile.ModeDev, |
| 779 | - | FilterManager: filterManager, | 779 | + | bootstrap.ResolveXDGRuntimeDir(runtimeProfile), |
| 780 | - | HistoryRepo: repos.history, | 780 | + | ), |
| 781 | - | FavoriteRepo: repos.favorite, | 781 | + | Engine: engine, |
| 782 | - | ZoomRepo: repos.zoom, | 782 | + | FilterManager: filterManager, |
| 783 | - | PermissionRepo: repos.permission, | 783 | + | HistoryRepo: repos.history, |
| 784 | - | TabsUC: uc.tabs, | 784 | + | FavoriteRepo: repos.favorite, |
| 785 | - | PanesUC: uc.panes, | 785 | + | ZoomRepo: repos.zoom, |
| 786 | - | HistoryUC: uc.history, | 786 | + | PermissionRepo: repos.permission, |
| 787 | - | FavoritesUC: uc.favorites, | 787 | + | TabsUC: uc.tabs, |
| 788 | - | ZoomUC: uc.zoom, | 788 | + | PanesUC: uc.panes, |
| 789 | - | PermissionUC: uc.permission, | 789 | + | HistoryUC: uc.history, |
| 790 | - | NavigateUC: uc.navigate, | 790 | + | FavoritesUC: uc.favorites, |
| 791 | - | CopyURLUC: uc.copyURL, | 791 | + | ZoomUC: uc.zoom, |
| 792 | - | Clipboard: uc.clipboard, | 792 | + | PermissionUC: uc.permission, |
| 793 | - | FaviconService: uc.favicon, | 793 | + | NavigateUC: uc.navigate, |
| 794 | + | CopyURLUC: uc.copyURL, | |||
| 795 | + | Clipboard: uc.clipboard, | |||
| 796 | + | FaviconService: uc.favicon, | |||
| 794 | FaviconAdapterConfig: adapter.FaviconAdapterConfig{ | 797 | FaviconAdapterConfig: adapter.FaviconAdapterConfig{ | ||
| 795 | IsInternalURL: favicon.IsInternalURL, | 798 | IsInternalURL: favicon.IsInternalURL, | ||
| 796 | InternalDomain: favicon.InternalDomain, | 799 | InternalDomain: favicon.InternalDomain, | ||
M internal/bootstrap/runtime_profile.go +14 -0
| @@ -2,6 +2,7 @@ package bootstrap | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | + | "path/filepath" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | 8 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| @@ -31,3 +32,16 @@ func ResolveRuntimeProfile(cfg *config.Config) (runtimeprofile.Profile, error) { | |||
| 31 | 32 | }, | |
| 32 | 33 | }) | |
| 33 | 34 | } | |
| 35 | + | ||
| 36 | + | // ResolveXDGRuntimeDir returns the shared XDG runtime dir for the current mode. | |
| 37 | + | func ResolveXDGRuntimeDir(profile runtimeprofile.Profile) string { | |
| 38 | + | if profile.Mode == runtimeprofile.ModeDev { | |
| 39 | + | if profile.Shared.RootDir != "" { | |
| 40 | + | return filepath.Join(profile.Shared.RootDir, "runtime") | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | if profile.Shared.StateDir != "" { | |
| 44 | + | return filepath.Join(profile.Shared.StateDir, "runtime") | |
| 45 | + | } | |
| 46 | + | return "" | |
| 47 | + | } | |
| @@ -2,6 +2,7 @@ package bootstrap | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "os" | 4 | "os" | ||
| 5 | + | "path/filepath" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/dumber/internal/infrastructure/config" | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 7 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | 8 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | ||
| @@ -31,3 +32,16 @@ func ResolveRuntimeProfile(cfg *config.Config) (runtimeprofile.Profile, error) { | |||||
| 31 | }, | 32 | }, | ||
| 32 | }) | 33 | }) | ||
| 33 | } | 34 | } | ||
| 35 | + | ||||
| 36 | + | // ResolveXDGRuntimeDir returns the shared XDG runtime dir for the current mode. | |||
| 37 | + | func ResolveXDGRuntimeDir(profile runtimeprofile.Profile) string { | |||
| 38 | + | if profile.Mode == runtimeprofile.ModeDev { | |||
| 39 | + | if profile.Shared.RootDir != "" { | |||
| 40 | + | return filepath.Join(profile.Shared.RootDir, "runtime") | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | if profile.Shared.StateDir != "" { | |||
| 44 | + | return filepath.Join(profile.Shared.StateDir, "runtime") | |||
| 45 | + | } | |||
| 46 | + | return "" | |||
| 47 | + | } | |||
M internal/bootstrap/runtime_profile_test.go +24 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -58,3 +59,26 @@ func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) { | |||
| 58 | 59 | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir()) | |
| 59 | 60 | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket) | |
| 60 | 61 | } | |
| 62 | + | ||
| 63 | + | func TestResolveXDGRuntimeDir_UsesSharedRuntimeRootInDev(t *testing.T) { | |
| 64 | + | profile := runtimeprofile.Profile{ | |
| 65 | + | Mode: runtimeprofile.ModeDev, | |
| 66 | + | Shared: runtimeprofile.SharedPaths{ | |
| 67 | + | RootDir: "/tmp/project/.dev/dumber", | |
| 68 | + | StateDir: "/tmp/project/.dev/dumber/state", | |
| 69 | + | }, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | require.Equal(t, "/tmp/project/.dev/dumber/runtime", ResolveXDGRuntimeDir(profile)) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestResolveXDGRuntimeDir_UsesStateRuntimeRootInProd(t *testing.T) { | |
| 76 | + | profile := runtimeprofile.Profile{ | |
| 77 | + | Mode: runtimeprofile.ModeProd, | |
| 78 | + | Shared: runtimeprofile.SharedPaths{ | |
| 79 | + | StateDir: "/tmp/dumber-state/dumber", | |
| 80 | + | }, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | require.Equal(t, "/tmp/dumber-state/dumber/runtime", ResolveXDGRuntimeDir(profile)) | |
| 84 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/infrastructure/config" | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -58,3 +59,26 @@ func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) { | |||||
| 58 | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir()) | 59 | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir()) | ||
| 59 | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket) | 60 | require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket) | ||
| 60 | } | 61 | } | ||
| 62 | + | ||||
| 63 | + | func TestResolveXDGRuntimeDir_UsesSharedRuntimeRootInDev(t *testing.T) { | |||
| 64 | + | profile := runtimeprofile.Profile{ | |||
| 65 | + | Mode: runtimeprofile.ModeDev, | |||
| 66 | + | Shared: runtimeprofile.SharedPaths{ | |||
| 67 | + | RootDir: "/tmp/project/.dev/dumber", | |||
| 68 | + | StateDir: "/tmp/project/.dev/dumber/state", | |||
| 69 | + | }, | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | require.Equal(t, "/tmp/project/.dev/dumber/runtime", ResolveXDGRuntimeDir(profile)) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestResolveXDGRuntimeDir_UsesStateRuntimeRootInProd(t *testing.T) { | |||
| 76 | + | profile := runtimeprofile.Profile{ | |||
| 77 | + | Mode: runtimeprofile.ModeProd, | |||
| 78 | + | Shared: runtimeprofile.SharedPaths{ | |||
| 79 | + | StateDir: "/tmp/dumber-state/dumber", | |||
| 80 | + | }, | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | require.Equal(t, "/tmp/dumber-state/dumber/runtime", ResolveXDGRuntimeDir(profile)) | |||
| 84 | + | } | |||
M internal/cli/cmd/gen_docs.go +1 -2
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | "github.com/spf13/cobra" | |
| 10 | 10 | "github.com/spf13/cobra/doc" | |
| 11 | 11 | ||
| 12 | - | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 13 | 12 | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| @@ -58,7 +57,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error { | |||
| 58 | 57 | if outputDir == "" { | |
| 59 | 58 | switch genDocsFormat { | |
| 60 | 59 | case "man": | |
| 61 | - | xdg := xdgadapter.New(runtimeprofile.Profile{}) | |
| 60 | + | xdg := xdgadapter.New(false, "") | |
| 62 | 61 | manDir, err := xdg.ManDir() | |
| 63 | 62 | if err != nil { | |
| 64 | 63 | return fmt.Errorf("resolve man directory: %w", err) | |
| @@ -9,7 +9,6 @@ import ( | |||||
| 9 | "github.com/spf13/cobra" | 9 | "github.com/spf13/cobra" | ||
| 10 | "github.com/spf13/cobra/doc" | 10 | "github.com/spf13/cobra/doc" | ||
| 11 | 11 | ||||
| 12 | - | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 13 | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | 12 | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | ||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| @@ -58,7 +57,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error { | |||||
| 58 | if outputDir == "" { | 57 | if outputDir == "" { | ||
| 59 | switch genDocsFormat { | 58 | switch genDocsFormat { | ||
| 60 | case "man": | 59 | case "man": | ||
| 61 | - | xdg := xdgadapter.New(runtimeprofile.Profile{}) | 60 | + | xdg := xdgadapter.New(false, "") |
| 62 | manDir, err := xdg.ManDir() | 61 | manDir, err := xdg.ManDir() | ||
| 63 | if err != nil { | 62 | if err != nil { | ||
| 64 | return fmt.Errorf("resolve man directory: %w", err) | 63 | return fmt.Errorf("resolve man directory: %w", err) | ||
M internal/cli/cmd/purge.go +5 -1
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/cli/styles" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 16 | 17 | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -52,7 +53,10 @@ func runPurge(_ *cobra.Command, _ []string) error { | |||
| 52 | 53 | if err != nil { | |
| 53 | 54 | return fmt.Errorf("resolve runtime profile: %w", err) | |
| 54 | 55 | } | |
| 55 | - | xdgAdapter := xdgadapter.New(profile) | |
| 56 | + | xdgAdapter := xdgadapter.New( | |
| 57 | + | profile.Mode == runtimeprofile.ModeDev, | |
| 58 | + | bootstrap.ResolveXDGRuntimeDir(profile), | |
| 59 | + | ) | |
| 56 | 60 | desktopAdapter := desktop.New() | |
| 57 | 61 | purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter) | |
| 58 | 62 | ||
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/cli/styles" | 13 | "github.com/bnema/dumber/internal/cli/styles" | ||
| 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | 15 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | ||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 16 | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | 17 | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | ||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -52,7 +53,10 @@ func runPurge(_ *cobra.Command, _ []string) error { | |||||
| 52 | if err != nil { | 53 | if err != nil { | ||
| 53 | return fmt.Errorf("resolve runtime profile: %w", err) | 54 | return fmt.Errorf("resolve runtime profile: %w", err) | ||
| 54 | } | 55 | } | ||
| 55 | - | xdgAdapter := xdgadapter.New(profile) | 56 | + | xdgAdapter := xdgadapter.New( |
| 57 | + | profile.Mode == runtimeprofile.ModeDev, | |||
| 58 | + | bootstrap.ResolveXDGRuntimeDir(profile), | |||
| 59 | + | ) | |||
| 56 | desktopAdapter := desktop.New() | 60 | desktopAdapter := desktop.New() | ||
| 57 | purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter) | 61 | purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter) | ||
| 58 | 62 | ||||
M internal/infrastructure/cef/content_injector_test.go +3 -0
| @@ -18,6 +18,9 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin | |||
| 18 | 18 | require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action") | |
| 19 | 19 | require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync") | |
| 20 | 20 | require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin") | |
| 21 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "event.isTrusted === false") | |
| 22 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "event && event.target") | |
| 23 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "document.activeElement") | |
| 21 | 24 | require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)") | |
| 22 | 25 | require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()") | |
| 23 | 26 | } | |
| @@ -18,6 +18,9 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin | |||||
| 18 | require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action") | 18 | require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action") | ||
| 19 | require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync") | 19 | require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync") | ||
| 20 | require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin") | 20 | require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin") | ||
| 21 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "event.isTrusted === false") | |||
| 22 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "event && event.target") | |||
| 23 | + | require.Contains(t, clipboardSelectionFetchBridgeJS, "document.activeElement") | |||
| 21 | require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)") | 24 | require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)") | ||
| 22 | require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()") | 25 | require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()") | ||
| 23 | } | 26 | } | ||
M internal/infrastructure/xdg/adapter.go +5 -17
| @@ -6,29 +6,17 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 11 | // Adapter implements port.XDGPaths using config.GetXDGDirs(). | |
| 13 | 12 | type Adapter struct { | |
| 14 | - | mode runtimeprofile.Mode | |
| 13 | + | isDev bool | |
| 15 | 14 | runtimeDir string | |
| 16 | 15 | } | |
| 17 | 16 | ||
| 18 | 17 | // New creates a new XDG paths adapter. | |
| 19 | - | func New(profile runtimeprofile.Profile) *Adapter { | |
| 20 | - | runtimeDir := "" | |
| 21 | - | switch profile.Mode { | |
| 22 | - | case runtimeprofile.ModeDev: | |
| 23 | - | if profile.Shared.RootDir != "" { | |
| 24 | - | runtimeDir = filepath.Join(profile.Shared.RootDir, "runtime") | |
| 25 | - | } | |
| 26 | - | case runtimeprofile.ModeProd: | |
| 27 | - | if profile.Shared.StateDir != "" { | |
| 28 | - | runtimeDir = filepath.Join(profile.Shared.StateDir, "runtime") | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | return &Adapter{mode: profile.Mode, runtimeDir: runtimeDir} | |
| 18 | + | func New(isDev bool, runtimeDir string) *Adapter { | |
| 19 | + | return &Adapter{isDev: isDev, runtimeDir: runtimeDir} | |
| 32 | 20 | } | |
| 33 | 21 | ||
| 34 | 22 | func (a *Adapter) ConfigDir() (string, error) { | |
| @@ -44,7 +32,7 @@ func (a *Adapter) StateDir() (string, error) { | |||
| 44 | 32 | } | |
| 45 | 33 | ||
| 46 | 34 | func (a *Adapter) RuntimeDir() (string, error) { | |
| 47 | - | if a == nil || a.mode != runtimeprofile.ModeDev { | |
| 35 | + | if a == nil || !a.isDev { | |
| 48 | 36 | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | |
| 49 | 37 | return dir, nil | |
| 50 | 38 | } | |
| @@ -53,7 +41,7 @@ func (a *Adapter) RuntimeDir() (string, error) { | |||
| 53 | 41 | return a.runtimeDir, nil | |
| 54 | 42 | } | |
| 55 | 43 | ||
| 56 | - | stateDir, err := a.StateDir() | |
| 44 | + | stateDir, err := config.GetStateDir() | |
| 57 | 45 | if err != nil { | |
| 58 | 46 | return "", err | |
| 59 | 47 | } | |
| @@ -6,29 +6,17 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 10 | ) | 9 | ) | ||
| 11 | 10 | ||||
| 12 | // Adapter implements port.XDGPaths using config.GetXDGDirs(). | 11 | // Adapter implements port.XDGPaths using config.GetXDGDirs(). | ||
| 13 | type Adapter struct { | 12 | type Adapter struct { | ||
| 14 | - | mode runtimeprofile.Mode | 13 | + | isDev bool |
| 15 | runtimeDir string | 14 | runtimeDir string | ||
| 16 | } | 15 | } | ||
| 17 | 16 | ||||
| 18 | // New creates a new XDG paths adapter. | 17 | // New creates a new XDG paths adapter. | ||
| 19 | - | func New(profile runtimeprofile.Profile) *Adapter { | 18 | + | func New(isDev bool, runtimeDir string) *Adapter { |
| 20 | - | runtimeDir := "" | 19 | + | return &Adapter{isDev: isDev, runtimeDir: runtimeDir} |
| 21 | - | switch profile.Mode { | |||
| 22 | - | case runtimeprofile.ModeDev: | |||
| 23 | - | if profile.Shared.RootDir != "" { | |||
| 24 | - | runtimeDir = filepath.Join(profile.Shared.RootDir, "runtime") | |||
| 25 | - | } | |||
| 26 | - | case runtimeprofile.ModeProd: | |||
| 27 | - | if profile.Shared.StateDir != "" { | |||
| 28 | - | runtimeDir = filepath.Join(profile.Shared.StateDir, "runtime") | |||
| 29 | - | } | |||
| 30 | - | } | |||
| 31 | - | return &Adapter{mode: profile.Mode, runtimeDir: runtimeDir} | |||
| 32 | } | 20 | } | ||
| 33 | 21 | ||||
| 34 | func (a *Adapter) ConfigDir() (string, error) { | 22 | func (a *Adapter) ConfigDir() (string, error) { | ||
| @@ -44,7 +32,7 @@ func (a *Adapter) StateDir() (string, error) { | |||||
| 44 | } | 32 | } | ||
| 45 | 33 | ||||
| 46 | func (a *Adapter) RuntimeDir() (string, error) { | 34 | func (a *Adapter) RuntimeDir() (string, error) { | ||
| 47 | - | if a == nil || a.mode != runtimeprofile.ModeDev { | 35 | + | if a == nil || !a.isDev { |
| 48 | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | 36 | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | ||
| 49 | return dir, nil | 37 | return dir, nil | ||
| 50 | } | 38 | } | ||
| @@ -53,7 +41,7 @@ func (a *Adapter) RuntimeDir() (string, error) { | |||||
| 53 | return a.runtimeDir, nil | 41 | return a.runtimeDir, nil | ||
| 54 | } | 42 | } | ||
| 55 | 43 | ||||
| 56 | - | stateDir, err := a.StateDir() | 44 | + | stateDir, err := config.GetStateDir() |
| 57 | if err != nil { | 45 | if err != nil { | ||
| 58 | return "", err | 46 | return "", err | ||
| 59 | } | 47 | } | ||
M internal/infrastructure/xdg/adapter_test.go +5 -16
| @@ -5,13 +5,12 @@ import ( | |||
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 9 | 8 | "github.com/stretchr/testify/assert" | |
| 10 | 9 | "github.com/stretchr/testify/require" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| 13 | 12 | func TestAdapter_DownloadDir(t *testing.T) { | |
| 14 | - | adapter := New(runtimeprofile.Profile{}) | |
| 13 | + | adapter := New(false, "") | |
| 15 | 14 | ||
| 16 | 15 | t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) { | |
| 17 | 16 | t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads") | |
| @@ -39,7 +38,7 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||
| 39 | 38 | ||
| 40 | 39 | func TestAdapter_RuntimeDir(t *testing.T) { | |
| 41 | 40 | t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) { | |
| 42 | - | adapter := New(runtimeprofile.Profile{Mode: runtimeprofile.ModeProd}) | |
| 41 | + | adapter := New(false, "") | |
| 43 | 42 | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | |
| 44 | 43 | ||
| 45 | 44 | dir, err := adapter.RuntimeDir() | |
| @@ -49,12 +48,7 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||
| 49 | 48 | }) | |
| 50 | 49 | ||
| 51 | 50 | t.Run("falls back to injected prod runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) { | |
| 52 | - | adapter := New(runtimeprofile.Profile{ | |
| 53 | - | Mode: runtimeprofile.ModeProd, | |
| 54 | - | Shared: runtimeprofile.SharedPaths{ | |
| 55 | - | StateDir: "/tmp/dumber-state/dumber", | |
| 56 | - | }, | |
| 57 | - | }) | |
| 51 | + | adapter := New(false, "/tmp/dumber-state/dumber/runtime") | |
| 58 | 52 | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 59 | 53 | ||
| 60 | 54 | dir, err := adapter.RuntimeDir() | |
| @@ -65,12 +59,7 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||
| 65 | 59 | ||
| 66 | 60 | t.Run("uses injected sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) { | |
| 67 | 61 | wd := t.TempDir() | |
| 68 | - | adapter := New(runtimeprofile.Profile{ | |
| 69 | - | Mode: runtimeprofile.ModeDev, | |
| 70 | - | Shared: runtimeprofile.SharedPaths{ | |
| 71 | - | RootDir: filepath.Join(wd, ".dev", "dumber"), | |
| 72 | - | }, | |
| 73 | - | }) | |
| 62 | + | adapter := New(true, filepath.Join(wd, ".dev", "dumber", "runtime")) | |
| 74 | 63 | t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime") | |
| 75 | 64 | ||
| 76 | 65 | dir, err := adapter.RuntimeDir() | |
| @@ -81,7 +70,7 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||
| 81 | 70 | } | |
| 82 | 71 | ||
| 83 | 72 | func TestNew(t *testing.T) { | |
| 84 | - | adapter := New(runtimeprofile.Profile{}) | |
| 73 | + | adapter := New(false, "") | |
| 85 | 74 | ||
| 86 | 75 | assert.NotNil(t, adapter) | |
| 87 | 76 | } | |
| @@ -5,13 +5,12 @@ import ( | |||||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | - | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 9 | "github.com/stretchr/testify/assert" | 8 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 10 | ) | ||
| 12 | 11 | ||||
| 13 | func TestAdapter_DownloadDir(t *testing.T) { | 12 | func TestAdapter_DownloadDir(t *testing.T) { | ||
| 14 | - | adapter := New(runtimeprofile.Profile{}) | 13 | + | adapter := New(false, "") |
| 15 | 14 | ||||
| 16 | t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) { | 15 | t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) { | ||
| 17 | t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads") | 16 | t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads") | ||
| @@ -39,7 +38,7 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||||
| 39 | 38 | ||||
| 40 | func TestAdapter_RuntimeDir(t *testing.T) { | 39 | func TestAdapter_RuntimeDir(t *testing.T) { | ||
| 41 | t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) { | 40 | t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) { | ||
| 42 | - | adapter := New(runtimeprofile.Profile{Mode: runtimeprofile.ModeProd}) | 41 | + | adapter := New(false, "") |
| 43 | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | 42 | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | ||
| 44 | 43 | ||||
| 45 | dir, err := adapter.RuntimeDir() | 44 | dir, err := adapter.RuntimeDir() | ||
| @@ -49,12 +48,7 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||||
| 49 | }) | 48 | }) | ||
| 50 | 49 | ||||
| 51 | t.Run("falls back to injected prod runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) { | 50 | t.Run("falls back to injected prod runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) { | ||
| 52 | - | adapter := New(runtimeprofile.Profile{ | 51 | + | adapter := New(false, "/tmp/dumber-state/dumber/runtime") |
| 53 | - | Mode: runtimeprofile.ModeProd, | |||
| 54 | - | Shared: runtimeprofile.SharedPaths{ | |||
| 55 | - | StateDir: "/tmp/dumber-state/dumber", | |||
| 56 | - | }, | |||
| 57 | - | }) | |||
| 58 | t.Setenv("XDG_RUNTIME_DIR", "") | 52 | t.Setenv("XDG_RUNTIME_DIR", "") | ||
| 59 | 53 | ||||
| 60 | dir, err := adapter.RuntimeDir() | 54 | dir, err := adapter.RuntimeDir() | ||
| @@ -65,12 +59,7 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||||
| 65 | 59 | ||||
| 66 | t.Run("uses injected sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) { | 60 | t.Run("uses injected sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) { | ||
| 67 | wd := t.TempDir() | 61 | wd := t.TempDir() | ||
| 68 | - | adapter := New(runtimeprofile.Profile{ | 62 | + | adapter := New(true, filepath.Join(wd, ".dev", "dumber", "runtime")) |
| 69 | - | Mode: runtimeprofile.ModeDev, | |||
| 70 | - | Shared: runtimeprofile.SharedPaths{ | |||
| 71 | - | RootDir: filepath.Join(wd, ".dev", "dumber"), | |||
| 72 | - | }, | |||
| 73 | - | }) | |||
| 74 | t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime") | 63 | t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime") | ||
| 75 | 64 | ||||
| 76 | dir, err := adapter.RuntimeDir() | 65 | dir, err := adapter.RuntimeDir() | ||
| @@ -81,7 +70,7 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||||
| 81 | } | 70 | } | ||
| 82 | 71 | ||||
| 83 | func TestNew(t *testing.T) { | 72 | func TestNew(t *testing.T) { | ||
| 84 | - | adapter := New(runtimeprofile.Profile{}) | 73 | + | adapter := New(false, "") |
| 85 | 74 | ||||
| 86 | assert.NotNil(t, adapter) | 75 | assert.NotNil(t, adapter) | ||
| 87 | } | 76 | } | ||
M internal/ui/component/progress_bar.go +51 -6
| @@ -112,6 +112,9 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||
| 112 | 112 | pb.targetValue = progress | |
| 113 | 113 | pb.lastProgressAt = now | |
| 114 | 114 | pb.progressEventSeq++ | |
| 115 | + | if pb.visible { | |
| 116 | + | pb.resetTimeout() | |
| 117 | + | } | |
| 115 | 118 | ||
| 116 | 119 | // For large jumps (>0.3) or completion, update immediately | |
| 117 | 120 | diff := progress - pb.currentValue | |
| @@ -204,7 +207,7 @@ func (pb *ProgressBar) Show() { | |||
| 204 | 207 | pb.progressBar.SetVisible(true) | |
| 205 | 208 | } | |
| 206 | 209 | ||
| 207 | - | // Reset timeout timer on every Show call | |
| 210 | + | // Reset timeout timer on every Show call. | |
| 208 | 211 | pb.resetTimeout() | |
| 209 | 212 | ||
| 210 | 213 | logging.FromContext(ctx). | |
| @@ -218,18 +221,36 @@ func (pb *ProgressBar) Show() { | |||
| 218 | 221 | // resetTimeout cancels any existing timeout and starts a new one. | |
| 219 | 222 | // Must be called with lock held. | |
| 220 | 223 | func (pb *ProgressBar) resetTimeout() { | |
| 221 | - | // Cancel existing timeout | |
| 224 | + | pb.resetTimeoutAfter(time.Duration(progressTimeoutMs) * time.Millisecond) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // resetTimeoutAfter cancels any existing timeout and starts a new one after | |
| 228 | + | // the requested delay. Must be called with lock held. | |
| 229 | + | func (pb *ProgressBar) resetTimeoutAfter(delay time.Duration) { | |
| 230 | + | if delay <= 0 { | |
| 231 | + | delay = time.Millisecond | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // Cancel existing timeout. | |
| 222 | 235 | if pb.timeoutTimer != 0 { | |
| 223 | 236 | glib.SourceRemove(pb.timeoutTimer) | |
| 224 | 237 | pb.timeoutTimer = 0 | |
| 225 | 238 | } | |
| 226 | 239 | ||
| 227 | - | // Start new timeout | |
| 240 | + | timeoutMs := int(delay / time.Millisecond) | |
| 241 | + | if delay%time.Millisecond != 0 { | |
| 242 | + | timeoutMs++ | |
| 243 | + | } | |
| 244 | + | if timeoutMs < 1 { | |
| 245 | + | timeoutMs = 1 | |
| 246 | + | } | |
| 247 | + | ||
| 228 | 248 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 229 | 249 | pb.mu.Lock() | |
| 230 | 250 | defer pb.mu.Unlock() | |
| 231 | 251 | ||
| 232 | 252 | now := time.Now() | |
| 253 | + | remaining := pb.timeoutRemainingLocked(now) | |
| 233 | 254 | ctx := pb.ctx | |
| 234 | 255 | if ctx == nil { | |
| 235 | 256 | ctx = context.Background() | |
| @@ -240,17 +261,23 @@ func (pb *ProgressBar) resetTimeout() { | |||
| 240 | 261 | Float64("target_value", pb.targetValue). | |
| 241 | 262 | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |
| 242 | 263 | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |
| 264 | + | Int64("timeout_remaining_ms", remaining.Milliseconds()). | |
| 243 | 265 | Msg("progress bar timeout fired") | |
| 244 | 266 | ||
| 245 | - | // Clear timer ID (timer is being removed) | |
| 267 | + | // Clear timer ID (timer is being removed). | |
| 246 | 268 | pb.timeoutTimer = 0 | |
| 247 | 269 | ||
| 248 | - | // Hide the progress bar inline to avoid race condition | |
| 270 | + | if remaining > 0 { | |
| 271 | + | pb.resetTimeoutAfter(remaining) | |
| 272 | + | return false | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Hide the progress bar inline to avoid race condition. | |
| 249 | 276 | pb.hideInternal("timeout") | |
| 250 | 277 | return false // Don't repeat | |
| 251 | 278 | }) | |
| 252 | 279 | ||
| 253 | - | pb.timeoutTimer = glib.TimeoutAdd(progressTimeoutMs, &cb, 0) | |
| 280 | + | pb.timeoutTimer = glib.TimeoutAdd(uint(timeoutMs), &cb, 0) | |
| 254 | 281 | } | |
| 255 | 282 | ||
| 256 | 283 | // Hide makes the progress bar invisible and resets state. | |
| @@ -305,10 +332,28 @@ func (pb *ProgressBar) hideInternal(reason string) { | |||
| 305 | 332 | // Reset values | |
| 306 | 333 | pb.currentValue = 0 | |
| 307 | 334 | pb.targetValue = 0 | |
| 335 | + | pb.lastShowAt = time.Time{} | |
| 336 | + | pb.lastProgressAt = time.Time{} | |
| 308 | 337 | pb.progressBar.SetFraction(0) | |
| 309 | 338 | } | |
| 310 | 339 | } | |
| 311 | 340 | ||
| 341 | + | func (pb *ProgressBar) timeoutRemainingLocked(now time.Time) time.Duration { | |
| 342 | + | lastActivityAt := pb.lastShowAt | |
| 343 | + | if pb.lastProgressAt.After(lastActivityAt) { | |
| 344 | + | lastActivityAt = pb.lastProgressAt | |
| 345 | + | } | |
| 346 | + | if lastActivityAt.IsZero() { | |
| 347 | + | return 0 | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | remaining := time.Duration(progressTimeoutMs)*time.Millisecond - now.Sub(lastActivityAt) | |
| 351 | + | if remaining < 0 { | |
| 352 | + | return 0 | |
| 353 | + | } | |
| 354 | + | return remaining | |
| 355 | + | } | |
| 356 | + | ||
| 312 | 357 | func sinceTimeMs(start, now time.Time) int64 { | |
| 313 | 358 | if start.IsZero() { | |
| 314 | 359 | return -1 | |
| @@ -112,6 +112,9 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||||
| 112 | pb.targetValue = progress | 112 | pb.targetValue = progress | ||
| 113 | pb.lastProgressAt = now | 113 | pb.lastProgressAt = now | ||
| 114 | pb.progressEventSeq++ | 114 | pb.progressEventSeq++ | ||
| 115 | + | if pb.visible { | |||
| 116 | + | pb.resetTimeout() | |||
| 117 | + | } | |||
| 115 | 118 | ||||
| 116 | // For large jumps (>0.3) or completion, update immediately | 119 | // For large jumps (>0.3) or completion, update immediately | ||
| 117 | diff := progress - pb.currentValue | 120 | diff := progress - pb.currentValue | ||
| @@ -204,7 +207,7 @@ func (pb *ProgressBar) Show() { | |||||
| 204 | pb.progressBar.SetVisible(true) | 207 | pb.progressBar.SetVisible(true) | ||
| 205 | } | 208 | } | ||
| 206 | 209 | ||||
| 207 | - | // Reset timeout timer on every Show call | 210 | + | // Reset timeout timer on every Show call. |
| 208 | pb.resetTimeout() | 211 | pb.resetTimeout() | ||
| 209 | 212 | ||||
| 210 | logging.FromContext(ctx). | 213 | logging.FromContext(ctx). | ||
| @@ -218,18 +221,36 @@ func (pb *ProgressBar) Show() { | |||||
| 218 | // resetTimeout cancels any existing timeout and starts a new one. | 221 | // resetTimeout cancels any existing timeout and starts a new one. | ||
| 219 | // Must be called with lock held. | 222 | // Must be called with lock held. | ||
| 220 | func (pb *ProgressBar) resetTimeout() { | 223 | func (pb *ProgressBar) resetTimeout() { | ||
| 221 | - | // Cancel existing timeout | 224 | + | pb.resetTimeoutAfter(time.Duration(progressTimeoutMs) * time.Millisecond) |
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // resetTimeoutAfter cancels any existing timeout and starts a new one after | |||
| 228 | + | // the requested delay. Must be called with lock held. | |||
| 229 | + | func (pb *ProgressBar) resetTimeoutAfter(delay time.Duration) { | |||
| 230 | + | if delay <= 0 { | |||
| 231 | + | delay = time.Millisecond | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // Cancel existing timeout. | |||
| 222 | if pb.timeoutTimer != 0 { | 235 | if pb.timeoutTimer != 0 { | ||
| 223 | glib.SourceRemove(pb.timeoutTimer) | 236 | glib.SourceRemove(pb.timeoutTimer) | ||
| 224 | pb.timeoutTimer = 0 | 237 | pb.timeoutTimer = 0 | ||
| 225 | } | 238 | } | ||
| 226 | 239 | ||||
| 227 | - | // Start new timeout | 240 | + | timeoutMs := int(delay / time.Millisecond) |
| 241 | + | if delay%time.Millisecond != 0 { | |||
| 242 | + | timeoutMs++ | |||
| 243 | + | } | |||
| 244 | + | if timeoutMs < 1 { | |||
| 245 | + | timeoutMs = 1 | |||
| 246 | + | } | |||
| 247 | + | ||||
| 228 | cb := glib.SourceFunc(func(_ uintptr) bool { | 248 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 229 | pb.mu.Lock() | 249 | pb.mu.Lock() | ||
| 230 | defer pb.mu.Unlock() | 250 | defer pb.mu.Unlock() | ||
| 231 | 251 | ||||
| 232 | now := time.Now() | 252 | now := time.Now() | ||
| 253 | + | remaining := pb.timeoutRemainingLocked(now) | |||
| 233 | ctx := pb.ctx | 254 | ctx := pb.ctx | ||
| 234 | if ctx == nil { | 255 | if ctx == nil { | ||
| 235 | ctx = context.Background() | 256 | ctx = context.Background() | ||
| @@ -240,17 +261,23 @@ func (pb *ProgressBar) resetTimeout() { | |||||
| 240 | Float64("target_value", pb.targetValue). | 261 | Float64("target_value", pb.targetValue). | ||
| 241 | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | 262 | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | ||
| 242 | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | 263 | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | ||
| 264 | + | Int64("timeout_remaining_ms", remaining.Milliseconds()). | |||
| 243 | Msg("progress bar timeout fired") | 265 | Msg("progress bar timeout fired") | ||
| 244 | 266 | ||||
| 245 | - | // Clear timer ID (timer is being removed) | 267 | + | // Clear timer ID (timer is being removed). |
| 246 | pb.timeoutTimer = 0 | 268 | pb.timeoutTimer = 0 | ||
| 247 | 269 | ||||
| 248 | - | // Hide the progress bar inline to avoid race condition | 270 | + | if remaining > 0 { |
| 271 | + | pb.resetTimeoutAfter(remaining) | |||
| 272 | + | return false | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Hide the progress bar inline to avoid race condition. | |||
| 249 | pb.hideInternal("timeout") | 276 | pb.hideInternal("timeout") | ||
| 250 | return false // Don't repeat | 277 | return false // Don't repeat | ||
| 251 | }) | 278 | }) | ||
| 252 | 279 | ||||
| 253 | - | pb.timeoutTimer = glib.TimeoutAdd(progressTimeoutMs, &cb, 0) | 280 | + | pb.timeoutTimer = glib.TimeoutAdd(uint(timeoutMs), &cb, 0) |
| 254 | } | 281 | } | ||
| 255 | 282 | ||||
| 256 | // Hide makes the progress bar invisible and resets state. | 283 | // Hide makes the progress bar invisible and resets state. | ||
| @@ -305,10 +332,28 @@ func (pb *ProgressBar) hideInternal(reason string) { | |||||
| 305 | // Reset values | 332 | // Reset values | ||
| 306 | pb.currentValue = 0 | 333 | pb.currentValue = 0 | ||
| 307 | pb.targetValue = 0 | 334 | pb.targetValue = 0 | ||
| 335 | + | pb.lastShowAt = time.Time{} | |||
| 336 | + | pb.lastProgressAt = time.Time{} | |||
| 308 | pb.progressBar.SetFraction(0) | 337 | pb.progressBar.SetFraction(0) | ||
| 309 | } | 338 | } | ||
| 310 | } | 339 | } | ||
| 311 | 340 | ||||
| 341 | + | func (pb *ProgressBar) timeoutRemainingLocked(now time.Time) time.Duration { | |||
| 342 | + | lastActivityAt := pb.lastShowAt | |||
| 343 | + | if pb.lastProgressAt.After(lastActivityAt) { | |||
| 344 | + | lastActivityAt = pb.lastProgressAt | |||
| 345 | + | } | |||
| 346 | + | if lastActivityAt.IsZero() { | |||
| 347 | + | return 0 | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | remaining := time.Duration(progressTimeoutMs)*time.Millisecond - now.Sub(lastActivityAt) | |||
| 351 | + | if remaining < 0 { | |||
| 352 | + | return 0 | |||
| 353 | + | } | |||
| 354 | + | return remaining | |||
| 355 | + | } | |||
| 356 | + | ||||
| 312 | func sinceTimeMs(start, now time.Time) int64 { | 357 | func sinceTimeMs(start, now time.Time) int64 { | ||
| 313 | if start.IsZero() { | 358 | if start.IsZero() { | ||
| 314 | return -1 | 359 | return -1 | ||
A internal/ui/component/progress_bar_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestProgressBarTimeoutRemainingLocked_UsesLatestProgressEvent(t *testing.T) { | |
| 11 | + | showAt := time.Unix(0, 0) | |
| 12 | + | progressAt := showAt.Add(20 * time.Second) | |
| 13 | + | pb := &ProgressBar{ | |
| 14 | + | lastShowAt: showAt, | |
| 15 | + | lastProgressAt: progressAt, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(25 * time.Second)) | |
| 19 | + | ||
| 20 | + | require.Equal(t, 25*time.Second, remaining) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestProgressBarTimeoutRemainingLocked_UsesShowTimeBeforeFirstProgress(t *testing.T) { | |
| 24 | + | showAt := time.Unix(0, 0) | |
| 25 | + | pb := &ProgressBar{lastShowAt: showAt} | |
| 26 | + | ||
| 27 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(31 * time.Second)) | |
| 28 | + | ||
| 29 | + | require.Zero(t, remaining) | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestProgressBarTimeoutRemainingLocked_UsesLatestProgressEvent(t *testing.T) { | |||
| 11 | + | showAt := time.Unix(0, 0) | |||
| 12 | + | progressAt := showAt.Add(20 * time.Second) | |||
| 13 | + | pb := &ProgressBar{ | |||
| 14 | + | lastShowAt: showAt, | |||
| 15 | + | lastProgressAt: progressAt, | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(25 * time.Second)) | |||
| 19 | + | ||||
| 20 | + | require.Equal(t, 25*time.Second, remaining) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestProgressBarTimeoutRemainingLocked_UsesShowTimeBeforeFirstProgress(t *testing.T) { | |||
| 24 | + | showAt := time.Unix(0, 0) | |||
| 25 | + | pb := &ProgressBar{lastShowAt: showAt} | |||
| 26 | + | ||||
| 27 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(31 * time.Second)) | |||
| 28 | + | ||||
| 29 | + | require.Zero(t, remaining) | |||
| 30 | + | } | |||