v0.25.0 2e6c485

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

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