v0.32.0 0617278

workspace_view.go RawHistoryFolder

1// Package component provides UI components for the browser.
2package component
3
4import (
5	"context"
6	"errors"
7	"sync"
8	"sync/atomic"
9	"time"
10
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/internal/ui/layout"
14	"github.com/bnema/puregotk/v4/glib"
15	"github.com/rs/zerolog"
16)
17
18// ErrNilWorkspace is returned when attempting to set a nil workspace.
19var ErrNilWorkspace = errors.New("workspace is nil")
20
21// ErrPaneNotFound is returned when a pane ID cannot be found.
22var ErrPaneNotFound = errors.New("pane not found")
23
24// WorkspaceView is the top-level container that renders a workspace's pane tree.
25// It manages the widget tree and handles active pane state.
26type WorkspaceView struct {
27	factory      layout.WidgetFactory
28	treeRenderer *layout.TreeRenderer
29	container    layout.BoxWidget
30	overlay      layout.OverlayWidget // Wraps container for mode borders
31	rootWidget   layout.Widget        // Current root widget for removal on rebuild
32	logger       zerolog.Logger
33
34	// Mode border overlay slot
35	modeBorderWidget layout.Widget
36
37	// Omnibox lifecycle
38	omnibox       *Omnibox      // Current omnibox (nil if not shown)
39	omniboxWidget layout.Widget // Wrapped omnibox widget for overlay
40	omniboxPaneID entity.PaneID // Which pane has the omnibox
41	omniboxCfg    OmniboxConfig // Stored config for creating omniboxes
42
43	// Find bar lifecycle
44	findBar       *FindBar      // Current find bar (nil if not shown)
45	findBarWidget layout.Widget // Wrapped find bar widget for overlay
46	findBarPaneID entity.PaneID // Which pane has the find bar
47	findBarCfg    FindBarConfig // Stored config for creating find bars
48
49	workspace *entity.Workspace
50	paneViews map[entity.PaneID]*PaneView
51
52	onPaneFocused       func(paneID entity.PaneID)
53	onActivePaneChanged func(paneID entity.PaneID)
54	onWebViewAttached   func(paneID entity.PaneID)
55	onSplitRatioDragged func(nodeID string, ratio float64)
56
57	// Hover suppression for keyboard navigation (Issue #89)
58	// Prevents hover focus from overriding keyboard-initiated focus changes
59	hoverSuppressedUntil time.Time
60	hoverFocusLocked     atomic.Bool
61
62	// newPaneFocusLock suppresses hover focus after a new pane is created.
63	// Rule: new pane creation always wins focus. Hover focus is blocked
64	// until the user intentionally moves the mouse (motion event), at which
65	// point normal hover-follows-mouse behavior resumes. This prevents
66	// GTK's synthetic enter events (fired on widget rearrangement) from
67	// stealing focus away from a freshly created pane.
68	newPaneFocusLock atomic.Bool
69
70	// Auto-open omnibox on new pane creation
71	autoOpenOnNewPane bool
72
73	mu sync.RWMutex
74}
75
76// SetPaneViewsForTest injects pane views into the workspace view for testing.
77// This is a narrow testability seam; production code populates pane views via
78// SetWorkspace and the tree renderer. The workspace field must be set
79// separately on the entity if the tests exercise code paths that read it.
80func (wv *WorkspaceView) SetPaneViewsForTest(paneViews map[entity.PaneID]*PaneView) {
81	wv.mu.Lock()
82	defer wv.mu.Unlock()
83	wv.paneViews = paneViews
84}
85
86// paneViewFactoryAdapter adapts WorkspaceView to the PaneViewFactory interface
87// so TreeRenderer can create PaneViews.
88type paneViewFactoryAdapter struct {
89	wv  *WorkspaceView
90	ctx context.Context
91}
92
93func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Widget {
94	if node == nil || node.Pane == nil {
95		return nil
96	}
97
98	// Create PaneView without WebView widget for now
99	// WebView will be attached later by the application layer
100	pv := NewPaneView(a.ctx, a.wv.factory, node.Pane.ID, nil)
101
102	// Store in map for later lookup
103	// Note: Caller (SetWorkspace) already holds the lock, so we access directly
104	a.wv.paneViews[node.Pane.ID] = pv
105
106	// Set up focus callback
107	pv.SetOnFocusIn(func(paneID entity.PaneID) {
108		a.wv.mu.RLock()
109		callback := a.wv.onPaneFocused
110		a.wv.mu.RUnlock()
111
112		if callback != nil {
113			callback(paneID)
114		}
115	})
116
117	// Clear new-pane focus lock on intentional mouse motion.
118	// This allows hover-follows-mouse to resume after the user moves the
119	// mouse, while keeping focus on the new pane until that happens.
120	pv.SetOnMouseMotion(func() {
121		a.wv.newPaneFocusLock.Store(false)
122	})
123
124	// Set up hover callback for focus-follows-mouse
125	pv.SetOnHover(func(paneID entity.PaneID) {
126		// Skip if hover is suppressed (keyboard navigation just happened)
127		if a.wv.IsHoverSuppressed() {
128			return
129		}
130
131		if a.wv.IsHoverFocusLocked() {
132			return
133		}
134
135		// Skip if new pane was just created — focus stays on new pane
136		// until the user intentionally moves the mouse (see SetOnMouseMotion).
137		if a.wv.newPaneFocusLock.Load() {
138			return
139		}
140
141		// Skip if this pane is already active
142		if a.wv.GetActivePaneID() == paneID {
143			return
144		}
145
146		// Activate the hovered pane and grab focus
147		if err := a.wv.SetActivePaneID(paneID); err == nil {
148			a.wv.FocusPane(paneID)
149		}
150	})
151
152	// Attach hover handler with debouncing
153	pv.AttachHoverHandler(a.ctx)
154
155	return pv.Widget()
156}
157
158// NewWorkspaceView creates a new workspace view.
159func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *WorkspaceView {
160	log := logging.FromContext(ctx)
161
162	container := factory.NewBox(layout.OrientationVertical, 0)
163	container.SetHexpand(true)
164	container.SetVexpand(true)
165	container.SetVisible(true)
166
167	// Wrap container in overlay for mode borders
168	overlay := factory.NewOverlay()
169	overlay.SetHexpand(true)
170	overlay.SetVexpand(true)
171	overlay.SetChild(container)
172	overlay.SetVisible(true)
173
174	wv := &WorkspaceView{
175		factory:   factory,
176		container: container,
177		overlay:   overlay,
178		logger:    log.With().Str("component", "workspace-view").Logger(),
179		paneViews: make(map[entity.PaneID]*PaneView),
180	}
181
182	// Create tree renderer with our adapter as the pane view factory
183	wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
184	wv.treeRenderer.SetOnSplitRatioChanged(func(nodeID string, ratio float64) {
185		wv.mu.RLock()
186		cb := wv.onSplitRatioDragged
187		wv.mu.RUnlock()
188		if cb != nil {
189			cb(nodeID, ratio)
190		}
191	})
192
193	return wv
194}
195
196// SetWorkspace sets the workspace to render and builds the widget tree.
197func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) error {
198	if ws == nil {
199		return ErrNilWorkspace
200	}
201
202	wv.mu.Lock()
203	defer wv.mu.Unlock()
204
205	// Release previous views before dropping their ownership. This also detaches
206	// hover controllers and callback closures before their widgets are rebuilt.
207	for _, pv := range wv.paneViews {
208		if pv != nil {
209			pv.Cleanup()
210		}
211	}
212
213	// Clear previous state
214	wv.workspace = ws
215	wv.paneViews = make(map[entity.PaneID]*PaneView)
216
217	// Remove old root widget from container before building new tree
218	if wv.rootWidget != nil {
219		wv.container.Remove(wv.rootWidget)
220		wv.rootWidget = nil
221	}
222
223	// Build new tree
224	if ws.Root != nil {
225		widget, err := wv.treeRenderer.Build(ctx, ws.Root)
226		if err != nil {
227			return err
228		}
229
230		if widget != nil {
231			widget.SetVisible(true)
232			wv.container.Append(widget)
233			wv.rootWidget = widget
234		}
235	}
236
237	// Set initial active pane. A persisted workspace can contain a stale active
238	// pane ID after restore/rebuild; choose a valid fallback instead of leaving
239	// later active-pane logic pointing at a missing pane.
240	wv.restoreActivePaneInternal()
241
242	// Update single-pane mode based on pane count
243	wv.updateSinglePaneModeInternal()
244
245	return nil
246}
247
248// SetActivePaneID updates which pane is visually marked as active.
249func (wv *WorkspaceView) SetActivePaneID(paneID entity.PaneID) error {
250	wv.mu.Lock()
251	currentActiveID := wv.getActivePaneIDInternal()
252	err := wv.setActivePaneIDInternal(paneID)
253	callback := wv.onActivePaneChanged
254	wv.mu.Unlock()
255	if err != nil {
256		return err
257	}
258	if currentActiveID != paneID && callback != nil {
259		callback(paneID)
260	}
261	return nil
262}
263
264// restoreActivePaneInternal restores the active pane after building paneViews.
265// Must be called with the lock held.
266func (wv *WorkspaceView) restoreActivePaneInternal() {
267	if wv.workspace == nil {
268		return
269	}
270
271	paneID := wv.workspace.ActivePaneID
272	if paneID != "" {
273		if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
274			_ = wv.setActivePaneIDInternal(paneID)
275			return
276		}
277		wv.logger.Debug().Str("pane_id", string(paneID)).Msg("stale active pane ignored")
278		wv.workspace.ActivePaneID = ""
279	}
280
281	if fallbackID, ok := wv.firstPaneIDInTreeInternal(); ok {
282		_ = wv.setActivePaneIDInternal(fallbackID)
283	}
284}
285
286func (wv *WorkspaceView) firstPaneIDInTreeInternal() (entity.PaneID, bool) {
287	if wv.workspace == nil {
288		return "", false
289	}
290	return wv.firstPaneIDInNodeInternal(wv.workspace.Root)
291}
292
293func (wv *WorkspaceView) firstPaneIDInNodeInternal(node *entity.PaneNode) (entity.PaneID, bool) {
294	if node == nil {
295		return "", false
296	}
297	if node.Pane != nil {
298		paneID := node.Pane.ID
299		pv, ok := wv.paneViews[paneID]
300		return paneID, ok && pv != nil
301	}
302	for _, child := range node.Children {
303		if paneID, ok := wv.firstPaneIDInNodeInternal(child); ok {
304			return paneID, true
305		}
306	}
307	return "", false
308}
309
310// setActivePaneIDInternal updates active pane without locking.
311// Updates the domain model as the single source of truth.
312func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
313	currentActiveID := wv.getActivePaneIDInternal()
314
315	wv.logger.Debug().
316		Str("from_pane", string(currentActiveID)).
317		Str("to_pane", string(paneID)).
318		Msg("active pane changing")
319
320	// Validate the target pane before mutating UI state. Invalid/stale IDs
321	// should leave the current active pane styling and overlays untouched.
322	newPV, ok := wv.paneViews[paneID]
323	if !ok || newPV == nil {
324		return ErrPaneNotFound
325	}
326
327	// Destroy find bar if active pane is changing
328	if currentActiveID != paneID && wv.findBar != nil {
329		wv.hideFindBarInternal()
330	}
331
332	// Hide omnibox if active pane is changing
333	if currentActiveID != paneID && wv.omnibox != nil {
334		wv.hideOmniboxInternal()
335	}
336
337	// Deactivate current active pane
338	if currentActiveID != "" {
339		if oldPV, ok := wv.paneViews[currentActiveID]; ok && oldPV != nil {
340			oldPV.SetActive(false)
341		}
342	}
343
344	newPV.SetActive(true)
345
346	// Update domain model - single source of truth
347	if wv.workspace != nil {
348		wv.workspace.ActivePaneID = paneID
349	}
350
351	return nil
352}
353
354// GetActivePaneID returns the ID of the currently active pane.
355// Reads from the domain model as the single source of truth.
356func (wv *WorkspaceView) GetActivePaneID() entity.PaneID {
357	wv.mu.RLock()
358	defer wv.mu.RUnlock()
359
360	return wv.getActivePaneIDInternal()
361}
362
363// getActivePaneIDInternal returns the active pane ID without locking.
364// Must be called with at least a read lock held.
365func (wv *WorkspaceView) getActivePaneIDInternal() entity.PaneID {
366	if wv.workspace == nil {
367		return ""
368	}
369	return wv.workspace.ActivePaneID
370}
371
372// GetPaneView returns the PaneView for a given pane ID.
373// Returns nil if not found.
374func (wv *WorkspaceView) GetPaneView(paneID entity.PaneID) *PaneView {
375	wv.mu.RLock()
376	defer wv.mu.RUnlock()
377
378	return wv.paneViews[paneID]
379}
380
381// GetPaneIDs returns all pane IDs in this workspace view.
382func (wv *WorkspaceView) GetPaneIDs() []entity.PaneID {
383	wv.mu.RLock()
384	defer wv.mu.RUnlock()
385
386	ids := make([]entity.PaneID, 0, len(wv.paneViews))
387	for id := range wv.paneViews {
388		ids = append(ids, id)
389	}
390	return ids
391}
392
393// PaneCount returns the number of panes in the workspace view.
394func (wv *WorkspaceView) PaneCount() int {
395	wv.mu.RLock()
396	defer wv.mu.RUnlock()
397
398	return len(wv.paneViews)
399}
400
401// singlePaneClass is used to hide the active pane border when only one visible area exists.
402const singlePaneClass = "single-pane"
403
404// updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
405// Uses VisibleAreaCount from the domain model: stacked panes count as 1 visible area.
406// Must be called with the lock held.
407func (wv *WorkspaceView) updateSinglePaneModeInternal() {
408	if wv.container == nil {
409		return
410	}
411
412	visibleAreas := 0
413	if wv.workspace != nil {
414		visibleAreas = wv.workspace.VisibleAreaCount()
415	}
416
417	if visibleAreas <= 1 {
418		wv.container.AddCssClass(singlePaneClass)
419	} else {
420		wv.container.RemoveCssClass(singlePaneClass)
421	}
422}
423
424// SetOnPaneFocused sets the callback for when a pane receives focus.
425func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
426	wv.mu.Lock()
427	defer wv.mu.Unlock()
428
429	wv.onPaneFocused = fn
430}
431
432// SetOnActivePaneChanged sets the callback invoked after the active pane changes.
433func (wv *WorkspaceView) SetOnActivePaneChanged(fn func(paneID entity.PaneID)) {
434	wv.mu.Lock()
435	defer wv.mu.Unlock()
436
437	wv.onActivePaneChanged = fn
438}
439
440// SetOnWebViewAttached sets the callback invoked after a pane receives a WebView widget.
441func (wv *WorkspaceView) SetOnWebViewAttached(fn func(paneID entity.PaneID)) {
442	wv.mu.Lock()
443	defer wv.mu.Unlock()
444
445	wv.onWebViewAttached = fn
446}
447
448func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
449	wv.mu.Lock()
450	defer wv.mu.Unlock()
451
452	wv.onSplitRatioDragged = fn
453}
454
455// Rebuild rebuilds the widget tree from the current workspace.
456// Use this after structural changes like splits or closes.
457func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
458	wv.mu.RLock()
459	ws := wv.workspace
460	wv.mu.RUnlock()
461
462	if ws == nil {
463		return ErrNilWorkspace
464	}
465
466	return wv.SetWorkspace(ctx, ws)
467}
468
469// FocusPane attempts to give focus to a specific pane.
470// Returns true if focus was successfully grabbed.
471func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
472	wv.mu.RLock()
473	pv, ok := wv.paneViews[paneID]
474	wv.mu.RUnlock()
475
476	if !ok || pv == nil {
477		return false
478	}
479
480	return pv.GrabFocus()
481}
482
483// DeactivatePane removes the active visual indicator from a pane.
484// This is used when keyboard navigation overrides mouse hover focus.
485func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
486	wv.mu.RLock()
487	pv, ok := wv.paneViews[paneID]
488	wv.mu.RUnlock()
489
490	if ok && pv != nil {
491		pv.SetActive(false)
492	}
493}
494
495// SetWebViewWidget attaches a not-yet-revealed WebView widget to a specific pane.
496// Call AttachWebViewWidget when the content coordinator has explicit reveal state.
497func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
498	return wv.AttachWebViewWidget(paneID, widget, false)
499}
500
501// AttachWebViewWidget attaches a WebView widget with the content coordinator's
502// explicit reveal state. A WorkspaceView rebuild can unparent a live widget, so
503// widget parentage cannot determine whether its loading skeleton is still needed.
504func (wv *WorkspaceView) AttachWebViewWidget(paneID entity.PaneID, widget layout.Widget, revealed bool) error {
505	wv.mu.RLock()
506	pv, ok := wv.paneViews[paneID]
507	callback := wv.onWebViewAttached
508	wv.mu.RUnlock()
509
510	if !ok || pv == nil {
511		return ErrPaneNotFound
512	}
513
514	pv.AttachWebViewWidget(widget, revealed)
515	if widget != nil && callback != nil {
516		callback(paneID)
517	}
518	return nil
519}
520
521// Widget returns the overlay widget for embedding in the UI.
522// The overlay wraps the pane container and allows mode borders to be displayed.
523func (wv *WorkspaceView) Widget() layout.Widget {
524	return wv.overlay
525}
526
527// Container returns the underlying BoxWidget for direct access.
528func (wv *WorkspaceView) Container() layout.BoxWidget {
529	return wv.container
530}
531
532// TreeRenderer returns the underlying tree renderer.
533func (wv *WorkspaceView) TreeRenderer() *layout.TreeRenderer {
534	return wv.treeRenderer
535}
536
537// Workspace returns the current workspace.
538func (wv *WorkspaceView) Workspace() *entity.Workspace {
539	wv.mu.RLock()
540	defer wv.mu.RUnlock()
541
542	return wv.workspace
543}
544
545// RegisterPaneView adds a PaneView to the tracking map without rebuilding.
546// Use this for incremental operations like stacked panes.
547func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
548	wv.mu.Lock()
549	defer wv.mu.Unlock()
550
551	wv.paneViews[paneID] = pv
552	wv.updateSinglePaneModeInternal()
553}
554
555// UnregisterPaneView removes a PaneView from the tracking map and cleans it up.
556// This must be called when closing a pane to properly release GTK resources
557// before destroying the WebView.
558func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
559	wv.mu.Lock()
560	defer wv.mu.Unlock()
561
562	// Call Cleanup on the PaneView to unparent widgets and clear callbacks
563	// This must happen before WebView.Destroy() is called
564	if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
565		pv.Cleanup()
566	}
567
568	delete(wv.paneViews, paneID)
569	wv.updateSinglePaneModeInternal()
570}
571
572// GetRootWidget returns the current root widget of the workspace.
573// This is useful for incremental operations that need to modify the tree.
574func (wv *WorkspaceView) GetRootWidget() layout.Widget {
575	wv.mu.RLock()
576	defer wv.mu.RUnlock()
577
578	return wv.rootWidget
579}
580
581// SetRootWidgetDirect replaces the root widget without rebuilding the entire tree.
582// Use this for incremental operations when converting to/from stacked panes.
583func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
584	wv.mu.Lock()
585	defer wv.mu.Unlock()
586
587	// Remove old root if present
588	if wv.rootWidget != nil {
589		wv.container.Remove(wv.rootWidget)
590	}
591
592	// Add new root
593	if widget != nil {
594		widget.SetVisible(true)
595		wv.container.Append(widget)
596	}
597
598	wv.rootWidget = widget
599}
600
601// ClearRootWidgetRef clears the stored root widget reference without removing it.
602// Use this before SetRootWidgetDirect when the old root has already been removed
603// through other means (e.g., GTK paned operations).
604func (wv *WorkspaceView) ClearRootWidgetRef() {
605	wv.mu.Lock()
606	defer wv.mu.Unlock()
607	wv.rootWidget = nil
608}
609
610// Factory returns the widget factory used by this workspace view.
611func (wv *WorkspaceView) Factory() layout.WidgetFactory {
612	return wv.factory
613}
614
615// SetModeBorderOverlay attaches a mode border overlay widget.
616// The widget will be displayed on top of the pane container when modes are active.
617func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
618	wv.mu.Lock()
619	defer wv.mu.Unlock()
620
621	// Remove old overlay if present
622	if wv.modeBorderWidget != nil && wv.overlay != nil {
623		wv.overlay.RemoveOverlay(wv.modeBorderWidget)
624	}
625
626	wv.modeBorderWidget = widget
627
628	// Add new overlay
629	if widget != nil && wv.overlay != nil {
630		wv.overlay.AddOverlay(widget)
631		// Don't clip or measure the overlay - it should fill the entire area
632		wv.overlay.SetClipOverlay(widget, false)
633		wv.overlay.SetMeasureOverlay(widget, false)
634	}
635}
636
637// WorkspaceOverlayWidget returns the workspace-level overlay.
638func (wv *WorkspaceView) WorkspaceOverlayWidget() layout.OverlayWidget {
639	wv.mu.RLock()
640	defer wv.mu.RUnlock()
641	return wv.overlay
642}
643
644// AddWorkspaceOverlayWidget adds a widget to the workspace-level overlay safely.
645func (wv *WorkspaceView) AddWorkspaceOverlayWidget(widget layout.Widget) {
646	if widget == nil {
647		return
648	}
649
650	wv.mu.Lock()
651	defer wv.mu.Unlock()
652	if wv.overlay == nil {
653		return
654	}
655
656	if parent := widget.GetParent(); parent != nil {
657		if parent == wv.overlay {
658			return
659		}
660
661		parentGTK := parent.GtkWidget()
662		overlayGTK := wv.overlay.GtkWidget()
663		if parentGTK != nil && overlayGTK != nil && parentGTK.GoPointer() == overlayGTK.GoPointer() {
664			return
665		}
666		widget.Unparent()
667	}
668
669	wv.overlay.AddOverlay(widget)
670	wv.overlay.SetClipOverlay(widget, false)
671	wv.overlay.SetMeasureOverlay(widget, false)
672}
673
674// RemoveWorkspaceOverlayWidget removes a widget from the workspace-level overlay safely.
675func (wv *WorkspaceView) RemoveWorkspaceOverlayWidget(widget layout.Widget) {
676	if widget == nil {
677		return
678	}
679
680	wv.mu.Lock()
681	defer wv.mu.Unlock()
682	if wv.overlay == nil {
683		return
684	}
685
686	if parent := widget.GetParent(); parent == wv.overlay {
687		wv.overlay.RemoveOverlay(widget)
688	} else if parent != nil {
689		widget.Unparent()
690	}
691}
692
693// GetPaneWidget returns the widget for a pane ID.
694// Implements focus.PaneGeometryProvider.
695func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
696	wv.mu.RLock()
697	defer wv.mu.RUnlock()
698
699	pv, ok := wv.paneViews[paneID]
700	if !ok || pv == nil {
701		return nil
702	}
703	return pv.Widget()
704}
705
706// GetStackContainerWidget returns the stack container widget for a stacked pane.
707// Returns nil if the pane is not in a stack.
708// Implements focus.PaneGeometryProvider.
709func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget {
710	if wv.treeRenderer == nil {
711		return nil
712	}
713
714	stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID))
715	if stackedView == nil {
716		return nil
717	}
718
719	return stackedView.Widget()
720}
721
722// ContainerWidget returns the container widget for relative positioning.
723// Implements focus.PaneGeometryProvider.
724func (wv *WorkspaceView) ContainerWidget() layout.Widget {
725	return wv.container
726}
727
728// SetOmniboxConfig stores the omnibox configuration for later use.
729func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) {
730	wv.mu.Lock()
731	defer wv.mu.Unlock()
732	wv.omniboxCfg = cfg
733}
734
735// SetOmniboxUIScale updates current and future omniboxes on the GTK main thread.
736func (wv *WorkspaceView) SetOmniboxUIScale(scale float64) {
737	scale = normalizeUIScale(scale)
738	wv.mu.Lock()
739	wv.omniboxCfg.UIScale = scale
740	omnibox := wv.omnibox
741	wv.mu.Unlock()
742	if omnibox != nil {
743		omnibox.SetUIScale(scale)
744	}
745}
746
747// SetFindBarConfig stores the find bar configuration for later use.
748func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
749	wv.mu.Lock()
750	defer wv.mu.Unlock()
751	wv.findBarCfg = cfg
752}
753
754// SetAutoOpenOnNewPane configures whether to show omnibox when a new pane is created.
755func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) {
756	wv.mu.Lock()
757	defer wv.mu.Unlock()
758	wv.autoOpenOnNewPane = enabled
759}
760
761// NotifyNewPaneCreated handles new pane creation events.
762// Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox.
763func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) {
764	// Lock hover focus until the user intentionally moves the mouse.
765	// New pane creation always wins focus; hover-follows-mouse resumes
766	// only after real mouse motion (cleared by PaneView.SetOnMouseMotion).
767	wv.newPaneFocusLock.Store(true)
768	wv.SuppressHover(KeyboardFocusSuppressDuration)
769	wv.CancelAllPendingHovers()
770
771	wv.mu.RLock()
772	autoOpen := wv.autoOpenOnNewPane
773	wv.mu.RUnlock()
774
775	if !autoOpen {
776		return
777	}
778
779	// Defer omnibox show until after GTK layout allocation completes
780	cb := glib.SourceFunc(func(uintptr) bool {
781		wv.ShowOmnibox(ctx, "")
782		return false
783	})
784	glib.IdleAdd(&cb, 0)
785}
786
787// ShowOmnibox creates and shows the omnibox in the active pane.
788func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
789	wv.mu.Lock()
790	defer wv.mu.Unlock()
791
792	// Get the active pane view
793	activePaneID := wv.getActivePaneIDInternal()
794	pv := wv.paneViews[activePaneID]
795	if pv == nil {
796		wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show omnibox: active pane not found")
797		return
798	}
799
800	// If omnibox exists but on different pane, destroy and recreate on active pane
801	if wv.omnibox != nil {
802		if wv.omniboxPaneID != activePaneID {
803			wv.logger.Debug().
804				Str("old_pane", string(wv.omniboxPaneID)).
805				Str("new_pane", string(activePaneID)).
806				Msg("omnibox pane changed, recreating")
807			wv.hideOmniboxInternal()
808		} else {
809			wv.logger.Debug().Str("pane", string(activePaneID)).Msg("showing existing omnibox")
810			wv.omnibox.Show(ctx, query)
811			return
812		}
813	}
814
815	// Create omnibox with pane-specific toast callback
816	cfg := wv.omniboxCfg
817	cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) {
818		pv.ShowToast(toastCtx, message, level)
819	}
820	omnibox := NewOmnibox(ctx, cfg)
821	if omnibox == nil {
822		wv.logger.Error().Msg("failed to create omnibox")
823		return
824	}
825
826	// Set parent overlay for sizing
827	omnibox.SetParentOverlay(pv.Overlay())
828
829	// Wrap widget for layout system
830	omniboxWidget := omnibox.WidgetAsLayout(wv.factory)
831	if omniboxWidget == nil {
832		wv.logger.Error().Msg("failed to wrap omnibox widget")
833		return
834	}
835
836	// Add to pane overlay
837	pv.AddOverlayWidget(omniboxWidget)
838
839	// Store references
840	wv.omnibox = omnibox
841	wv.omniboxWidget = omniboxWidget
842	wv.omniboxPaneID = activePaneID
843
844	// Show the omnibox
845	omnibox.Show(ctx, query)
846
847	wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("omnibox shown")
848}
849
850// HideOmnibox hides and destroys the current omnibox.
851func (wv *WorkspaceView) HideOmnibox() {
852	wv.mu.Lock()
853	defer wv.mu.Unlock()
854
855	wv.hideOmniboxInternal()
856}
857
858// hideOmniboxInternal destroys the omnibox without locking.
859func (wv *WorkspaceView) hideOmniboxInternal() {
860	if wv.omnibox == nil {
861		return
862	}
863
864	// Hide the omnibox first (use Background context since this is internal cleanup)
865	wv.omnibox.Hide(context.Background())
866
867	// Remove from pane overlay
868	if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
869		// The omnibox widget may have been reparented during a rebuild/move.
870		// Avoid GTK criticals by only removing from the overlay that owns it.
871		if parent := wv.omniboxWidget.GetParent(); parent == pv.Overlay() {
872			pv.RemoveOverlayWidget(wv.omniboxWidget)
873		} else if parent != nil {
874			wv.omniboxWidget.Unparent()
875		}
876	}
877
878	// Clear references
879	wv.omnibox = nil
880	wv.omniboxWidget = nil
881	wv.omniboxPaneID = ""
882
883	wv.logger.Debug().Msg("omnibox hidden and destroyed")
884}
885
886// ShowFindBar creates and shows the find bar in the active pane.
887func (wv *WorkspaceView) ShowFindBar(ctx context.Context) {
888	wv.mu.Lock()
889	defer wv.mu.Unlock()
890
891	// If find bar already exists, just show it
892	if wv.findBar != nil {
893		wv.findBar.Show()
894		return
895	}
896
897	activePaneID := wv.getActivePaneIDInternal()
898	pv := wv.paneViews[activePaneID]
899	if pv == nil {
900		wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show find bar: active pane not found")
901		return
902	}
903
904	cfg := wv.findBarCfg
905	cfg.OnClose = func() {
906		wv.HideFindBar()
907	}
908	findBar := NewFindBar(ctx, cfg)
909	if findBar == nil {
910		wv.logger.Error().Msg("failed to create find bar")
911		return
912	}
913
914	findBarWidget := findBar.WidgetAsLayout(wv.factory)
915	if findBarWidget == nil {
916		wv.logger.Error().Msg("failed to wrap find bar widget")
917		return
918	}
919
920	pv.AddOverlayWidget(findBarWidget)
921
922	if cfg.GetFindController != nil {
923		controller := cfg.GetFindController(activePaneID)
924		findBar.SetFindController(controller)
925	}
926
927	wv.findBar = findBar
928	wv.findBarWidget = findBarWidget
929	wv.findBarPaneID = activePaneID
930
931	findBar.Show()
932
933	wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("find bar shown")
934}
935
936// HideFindBar hides and destroys the current find bar.
937func (wv *WorkspaceView) HideFindBar() {
938	wv.mu.Lock()
939	defer wv.mu.Unlock()
940
941	wv.hideFindBarInternal()
942}
943
944// FindNext triggers the next match in the find bar if available.
945func (wv *WorkspaceView) FindNext() {
946	wv.mu.RLock()
947	findBar := wv.findBar
948	wv.mu.RUnlock()
949
950	if findBar != nil {
951		findBar.FindNext()
952	}
953}
954
955// FindPrevious triggers the previous match in the find bar if available.
956func (wv *WorkspaceView) FindPrevious() {
957	wv.mu.RLock()
958	findBar := wv.findBar
959	wv.mu.RUnlock()
960
961	if findBar != nil {
962		findBar.FindPrevious()
963	}
964}
965
966// IsFindBarVisible returns whether the find bar is currently visible.
967func (wv *WorkspaceView) IsFindBarVisible() bool {
968	wv.mu.RLock()
969	defer wv.mu.RUnlock()
970	return wv.findBar != nil && wv.findBar.IsVisible()
971}
972
973// hideFindBarInternal destroys the find bar without locking.
974func (wv *WorkspaceView) hideFindBarInternal() {
975	if wv.findBar == nil {
976		return
977	}
978
979	wv.findBar.Hide()
980
981	if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
982		if parent := wv.findBarWidget.GetParent(); parent == pv.Overlay() {
983			pv.RemoveOverlayWidget(wv.findBarWidget)
984		} else if parent != nil {
985			wv.findBarWidget.Unparent()
986		}
987	}
988
989	wv.findBar = nil
990	wv.findBarWidget = nil
991	wv.findBarPaneID = ""
992
993	wv.logger.Debug().Msg("find bar hidden and destroyed")
994}
995
996// GetOmnibox returns the current omnibox if visible.
997func (wv *WorkspaceView) GetOmnibox() *Omnibox {
998	wv.mu.RLock()
999	defer wv.mu.RUnlock()
1000	return wv.omnibox
1001}
1002
1003// IsOmniboxVisible returns whether the omnibox is currently visible.
1004func (wv *WorkspaceView) IsOmniboxVisible() bool {
1005	wv.mu.RLock()
1006	defer wv.mu.RUnlock()
1007	return wv.omnibox != nil && wv.omnibox.IsVisible()
1008}
1009
1010// GetActivePaneView returns the PaneView for the current active pane.
1011func (wv *WorkspaceView) GetActivePaneView() *PaneView {
1012	wv.mu.RLock()
1013	defer wv.mu.RUnlock()
1014	return wv.paneViews[wv.getActivePaneIDInternal()]
1015}
1016
1017// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
1018const KeyboardFocusSuppressDuration = 300 * time.Millisecond
1019
1020// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
1021// This prevents the race condition where a pending hover timer overrides
1022// an explicit keyboard focus change (Issue #89).
1023func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
1024	wv.mu.Lock()
1025	wv.hoverSuppressedUntil = time.Now().Add(duration)
1026	wv.mu.Unlock()
1027}
1028
1029// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
1030func (wv *WorkspaceView) IsHoverSuppressed() bool {
1031	wv.mu.RLock()
1032	defer wv.mu.RUnlock()
1033	return time.Now().Before(wv.hoverSuppressedUntil)
1034}
1035
1036// SetHoverFocusLocked enables or disables hover-to-focus behavior.
1037func (wv *WorkspaceView) SetHoverFocusLocked(locked bool) {
1038	wv.hoverFocusLocked.Store(locked)
1039}
1040
1041// IsHoverFocusLocked returns whether hover-to-focus is currently locked.
1042func (wv *WorkspaceView) IsHoverFocusLocked() bool {
1043	return wv.hoverFocusLocked.Load()
1044}
1045
1046// CancelAllPendingHovers cancels any pending hover timers on all pane views.
1047// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
1048func (wv *WorkspaceView) CancelAllPendingHovers() {
1049	wv.mu.RLock()
1050	defer wv.mu.RUnlock()
1051
1052	for _, pv := range wv.paneViews {
1053		if pv != nil {
1054			pv.CancelPendingHover()
1055		}
1056	}
1057}

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