detached 622e14a

fix(cef): stabilize local navigation behavior

Restore the legacy CEF profile path and avoid premature load completion so existing sessions survive and cross-site navigations keep visible progress. Disable transcoding by default for local testing and switch PipeWire to the released module version.
commit
622e14a2acb97cefacd65a947932a42c89cf1ca6
author
brice · · 2026-04-11 10:03 UTC
parent
02f4a19
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +224 -73
M go.mod +1 -3
@@ -5,7 +5,7 @@ go 1.26.1
55 require (
66 github.com/bnema/purego-cef v0.8.0
77 github.com/bnema/purego-ffmpeg v0.3.0
8- github.com/bnema/purego-pipewire v0.0.0-00010101000000-000000000000
8+ github.com/bnema/purego-pipewire v0.1.1
99 github.com/bnema/purego-sqlite v0.1.1
1010 github.com/bnema/puregotk v0.3.0
1111 github.com/bnema/puregotk-webkit v0.0.16
@@ -31,8 +31,6 @@ replace github.com/bnema/purego-cef => ../purego-cef
3131
3232 replace github.com/bnema/purego-ffmpeg => ../purego-ffmpeg
3333
34-replace github.com/bnema/purego-pipewire => ../../../purego-pipewire/.worktrees/dumber-cef-audio
35-
3634 require (
3735 github.com/atotto/clipboard v0.1.4 // indirect
3836 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -5,7 +5,7 @@ go 1.26.1
5 require (5 require (
6 github.com/bnema/purego-cef v0.8.06 github.com/bnema/purego-cef v0.8.0
7 github.com/bnema/purego-ffmpeg v0.3.07 github.com/bnema/purego-ffmpeg v0.3.0
8- github.com/bnema/purego-pipewire v0.0.0-00010101000000-0000000000008+ github.com/bnema/purego-pipewire v0.1.1
9 github.com/bnema/purego-sqlite v0.1.19 github.com/bnema/purego-sqlite v0.1.1
10 github.com/bnema/puregotk v0.3.010 github.com/bnema/puregotk v0.3.0
11 github.com/bnema/puregotk-webkit v0.0.1611 github.com/bnema/puregotk-webkit v0.0.16
@@ -31,8 +31,6 @@ replace github.com/bnema/purego-cef => ../purego-cef
31 31
32 replace github.com/bnema/purego-ffmpeg => ../purego-ffmpeg32 replace github.com/bnema/purego-ffmpeg => ../purego-ffmpeg
33 33
34-replace github.com/bnema/purego-pipewire => ../../../purego-pipewire/.worktrees/dumber-cef-audio
35-
36 require (34 require (
37 github.com/atotto/clipboard v0.1.4 // indirect35 github.com/atotto/clipboard v0.1.4 // indirect
38 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect36 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
M go.sum +2 -0 generated file: collapsed
M internal/infrastructure/cef/engine_init.go +4 -1
@@ -105,7 +105,10 @@ func defaultCEFUserDataDir() string {
105105 // Fallback to HOME-based path if XDG resolution fails
106106 return filepath.Clean(filepath.Join(os.Getenv("HOME"), ".config", "cef_user_data"))
107107 }
108- return filepath.Join(dirs.ConfigHome, "cef_user_data")
108+ if os.Getenv("ENV") == "dev" {
109+ return filepath.Join(dirs.ConfigHome, "cef_user_data")
110+ }
111+ return filepath.Join(filepath.Dir(dirs.ConfigHome), "cef_user_data")
109112 }
110113
111114 // initializeCEF calls cef_initialize with the App to register custom schemes
@@ -105,7 +105,10 @@ func defaultCEFUserDataDir() string {
105 // Fallback to HOME-based path if XDG resolution fails105 // Fallback to HOME-based path if XDG resolution fails
106 return filepath.Clean(filepath.Join(os.Getenv("HOME"), ".config", "cef_user_data"))106 return filepath.Clean(filepath.Join(os.Getenv("HOME"), ".config", "cef_user_data"))
107 }107 }
108- return filepath.Join(dirs.ConfigHome, "cef_user_data")108+ if os.Getenv("ENV") == "dev" {
109+ return filepath.Join(dirs.ConfigHome, "cef_user_data")
110+ }
111+ return filepath.Join(filepath.Dir(dirs.ConfigHome), "cef_user_data")
109 }112 }
110 113
111 // initializeCEF calls cef_initialize with the App to register custom schemes114 // initializeCEF calls cef_initialize with the App to register custom schemes
M internal/infrastructure/cef/engine_init_test.go +2 -4
@@ -31,8 +31,7 @@ func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) {
3131 require.FileExists(t, path)
3232 }
3333
34-func TestPrepareCEFSettings_SetsRootCachePath(t *testing.T) {
35- // Test production mode: should use XDG config home
34+func TestPrepareCEFSettings_RootCachePath_UsesLegacyCEFUserDataDir(t *testing.T) {
3635 homeDir := filepath.Join(t.TempDir(), "home")
3736 t.Setenv("HOME", homeDir)
3837 t.Setenv("ENV", "")
@@ -40,8 +39,7 @@ func TestPrepareCEFSettings_SetsRootCachePath(t *testing.T) {
4039 logger := zerolog.Nop()
4140 settings := prepareCEFSettings(config.CEFEngineConfig{}, &logger)
4241
43- // Should use XDG config home (~/.config/dumber/cef_user_data in prod)
44- require.Equal(t, filepath.Join(homeDir, ".config", "dumber", "cef_user_data"), settings.RootCachePath)
42+ require.Equal(t, filepath.Join(homeDir, ".config", "cef_user_data"), settings.RootCachePath)
4543 }
4644
4745 func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) {
@@ -31,8 +31,7 @@ func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) {
31 require.FileExists(t, path)31 require.FileExists(t, path)
32 }32 }
33 33
34-func TestPrepareCEFSettings_SetsRootCachePath(t *testing.T) {34+func TestPrepareCEFSettings_RootCachePath_UsesLegacyCEFUserDataDir(t *testing.T) {
35- // Test production mode: should use XDG config home
36 homeDir := filepath.Join(t.TempDir(), "home")35 homeDir := filepath.Join(t.TempDir(), "home")
37 t.Setenv("HOME", homeDir)36 t.Setenv("HOME", homeDir)
38 t.Setenv("ENV", "")37 t.Setenv("ENV", "")
@@ -40,8 +39,7 @@ func TestPrepareCEFSettings_SetsRootCachePath(t *testing.T) {
40 logger := zerolog.Nop()39 logger := zerolog.Nop()
41 settings := prepareCEFSettings(config.CEFEngineConfig{}, &logger)40 settings := prepareCEFSettings(config.CEFEngineConfig{}, &logger)
42 41
43- // Should use XDG config home (~/.config/dumber/cef_user_data in prod)42+ require.Equal(t, filepath.Join(homeDir, ".config", "cef_user_data"), settings.RootCachePath)
44- require.Equal(t, filepath.Join(homeDir, ".config", "dumber", "cef_user_data"), settings.RootCachePath)
45 }43 }
46 44
47 func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) {45 func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) {
M internal/infrastructure/cef/webview_handlers.go +10 -16
@@ -411,7 +411,7 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
411411 }
412412 }
413413
414-// OnLoadEnd fires LoadFinished and sets progress to 1.0 for the main frame.
414+// OnLoadEnd resets crash count and injects content scripts for the main frame.
415415 func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
416416 log := logging.FromContext(h.wv.ctx)
417417 log.Debug().
@@ -434,21 +434,15 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
434434 })
435435 }
436436
437- h.wv.mu.RLock()
438- cb := h.wv.callbacks
439- h.wv.mu.RUnlock()
440- if cb != nil {
441- if cb.OnLoadChanged != nil {
442- h.wv.runOnGTK(func() {
443- cb.OnLoadChanged(port.LoadFinished)
444- })
445- }
446- if cb.OnProgressChanged != nil {
447- h.wv.runOnGTK(func() {
448- cb.OnProgressChanged(1.0)
449- })
450- }
451- }
437+ // NOTE: We intentionally do NOT dispatch LoadFinished or ProgressChanged(1.0)
438+ // here. OnLoadEnd is a per-frame event, and during cross-site navigations
439+ // (process swap), CEF fires OnLoadEnd for the OLD main frame before the new
440+ // page finishes loading. Dispatching LoadFinished here caused the progress
441+ // bar to hide prematurely while the new page was still loading.
442+ //
443+ // OnLoadingStateChange(isLoading=false) is the correct browser-level signal
444+ // for load completion — CEF guarantees it fires AFTER all OnLoadEnd calls.
445+ // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
452446 }
453447
454448 // OnLoadError is a no-op.
@@ -411,7 +411,7 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
411 }411 }
412 }412 }
413 413
414-// OnLoadEnd fires LoadFinished and sets progress to 1.0 for the main frame.414+// OnLoadEnd resets crash count and injects content scripts for the main frame.
415 func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {415 func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
416 log := logging.FromContext(h.wv.ctx)416 log := logging.FromContext(h.wv.ctx)
417 log.Debug().417 log.Debug().
@@ -434,21 +434,15 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
434 })434 })
435 }435 }
436 436
437- h.wv.mu.RLock()437+ // NOTE: We intentionally do NOT dispatch LoadFinished or ProgressChanged(1.0)
438- cb := h.wv.callbacks438+ // here. OnLoadEnd is a per-frame event, and during cross-site navigations
439- h.wv.mu.RUnlock()439+ // (process swap), CEF fires OnLoadEnd for the OLD main frame before the new
440- if cb != nil {440+ // page finishes loading. Dispatching LoadFinished here caused the progress
441- if cb.OnLoadChanged != nil {441+ // bar to hide prematurely while the new page was still loading.
442- h.wv.runOnGTK(func() {442+ //
443- cb.OnLoadChanged(port.LoadFinished)443+ // OnLoadingStateChange(isLoading=false) is the correct browser-level signal
444- })444+ // for load completion — CEF guarantees it fires AFTER all OnLoadEnd calls.
445- }445+ // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
446- if cb.OnProgressChanged != nil {
447- h.wv.runOnGTK(func() {
448- cb.OnProgressChanged(1.0)
449- })
450- }
451- }
452 }446 }
453 447
454 // OnLoadError is a no-op.448 // OnLoadError is a no-op.
M internal/infrastructure/cef/webview_handlers_test.go +21 -0
@@ -5,6 +5,7 @@ import (
55 "testing"
66
77 purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/assert"
89 "github.com/stretchr/testify/require"
910
1011 "github.com/bnema/dumber/internal/application/port"
@@ -68,6 +69,26 @@ func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
6869 require.Equal(t, "https://google.com", wv.URI())
6970 }
7071
72+func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
73+ wv := &WebView{ctx: context.Background()}
74+ var gotEvents []port.LoadEvent
75+ var gotProgress []float64
76+ wv.SetCallbacks(&port.WebViewCallbacks{
77+ OnLoadChanged: func(event port.LoadEvent) {
78+ gotEvents = append(gotEvents, event)
79+ },
80+ OnProgressChanged: func(progress float64) {
81+ gotProgress = append(gotProgress, progress)
82+ },
83+ })
84+
85+ h := &handlerSet{wv: wv}
86+ h.OnLoadEnd(nil, stubFrame{main: true, url: "https://reddit.com"}, 200)
87+
88+ assert.Empty(t, gotEvents)
89+ assert.Empty(t, gotProgress)
90+}
91+
7192 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
7293 rect := &purecef.Rect{}
7394 h := &handlerSet{
@@ -5,6 +5,7 @@ import (
5 "testing"5 "testing"
6 6
7 purecef "github.com/bnema/purego-cef/cef"7 purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 10
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
@@ -68,6 +69,26 @@ func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
68 require.Equal(t, "https://google.com", wv.URI())69 require.Equal(t, "https://google.com", wv.URI())
69 }70 }
70 71
72+func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
73+ wv := &WebView{ctx: context.Background()}
74+ var gotEvents []port.LoadEvent
75+ var gotProgress []float64
76+ wv.SetCallbacks(&port.WebViewCallbacks{
77+ OnLoadChanged: func(event port.LoadEvent) {
78+ gotEvents = append(gotEvents, event)
79+ },
80+ OnProgressChanged: func(progress float64) {
81+ gotProgress = append(gotProgress, progress)
82+ },
83+ })
84+
85+ h := &handlerSet{wv: wv}
86+ h.OnLoadEnd(nil, stubFrame{main: true, url: "https://reddit.com"}, 200)
87+
88+ assert.Empty(t, gotEvents)
89+ assert.Empty(t, gotProgress)
90+}
91+
71 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {92 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
72 rect := &purecef.Rect{}93 rect := &purecef.Rect{}
73 h := &handlerSet{94 h := &handlerSet{
M internal/infrastructure/config/defaults.go +1 -1
@@ -70,7 +70,7 @@ const (
7070 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
7171
7272 // Transcoding defaults
73- defaultTranscodingEnabled = true
73+ defaultTranscodingEnabled = false
7474 defaultTranscodingHWAccel = "auto"
7575 defaultTranscodingMaxConcurrent = 3
7676 defaultTranscodingQuality = "medium"
@@ -70,7 +70,7 @@ const (
70 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF70 defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
71 71
72 // Transcoding defaults72 // Transcoding defaults
73- defaultTranscodingEnabled = true73+ defaultTranscodingEnabled = false
74 defaultTranscodingHWAccel = "auto"74 defaultTranscodingHWAccel = "auto"
75 defaultTranscodingMaxConcurrent = 375 defaultTranscodingMaxConcurrent = 3
76 defaultTranscodingQuality = "medium"76 defaultTranscodingQuality = "medium"
M internal/infrastructure/config/defaults_test.go +1 -1
@@ -11,7 +11,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
1111 assert.Equal(t, "info", cfg.Logging.Level)
1212 assert.False(t, cfg.Logging.CaptureGTKLogs)
1313 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
14- assert.True(t, cfg.Transcoding.Enabled)
14+ assert.False(t, cfg.Transcoding.Enabled)
1515 assert.Equal(t, "auto", cfg.Transcoding.HWAccel)
1616 assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)
1717 assert.Equal(t, "medium", cfg.Transcoding.Quality)
@@ -11,7 +11,7 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
11 assert.Equal(t, "info", cfg.Logging.Level)11 assert.Equal(t, "info", cfg.Logging.Level)
12 assert.False(t, cfg.Logging.CaptureGTKLogs)12 assert.False(t, cfg.Logging.CaptureGTKLogs)
13 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)13 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
14- assert.True(t, cfg.Transcoding.Enabled)14+ assert.False(t, cfg.Transcoding.Enabled)
15 assert.Equal(t, "auto", cfg.Transcoding.HWAccel)15 assert.Equal(t, "auto", cfg.Transcoding.HWAccel)
16 assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)16 assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)
17 assert.Equal(t, "medium", cfg.Transcoding.Quality)17 assert.Equal(t, "medium", cfg.Transcoding.Quality)
M internal/infrastructure/config/loader_test.go +2 -2
@@ -28,7 +28,7 @@ func TestSetEngineDefaults(t *testing.T) {
2828 assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer"))
2929
3030 // Transcoding defaults
31- assert.True(t, mgr.viper.GetBool("transcoding.enabled"))
31+ assert.False(t, mgr.viper.GetBool("transcoding.enabled"))
3232 assert.Equal(t, "auto", mgr.viper.GetString("transcoding.hwaccel"))
3333 assert.Equal(t, 3, mgr.viper.GetInt("transcoding.max_concurrent"))
3434 assert.Equal(t, "medium", mgr.viper.GetString("transcoding.quality"))
@@ -74,7 +74,7 @@ type = "cef"
7474
7575 cfg := mgr.Get()
7676 require.NotNil(t, cfg)
77- assert.True(t, cfg.Transcoding.Enabled)
77+ assert.False(t, cfg.Transcoding.Enabled)
7878 assert.Equal(t, "auto", cfg.Transcoding.HWAccel)
7979 assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)
8080 assert.Equal(t, "medium", cfg.Transcoding.Quality)
@@ -28,7 +28,7 @@ func TestSetEngineDefaults(t *testing.T) {
28 assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer"))28 assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer"))
29 29
30 // Transcoding defaults30 // Transcoding defaults
31- assert.True(t, mgr.viper.GetBool("transcoding.enabled"))31+ assert.False(t, mgr.viper.GetBool("transcoding.enabled"))
32 assert.Equal(t, "auto", mgr.viper.GetString("transcoding.hwaccel"))32 assert.Equal(t, "auto", mgr.viper.GetString("transcoding.hwaccel"))
33 assert.Equal(t, 3, mgr.viper.GetInt("transcoding.max_concurrent"))33 assert.Equal(t, 3, mgr.viper.GetInt("transcoding.max_concurrent"))
34 assert.Equal(t, "medium", mgr.viper.GetString("transcoding.quality"))34 assert.Equal(t, "medium", mgr.viper.GetString("transcoding.quality"))
@@ -74,7 +74,7 @@ type = "cef"
74 74
75 cfg := mgr.Get()75 cfg := mgr.Get()
76 require.NotNil(t, cfg)76 require.NotNil(t, cfg)
77- assert.True(t, cfg.Transcoding.Enabled)77+ assert.False(t, cfg.Transcoding.Enabled)
78 assert.Equal(t, "auto", cfg.Transcoding.HWAccel)78 assert.Equal(t, "auto", cfg.Transcoding.HWAccel)
79 assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)79 assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent)
80 assert.Equal(t, "medium", cfg.Transcoding.Quality)80 assert.Equal(t, "medium", cfg.Transcoding.Quality)
M internal/infrastructure/config/schema.go +1 -1
@@ -369,7 +369,7 @@ type DownloadsConfig struct {
369369 // When enabled, proprietary video formats (H.264, HEVC) are transcoded
370370 // to open codecs (VP9, AV1) that CEF can decode natively.
371371 type TranscodingConfig struct {
372- // Enabled controls whether transcoding is active (default: true).
372+ // Enabled controls whether transcoding is active (default: false).
373373 Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"`
374374 // HWAccel selects the hardware acceleration API: "auto", "vaapi", or "nvenc".
375375 // Default: "auto" (tries VAAPI first, then CUDA).
@@ -369,7 +369,7 @@ type DownloadsConfig struct {
369 // When enabled, proprietary video formats (H.264, HEVC) are transcoded369 // When enabled, proprietary video formats (H.264, HEVC) are transcoded
370 // to open codecs (VP9, AV1) that CEF can decode natively.370 // to open codecs (VP9, AV1) that CEF can decode natively.
371 type TranscodingConfig struct {371 type TranscodingConfig struct {
372- // Enabled controls whether transcoding is active (default: true).372+ // Enabled controls whether transcoding is active (default: false).
373 Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"`373 Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"`
374 // HWAccel selects the hardware acceleration API: "auto", "vaapi", or "nvenc".374 // HWAccel selects the hardware acceleration API: "auto", "vaapi", or "nvenc".
375 // Default: "auto" (tries VAAPI first, then CUDA).375 // Default: "auto" (tries VAAPI first, then CUDA).
M internal/infrastructure/config/writer_test.go +3 -3
@@ -47,7 +47,7 @@ func TestWriteConfigOrdered(t *testing.T) {
4747 if !strings.Contains(string(content), "[transcoding]") {
4848 t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))
4949 }
50- // Match "enabled = true" specifically within the [transcoding] section to
50+ // Match "enabled = false" specifically within the [transcoding] section to
5151 // avoid false positives from other sections that may also have an enabled key.
5252 transcodingIdx := bytes.Index(content, []byte("[transcoding]"))
5353 if transcodingIdx < 0 {
@@ -61,8 +61,8 @@ func TestWriteConfigOrdered(t *testing.T) {
6161 } else {
6262 transcodingBlock = afterTranscoding
6363 }
64- if !bytes.Contains(transcodingBlock, []byte("enabled = true")) {
65- t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", string(transcodingBlock))
64+ if !bytes.Contains(transcodingBlock, []byte("enabled = false")) {
65+ t.Fatalf("expected enabled = false in [transcoding] section, got:\n%s", string(transcodingBlock))
6666 }
6767
6868 t.Logf("Found %d sections, all sorted", len(sections))
@@ -47,7 +47,7 @@ func TestWriteConfigOrdered(t *testing.T) {
47 if !strings.Contains(string(content), "[transcoding]") {47 if !strings.Contains(string(content), "[transcoding]") {
48 t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))48 t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content))
49 }49 }
50- // Match "enabled = true" specifically within the [transcoding] section to50+ // Match "enabled = false" specifically within the [transcoding] section to
51 // avoid false positives from other sections that may also have an enabled key.51 // avoid false positives from other sections that may also have an enabled key.
52 transcodingIdx := bytes.Index(content, []byte("[transcoding]"))52 transcodingIdx := bytes.Index(content, []byte("[transcoding]"))
53 if transcodingIdx < 0 {53 if transcodingIdx < 0 {
@@ -61,8 +61,8 @@ func TestWriteConfigOrdered(t *testing.T) {
61 } else {61 } else {
62 transcodingBlock = afterTranscoding62 transcodingBlock = afterTranscoding
63 }63 }
64- if !bytes.Contains(transcodingBlock, []byte("enabled = true")) {64+ if !bytes.Contains(transcodingBlock, []byte("enabled = false")) {
65- t.Fatalf("expected enabled = true in [transcoding] section, got:\n%s", string(transcodingBlock))65+ t.Fatalf("expected enabled = false in [transcoding] section, got:\n%s", string(transcodingBlock))
66 }66 }
67 67
68 t.Logf("Found %d sections, all sorted", len(sections))68 t.Logf("Found %d sections, all sorted", len(sections))
M internal/ui/app.go +1 -1
@@ -2579,7 +2579,7 @@ func (a *App) ensureFloatingSession(
25792579 // regular workspace panes. This allows the content coordinator's
25802580 // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
25812581 // and update the floating pane automatically.
2582- pv := component.NewPaneView(a.widgetFactory, paneID, webViewWidget)
2582+ pv := component.NewPaneView(ctx, a.widgetFactory, paneID, webViewWidget)
25832583 pv.SetActive(true)
25842584 // Hide loading skeleton — floating panes have their own themed container.
25852585 pv.HideLoadingSkeleton()
@@ -2579,7 +2579,7 @@ func (a *App) ensureFloatingSession(
2579 // regular workspace panes. This allows the content coordinator's2579 // regular workspace panes. This allows the content coordinator's
2580 // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find2580 // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
2581 // and update the floating pane automatically.2581 // and update the floating pane automatically.
2582- pv := component.NewPaneView(a.widgetFactory, paneID, webViewWidget)2582+ pv := component.NewPaneView(ctx, a.widgetFactory, paneID, webViewWidget)
2583 pv.SetActive(true)2583 pv.SetActive(true)
2584 // Hide loading skeleton — floating panes have their own themed container.2584 // Hide loading skeleton — floating panes have their own themed container.
2585 pv.HideLoadingSkeleton()2585 pv.HideLoadingSkeleton()
M internal/ui/component/pane_view.go +34 -2
@@ -6,6 +6,7 @@ import (
66 "sync"
77
88 "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
910 "github.com/bnema/dumber/internal/ui/input"
1011 "github.com/bnema/dumber/internal/ui/layout"
1112 )
@@ -18,6 +19,7 @@ const (
1819 // PaneView is a container for a single WebView with active state indication.
1920 // It uses an overlay to display a border around the active pane.
2021 type PaneView struct {
22+ ctx context.Context
2123 factory layout.WidgetFactory
2224 overlay layout.OverlayWidget
2325 webViewWidget layout.Widget // The actual WebView widget
@@ -40,7 +42,7 @@ type PaneView struct {
4042 }
4143
4244 // NewPaneView creates a new pane view container for a WebView widget.
43-func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
45+func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
4446 overlay := factory.NewOverlay()
4547 overlay.SetHexpand(true)
4648 overlay.SetVexpand(true)
@@ -81,6 +83,7 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg
8183 // issues with the internal progress gizmo before the overlay is realized
8284
8385 return &PaneView{
86+ ctx: ctx,
8487 factory: factory,
8588 overlay: overlay,
8689 webViewWidget: webViewWidget,
@@ -344,7 +347,16 @@ func (pv *PaneView) ensureProgressBar() *ProgressBar {
344347 return pv.progressBar
345348 }
346349
347- pb := NewProgressBar(pv.factory)
350+ ctx := pv.ctx
351+ if ctx == nil {
352+ ctx = context.Background()
353+ }
354+ logging.FromContext(ctx).
355+ Debug().
356+ Str("pane_id", string(pv.paneID)).
357+ Msg("creating progress bar")
358+
359+ pb := NewProgressBar(pv.ctx, pv.factory)
348360 pv.overlay.AddOverlay(pb.Widget())
349361 pv.overlay.SetClipOverlay(pb.Widget(), false)
350362 pv.overlay.SetMeasureOverlay(pb.Widget(), false)
@@ -357,6 +369,15 @@ func (pv *PaneView) ensureProgressBar() *ProgressBar {
357369 func (pv *PaneView) SetLoadProgress(progress float64) {
358370 pv.mu.Lock()
359371 pb := pv.ensureProgressBar()
372+ ctx := pv.ctx
373+ if ctx == nil {
374+ ctx = context.Background()
375+ }
376+ logging.FromContext(ctx).
377+ Debug().
378+ Str("pane_id", string(pv.paneID)).
379+ Float64("progress", progress).
380+ Msg("setting load progress")
360381 pv.mu.Unlock()
361382
362383 pb.SetProgress(progress)
@@ -365,7 +386,18 @@ func (pv *PaneView) SetLoadProgress(progress float64) {
365386 // SetLoading shows or hides the progress bar.
366387 func (pv *PaneView) SetLoading(loading bool) {
367388 pv.mu.Lock()
389+ hadProgressBar := pv.progressBar != nil
368390 pb := pv.ensureProgressBar()
391+ ctx := pv.ctx
392+ if ctx == nil {
393+ ctx = context.Background()
394+ }
395+ logging.FromContext(ctx).
396+ Debug().
397+ Str("pane_id", string(pv.paneID)).
398+ Bool("loading", loading).
399+ Bool("progress_bar_existed", hadProgressBar).
400+ Msg("setting loading state")
369401 pv.mu.Unlock()
370402
371403 if loading {
@@ -6,6 +6,7 @@ import (
6 "sync"6 "sync"
7 7
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/dumber/internal/ui/input"10 "github.com/bnema/dumber/internal/ui/input"
10 "github.com/bnema/dumber/internal/ui/layout"11 "github.com/bnema/dumber/internal/ui/layout"
11 )12 )
@@ -18,6 +19,7 @@ const (
18 // PaneView is a container for a single WebView with active state indication.19 // PaneView is a container for a single WebView with active state indication.
19 // It uses an overlay to display a border around the active pane.20 // It uses an overlay to display a border around the active pane.
20 type PaneView struct {21 type PaneView struct {
22+ ctx context.Context
21 factory layout.WidgetFactory23 factory layout.WidgetFactory
22 overlay layout.OverlayWidget24 overlay layout.OverlayWidget
23 webViewWidget layout.Widget // The actual WebView widget25 webViewWidget layout.Widget // The actual WebView widget
@@ -40,7 +42,7 @@ type PaneView struct {
40 }42 }
41 43
42 // NewPaneView creates a new pane view container for a WebView widget.44 // NewPaneView creates a new pane view container for a WebView widget.
43-func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {45+func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
44 overlay := factory.NewOverlay()46 overlay := factory.NewOverlay()
45 overlay.SetHexpand(true)47 overlay.SetHexpand(true)
46 overlay.SetVexpand(true)48 overlay.SetVexpand(true)
@@ -81,6 +83,7 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg
81 // issues with the internal progress gizmo before the overlay is realized83 // issues with the internal progress gizmo before the overlay is realized
82 84
83 return &PaneView{85 return &PaneView{
86+ ctx: ctx,
84 factory: factory,87 factory: factory,
85 overlay: overlay,88 overlay: overlay,
86 webViewWidget: webViewWidget,89 webViewWidget: webViewWidget,
@@ -344,7 +347,16 @@ func (pv *PaneView) ensureProgressBar() *ProgressBar {
344 return pv.progressBar347 return pv.progressBar
345 }348 }
346 349
347- pb := NewProgressBar(pv.factory)350+ ctx := pv.ctx
351+ if ctx == nil {
352+ ctx = context.Background()
353+ }
354+ logging.FromContext(ctx).
355+ Debug().
356+ Str("pane_id", string(pv.paneID)).
357+ Msg("creating progress bar")
358+
359+ pb := NewProgressBar(pv.ctx, pv.factory)
348 pv.overlay.AddOverlay(pb.Widget())360 pv.overlay.AddOverlay(pb.Widget())
349 pv.overlay.SetClipOverlay(pb.Widget(), false)361 pv.overlay.SetClipOverlay(pb.Widget(), false)
350 pv.overlay.SetMeasureOverlay(pb.Widget(), false)362 pv.overlay.SetMeasureOverlay(pb.Widget(), false)
@@ -357,6 +369,15 @@ func (pv *PaneView) ensureProgressBar() *ProgressBar {
357 func (pv *PaneView) SetLoadProgress(progress float64) {369 func (pv *PaneView) SetLoadProgress(progress float64) {
358 pv.mu.Lock()370 pv.mu.Lock()
359 pb := pv.ensureProgressBar()371 pb := pv.ensureProgressBar()
372+ ctx := pv.ctx
373+ if ctx == nil {
374+ ctx = context.Background()
375+ }
376+ logging.FromContext(ctx).
377+ Debug().
378+ Str("pane_id", string(pv.paneID)).
379+ Float64("progress", progress).
380+ Msg("setting load progress")
360 pv.mu.Unlock()381 pv.mu.Unlock()
361 382
362 pb.SetProgress(progress)383 pb.SetProgress(progress)
@@ -365,7 +386,18 @@ func (pv *PaneView) SetLoadProgress(progress float64) {
365 // SetLoading shows or hides the progress bar.386 // SetLoading shows or hides the progress bar.
366 func (pv *PaneView) SetLoading(loading bool) {387 func (pv *PaneView) SetLoading(loading bool) {
367 pv.mu.Lock()388 pv.mu.Lock()
389+ hadProgressBar := pv.progressBar != nil
368 pb := pv.ensureProgressBar()390 pb := pv.ensureProgressBar()
391+ ctx := pv.ctx
392+ if ctx == nil {
393+ ctx = context.Background()
394+ }
395+ logging.FromContext(ctx).
396+ Debug().
397+ Str("pane_id", string(pv.paneID)).
398+ Bool("loading", loading).
399+ Bool("progress_bar_existed", hadProgressBar).
400+ Msg("setting loading state")
369 pv.mu.Unlock()401 pv.mu.Unlock()
370 402
371 if loading {403 if loading {
M internal/ui/component/pane_view_test.go +24 -23
@@ -1,6 +1,7 @@
11 package component_test
22
33 import (
4+ "context"
45 "testing"
56
67 "github.com/stretchr/testify/assert"
@@ -47,7 +48,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
4748 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
4849
4950 // Act
50- pv := component.NewPaneView(mockFactory, paneID, mockWebView)
51+ pv := component.NewPaneView(context.Background(), mockFactory, paneID, mockWebView)
5152
5253 // Assert
5354 require.NotNil(t, pv)
@@ -88,7 +89,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
8889 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
8990
9091 // Act
91- pv := component.NewPaneView(mockFactory, paneID, nil)
92+ pv := component.NewPaneView(context.Background(), mockFactory, paneID, nil)
9293
9394 // Assert
9495 require.NotNil(t, pv)
@@ -104,7 +105,7 @@ func TestSetActive_True_AddsCSSClass(t *testing.T) {
104105
105106 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
106107
107- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
108+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
108109
109110 // Expect CSS class to be added
110111 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
@@ -125,7 +126,7 @@ func TestSetActive_False_RemovesCSSClass(t *testing.T) {
125126
126127 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
127128
128- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
129+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
129130
130131 // First activate
131132 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
@@ -150,7 +151,7 @@ func TestSetActive_NoChangeWhenSameState(t *testing.T) {
150151
151152 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
152153
153- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
154+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
154155
155156 // Act - setting false when already false should not call RemoveCssClass
156157 pv.SetActive(false)
@@ -169,7 +170,7 @@ func TestPaneID_ReturnsPaneID(t *testing.T) {
169170 paneID := entity.PaneID("test-pane-123")
170171 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
171172
172- pv := component.NewPaneView(mockFactory, paneID, mockWebView)
173+ pv := component.NewPaneView(context.Background(), mockFactory, paneID, mockWebView)
173174
174175 // Act
175176 result := pv.PaneID()
@@ -187,7 +188,7 @@ func TestWebViewWidget_ReturnsWebView(t *testing.T) {
187188
188189 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
189190
190- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
191+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
191192
192193 // Act
193194 result := pv.WebViewWidget()
@@ -206,7 +207,7 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
206207
207208 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
208209
209- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockOldWebView)
210+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockOldWebView)
210211
211212 // Expect removal of old widget and addition of new
212213 mockOverlay.EXPECT().SetChild(nil).Once()
@@ -229,7 +230,7 @@ func TestSetWebViewWidget_FromNil(t *testing.T) {
229230
230231 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
231232
232- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
233+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
233234
234235 // Expect only setting new child (no removal since old was nil)
235236 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
@@ -251,7 +252,7 @@ func TestGrabFocus_DelegatesToWebView(t *testing.T) {
251252
252253 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
253254
254- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
255+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
255256
256257 mockWebView.EXPECT().GrabFocus().Return(true).Once()
257258
@@ -270,7 +271,7 @@ func TestGrabFocus_NilWebView_ReturnsFalse(t *testing.T) {
270271
271272 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
272273
273- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
274+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
274275
275276 // Act
276277 result := pv.GrabFocus()
@@ -288,7 +289,7 @@ func TestHasFocus_DelegatesToWebView(t *testing.T) {
288289
289290 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
290291
291- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
292+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
292293
293294 mockWebView.EXPECT().HasFocus().Return(true).Once()
294295
@@ -307,7 +308,7 @@ func TestHasFocus_NilWebView_ReturnsFalse(t *testing.T) {
307308
308309 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
309310
310- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
311+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
311312
312313 // Act
313314 result := pv.HasFocus()
@@ -325,7 +326,7 @@ func TestWidget_ReturnsOverlay(t *testing.T) {
325326
326327 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
327328
328- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
329+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
329330
330331 // Act
331332 result := pv.Widget()
@@ -343,7 +344,7 @@ func TestOverlay_ReturnsOverlayWidget(t *testing.T) {
343344
344345 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
345346
346- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
347+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
347348
348349 // Act
349350 result := pv.Overlay()
@@ -361,7 +362,7 @@ func TestShow_DelegatesToOverlay(t *testing.T) {
361362
362363 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
363364
364- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
365+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
365366
366367 mockOverlay.EXPECT().Show().Once()
367368
@@ -380,7 +381,7 @@ func TestHide_DelegatesToOverlay(t *testing.T) {
380381
381382 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
382383
383- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
384+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
384385
385386 mockOverlay.EXPECT().Hide().Once()
386387
@@ -399,7 +400,7 @@ func TestSetVisible_DelegatesToOverlay(t *testing.T) {
399400
400401 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
401402
402- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
403+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
403404
404405 mockOverlay.EXPECT().SetVisible(true).Once()
405406
@@ -418,7 +419,7 @@ func TestIsVisible_DelegatesToOverlay(t *testing.T) {
418419
419420 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
420421
421- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
422+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
422423
423424 mockOverlay.EXPECT().IsVisible().Return(true).Once()
424425
@@ -438,7 +439,7 @@ func TestAddCssClass_DelegatesToOverlay(t *testing.T) {
438439
439440 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
440441
441- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
442+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
442443
443444 mockOverlay.EXPECT().AddCssClass("custom-class").Once()
444445
@@ -457,7 +458,7 @@ func TestRemoveCssClass_DelegatesToOverlay(t *testing.T) {
457458
458459 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
459460
460- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
461+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
461462
462463 mockOverlay.EXPECT().RemoveCssClass("custom-class").Once()
463464
@@ -476,7 +477,7 @@ func TestSetOnFocusIn_SetsCallback(t *testing.T) {
476477
477478 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
478479
479- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
480+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
480481
481482 callback := func(paneID entity.PaneID) {}
482483
@@ -496,7 +497,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) {
496497
497498 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
498499
499- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
500+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
500501
501502 callback := func(paneID entity.PaneID) {}
502503
@@ -1,6 +1,7 @@
1 package component_test1 package component_test
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
@@ -47,7 +48,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
47 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()48 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
48 49
49 // Act50 // Act
50- pv := component.NewPaneView(mockFactory, paneID, mockWebView)51+ pv := component.NewPaneView(context.Background(), mockFactory, paneID, mockWebView)
51 52
52 // Assert53 // Assert
53 require.NotNil(t, pv)54 require.NotNil(t, pv)
@@ -88,7 +89,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
88 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()89 mockOverlay.EXPECT().SetMeasureOverlay(mockBorderBox, false).Once()
89 90
90 // Act91 // Act
91- pv := component.NewPaneView(mockFactory, paneID, nil)92+ pv := component.NewPaneView(context.Background(), mockFactory, paneID, nil)
92 93
93 // Assert94 // Assert
94 require.NotNil(t, pv)95 require.NotNil(t, pv)
@@ -104,7 +105,7 @@ func TestSetActive_True_AddsCSSClass(t *testing.T) {
104 105
105 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)106 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
106 107
107- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)108+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
108 109
109 // Expect CSS class to be added110 // Expect CSS class to be added
110 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()111 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
@@ -125,7 +126,7 @@ func TestSetActive_False_RemovesCSSClass(t *testing.T) {
125 126
126 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)127 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
127 128
128- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)129+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
129 130
130 // First activate131 // First activate
131 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()132 mockBorderBox.EXPECT().AddCssClass("pane-active").Once()
@@ -150,7 +151,7 @@ func TestSetActive_NoChangeWhenSameState(t *testing.T) {
150 151
151 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)152 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
152 153
153- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)154+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
154 155
155 // Act - setting false when already false should not call RemoveCssClass156 // Act - setting false when already false should not call RemoveCssClass
156 pv.SetActive(false)157 pv.SetActive(false)
@@ -169,7 +170,7 @@ func TestPaneID_ReturnsPaneID(t *testing.T) {
169 paneID := entity.PaneID("test-pane-123")170 paneID := entity.PaneID("test-pane-123")
170 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)171 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
171 172
172- pv := component.NewPaneView(mockFactory, paneID, mockWebView)173+ pv := component.NewPaneView(context.Background(), mockFactory, paneID, mockWebView)
173 174
174 // Act175 // Act
175 result := pv.PaneID()176 result := pv.PaneID()
@@ -187,7 +188,7 @@ func TestWebViewWidget_ReturnsWebView(t *testing.T) {
187 188
188 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)189 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
189 190
190- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)191+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
191 192
192 // Act193 // Act
193 result := pv.WebViewWidget()194 result := pv.WebViewWidget()
@@ -206,7 +207,7 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
206 207
207 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView)208 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
208 209
209- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockOldWebView)210+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockOldWebView)
210 211
211 // Expect removal of old widget and addition of new212 // Expect removal of old widget and addition of new
212 mockOverlay.EXPECT().SetChild(nil).Once()213 mockOverlay.EXPECT().SetChild(nil).Once()
@@ -229,7 +230,7 @@ func TestSetWebViewWidget_FromNil(t *testing.T) {
229 230
230 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)231 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
231 232
232- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)233+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
233 234
234 // Expect only setting new child (no removal since old was nil)235 // Expect only setting new child (no removal since old was nil)
235 mockNewWebView.EXPECT().GetParent().Return(nil).Once()236 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
@@ -251,7 +252,7 @@ func TestGrabFocus_DelegatesToWebView(t *testing.T) {
251 252
252 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)253 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
253 254
254- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)255+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
255 256
256 mockWebView.EXPECT().GrabFocus().Return(true).Once()257 mockWebView.EXPECT().GrabFocus().Return(true).Once()
257 258
@@ -270,7 +271,7 @@ func TestGrabFocus_NilWebView_ReturnsFalse(t *testing.T) {
270 271
271 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)272 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
272 273
273- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)274+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
274 275
275 // Act276 // Act
276 result := pv.GrabFocus()277 result := pv.GrabFocus()
@@ -288,7 +289,7 @@ func TestHasFocus_DelegatesToWebView(t *testing.T) {
288 289
289 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)290 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
290 291
291- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)292+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
292 293
293 mockWebView.EXPECT().HasFocus().Return(true).Once()294 mockWebView.EXPECT().HasFocus().Return(true).Once()
294 295
@@ -307,7 +308,7 @@ func TestHasFocus_NilWebView_ReturnsFalse(t *testing.T) {
307 308
308 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)309 setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
309 310
310- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)311+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), nil)
311 312
312 // Act313 // Act
313 result := pv.HasFocus()314 result := pv.HasFocus()
@@ -325,7 +326,7 @@ func TestWidget_ReturnsOverlay(t *testing.T) {
325 326
326 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)327 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
327 328
328- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)329+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
329 330
330 // Act331 // Act
331 result := pv.Widget()332 result := pv.Widget()
@@ -343,7 +344,7 @@ func TestOverlay_ReturnsOverlayWidget(t *testing.T) {
343 344
344 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)345 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
345 346
346- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)347+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
347 348
348 // Act349 // Act
349 result := pv.Overlay()350 result := pv.Overlay()
@@ -361,7 +362,7 @@ func TestShow_DelegatesToOverlay(t *testing.T) {
361 362
362 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)363 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
363 364
364- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)365+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
365 366
366 mockOverlay.EXPECT().Show().Once()367 mockOverlay.EXPECT().Show().Once()
367 368
@@ -380,7 +381,7 @@ func TestHide_DelegatesToOverlay(t *testing.T) {
380 381
381 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)382 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
382 383
383- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)384+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
384 385
385 mockOverlay.EXPECT().Hide().Once()386 mockOverlay.EXPECT().Hide().Once()
386 387
@@ -399,7 +400,7 @@ func TestSetVisible_DelegatesToOverlay(t *testing.T) {
399 400
400 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)401 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
401 402
402- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)403+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
403 404
404 mockOverlay.EXPECT().SetVisible(true).Once()405 mockOverlay.EXPECT().SetVisible(true).Once()
405 406
@@ -418,7 +419,7 @@ func TestIsVisible_DelegatesToOverlay(t *testing.T) {
418 419
419 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)420 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
420 421
421- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)422+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
422 423
423 mockOverlay.EXPECT().IsVisible().Return(true).Once()424 mockOverlay.EXPECT().IsVisible().Return(true).Once()
424 425
@@ -438,7 +439,7 @@ func TestAddCssClass_DelegatesToOverlay(t *testing.T) {
438 439
439 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)440 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
440 441
441- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)442+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
442 443
443 mockOverlay.EXPECT().AddCssClass("custom-class").Once()444 mockOverlay.EXPECT().AddCssClass("custom-class").Once()
444 445
@@ -457,7 +458,7 @@ func TestRemoveCssClass_DelegatesToOverlay(t *testing.T) {
457 458
458 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)459 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
459 460
460- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)461+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
461 462
462 mockOverlay.EXPECT().RemoveCssClass("custom-class").Once()463 mockOverlay.EXPECT().RemoveCssClass("custom-class").Once()
463 464
@@ -476,7 +477,7 @@ func TestSetOnFocusIn_SetsCallback(t *testing.T) {
476 477
477 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)478 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
478 479
479- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)480+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
480 481
481 callback := func(paneID entity.PaneID) {}482 callback := func(paneID entity.PaneID) {}
482 483
@@ -496,7 +497,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) {
496 497
497 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)498 setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
498 499
499- pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)500+ pv := component.NewPaneView(context.Background(), mockFactory, entity.PaneID("pane-1"), mockWebView)
500 501
501 callback := func(paneID entity.PaneID) {}502 callback := func(paneID entity.PaneID) {}
502 503
M internal/ui/component/progress_bar.go +58 -1
@@ -2,8 +2,10 @@
22 package component
33
44 import (
5+ "context"
56 "sync"
67
8+ "github.com/bnema/dumber/internal/logging"
79 "github.com/bnema/puregotk/v4/glib"
810 "github.com/bnema/puregotk/v4/gtk"
911
@@ -24,6 +26,7 @@ const (
2426 // Implements smooth animation by incrementing towards the target value.
2527 // Includes a 30-second timeout to auto-hide if the page load stalls.
2628 type ProgressBar struct {
29+ ctx context.Context
2730 progressBar layout.ProgressBarWidget
2831
2932 visible bool
@@ -36,7 +39,7 @@ type ProgressBar struct {
3639 }
3740
3841 // NewProgressBar creates a new progress bar component using the widget factory.
39-func NewProgressBar(factory layout.WidgetFactory) *ProgressBar {
42+func NewProgressBar(ctx context.Context, factory layout.WidgetFactory) *ProgressBar {
4043 progressBar := factory.NewProgressBar()
4144
4245 // Add "osd" class for on-screen-display overlay styling (like Epiphany)
@@ -63,6 +66,7 @@ func NewProgressBar(factory layout.WidgetFactory) *ProgressBar {
6366 progressBar.SetVisible(false)
6467
6568 return &ProgressBar{
69+ ctx: ctx,
6670 progressBar: progressBar,
6771 visible: false,
6872 currentValue: 0,
@@ -76,6 +80,19 @@ func (pb *ProgressBar) SetProgress(progress float64) {
7680 pb.mu.Lock()
7781 defer pb.mu.Unlock()
7882
83+ incomingProgress := progress
84+ ctx := pb.ctx
85+ if ctx == nil {
86+ ctx = context.Background()
87+ }
88+ logging.FromContext(ctx).
89+ Debug().
90+ Float64("incoming_progress", incomingProgress).
91+ Float64("current_value", pb.currentValue).
92+ Float64("target_value", pb.targetValue).
93+ Bool("visible", pb.visible).
94+ Msg("setting progress")
95+
7996 // Clamp progress to valid range
8097 if progress < 0 {
8198 progress = 0
@@ -131,18 +148,49 @@ func (pb *ProgressBar) startAnimation() {
131148 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
132149 }
133150
151+// initialProgressFraction is the fraction set when the progress bar first
152+// appears so the user gets immediate visual feedback that loading has begun.
153+// CEF may not fire OnLoadingProgressChange for hundreds of milliseconds
154+// during cross-site process swaps, leaving the bar at 0% (invisible).
155+const initialProgressFraction = 0.1
156+
134157 // Show makes the progress bar visible and starts the auto-hide timeout.
135158 func (pb *ProgressBar) Show() {
136159 pb.mu.Lock()
137160 defer pb.mu.Unlock()
138161
162+ ctx := pb.ctx
163+ if ctx == nil {
164+ ctx = context.Background()
165+ }
166+ logging.FromContext(ctx).
167+ Debug().
168+ Bool("visible", pb.visible).
169+ Float64("current_value", pb.currentValue).
170+ Float64("target_value", pb.targetValue).
171+ Msg("progress bar show before")
172+
139173 if !pb.visible {
140174 pb.visible = true
175+ // Set an initial non-zero fraction so the bar is visually noticeable
176+ // immediately. Without this, the bar is technically visible but
177+ // renders as empty (0% fill) until the first progress callback,
178+ // which in CEF can be delayed during cross-site process swaps.
179+ pb.currentValue = initialProgressFraction
180+ pb.targetValue = initialProgressFraction
181+ pb.progressBar.SetFraction(initialProgressFraction)
141182 pb.progressBar.SetVisible(true)
142183 }
143184
144185 // Reset timeout timer on every Show call
145186 pb.resetTimeout()
187+
188+ logging.FromContext(pb.ctx).
189+ Debug().
190+ Bool("visible", pb.visible).
191+ Float64("current_value", pb.currentValue).
192+ Float64("target_value", pb.targetValue).
193+ Msg("progress bar show after")
146194 }
147195
148196 // resetTimeout cancels any existing timeout and starts a new one.
@@ -181,6 +229,15 @@ func (pb *ProgressBar) Hide() {
181229 // hideInternal performs the actual hide operation.
182230 // Must be called with lock held.
183231 func (pb *ProgressBar) hideInternal() {
232+ ctx := pb.ctx
233+ if ctx == nil {
234+ ctx = context.Background()
235+ }
236+ logging.FromContext(ctx).
237+ Debug().
238+ Bool("visible", pb.visible).
239+ Msg("progress bar hide before")
240+
184241 if pb.visible {
185242 pb.visible = false
186243 pb.progressBar.SetVisible(false)
@@ -2,8 +2,10 @@
2 package component2 package component
3 3
4 import (4 import (
5+ "context"
5 "sync"6 "sync"
6 7
8+ "github.com/bnema/dumber/internal/logging"
7 "github.com/bnema/puregotk/v4/glib"9 "github.com/bnema/puregotk/v4/glib"
8 "github.com/bnema/puregotk/v4/gtk"10 "github.com/bnema/puregotk/v4/gtk"
9 11
@@ -24,6 +26,7 @@ const (
24 // Implements smooth animation by incrementing towards the target value.26 // Implements smooth animation by incrementing towards the target value.
25 // Includes a 30-second timeout to auto-hide if the page load stalls.27 // Includes a 30-second timeout to auto-hide if the page load stalls.
26 type ProgressBar struct {28 type ProgressBar struct {
29+ ctx context.Context
27 progressBar layout.ProgressBarWidget30 progressBar layout.ProgressBarWidget
28 31
29 visible bool32 visible bool
@@ -36,7 +39,7 @@ type ProgressBar struct {
36 }39 }
37 40
38 // NewProgressBar creates a new progress bar component using the widget factory.41 // NewProgressBar creates a new progress bar component using the widget factory.
39-func NewProgressBar(factory layout.WidgetFactory) *ProgressBar {42+func NewProgressBar(ctx context.Context, factory layout.WidgetFactory) *ProgressBar {
40 progressBar := factory.NewProgressBar()43 progressBar := factory.NewProgressBar()
41 44
42 // Add "osd" class for on-screen-display overlay styling (like Epiphany)45 // Add "osd" class for on-screen-display overlay styling (like Epiphany)
@@ -63,6 +66,7 @@ func NewProgressBar(factory layout.WidgetFactory) *ProgressBar {
63 progressBar.SetVisible(false)66 progressBar.SetVisible(false)
64 67
65 return &ProgressBar{68 return &ProgressBar{
69+ ctx: ctx,
66 progressBar: progressBar,70 progressBar: progressBar,
67 visible: false,71 visible: false,
68 currentValue: 0,72 currentValue: 0,
@@ -76,6 +80,19 @@ func (pb *ProgressBar) SetProgress(progress float64) {
76 pb.mu.Lock()80 pb.mu.Lock()
77 defer pb.mu.Unlock()81 defer pb.mu.Unlock()
78 82
83+ incomingProgress := progress
84+ ctx := pb.ctx
85+ if ctx == nil {
86+ ctx = context.Background()
87+ }
88+ logging.FromContext(ctx).
89+ Debug().
90+ Float64("incoming_progress", incomingProgress).
91+ Float64("current_value", pb.currentValue).
92+ Float64("target_value", pb.targetValue).
93+ Bool("visible", pb.visible).
94+ Msg("setting progress")
95+
79 // Clamp progress to valid range96 // Clamp progress to valid range
80 if progress < 0 {97 if progress < 0 {
81 progress = 098 progress = 0
@@ -131,18 +148,49 @@ func (pb *ProgressBar) startAnimation() {
131 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)148 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
132 }149 }
133 150
151+// initialProgressFraction is the fraction set when the progress bar first
152+// appears so the user gets immediate visual feedback that loading has begun.
153+// CEF may not fire OnLoadingProgressChange for hundreds of milliseconds
154+// during cross-site process swaps, leaving the bar at 0% (invisible).
155+const initialProgressFraction = 0.1
156+
134 // Show makes the progress bar visible and starts the auto-hide timeout.157 // Show makes the progress bar visible and starts the auto-hide timeout.
135 func (pb *ProgressBar) Show() {158 func (pb *ProgressBar) Show() {
136 pb.mu.Lock()159 pb.mu.Lock()
137 defer pb.mu.Unlock()160 defer pb.mu.Unlock()
138 161
162+ ctx := pb.ctx
163+ if ctx == nil {
164+ ctx = context.Background()
165+ }
166+ logging.FromContext(ctx).
167+ Debug().
168+ Bool("visible", pb.visible).
169+ Float64("current_value", pb.currentValue).
170+ Float64("target_value", pb.targetValue).
171+ Msg("progress bar show before")
172+
139 if !pb.visible {173 if !pb.visible {
140 pb.visible = true174 pb.visible = true
175+ // Set an initial non-zero fraction so the bar is visually noticeable
176+ // immediately. Without this, the bar is technically visible but
177+ // renders as empty (0% fill) until the first progress callback,
178+ // which in CEF can be delayed during cross-site process swaps.
179+ pb.currentValue = initialProgressFraction
180+ pb.targetValue = initialProgressFraction
181+ pb.progressBar.SetFraction(initialProgressFraction)
141 pb.progressBar.SetVisible(true)182 pb.progressBar.SetVisible(true)
142 }183 }
143 184
144 // Reset timeout timer on every Show call185 // Reset timeout timer on every Show call
145 pb.resetTimeout()186 pb.resetTimeout()
187+
188+ logging.FromContext(pb.ctx).
189+ Debug().
190+ Bool("visible", pb.visible).
191+ Float64("current_value", pb.currentValue).
192+ Float64("target_value", pb.targetValue).
193+ Msg("progress bar show after")
146 }194 }
147 195
148 // resetTimeout cancels any existing timeout and starts a new one.196 // resetTimeout cancels any existing timeout and starts a new one.
@@ -181,6 +229,15 @@ func (pb *ProgressBar) Hide() {
181 // hideInternal performs the actual hide operation.229 // hideInternal performs the actual hide operation.
182 // Must be called with lock held.230 // Must be called with lock held.
183 func (pb *ProgressBar) hideInternal() {231 func (pb *ProgressBar) hideInternal() {
232+ ctx := pb.ctx
233+ if ctx == nil {
234+ ctx = context.Background()
235+ }
236+ logging.FromContext(ctx).
237+ Debug().
238+ Bool("visible", pb.visible).
239+ Msg("progress bar hide before")
240+
184 if pb.visible {241 if pb.visible {
185 pb.visible = false242 pb.visible = false
186 pb.progressBar.SetVisible(false)243 pb.progressBar.SetVisible(false)
M internal/ui/component/workspace_view.go +1 -1
@@ -86,7 +86,7 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
8686
8787 // Create PaneView without WebView widget for now
8888 // WebView will be attached later by the application layer
89- pv := NewPaneView(a.wv.factory, node.Pane.ID, nil)
89+ pv := NewPaneView(a.ctx, a.wv.factory, node.Pane.ID, nil)
9090
9191 // Store in map for later lookup
9292 // Note: Caller (SetWorkspace) already holds the lock, so we access directly
@@ -86,7 +86,7 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
86 86
87 // Create PaneView without WebView widget for now87 // Create PaneView without WebView widget for now
88 // WebView will be attached later by the application layer88 // WebView will be attached later by the application layer
89- pv := NewPaneView(a.wv.factory, node.Pane.ID, nil)89+ pv := NewPaneView(a.ctx, a.wv.factory, node.Pane.ID, nil)
90 90
91 // Store in map for later lookup91 // Store in map for later lookup
92 // Note: Caller (SetWorkspace) already holds the lock, so we access directly92 // Note: Caller (SetWorkspace) already holds the lock, so we access directly
M internal/ui/coordinator/content/content_reveal_test.go +34 -0
@@ -8,7 +8,9 @@ import (
88 "github.com/stretchr/testify/assert"
99
1010 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/application/port/mocks"
1112 "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/dumber/internal/ui/component"
1214 )
1315
1416 // TestPendingReveal tests the pending reveal state management.
@@ -108,6 +110,38 @@ func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
108110 assert.False(t, c.pendingReveal[paneID])
109111 }
110112
113+func TestOnLoadCommitted_RevealsPendingWebViewForCommittedPage(t *testing.T) {
114+ paneID := entity.PaneID("pane-1")
115+ shownCount := 0
116+ var shownPane entity.PaneID
117+
118+ wv := mocks.NewMockWebView(t)
119+ wv.EXPECT().URI().Return("https://example.com")
120+ wv.EXPECT().ResetBackgroundToDefault()
121+ wv.EXPECT().Title().Return("")
122+ wv.EXPECT().IsDestroyed().Return(false).Maybe()
123+
124+ c := &Coordinator{
125+ pendingReveal: make(map[entity.PaneID]bool),
126+ webViews: map[entity.PaneID]port.WebView{
127+ paneID: wv,
128+ },
129+ paneTitles: make(map[entity.PaneID]string),
130+ navOrigins: make(map[entity.PaneID]string),
131+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil },
132+ onWebViewShown: func(id entity.PaneID) {
133+ shownCount++
134+ shownPane = id
135+ },
136+ }
137+ c.pendingReveal[paneID] = true
138+
139+ c.onLoadCommitted(context.Background(), paneID, wv)
140+
141+ assert.Equal(t, 1, shownCount)
142+ assert.Equal(t, paneID, shownPane)
143+}
144+
111145 func TestPendingReveal_ConcurrentAccess(t *testing.T) {
112146 c := &Coordinator{
113147 pendingReveal: make(map[entity.PaneID]bool),
@@ -8,7 +8,9 @@ import (
8 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/application/port/mocks"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/dumber/internal/ui/component"
12 )14 )
13 15
14 // TestPendingReveal tests the pending reveal state management.16 // TestPendingReveal tests the pending reveal state management.
@@ -108,6 +110,38 @@ func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
108 assert.False(t, c.pendingReveal[paneID])110 assert.False(t, c.pendingReveal[paneID])
109 }111 }
110 112
113+func TestOnLoadCommitted_RevealsPendingWebViewForCommittedPage(t *testing.T) {
114+ paneID := entity.PaneID("pane-1")
115+ shownCount := 0
116+ var shownPane entity.PaneID
117+
118+ wv := mocks.NewMockWebView(t)
119+ wv.EXPECT().URI().Return("https://example.com")
120+ wv.EXPECT().ResetBackgroundToDefault()
121+ wv.EXPECT().Title().Return("")
122+ wv.EXPECT().IsDestroyed().Return(false).Maybe()
123+
124+ c := &Coordinator{
125+ pendingReveal: make(map[entity.PaneID]bool),
126+ webViews: map[entity.PaneID]port.WebView{
127+ paneID: wv,
128+ },
129+ paneTitles: make(map[entity.PaneID]string),
130+ navOrigins: make(map[entity.PaneID]string),
131+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil },
132+ onWebViewShown: func(id entity.PaneID) {
133+ shownCount++
134+ shownPane = id
135+ },
136+ }
137+ c.pendingReveal[paneID] = true
138+
139+ c.onLoadCommitted(context.Background(), paneID, wv)
140+
141+ assert.Equal(t, 1, shownCount)
142+ assert.Equal(t, paneID, shownPane)
143+}
144+
111 func TestPendingReveal_ConcurrentAccess(t *testing.T) {145 func TestPendingReveal_ConcurrentAccess(t *testing.T) {
112 c := &Coordinator{146 c := &Coordinator{
113 pendingReveal: make(map[entity.PaneID]bool),147 pendingReveal: make(map[entity.PaneID]bool),
M internal/ui/coordinator/content/coordinator.go +3 -0
@@ -14,10 +14,12 @@ import (
1414 "github.com/bnema/dumber/internal/ui/component"
1515 "github.com/bnema/dumber/internal/ui/input"
1616 "github.com/bnema/dumber/internal/ui/layout"
17+ "github.com/rs/zerolog"
1718 )
1819
1920 // Coordinator manages WebView lifecycle, title tracking, and content attachment.
2021 type Coordinator struct {
22+ logger zerolog.Logger
2123 pool port.WebViewPool
2224 widgetFactory layout.WidgetFactory
2325 faviconAdapter *adapter.FaviconAdapter
@@ -151,6 +153,7 @@ func NewCoordinator(
151153 log.Debug().Msg("creating content coordinator")
152154
153155 return &Coordinator{
156+ logger: log.With().Str("component", "content-coordinator").Logger(),
154157 pool: pool,
155158 injector: injector,
156159 widgetFactory: widgetFactory,
@@ -14,10 +14,12 @@ import (
14 "github.com/bnema/dumber/internal/ui/component"14 "github.com/bnema/dumber/internal/ui/component"
15 "github.com/bnema/dumber/internal/ui/input"15 "github.com/bnema/dumber/internal/ui/input"
16 "github.com/bnema/dumber/internal/ui/layout"16 "github.com/bnema/dumber/internal/ui/layout"
17+ "github.com/rs/zerolog"
17 )18 )
18 19
19 // Coordinator manages WebView lifecycle, title tracking, and content attachment.20 // Coordinator manages WebView lifecycle, title tracking, and content attachment.
20 type Coordinator struct {21 type Coordinator struct {
22+ logger zerolog.Logger
21 pool port.WebViewPool23 pool port.WebViewPool
22 widgetFactory layout.WidgetFactory24 widgetFactory layout.WidgetFactory
23 faviconAdapter *adapter.FaviconAdapter25 faviconAdapter *adapter.FaviconAdapter
@@ -151,6 +153,7 @@ func NewCoordinator(
151 log.Debug().Msg("creating content coordinator")153 log.Debug().Msg("creating content coordinator")
152 154
153 return &Coordinator{155 return &Coordinator{
156+ logger: log.With().Str("component", "content-coordinator").Logger(),
154 pool: pool,157 pool: pool,
155 injector: injector,158 injector: injector,
156 widgetFactory: widgetFactory,159 widgetFactory: widgetFactory,
M internal/ui/coordinator/content/navigation.go +17 -9
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/domain/entity"
1010 urlutil "github.com/bnema/dumber/internal/domain/url"
1111 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/component"
1213 "github.com/bnema/puregotk/v4/gtk"
1314 )
1415
@@ -77,9 +78,7 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
7778 }
7879
7980 c.markPendingReveal(paneID)
80- if wv.EstimatedProgress() > 0 {
81- c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
82- }
81+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
8382
8483 // Update domain model with current URI for session snapshots
8584 c.updatePaneURI(paneID, uri)
@@ -188,11 +187,14 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
188187 })
189188
190189 _, wsView := c.getActiveWS()
190+ var paneView *component.PaneView
191+ if wsView != nil {
192+ paneView = wsView.GetPaneView(paneID)
193+ }
194+
191195 if wsView == nil {
192196 return
193197 }
194-
195- paneView := wsView.GetPaneView(paneID)
196198 if paneView != nil {
197199 paneView.SetLoading(true)
198200 }
@@ -201,11 +203,14 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
201203 // onLoadFinished hides the progress bar when page loading completes.
202204 func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
203205 _, wsView := c.getActiveWS()
206+ var paneView *component.PaneView
207+ if wsView != nil {
208+ paneView = wsView.GetPaneView(paneID)
209+ }
210+
204211 if wsView == nil {
205212 return
206213 }
207-
208- paneView := wsView.GetPaneView(paneID)
209214 if paneView != nil {
210215 paneView.SetLoading(false)
211216 }
@@ -225,11 +230,14 @@ func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64)
225230 }
226231
227232 _, wsView := c.getActiveWS()
233+ var paneView *component.PaneView
234+ if wsView != nil {
235+ paneView = wsView.GetPaneView(paneID)
236+ }
237+
228238 if wsView == nil {
229239 return
230240 }
231-
232- paneView := wsView.GetPaneView(paneID)
233241 if paneView != nil {
234242 paneView.SetLoadProgress(progress)
235243 }
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 urlutil "github.com/bnema/dumber/internal/domain/url"10 urlutil "github.com/bnema/dumber/internal/domain/url"
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/puregotk/v4/gtk"13 "github.com/bnema/puregotk/v4/gtk"
13 )14 )
14 15
@@ -77,9 +78,7 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
77 }78 }
78 79
79 c.markPendingReveal(paneID)80 c.markPendingReveal(paneID)
80- if wv.EstimatedProgress() > 0 {81+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
81- c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
82- }
83 82
84 // Update domain model with current URI for session snapshots83 // Update domain model with current URI for session snapshots
85 c.updatePaneURI(paneID, uri)84 c.updatePaneURI(paneID, uri)
@@ -188,11 +187,14 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
188 })187 })
189 188
190 _, wsView := c.getActiveWS()189 _, wsView := c.getActiveWS()
190+ var paneView *component.PaneView
191+ if wsView != nil {
192+ paneView = wsView.GetPaneView(paneID)
193+ }
194+
191 if wsView == nil {195 if wsView == nil {
192 return196 return
193 }197 }
194-
195- paneView := wsView.GetPaneView(paneID)
196 if paneView != nil {198 if paneView != nil {
197 paneView.SetLoading(true)199 paneView.SetLoading(true)
198 }200 }
@@ -201,11 +203,14 @@ func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
201 // onLoadFinished hides the progress bar when page loading completes.203 // onLoadFinished hides the progress bar when page loading completes.
202 func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) {204 func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
203 _, wsView := c.getActiveWS()205 _, wsView := c.getActiveWS()
206+ var paneView *component.PaneView
207+ if wsView != nil {
208+ paneView = wsView.GetPaneView(paneID)
209+ }
210+
204 if wsView == nil {211 if wsView == nil {
205 return212 return
206 }213 }
207-
208- paneView := wsView.GetPaneView(paneID)
209 if paneView != nil {214 if paneView != nil {
210 paneView.SetLoading(false)215 paneView.SetLoading(false)
211 }216 }
@@ -225,11 +230,14 @@ func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64)
225 }230 }
226 231
227 _, wsView := c.getActiveWS()232 _, wsView := c.getActiveWS()
233+ var paneView *component.PaneView
234+ if wsView != nil {
235+ paneView = wsView.GetPaneView(paneID)
236+ }
237+
228 if wsView == nil {238 if wsView == nil {
229 return239 return
230 }240 }
231-
232- paneView := wsView.GetPaneView(paneID)
233 if paneView != nil {241 if paneView != nil {
234 paneView.SetLoadProgress(progress)242 paneView.SetLoadProgress(progress)
235 }243 }
M internal/ui/coordinator/workspace.go +4 -4
@@ -358,7 +358,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
358358 Msg("stack split: determined split type")
359359
360360 // 3. Create new PaneView for the new pane (without WebView - will attach later)
361- newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
361+ newPaneView := component.NewPaneView(ctx, factory, output.NewPaneNode.Pane.ID, nil)
362362 setupPaneViewHover(ctx, newPaneView, wsView)
363363
364364 // 4. Wrap the new PaneView in a StackedView
@@ -534,7 +534,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
534534 Msg("determined split type")
535535
536536 // 4. Create new PaneView for the new pane
537- newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
537+ newPaneView := component.NewPaneView(ctx, factory, output.NewPaneNode.Pane.ID, nil)
538538 setupPaneViewHover(ctx, newPaneView, wsView)
539539
540540 // 5. Wrap the new PaneView in a StackedView
@@ -1438,7 +1438,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
14381438 }
14391439
14401440 // Create PaneView for the new pane
1441- newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
1441+ newPaneView := component.NewPaneView(ctx, c.widgetFactory, newPaneID, nil)
14421442 setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)
14431443 stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
14441444
@@ -2084,7 +2084,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
20842084 if wsView == nil {
20852085 return nil
20862086 }
2087- newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil)
2087+ newPaneView := component.NewPaneView(ctx, c.widgetFactory, input.PopupPane.ID, nil)
20882088 setupPaneViewHover(ctx, newPaneView, wsView)
20892089 wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)
20902090
@@ -358,7 +358,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
358 Msg("stack split: determined split type")358 Msg("stack split: determined split type")
359 359
360 // 3. Create new PaneView for the new pane (without WebView - will attach later)360 // 3. Create new PaneView for the new pane (without WebView - will attach later)
361- newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)361+ newPaneView := component.NewPaneView(ctx, factory, output.NewPaneNode.Pane.ID, nil)
362 setupPaneViewHover(ctx, newPaneView, wsView)362 setupPaneViewHover(ctx, newPaneView, wsView)
363 363
364 // 4. Wrap the new PaneView in a StackedView364 // 4. Wrap the new PaneView in a StackedView
@@ -534,7 +534,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
534 Msg("determined split type")534 Msg("determined split type")
535 535
536 // 4. Create new PaneView for the new pane536 // 4. Create new PaneView for the new pane
537- newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)537+ newPaneView := component.NewPaneView(ctx, factory, output.NewPaneNode.Pane.ID, nil)
538 setupPaneViewHover(ctx, newPaneView, wsView)538 setupPaneViewHover(ctx, newPaneView, wsView)
539 539
540 // 5. Wrap the new PaneView in a StackedView540 // 5. Wrap the new PaneView in a StackedView
@@ -1438,7 +1438,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1438 }1438 }
1439 1439
1440 // Create PaneView for the new pane1440 // Create PaneView for the new pane
1441- newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)1441+ newPaneView := component.NewPaneView(ctx, c.widgetFactory, newPaneID, nil)
1442 setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)1442 setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)
1443 stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)1443 stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
1444 1444
@@ -2084,7 +2084,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
2084 if wsView == nil {2084 if wsView == nil {
2085 return nil2085 return nil
2086 }2086 }
2087- newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil)2087+ newPaneView := component.NewPaneView(ctx, c.widgetFactory, input.PopupPane.ID, nil)
2088 setupPaneViewHover(ctx, newPaneView, wsView)2088 setupPaneViewHover(ctx, newPaneView, wsView)
2089 wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)2089 wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)
2090 2090

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