callbacks.go RawHistoryFolder

1package content
2
3import (
4	"context"
5	"net/url"
6	"strings"
7
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	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/dumber/internal/ui/component"
13)
14
15// internalSchemePath is the host used in dumb:// crash-page URIs.
16const internalSchemePath = "history"
17
18func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool {
19	switch reason {
20	case port.WebProcessTerminationCrashed, port.WebProcessTerminationExceededMemory:
21		return true
22	case port.WebProcessTerminationByAPI:
23		return false
24	default:
25		return true
26	}
27}
28
29func extractOriginalURIFromCrashPage(uri string) string {
30	if uri == "" {
31		return ""
32	}
33
34	parsed, err := url.Parse(uri)
35	if err != nil {
36		return uri
37	}
38
39	if parsed.Scheme != "dumb" || parsed.Host != internalSchemePath {
40		return uri
41	}
42
43	if strings.Trim(parsed.Path, "/") != "crash" {
44		return uri
45	}
46
47	original := strings.TrimSpace(parsed.Query().Get("url"))
48	if original == "" {
49		return ""
50	}
51	return original
52}
53
54func buildCrashPageURI(originalURI string) string {
55	if strings.TrimSpace(originalURI) == "" {
56		return crashPageURI
57	}
58	query := url.Values{}
59	query.Set("url", originalURI)
60	return crashPageURI + "?" + query.Encode()
61}
62
63// setupWebViewCallbacks configures standard callbacks and popup handling.
64func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
65	log := logging.FromContext(ctx)
66	identity, ok := identityForWebView(wv)
67	if !ok {
68		return
69	}
70
71	// Capture the association at installation, rather than reading mutable
72	// WebView identity from a callback that can outlive a pool reuse.
73	callbacks := &port.WebViewCallbacks{
74		OnTitleChanged: func(title string) {
75			c.onTitleChanged(ctx, paneID, title)
76		},
77		OnLoadChanged: func(event port.LoadEvent) {
78			switch event {
79			case port.LoadStarted:
80				c.onLoadStarted(paneID)
81			case port.LoadCommitted:
82				c.onLoadCommitted(ctx, paneID, wv, identity)
83			case port.LoadFinished:
84				c.onLoadFinished(ctx, paneID, wv, identity)
85			}
86		},
87		OnProgressChanged: func(progress float64) {
88			c.onProgressChanged(paneID, wv, identity, progress)
89		},
90		OnURIChanged: func(uri string) {
91			c.handleURIChanged(ctx, paneID, wv, uri)
92		},
93		OnLinkHover: func(uri string) {
94			c.onLinkHover(paneID, uri)
95		},
96		OnTouchpadNavigationGesture: func(gesture entity.TouchpadNavigationGesture) {
97			if c.onTouchpadNavigationGesture != nil {
98				c.onTouchpadNavigationGesture(paneID, gesture)
99			}
100		},
101		OnWebProcessTerminated: func(reason port.WebProcessTerminationReason, reasonLabel string, uri string) {
102			originalURI := extractOriginalURIFromCrashPage(uri)
103			if !shouldRenderCrashPage(reason) {
104				log.Info().
105					Str("pane_id", string(paneID)).
106					Str("reason", reasonLabel).
107					Str("uri", uri).
108					Msg("web process termination handled without crash page")
109				return
110			}
111
112			crashURI := buildCrashPageURI(originalURI)
113			log.Warn().
114				Str("pane_id", string(paneID)).
115				Str("reason", reasonLabel).
116				Str("uri", uri).
117				Str("crash_uri", crashURI).
118				Msg("web process terminated, redirecting to crash page")
119
120			if err := wv.LoadURI(ctx, crashURI); err != nil {
121				log.Error().
122					Err(err).
123					Str("pane_id", string(paneID)).
124					Str("reason", reasonLabel).
125					Str("uri", uri).
126					Str("crash_uri", crashURI).
127					Msg("failed to load crash page after web process termination")
128			}
129		},
130		OnPermissionRequest: func(origin string, permTypes []string, metadata map[string]string, allow, deny func()) bool {
131			return c.handlePermissionRequest(ctx, paneID, origin, permTypes, metadata, allow, deny)
132		},
133	}
134
135	c.setupJSDialogCallbacks(paneID, callbacks)
136	c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)
137
138	// Middle-click link handler
139	callbacks.OnLinkMiddleClick = func(uri string) bool {
140		return c.handleLinkMiddleClick(ctx, paneID, uri)
141	}
142
143	// Fullscreen handlers for idle inhibition
144	callbacks.OnEnterFullscreen = func() bool {
145		if c.idleInhibitor != nil {
146			if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
147				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
148			}
149		}
150		if c.onFullscreenChanged != nil {
151			c.onFullscreenChanged(paneID, true)
152		}
153		return false // Allow fullscreen
154	}
155
156	callbacks.OnLeaveFullscreen = func() bool {
157		if c.idleInhibitor != nil {
158			if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
159				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
160			}
161		}
162		if c.onFullscreenChanged != nil {
163			c.onFullscreenChanged(paneID, false)
164		}
165		return false // Allow leaving fullscreen
166	}
167
168	// Audio playback handling
169	callbacks.OnAudioStateChanged = func(playing bool) {
170		if c.idleInhibitor == nil {
171			return
172		}
173		if playing {
174			if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
175				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
176			}
177		} else {
178			if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
179				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
180			}
181		}
182	}
183
184	callbacks.OnEditableFocusChanged = func(editable bool) {
185		if c.onEditableFocusChanged != nil {
186			c.onEditableFocusChanged(paneID, editable)
187		}
188	}
189
190	// Add popup create handler if popup handling is configured
191	callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
192
193	wv.SetCallbacks(callbacks)
194}
195
196// handlePermissionRequest processes media permission requests from WebKit.
197// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
198func (c *Coordinator) handlePermissionRequest(
199	ctx context.Context,
200	paneID entity.PaneID,
201	origin string,
202	permTypes []string,
203	metadata map[string]string,
204	allow, deny func(),
205) bool {
206	log := logging.FromContext(ctx)
207
208	entityTypes := permissionTypesFromStrings(ctx, permTypes)
209
210	if len(entityTypes) == 0 {
211		log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
212		deny()
213		return true
214	}
215
216	trackedTypes := filterWebRTCPermissionTypes(entityTypes)
217	notifyActivity := func(state PermissionActivityState) {
218		if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
219			return
220		}
221		c.onPermissionActivity(paneID, origin, trackedTypes, state)
222	}
223
224	notifyActivity(PermissionActivityRequesting)
225
226	wrappedAllow := func() {
227		notifyActivity(PermissionActivityAllowed)
228		allow()
229	}
230	wrappedDeny := func() {
231		notifyActivity(PermissionActivityBlocked)
232		deny()
233	}
234
235	// Check if permission use case is available
236	if c.permissionUC == nil {
237		log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
238		// Auto-allow display and device_info, deny others
239		allAutoAllow := true
240		for _, pt := range entityTypes {
241			if !entity.IsAutoAllowFor(pt, entity.PermissionMetadata(metadata)) {
242				allAutoAllow = false
243				break
244			}
245		}
246		if allAutoAllow {
247			wrappedAllow()
248		} else {
249			wrappedDeny()
250		}
251		return true
252	}
253
254	// Bind the dialog presenter of the window owning this pane before handling.
255	if c.onPermissionPrompt != nil {
256		c.onPermissionPrompt(paneID)
257	}
258
259	// Delegate to use case
260	callback := usecase.PermissionCallback{
261		Allow: wrappedAllow,
262		Deny:  wrappedDeny,
263	}
264
265	c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, entity.PermissionMetadata(metadata), callback)
266	return true
267}
268
269// permissionTypesFromStrings converts engine permission type strings to entity
270// types, skipping unknown ones.
271func permissionTypesFromStrings(ctx context.Context, permTypes []string) []entity.PermissionType {
272	log := logging.FromContext(ctx)
273	entityTypes := make([]entity.PermissionType, 0, len(permTypes))
274	for _, pt := range permTypes {
275		switch pt {
276		case "microphone":
277			entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
278		case "camera":
279			entityTypes = append(entityTypes, entity.PermissionTypeCamera)
280		case "display":
281			entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
282		case "notification":
283			entityTypes = append(entityTypes, entity.PermissionTypeNotification)
284		case "geolocation":
285			entityTypes = append(entityTypes, entity.PermissionTypeGeolocation)
286		case "device_info":
287			entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
288		case "website_data_access":
289			entityTypes = append(entityTypes, entity.PermissionTypeWebsiteDataAccess)
290		default:
291			log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
292		}
293	}
294	return entityTypes
295}
296
297func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
298	filtered := make([]entity.PermissionType, 0, len(types))
299	for _, permType := range types {
300		switch permType {
301		case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
302			filtered = append(filtered, permType)
303		}
304	}
305	return filtered
306}
307
308// setupJSDialogCallbacks routes JavaScript dialogs to the owning pane's overlay.
309func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) {
310	callbacks.OnJSDialog = func(req port.JSDialogRequest, respond func(ok bool, input string)) bool {
311		pv := c.paneViewForJSDialog(paneID)
312		if pv == nil {
313			return false
314		}
315		return pv.ShowJSDialog(req, respond, c.jsDialogUIScale())
316	}
317	callbacks.OnJSDialogReset = func() {
318		if pv := c.paneViewForJSDialog(paneID); pv != nil {
319			pv.HideJSDialog()
320		}
321	}
322}
323
324// jsDialogUIScale returns the current UI scale (1 when no provider is set).
325func (c *Coordinator) jsDialogUIScale() float64 {
326	if c.uiScaleProvider == nil {
327		return 1
328	}
329	return c.uiScaleProvider()
330}
331
332// paneViewForJSDialog resolves the PaneView that owns paneID's WebView through
333// the app-provided resolver (any tab/window). The dialog is shown inside that
334// pane's overlay even if the pane is in a background tab and never blocks others.
335func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView {
336	if c.paneViewResolver == nil {
337		return nil
338	}
339	return c.paneViewResolver(paneID)
340}

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