detached b703063

fix(review): tighten xdg and progress handling

commit
b703063bc0893da8fb42285719237b4693655baf
author
brice · · 2026-04-18 12:30 UTC
parent
b944d63
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +158 -59
M cmd/dumber/main.go +20 -17
@@ -774,23 +774,26 @@ func buildUIDependencies(
774774 Theme: themeManager,
775775 ColorResolver: colorResolver,
776776 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,
794797 FaviconAdapterConfig: adapter.FaviconAdapterConfig{
795798 IsInternalURL: favicon.IsInternalURL,
796799 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
22
33 import (
44 "os"
5+ "path/filepath"
56
67 "github.com/bnema/dumber/internal/infrastructure/config"
78 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
@@ -31,3 +32,16 @@ func ResolveRuntimeProfile(cfg *config.Config) (runtimeprofile.Profile, error) {
3132 },
3233 })
3334 }
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 (
55 "testing"
66
77 "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
89 "github.com/stretchr/testify/require"
910 )
1011
@@ -58,3 +59,26 @@ func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) {
5859 require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir())
5960 require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket)
6061 }
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 (
99 "github.com/spf13/cobra"
1010 "github.com/spf13/cobra/doc"
1111
12- "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1312 xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"
1413 )
1514
@@ -58,7 +57,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error {
5857 if outputDir == "" {
5958 switch genDocsFormat {
6059 case "man":
61- xdg := xdgadapter.New(runtimeprofile.Profile{})
60+ xdg := xdgadapter.New(false, "")
6261 manDir, err := xdg.ManDir()
6362 if err != nil {
6463 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 (
1313 "github.com/bnema/dumber/internal/cli/styles"
1414 "github.com/bnema/dumber/internal/infrastructure/desktop"
1515 "github.com/bnema/dumber/internal/infrastructure/filesystem"
16+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1617 xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"
1718 )
1819
@@ -52,7 +53,10 @@ func runPurge(_ *cobra.Command, _ []string) error {
5253 if err != nil {
5354 return fmt.Errorf("resolve runtime profile: %w", err)
5455 }
55- xdgAdapter := xdgadapter.New(profile)
56+ xdgAdapter := xdgadapter.New(
57+ profile.Mode == runtimeprofile.ModeDev,
58+ bootstrap.ResolveXDGRuntimeDir(profile),
59+ )
5660 desktopAdapter := desktop.New()
5761 purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter)
5862
@@ -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
1818 require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action")
1919 require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync")
2020 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")
2124 require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)")
2225 require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()")
2326 }
@@ -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 (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/infrastructure/config"
9- "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
109 )
1110
1211 // Adapter implements port.XDGPaths using config.GetXDGDirs().
1312 type Adapter struct {
14- mode runtimeprofile.Mode
13+ isDev bool
1514 runtimeDir string
1615 }
1716
1817 // 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}
3220 }
3321
3422 func (a *Adapter) ConfigDir() (string, error) {
@@ -44,7 +32,7 @@ func (a *Adapter) StateDir() (string, error) {
4432 }
4533
4634 func (a *Adapter) RuntimeDir() (string, error) {
47- if a == nil || a.mode != runtimeprofile.ModeDev {
35+ if a == nil || !a.isDev {
4836 if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
4937 return dir, nil
5038 }
@@ -53,7 +41,7 @@ func (a *Adapter) RuntimeDir() (string, error) {
5341 return a.runtimeDir, nil
5442 }
5543
56- stateDir, err := a.StateDir()
44+ stateDir, err := config.GetStateDir()
5745 if err != nil {
5846 return "", err
5947 }
@@ -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.Mode13+ isDev bool
15 runtimeDir string14 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, nil37 return dir, nil
50 }38 }
@@ -53,7 +41,7 @@ func (a *Adapter) RuntimeDir() (string, error) {
53 return a.runtimeDir, nil41 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 "", err46 return "", err
59 }47 }
M internal/infrastructure/xdg/adapter_test.go +5 -16
@@ -5,13 +5,12 @@ import (
55 "path/filepath"
66 "testing"
77
8- "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
98 "github.com/stretchr/testify/assert"
109 "github.com/stretchr/testify/require"
1110 )
1211
1312 func TestAdapter_DownloadDir(t *testing.T) {
14- adapter := New(runtimeprofile.Profile{})
13+ adapter := New(false, "")
1514
1615 t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
1716 t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
@@ -39,7 +38,7 @@ func TestAdapter_DownloadDir(t *testing.T) {
3938
4039 func TestAdapter_RuntimeDir(t *testing.T) {
4140 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, "")
4342 t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")
4443
4544 dir, err := adapter.RuntimeDir()
@@ -49,12 +48,7 @@ func TestAdapter_RuntimeDir(t *testing.T) {
4948 })
5049
5150 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")
5852 t.Setenv("XDG_RUNTIME_DIR", "")
5953
6054 dir, err := adapter.RuntimeDir()
@@ -65,12 +59,7 @@ func TestAdapter_RuntimeDir(t *testing.T) {
6559
6660 t.Run("uses injected sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) {
6761 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"))
7463 t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime")
7564
7665 dir, err := adapter.RuntimeDir()
@@ -81,7 +70,7 @@ func TestAdapter_RuntimeDir(t *testing.T) {
8170 }
8271
8372 func TestNew(t *testing.T) {
84- adapter := New(runtimeprofile.Profile{})
73+ adapter := New(false, "")
8574
8675 assert.NotNil(t, adapter)
8776 }
@@ -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) {
112112 pb.targetValue = progress
113113 pb.lastProgressAt = now
114114 pb.progressEventSeq++
115+ if pb.visible {
116+ pb.resetTimeout()
117+ }
115118
116119 // For large jumps (>0.3) or completion, update immediately
117120 diff := progress - pb.currentValue
@@ -204,7 +207,7 @@ func (pb *ProgressBar) Show() {
204207 pb.progressBar.SetVisible(true)
205208 }
206209
207- // Reset timeout timer on every Show call
210+ // Reset timeout timer on every Show call.
208211 pb.resetTimeout()
209212
210213 logging.FromContext(ctx).
@@ -218,18 +221,36 @@ func (pb *ProgressBar) Show() {
218221 // resetTimeout cancels any existing timeout and starts a new one.
219222 // Must be called with lock held.
220223 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.
222235 if pb.timeoutTimer != 0 {
223236 glib.SourceRemove(pb.timeoutTimer)
224237 pb.timeoutTimer = 0
225238 }
226239
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+
228248 cb := glib.SourceFunc(func(_ uintptr) bool {
229249 pb.mu.Lock()
230250 defer pb.mu.Unlock()
231251
232252 now := time.Now()
253+ remaining := pb.timeoutRemainingLocked(now)
233254 ctx := pb.ctx
234255 if ctx == nil {
235256 ctx = context.Background()
@@ -240,17 +261,23 @@ func (pb *ProgressBar) resetTimeout() {
240261 Float64("target_value", pb.targetValue).
241262 Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
242263 Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
264+ Int64("timeout_remaining_ms", remaining.Milliseconds()).
243265 Msg("progress bar timeout fired")
244266
245- // Clear timer ID (timer is being removed)
267+ // Clear timer ID (timer is being removed).
246268 pb.timeoutTimer = 0
247269
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.
249276 pb.hideInternal("timeout")
250277 return false // Don't repeat
251278 })
252279
253- pb.timeoutTimer = glib.TimeoutAdd(progressTimeoutMs, &cb, 0)
280+ pb.timeoutTimer = glib.TimeoutAdd(uint(timeoutMs), &cb, 0)
254281 }
255282
256283 // Hide makes the progress bar invisible and resets state.
@@ -305,10 +332,28 @@ func (pb *ProgressBar) hideInternal(reason string) {
305332 // Reset values
306333 pb.currentValue = 0
307334 pb.targetValue = 0
335+ pb.lastShowAt = time.Time{}
336+ pb.lastProgressAt = time.Time{}
308337 pb.progressBar.SetFraction(0)
309338 }
310339 }
311340
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+
312357 func sinceTimeMs(start, now time.Time) int64 {
313358 if start.IsZero() {
314359 return -1
@@ -112,6 +112,9 @@ func (pb *ProgressBar) SetProgress(progress float64) {
112 pb.targetValue = progress112 pb.targetValue = progress
113 pb.lastProgressAt = now113 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 immediately119 // For large jumps (>0.3) or completion, update immediately
117 diff := progress - pb.currentValue120 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 call210+ // 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 timeout224+ 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 = 0237 pb.timeoutTimer = 0
225 }238 }
226 239
227- // Start new timeout240+ 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.ctx254 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 = 0268 pb.timeoutTimer = 0
247 269
248- // Hide the progress bar inline to avoid race condition270+ 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 repeat277 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 values332 // Reset values
306 pb.currentValue = 0333 pb.currentValue = 0
307 pb.targetValue = 0334 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 -1359 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close