v0.33.2 57f21d6

coordinator.go RawHistoryFolder

1package content
2
3import (
4	"context"
5	"sync"
6
7	"github.com/bnema/dumber/internal/application/dto"
8	"github.com/bnema/dumber/internal/application/port"
9	"github.com/bnema/dumber/internal/application/usecase"
10	"github.com/bnema/dumber/internal/domain/entity"
11
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/internal/ui/adapter"
14	"github.com/bnema/dumber/internal/ui/component"
15	"github.com/bnema/dumber/internal/ui/input"
16	"github.com/bnema/dumber/internal/ui/layout"
17	"github.com/rs/zerolog"
18)
19
20// Coordinator manages WebView lifecycle, title tracking, and content attachment.
21type Coordinator struct {
22	logger          zerolog.Logger
23	pool            port.WebViewPool
24	widgetFactory   layout.WidgetFactory
25	faviconAdapter  *adapter.FaviconAdapter
26	zoomUC          *usecase.ManageZoomUseCase
27	permissionUC    *usecase.HandlePermissionUseCase
28	injector        port.ContentInjector
29	settingsApplier port.SettingsApplier // optional: nil if engine doesn't support
30	filterApplier   port.FilterApplier   // optional: nil if engine doesn't support
31
32	webViews       map[entity.PaneID]port.WebView
33	webViewPaneIDs map[port.WebViewID]entity.PaneID
34	webViewsMu     sync.RWMutex
35
36	activePaneOverride   entity.PaneID
37	activePaneOverrideMu sync.RWMutex
38
39	paneTitles map[entity.PaneID]string
40	titleMu    sync.RWMutex
41
42	// Track original navigation URLs to handle cross-domain redirects
43	// e.g., google.fr → google.com: cache favicon under both domains
44	navOrigins  map[entity.PaneID]string
45	navOriginMu sync.RWMutex
46
47	// Callback to get active workspace state (avoids circular dependency)
48	getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
49
50	// Resolves a pane's view across all tabs/windows (JS dialogs can come from
51	// background panes). JS dialogs are not shown while it is nil.
52	paneViewResolver func(paneID entity.PaneID) *component.PaneView
53	// Current UI scale, used to size JS dialogs.
54	uiScaleProvider func() float64
55
56	// Callback when title changes (for history persistence)
57	onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
58
59	// Callback when page is committed (for history recording)
60	onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
61
62	// Callback when pane URI changes (for session snapshots)
63	onPaneURIUpdated func(paneID entity.PaneID, url string)
64
65	// Callback when active pane title changes (for window title updates)
66	onWindowTitleChanged func(paneID entity.PaneID, title string)
67
68	// Callback when media permission activity changes (requesting/allowed/blocked).
69	onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState)
70
71	// Callback invoked for every permission request, before it is handled, so the
72	// UI can bind the permission dialog of the window owning the pane.
73	onPermissionPrompt func(paneID entity.PaneID)
74
75	// Callback when the active pane commits a navigation (new page loading).
76	onActiveNavigationCommitted func(paneID entity.PaneID, uri string)
77
78	// Callback when a CEF touchpad history navigation gesture progresses.
79	onTouchpadNavigationGesture func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)
80
81	// Callback when the WebView becomes visible (first real commit)
82	onWebViewShown func(paneID entity.PaneID)
83
84	// setWebViewVisible is test-only injection for deterministic lifecycle
85	// serialization tests. Production uses the native widget provider directly.
86	setWebViewVisible func(port.WebView)
87
88	// revealMutationMu serializes mapping replacement/release with the native
89	// visibility transition. It is acquired before webViewsMu and revealMu.
90	revealMutationMu sync.Mutex
91
92	// revealMu is always acquired after webViewsMu. Reveal state is keyed by the
93	// WebView identity (ID plus pool-reuse generation), never by pane alone.
94	revealMu         sync.Mutex
95	pendingReveal    map[entity.PaneID]webViewIdentity
96	revealedWebViews map[entity.PaneID]webViewIdentity
97
98	appearanceMu          sync.Mutex
99	pendingScriptRefresh  map[entity.PaneID]bool
100	pendingThemePanes     map[entity.PaneID]bool
101	pendingThemeUpdate    pendingThemeUpdate
102	hasPendingThemeUpdate bool
103	currentTheme          pendingThemeUpdate
104	hasCurrentTheme       bool
105
106	// Gesture action handler for mouse button navigation
107	gestureActionHandler input.ActionHandler
108
109	// Popup handling stays in a dedicated UI-layer manager so popup-specific
110	// pane state does not bloat the main coordinator.
111	popups     *popupManager
112	popupsOnce sync.Once
113
114	// Idle inhibitor for fullscreen video playback
115	idleInhibitor port.IdleInhibitor
116
117	// Callback when fullscreen state changes (for hiding/showing tab bar)
118	onFullscreenChanged func(paneID entity.PaneID, entering bool)
119
120	// Callback when WebView gains focus (for accent picker text input targeting)
121	onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
122
123	// Callback when a page editable target gains or loses focus.
124	onEditableFocusChanged func(paneID entity.PaneID, editable bool)
125	// onVimPageInteractionEnded fires when a page finishes link hints or visual selection.
126	onVimPageInteractionEnded func(paneID entity.PaneID)
127	// onVimPageModeChanged fires when a page interaction moves to another sub-mode.
128	onVimPageModeChanged func(paneID entity.PaneID, mode dto.VimPageMode)
129
130	// Callback for first load_started event (triggers deferred initialization)
131	onFirstLoadStarted func()
132	loadStartedOnce    sync.Once
133
134	// Callback to open a URL with the system's default handler (e.g. xdg-open).
135	// Used for external URL schemes like vscode://, spotify://, etc.
136	onLaunchExternalURL func(uri string)
137	// Callback asking the user to confirm an external scheme launch for a pane.
138	// respond must be called exactly once on the GTK thread.
139	// origin is the host of the requesting page, empty when unknown.
140	onConfirmExternalLaunch func(paneID entity.PaneID, origin, uri string, respond func(allowed bool))
141
142	// externalPromptMu guards the external-launch prompt state: outstanding
143	// prompts per pane, the token sequence, and the last web host per pane.
144	externalPromptMu      sync.Mutex
145	externalPrompts       map[entity.PaneID]externalPrompt
146	externalPromptSeq     uint64
147	externalPromptOrigins map[entity.PaneID]string
148}
149
150type pendingThemeUpdate struct {
151	prefersDark bool
152	cssText     string
153}
154
155// PermissionActivityState represents the visible state for media permission activity.
156type PermissionActivityState string
157
158const (
159	PermissionActivityRequesting PermissionActivityState = "requesting"
160	PermissionActivityAllowed    PermissionActivityState = "allowed"
161	PermissionActivityBlocked    PermissionActivityState = "blocked"
162)
163
164// NewCoordinator creates a new Coordinator.
165func NewCoordinator(
166	ctx context.Context,
167	pool port.WebViewPool,
168	injector port.ContentInjector,
169	widgetFactory layout.WidgetFactory,
170	faviconAdapter *adapter.FaviconAdapter,
171	getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
172	zoomUC *usecase.ManageZoomUseCase,
173	permissionUC *usecase.HandlePermissionUseCase,
174) *Coordinator {
175	log := logging.FromContext(ctx)
176	log.Debug().Msg("creating content coordinator")
177
178	return &Coordinator{
179		logger:               log.With().Str("component", "content-coordinator").Logger(),
180		pool:                 pool,
181		injector:             injector,
182		widgetFactory:        widgetFactory,
183		faviconAdapter:       faviconAdapter,
184		zoomUC:               zoomUC,
185		permissionUC:         permissionUC,
186		webViews:             make(map[entity.PaneID]port.WebView),
187		webViewPaneIDs:       make(map[port.WebViewID]entity.PaneID),
188		paneTitles:           make(map[entity.PaneID]string),
189		navOrigins:           make(map[entity.PaneID]string),
190		pendingReveal:        make(map[entity.PaneID]webViewIdentity),
191		revealedWebViews:     make(map[entity.PaneID]webViewIdentity),
192		pendingScriptRefresh: make(map[entity.PaneID]bool),
193		pendingThemePanes:    make(map[entity.PaneID]bool),
194		getActiveWS:          getActiveWS,
195		popups:               newPopupManager(),
196	}
197}
198
199func (c *Coordinator) ensurePopupManager() *popupManager {
200	if c == nil {
201		return nil
202	}
203	c.popupsOnce.Do(func() {
204		if c.popups == nil {
205			c.popups = newPopupManager()
206		}
207	})
208	c.popups.ensureInitialized()
209	return c.popups
210}
211
212// SetOnTitleUpdated sets the callback for title changes (for history persistence).
213func (c *Coordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
214	c.onTitleUpdated = fn
215}
216
217// SetOnHistoryRecord sets the callback for recording history on page commit.
218func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
219	c.onHistoryRecord = fn
220}
221
222// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
223func (c *Coordinator) SetOnPermissionActivity(
224	fn func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState),
225) {
226	c.onPermissionActivity = fn
227}
228
229// SetOnPermissionPrompt sets a callback invoked for every permission request
230// (not only WebRTC ones) before it reaches the permission use case.
231func (c *Coordinator) SetOnPermissionPrompt(fn func(paneID entity.PaneID)) {
232	c.onPermissionPrompt = fn
233}
234
235// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
236func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(paneID entity.PaneID, uri string)) {
237	c.onActiveNavigationCommitted = fn
238}
239
240// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
241func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
242	c.onPaneURIUpdated = fn
243}
244
245// SetOnTouchpadNavigationGesture sets the callback for CEF two-finger history
246// navigation gesture progress.
247func (c *Coordinator) SetOnTouchpadNavigationGesture(fn func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)) {
248	c.onTouchpadNavigationGesture = fn
249}
250
251// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
252func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) {
253	c.onWindowTitleChanged = fn
254}
255
256// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
257func (c *Coordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
258	c.onWebViewShown = fn
259}
260
261// SetGestureActionHandler sets the callback for mouse button navigation gestures.
262func (c *Coordinator) SetGestureActionHandler(handler input.ActionHandler) {
263	c.gestureActionHandler = handler
264}
265
266// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
267func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
268	c.idleInhibitor = inhibitor
269}
270
271// SetOnFullscreenChanged sets the callback for fullscreen state changes.
272func (c *Coordinator) SetOnFullscreenChanged(fn func(paneID entity.PaneID, entering bool)) {
273	c.onFullscreenChanged = fn
274}
275
276// SetOnWebViewFocused sets the callback for when a WebView gains focus.
277func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.WebView)) {
278	c.onWebViewFocused = fn
279}
280
281// SetPaneViewResolver sets a resolver that finds a pane's view in any tab/window.
282func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *component.PaneView) {
283	c.paneViewResolver = fn
284}
285
286// SetUIScaleProvider sets the source of the current UI scale.
287func (c *Coordinator) SetUIScaleProvider(fn func() float64) {
288	c.uiScaleProvider = fn
289}
290
291// SetOnEditableFocusChanged sets the callback for page editable focus changes.
292func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
293	c.onEditableFocusChanged = fn
294}
295
296// SetOnVimPageInteractionEnded sets the callback for the end of an in-page
297// Vim interaction (link hints or visual selection).
298func (c *Coordinator) SetOnVimPageInteractionEnded(fn func(paneID entity.PaneID)) {
299	c.onVimPageInteractionEnded = fn
300}
301
302// SetOnVimPageModeChanged sets the callback for sub-mode changes of an
303// in-page Vim interaction (hints, caret, visual).
304func (c *Coordinator) SetOnVimPageModeChanged(fn func(paneID entity.PaneID, mode dto.VimPageMode)) {
305	c.onVimPageModeChanged = fn
306}
307
308// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
309// This is used to trigger deferred initialization after the initial load_uri()
310// has been processed by the GTK main loop.
311func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
312	c.onFirstLoadStarted = fn
313}
314
315// SetOnLaunchExternalURL sets the callback invoked when an external URL scheme
316// (e.g. vscode://, spotify://) is detected and must be handed off to the system.
317func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
318	c.onLaunchExternalURL = fn
319}
320
321// SetOnConfirmExternalLaunch sets the callback that asks the user whether an
322// external URL scheme reached by page navigation may be opened.
323func (c *Coordinator) SetOnConfirmExternalLaunch(fn func(paneID entity.PaneID, origin, uri string, respond func(allowed bool))) {
324	c.onConfirmExternalLaunch = fn
325}
326
327// SetSettingsApplier sets the engine settings applier for config hot-reload.
328func (c *Coordinator) SetSettingsApplier(sa port.SettingsApplier) {
329	c.settingsApplier = sa
330}
331
332// SetFilterApplier sets the content filter applier for late-binding filters.
333func (c *Coordinator) SetFilterApplier(fa port.FilterApplier) {
334	c.filterApplier = fa
335}
336
337// ActivePaneID returns the currently active pane ID used by navigation.
338func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
339	if paneID, ok := c.activePaneOverrideID(); ok {
340		return paneID
341	}
342	if c.getActiveWS == nil {
343		return ""
344	}
345
346	ws, _ := c.getActiveWS()
347	if ws == nil {
348		return ""
349	}
350	return ws.ActivePaneID
351}
352
353// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
354func (c *Coordinator) SetActivePaneOverride(paneID entity.PaneID) {
355	c.activePaneOverrideMu.Lock()
356	c.activePaneOverride = paneID
357	c.activePaneOverrideMu.Unlock()
358}
359
360// ClearActivePaneOverride removes any forced active pane override.
361func (c *Coordinator) ClearActivePaneOverride() {
362	c.activePaneOverrideMu.Lock()
363	c.activePaneOverride = ""
364	c.activePaneOverrideMu.Unlock()
365}
366
367// ActivePaneOverrideID returns the currently forced active pane override, if any.
368func (c *Coordinator) ActivePaneOverrideID() (entity.PaneID, bool) {
369	return c.activePaneOverrideID()
370}
371
372func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
373	c.activePaneOverrideMu.RLock()
374	defer c.activePaneOverrideMu.RUnlock()
375	if c.activePaneOverride == "" {
376		return "", false
377	}
378	return c.activePaneOverride, true
379}
380
381func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
382	c.webViewsMu.RLock()
383	defer c.webViewsMu.RUnlock()
384	return c.webViews[paneID]
385}
386
387type webViewIdentity struct {
388	id         port.WebViewID
389	generation uint64
390}
391
392func identityForWebView(wv port.WebView) (webViewIdentity, bool) {
393	if wv == nil {
394		return webViewIdentity{}, false
395	}
396	return webViewIdentity{id: wv.ID(), generation: wv.Generation()}, true
397}
398
399func (c *Coordinator) ensureRevealMapsLocked() {
400	if c.pendingReveal == nil {
401		c.pendingReveal = make(map[entity.PaneID]webViewIdentity)
402	}
403	if c.revealedWebViews == nil {
404		c.revealedWebViews = make(map[entity.PaneID]webViewIdentity)
405	}
406}
407
408// setWebViewLocked replaces the pane mapping and resets all reveal state in
409// one critical section. It is used by normal acquisition and popup paths.
410func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
411	c.revealMutationMu.Lock()
412	defer c.revealMutationMu.Unlock()
413	c.webViewsMu.Lock()
414	c.revealMu.Lock()
415	defer c.revealMu.Unlock()
416	defer c.webViewsMu.Unlock()
417	if c.webViews == nil {
418		c.webViews = make(map[entity.PaneID]port.WebView)
419	}
420	if existing := c.webViews[paneID]; existing != nil && c.webViewPaneIDs != nil {
421		delete(c.webViewPaneIDs, existing.ID())
422	}
423	c.webViews[paneID] = wv
424	if wv != nil && c.webViewPaneIDs != nil {
425		c.webViewPaneIDs[wv.ID()] = paneID
426	}
427	c.ensureRevealMapsLocked()
428	delete(c.pendingReveal, paneID)
429	delete(c.revealedWebViews, paneID)
430}
431
432// deleteWebViewLocked removes both the mapping and any presentation state,
433// including callers which release after another path already removed the map.
434func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
435	c.revealMutationMu.Lock()
436	defer c.revealMutationMu.Unlock()
437	c.webViewsMu.Lock()
438	c.revealMu.Lock()
439	defer c.revealMu.Unlock()
440	defer c.webViewsMu.Unlock()
441	wv := c.webViews[paneID]
442	delete(c.webViews, paneID)
443	if wv != nil && c.webViewPaneIDs != nil {
444		delete(c.webViewPaneIDs, wv.ID())
445	}
446	c.ensureRevealMapsLocked()
447	delete(c.pendingReveal, paneID)
448	delete(c.revealedWebViews, paneID)
449	return wv
450}
451
452func (c *Coordinator) paneIDByWebViewIDLocked(webViewID port.WebViewID) (entity.PaneID, bool) {
453	paneID, ok := c.webViewPaneIDs[webViewID]
454	return paneID, ok
455}
456
457func (c *Coordinator) findPaneByWebViewID(webViewID port.WebViewID) (entity.PaneID, bool) {
458	if webViewID == 0 {
459		return "", false
460	}
461
462	c.webViewsMu.RLock()
463	defer c.webViewsMu.RUnlock()
464	if paneID, ok := c.paneIDByWebViewIDLocked(webViewID); ok && paneID != "" {
465		return paneID, true
466	}
467
468	c.logger.Debug().
469		Uint64("webview_id", uint64(webViewID)).
470		Msg("findPaneByWebViewID: using fallback scan")
471	for paneID, wv := range c.webViews {
472		if wv != nil && wv.ID() == webViewID {
473			return paneID, true
474		}
475	}
476	return "", false
477}
478
479func (c *Coordinator) popupHooks() popupCoordinatorHooks {
480	return popupCoordinatorHooks{
481		setupWebViewCallbacks: c.setupWebViewCallbacks,
482		registerPopupWebView:  c.RegisterPopupWebView,
483		setWebView:            c.setWebViewLocked,
484		getWebView:            c.getWebViewLocked,
485		deleteWebView:         c.deleteWebViewLocked,
486		releaseWebView:        c.ReleaseWebView,
487		findPaneByWebViewID:   c.findPaneByWebViewID,
488		setupOAuthAutoClose:   c.setupOAuthAutoClose,
489		handlePopupOAuthClose: c.handlePopupOAuthClose,
490	}
491}

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