v0.24.0 a261e52

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 pane exists.
301const singlePaneClass = "single-pane"
302
303// updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
304// Must be called with the lock held.
305func (wv *WorkspaceView) updateSinglePaneModeInternal() {
306	if wv.container == nil {
307		return
308	}
309
310	if len(wv.paneViews) <= 1 {
311		wv.container.AddCssClass(singlePaneClass)
312	} else {
313		wv.container.RemoveCssClass(singlePaneClass)
314	}
315}
316
317// SetOnPaneFocused sets the callback for when a pane receives focus.
318func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
319	wv.mu.Lock()
320	defer wv.mu.Unlock()
321
322	wv.onPaneFocused = fn
323}
324
325func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
326	wv.mu.Lock()
327	defer wv.mu.Unlock()
328
329	wv.onSplitRatioDragged = fn
330}
331
332// Rebuild rebuilds the widget tree from the current workspace.
333// Use this after structural changes like splits or closes.
334func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
335	wv.mu.RLock()
336	ws := wv.workspace
337	wv.mu.RUnlock()
338
339	if ws == nil {
340		return ErrNilWorkspace
341	}
342
343	return wv.SetWorkspace(ctx, ws)
344}
345
346// FocusPane attempts to give focus to a specific pane.
347// Returns true if focus was successfully grabbed.
348func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
349	wv.mu.RLock()
350	pv, ok := wv.paneViews[paneID]
351	wv.mu.RUnlock()
352
353	if !ok || pv == nil {
354		return false
355	}
356
357	return pv.GrabFocus()
358}
359
360// DeactivatePane removes the active visual indicator from a pane.
361// This is used when keyboard navigation overrides mouse hover focus.
362func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
363	wv.mu.RLock()
364	pv, ok := wv.paneViews[paneID]
365	wv.mu.RUnlock()
366
367	if ok && pv != nil {
368		pv.SetActive(false)
369	}
370}
371
372// SetWebViewWidget attaches a WebView widget to a specific pane.
373func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
374	wv.mu.RLock()
375	pv, ok := wv.paneViews[paneID]
376	wv.mu.RUnlock()
377
378	if !ok {
379		return ErrPaneNotFound
380	}
381
382	pv.SetWebViewWidget(widget)
383	return nil
384}
385
386// Widget returns the overlay widget for embedding in the UI.
387// The overlay wraps the pane container and allows mode borders to be displayed.
388func (wv *WorkspaceView) Widget() layout.Widget {
389	return wv.overlay
390}
391
392// Container returns the underlying BoxWidget for direct access.
393func (wv *WorkspaceView) Container() layout.BoxWidget {
394	return wv.container
395}
396
397// TreeRenderer returns the underlying tree renderer.
398func (wv *WorkspaceView) TreeRenderer() *layout.TreeRenderer {
399	return wv.treeRenderer
400}
401
402// Workspace returns the current workspace.
403func (wv *WorkspaceView) Workspace() *entity.Workspace {
404	wv.mu.RLock()
405	defer wv.mu.RUnlock()
406
407	return wv.workspace
408}
409
410// RegisterPaneView adds a PaneView to the tracking map without rebuilding.
411// Use this for incremental operations like stacked panes.
412func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) {
413	wv.mu.Lock()
414	defer wv.mu.Unlock()
415
416	wv.paneViews[paneID] = pv
417	wv.updateSinglePaneModeInternal()
418}
419
420// UnregisterPaneView removes a PaneView from the tracking map and cleans it up.
421// This must be called when closing a pane to properly release GTK resources
422// before destroying the WebView.
423func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) {
424	wv.mu.Lock()
425	defer wv.mu.Unlock()
426
427	// Call Cleanup on the PaneView to unparent widgets and clear callbacks
428	// This must happen before WebView.Destroy() is called
429	if pv, ok := wv.paneViews[paneID]; ok && pv != nil {
430		pv.Cleanup()
431	}
432
433	delete(wv.paneViews, paneID)
434	wv.updateSinglePaneModeInternal()
435}
436
437// GetRootWidget returns the current root widget of the workspace.
438// This is useful for incremental operations that need to modify the tree.
439func (wv *WorkspaceView) GetRootWidget() layout.Widget {
440	wv.mu.RLock()
441	defer wv.mu.RUnlock()
442
443	return wv.rootWidget
444}
445
446// SetRootWidgetDirect replaces the root widget without rebuilding the entire tree.
447// Use this for incremental operations when converting to/from stacked panes.
448func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) {
449	wv.mu.Lock()
450	defer wv.mu.Unlock()
451
452	// Remove old root if present
453	if wv.rootWidget != nil {
454		wv.container.Remove(wv.rootWidget)
455	}
456
457	// Add new root
458	if widget != nil {
459		widget.SetVisible(true)
460		wv.container.Append(widget)
461	}
462
463	wv.rootWidget = widget
464}
465
466// ClearRootWidgetRef clears the stored root widget reference without removing it.
467// Use this before SetRootWidgetDirect when the old root has already been removed
468// through other means (e.g., GTK paned operations).
469func (wv *WorkspaceView) ClearRootWidgetRef() {
470	wv.mu.Lock()
471	defer wv.mu.Unlock()
472	wv.rootWidget = nil
473}
474
475// Factory returns the widget factory used by this workspace view.
476func (wv *WorkspaceView) Factory() layout.WidgetFactory {
477	return wv.factory
478}
479
480// SetModeBorderOverlay attaches a mode border overlay widget.
481// The widget will be displayed on top of the pane container when modes are active.
482func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
483	wv.mu.Lock()
484	defer wv.mu.Unlock()
485
486	// Remove old overlay if present
487	if wv.modeBorderWidget != nil && wv.overlay != nil {
488		wv.overlay.RemoveOverlay(wv.modeBorderWidget)
489	}
490
491	wv.modeBorderWidget = widget
492
493	// Add new overlay
494	if widget != nil && wv.overlay != nil {
495		wv.overlay.AddOverlay(widget)
496		// Don't clip or measure the overlay - it should fill the entire area
497		wv.overlay.SetClipOverlay(widget, false)
498		wv.overlay.SetMeasureOverlay(widget, false)
499	}
500}
501
502// GetPaneWidget returns the widget for a pane ID.
503// Implements focus.PaneGeometryProvider.
504func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
505	wv.mu.RLock()
506	defer wv.mu.RUnlock()
507
508	pv, ok := wv.paneViews[paneID]
509	if !ok || pv == nil {
510		return nil
511	}
512	return pv.Widget()
513}
514
515// GetStackContainerWidget returns the stack container widget for a stacked pane.
516// Returns nil if the pane is not in a stack.
517// Implements focus.PaneGeometryProvider.
518func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget {
519	if wv.treeRenderer == nil {
520		return nil
521	}
522
523	stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID))
524	if stackedView == nil {
525		return nil
526	}
527
528	return stackedView.Widget()
529}
530
531// ContainerWidget returns the container widget for relative positioning.
532// Implements focus.PaneGeometryProvider.
533func (wv *WorkspaceView) ContainerWidget() layout.Widget {
534	return wv.container
535}
536
537// SetOmniboxConfig stores the omnibox configuration for later use.
538func (wv *WorkspaceView) SetOmniboxConfig(cfg OmniboxConfig) {
539	wv.mu.Lock()
540	defer wv.mu.Unlock()
541	wv.omniboxCfg = cfg
542}
543
544// SetFindBarConfig stores the find bar configuration for later use.
545func (wv *WorkspaceView) SetFindBarConfig(cfg FindBarConfig) {
546	wv.mu.Lock()
547	defer wv.mu.Unlock()
548	wv.findBarCfg = cfg
549}
550
551// ShowOmnibox creates and shows the omnibox in the active pane.
552func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
553	wv.mu.Lock()
554	defer wv.mu.Unlock()
555
556	// Get the active pane view
557	activePaneID := wv.getActivePaneIDInternal()
558	pv := wv.paneViews[activePaneID]
559	if pv == nil {
560		wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show omnibox: active pane not found")
561		return
562	}
563
564	// If omnibox exists but on different pane, destroy and recreate on active pane
565	if wv.omnibox != nil {
566		if wv.omniboxPaneID != activePaneID {
567			wv.logger.Debug().
568				Str("old_pane", string(wv.omniboxPaneID)).
569				Str("new_pane", string(activePaneID)).
570				Msg("omnibox pane changed, recreating")
571			wv.hideOmniboxInternal()
572		} else {
573			wv.logger.Debug().Str("pane", string(activePaneID)).Msg("showing existing omnibox")
574			wv.omnibox.Show(ctx, query)
575			return
576		}
577	}
578
579	// Create omnibox with pane-specific toast callback
580	cfg := wv.omniboxCfg
581	cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) {
582		pv.ShowToast(toastCtx, message, level)
583	}
584	omnibox := NewOmnibox(ctx, cfg)
585	if omnibox == nil {
586		wv.logger.Error().Msg("failed to create omnibox")
587		return
588	}
589
590	// Set parent overlay for sizing
591	omnibox.SetParentOverlay(pv.Overlay())
592
593	// Wrap widget for layout system
594	omniboxWidget := omnibox.WidgetAsLayout(wv.factory)
595	if omniboxWidget == nil {
596		wv.logger.Error().Msg("failed to wrap omnibox widget")
597		return
598	}
599
600	// Add to pane overlay
601	pv.AddOverlayWidget(omniboxWidget)
602
603	// Store references
604	wv.omnibox = omnibox
605	wv.omniboxWidget = omniboxWidget
606	wv.omniboxPaneID = activePaneID
607
608	// Show the omnibox
609	omnibox.Show(ctx, query)
610
611	wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("omnibox shown")
612}
613
614// HideOmnibox hides and destroys the current omnibox.
615func (wv *WorkspaceView) HideOmnibox() {
616	wv.mu.Lock()
617	defer wv.mu.Unlock()
618
619	wv.hideOmniboxInternal()
620}
621
622// hideOmniboxInternal destroys the omnibox without locking.
623func (wv *WorkspaceView) hideOmniboxInternal() {
624	if wv.omnibox == nil {
625		return
626	}
627
628	// Hide the omnibox first (use Background context since this is internal cleanup)
629	wv.omnibox.Hide(context.Background())
630
631	// Remove from pane overlay
632	if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
633		// The omnibox widget may have been reparented during a rebuild/move.
634		// Avoid GTK criticals by only removing from the overlay that owns it.
635		if parent := wv.omniboxWidget.GetParent(); parent == pv.Overlay() {
636			pv.RemoveOverlayWidget(wv.omniboxWidget)
637		} else if parent != nil {
638			wv.omniboxWidget.Unparent()
639		}
640	}
641
642	// Clear references
643	wv.omnibox = nil
644	wv.omniboxWidget = nil
645	wv.omniboxPaneID = ""
646
647	wv.logger.Debug().Msg("omnibox hidden and destroyed")
648}
649
650// ShowFindBar creates and shows the find bar in the active pane.
651func (wv *WorkspaceView) ShowFindBar(ctx context.Context) {
652	wv.mu.Lock()
653	defer wv.mu.Unlock()
654
655	// If find bar already exists, just show it
656	if wv.findBar != nil {
657		wv.findBar.Show()
658		return
659	}
660
661	activePaneID := wv.getActivePaneIDInternal()
662	pv := wv.paneViews[activePaneID]
663	if pv == nil {
664		wv.logger.Warn().Str("paneID", string(activePaneID)).Msg("cannot show find bar: active pane not found")
665		return
666	}
667
668	cfg := wv.findBarCfg
669	cfg.OnClose = func() {
670		wv.HideFindBar()
671	}
672	findBar := NewFindBar(ctx, cfg)
673	if findBar == nil {
674		wv.logger.Error().Msg("failed to create find bar")
675		return
676	}
677
678	findBarWidget := findBar.WidgetAsLayout(wv.factory)
679	if findBarWidget == nil {
680		wv.logger.Error().Msg("failed to wrap find bar widget")
681		return
682	}
683
684	pv.AddOverlayWidget(findBarWidget)
685
686	if cfg.GetFindController != nil {
687		controller := cfg.GetFindController(activePaneID)
688		findBar.SetFindController(controller)
689	}
690
691	wv.findBar = findBar
692	wv.findBarWidget = findBarWidget
693	wv.findBarPaneID = activePaneID
694
695	findBar.Show()
696
697	wv.logger.Debug().Str("paneID", string(activePaneID)).Msg("find bar shown")
698}
699
700// HideFindBar hides and destroys the current find bar.
701func (wv *WorkspaceView) HideFindBar() {
702	wv.mu.Lock()
703	defer wv.mu.Unlock()
704
705	wv.hideFindBarInternal()
706}
707
708// FindNext triggers the next match in the find bar if available.
709func (wv *WorkspaceView) FindNext() {
710	wv.mu.RLock()
711	findBar := wv.findBar
712	wv.mu.RUnlock()
713
714	if findBar != nil {
715		findBar.FindNext()
716	}
717}
718
719// FindPrevious triggers the previous match in the find bar if available.
720func (wv *WorkspaceView) FindPrevious() {
721	wv.mu.RLock()
722	findBar := wv.findBar
723	wv.mu.RUnlock()
724
725	if findBar != nil {
726		findBar.FindPrevious()
727	}
728}
729
730// IsFindBarVisible returns whether the find bar is currently visible.
731func (wv *WorkspaceView) IsFindBarVisible() bool {
732	wv.mu.RLock()
733	defer wv.mu.RUnlock()
734	return wv.findBar != nil && wv.findBar.IsVisible()
735}
736
737// hideFindBarInternal destroys the find bar without locking.
738func (wv *WorkspaceView) hideFindBarInternal() {
739	if wv.findBar == nil {
740		return
741	}
742
743	wv.findBar.Hide()
744
745	if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
746		if parent := wv.findBarWidget.GetParent(); parent == pv.Overlay() {
747			pv.RemoveOverlayWidget(wv.findBarWidget)
748		} else if parent != nil {
749			wv.findBarWidget.Unparent()
750		}
751	}
752
753	wv.findBar = nil
754	wv.findBarWidget = nil
755	wv.findBarPaneID = ""
756
757	wv.logger.Debug().Msg("find bar hidden and destroyed")
758}
759
760// GetOmnibox returns the current omnibox if visible.
761func (wv *WorkspaceView) GetOmnibox() *Omnibox {
762	wv.mu.RLock()
763	defer wv.mu.RUnlock()
764	return wv.omnibox
765}
766
767// IsOmniboxVisible returns whether the omnibox is currently visible.
768func (wv *WorkspaceView) IsOmniboxVisible() bool {
769	wv.mu.RLock()
770	defer wv.mu.RUnlock()
771	return wv.omnibox != nil && wv.omnibox.IsVisible()
772}
773
774// GetActivePaneView returns the PaneView for the current active pane.
775func (wv *WorkspaceView) GetActivePaneView() *PaneView {
776	wv.mu.RLock()
777	defer wv.mu.RUnlock()
778	return wv.paneViews[wv.getActivePaneIDInternal()]
779}
780
781// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
782const KeyboardFocusSuppressDuration = 300 * time.Millisecond
783
784// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
785// This prevents the race condition where a pending hover timer overrides
786// an explicit keyboard focus change (Issue #89).
787func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
788	wv.mu.Lock()
789	wv.hoverSuppressedUntil = time.Now().Add(duration)
790	wv.mu.Unlock()
791}
792
793// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
794func (wv *WorkspaceView) IsHoverSuppressed() bool {
795	wv.mu.RLock()
796	defer wv.mu.RUnlock()
797	return time.Now().Before(wv.hoverSuppressedUntil)
798}
799
800// CancelAllPendingHovers cancels any pending hover timers on all pane views.
801// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
802func (wv *WorkspaceView) CancelAllPendingHovers() {
803	wv.mu.RLock()
804	defer wv.mu.RUnlock()
805
806	for _, pv := range wv.paneViews {
807		if pv != nil {
808			pv.CancelPendingHover()
809		}
810	}
811}

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