v0.29.0 21b3150

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

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