v0.27.2 5960dd8

workspace_view.go RawHistoryFolder

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

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