v0.33.1 9577fee

callbacks.go RawHistoryFolder

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

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