v0.21.0 73bf644

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

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