v0.31.0 bf0756b

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// SetFindBarConfig stores the find bar configuration for later use.
736func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
737	wv.mu.Lock()
738	defer wv.mu.Unlock()
739	wv.findBarCfg = cfg
740}
741
742// SetAutoOpenOnNewPane configures whether to show omnibox when a new pane is created.
743func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) {
744	wv.mu.Lock()
745	defer wv.mu.Unlock()
746	wv.autoOpenOnNewPane = enabled
747}
748
749// NotifyNewPaneCreated handles new pane creation events.
750// Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox.
751func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) {
752	// Lock hover focus until the user intentionally moves the mouse.
753	// New pane creation always wins focus; hover-follows-mouse resumes
754	// only after real mouse motion (cleared by PaneView.SetOnMouseMotion).
755	wv.newPaneFocusLock.Store(true)
756	wv.SuppressHover(KeyboardFocusSuppressDuration)
757	wv.CancelAllPendingHovers()
758
759	wv.mu.RLock()
760	autoOpen := wv.autoOpenOnNewPane
761	wv.mu.RUnlock()
762
763	if !autoOpen {
764		return
765	}
766
767	// Defer omnibox show until after GTK layout allocation completes
768	cb := glib.SourceFunc(func(uintptr) bool {
769		wv.ShowOmnibox(ctx, "")
770		return false
771	})
772	glib.IdleAdd(&cb, 0)
773}
774
775// ShowOmnibox creates and shows the omnibox in the active pane.
776func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
777	wv.mu.Lock()
778	defer wv.mu.Unlock()
779
780	// Get the active pane view
781	activePaneID := wv.getActivePaneIDInternal()
782	pv := wv.paneViews[activePaneID]
783	if pv == nil {
784		wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show omnibox: active pane not found")
785		return
786	}
787
788	// If omnibox exists but on different pane, destroy and recreate on active pane
789	if wv.omnibox != nil {
790		if wv.omniboxPaneID != activePaneID {
791			wv.logger.Debug().
792				Str("old_pane", string(wv.omniboxPaneID)).
793				Str("new_pane", string(activePaneID)).
794				Msg("omnibox pane changed, recreating")
795			wv.hideOmniboxInternal()
796		} else {
797			wv.logger.Debug().Str("pane", string(activePaneID)).Msg("showing existing omnibox")
798			wv.omnibox.Show(ctx, query)
799			return
800		}
801	}
802
803	// Create omnibox with pane-specific toast callback
804	cfg := wv.omniboxCfg
805	cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) {
806		pv.ShowToast(toastCtx, message, level)
807	}
808	omnibox := NewOmnibox(ctx, cfg)
809	if omnibox == nil {
810		wv.logger.Error().Msg("failed to create omnibox")
811		return
812	}
813
814	// Set parent overlay for sizing
815	omnibox.SetParentOverlay(pv.Overlay())
816
817	// Wrap widget for layout system
818	omniboxWidget := omnibox.WidgetAsLayout(wv.factory)
819	if omniboxWidget == nil {
820		wv.logger.Error().Msg("failed to wrap omnibox widget")
821		return
822	}
823
824	// Add to pane overlay
825	pv.AddOverlayWidget(omniboxWidget)
826
827	// Store references
828	wv.omnibox = omnibox
829	wv.omniboxWidget = omniboxWidget
830	wv.omniboxPaneID = activePaneID
831
832	// Show the omnibox
833	omnibox.Show(ctx, query)
834
835	wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("omnibox shown")
836}
837
838// HideOmnibox hides and destroys the current omnibox.
839func (wv *WorkspaceView) HideOmnibox() {
840	wv.mu.Lock()
841	defer wv.mu.Unlock()
842
843	wv.hideOmniboxInternal()
844}
845
846// hideOmniboxInternal destroys the omnibox without locking.
847func (wv *WorkspaceView) hideOmniboxInternal() {
848	if wv.omnibox == nil {
849		return
850	}
851
852	// Hide the omnibox first (use Background context since this is internal cleanup)
853	wv.omnibox.Hide(context.Background())
854
855	// Remove from pane overlay
856	if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
857		// The omnibox widget may have been reparented during a rebuild/move.
858		// Avoid GTK criticals by only removing from the overlay that owns it.
859		if parent := wv.omniboxWidget.GetParent(); parent == pv.Overlay() {
860			pv.RemoveOverlayWidget(wv.omniboxWidget)
861		} else if parent != nil {
862			wv.omniboxWidget.Unparent()
863		}
864	}
865
866	// Clear references
867	wv.omnibox = nil
868	wv.omniboxWidget = nil
869	wv.omniboxPaneID = ""
870
871	wv.logger.Debug().Msg("omnibox hidden and destroyed")
872}
873
874// ShowFindBar creates and shows the find bar in the active pane.
875func (wv *WorkspaceView) ShowFindBar(ctx context.Context) {
876	wv.mu.Lock()
877	defer wv.mu.Unlock()
878
879	// If find bar already exists, just show it
880	if wv.findBar != nil {
881		wv.findBar.Show()
882		return
883	}
884
885	activePaneID := wv.getActivePaneIDInternal()
886	pv := wv.paneViews[activePaneID]
887	if pv == nil {
888		wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show find bar: active pane not found")
889		return
890	}
891
892	cfg := wv.findBarCfg
893	cfg.OnClose = func() {
894		wv.HideFindBar()
895	}
896	findBar := NewFindBar(ctx, cfg)
897	if findBar == nil {
898		wv.logger.Error().Msg("failed to create find bar")
899		return
900	}
901
902	findBarWidget := findBar.WidgetAsLayout(wv.factory)
903	if findBarWidget == nil {
904		wv.logger.Error().Msg("failed to wrap find bar widget")
905		return
906	}
907
908	pv.AddOverlayWidget(findBarWidget)
909
910	if cfg.GetFindController != nil {
911		controller := cfg.GetFindController(activePaneID)
912		findBar.SetFindController(controller)
913	}
914
915	wv.findBar = findBar
916	wv.findBarWidget = findBarWidget
917	wv.findBarPaneID = activePaneID
918
919	findBar.Show()
920
921	wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("find bar shown")
922}
923
924// HideFindBar hides and destroys the current find bar.
925func (wv *WorkspaceView) HideFindBar() {
926	wv.mu.Lock()
927	defer wv.mu.Unlock()
928
929	wv.hideFindBarInternal()
930}
931
932// FindNext triggers the next match in the find bar if available.
933func (wv *WorkspaceView) FindNext() {
934	wv.mu.RLock()
935	findBar := wv.findBar
936	wv.mu.RUnlock()
937
938	if findBar != nil {
939		findBar.FindNext()
940	}
941}
942
943// FindPrevious triggers the previous match in the find bar if available.
944func (wv *WorkspaceView) FindPrevious() {
945	wv.mu.RLock()
946	findBar := wv.findBar
947	wv.mu.RUnlock()
948
949	if findBar != nil {
950		findBar.FindPrevious()
951	}
952}
953
954// IsFindBarVisible returns whether the find bar is currently visible.
955func (wv *WorkspaceView) IsFindBarVisible() bool {
956	wv.mu.RLock()
957	defer wv.mu.RUnlock()
958	return wv.findBar != nil && wv.findBar.IsVisible()
959}
960
961// hideFindBarInternal destroys the find bar without locking.
962func (wv *WorkspaceView) hideFindBarInternal() {
963	if wv.findBar == nil {
964		return
965	}
966
967	wv.findBar.Hide()
968
969	if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
970		if parent := wv.findBarWidget.GetParent(); parent == pv.Overlay() {
971			pv.RemoveOverlayWidget(wv.findBarWidget)
972		} else if parent != nil {
973			wv.findBarWidget.Unparent()
974		}
975	}
976
977	wv.findBar = nil
978	wv.findBarWidget = nil
979	wv.findBarPaneID = ""
980
981	wv.logger.Debug().Msg("find bar hidden and destroyed")
982}
983
984// GetOmnibox returns the current omnibox if visible.
985func (wv *WorkspaceView) GetOmnibox() *Omnibox {
986	wv.mu.RLock()
987	defer wv.mu.RUnlock()
988	return wv.omnibox
989}
990
991// IsOmniboxVisible returns whether the omnibox is currently visible.
992func (wv *WorkspaceView) IsOmniboxVisible() bool {
993	wv.mu.RLock()
994	defer wv.mu.RUnlock()
995	return wv.omnibox != nil && wv.omnibox.IsVisible()
996}
997
998// GetActivePaneView returns the PaneView for the current active pane.
999func (wv *WorkspaceView) GetActivePaneView() *PaneView {
1000	wv.mu.RLock()
1001	defer wv.mu.RUnlock()
1002	return wv.paneViews[wv.getActivePaneIDInternal()]
1003}
1004
1005// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
1006const KeyboardFocusSuppressDuration = 300 * time.Millisecond
1007
1008// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
1009// This prevents the race condition where a pending hover timer overrides
1010// an explicit keyboard focus change (Issue #89).
1011func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
1012	wv.mu.Lock()
1013	wv.hoverSuppressedUntil = time.Now().Add(duration)
1014	wv.mu.Unlock()
1015}
1016
1017// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
1018func (wv *WorkspaceView) IsHoverSuppressed() bool {
1019	wv.mu.RLock()
1020	defer wv.mu.RUnlock()
1021	return time.Now().Before(wv.hoverSuppressedUntil)
1022}
1023
1024// SetHoverFocusLocked enables or disables hover-to-focus behavior.
1025func (wv *WorkspaceView) SetHoverFocusLocked(locked bool) {
1026	wv.hoverFocusLocked.Store(locked)
1027}
1028
1029// IsHoverFocusLocked returns whether hover-to-focus is currently locked.
1030func (wv *WorkspaceView) IsHoverFocusLocked() bool {
1031	return wv.hoverFocusLocked.Load()
1032}
1033
1034// CancelAllPendingHovers cancels any pending hover timers on all pane views.
1035// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
1036func (wv *WorkspaceView) CancelAllPendingHovers() {
1037	wv.mu.RLock()
1038	defer wv.mu.RUnlock()
1039
1040	for _, pv := range wv.paneViews {
1041		if pv != nil {
1042			pv.CancelPendingHover()
1043		}
1044	}
1045}

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