main 5041353

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

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