detached 6532eea

feat(browser): add page load progress bar

commit
6532eea697cc5504ba264e455ddd3412eaaa9946
author
bnema · · 2025-11-18 22:58 UTC
parent
e2080dd
signature
unsigned
7 files · +278 -11
M CHANGELOG.md +1 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Page load progress bar**: Thin animated indicator at window bottom showing web page loading progress. Tracks WebView load events with gradient animation, auto-hides when complete.
89 - **Omnibox initial history display**: Configurable behavior when opening empty omnibox via `omnibox.initial_behavior` ("recent", "most_visited", "none"). Shows initial history on open and when input is cleared. Default: recent visits.
910 - **Omnibox keyboard blocking**: Prevent page scripts from stealing focus while omnibox is active.
1011
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Page load progress bar**: Thin animated indicator at window bottom showing web page loading progress. Tracks WebView load events with gradient animation, auto-hides when complete.
8 - **Omnibox initial history display**: Configurable behavior when opening empty omnibox via `omnibox.initial_behavior` ("recent", "most_visited", "none"). Shows initial history on open and when input is cleared. Default: recent visits.9 - **Omnibox initial history display**: Configurable behavior when opening empty omnibox via `omnibox.initial_behavior` ("recent", "most_visited", "none"). Shows initial history on open and when input is cleared. Default: recent visits.
9 - **Omnibox keyboard blocking**: Prevent page scripts from stealing focus while omnibox is active.10 - **Omnibox keyboard blocking**: Prevent page scripts from stealing focus while omnibox is active.
10 11
A internal/app/browser/progress_bar.go +24 -0
@@ -0,0 +1,24 @@
1+package browser
2+
3+import (
4+ "github.com/bnema/dumber/internal/logging"
5+ "github.com/bnema/dumber/pkg/webkit"
6+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
7+)
8+
9+// createProgressBar builds the shared loading indicator shown at the bottom of the window.
10+func (tm *TabManager) createProgressBar() gtk.Widgetter {
11+ bar := gtk.NewProgressBar()
12+ if bar == nil {
13+ logging.Error("[tabs] Failed to create tab progress bar")
14+ return nil
15+ }
16+
17+ bar.SetHExpand(true)
18+ bar.SetVExpand(false)
19+ bar.SetShowText(false)
20+ bar.SetFraction(0.0)
21+ bar.AddCSSClass("tab-progress-bar")
22+ webkit.WidgetSetVisible(bar, false)
23+ return bar
24+}
@@ -0,0 +1,24 @@
1+package browser
2+
3+import (
4+ "github.com/bnema/dumber/internal/logging"
5+ "github.com/bnema/dumber/pkg/webkit"
6+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
7+)
8+
9+// createProgressBar builds the shared loading indicator shown at the bottom of the window.
10+func (tm *TabManager) createProgressBar() gtk.Widgetter {
11+ bar := gtk.NewProgressBar()
12+ if bar == nil {
13+ logging.Error("[tabs] Failed to create tab progress bar")
14+ return nil
15+ }
16+
17+ bar.SetHExpand(true)
18+ bar.SetVExpand(false)
19+ bar.SetShowText(false)
20+ bar.SetFraction(0.0)
21+ bar.AddCSSClass("tab-progress-bar")
22+ webkit.WidgetSetVisible(bar, false)
23+ return bar
24+}
M internal/app/browser/tab_manager.go +141 -1
@@ -24,10 +24,12 @@ type TabManager struct {
2424 tabBar gtk.Widgetter // Horizontal tab bar container
2525 ContentArea gtk.Widgetter // Container for active workspace (exported for border styling)
2626 tabModeTarget gtk.Widgetter // Widget currently holding tab mode border margins
27+ progressBar gtk.Widgetter // Thin load progress indicator
2728
2829 // Modal state
2930 tabModeActive bool
3031 tabModeTimer *time.Timer
32+ lastTabCreate time.Time // Debounce guard for rapid tab creation shortcuts
3133
3234 // Rename state
3335 renameInProgress bool
@@ -52,6 +54,9 @@ type Tab struct {
5254 workspace *WorkspaceManager
5355 titleButton gtk.Widgetter
5456 isActive bool
57+ progress float64
58+ isLoading bool
59+ webViews map[*webkit.WebView]struct{}
5560 }
5661
5762 // NewTabManager creates a new tab manager instance.
@@ -126,6 +131,9 @@ func (tm *TabManager) createRootContainer() error {
126131 contentArea.SetVExpand(true)
127132 contentArea.AddCSSClass("tab-content-area")
128133
134+ // Create progress bar (displayed at the bottom regardless of tab bar position)
135+ progressBar := tm.createProgressBar()
136+
129137 // Add widgets in order based on position
130138 if position == "top" {
131139 rootBox.Append(tabBar)
@@ -135,10 +143,27 @@ func (tm *TabManager) createRootContainer() error {
135143 rootBox.Append(tabBar)
136144 }
137145
146+ // Wrap everything in an overlay so the progress bar can float without resizing layout
147+ rootOverlay := gtk.NewOverlay()
148+ if rootOverlay == nil {
149+ return fmt.Errorf("failed to create root overlay")
150+ }
151+ rootOverlay.SetHExpand(true)
152+ rootOverlay.SetVExpand(true)
153+ rootOverlay.SetChild(rootBox)
154+ if progressBar != nil {
155+ if bar, ok := progressBar.(*gtk.ProgressBar); ok && bar != nil {
156+ bar.SetHAlign(gtk.AlignFill)
157+ bar.SetVAlign(gtk.AlignEnd)
158+ }
159+ rootOverlay.AddOverlay(progressBar)
160+ }
161+
138162 // Store references
139- tm.rootContainer = rootBox
163+ tm.rootContainer = rootOverlay
140164 tm.tabBar = tabBar
141165 tm.ContentArea = contentArea
166+ tm.progressBar = progressBar
142167
143168 logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
144169 return nil
@@ -157,6 +182,12 @@ func (tm *TabManager) CreateTab(url string) error {
157182 func (tm *TabManager) createTabInternal(url string) error {
158183 logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))
159184
185+ const tabCreateDebounce = 200 * time.Millisecond
186+ if !tm.lastTabCreate.IsZero() && time.Since(tm.lastTabCreate) < tabCreateDebounce {
187+ logging.Warn("[tabs] Ignoring rapid tab creation request (debounced)")
188+ return nil
189+ }
190+
160191 // Use default URL if empty
161192 if url == "" {
162193 url = "about:blank"
@@ -172,6 +203,7 @@ func (tm *TabManager) createTabInternal(url string) error {
172203 id: tabID,
173204 title: defaultTitle,
174205 isActive: false,
206+ webViews: make(map[*webkit.WebView]struct{}),
175207 }
176208
177209 // Create WebView for this tab
@@ -195,6 +227,7 @@ func (tm *TabManager) createTabInternal(url string) error {
195227 // Create workspace for this tab
196228 workspace := NewWorkspaceManager(tm.app, pane)
197229 tab.workspace = workspace
230+ tm.registerWebViewLocked(tab, view)
198231
199232 // Verify workspace root exists
200233 rootWidget := workspace.GetRootWidget()
@@ -222,6 +255,7 @@ func (tm *TabManager) createTabInternal(url string) error {
222255
223256 // Add to tabs slice
224257 tm.tabs = append(tm.tabs, tab)
258+ tm.lastTabCreate = time.Now()
225259
226260 // Attach click handler with unique ID (pattern from stacked panes)
227261 tm.attachTabClickHandler(tab.titleButton, tab)
@@ -325,6 +359,9 @@ func (tm *TabManager) switchToTabInternal(index int) error {
325359 newTab.workspace.RestoreFocus()
326360 }
327361
362+ // Sync progress bar with the newly active tab
363+ tm.updateProgressBarLocked()
364+
328365 logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))
329366 return nil
330367 }
@@ -405,6 +442,9 @@ func (tm *TabManager) closeTabInternal(index int) error {
405442 // Update tab bar visibility (hide if only 1 tab)
406443 tm.updateTabBarVisibility()
407444
445+ // Update progress bar (active tab may have changed after close)
446+ tm.updateProgressBarLocked()
447+
408448 logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))
409449 return nil
410450 }
@@ -553,3 +593,103 @@ func (tm *TabManager) updateTabBarVisibility() {
553593 func (tm *TabManager) getConfig() *config.Config {
554594 return config.Get()
555595 }
596+
597+// updateProgressForWebView updates progress state for the tab that owns the given WebView.
598+func (tm *TabManager) updateProgressForWebView(view *webkit.WebView, progress float64, loading bool) {
599+ if view == nil || tm == nil {
600+ return
601+ }
602+
603+ tm.mu.Lock()
604+ defer tm.mu.Unlock()
605+
606+ tab := tm.findTabForViewLocked(view)
607+ if tab == nil {
608+ return
609+ }
610+
611+ // Clamp progress
612+ if progress < 0 {
613+ progress = 0
614+ }
615+ if progress > 1 {
616+ progress = 1
617+ }
618+
619+ // Only mark loading when progress is incomplete and the event indicates an active load.
620+ tab.isLoading = loading && progress < 1.0
621+ tab.progress = progress
622+
623+ tm.updateProgressBarLocked()
624+}
625+
626+// findTabForViewLocked finds the tab owning the given WebView. Caller must hold tm.mu.
627+func (tm *TabManager) findTabForViewLocked(view *webkit.WebView) *Tab {
628+ for _, tab := range tm.tabs {
629+ if _, ok := tab.webViews[view]; ok {
630+ return tab
631+ }
632+ }
633+ return nil
634+}
635+
636+// registerWebViewLocked associates a WebView with a tab. Caller must hold tm.mu.
637+func (tm *TabManager) registerWebViewLocked(tab *Tab, view *webkit.WebView) {
638+ if tab == nil || view == nil {
639+ return
640+ }
641+ if tab.webViews == nil {
642+ tab.webViews = make(map[*webkit.WebView]struct{})
643+ }
644+ tab.webViews[view] = struct{}{}
645+}
646+
647+// registerWebViewForWorkspace registers a webview to the tab that owns the workspace.
648+func (tm *TabManager) registerWebViewForWorkspace(view *webkit.WebView, workspace *WorkspaceManager) {
649+ if tm == nil || view == nil || workspace == nil {
650+ return
651+ }
652+
653+ tm.mu.Lock()
654+ defer tm.mu.Unlock()
655+
656+ for _, tab := range tm.tabs {
657+ if tab.workspace == workspace {
658+ tm.registerWebViewLocked(tab, view)
659+ return
660+ }
661+ }
662+}
663+
664+// updateProgressBarLocked syncs the shared progress bar to the active tab. Caller must hold tm.mu.
665+func (tm *TabManager) updateProgressBarLocked() {
666+ if tm.progressBar == nil {
667+ return
668+ }
669+
670+ bar, ok := tm.progressBar.(*gtk.ProgressBar)
671+ if !ok || bar == nil {
672+ return
673+ }
674+
675+ // No active tab -> hide bar
676+ if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) {
677+ webkit.RunOnMainThread(func() {
678+ bar.SetFraction(0.0)
679+ webkit.WidgetSetVisible(bar, false)
680+ })
681+ return
682+ }
683+
684+ activeTab := tm.tabs[tm.activeIndex]
685+ visible := activeTab.isLoading
686+ fraction := activeTab.progress
687+ if !visible {
688+ fraction = 0.0
689+ }
690+
691+ webkit.RunOnMainThread(func() {
692+ bar.SetFraction(fraction)
693+ webkit.WidgetSetVisible(bar, visible)
694+ })
695+}
@@ -24,10 +24,12 @@ type TabManager struct {
24 tabBar gtk.Widgetter // Horizontal tab bar container24 tabBar gtk.Widgetter // Horizontal tab bar container
25 ContentArea gtk.Widgetter // Container for active workspace (exported for border styling)25 ContentArea gtk.Widgetter // Container for active workspace (exported for border styling)
26 tabModeTarget gtk.Widgetter // Widget currently holding tab mode border margins26 tabModeTarget gtk.Widgetter // Widget currently holding tab mode border margins
27+ progressBar gtk.Widgetter // Thin load progress indicator
27 28
28 // Modal state29 // Modal state
29 tabModeActive bool30 tabModeActive bool
30 tabModeTimer *time.Timer31 tabModeTimer *time.Timer
32+ lastTabCreate time.Time // Debounce guard for rapid tab creation shortcuts
31 33
32 // Rename state34 // Rename state
33 renameInProgress bool35 renameInProgress bool
@@ -52,6 +54,9 @@ type Tab struct {
52 workspace *WorkspaceManager54 workspace *WorkspaceManager
53 titleButton gtk.Widgetter55 titleButton gtk.Widgetter
54 isActive bool56 isActive bool
57+ progress float64
58+ isLoading bool
59+ webViews map[*webkit.WebView]struct{}
55 }60 }
56 61
57 // NewTabManager creates a new tab manager instance.62 // NewTabManager creates a new tab manager instance.
@@ -126,6 +131,9 @@ func (tm *TabManager) createRootContainer() error {
126 contentArea.SetVExpand(true)131 contentArea.SetVExpand(true)
127 contentArea.AddCSSClass("tab-content-area")132 contentArea.AddCSSClass("tab-content-area")
128 133
134+ // Create progress bar (displayed at the bottom regardless of tab bar position)
135+ progressBar := tm.createProgressBar()
136+
129 // Add widgets in order based on position137 // Add widgets in order based on position
130 if position == "top" {138 if position == "top" {
131 rootBox.Append(tabBar)139 rootBox.Append(tabBar)
@@ -135,10 +143,27 @@ func (tm *TabManager) createRootContainer() error {
135 rootBox.Append(tabBar)143 rootBox.Append(tabBar)
136 }144 }
137 145
146+ // Wrap everything in an overlay so the progress bar can float without resizing layout
147+ rootOverlay := gtk.NewOverlay()
148+ if rootOverlay == nil {
149+ return fmt.Errorf("failed to create root overlay")
150+ }
151+ rootOverlay.SetHExpand(true)
152+ rootOverlay.SetVExpand(true)
153+ rootOverlay.SetChild(rootBox)
154+ if progressBar != nil {
155+ if bar, ok := progressBar.(*gtk.ProgressBar); ok && bar != nil {
156+ bar.SetHAlign(gtk.AlignFill)
157+ bar.SetVAlign(gtk.AlignEnd)
158+ }
159+ rootOverlay.AddOverlay(progressBar)
160+ }
161+
138 // Store references162 // Store references
139- tm.rootContainer = rootBox163+ tm.rootContainer = rootOverlay
140 tm.tabBar = tabBar164 tm.tabBar = tabBar
141 tm.ContentArea = contentArea165 tm.ContentArea = contentArea
166+ tm.progressBar = progressBar
142 167
143 logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))168 logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
144 return nil169 return nil
@@ -157,6 +182,12 @@ func (tm *TabManager) CreateTab(url string) error {
157 func (tm *TabManager) createTabInternal(url string) error {182 func (tm *TabManager) createTabInternal(url string) error {
158 logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))183 logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))
159 184
185+ const tabCreateDebounce = 200 * time.Millisecond
186+ if !tm.lastTabCreate.IsZero() && time.Since(tm.lastTabCreate) < tabCreateDebounce {
187+ logging.Warn("[tabs] Ignoring rapid tab creation request (debounced)")
188+ return nil
189+ }
190+
160 // Use default URL if empty191 // Use default URL if empty
161 if url == "" {192 if url == "" {
162 url = "about:blank"193 url = "about:blank"
@@ -172,6 +203,7 @@ func (tm *TabManager) createTabInternal(url string) error {
172 id: tabID,203 id: tabID,
173 title: defaultTitle,204 title: defaultTitle,
174 isActive: false,205 isActive: false,
206+ webViews: make(map[*webkit.WebView]struct{}),
175 }207 }
176 208
177 // Create WebView for this tab209 // Create WebView for this tab
@@ -195,6 +227,7 @@ func (tm *TabManager) createTabInternal(url string) error {
195 // Create workspace for this tab227 // Create workspace for this tab
196 workspace := NewWorkspaceManager(tm.app, pane)228 workspace := NewWorkspaceManager(tm.app, pane)
197 tab.workspace = workspace229 tab.workspace = workspace
230+ tm.registerWebViewLocked(tab, view)
198 231
199 // Verify workspace root exists232 // Verify workspace root exists
200 rootWidget := workspace.GetRootWidget()233 rootWidget := workspace.GetRootWidget()
@@ -222,6 +255,7 @@ func (tm *TabManager) createTabInternal(url string) error {
222 255
223 // Add to tabs slice256 // Add to tabs slice
224 tm.tabs = append(tm.tabs, tab)257 tm.tabs = append(tm.tabs, tab)
258+ tm.lastTabCreate = time.Now()
225 259
226 // Attach click handler with unique ID (pattern from stacked panes)260 // Attach click handler with unique ID (pattern from stacked panes)
227 tm.attachTabClickHandler(tab.titleButton, tab)261 tm.attachTabClickHandler(tab.titleButton, tab)
@@ -325,6 +359,9 @@ func (tm *TabManager) switchToTabInternal(index int) error {
325 newTab.workspace.RestoreFocus()359 newTab.workspace.RestoreFocus()
326 }360 }
327 361
362+ // Sync progress bar with the newly active tab
363+ tm.updateProgressBarLocked()
364+
328 logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))365 logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))
329 return nil366 return nil
330 }367 }
@@ -405,6 +442,9 @@ func (tm *TabManager) closeTabInternal(index int) error {
405 // Update tab bar visibility (hide if only 1 tab)442 // Update tab bar visibility (hide if only 1 tab)
406 tm.updateTabBarVisibility()443 tm.updateTabBarVisibility()
407 444
445+ // Update progress bar (active tab may have changed after close)
446+ tm.updateProgressBarLocked()
447+
408 logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))448 logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))
409 return nil449 return nil
410 }450 }
@@ -553,3 +593,103 @@ func (tm *TabManager) updateTabBarVisibility() {
553 func (tm *TabManager) getConfig() *config.Config {593 func (tm *TabManager) getConfig() *config.Config {
554 return config.Get()594 return config.Get()
555 }595 }
596+
597+// updateProgressForWebView updates progress state for the tab that owns the given WebView.
598+func (tm *TabManager) updateProgressForWebView(view *webkit.WebView, progress float64, loading bool) {
599+ if view == nil || tm == nil {
600+ return
601+ }
602+
603+ tm.mu.Lock()
604+ defer tm.mu.Unlock()
605+
606+ tab := tm.findTabForViewLocked(view)
607+ if tab == nil {
608+ return
609+ }
610+
611+ // Clamp progress
612+ if progress < 0 {
613+ progress = 0
614+ }
615+ if progress > 1 {
616+ progress = 1
617+ }
618+
619+ // Only mark loading when progress is incomplete and the event indicates an active load.
620+ tab.isLoading = loading && progress < 1.0
621+ tab.progress = progress
622+
623+ tm.updateProgressBarLocked()
624+}
625+
626+// findTabForViewLocked finds the tab owning the given WebView. Caller must hold tm.mu.
627+func (tm *TabManager) findTabForViewLocked(view *webkit.WebView) *Tab {
628+ for _, tab := range tm.tabs {
629+ if _, ok := tab.webViews[view]; ok {
630+ return tab
631+ }
632+ }
633+ return nil
634+}
635+
636+// registerWebViewLocked associates a WebView with a tab. Caller must hold tm.mu.
637+func (tm *TabManager) registerWebViewLocked(tab *Tab, view *webkit.WebView) {
638+ if tab == nil || view == nil {
639+ return
640+ }
641+ if tab.webViews == nil {
642+ tab.webViews = make(map[*webkit.WebView]struct{})
643+ }
644+ tab.webViews[view] = struct{}{}
645+}
646+
647+// registerWebViewForWorkspace registers a webview to the tab that owns the workspace.
648+func (tm *TabManager) registerWebViewForWorkspace(view *webkit.WebView, workspace *WorkspaceManager) {
649+ if tm == nil || view == nil || workspace == nil {
650+ return
651+ }
652+
653+ tm.mu.Lock()
654+ defer tm.mu.Unlock()
655+
656+ for _, tab := range tm.tabs {
657+ if tab.workspace == workspace {
658+ tm.registerWebViewLocked(tab, view)
659+ return
660+ }
661+ }
662+}
663+
664+// updateProgressBarLocked syncs the shared progress bar to the active tab. Caller must hold tm.mu.
665+func (tm *TabManager) updateProgressBarLocked() {
666+ if tm.progressBar == nil {
667+ return
668+ }
669+
670+ bar, ok := tm.progressBar.(*gtk.ProgressBar)
671+ if !ok || bar == nil {
672+ return
673+ }
674+
675+ // No active tab -> hide bar
676+ if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) {
677+ webkit.RunOnMainThread(func() {
678+ bar.SetFraction(0.0)
679+ webkit.WidgetSetVisible(bar, false)
680+ })
681+ return
682+ }
683+
684+ activeTab := tm.tabs[tm.activeIndex]
685+ visible := activeTab.isLoading
686+ fraction := activeTab.progress
687+ if !visible {
688+ fraction = 0.0
689+ }
690+
691+ webkit.RunOnMainThread(func() {
692+ bar.SetFraction(fraction)
693+ webkit.WidgetSetVisible(bar, visible)
694+ })
695+}
M internal/app/browser/webview.go +21 -0
@@ -191,6 +191,17 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
191191 pane.messageHandler.Handle(payload)
192192 })
193193
194+ // Page load progress - drive tab-level progress bar
195+ pane.webView.RegisterLoadStartedHandler(func() {
196+ app.handleLoadProgress(pane.webView, 0.0, true)
197+ })
198+ pane.webView.RegisterLoadFinishedHandler(func() {
199+ app.handleLoadProgress(pane.webView, 1.0, false)
200+ })
201+ pane.webView.RegisterLoadProgressHandler(func(progress float64) {
202+ app.handleLoadProgress(pane.webView, progress, true)
203+ })
204+
194205 if pane.messageHandler != nil && pane.navigationController != nil {
195206 pane.messageHandler.SetNavigationController(pane.navigationController)
196207 }
@@ -205,6 +216,16 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
205216 }
206217 }
207218
219+// handleLoadProgress routes WebView load progress events to the tab manager for UI display.
220+func (app *BrowserApp) handleLoadProgress(view *webkit.WebView, progress float64, loading bool) {
221+ if app == nil || view == nil || app.tabManager == nil {
222+ return
223+ }
224+
225+ // Run asynchronously to avoid blocking the GTK/main thread during load-changed signals.
226+ go app.tabManager.updateProgressForWebView(view, progress, loading)
227+}
228+
208229 // shouldFocusForScriptMessage filters script messages and only allows focus handoff
209230 // for explicit user-driven actions (navigation, history commands, popup lifecycle).
210231 // Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are
@@ -191,6 +191,17 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
191 pane.messageHandler.Handle(payload)191 pane.messageHandler.Handle(payload)
192 })192 })
193 193
194+ // Page load progress - drive tab-level progress bar
195+ pane.webView.RegisterLoadStartedHandler(func() {
196+ app.handleLoadProgress(pane.webView, 0.0, true)
197+ })
198+ pane.webView.RegisterLoadFinishedHandler(func() {
199+ app.handleLoadProgress(pane.webView, 1.0, false)
200+ })
201+ pane.webView.RegisterLoadProgressHandler(func(progress float64) {
202+ app.handleLoadProgress(pane.webView, progress, true)
203+ })
204+
194 if pane.messageHandler != nil && pane.navigationController != nil {205 if pane.messageHandler != nil && pane.navigationController != nil {
195 pane.messageHandler.SetNavigationController(pane.navigationController)206 pane.messageHandler.SetNavigationController(pane.navigationController)
196 }207 }
@@ -205,6 +216,16 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
205 }216 }
206 }217 }
207 218
219+// handleLoadProgress routes WebView load progress events to the tab manager for UI display.
220+func (app *BrowserApp) handleLoadProgress(view *webkit.WebView, progress float64, loading bool) {
221+ if app == nil || view == nil || app.tabManager == nil {
222+ return
223+ }
224+
225+ // Run asynchronously to avoid blocking the GTK/main thread during load-changed signals.
226+ go app.tabManager.updateProgressForWebView(view, progress, loading)
227+}
228+
208 // shouldFocusForScriptMessage filters script messages and only allows focus handoff229 // shouldFocusForScriptMessage filters script messages and only allows focus handoff
209 // for explicit user-driven actions (navigation, history commands, popup lifecycle).230 // for explicit user-driven actions (navigation, history commands, popup lifecycle).
210 // Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are231 // Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are
M internal/app/browser/workspace_css.go +43 -9
@@ -241,6 +241,32 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
241241 min-height: %dpx;
242242 }
243243
244+ /* Tab progress bar */
245+ progressbar.tab-progress-bar {
246+ min-height: 4px;
247+ padding: 0;
248+ margin: 0;
249+ border: none;
250+ transition: opacity 180ms ease-in-out;
251+ }
252+
253+ progressbar.tab-progress-bar trough {
254+ min-height: 4px;
255+ background: transparent;
256+ border: none;
257+ padding: 0;
258+ }
259+
260+ progressbar.tab-progress-bar progress {
261+ min-height: 4px;
262+ border-radius: 0;
263+ background-image: linear-gradient(90deg, %s 0%%, %s 50%%, %s 100%%);
264+ background-size: 240%% 100%%;
265+ background-position: 0%% 0%%;
266+ transition: width 180ms ease-in-out, background-position 420ms linear;
267+ animation: tab-progress-stripe 1.3s linear infinite;
268+ }
269+
244270 /* Tab mode active - window background changes to border color (like pane mode) */
245271 window.tab-mode-active {
246272 background-color: %s;
@@ -312,6 +338,11 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
312338 @keyframes pulse-border {
313339 0%%, 100%% { opacity: 1.0; }
314340 50%% { opacity: 0.6; }
341+ }
342+
343+ @keyframes tab-progress-stripe {
344+ 0%% { background-position: 0%% 0%%; }
345+ 100%% { background-position: -100%% 0%%; }
315346 }`,
316347 windowBackgroundColor, // window background
317348 paneModeColor, // window.pane-mode-active background (border color)
@@ -339,6 +370,9 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
339370 windowBackgroundColor, // tab-bar background
340371 getStackTitleBorderColor(isDark), // tab-bar border-top
341372 int(32*uiScale), // tab-bar min-height
373+ "#606060", // tab progress gradient start (neutral gray)
374+ "#747474", // tab progress gradient middle (neutral gray)
375+ "#8a8a8a", // tab progress gradient end (neutral gray)
342376 tabModeColor, // window.tab-mode-active .tab-bar border-top-color (orange, distinct from pane mode blue)
343377 getStackTitleBg(isDark), // tab-button background (same as stacked title)
344378 getStackTitleBorderColor(isDark), // tab-button border-right
@@ -351,15 +385,15 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
351385 titleFontSize, // tab-title font-size
352386 getStackTitleTextColor(isDark), // tab-title color
353387 // Tab rename entry styles
354- getEntryBg(isDark), // entry.tab-rename-entry background
355- getStackTitleTextColor(isDark), // entry.tab-rename-entry color (text)
356- titleFontSize, // entry.tab-rename-entry font-size
357- titlePaddingHorizontal, // entry.tab-rename-entry padding horizontal (vertical is hardcoded 2px)
358- getEntryBorderColor(isDark), // entry.tab-rename-entry border-color
359- getEntryFocusBg(isDark), // entry.tab-rename-entry:focus background
360- activeBorderColor, // entry.tab-rename-entry:focus border-color (use active pane color)
361- windowBackgroundColor, // tab-content-area background
362- windowBackgroundColor, // tab-workspace-container background
388+ getEntryBg(isDark), // entry.tab-rename-entry background
389+ getStackTitleTextColor(isDark), // entry.tab-rename-entry color (text)
390+ titleFontSize, // entry.tab-rename-entry font-size
391+ titlePaddingHorizontal, // entry.tab-rename-entry padding horizontal (vertical is hardcoded 2px)
392+ getEntryBorderColor(isDark), // entry.tab-rename-entry border-color
393+ getEntryFocusBg(isDark), // entry.tab-rename-entry:focus background
394+ activeBorderColor, // entry.tab-rename-entry:focus border-color (use active pane color)
395+ windowBackgroundColor, // tab-content-area background
396+ windowBackgroundColor, // tab-workspace-container background
363397 )
364398
365399 return css
@@ -241,6 +241,32 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
241 min-height: %dpx;241 min-height: %dpx;
242 }242 }
243 243
244+ /* Tab progress bar */
245+ progressbar.tab-progress-bar {
246+ min-height: 4px;
247+ padding: 0;
248+ margin: 0;
249+ border: none;
250+ transition: opacity 180ms ease-in-out;
251+ }
252+
253+ progressbar.tab-progress-bar trough {
254+ min-height: 4px;
255+ background: transparent;
256+ border: none;
257+ padding: 0;
258+ }
259+
260+ progressbar.tab-progress-bar progress {
261+ min-height: 4px;
262+ border-radius: 0;
263+ background-image: linear-gradient(90deg, %s 0%%, %s 50%%, %s 100%%);
264+ background-size: 240%% 100%%;
265+ background-position: 0%% 0%%;
266+ transition: width 180ms ease-in-out, background-position 420ms linear;
267+ animation: tab-progress-stripe 1.3s linear infinite;
268+ }
269+
244 /* Tab mode active - window background changes to border color (like pane mode) */270 /* Tab mode active - window background changes to border color (like pane mode) */
245 window.tab-mode-active {271 window.tab-mode-active {
246 background-color: %s;272 background-color: %s;
@@ -312,6 +338,11 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
312 @keyframes pulse-border {338 @keyframes pulse-border {
313 0%%, 100%% { opacity: 1.0; }339 0%%, 100%% { opacity: 1.0; }
314 50%% { opacity: 0.6; }340 50%% { opacity: 0.6; }
341+ }
342+
343+ @keyframes tab-progress-stripe {
344+ 0%% { background-position: 0%% 0%%; }
345+ 100%% { background-position: -100%% 0%%; }
315 }`,346 }`,
316 windowBackgroundColor, // window background347 windowBackgroundColor, // window background
317 paneModeColor, // window.pane-mode-active background (border color)348 paneModeColor, // window.pane-mode-active background (border color)
@@ -339,6 +370,9 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
339 windowBackgroundColor, // tab-bar background370 windowBackgroundColor, // tab-bar background
340 getStackTitleBorderColor(isDark), // tab-bar border-top371 getStackTitleBorderColor(isDark), // tab-bar border-top
341 int(32*uiScale), // tab-bar min-height372 int(32*uiScale), // tab-bar min-height
373+ "#606060", // tab progress gradient start (neutral gray)
374+ "#747474", // tab progress gradient middle (neutral gray)
375+ "#8a8a8a", // tab progress gradient end (neutral gray)
342 tabModeColor, // window.tab-mode-active .tab-bar border-top-color (orange, distinct from pane mode blue)376 tabModeColor, // window.tab-mode-active .tab-bar border-top-color (orange, distinct from pane mode blue)
343 getStackTitleBg(isDark), // tab-button background (same as stacked title)377 getStackTitleBg(isDark), // tab-button background (same as stacked title)
344 getStackTitleBorderColor(isDark), // tab-button border-right378 getStackTitleBorderColor(isDark), // tab-button border-right
@@ -351,15 +385,15 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
351 titleFontSize, // tab-title font-size385 titleFontSize, // tab-title font-size
352 getStackTitleTextColor(isDark), // tab-title color386 getStackTitleTextColor(isDark), // tab-title color
353 // Tab rename entry styles387 // Tab rename entry styles
354- getEntryBg(isDark), // entry.tab-rename-entry background388+ getEntryBg(isDark), // entry.tab-rename-entry background
355- getStackTitleTextColor(isDark), // entry.tab-rename-entry color (text)389+ getStackTitleTextColor(isDark), // entry.tab-rename-entry color (text)
356- titleFontSize, // entry.tab-rename-entry font-size390+ titleFontSize, // entry.tab-rename-entry font-size
357- titlePaddingHorizontal, // entry.tab-rename-entry padding horizontal (vertical is hardcoded 2px)391+ titlePaddingHorizontal, // entry.tab-rename-entry padding horizontal (vertical is hardcoded 2px)
358- getEntryBorderColor(isDark), // entry.tab-rename-entry border-color392+ getEntryBorderColor(isDark), // entry.tab-rename-entry border-color
359- getEntryFocusBg(isDark), // entry.tab-rename-entry:focus background393+ getEntryFocusBg(isDark), // entry.tab-rename-entry:focus background
360- activeBorderColor, // entry.tab-rename-entry:focus border-color (use active pane color)394+ activeBorderColor, // entry.tab-rename-entry:focus border-color (use active pane color)
361- windowBackgroundColor, // tab-content-area background395+ windowBackgroundColor, // tab-content-area background
362- windowBackgroundColor, // tab-workspace-container background396+ windowBackgroundColor, // tab-workspace-container background
363 )397 )
364 398
365 return css399 return css
M internal/app/browser/workspace_pane_ops.go +11 -1
@@ -164,7 +164,17 @@ func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, erro
164164 if wm == nil || wm.createPaneFn == nil {
165165 return nil, errors.New("workspace manager missing pane factory")
166166 }
167- return wm.createPaneFn(view)
167+ pane, err := wm.createPaneFn(view)
168+ if err != nil {
169+ return nil, err
170+ }
171+
172+ // Register the new WebView with the tab manager for progress tracking.
173+ if wm.app != nil && wm.app.tabManager != nil {
174+ wm.app.tabManager.registerWebViewForWorkspace(view, wm)
175+ }
176+
177+ return pane, nil
168178 }
169179
170180 // clonePaneState sets up a new pane with cloned state from another pane
@@ -164,7 +164,17 @@ func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, erro
164 if wm == nil || wm.createPaneFn == nil {164 if wm == nil || wm.createPaneFn == nil {
165 return nil, errors.New("workspace manager missing pane factory")165 return nil, errors.New("workspace manager missing pane factory")
166 }166 }
167- return wm.createPaneFn(view)167+ pane, err := wm.createPaneFn(view)
168+ if err != nil {
169+ return nil, err
170+ }
171+
172+ // Register the new WebView with the tab manager for progress tracking.
173+ if wm.app != nil && wm.app.tabManager != nil {
174+ wm.app.tabManager.registerWebViewForWorkspace(view, wm)
175+ }
176+
177+ return pane, nil
168 }178 }
169 179
170 // clonePaneState sets up a new pane with cloned state from another pane180 // clonePaneState sets up a new pane with cloned state from another pane
M pkg/webkit/webview.go +37 -0
@@ -43,6 +43,9 @@ type WebView struct {
4343 onFaviconURIChanged func(pageURI, faviconURI string)
4444 onZoomChanged func(float64)
4545 onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom)
46+ onLoadStarted func() // Called when a load starts
47+ onLoadFinished func() // Called when a load finishes
48+ onLoadProgress func(float64) // Called when estimated-load-progress changes
4649 onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
4750 onReadyToShow func() // WebKit ready-to-show signal handler
4851 onClose func()
@@ -246,10 +249,23 @@ func (w *WebView) setupEventHandlers() {
246249
247250 // Load committed - connect to load-changed signal for WEBKIT_LOAD_COMMITTED
248251 w.view.ConnectLoadChanged(func(loadEvent webkit.LoadEvent) {
252+ if loadEvent == webkit.LoadStarted && w.onLoadStarted != nil {
253+ w.onLoadStarted()
254+ }
249255 if loadEvent == webkit.LoadCommitted && w.onLoadCommitted != nil {
250256 uri := w.view.URI()
251257 w.onLoadCommitted(uri)
252258 }
259+ if loadEvent == webkit.LoadFinished && w.onLoadFinished != nil {
260+ w.onLoadFinished()
261+ }
262+ })
263+
264+ // Estimated load progress - notify::estimated-load-progress property
265+ w.view.Connect("notify::estimated-load-progress", func() {
266+ if w.onLoadProgress != nil {
267+ w.onLoadProgress(w.view.EstimatedLoadProgress())
268+ }
253269 })
254270
255271 // Favicon changed - connect to FaviconDatabase
@@ -722,6 +738,27 @@ func (w *WebView) RegisterLoadCommittedHandler(handler func(string)) {
722738 w.onLoadCommitted = handler
723739 }
724740
741+// RegisterLoadStartedHandler registers a handler for load start events.
742+func (w *WebView) RegisterLoadStartedHandler(handler func()) {
743+ w.mu.Lock()
744+ defer w.mu.Unlock()
745+ w.onLoadStarted = handler
746+}
747+
748+// RegisterLoadFinishedHandler registers a handler for load finished events.
749+func (w *WebView) RegisterLoadFinishedHandler(handler func()) {
750+ w.mu.Lock()
751+ defer w.mu.Unlock()
752+ w.onLoadFinished = handler
753+}
754+
755+// RegisterLoadProgressHandler registers a handler for load progress updates (0.0 - 1.0).
756+func (w *WebView) RegisterLoadProgressHandler(handler func(float64)) {
757+ w.mu.Lock()
758+ defer w.mu.Unlock()
759+ w.onLoadProgress = handler
760+}
761+
725762 // RegisterCloseHandler registers a handler for close requests
726763 func (w *WebView) RegisterCloseHandler(handler func()) {
727764 w.mu.Lock()
@@ -43,6 +43,9 @@ type WebView struct {
43 onFaviconURIChanged func(pageURI, faviconURI string)43 onFaviconURIChanged func(pageURI, faviconURI string)
44 onZoomChanged func(float64)44 onZoomChanged func(float64)
45 onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom)45 onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom)
46+ onLoadStarted func() // Called when a load starts
47+ onLoadFinished func() // Called when a load finishes
48+ onLoadProgress func(float64) // Called when estimated-load-progress changes
46 onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler49 onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
47 onReadyToShow func() // WebKit ready-to-show signal handler50 onReadyToShow func() // WebKit ready-to-show signal handler
48 onClose func()51 onClose func()
@@ -246,10 +249,23 @@ func (w *WebView) setupEventHandlers() {
246 249
247 // Load committed - connect to load-changed signal for WEBKIT_LOAD_COMMITTED250 // Load committed - connect to load-changed signal for WEBKIT_LOAD_COMMITTED
248 w.view.ConnectLoadChanged(func(loadEvent webkit.LoadEvent) {251 w.view.ConnectLoadChanged(func(loadEvent webkit.LoadEvent) {
252+ if loadEvent == webkit.LoadStarted && w.onLoadStarted != nil {
253+ w.onLoadStarted()
254+ }
249 if loadEvent == webkit.LoadCommitted && w.onLoadCommitted != nil {255 if loadEvent == webkit.LoadCommitted && w.onLoadCommitted != nil {
250 uri := w.view.URI()256 uri := w.view.URI()
251 w.onLoadCommitted(uri)257 w.onLoadCommitted(uri)
252 }258 }
259+ if loadEvent == webkit.LoadFinished && w.onLoadFinished != nil {
260+ w.onLoadFinished()
261+ }
262+ })
263+
264+ // Estimated load progress - notify::estimated-load-progress property
265+ w.view.Connect("notify::estimated-load-progress", func() {
266+ if w.onLoadProgress != nil {
267+ w.onLoadProgress(w.view.EstimatedLoadProgress())
268+ }
253 })269 })
254 270
255 // Favicon changed - connect to FaviconDatabase271 // Favicon changed - connect to FaviconDatabase
@@ -722,6 +738,27 @@ func (w *WebView) RegisterLoadCommittedHandler(handler func(string)) {
722 w.onLoadCommitted = handler738 w.onLoadCommitted = handler
723 }739 }
724 740
741+// RegisterLoadStartedHandler registers a handler for load start events.
742+func (w *WebView) RegisterLoadStartedHandler(handler func()) {
743+ w.mu.Lock()
744+ defer w.mu.Unlock()
745+ w.onLoadStarted = handler
746+}
747+
748+// RegisterLoadFinishedHandler registers a handler for load finished events.
749+func (w *WebView) RegisterLoadFinishedHandler(handler func()) {
750+ w.mu.Lock()
751+ defer w.mu.Unlock()
752+ w.onLoadFinished = handler
753+}
754+
755+// RegisterLoadProgressHandler registers a handler for load progress updates (0.0 - 1.0).
756+func (w *WebView) RegisterLoadProgressHandler(handler func(float64)) {
757+ w.mu.Lock()
758+ defer w.mu.Unlock()
759+ w.onLoadProgress = handler
760+}
761+
725 // RegisterCloseHandler registers a handler for close requests762 // RegisterCloseHandler registers a handler for close requests
726 func (w *WebView) RegisterCloseHandler(handler func()) {763 func (w *WebView) RegisterCloseHandler(handler func()) {
727 w.mu.Lock()764 w.mu.Lock()

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