detached e4c93f7

feat(app): integrate native omnibox with navigation and history recording

commit
e4c93f7cf7132a5cba9ff6f57d20306d836d2ded
author
brice · · 2025-12-14 20:50 UTC
committer
brice · · 2025-12-14 18:50 UTC
parent
09cb23d
signature
unsigned
9 files · +130 -91
M internal/ui/app.go +81 -9
@@ -45,6 +45,9 @@ type App struct {
4545 // Input handling
4646 keyboardHandler *input.KeyboardHandler
4747
48+ // Native omnibox
49+ omnibox *component.Omnibox
50+
4851 // Web content
4952 pool *webkit.WebViewPool
5053 injector *webkit.ContentInjector
@@ -99,7 +102,8 @@ func (a *App) Run(ctx context.Context, args []string) int {
99102 log := logging.FromContext(ctx)
100103 log.Debug().Msg("creating GTK application")
101104
102- a.gtkApp = gtk.NewApplication(AppID, gio.GApplicationFlagsNoneValue)
105+ // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
106+ a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
103107 if a.gtkApp == nil {
104108 log.Error().Msg("failed to create GTK application")
105109 return 1
@@ -163,6 +167,23 @@ func (a *App) onActivate(ctx context.Context) {
163167 })
164168 a.keyboardHandler.AttachTo(a.mainWindow.Window())
165169
170+ // Create native omnibox
171+ a.omnibox = component.NewOmnibox(ctx, a.mainWindow.Window(), component.OmniboxConfig{
172+ HistoryUC: a.deps.HistoryUC,
173+ FavoritesUC: a.deps.FavoritesUC,
174+ Shortcuts: a.deps.Config.SearchShortcuts,
175+ DefaultSearch: a.deps.Config.DefaultSearchEngine,
176+ InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
177+ })
178+ if a.omnibox != nil {
179+ a.omnibox.SetOnNavigate(func(url string) {
180+ a.navigateActivePane(ctx, url)
181+ })
182+ log.Debug().Msg("native omnibox created")
183+ } else {
184+ log.Warn().Msg("failed to create native omnibox")
185+ }
186+
166187 if err := a.registerMessageHandlers(ctx); err != nil {
167188 log.Warn().Err(err).Msg("failed to register message handlers")
168189 }
@@ -799,21 +820,72 @@ func (a *App) activeWebView(ctx context.Context) *webkit.WebView {
799820 return a.webViews[pane.Pane.ID]
800821 }
801822
802-// openOmnibox toggles the omnibox overlay for the active WebView.
823+// openOmnibox toggles the native GTK omnibox.
803824 func (a *App) openOmnibox(ctx context.Context) error {
804825 log := logging.FromContext(ctx)
805826
806- if a.overlay == nil {
807- log.Error().Msg("overlay controller not configured")
808- return fmt.Errorf("overlay controller not configured")
827+ if a.omnibox == nil {
828+ log.Error().Msg("native omnibox not initialized")
829+ return fmt.Errorf("native omnibox not initialized")
809830 }
831+
832+ log.Debug().Msg("toggling native omnibox")
833+ a.omnibox.Toggle(ctx)
834+ return nil
835+}
836+
837+// navigateActivePane navigates the active pane to the given URL.
838+// Records history for omnibox search to find it immediately.
839+func (a *App) navigateActivePane(ctx context.Context, url string) {
840+ log := logging.FromContext(ctx)
841+
810842 wv := a.activeWebView(ctx)
811843 if wv == nil {
812- log.Error().Msg("no active webview for omnibox")
813- return fmt.Errorf("no active webview for omnibox")
844+ log.Warn().Str("url", url).Msg("no active webview for navigation")
845+ return
846+ }
847+
848+ // Load the URL
849+ if err := wv.LoadURI(ctx, url); err != nil {
850+ log.Error().Err(err).Str("url", url).Msg("failed to navigate")
851+ return
852+ }
853+
854+ // Record in history asynchronously so it appears in omnibox immediately
855+ if a.deps.HistoryRepo != nil {
856+ go a.recordHistory(ctx, url)
857+ }
858+
859+ log.Debug().Str("url", url).Msg("navigated active pane")
860+}
861+
862+// recordHistory saves or updates a history entry.
863+func (a *App) recordHistory(ctx context.Context, url string) {
864+ log := logging.FromContext(ctx)
865+ log.Debug().Str("url", url).Msg("recording history entry")
866+
867+ existing, err := a.deps.HistoryRepo.FindByURL(ctx, url)
868+ if err != nil {
869+ log.Warn().Err(err).Str("url", url).Msg("failed to check history")
870+ return
871+ }
872+
873+ if existing != nil {
874+ // Increment visit count
875+ if err := a.deps.HistoryRepo.IncrementVisitCount(ctx, url); err != nil {
876+ log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
877+ } else {
878+ log.Debug().Str("url", url).Int64("prev_count", existing.VisitCount).Msg("history visit count incremented")
879+ }
880+ } else {
881+ // Create new entry
882+ entry := entity.NewHistoryEntry(url, "")
883+ if err := a.deps.HistoryRepo.Save(ctx, entry); err != nil {
884+ log.Warn().Err(err).Str("url", url).Msg("failed to save history")
885+ } else {
886+ log.Debug().Str("url", url).Msg("new history entry saved")
887+ }
814888 }
815- log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening omnibox")
816- return a.overlay.Show(ctx, wv.ID(), "omnibox", "")
817889 }
818890
819891 // openDevTools opens the WebKit inspector for the active WebView.
@@ -45,6 +45,9 @@ type App struct {
45 // Input handling45 // Input handling
46 keyboardHandler *input.KeyboardHandler46 keyboardHandler *input.KeyboardHandler
47 47
48+ // Native omnibox
49+ omnibox *component.Omnibox
50+
48 // Web content51 // Web content
49 pool *webkit.WebViewPool52 pool *webkit.WebViewPool
50 injector *webkit.ContentInjector53 injector *webkit.ContentInjector
@@ -99,7 +102,8 @@ func (a *App) Run(ctx context.Context, args []string) int {
99 log := logging.FromContext(ctx)102 log := logging.FromContext(ctx)
100 log.Debug().Msg("creating GTK application")103 log.Debug().Msg("creating GTK application")
101 104
102- a.gtkApp = gtk.NewApplication(AppID, gio.GApplicationFlagsNoneValue)105+ // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
106+ a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
103 if a.gtkApp == nil {107 if a.gtkApp == nil {
104 log.Error().Msg("failed to create GTK application")108 log.Error().Msg("failed to create GTK application")
105 return 1109 return 1
@@ -163,6 +167,23 @@ func (a *App) onActivate(ctx context.Context) {
163 })167 })
164 a.keyboardHandler.AttachTo(a.mainWindow.Window())168 a.keyboardHandler.AttachTo(a.mainWindow.Window())
165 169
170+ // Create native omnibox
171+ a.omnibox = component.NewOmnibox(ctx, a.mainWindow.Window(), component.OmniboxConfig{
172+ HistoryUC: a.deps.HistoryUC,
173+ FavoritesUC: a.deps.FavoritesUC,
174+ Shortcuts: a.deps.Config.SearchShortcuts,
175+ DefaultSearch: a.deps.Config.DefaultSearchEngine,
176+ InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
177+ })
178+ if a.omnibox != nil {
179+ a.omnibox.SetOnNavigate(func(url string) {
180+ a.navigateActivePane(ctx, url)
181+ })
182+ log.Debug().Msg("native omnibox created")
183+ } else {
184+ log.Warn().Msg("failed to create native omnibox")
185+ }
186+
166 if err := a.registerMessageHandlers(ctx); err != nil {187 if err := a.registerMessageHandlers(ctx); err != nil {
167 log.Warn().Err(err).Msg("failed to register message handlers")188 log.Warn().Err(err).Msg("failed to register message handlers")
168 }189 }
@@ -799,21 +820,72 @@ func (a *App) activeWebView(ctx context.Context) *webkit.WebView {
799 return a.webViews[pane.Pane.ID]820 return a.webViews[pane.Pane.ID]
800 }821 }
801 822
802-// openOmnibox toggles the omnibox overlay for the active WebView.823+// openOmnibox toggles the native GTK omnibox.
803 func (a *App) openOmnibox(ctx context.Context) error {824 func (a *App) openOmnibox(ctx context.Context) error {
804 log := logging.FromContext(ctx)825 log := logging.FromContext(ctx)
805 826
806- if a.overlay == nil {827+ if a.omnibox == nil {
807- log.Error().Msg("overlay controller not configured")828+ log.Error().Msg("native omnibox not initialized")
808- return fmt.Errorf("overlay controller not configured")829+ return fmt.Errorf("native omnibox not initialized")
809 }830 }
831+
832+ log.Debug().Msg("toggling native omnibox")
833+ a.omnibox.Toggle(ctx)
834+ return nil
835+}
836+
837+// navigateActivePane navigates the active pane to the given URL.
838+// Records history for omnibox search to find it immediately.
839+func (a *App) navigateActivePane(ctx context.Context, url string) {
840+ log := logging.FromContext(ctx)
841+
810 wv := a.activeWebView(ctx)842 wv := a.activeWebView(ctx)
811 if wv == nil {843 if wv == nil {
812- log.Error().Msg("no active webview for omnibox")844+ log.Warn().Str("url", url).Msg("no active webview for navigation")
813- return fmt.Errorf("no active webview for omnibox")845+ return
846+ }
847+
848+ // Load the URL
849+ if err := wv.LoadURI(ctx, url); err != nil {
850+ log.Error().Err(err).Str("url", url).Msg("failed to navigate")
851+ return
852+ }
853+
854+ // Record in history asynchronously so it appears in omnibox immediately
855+ if a.deps.HistoryRepo != nil {
856+ go a.recordHistory(ctx, url)
857+ }
858+
859+ log.Debug().Str("url", url).Msg("navigated active pane")
860+}
861+
862+// recordHistory saves or updates a history entry.
863+func (a *App) recordHistory(ctx context.Context, url string) {
864+ log := logging.FromContext(ctx)
865+ log.Debug().Str("url", url).Msg("recording history entry")
866+
867+ existing, err := a.deps.HistoryRepo.FindByURL(ctx, url)
868+ if err != nil {
869+ log.Warn().Err(err).Str("url", url).Msg("failed to check history")
870+ return
871+ }
872+
873+ if existing != nil {
874+ // Increment visit count
875+ if err := a.deps.HistoryRepo.IncrementVisitCount(ctx, url); err != nil {
876+ log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
877+ } else {
878+ log.Debug().Str("url", url).Int64("prev_count", existing.VisitCount).Msg("history visit count incremented")
879+ }
880+ } else {
881+ // Create new entry
882+ entry := entity.NewHistoryEntry(url, "")
883+ if err := a.deps.HistoryRepo.Save(ctx, entry); err != nil {
884+ log.Warn().Err(err).Str("url", url).Msg("failed to save history")
885+ } else {
886+ log.Debug().Str("url", url).Msg("new history entry saved")
887+ }
814 }888 }
815- log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening omnibox")
816- return a.overlay.Show(ctx, wv.ID(), "omnibox", "")
817 }889 }
818 890
819 // openDevTools opens the WebKit inspector for the active WebView.891 // openDevTools opens the WebKit inspector for the active WebView.
M internal/ui/component/overlay.go +9 -39
@@ -18,32 +18,23 @@ type overlayState struct {
1818
1919 // OverlayController controls the injected overlay UI (omnibox/find).
2020 // It issues JavaScript calls into the WebView to show/hide the overlay.
21-// Logging is derived from the provided context; no logger fields are stored.
2221 type OverlayController struct {
23- baseCtx context.Context
24-
2522 mu sync.RWMutex
2623 states map[webkit.WebViewID]overlayState
2724 }
2825
2926 // NewOverlayController creates a new overlay controller.
3027 func NewOverlayController(ctx context.Context) *OverlayController {
31- if ctx == nil {
32- ctx = context.Background()
33- }
34-
28+ log := logging.FromContext(ctx)
29+ log.Debug().Msg("overlay controller initialized")
3530 return &OverlayController{
36- baseCtx: ctx,
37- states: make(map[webkit.WebViewID]overlayState),
31+ states: make(map[webkit.WebViewID]overlayState),
3832 }
3933 }
4034
4135 // Show opens the overlay in the given mode ("omnibox" default or "find").
4236 // An optional query seeds the overlay input.
4337 func (oc *OverlayController) Show(ctx context.Context, webviewID webkit.WebViewID, mode, query string) error {
44- if ctx == nil {
45- ctx = oc.baseCtx
46- }
4738 if mode == "" {
4839 mode = "omnibox"
4940 }
@@ -77,10 +68,6 @@ func (oc *OverlayController) Show(ctx context.Context, webviewID webkit.WebViewI
7768
7869 // Hide closes the overlay if present.
7970 func (oc *OverlayController) Hide(ctx context.Context, webviewID webkit.WebViewID) error {
80- if ctx == nil {
81- ctx = oc.baseCtx
82- }
83-
8471 log := logging.FromContext(ctx).With().
8572 Str("component", "overlay-controller").
8673 Uint64("webview_id", uint64(webviewID)).
@@ -100,10 +87,6 @@ func (oc *OverlayController) Hide(ctx context.Context, webviewID webkit.WebViewI
10087
10188 // Toggle toggles the overlay visibility.
10289 func (oc *OverlayController) Toggle(ctx context.Context, webviewID webkit.WebViewID) error {
103- if ctx == nil {
104- ctx = oc.baseCtx
105- }
106-
10790 log := logging.FromContext(ctx).With().
10891 Str("component", "overlay-controller").
10992 Uint64("webview_id", uint64(webviewID)).
@@ -125,10 +108,6 @@ func (oc *OverlayController) Toggle(ctx context.Context, webviewID webkit.WebVie
125108
126109 // OpenFind opens the find-in-page overlay with an optional query.
127110 func (oc *OverlayController) OpenFind(ctx context.Context, webviewID webkit.WebViewID, query string) error {
128- if ctx == nil {
129- ctx = oc.baseCtx
130- }
131-
132111 log := logging.FromContext(ctx).With().
133112 Str("component", "overlay-controller").
134113 Uint64("webview_id", uint64(webviewID)).
@@ -172,18 +151,12 @@ func (oc *OverlayController) getState(webviewID webkit.WebViewID) overlayState {
172151 return overlayState{}
173152 }
174153
175-// runJavaScript executes script in the main world for the given WebView.
154+// runJavaScript executes script in the isolated "dumber" world for the given WebView.
155+// This is fire-and-forget: errors are logged asynchronously by the WebView.
176156 func (oc *OverlayController) runJavaScript(ctx context.Context, webviewID webkit.WebViewID, script string) error {
177- if ctx == nil {
178- ctx = oc.baseCtx
179- }
180-
181- log := logging.FromContext(ctx).With().
182- Str("component", "overlay-controller").
183- Uint64("webview_id", uint64(webviewID)).
184- Logger()
185-
157+ log := logging.FromContext(ctx)
186158 if webviewID == 0 {
159+ log.Debug().Msg("runJavaScript called with webviewID=0")
187160 return fmt.Errorf("webview id is required for overlay command")
188161 }
189162
@@ -192,10 +165,7 @@ func (oc *OverlayController) runJavaScript(ctx context.Context, webviewID webkit
192165 return fmt.Errorf("webview %d not found", webviewID)
193166 }
194167
195- if err := wv.RunJavaScript(ctx, script, ""); err != nil {
196- log.Warn().Err(err).Msg("overlay JS execution failed")
197- return err
198- }
199-
168+ // Run in the isolated "dumber" world where the GUI scripts are injected
169+ wv.RunJavaScript(ctx, script, webkit.ScriptWorldName)
200170 return nil
201171 }
@@ -18,32 +18,23 @@ type overlayState struct {
18 18
19 // OverlayController controls the injected overlay UI (omnibox/find).19 // OverlayController controls the injected overlay UI (omnibox/find).
20 // It issues JavaScript calls into the WebView to show/hide the overlay.20 // It issues JavaScript calls into the WebView to show/hide the overlay.
21-// Logging is derived from the provided context; no logger fields are stored.
22 type OverlayController struct {21 type OverlayController struct {
23- baseCtx context.Context
24-
25 mu sync.RWMutex22 mu sync.RWMutex
26 states map[webkit.WebViewID]overlayState23 states map[webkit.WebViewID]overlayState
27 }24 }
28 25
29 // NewOverlayController creates a new overlay controller.26 // NewOverlayController creates a new overlay controller.
30 func NewOverlayController(ctx context.Context) *OverlayController {27 func NewOverlayController(ctx context.Context) *OverlayController {
31- if ctx == nil {28+ log := logging.FromContext(ctx)
32- ctx = context.Background()29+ log.Debug().Msg("overlay controller initialized")
33- }
34-
35 return &OverlayController{30 return &OverlayController{
36- baseCtx: ctx,31+ states: make(map[webkit.WebViewID]overlayState),
37- states: make(map[webkit.WebViewID]overlayState),
38 }32 }
39 }33 }
40 34
41 // Show opens the overlay in the given mode ("omnibox" default or "find").35 // Show opens the overlay in the given mode ("omnibox" default or "find").
42 // An optional query seeds the overlay input.36 // An optional query seeds the overlay input.
43 func (oc *OverlayController) Show(ctx context.Context, webviewID webkit.WebViewID, mode, query string) error {37 func (oc *OverlayController) Show(ctx context.Context, webviewID webkit.WebViewID, mode, query string) error {
44- if ctx == nil {
45- ctx = oc.baseCtx
46- }
47 if mode == "" {38 if mode == "" {
48 mode = "omnibox"39 mode = "omnibox"
49 }40 }
@@ -77,10 +68,6 @@ func (oc *OverlayController) Show(ctx context.Context, webviewID webkit.WebViewI
77 68
78 // Hide closes the overlay if present.69 // Hide closes the overlay if present.
79 func (oc *OverlayController) Hide(ctx context.Context, webviewID webkit.WebViewID) error {70 func (oc *OverlayController) Hide(ctx context.Context, webviewID webkit.WebViewID) error {
80- if ctx == nil {
81- ctx = oc.baseCtx
82- }
83-
84 log := logging.FromContext(ctx).With().71 log := logging.FromContext(ctx).With().
85 Str("component", "overlay-controller").72 Str("component", "overlay-controller").
86 Uint64("webview_id", uint64(webviewID)).73 Uint64("webview_id", uint64(webviewID)).
@@ -100,10 +87,6 @@ func (oc *OverlayController) Hide(ctx context.Context, webviewID webkit.WebViewI
100 87
101 // Toggle toggles the overlay visibility.88 // Toggle toggles the overlay visibility.
102 func (oc *OverlayController) Toggle(ctx context.Context, webviewID webkit.WebViewID) error {89 func (oc *OverlayController) Toggle(ctx context.Context, webviewID webkit.WebViewID) error {
103- if ctx == nil {
104- ctx = oc.baseCtx
105- }
106-
107 log := logging.FromContext(ctx).With().90 log := logging.FromContext(ctx).With().
108 Str("component", "overlay-controller").91 Str("component", "overlay-controller").
109 Uint64("webview_id", uint64(webviewID)).92 Uint64("webview_id", uint64(webviewID)).
@@ -125,10 +108,6 @@ func (oc *OverlayController) Toggle(ctx context.Context, webviewID webkit.WebVie
125 108
126 // OpenFind opens the find-in-page overlay with an optional query.109 // OpenFind opens the find-in-page overlay with an optional query.
127 func (oc *OverlayController) OpenFind(ctx context.Context, webviewID webkit.WebViewID, query string) error {110 func (oc *OverlayController) OpenFind(ctx context.Context, webviewID webkit.WebViewID, query string) error {
128- if ctx == nil {
129- ctx = oc.baseCtx
130- }
131-
132 log := logging.FromContext(ctx).With().111 log := logging.FromContext(ctx).With().
133 Str("component", "overlay-controller").112 Str("component", "overlay-controller").
134 Uint64("webview_id", uint64(webviewID)).113 Uint64("webview_id", uint64(webviewID)).
@@ -172,18 +151,12 @@ func (oc *OverlayController) getState(webviewID webkit.WebViewID) overlayState {
172 return overlayState{}151 return overlayState{}
173 }152 }
174 153
175-// runJavaScript executes script in the main world for the given WebView.154+// runJavaScript executes script in the isolated "dumber" world for the given WebView.
155+// This is fire-and-forget: errors are logged asynchronously by the WebView.
176 func (oc *OverlayController) runJavaScript(ctx context.Context, webviewID webkit.WebViewID, script string) error {156 func (oc *OverlayController) runJavaScript(ctx context.Context, webviewID webkit.WebViewID, script string) error {
177- if ctx == nil {157+ log := logging.FromContext(ctx)
178- ctx = oc.baseCtx
179- }
180-
181- log := logging.FromContext(ctx).With().
182- Str("component", "overlay-controller").
183- Uint64("webview_id", uint64(webviewID)).
184- Logger()
185-
186 if webviewID == 0 {158 if webviewID == 0 {
159+ log.Debug().Msg("runJavaScript called with webviewID=0")
187 return fmt.Errorf("webview id is required for overlay command")160 return fmt.Errorf("webview id is required for overlay command")
188 }161 }
189 162
@@ -192,10 +165,7 @@ func (oc *OverlayController) runJavaScript(ctx context.Context, webviewID webkit
192 return fmt.Errorf("webview %d not found", webviewID)165 return fmt.Errorf("webview %d not found", webviewID)
193 }166 }
194 167
195- if err := wv.RunJavaScript(ctx, script, ""); err != nil {168+ // Run in the isolated "dumber" world where the GUI scripts are injected
196- log.Warn().Err(err).Msg("overlay JS execution failed")169+ wv.RunJavaScript(ctx, script, webkit.ScriptWorldName)
197- return err
198- }
199-
200 return nil170 return nil
201 }171 }
M internal/ui/component/tab_button.go +1 -1
@@ -39,7 +39,7 @@ func NewTabButton(tab *entity.Tab) *TabButton {
3939
4040 // Create the label
4141 title := tab.Title()
42- tb.label = gtk.NewLabel(title)
42+ tb.label = gtk.NewLabel(&title)
4343 if tb.label == nil {
4444 tb.button.Unref()
4545 return nil
@@ -39,7 +39,7 @@ func NewTabButton(tab *entity.Tab) *TabButton {
39 39
40 // Create the label40 // Create the label
41 title := tab.Title()41 title := tab.Title()
42- tb.label = gtk.NewLabel(title)42+ tb.label = gtk.NewLabel(&title)
43 if tb.label == nil {43 if tb.label == nil {
44 tb.button.Unref()44 tb.button.Unref()
45 return nil45 return nil
M internal/ui/handler/keyboard_handler.go +1 -8
@@ -49,14 +49,7 @@ func (h *KeyboardBlockingHandler) Handle(ctx context.Context, webviewID webkit.W
4949 return nil, fmt.Errorf("unknown keyboard_blocking action: %s", action)
5050 }
5151
52- if err := wv.RunJavaScript(ctx, js, ""); err != nil {
53- log.Warn().
54- Err(err).
55- Uint64("webview_id", uint64(webviewID)).
56- Str("action", action).
57- Msg("failed to toggle keyboard blocking")
58- return nil, err
59- }
52+ wv.RunJavaScript(ctx, js, "")
6053
6154 log.Debug().
6255 Str("action", action).
@@ -49,14 +49,7 @@ func (h *KeyboardBlockingHandler) Handle(ctx context.Context, webviewID webkit.W
49 return nil, fmt.Errorf("unknown keyboard_blocking action: %s", action)49 return nil, fmt.Errorf("unknown keyboard_blocking action: %s", action)
50 }50 }
51 51
52- if err := wv.RunJavaScript(ctx, js, ""); err != nil {52+ wv.RunJavaScript(ctx, js, "")
53- log.Warn().
54- Err(err).
55- Uint64("webview_id", uint64(webviewID)).
56- Str("action", action).
57- Msg("failed to toggle keyboard blocking")
58- return nil, err
59- }
60 53
61 log.Debug().54 log.Debug().
62 Str("action", action).55 Str("action", action).
M internal/ui/layout/gtk_widgets.go +3 -3
@@ -465,8 +465,8 @@ func (img *gtkImage) GetParent() Widget {
465465 return &gtkWidget{inner: parent}
466466 }
467467
468-func (img *gtkImage) SetFromIconName(name string) { img.inner.SetFromIconName(name) }
469-func (img *gtkImage) SetFromFile(filename string) { img.inner.SetFromFile(filename) }
468+func (img *gtkImage) SetFromIconName(name string) { img.inner.SetFromIconName(&name) }
469+func (img *gtkImage) SetFromFile(filename string) { img.inner.SetFromFile(&filename) }
470470 func (img *gtkImage) SetPixelSize(size int) { img.inner.SetPixelSize(size) }
471471 func (img *gtkImage) Clear() { img.inner.Clear() }
472472
@@ -500,7 +500,7 @@ func (f *GtkWidgetFactory) NewOverlay() OverlayWidget {
500500 }
501501
502502 func (f *GtkWidgetFactory) NewLabel(text string) LabelWidget {
503- label := gtk.NewLabel(text)
503+ label := gtk.NewLabel(&text)
504504 return &gtkLabel{inner: label}
505505 }
506506
@@ -465,8 +465,8 @@ func (img *gtkImage) GetParent() Widget {
465 return &gtkWidget{inner: parent}465 return &gtkWidget{inner: parent}
466 }466 }
467 467
468-func (img *gtkImage) SetFromIconName(name string) { img.inner.SetFromIconName(name) }468+func (img *gtkImage) SetFromIconName(name string) { img.inner.SetFromIconName(&name) }
469-func (img *gtkImage) SetFromFile(filename string) { img.inner.SetFromFile(filename) }469+func (img *gtkImage) SetFromFile(filename string) { img.inner.SetFromFile(&filename) }
470 func (img *gtkImage) SetPixelSize(size int) { img.inner.SetPixelSize(size) }470 func (img *gtkImage) SetPixelSize(size int) { img.inner.SetPixelSize(size) }
471 func (img *gtkImage) Clear() { img.inner.Clear() }471 func (img *gtkImage) Clear() { img.inner.Clear() }
472 472
@@ -500,7 +500,7 @@ func (f *GtkWidgetFactory) NewOverlay() OverlayWidget {
500 }500 }
501 501
502 func (f *GtkWidgetFactory) NewLabel(text string) LabelWidget {502 func (f *GtkWidgetFactory) NewLabel(text string) LabelWidget {
503- label := gtk.NewLabel(text)503+ label := gtk.NewLabel(&text)
504 return &gtkLabel{inner: label}504 return &gtkLabel{inner: label}
505 }505 }
506 506
M internal/ui/window/main_window.go +2 -1
@@ -39,7 +39,8 @@ func New(app *gtk.Application, cfg *config.Config, logger *zerolog.Logger) (*Mai
3939 }
4040
4141 // Configure window
42- mw.window.SetTitle(windowTitle)
42+ title := windowTitle
43+ mw.window.SetTitle(&title)
4344 mw.window.SetDefaultSize(defaultWidth, defaultHeight)
4445
4546 // Create root container (vertical box)
@@ -39,7 +39,8 @@ func New(app *gtk.Application, cfg *config.Config, logger *zerolog.Logger) (*Mai
39 }39 }
40 40
41 // Configure window41 // Configure window
42- mw.window.SetTitle(windowTitle)42+ title := windowTitle
43+ mw.window.SetTitle(&title)
43 mw.window.SetDefaultSize(defaultWidth, defaultHeight)44 mw.window.SetDefaultSize(defaultWidth, defaultHeight)
44 45
45 // Create root container (vertical box)46 // Create root container (vertical box)
M webui/src/components/omnibox/messaging.ts +18 -24
@@ -13,14 +13,17 @@ export class OmniboxBridge implements OmniboxMessageBridge {
1313 postMessage(msg: OmniboxMessage): void {
1414 console.log("📤 [DEBUG] Posting message to backend via bridge:", msg);
1515 try {
16- // Include webview_id for message routing
17- const payload = {
18- ...msg,
16+ // Structure message for Go backend: type + payload + webview_id
17+ // Go expects: { type: string, payload: {...}, webview_id: number }
18+ const { type, ...rest } = msg;
19+ const structuredMessage = {
20+ type,
21+ payload: rest,
1922 webview_id: (window as any).__dumber_webview_id ?? 0,
2023 };
2124 // Dispatch CustomEvent that main-world bridge will listen to
2225 document.dispatchEvent(new CustomEvent("dumber:isolated-message", {
23- detail: { payload }
26+ detail: { payload: structuredMessage }
2427 }));
2528 console.log("✅ [DEBUG] Dispatched isolated message event");
2629 } catch (e) {
@@ -86,10 +89,10 @@ export class OmniboxBridge implements OmniboxMessageBridge {
8689 */
8790 async fetchSearchShortcuts(): Promise<void> {
8891 return new Promise((resolve, reject) => {
89- // Set up one-time response handler
90- const originalCallback = (window as any).__dumber_search_shortcuts;
91- (window as any).__dumber_search_shortcuts = (data: unknown) => {
92+ // Set up one-time response handler via CustomEvent (isolated world pattern)
93+ const handleShortcuts = ((event: CustomEvent) => {
9294 try {
95+ const data = event.detail?.shortcuts ?? event.detail;
9396 // Type guard
9497 if (typeof data !== "object" || data === null) {
9598 reject(new Error("Invalid shortcuts data"));
@@ -106,29 +109,20 @@ export class OmniboxBridge implements OmniboxMessageBridge {
106109 };
107110 }
108111 this.setSearchShortcuts(normalized);
109-
110- // Restore original callback if it existed
111- if (originalCallback) {
112- (window as any).__dumber_search_shortcuts = originalCallback;
113- }
114-
112+ document.removeEventListener("dumber:search-shortcuts", handleShortcuts as EventListener);
115113 resolve();
116114 } catch (error) {
117115 console.error("Failed to process search shortcuts:", error);
116+ document.removeEventListener("dumber:search-shortcuts", handleShortcuts as EventListener);
118117 reject(error);
119118 }
120- };
119+ }) as EventListener;
121120
122- // Send message to Go backend
123- const bridge = window.webkit?.messageHandlers?.dumber;
124- if (bridge && typeof bridge.postMessage === "function") {
125- bridge.postMessage({
126- type: "get_search_shortcuts",
127- webview_id: (window as any).__dumber_webview_id ?? 0,
128- });
129- } else {
130- reject(new Error("WebKit message handler not available"));
131- }
121+ // Listen for response CustomEvent from main world bridge
122+ document.addEventListener("dumber:search-shortcuts", handleShortcuts as EventListener);
123+
124+ // Send message via CustomEvent bridge (isolated world cannot access webkit.messageHandlers)
125+ this.postMessage({ type: "get_search_shortcuts" });
132126 });
133127 }
134128
@@ -13,14 +13,17 @@ export class OmniboxBridge implements OmniboxMessageBridge {
13 postMessage(msg: OmniboxMessage): void {13 postMessage(msg: OmniboxMessage): void {
14 console.log("📤 [DEBUG] Posting message to backend via bridge:", msg);14 console.log("📤 [DEBUG] Posting message to backend via bridge:", msg);
15 try {15 try {
16- // Include webview_id for message routing16+ // Structure message for Go backend: type + payload + webview_id
17- const payload = {17+ // Go expects: { type: string, payload: {...}, webview_id: number }
18- ...msg,18+ const { type, ...rest } = msg;
19+ const structuredMessage = {
20+ type,
21+ payload: rest,
19 webview_id: (window as any).__dumber_webview_id ?? 0,22 webview_id: (window as any).__dumber_webview_id ?? 0,
20 };23 };
21 // Dispatch CustomEvent that main-world bridge will listen to24 // Dispatch CustomEvent that main-world bridge will listen to
22 document.dispatchEvent(new CustomEvent("dumber:isolated-message", {25 document.dispatchEvent(new CustomEvent("dumber:isolated-message", {
23- detail: { payload }26+ detail: { payload: structuredMessage }
24 }));27 }));
25 console.log("✅ [DEBUG] Dispatched isolated message event");28 console.log("✅ [DEBUG] Dispatched isolated message event");
26 } catch (e) {29 } catch (e) {
@@ -86,10 +89,10 @@ export class OmniboxBridge implements OmniboxMessageBridge {
86 */89 */
87 async fetchSearchShortcuts(): Promise<void> {90 async fetchSearchShortcuts(): Promise<void> {
88 return new Promise((resolve, reject) => {91 return new Promise((resolve, reject) => {
89- // Set up one-time response handler92+ // Set up one-time response handler via CustomEvent (isolated world pattern)
90- const originalCallback = (window as any).__dumber_search_shortcuts;93+ const handleShortcuts = ((event: CustomEvent) => {
91- (window as any).__dumber_search_shortcuts = (data: unknown) => {
92 try {94 try {
95+ const data = event.detail?.shortcuts ?? event.detail;
93 // Type guard96 // Type guard
94 if (typeof data !== "object" || data === null) {97 if (typeof data !== "object" || data === null) {
95 reject(new Error("Invalid shortcuts data"));98 reject(new Error("Invalid shortcuts data"));
@@ -106,29 +109,20 @@ export class OmniboxBridge implements OmniboxMessageBridge {
106 };109 };
107 }110 }
108 this.setSearchShortcuts(normalized);111 this.setSearchShortcuts(normalized);
109-112+ document.removeEventListener("dumber:search-shortcuts", handleShortcuts as EventListener);
110- // Restore original callback if it existed
111- if (originalCallback) {
112- (window as any).__dumber_search_shortcuts = originalCallback;
113- }
114-
115 resolve();113 resolve();
116 } catch (error) {114 } catch (error) {
117 console.error("Failed to process search shortcuts:", error);115 console.error("Failed to process search shortcuts:", error);
116+ document.removeEventListener("dumber:search-shortcuts", handleShortcuts as EventListener);
118 reject(error);117 reject(error);
119 }118 }
120- };119+ }) as EventListener;
121 120
122- // Send message to Go backend121+ // Listen for response CustomEvent from main world bridge
123- const bridge = window.webkit?.messageHandlers?.dumber;122+ document.addEventListener("dumber:search-shortcuts", handleShortcuts as EventListener);
124- if (bridge && typeof bridge.postMessage === "function") {123+
125- bridge.postMessage({124+ // Send message via CustomEvent bridge (isolated world cannot access webkit.messageHandlers)
126- type: "get_search_shortcuts",125+ this.postMessage({ type: "get_search_shortcuts" });
127- webview_id: (window as any).__dumber_webview_id ?? 0,
128- });
129- } else {
130- reject(new Error("WebKit message handler not available"));
131- }
132 });126 });
133 }127 }
134 128
M webui/src/injected/main-world.ts +11 -0
@@ -965,6 +965,16 @@ interface DumberAPI {
965965 });
966966 };
967967
968+ // Search shortcuts response bridge (backend → isolated world)
969+ window.__dumber_search_shortcuts = (shortcuts: unknown) => {
970+ console.log("[dumber-bridge] Received search shortcuts from backend");
971+ bridgeDispatch("dumber:search-shortcuts", { shortcuts });
972+ };
973+
974+ window.__dumber_search_shortcuts_error = (error: string) => {
975+ console.error("[dumber-bridge] Search shortcuts error:", error);
976+ };
977+
968978 // Bridge for isolated world communication
969979 // Listen for messages from isolated world and forward to backend via webkit.messageHandlers
970980 const setupIsolatedWorldBridge = () => {
@@ -972,6 +982,7 @@ interface DumberAPI {
972982 try {
973983 const { payload } = event.detail;
974984 if (window.webkit?.messageHandlers?.dumber) {
985+ // Send object directly - Go's jscValue.ToJson() handles conversion
975986 window.webkit.messageHandlers.dumber.postMessage(payload);
976987 console.log("[dumber-bridge] Forwarded isolated world message:", payload.type);
977988 } else {
@@ -965,6 +965,16 @@ interface DumberAPI {
965 });965 });
966 };966 };
967 967
968+ // Search shortcuts response bridge (backend → isolated world)
969+ window.__dumber_search_shortcuts = (shortcuts: unknown) => {
970+ console.log("[dumber-bridge] Received search shortcuts from backend");
971+ bridgeDispatch("dumber:search-shortcuts", { shortcuts });
972+ };
973+
974+ window.__dumber_search_shortcuts_error = (error: string) => {
975+ console.error("[dumber-bridge] Search shortcuts error:", error);
976+ };
977+
968 // Bridge for isolated world communication978 // Bridge for isolated world communication
969 // Listen for messages from isolated world and forward to backend via webkit.messageHandlers979 // Listen for messages from isolated world and forward to backend via webkit.messageHandlers
970 const setupIsolatedWorldBridge = () => {980 const setupIsolatedWorldBridge = () => {
@@ -972,6 +982,7 @@ interface DumberAPI {
972 try {982 try {
973 const { payload } = event.detail;983 const { payload } = event.detail;
974 if (window.webkit?.messageHandlers?.dumber) {984 if (window.webkit?.messageHandlers?.dumber) {
985+ // Send object directly - Go's jscValue.ToJson() handles conversion
975 window.webkit.messageHandlers.dumber.postMessage(payload);986 window.webkit.messageHandlers.dumber.postMessage(payload);
976 console.log("[dumber-bridge] Forwarded isolated world message:", payload.type);987 console.log("[dumber-bridge] Forwarded isolated world message:", payload.type);
977 } else {988 } else {
M webui/src/injected/modules/color-scheme.ts +4 -6
@@ -80,12 +80,10 @@ class ColorSchemeManager {
8080
8181 private notifyNativeLayer(): void {
8282 try {
83- (window as any).webkit?.messageHandlers?.dumber?.postMessage(
84- JSON.stringify({
85- type: "theme",
86- value: this.config.colorScheme,
87- }),
88- );
83+ (window as any).webkit?.messageHandlers?.dumber?.postMessage({
84+ type: "theme",
85+ payload: { value: this.config.colorScheme },
86+ });
8987 } catch {
9088 // Ignore errors - native layer may not be available
9189 }
@@ -80,12 +80,10 @@ class ColorSchemeManager {
80 80
81 private notifyNativeLayer(): void {81 private notifyNativeLayer(): void {
82 try {82 try {
83- (window as any).webkit?.messageHandlers?.dumber?.postMessage(83+ (window as any).webkit?.messageHandlers?.dumber?.postMessage({
84- JSON.stringify({84+ type: "theme",
85- type: "theme",85+ payload: { value: this.config.colorScheme },
86- value: this.config.colorScheme,86+ });
87- }),
88- );
89 } catch {87 } catch {
90 // Ignore errors - native layer may not be available88 // Ignore errors - native layer may not be available
91 }89 }

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