v0.31.0 bf0756b

coordinator.go RawHistoryFolder

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

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