detached 5bdfa41

feat(ui): add overlay controller for omnibox and find UI

Introduces OverlayController component that manages showing/hiding the
injected overlay UI (omnibox and find-in-page). Provides methods to
show, hide, and toggle overlays with optional mode and query parameters.
commit
5bdfa41b017bff6c91e4f0100606dabf830d0ddc
author
brice · · 2025-12-11 22:56 UTC
committer
brice · · 2025-12-11 20:07 UTC
parent
4539669
signature
unsigned
1 file · +201 -0
A internal/ui/component/overlay.go +201 -0
@@ -0,0 +1,201 @@
1+package component
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// overlayState tracks the current overlay status for a WebView.
14+type overlayState struct {
15+ visible bool
16+ mode string
17+}
18+
19+// OverlayController controls the injected overlay UI (omnibox/find).
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 {
23+ baseCtx context.Context
24+
25+ mu sync.RWMutex
26+ states map[webkit.WebViewID]overlayState
27+}
28+
29+// NewOverlayController creates a new overlay controller.
30+func NewOverlayController(ctx context.Context) *OverlayController {
31+ if ctx == nil {
32+ ctx = context.Background()
33+ }
34+
35+ return &OverlayController{
36+ baseCtx: ctx,
37+ states: make(map[webkit.WebViewID]overlayState),
38+ }
39+}
40+
41+// Show opens the overlay in the given mode ("omnibox" default or "find").
42+// An optional query seeds the overlay input.
43+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 == "" {
48+ mode = "omnibox"
49+ }
50+
51+ log := logging.FromContext(ctx).With().
52+ Str("component", "overlay-controller").
53+ Uint64("webview_id", uint64(webviewID)).
54+ Str("mode", mode).
55+ Logger()
56+
57+ modeJSON, err := json.Marshal(mode)
58+ if err != nil {
59+ return fmt.Errorf("marshal mode: %w", err)
60+ }
61+ queryJSON, err := json.Marshal(query)
62+ if err != nil {
63+ return fmt.Errorf("marshal query: %w", err)
64+ }
65+
66+ script := fmt.Sprintf(`(function(){try{if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open(%s,%s);return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();}else{console.warn("dumber overlay open: APIs unavailable");}}catch(e){console.error("dumber overlay open failed",e);}})();`, string(modeJSON), string(queryJSON))
67+
68+ if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
69+ log.Warn().Err(err).Msg("failed to show overlay")
70+ return err
71+ }
72+
73+ oc.setState(webviewID, overlayState{visible: true, mode: mode})
74+ log.Debug().Msg("overlay show requested")
75+ return nil
76+}
77+
78+// Hide closes the overlay if present.
79+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().
85+ Str("component", "overlay-controller").
86+ Uint64("webview_id", uint64(webviewID)).
87+ Logger()
88+
89+ script := `(function(){try{if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.close==="function"){window.__dumber_omnibox.close();return;}if(typeof window.__dumber_find_close==="function"){window.__dumber_find_close();return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();}else{console.warn("dumber overlay close: APIs unavailable");}}catch(e){console.error("dumber overlay close failed",e);}})();`
90+
91+ if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
92+ log.Warn().Err(err).Msg("failed to hide overlay")
93+ return err
94+ }
95+
96+ oc.setState(webviewID, overlayState{visible: false})
97+ log.Debug().Msg("overlay hide requested")
98+ return nil
99+}
100+
101+// Toggle toggles the overlay visibility.
102+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().
108+ Str("component", "overlay-controller").
109+ Uint64("webview_id", uint64(webviewID)).
110+ Logger()
111+
112+ script := `(function(){try{if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.toggle==="function"){window.__dumber_omnibox.toggle();return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open("omnibox","");return;}console.warn("dumber overlay toggle: APIs unavailable");}catch(e){console.error("dumber overlay toggle failed",e);}})();`
113+
114+ if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
115+ log.Warn().Err(err).Msg("failed to toggle overlay")
116+ return err
117+ }
118+
119+ // Flip current state best-effort
120+ current := oc.getState(webviewID)
121+ oc.setState(webviewID, overlayState{visible: !current.visible, mode: current.mode})
122+ log.Debug().Bool("visible", !current.visible).Msg("overlay toggle requested")
123+ return nil
124+}
125+
126+// 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 {
128+ if ctx == nil {
129+ ctx = oc.baseCtx
130+ }
131+
132+ log := logging.FromContext(ctx).With().
133+ Str("component", "overlay-controller").
134+ Uint64("webview_id", uint64(webviewID)).
135+ Logger()
136+
137+ queryJSON, err := json.Marshal(query)
138+ if err != nil {
139+ return fmt.Errorf("marshal find query: %w", err)
140+ }
141+
142+ script := fmt.Sprintf(`(function(){try{if(typeof window.__dumber_find_open==="function"){window.__dumber_find_open(%s);return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open("find",%s);return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();return;}console.warn("dumber find open: APIs unavailable");}catch(e){console.error("dumber find open failed",e);}})();`, string(queryJSON), string(queryJSON))
143+
144+ if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
145+ log.Warn().Err(err).Msg("failed to open find overlay")
146+ return err
147+ }
148+
149+ oc.setState(webviewID, overlayState{visible: true, mode: "find"})
150+ log.Debug().Msg("find overlay requested")
151+ return nil
152+}
153+
154+// State returns the tracked overlay state for the given WebView.
155+func (oc *OverlayController) State(webviewID webkit.WebViewID) overlayState {
156+ return oc.getState(webviewID)
157+}
158+
159+func (oc *OverlayController) setState(webviewID webkit.WebViewID, state overlayState) {
160+ oc.mu.Lock()
161+ defer oc.mu.Unlock()
162+ oc.states[webviewID] = state
163+}
164+
165+func (oc *OverlayController) getState(webviewID webkit.WebViewID) overlayState {
166+ oc.mu.RLock()
167+ defer oc.mu.RUnlock()
168+
169+ if state, ok := oc.states[webviewID]; ok {
170+ return state
171+ }
172+ return overlayState{}
173+}
174+
175+// runJavaScript executes script in the main world for the given WebView.
176+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+
186+ if webviewID == 0 {
187+ return fmt.Errorf("webview id is required for overlay command")
188+ }
189+
190+ wv := webkit.LookupWebView(webviewID)
191+ if wv == nil {
192+ return fmt.Errorf("webview %d not found", webviewID)
193+ }
194+
195+ if err := wv.RunJavaScript(ctx, script, ""); err != nil {
196+ log.Warn().Err(err).Msg("overlay JS execution failed")
197+ return err
198+ }
199+
200+ return nil
201+}
@@ -0,0 +1,201 @@
1+package component
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// overlayState tracks the current overlay status for a WebView.
14+type overlayState struct {
15+ visible bool
16+ mode string
17+}
18+
19+// OverlayController controls the injected overlay UI (omnibox/find).
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 {
23+ baseCtx context.Context
24+
25+ mu sync.RWMutex
26+ states map[webkit.WebViewID]overlayState
27+}
28+
29+// NewOverlayController creates a new overlay controller.
30+func NewOverlayController(ctx context.Context) *OverlayController {
31+ if ctx == nil {
32+ ctx = context.Background()
33+ }
34+
35+ return &OverlayController{
36+ baseCtx: ctx,
37+ states: make(map[webkit.WebViewID]overlayState),
38+ }
39+}
40+
41+// Show opens the overlay in the given mode ("omnibox" default or "find").
42+// An optional query seeds the overlay input.
43+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 == "" {
48+ mode = "omnibox"
49+ }
50+
51+ log := logging.FromContext(ctx).With().
52+ Str("component", "overlay-controller").
53+ Uint64("webview_id", uint64(webviewID)).
54+ Str("mode", mode).
55+ Logger()
56+
57+ modeJSON, err := json.Marshal(mode)
58+ if err != nil {
59+ return fmt.Errorf("marshal mode: %w", err)
60+ }
61+ queryJSON, err := json.Marshal(query)
62+ if err != nil {
63+ return fmt.Errorf("marshal query: %w", err)
64+ }
65+
66+ script := fmt.Sprintf(`(function(){try{if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open(%s,%s);return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();}else{console.warn("dumber overlay open: APIs unavailable");}}catch(e){console.error("dumber overlay open failed",e);}})();`, string(modeJSON), string(queryJSON))
67+
68+ if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
69+ log.Warn().Err(err).Msg("failed to show overlay")
70+ return err
71+ }
72+
73+ oc.setState(webviewID, overlayState{visible: true, mode: mode})
74+ log.Debug().Msg("overlay show requested")
75+ return nil
76+}
77+
78+// Hide closes the overlay if present.
79+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().
85+ Str("component", "overlay-controller").
86+ Uint64("webview_id", uint64(webviewID)).
87+ Logger()
88+
89+ script := `(function(){try{if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.close==="function"){window.__dumber_omnibox.close();return;}if(typeof window.__dumber_find_close==="function"){window.__dumber_find_close();return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();}else{console.warn("dumber overlay close: APIs unavailable");}}catch(e){console.error("dumber overlay close failed",e);}})();`
90+
91+ if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
92+ log.Warn().Err(err).Msg("failed to hide overlay")
93+ return err
94+ }
95+
96+ oc.setState(webviewID, overlayState{visible: false})
97+ log.Debug().Msg("overlay hide requested")
98+ return nil
99+}
100+
101+// Toggle toggles the overlay visibility.
102+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().
108+ Str("component", "overlay-controller").
109+ Uint64("webview_id", uint64(webviewID)).
110+ Logger()
111+
112+ script := `(function(){try{if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.toggle==="function"){window.__dumber_omnibox.toggle();return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open("omnibox","");return;}console.warn("dumber overlay toggle: APIs unavailable");}catch(e){console.error("dumber overlay toggle failed",e);}})();`
113+
114+ if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
115+ log.Warn().Err(err).Msg("failed to toggle overlay")
116+ return err
117+ }
118+
119+ // Flip current state best-effort
120+ current := oc.getState(webviewID)
121+ oc.setState(webviewID, overlayState{visible: !current.visible, mode: current.mode})
122+ log.Debug().Bool("visible", !current.visible).Msg("overlay toggle requested")
123+ return nil
124+}
125+
126+// 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 {
128+ if ctx == nil {
129+ ctx = oc.baseCtx
130+ }
131+
132+ log := logging.FromContext(ctx).With().
133+ Str("component", "overlay-controller").
134+ Uint64("webview_id", uint64(webviewID)).
135+ Logger()
136+
137+ queryJSON, err := json.Marshal(query)
138+ if err != nil {
139+ return fmt.Errorf("marshal find query: %w", err)
140+ }
141+
142+ script := fmt.Sprintf(`(function(){try{if(typeof window.__dumber_find_open==="function"){window.__dumber_find_open(%s);return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open("find",%s);return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();return;}console.warn("dumber find open: APIs unavailable");}catch(e){console.error("dumber find open failed",e);}})();`, string(queryJSON), string(queryJSON))
143+
144+ if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
145+ log.Warn().Err(err).Msg("failed to open find overlay")
146+ return err
147+ }
148+
149+ oc.setState(webviewID, overlayState{visible: true, mode: "find"})
150+ log.Debug().Msg("find overlay requested")
151+ return nil
152+}
153+
154+// State returns the tracked overlay state for the given WebView.
155+func (oc *OverlayController) State(webviewID webkit.WebViewID) overlayState {
156+ return oc.getState(webviewID)
157+}
158+
159+func (oc *OverlayController) setState(webviewID webkit.WebViewID, state overlayState) {
160+ oc.mu.Lock()
161+ defer oc.mu.Unlock()
162+ oc.states[webviewID] = state
163+}
164+
165+func (oc *OverlayController) getState(webviewID webkit.WebViewID) overlayState {
166+ oc.mu.RLock()
167+ defer oc.mu.RUnlock()
168+
169+ if state, ok := oc.states[webviewID]; ok {
170+ return state
171+ }
172+ return overlayState{}
173+}
174+
175+// runJavaScript executes script in the main world for the given WebView.
176+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+
186+ if webviewID == 0 {
187+ return fmt.Errorf("webview id is required for overlay command")
188+ }
189+
190+ wv := webkit.LookupWebView(webviewID)
191+ if wv == nil {
192+ return fmt.Errorf("webview %d not found", webviewID)
193+ }
194+
195+ if err := wv.RunJavaScript(ctx, script, ""); err != nil {
196+ log.Warn().Err(err).Msg("overlay JS execution failed")
197+ return err
198+ }
199+
200+ return nil
201+}

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