v0.29.0 21b3150

pane_view.go RawHistoryFolder

1// Package component provides UI components for the browser.
2package component
3
4import (
5	"context"
6	"sync"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/bnema/dumber/internal/ui/input"
11	"github.com/bnema/dumber/internal/ui/layout"
12)
13
14const (
15	// CSS class applied to active pane's border overlay
16	activePaneClass = "pane-active"
17)
18
19// PaneView is a container for a single WebView with active state indication.
20// It uses an overlay to display a border around the active pane.
21type PaneView struct {
22	ctx           context.Context
23	factory       layout.WidgetFactory
24	overlay       layout.OverlayWidget
25	webViewWidget layout.Widget      // The actual WebView widget
26	borderBox     layout.BoxWidget   // Border overlay for active indication
27	progressBar   *ProgressBar       // Loading progress indicator
28	toaster       *Toaster           // Toast notification overlay
29	linkStatus    *LinkStatusOverlay // Link hover URL overlay
30	loading       *LoadingSkeleton   // Placeholder shown until WebView paints
31	paneID        entity.PaneID
32	isActive      bool
33
34	onFocusIn     func(paneID entity.PaneID)
35	onFocusOut    func(paneID entity.PaneID)
36	onHover       func(paneID entity.PaneID)
37	onMouseMotion func()
38
39	hoverHandler *input.HoverHandler
40
41	mu sync.RWMutex
42}
43
44// NewPaneView creates a new pane view container for a WebView widget.
45func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
46	overlay := factory.NewOverlay()
47	overlay.SetHexpand(true)
48	overlay.SetVexpand(true)
49	overlay.SetVisible(true)
50	overlay.AddCssClass("pane-overlay") // Theme background prevents white flash
51
52	// Set the WebView as the main child
53	// Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted
54	if webViewWidget != nil {
55		overlay.SetChild(webViewWidget)
56	}
57
58	// Loading skeleton overlay (shown until WebView paints)
59	loading := NewLoadingSkeleton(factory)
60	loading.Widget().SetCanFocus(false)
61	loading.Widget().SetCanTarget(false)
62	overlay.AddOverlay(loading.Widget())
63	// Don't let loading affect layout
64	overlay.SetClipOverlay(loading.Widget(), false)
65	overlay.SetMeasureOverlay(loading.Widget(), false)
66
67	// Create border overlay box (used for active pane indication)
68	// This is an empty box that gets styled via CSS to show a border
69	borderBox := factory.NewBox(layout.OrientationVertical, 0)
70	borderBox.SetCanFocus(false)
71	borderBox.SetCanTarget(false) // Don't intercept pointer events - let them pass through to WebView
72	borderBox.AddCssClass("pane-border")
73	// Position border to cover entire pane
74	borderBox.SetHexpand(true)
75	borderBox.SetVexpand(true)
76	overlay.AddOverlay(borderBox)
77
78	// Don't let border affect layout
79	overlay.SetClipOverlay(borderBox, false)
80	overlay.SetMeasureOverlay(borderBox, false)
81
82	// Progress bar is created lazily on first use to avoid GTK measurement
83	// issues with the internal progress gizmo before the overlay is realized
84
85	return &PaneView{
86		ctx:           ctx,
87		factory:       factory,
88		overlay:       overlay,
89		webViewWidget: webViewWidget,
90		borderBox:     borderBox,
91		progressBar:   nil, // Created lazily in ensureProgressBar()
92		loading:       loading,
93		paneID:        paneID,
94		isActive:      false,
95	}
96}
97
98// SetActive updates the active state of the pane.
99// Active panes display a visual border indicator.
100func (pv *PaneView) SetActive(active bool) {
101	pv.mu.Lock()
102	defer pv.mu.Unlock()
103
104	if pv.isActive == active {
105		return
106	}
107
108	pv.isActive = active
109
110	if active {
111		pv.borderBox.AddCssClass(activePaneClass)
112	} else {
113		pv.borderBox.RemoveCssClass(activePaneClass)
114	}
115}
116
117// IsActive returns whether this pane is currently active.
118func (pv *PaneView) IsActive() bool {
119	pv.mu.RLock()
120	defer pv.mu.RUnlock()
121
122	return pv.isActive
123}
124
125// PaneID returns the ID of the pane this view represents.
126func (pv *PaneView) PaneID() entity.PaneID {
127	return pv.paneID
128}
129
130// WebViewWidget returns the underlying WebView widget.
131func (pv *PaneView) WebViewWidget() layout.Widget {
132	pv.mu.RLock()
133	defer pv.mu.RUnlock()
134
135	return pv.webViewWidget
136}
137
138// SetWebViewWidget replaces the WebView widget.
139func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
140	pv.mu.Lock()
141	defer pv.mu.Unlock()
142
143	// Remove old widget from this overlay
144	if pv.webViewWidget != nil {
145		pv.overlay.SetChild(nil)
146	}
147
148	pv.webViewWidget = widget
149
150	if widget != nil {
151		overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth()
152		overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight()
153		widgetAllocWidthBefore := widget.GetAllocatedWidth()
154		widgetAllocHeightBefore := widget.GetAllocatedHeight()
155		hadParent := widget.GetParent() != nil
156		wasVisible := widget.IsVisible()
157
158		// If this widget already had a parent, we're reparenting an existing WebView
159		// (e.g. after rebuilding/moving panes). In that case the WebView has likely
160		// already painted, so the loading skeleton should be hidden immediately.
161
162		// Unparent widget from any previous parent (critical for rebuild scenarios)
163		// In GTK4, a widget can only have one parent at a time
164		if hadParent {
165			widget.Unparent()
166		}
167		pv.overlay.SetChild(widget)
168
169		if hadParent && wasVisible {
170			widget.SetVisible(true)
171		}
172		if hadParent && pv.loading != nil {
173			pv.loading.SetVisible(false)
174		}
175
176		logging.FromContext(pv.ctx).Debug().
177			Str("pane_id", string(pv.paneID)).
178			Bool("had_parent", hadParent).
179			Bool("was_visible", wasVisible).
180			Int("overlay_alloc_width_before", overlayAllocWidthBefore).
181			Int("overlay_alloc_height_before", overlayAllocHeightBefore).
182			Int("widget_alloc_width_before", widgetAllocWidthBefore).
183			Int("widget_alloc_height_before", widgetAllocHeightBefore).
184			Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()).
185			Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()).
186			Int("widget_alloc_width_after", widget.GetAllocatedWidth()).
187			Int("widget_alloc_height_after", widget.GetAllocatedHeight()).
188			Msg("pane view webview widget attached")
189	}
190}
191
192// GrabFocus attempts to focus the WebView.
193// Returns true if focus was successfully grabbed.
194func (pv *PaneView) GrabFocus() bool {
195	pv.mu.RLock()
196	wv := pv.webViewWidget
197	pv.mu.RUnlock()
198
199	if wv == nil {
200		return false
201	}
202	return wv.GrabFocus()
203}
204
205// HasFocus returns whether the WebView currently has focus.
206func (pv *PaneView) HasFocus() bool {
207	pv.mu.RLock()
208	wv := pv.webViewWidget
209	pv.mu.RUnlock()
210
211	if wv == nil {
212		return false
213	}
214	return wv.HasFocus()
215}
216
217// SetOnFocusIn sets the callback for when the pane gains focus.
218func (pv *PaneView) SetOnFocusIn(fn func(paneID entity.PaneID)) {
219	pv.mu.Lock()
220	defer pv.mu.Unlock()
221
222	pv.onFocusIn = fn
223}
224
225// SetOnFocusOut sets the callback for when the pane loses focus.
226func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
227	pv.mu.Lock()
228	defer pv.mu.Unlock()
229
230	pv.onFocusOut = fn
231}
232
233// SetOnHover sets the callback for when the pane is hovered.
234func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
235	pv.mu.Lock()
236	defer pv.mu.Unlock()
237
238	pv.onHover = fn
239}
240
241// SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane.
242// Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement.
243func (pv *PaneView) SetOnMouseMotion(fn func()) {
244	pv.mu.Lock()
245	defer pv.mu.Unlock()
246
247	pv.onMouseMotion = fn
248}
249
250// AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
251func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
252	pv.mu.Lock()
253	defer pv.mu.Unlock()
254
255	// Create hover handler
256	pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
257
258	// Wire up hover enter callback
259	pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
260		pv.mu.RLock()
261		callback := pv.onHover
262		pv.mu.RUnlock()
263
264		if callback != nil {
265			callback(paneID)
266		}
267	})
268
269	// Wire up mouse motion callback (intentional movement detection)
270	pv.hoverHandler.SetOnMotion(func() {
271		pv.mu.RLock()
272		callback := pv.onMouseMotion
273		pv.mu.RUnlock()
274
275		if callback != nil {
276			callback()
277		}
278	})
279
280	// Attach to overlay widget
281	gtkWidget := pv.overlay.GtkWidget()
282	if gtkWidget != nil {
283		pv.hoverHandler.AttachTo(gtkWidget)
284	}
285}
286
287// CancelPendingHover cancels any pending hover focus timer.
288// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
289func (pv *PaneView) CancelPendingHover() {
290	pv.mu.RLock()
291	handler := pv.hoverHandler
292	pv.mu.RUnlock()
293
294	if handler != nil {
295		handler.Cancel()
296	}
297}
298
299// Widget returns the underlying overlay widget for embedding in containers.
300func (pv *PaneView) Widget() layout.Widget {
301	return pv.overlay
302}
303
304// Overlay returns the underlying overlay widget for direct access.
305func (pv *PaneView) Overlay() layout.OverlayWidget {
306	return pv.overlay
307}
308
309// Show makes the pane visible.
310func (pv *PaneView) Show() {
311	pv.overlay.Show()
312}
313
314// Hide makes the pane invisible.
315func (pv *PaneView) Hide() {
316	pv.overlay.Hide()
317}
318
319// SetVisible sets the visibility of the pane.
320func (pv *PaneView) SetVisible(visible bool) {
321	pv.overlay.SetVisible(visible)
322}
323
324// IsVisible returns whether the pane is visible.
325func (pv *PaneView) IsVisible() bool {
326	return pv.overlay.IsVisible()
327}
328
329// AddCssClass adds a CSS class to the overlay.
330func (pv *PaneView) AddCssClass(class string) {
331	pv.overlay.AddCssClass(class)
332}
333
334// RemoveCssClass removes a CSS class from the overlay.
335func (pv *PaneView) RemoveCssClass(class string) {
336	pv.overlay.RemoveCssClass(class)
337}
338
339// AddOverlayWidget adds a widget as an overlay on this pane.
340// The widget will appear above the WebView and border.
341func (pv *PaneView) AddOverlayWidget(widget layout.Widget) {
342	pv.overlay.AddOverlay(widget)
343	pv.overlay.SetClipOverlay(widget, false)
344	pv.overlay.SetMeasureOverlay(widget, false)
345}
346
347// RemoveOverlayWidget removes an overlay widget from this pane.
348func (pv *PaneView) RemoveOverlayWidget(widget layout.Widget) {
349	pv.overlay.RemoveOverlay(widget)
350}
351
352// GetContentDimensions returns the pane's allocated width and height.
353func (pv *PaneView) GetContentDimensions() (width, height int) {
354	return pv.overlay.GetAllocatedWidth(), pv.overlay.GetAllocatedHeight()
355}
356
357// ensureProgressBar creates the progress bar lazily on first use.
358// This avoids GTK measurement issues with the internal progress gizmo
359// that can occur when the widget is added before the overlay is realized.
360// Must be called with write lock held.
361func (pv *PaneView) ensureProgressBar() *ProgressBar {
362	if pv.progressBar != nil {
363		return pv.progressBar
364	}
365
366	ctx := pv.ctx
367	if ctx == nil {
368		ctx = context.Background()
369	}
370	logging.FromContext(ctx).
371		Debug().
372		Str("pane_id", string(pv.paneID)).
373		Msg("creating progress bar")
374
375	pb := NewProgressBar(pv.ctx, pv.factory)
376	pv.overlay.AddOverlay(pb.Widget())
377	pv.overlay.SetClipOverlay(pb.Widget(), false)
378	pv.overlay.SetMeasureOverlay(pb.Widget(), false)
379	pv.progressBar = pb
380	return pb
381}
382
383// SetLoadProgress updates the progress bar with the current load progress.
384// progress should be between 0.0 and 1.0.
385func (pv *PaneView) SetLoadProgress(progress float64) {
386	pv.mu.Lock()
387	pb := pv.ensureProgressBar()
388	ctx := pv.ctx
389	if ctx == nil {
390		ctx = context.Background()
391	}
392	logging.FromContext(ctx).
393		Trace().
394		Str("pane_id", string(pv.paneID)).
395		Float64("progress", progress).
396		Msg("setting load progress")
397	pv.mu.Unlock()
398
399	pb.SetProgress(progress)
400}
401
402// SetLoading shows or hides the progress bar.
403func (pv *PaneView) SetLoading(loading bool) {
404	pv.mu.Lock()
405	hadProgressBar := pv.progressBar != nil
406	pb := pv.ensureProgressBar()
407	ctx := pv.ctx
408	if ctx == nil {
409		ctx = context.Background()
410	}
411	logging.FromContext(ctx).
412		Debug().
413		Str("pane_id", string(pv.paneID)).
414		Bool("loading", loading).
415		Bool("progress_bar_existed", hadProgressBar).
416		Msg("setting loading state")
417	pv.mu.Unlock()
418
419	if loading {
420		pb.Show()
421	} else {
422		pb.HideWithReason("loading_false")
423	}
424}
425
426// HideLoadingSkeleton hides the loading skeleton overlay (if present).
427func (pv *PaneView) HideLoadingSkeleton() {
428	pv.mu.Lock()
429	loading := pv.loading
430	pv.mu.Unlock()
431
432	if loading != nil {
433		loading.SetVisible(false)
434	}
435}
436
437// ensureToaster creates the toaster lazily on first use.
438// Must be called with write lock held.
439func (pv *PaneView) ensureToaster() *Toaster {
440	if pv.toaster != nil {
441		return pv.toaster
442	}
443
444	t := NewToaster(pv.factory)
445	pv.overlay.AddOverlay(t.Widget())
446	pv.overlay.SetClipOverlay(t.Widget(), false)
447	pv.overlay.SetMeasureOverlay(t.Widget(), false)
448	pv.toaster = t
449	return t
450}
451
452// ShowToast displays a toast notification with the given message and level.
453// If a toast is already visible, updates the text and resets the dismiss timer.
454func (pv *PaneView) ShowToast(ctx context.Context, message string, level ToastLevel) {
455	pv.mu.Lock()
456	t := pv.ensureToaster()
457	pv.mu.Unlock()
458
459	t.Show(ctx, message, level)
460}
461
462// ShowZoomToast displays a zoom level toast notification.
463// Formats the zoom percentage with a % suffix.
464func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
465	pv.mu.Lock()
466	t := pv.ensureToaster()
467	pv.mu.Unlock()
468
469	t.ShowZoom(ctx, zoomPercent)
470}
471
472// ensureLinkStatus creates the link status overlay lazily on first use.
473// Must be called with write lock held.
474func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay {
475	if pv.linkStatus != nil {
476		return pv.linkStatus
477	}
478
479	ls := NewLinkStatusOverlay(pv.factory)
480	pv.overlay.AddOverlay(ls.Widget())
481	pv.overlay.SetClipOverlay(ls.Widget(), false)
482	pv.overlay.SetMeasureOverlay(ls.Widget(), false)
483	pv.linkStatus = ls
484	return ls
485}
486
487// ShowLinkStatus displays the link status overlay with the given URI.
488// If uri is empty, hides the overlay instead.
489func (pv *PaneView) ShowLinkStatus(uri string) {
490	pv.mu.Lock()
491	ls := pv.ensureLinkStatus()
492	pv.mu.Unlock()
493
494	ls.Show(uri)
495}
496
497// HideLinkStatus hides the link status overlay.
498func (pv *PaneView) HideLinkStatus() {
499	pv.mu.Lock()
500	ls := pv.linkStatus
501	pv.mu.Unlock()
502
503	if ls != nil {
504		ls.Hide()
505	}
506}
507
508// Cleanup removes the WebView widget from the overlay and clears references.
509// This must be called before destroying the WebView to ensure proper GTK cleanup.
510// After calling Cleanup, the PaneView should not be reused.
511func (pv *PaneView) Cleanup() {
512	pv.mu.Lock()
513	defer pv.mu.Unlock()
514
515	// Clear callbacks to prevent use-after-free
516	pv.onFocusIn = nil
517	pv.onFocusOut = nil
518	pv.onHover = nil
519
520	// Detach hover handler if present
521	if pv.hoverHandler != nil {
522		pv.hoverHandler.Detach()
523		pv.hoverHandler = nil
524	}
525
526	// Remove WebView from overlay (unparents it from GTK hierarchy)
527	if pv.webViewWidget != nil {
528		pv.overlay.SetChild(nil)
529		pv.webViewWidget = nil
530	}
531
532	// Clear other overlay children
533	if pv.progressBar != nil {
534		pv.overlay.RemoveOverlay(pv.progressBar.Widget())
535		pv.progressBar = nil
536	}
537	if pv.toaster != nil {
538		pv.overlay.RemoveOverlay(pv.toaster.Widget())
539		pv.toaster = nil
540	}
541	if pv.linkStatus != nil {
542		pv.linkStatus.Cleanup() // Cancel pending timers before removal
543		pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
544		pv.linkStatus = nil
545	}
546}

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