v0.26.2 3cd9d2e

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

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