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

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