v0.23.2 bfd5eb4

workspace.go RawHistoryFolder

1package coordinator
2
3import (
4	"context"
5	"errors"
6	"fmt"
7
8	"github.com/bnema/dumber/internal/application/usecase"
9	"github.com/bnema/dumber/internal/domain/entity"
10	domainurl "github.com/bnema/dumber/internal/domain/url"
11	"github.com/bnema/dumber/internal/infrastructure/config"
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/internal/ui/component"
14	"github.com/bnema/dumber/internal/ui/focus"
15	"github.com/bnema/dumber/internal/ui/layout"
16	"github.com/rs/zerolog"
17)
18
19const (
20	defaultPaneTitle = "Untitled"
21	nilString        = "nil"
22)
23
24// WorkspaceCoordinator manages pane operations within a workspace.
25type WorkspaceCoordinator struct {
26	panesUC        *usecase.ManagePanesUseCase
27	focusMgr       *focus.Manager
28	stackedPaneMgr *component.StackedPaneManager
29	widgetFactory  layout.WidgetFactory
30	contentCoord   *ContentCoordinator
31
32	// Callbacks to avoid circular dependencies
33	getActiveWS      func() (*entity.Workspace, *component.WorkspaceView)
34	generateID       func() string
35	onCloseLastPane  func(ctx context.Context) error
36	onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
37	onStateChanged   func()                                                  // For session snapshots
38}
39
40// WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
41type WorkspaceCoordinatorConfig struct {
42	PanesUC        *usecase.ManagePanesUseCase
43	FocusMgr       *focus.Manager
44	StackedPaneMgr *component.StackedPaneManager
45	WidgetFactory  layout.WidgetFactory
46	ContentCoord   *ContentCoordinator
47	GetActiveWS    func() (*entity.Workspace, *component.WorkspaceView)
48	GenerateID     func() string
49}
50
51type splitContext struct {
52	ws             *entity.Workspace
53	wsView         *component.WorkspaceView
54	activePane     *entity.PaneNode
55	existingWidget layout.Widget
56	isStackSplit   bool
57}
58
59type stackPaneContext struct {
60	ws            *entity.Workspace
61	wsView        *component.WorkspaceView
62	activeNode    *entity.PaneNode
63	activePaneID  entity.PaneID
64	originalTitle string
65}
66
67// NewWorkspaceCoordinator creates a new WorkspaceCoordinator.
68func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {
69	log := logging.FromContext(ctx)
70	log.Debug().Msg("creating workspace coordinator")
71
72	return &WorkspaceCoordinator{
73		panesUC:        cfg.PanesUC,
74		focusMgr:       cfg.FocusMgr,
75		stackedPaneMgr: cfg.StackedPaneMgr,
76		widgetFactory:  cfg.WidgetFactory,
77		contentCoord:   cfg.ContentCoord,
78		getActiveWS:    cfg.GetActiveWS,
79		generateID:     cfg.GenerateID,
80	}
81}
82
83// SetOnCloseLastPane sets the callback for when the last pane is closed.
84func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) error) {
85	c.onCloseLastPane = fn
86}
87
88// SetOnStateChanged sets the callback for when workspace state changes (for session snapshots).
89func (c *WorkspaceCoordinator) SetOnStateChanged(fn func()) {
90	c.onStateChanged = fn
91}
92
93// notifyStateChanged triggers the state changed callback if set.
94func (c *WorkspaceCoordinator) notifyStateChanged() {
95	if c.onStateChanged != nil {
96		c.onStateChanged()
97	}
98}
99
100// setupPaneViewHover configures hover-to-focus behavior on a PaneView.
101func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {
102	pv.SetOnHover(func(paneID entity.PaneID) {
103		// Skip if this pane is already active
104		if wsView.GetActivePaneID() == paneID {
105			return
106		}
107
108		// Activate the hovered pane and grab focus
109		if err := wsView.SetActivePaneID(paneID); err == nil {
110			wsView.FocusPane(paneID)
111		}
112	})
113
114	pv.AttachHoverHandler(ctx)
115}
116
117// Split splits the active pane in the given direction.
118func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
119	log := logging.FromContext(ctx)
120
121	splitCtx, ok := c.prepareSplit(ctx, direction)
122	if !ok {
123		return nil
124	}
125
126	output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
127		Workspace:  splitCtx.ws,
128		TargetPane: splitCtx.activePane,
129		Direction:  direction,
130		InitialURL: domainurl.Normalize(config.Get().Workspace.NewPaneURL),
131	})
132	if err != nil {
133		log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
134		return err
135	}
136
137	// Remember old active pane before changing
138	oldActivePaneID := splitCtx.activePane.Pane.ID
139
140	// Set the new pane as active
141	splitCtx.ws.ActivePaneID = output.NewPaneNode.Pane.ID
142
143	// Update the workspace view
144	if splitCtx.wsView != nil {
145		c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
146	}
147
148	if splitCtx.wsView != nil && config.Get().Omnibox.AutoOpenOnNewPane {
149		splitCtx.wsView.ShowOmnibox(ctx, "")
150	}
151
152	// Notify state change for session snapshots
153	c.notifyStateChanged()
154
155	log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
156
157	return nil
158}
159
160func (c *WorkspaceCoordinator) prepareSplit(
161	ctx context.Context,
162	direction usecase.SplitDirection,
163) (*splitContext, bool) {
164	log := logging.FromContext(ctx)
165	if c.panesUC == nil {
166		log.Warn().Msg("panes use case not available")
167		return nil, false
168	}
169
170	ws, wsView := c.getActiveWS()
171	if ws == nil {
172		log.Warn().Msg("no active workspace")
173		return nil, false
174	}
175
176	activePane := ws.ActivePane()
177	if activePane == nil {
178		log.Warn().Msg("no active pane to split")
179		return nil, false
180	}
181
182	isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
183		activePane.Parent != nil && activePane.Parent.IsStacked
184
185	var existingWidget layout.Widget
186	if wsView != nil {
187		existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit)
188	}
189
190	return &splitContext{
191		ws:             ws,
192		wsView:         wsView,
193		activePane:     activePane,
194		existingWidget: existingWidget,
195		isStackSplit:   isStackSplit,
196	}, true
197}
198
199func (c *WorkspaceCoordinator) resolveSplitWidget(
200	wsView *component.WorkspaceView,
201	activePane *entity.PaneNode,
202	isStackSplit bool,
203) layout.Widget {
204	if wsView == nil {
205		return nil
206	}
207
208	if !isStackSplit {
209		return wsView.GetRootWidget()
210	}
211
212	tr := wsView.TreeRenderer()
213	if tr == nil {
214		return nil
215	}
216
217	stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
218	if stackedView == nil {
219		return nil
220	}
221
222	return stackedView.Widget()
223}
224
225func (c *WorkspaceCoordinator) applySplitToView(
226	ctx context.Context,
227	wsView *component.WorkspaceView,
228	ws *entity.Workspace,
229	output *usecase.SplitPaneOutput,
230	direction usecase.SplitDirection,
231	existingWidget layout.Widget,
232	isStackSplit bool,
233	oldActivePaneID entity.PaneID,
234) {
235	log := logging.FromContext(ctx)
236	needsAttach := false
237
238	if existingWidget != nil {
239		var splitErr error
240		if isStackSplit {
241			splitErr = c.doIncrementalStackSplit(ctx, wsView, output, direction, existingWidget, oldActivePaneID)
242		} else {
243			splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID)
244		}
245
246		if splitErr != nil {
247			log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
248			if err := wsView.Rebuild(ctx); err != nil {
249				log.Error().Err(err).Msg("failed to rebuild workspace view")
250			}
251			needsAttach = true
252		}
253	} else {
254		if err := wsView.Rebuild(ctx); err != nil {
255			log.Error().Err(err).Msg("failed to rebuild workspace view")
256		}
257		needsAttach = true
258	}
259
260	if needsAttach {
261		c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
262	}
263	if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
264		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
265	}
266	wsView.FocusPane(ws.ActivePaneID)
267}
268
269// doIncrementalStackSplit performs an incremental split around an existing stacked pane.
270func (c *WorkspaceCoordinator) doIncrementalStackSplit(
271	ctx context.Context,
272	wsView *component.WorkspaceView,
273	output *usecase.SplitPaneOutput,
274	direction usecase.SplitDirection,
275	existingStackWidget layout.Widget,
276	oldActivePaneID entity.PaneID,
277) error {
278	log := logging.FromContext(ctx)
279	factory := wsView.Factory()
280
281	log.Debug().
282		Str("direction", string(direction)).
283		Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
284		Str("old_active_pane_id", string(oldActivePaneID)).
285		Msg("performing incremental stack split")
286
287	// 1. Deactivate the old active pane (in the stack)
288	if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
289		oldPaneView.SetActive(false)
290	}
291
292	// 2. Determine if this is a root split or non-root split
293	// The grandparent of the new split node tells us where the stack currently sits
294	grandparentNode := output.ParentNode.Parent
295	isRootSplit := grandparentNode == nil
296
297	log.Debug().
298		Bool("is_root_split", isRootSplit).
299		Str("grandparent_id", func() string {
300			if grandparentNode != nil {
301				return grandparentNode.ID
302			}
303			return nilString
304		}()).
305		Msg("stack split: determined split type")
306
307	// 3. Create new PaneView for the new pane (without WebView - will attach later)
308	newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
309	setupPaneViewHover(ctx, newPaneView, wsView)
310
311	// 4. Wrap the new PaneView in a StackedView
312	newStackedView := layout.NewStackedView(factory)
313	newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
314
315	tr := wsView.TreeRenderer()
316
317	// 5. Handle root vs non-root split differently
318	if isRootSplit {
319		// Root split: stack is direct child of container
320		wsView.ClearRootWidgetRef()
321		wsView.Container().Remove(existingStackWidget)
322
323		// Create a SplitView with the appropriate orientation and child placement
324		var splitView *layout.SplitView
325		if direction == usecase.SplitLeft {
326			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
327				newStackedView.Widget(), existingStackWidget, output.SplitRatio)
328		} else {
329			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
330				existingStackWidget, newStackedView.Widget(), output.SplitRatio)
331		}
332		c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
333
334		wsView.SetRootWidgetDirect(splitView.Widget())
335
336		if tr != nil {
337			tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
338		}
339	} else {
340		// Non-root split: stack is inside another split, need to replace in grandparent
341		if tr == nil {
342			return fmt.Errorf("tree renderer not available for non-root stack split")
343		}
344
345		grandparentWidget := tr.Lookup(grandparentNode.ID)
346		if grandparentWidget == nil {
347			log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found")
348			return fmt.Errorf("grandparent widget not found")
349		}
350
351		panedWidget, ok := grandparentWidget.(layout.PanedWidget)
352		if !ok {
353			log.Warn().Msg("grandparent widget is not a PanedWidget")
354			return fmt.Errorf("grandparent is not a PanedWidget")
355		}
356
357		// Determine if the stack's parent (the new split node) is start or end child
358		isStartChild := grandparentNode.Left() == output.ParentNode
359
360		log.Debug().
361			Bool("is_start_child", isStartChild).
362			Msg("stack split: determined position in grandparent")
363
364		// Clear the position first to unparent the stack widget
365		if isStartChild {
366			panedWidget.SetStartChild(nil)
367		} else {
368			panedWidget.SetEndChild(nil)
369		}
370
371		// Create the new split view
372		var splitView *layout.SplitView
373		if direction == usecase.SplitLeft {
374			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
375				newStackedView.Widget(), existingStackWidget, output.SplitRatio)
376		} else {
377			splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
378				existingStackWidget, newStackedView.Widget(), output.SplitRatio)
379		}
380		c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
381
382		// Put the new split view in the grandparent's slot
383		if isStartChild {
384			panedWidget.SetStartChild(splitView.Widget())
385		} else {
386			panedWidget.SetEndChild(splitView.Widget())
387		}
388
389		tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
390
391		log.Debug().
392			Bool("was_start_child", isStartChild).
393			Msg("stack split: replaced stack in grandparent with new split view")
394	}
395
396	// 6. Register the new pane in tracking maps and activate it
397	wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
398	newPaneView.SetActive(true)
399
400	if tr != nil {
401		tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
402	}
403
404	// 7. Attach WebView only for the new pane
405	wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
406	if err != nil {
407		log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
408		return err
409	}
410
411	// Load the default URL for new pane
412	newPaneEntity := output.NewPaneNode.Pane
413	if newPaneEntity.URI != "" {
414		if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
415			log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
416		}
417	}
418
419	widget := c.contentCoord.WrapWidget(ctx, wv)
420	if widget != nil {
421		newPaneView.SetWebViewWidget(widget)
422	}
423
424	log.Debug().Msg("incremental stack split completed successfully")
425	return nil
426}
427
428// doIncrementalSplit performs an incremental split by reusing the existing widget tree.
429// This avoids rebuilding the entire tree which would destroy existing WebView widgets.
430func (c *WorkspaceCoordinator) doIncrementalSplit(
431	ctx context.Context,
432	wsView *component.WorkspaceView,
433	ws *entity.Workspace,
434	output *usecase.SplitPaneOutput,
435	direction usecase.SplitDirection,
436	existingRootWidget layout.Widget,
437	oldActivePaneID entity.PaneID,
438) error {
439	log := logging.FromContext(ctx)
440	factory := wsView.Factory()
441
442	log.Debug().
443		Str("direction", string(direction)).
444		Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
445		Str("old_active_pane_id", string(oldActivePaneID)).
446		Int("pane_count", ws.PaneCount()).
447		Msg("performing incremental split")
448
449	// 1. Deactivate the old active pane
450	if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
451		oldPaneView.SetActive(false)
452	}
453
454	// 2. Get the active pane's StackedView widget (what we're actually splitting)
455	tr := wsView.TreeRenderer()
456	if tr == nil {
457		return nil
458	}
459
460	activeStackedView := tr.GetStackedViewForPane(string(oldActivePaneID))
461	if activeStackedView == nil {
462		log.Warn().Msg("no stacked view found for active pane")
463		return nil
464	}
465	activePaneWidget := activeStackedView.Widget()
466
467	// 3. Determine if this is a root split by checking domain tree
468	// output.ParentNode is the NEW split node (h0)
469	// output.ParentNode's parent in domain tree is the ORIGINAL parent (f0) or nil if root
470	grandparentNode := output.ParentNode.Parent
471	isRootSplit := grandparentNode == nil
472
473	log.Debug().
474		Bool("is_root_split", isRootSplit).
475		Str("grandparent_id", func() string {
476			if grandparentNode != nil {
477				return grandparentNode.ID
478			}
479			return nilString
480		}()).
481		Msg("determined split type")
482
483	// 4. Create new PaneView for the new pane
484	newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
485	setupPaneViewHover(ctx, newPaneView, wsView)
486
487	// 5. Wrap the new PaneView in a StackedView
488	newStackedView := layout.NewStackedView(factory)
489	newStackedView.AddPane(ctx, string(output.NewPaneNode.Pane.ID), output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
490
491	orientation, existingFirst, ok := splitOrientation(direction)
492	if !ok {
493		return nil
494	}
495
496	// 7. Handle root vs non-root split differently
497	if isRootSplit {
498		if err := c.replaceRootSplit(
499			ctx,
500			wsView,
501			tr,
502			factory,
503			existingRootWidget,
504			newStackedView,
505			output,
506			orientation,
507			existingFirst,
508		); err != nil {
509			return err
510		}
511	} else {
512		if err := c.replaceNonRootSplit(
513			ctx,
514			tr,
515			factory,
516			grandparentNode,
517			output,
518			activePaneWidget,
519			newStackedView,
520			orientation,
521			existingFirst,
522		); err != nil {
523			return err
524		}
525	}
526
527	// 8. Register the new pane in tracking maps and activate it
528	wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
529	newPaneView.SetActive(true)
530
531	// Register the new pane's StackedView mapping
532	tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
533
534	// 9. Attach WebView only for the new pane
535	wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
536	if err != nil {
537		log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
538		return err
539	}
540
541	// Load the default URL for new pane
542	newPaneEntity := output.NewPaneNode.Pane
543	if newPaneEntity.URI != "" {
544		if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
545			log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
546		}
547	}
548
549	widget := c.contentCoord.WrapWidget(ctx, wv)
550	if widget != nil {
551		newPaneView.SetWebViewWidget(widget)
552	}
553
554	log.Debug().Msg("incremental split completed successfully")
555	return nil
556}
557
558func splitOrientation(direction usecase.SplitDirection) (layout.Orientation, bool, bool) {
559	switch direction {
560	case usecase.SplitRight:
561		return layout.OrientationHorizontal, true, true
562	case usecase.SplitLeft:
563		return layout.OrientationHorizontal, false, true
564	case usecase.SplitDown:
565		return layout.OrientationVertical, true, true
566	case usecase.SplitUp:
567		return layout.OrientationVertical, false, true
568	default:
569		return layout.OrientationHorizontal, false, false
570	}
571}
572
573func (c *WorkspaceCoordinator) wireSplitRatioPersistence(ctx context.Context, splitView *layout.SplitView, splitNodeID string) {
574	if c == nil || splitView == nil || splitNodeID == "" {
575		return
576	}
577
578	splitView.SetOnRatioChanged(func(ratio float64) {
579		if err := c.SetSplitRatio(ctx, splitNodeID, ratio); err != nil {
580			logging.FromContext(ctx).Warn().Err(err).Str("split_node_id", splitNodeID).Msg("failed to persist split ratio")
581		}
582	})
583}
584
585func (c *WorkspaceCoordinator) replaceRootSplit(
586	ctx context.Context,
587	wsView *component.WorkspaceView,
588	tr *layout.TreeRenderer,
589	factory layout.WidgetFactory,
590	existingRootWidget layout.Widget,
591	newStackedView *layout.StackedView,
592	output *usecase.SplitPaneOutput,
593	orientation layout.Orientation,
594	existingFirst bool,
595) error {
596	wsView.ClearRootWidgetRef()
597	wsView.Container().Remove(existingRootWidget)
598
599	var splitView *layout.SplitView
600	if existingFirst {
601		splitView = layout.NewSplitView(ctx, factory, orientation,
602			existingRootWidget, newStackedView.Widget(), output.SplitRatio)
603	} else {
604		splitView = layout.NewSplitView(ctx, factory, orientation,
605			newStackedView.Widget(), existingRootWidget, output.SplitRatio)
606	}
607	c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
608
609	wsView.SetRootWidgetDirect(splitView.Widget())
610	tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
611
612	logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")
613	return nil
614}
615
616func (c *WorkspaceCoordinator) replaceNonRootSplit(
617	ctx context.Context,
618	tr *layout.TreeRenderer,
619	factory layout.WidgetFactory,
620	grandparentNode *entity.PaneNode,
621	output *usecase.SplitPaneOutput,
622	activePaneWidget layout.Widget,
623	newStackedView *layout.StackedView,
624	orientation layout.Orientation,
625	existingFirst bool,
626) error {
627	log := logging.FromContext(ctx)
628	grandparentWidget := tr.Lookup(grandparentNode.ID)
629	if grandparentWidget == nil {
630		log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found in TreeRenderer")
631		return fmt.Errorf("grandparent widget not found")
632	}
633
634	panedWidget, ok := grandparentWidget.(layout.PanedWidget)
635	if !ok {
636		log.Warn().Msg("grandparent widget is not a PanedWidget, falling back")
637		return fmt.Errorf("grandparent is not a PanedWidget")
638	}
639
640	isStartChild := grandparentNode.Left() == output.ParentNode
641
642	log.Debug().
643		Bool("is_start_child", isStartChild).
644		Str("grandparent_left_id", func() string {
645			if grandparentNode.Left() != nil {
646				return grandparentNode.Left().ID
647			}
648			return nilString
649		}()).
650		Str("new_parent_id", output.ParentNode.ID).
651		Msg("determined position in grandparent")
652
653	if isStartChild {
654		panedWidget.SetStartChild(nil)
655	} else {
656		panedWidget.SetEndChild(nil)
657	}
658
659	var splitView *layout.SplitView
660	if existingFirst {
661		splitView = layout.NewSplitView(ctx, factory, orientation,
662			activePaneWidget, newStackedView.Widget(), output.SplitRatio)
663	} else {
664		splitView = layout.NewSplitView(ctx, factory, orientation,
665			newStackedView.Widget(), activePaneWidget, output.SplitRatio)
666	}
667	c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
668
669	if isStartChild {
670		panedWidget.SetStartChild(splitView.Widget())
671	} else {
672		panedWidget.SetEndChild(splitView.Widget())
673	}
674
675	tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
676
677	log.Debug().
678		Bool("was_start_child", isStartChild).
679		Msg("non-root split: replaced pane in grandparent with new split view")
680	return nil
681}
682
683// ClosePane closes the active pane.
684func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
685	log := logging.FromContext(ctx)
686
687	if c.panesUC == nil {
688		log.Warn().Msg("panes use case not available")
689		return nil
690	}
691
692	ws, wsView := c.getActiveWS()
693	if ws == nil {
694		log.Warn().Msg("no active workspace")
695		return nil
696	}
697
698	activePane := ws.ActivePane()
699	if activePane == nil {
700		log.Warn().Msg("no active pane to close")
701		return nil
702	}
703	closingPaneID := activePane.Pane.ID
704
705	log.Debug().Str("pane_id", string(closingPaneID)).Msg("closing pane")
706
707	// Don't close the last pane - close the tab instead
708	if ws.PaneCount() <= 1 {
709		if c.onCloseLastPane != nil {
710			return c.onCloseLastPane(ctx)
711		}
712		return nil
713	}
714
715	// BEFORE domain changes: capture widget info for incremental close
716	var parentNode *entity.PaneNode
717	var siblingNode *entity.PaneNode
718	var grandparentNode *entity.PaneNode
719	var parentWidget layout.Widget
720	var siblingIsStartChild bool  // true if sibling is the start (left/top) child in parent
721	var parentIsStartInGrand bool // true if parent is the start (left/top) child in grandparent
722
723	if wsView != nil {
724		tr := wsView.TreeRenderer()
725		if tr != nil && activePane.Parent != nil {
726			parentNode = activePane.Parent
727
728			// Find sibling (the other child of parent)
729			// If closing pane is start child, sibling is end child (and vice versa)
730			if parentNode.Left() == activePane {
731				siblingNode = parentNode.Right()
732				siblingIsStartChild = false // sibling is end/right child
733			} else {
734				siblingNode = parentNode.Left()
735				siblingIsStartChild = true // sibling is start/left child
736			}
737
738			grandparentNode = parentNode.Parent
739			parentWidget = tr.Lookup(parentNode.ID)
740
741			// Determine parent's position in grandparent (if grandparent exists)
742			if grandparentNode != nil {
743				parentIsStartInGrand = grandparentNode.Left() == parentNode
744			}
745
746			log.Debug().
747				Str("parent_id", parentNode.ID).
748				Str("sibling_id", func() string {
749					if siblingNode != nil {
750						return siblingNode.ID
751					}
752					return nilString
753				}()).
754				Bool("sibling_is_start", siblingIsStartChild).
755				Bool("parent_is_start_in_grand", parentIsStartInGrand).
756				Bool("has_grandparent", grandparentNode != nil).
757				Bool("has_parent_widget", parentWidget != nil).
758				Msg("captured close context")
759		}
760	}
761
762	// Now do domain changes
763	_, err := c.panesUC.Close(ctx, ws, activePane)
764	if err != nil {
765		log.Error().Err(err).Msg("failed to close pane")
766		return err
767	}
768
769	c.finalizePaneClose(
770		ctx,
771		wsView,
772		ws,
773		closingPaneID,
774		parentNode,
775		siblingNode,
776		grandparentNode,
777		parentWidget,
778		siblingIsStartChild,
779		parentIsStartInGrand,
780	)
781
782	// Notify state change for session snapshots
783	c.notifyStateChanged()
784
785	log.Info().Msg("pane closed")
786	return nil
787}
788
789// ClosePaneByID closes a specific pane by ID.
790// This is used for closing popup panes when window.close() is called.
791func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.PaneID) error {
792	log := logging.FromContext(ctx)
793
794	if c.panesUC == nil {
795		log.Warn().Msg("panes use case not available")
796		return nil
797	}
798
799	ws, wsView := c.getActiveWS()
800	if ws == nil {
801		log.Warn().Msg("no active workspace")
802		return nil
803	}
804
805	// Find the pane node
806	paneNode := ws.FindPane(paneID)
807	if paneNode == nil {
808		log.Warn().Str("pane_id", string(paneID)).Msg("pane not found")
809		return nil
810	}
811
812	log.Debug().Str("pane_id", string(paneID)).Msg("closing pane by ID")
813
814	// Don't close the last pane - close the tab instead
815	if ws.PaneCount() <= 1 {
816		if c.onCloseLastPane != nil {
817			return c.onCloseLastPane(ctx)
818		}
819		return nil
820	}
821
822	// BEFORE domain changes: capture widget info for incremental close
823	var parentNode *entity.PaneNode
824	var siblingNode *entity.PaneNode
825	var grandparentNode *entity.PaneNode
826	var parentWidget layout.Widget
827	var siblingIsStartChild bool
828	var parentIsStartInGrand bool
829
830	if wsView != nil {
831		tr := wsView.TreeRenderer()
832		if tr != nil && paneNode.Parent != nil {
833			parentNode = paneNode.Parent
834
835			if parentNode.Left() == paneNode {
836				siblingNode = parentNode.Right()
837				siblingIsStartChild = false
838			} else {
839				siblingNode = parentNode.Left()
840				siblingIsStartChild = true
841			}
842
843			grandparentNode = parentNode.Parent
844			parentWidget = tr.Lookup(parentNode.ID)
845
846			if grandparentNode != nil {
847				parentIsStartInGrand = grandparentNode.Left() == parentNode
848			}
849		}
850	}
851
852	// Now do domain changes
853	_, err := c.panesUC.Close(ctx, ws, paneNode)
854	if err != nil {
855		log.Error().Err(err).Msg("failed to close pane")
856		return err
857	}
858
859	c.finalizePaneClose(
860		ctx,
861		wsView,
862		ws,
863		paneID,
864		parentNode,
865		siblingNode,
866		grandparentNode,
867		parentWidget,
868		siblingIsStartChild,
869		parentIsStartInGrand,
870	)
871
872	// Notify state change for session snapshots
873	c.notifyStateChanged()
874
875	log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")
876	return nil
877}
878
879// doIncrementalClose performs incremental close by promoting sibling without rebuild.
880func (c *WorkspaceCoordinator) doIncrementalClose(
881	ctx context.Context,
882	wsView *component.WorkspaceView,
883	closingPaneID entity.PaneID,
884	siblingNode *entity.PaneNode,
885	parentNode *entity.PaneNode,
886	grandparentNode *entity.PaneNode,
887	parentWidget layout.Widget,
888	siblingIsStartChild bool, // true if sibling is start/left child in parent
889	parentIsStartInGrand bool, // true if parent is start/left child in grandparent
890) error {
891	log := logging.FromContext(ctx)
892	tr := wsView.TreeRenderer()
893	if tr == nil {
894		return fmt.Errorf("tree renderer not available")
895	}
896
897	log.Debug().
898		Str("closing_pane", string(closingPaneID)).
899		Str("sibling_id", siblingNode.ID).
900		Bool("sibling_is_leaf", siblingNode.IsLeaf()).
901		Bool("sibling_is_start", siblingIsStartChild).
902		Bool("parent_is_start_in_grand", parentIsStartInGrand).
903		Msg("performing incremental close")
904
905	// Get sibling's widget
906	var siblingWidget layout.Widget
907	if siblingNode.IsLeaf() && siblingNode.Pane != nil {
908		stackedView := tr.GetStackedViewForPane(string(siblingNode.Pane.ID))
909		if stackedView != nil {
910			siblingWidget = stackedView.Widget()
911		}
912	} else {
913		// Sibling is a split node
914		siblingWidget = tr.Lookup(siblingNode.ID)
915	}
916
917	if siblingWidget == nil {
918		return fmt.Errorf("sibling widget not found")
919	}
920
921	// Cast parent widget to PanedWidget
922	panedWidget, ok := parentWidget.(layout.PanedWidget)
923	if !ok {
924		return fmt.Errorf("parent widget is not a PanedWidget")
925	}
926
927	// Remove BOTH children from parent paned before any reparenting
928	// This is critical - GTK requires widgets to be unparented before reparenting
929	// Order: unparent closing pane first, then sibling
930	if siblingIsStartChild {
931		// Sibling is start, closing pane is end
932		panedWidget.SetEndChild(nil)   // Unparent closing pane
933		panedWidget.SetStartChild(nil) // Unparent sibling
934	} else {
935		// Sibling is end, closing pane is start
936		panedWidget.SetStartChild(nil) // Unparent closing pane
937		panedWidget.SetEndChild(nil)   // Unparent sibling
938	}
939
940	if grandparentNode == nil {
941		// Sibling becomes new root
942		// Remove the old root (parent paned) from container manually since
943		// ClearRootWidgetRef only clears the reference, not the actual widget
944		wsView.Container().Remove(parentWidget)
945		wsView.ClearRootWidgetRef()
946
947		// Grab focus on sibling before reparenting to avoid GTK focus warnings
948		siblingWidget.GrabFocus()
949
950		// Ensure sibling expands to fill container (was constrained by paned before)
951		siblingWidget.SetHexpand(true)
952		siblingWidget.SetVexpand(true)
953
954		wsView.SetRootWidgetDirect(siblingWidget)
955
956		log.Debug().Msg("sibling promoted to root")
957	} else {
958		// Replace parent with sibling in grandparent
959		grandparentWidget := tr.Lookup(grandparentNode.ID)
960		if grandparentWidget == nil {
961			return fmt.Errorf("grandparent widget not found")
962		}
963
964		grandPaned, ok := grandparentWidget.(layout.PanedWidget)
965		if !ok {
966			return fmt.Errorf("grandparent widget is not a PanedWidget")
967		}
968
969		// Grab focus on sibling before reparenting to avoid GTK focus warnings
970		siblingWidget.GrabFocus()
971
972		// Remove parent from grandparent using known position from domain tree
973		if parentIsStartInGrand {
974			grandPaned.SetStartChild(nil)
975		} else {
976			grandPaned.SetEndChild(nil)
977		}
978
979		// Insert sibling at same position
980		if parentIsStartInGrand {
981			grandPaned.SetStartChild(siblingWidget)
982		} else {
983			grandPaned.SetEndChild(siblingWidget)
984		}
985
986		log.Debug().Bool("was_start_child", parentIsStartInGrand).Msg("sibling promoted in grandparent")
987	}
988
989	// Clean up tracking
990	tr.UnregisterWidget(parentNode.ID)
991	wsView.UnregisterPaneView(closingPaneID)
992	tr.UnregisterPane(string(closingPaneID))
993
994	// Release the closing pane's webview
995	c.contentCoord.ReleaseWebView(ctx, closingPaneID)
996
997	// Activate sibling
998	if siblingNode.IsLeaf() && siblingNode.Pane != nil {
999		if siblingPaneView := wsView.GetPaneView(siblingNode.Pane.ID); siblingPaneView != nil {
1000			siblingPaneView.SetActive(true)
1001		}
1002	}
1003
1004	log.Debug().Msg("incremental close completed successfully")
1005	return nil
1006}
1007
1008func (c *WorkspaceCoordinator) finalizePaneClose(
1009	ctx context.Context,
1010	wsView *component.WorkspaceView,
1011	ws *entity.Workspace,
1012	paneID entity.PaneID,
1013	parentNode *entity.PaneNode,
1014	siblingNode *entity.PaneNode,
1015	grandparentNode *entity.PaneNode,
1016	parentWidget layout.Widget,
1017	siblingIsStartChild bool,
1018	parentIsStartInGrand bool,
1019) {
1020	if wsView == nil {
1021		return
1022	}
1023
1024	log := logging.FromContext(ctx)
1025
1026	if parentNode != nil {
1027		var closeErr error
1028
1029		if parentNode.IsStacked {
1030			closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode)
1031		} else if siblingNode != nil && parentWidget != nil {
1032			closeErr = c.doIncrementalClose(
1033				ctx,
1034				wsView,
1035				paneID,
1036				siblingNode,
1037				parentNode,
1038				grandparentNode,
1039				parentWidget,
1040				siblingIsStartChild,
1041				parentIsStartInGrand,
1042			)
1043		} else {
1044			closeErr = fmt.Errorf("missing context for incremental close")
1045		}
1046
1047		if closeErr != nil {
1048			log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild")
1049			if err := wsView.Rebuild(ctx); err != nil {
1050				log.Error().Err(err).Msg("failed to rebuild workspace view")
1051			}
1052			c.contentCoord.ReleaseWebView(ctx, paneID)
1053			c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1054		}
1055	} else {
1056		if err := wsView.Rebuild(ctx); err != nil {
1057			log.Error().Err(err).Msg("failed to rebuild workspace view")
1058		}
1059		c.contentCoord.ReleaseWebView(ctx, paneID)
1060		c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1061	}
1062
1063	if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1064		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1065	}
1066	wsView.FocusPane(ws.ActivePaneID)
1067}
1068
1069// doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
1070func (c *WorkspaceCoordinator) doIncrementalStackClose(
1071	ctx context.Context,
1072	wsView *component.WorkspaceView,
1073	closingPaneID entity.PaneID,
1074	stackNode *entity.PaneNode,
1075) error {
1076	log := logging.FromContext(ctx)
1077	tr := wsView.TreeRenderer()
1078	if tr == nil {
1079		return fmt.Errorf("tree renderer not available")
1080	}
1081
1082	log.Debug().
1083		Str("closing_pane", string(closingPaneID)).
1084		Str("stack_node", stackNode.ID).
1085		Int("stack_children", len(stackNode.Children)).
1086		Msg("performing incremental stack close")
1087
1088	// Get the StackedView for this pane
1089	stackedView := tr.GetStackedViewForPane(string(closingPaneID))
1090	if stackedView == nil {
1091		return fmt.Errorf("stacked view not found for pane %s", closingPaneID)
1092	}
1093
1094	// Find the index of the closing pane in the stack
1095	closingIndex := -1
1096	for i, child := range stackNode.Children {
1097		if child.Pane != nil && child.Pane.ID == closingPaneID {
1098			closingIndex = i
1099			break
1100		}
1101	}
1102
1103	if closingIndex < 0 {
1104		return fmt.Errorf("pane %s not found in stack", closingPaneID)
1105	}
1106
1107	// Remove the pane from the StackedView widget
1108	if err := stackedView.RemovePane(ctx, closingIndex); err != nil {
1109		return fmt.Errorf("failed to remove pane from stacked view: %w", err)
1110	}
1111
1112	// Clean up tracking
1113	wsView.UnregisterPaneView(closingPaneID)
1114	tr.UnregisterPane(string(closingPaneID))
1115
1116	// Release the closing pane's webview
1117	c.contentCoord.ReleaseWebView(ctx, closingPaneID)
1118
1119	log.Debug().
1120		Int("removed_index", closingIndex).
1121		Msg("incremental stack close completed successfully")
1122	return nil
1123}
1124
1125// FocusPane navigates focus to an adjacent pane.
1126func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
1127	log := logging.FromContext(ctx)
1128
1129	ws, wsView := c.getActiveWS()
1130	if ws == nil {
1131		log.Warn().Msg("no active workspace")
1132		return nil
1133	}
1134
1135	if wsView == nil {
1136		log.Warn().Msg("no active workspace view")
1137		return nil
1138	}
1139
1140	// Use geometric navigation if focus manager is available
1141	var newPane *entity.PaneNode
1142	var err error
1143
1144	if c.focusMgr != nil {
1145		newPane, err = c.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
1146	} else if c.panesUC != nil {
1147		// Fallback to structural navigation
1148		newPane, err = c.panesUC.NavigateFocus(ctx, ws, direction)
1149	} else {
1150		log.Warn().Msg("no navigation manager available")
1151		return nil
1152	}
1153
1154	if err != nil {
1155		log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
1156		return err
1157	}
1158
1159	if newPane == nil {
1160		log.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
1161		return nil
1162	}
1163
1164	// Update the workspace view's active pane
1165	if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
1166		log.Warn().Err(err).Msg("failed to update active pane in view")
1167	} else {
1168		wsView.FocusPane(newPane.Pane.ID)
1169	}
1170
1171	// Sync StackedView visibility if new pane is in a stack
1172	if newPane.Parent != nil && newPane.Parent.IsStacked {
1173		c.syncStackedViewActive(ctx, wsView, newPane)
1174	}
1175
1176	log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
1177
1178	return nil
1179}
1180
1181// syncStackedViewActive updates the StackedView's visibility to match the domain model.
1182func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView *component.WorkspaceView, paneNode *entity.PaneNode) {
1183	log := logging.FromContext(ctx)
1184
1185	if paneNode == nil || paneNode.Parent == nil || !paneNode.Parent.IsStacked {
1186		return
1187	}
1188
1189	tr := wsView.TreeRenderer()
1190	if tr == nil {
1191		return
1192	}
1193
1194	// Get the StackedView for this pane
1195	stackedView := tr.GetStackedViewForPane(string(paneNode.Pane.ID))
1196	if stackedView == nil {
1197		log.Warn().Str("pane_id", string(paneNode.Pane.ID)).Msg("stacked view not found for pane")
1198		return
1199	}
1200
1201	// Use the parent's ActiveStackIndex which was set by the focus manager
1202	stackIndex := paneNode.Parent.ActiveStackIndex
1203
1204	log.Debug().
1205		Str("pane_id", string(paneNode.Pane.ID)).
1206		Int("stack_index", stackIndex).
1207		Msg("syncing stacked view visibility")
1208
1209	if err := stackedView.SetActive(ctx, stackIndex); err != nil {
1210		log.Warn().Err(err).Msg("failed to set stacked view active index")
1211	}
1212}
1213
1214// StackPane adds a new pane stacked on top of the active pane.
1215func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1216	log := logging.FromContext(ctx)
1217
1218	stackCtx, ok := c.prepareStackPane(ctx)
1219	if !ok {
1220		return nil
1221	}
1222
1223	newPaneID := entity.PaneID(c.generateID())
1224	newPane := entity.NewPane(newPaneID)
1225	newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)
1226	newPane.Title = defaultPaneTitle
1227
1228	stackNode, needsFirstPaneTitleUpdate := c.ensureStackNode(ctx, stackCtx.activeNode, stackCtx.activePaneID, stackCtx.originalTitle)
1229	insertIndex := insertStackChild(stackNode, newPaneID, newPane)
1230
1231	log.Debug().
1232		Int("stack_size", len(stackNode.Children)).
1233		Int("insert_index", insertIndex).
1234		Int("active_index", stackNode.ActiveStackIndex).
1235		Msg("domain tree updated with position-aware insertion")
1236
1237	// Create PaneView for the new pane
1238	newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
1239	setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)
1240	stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
1241
1242	// Add to the UI StackedView
1243	if err := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle); err != nil {
1244		log.Error().Err(err).Msg("failed to add pane to stack")
1245		return err
1246	}
1247
1248	// Update the first pane's title if we just converted from leaf to stacked
1249	if needsFirstPaneTitleUpdate {
1250		c.updateFirstStackTitle(ctx, stackCtx.wsView, stackCtx.activePaneID, stackCtx.originalTitle)
1251	}
1252
1253	// Get WebView and attach
1254	wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
1255	if err != nil {
1256		log.Warn().Err(err).Msg("failed to get webview for new pane")
1257	} else {
1258		widget := c.contentCoord.WrapWidget(ctx, wv)
1259		if widget != nil {
1260			newPaneView.SetWebViewWidget(widget)
1261		}
1262		// Load initial page for the new pane
1263		if err := wv.LoadURI(ctx, newPane.URI); err != nil {
1264			log.Warn().Err(err).Str("uri", newPane.URI).Msg("failed to load initial page")
1265		}
1266	}
1267
1268	// Update workspace active pane ID
1269	stackCtx.ws.ActivePaneID = newPaneID
1270
1271	// Update workspace view
1272	if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
1273		log.Warn().Err(err).Msg("failed to set active pane")
1274	}
1275
1276	if config.Get().Omnibox.AutoOpenOnNewPane {
1277		stackCtx.wsView.ShowOmnibox(ctx, "")
1278	}
1279
1280	// Set up title bar click callback
1281	tr := stackCtx.wsView.TreeRenderer()
1282	if tr != nil {
1283		stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))
1284		if stackedView != nil {
1285			capturedStackNode := stackNode
1286			stackedView.SetOnActivate(func(index int) {
1287				c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1288			})
1289		}
1290	}
1291
1292	// Notify state change for session snapshots
1293	c.notifyStateChanged()
1294
1295	log.Info().
1296		Str("original_pane", string(stackCtx.activePaneID)).
1297		Str("new_pane", string(newPaneID)).
1298		Int("stack_size", len(stackNode.Children)).
1299		Msg("stacked new pane")
1300
1301	return nil
1302}
1303
1304func (c *WorkspaceCoordinator) prepareStackPane(
1305	ctx context.Context,
1306) (*stackPaneContext, bool) {
1307	log := logging.FromContext(ctx)
1308	if c.stackedPaneMgr == nil {
1309		log.Warn().Msg("stacked pane manager not available")
1310		return nil, false
1311	}
1312
1313	ws, wsView := c.getActiveWS()
1314	if ws == nil {
1315		log.Warn().Msg("no active workspace")
1316		return nil, false
1317	}
1318
1319	activeNode := ws.ActivePane()
1320	if activeNode == nil || activeNode.Pane == nil {
1321		log.Warn().Msg("no active pane")
1322		return nil, false
1323	}
1324
1325	if wsView == nil {
1326		log.Warn().Msg("no workspace view")
1327		return nil, false
1328	}
1329
1330	activePaneID := activeNode.Pane.ID
1331	originalTitle := c.contentCoord.GetTitle(activePaneID)
1332	if originalTitle == "" {
1333		originalTitle = activeNode.Pane.Title
1334	}
1335	if originalTitle == "" {
1336		originalTitle = defaultPaneTitle
1337	}
1338
1339	return &stackPaneContext{
1340		ws:            ws,
1341		wsView:        wsView,
1342		activeNode:    activeNode,
1343		activePaneID:  activePaneID,
1344		originalTitle: originalTitle,
1345	}, true
1346}
1347
1348func (c *WorkspaceCoordinator) ensureStackNode(
1349	ctx context.Context,
1350	activeNode *entity.PaneNode,
1351	activePaneID entity.PaneID,
1352	originalTitle string,
1353) (*entity.PaneNode, bool) {
1354	log := logging.FromContext(ctx)
1355	if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1356		stackNode := activeNode.Parent
1357		log.Debug().
1358			Str("stack_id", stackNode.ID).
1359			Str("active_pane", string(activePaneID)).
1360			Int("stack_size", len(stackNode.Children)).
1361			Msg("adding to existing stack via parent")
1362		return stackNode, false
1363	}
1364
1365	if activeNode.IsStacked {
1366		return activeNode, false
1367	}
1368
1369	originalPane := activeNode.Pane
1370	originalPane.Title = originalTitle
1371	originalPaneChild := &entity.PaneNode{
1372		ID:     activeNode.ID + "_0",
1373		Pane:   originalPane,
1374		Parent: activeNode,
1375	}
1376
1377	activeNode.Pane = nil
1378	activeNode.IsStacked = true
1379	activeNode.Children = []*entity.PaneNode{originalPaneChild}
1380
1381	log.Debug().
1382		Str("node_id", activeNode.ID).
1383		Str("original_pane", string(originalPane.ID)).
1384		Str("original_title", originalTitle).
1385		Msg("converted leaf to stacked node")
1386
1387	return activeNode, true
1388}
1389
1390func insertStackChild(stackNode *entity.PaneNode, newPaneID entity.PaneID, newPane *entity.Pane) int {
1391	newChildNode := &entity.PaneNode{
1392		ID:     stackNode.ID + "_" + string(newPaneID),
1393		Pane:   newPane,
1394		Parent: stackNode,
1395	}
1396
1397	currentActiveIndex := stackNode.ActiveStackIndex
1398	if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1399		currentActiveIndex = len(stackNode.Children) - 1
1400		if currentActiveIndex < 0 {
1401			currentActiveIndex = -1
1402		}
1403	}
1404	insertIndex := currentActiveIndex + 1
1405
1406	stackNode.Children = append(stackNode.Children, nil)
1407	copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1408	stackNode.Children[insertIndex] = newChildNode
1409	stackNode.ActiveStackIndex = insertIndex
1410	return insertIndex
1411}
1412
1413func (c *WorkspaceCoordinator) updateFirstStackTitle(
1414	ctx context.Context,
1415	wsView *component.WorkspaceView,
1416	activePaneID entity.PaneID,
1417	originalTitle string,
1418) {
1419	log := logging.FromContext(ctx)
1420	tr := wsView.TreeRenderer()
1421	if tr == nil {
1422		return
1423	}
1424	stackedView := tr.GetStackedViewForPane(string(activePaneID))
1425	if stackedView == nil {
1426		return
1427	}
1428	if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
1429		log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
1430		return
1431	}
1432	log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
1433}
1434
1435// NavigateStack navigates up or down within a stacked pane container.
1436func (c *WorkspaceCoordinator) NavigateStack(ctx context.Context, direction string) error {
1437	log := logging.FromContext(ctx)
1438
1439	if c.stackedPaneMgr == nil {
1440		return nil
1441	}
1442
1443	ws, wsView := c.getActiveWS()
1444	if ws == nil || wsView == nil {
1445		return nil
1446	}
1447
1448	activePane := ws.ActivePane()
1449	if activePane == nil || activePane.Pane == nil {
1450		return nil
1451	}
1452
1453	currentPaneID := activePane.Pane.ID
1454
1455	// Check if we're actually in a multi-pane stack
1456	if !c.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
1457		log.Debug().Msg("current pane is not in a multi-pane stack")
1458		return nil
1459	}
1460
1461	// Navigate within the stack
1462	_, err := c.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
1463	if err != nil {
1464		log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
1465		return err
1466	}
1467
1468	log.Debug().
1469		Str("direction", direction).
1470		Str("current_pane", string(currentPaneID)).
1471		Msg("navigated stack")
1472
1473	return nil
1474}
1475
1476// onTitleBarClick handles clicks on title bars to switch the active pane in a stack.
1477func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
1478	log := logging.FromContext(ctx)
1479
1480	if stackNode == nil || sv == nil {
1481		return
1482	}
1483
1484	// Validate index
1485	if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
1486		log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
1487		return
1488	}
1489
1490	// Get the current active index
1491	currentIndex := sv.ActiveIndex()
1492	if clickedIndex == currentIndex {
1493		log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
1494		return
1495	}
1496
1497	// Update the outgoing pane's title bar with its current webpage title
1498	if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
1499		outgoingChild := stackNode.Children[currentIndex]
1500		if outgoingChild.Pane != nil {
1501			outgoingPaneID := outgoingChild.Pane.ID
1502			outgoingTitle := c.contentCoord.GetTitle(outgoingPaneID)
1503			if outgoingTitle == "" {
1504				outgoingTitle = outgoingChild.Pane.Title
1505			}
1506			if outgoingTitle != "" {
1507				if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
1508					log.Warn().Err(err).Msg("failed to update outgoing pane title")
1509				}
1510			}
1511		}
1512	}
1513
1514	// Get the pane ID at the clicked index
1515	clickedChild := stackNode.Children[clickedIndex]
1516	if clickedChild.Pane == nil {
1517		log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
1518		return
1519	}
1520	clickedPaneID := clickedChild.Pane.ID
1521
1522	// Update StackedView active index
1523	if err := sv.SetActive(ctx, clickedIndex); err != nil {
1524		log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
1525		return
1526	}
1527
1528	// Update domain model
1529	stackNode.ActiveStackIndex = clickedIndex
1530
1531	// Update workspace view (also updates domain model)
1532	_, wsView := c.getActiveWS()
1533	if wsView != nil {
1534		if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1535			log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1536		}
1537	}
1538
1539	log.Info().
1540		Int("from_index", currentIndex).
1541		Int("to_index", clickedIndex).
1542		Str("pane_id", string(clickedPaneID)).
1543		Msg("switched active pane via title bar click")
1544}
1545
1546// --- Popup Insertion ---
1547
1548// SetOnCreatePopupTab sets the callback for creating popup tabs.
1549// This is used when popup behavior is "tabbed".
1550func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input InsertPopupInput) error) {
1551	c.onCreatePopupTab = fn
1552}
1553
1554// InsertPopup inserts a popup pane into the workspace based on the specified behavior.
1555// Supports split, stacked, and tabbed behaviors.
1556func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopupInput) error {
1557	log := logging.FromContext(ctx)
1558
1559	log.Debug().
1560		Str("parent_pane", string(input.ParentPaneID)).
1561		Str("popup_pane", string(input.PopupPane.ID)).
1562		Str("behavior", string(input.Behavior)).
1563		Str("placement", input.Placement).
1564		Str("popup_type", input.PopupType.String()).
1565		Msg("inserting popup into workspace")
1566
1567	switch input.Behavior {
1568	case config.PopupBehaviorSplit:
1569		return c.insertPopupSplit(ctx, input)
1570	case config.PopupBehaviorStacked:
1571		return c.insertPopupStacked(ctx, input)
1572	case config.PopupBehaviorTabbed:
1573		return c.insertPopupTabbed(ctx, input)
1574	default:
1575		// Default to split right
1576		return c.insertPopupSplit(ctx, input)
1577	}
1578}
1579
1580// insertPopupSplit inserts a popup as a split pane adjacent to the parent.
1581func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input InsertPopupInput) error {
1582	log := logging.FromContext(ctx)
1583
1584	ws, wsView := c.getActiveWS()
1585	if ws == nil {
1586		return fmt.Errorf("no active workspace")
1587	}
1588
1589	// Find parent pane node
1590	parentNode := ws.FindPane(input.ParentPaneID)
1591	if parentNode == nil {
1592		return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1593	}
1594
1595	direction := resolvePopupSplitDirection(input.Placement)
1596
1597	// Check if parent is in a stack - if so, split around the stack
1598	isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
1599		parentNode.Parent != nil && parentNode.Parent.IsStacked
1600
1601	// Get existing widget before domain changes
1602	existingWidget := c.resolvePopupSplitWidget(wsView, parentNode, isStackSplit)
1603
1604	// Perform the split with pre-created pane
1605	output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
1606		Workspace:  ws,
1607		TargetPane: parentNode,
1608		Direction:  direction,
1609		NewPane:    input.PopupPane,
1610	})
1611	if err != nil {
1612		return fmt.Errorf("split for popup: %w", err)
1613	}
1614
1615	// Set popup as active
1616	ws.ActivePaneID = input.PopupPane.ID
1617
1618	// Update UI
1619	if wsView != nil {
1620		c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID)
1621		c.attachPopupWebView(ctx, wsView, input)
1622	}
1623
1624	log.Info().
1625		Str("popup_pane", string(input.PopupPane.ID)).
1626		Str("direction", string(direction)).
1627		Msg("popup inserted as split pane")
1628
1629	return nil
1630}
1631
1632func resolvePopupSplitDirection(placement string) usecase.SplitDirection {
1633	switch placement {
1634	case string(usecase.SplitLeft):
1635		return usecase.SplitLeft
1636	case string(usecase.SplitRight):
1637		return usecase.SplitRight
1638	case "top", string(usecase.SplitUp):
1639		return usecase.SplitUp
1640	case "bottom", string(usecase.SplitDown):
1641		return usecase.SplitDown
1642	default:
1643		return usecase.SplitRight
1644	}
1645}
1646
1647func (c *WorkspaceCoordinator) resolvePopupSplitWidget(
1648	wsView *component.WorkspaceView,
1649	parentNode *entity.PaneNode,
1650	isStackSplit bool,
1651) layout.Widget {
1652	if wsView == nil {
1653		return nil
1654	}
1655
1656	if !isStackSplit {
1657		return wsView.GetRootWidget()
1658	}
1659
1660	tr := wsView.TreeRenderer()
1661	if tr == nil {
1662		return nil
1663	}
1664
1665	stackedView := tr.GetStackedViewForPane(string(parentNode.Pane.ID))
1666	if stackedView == nil {
1667		return nil
1668	}
1669
1670	return stackedView.Widget()
1671}
1672
1673func (c *WorkspaceCoordinator) attachPopupWebView(
1674	ctx context.Context,
1675	wsView *component.WorkspaceView,
1676	input InsertPopupInput,
1677) {
1678	if wsView == nil || input.WebView == nil {
1679		return
1680	}
1681
1682	widget := c.contentCoord.WrapWidget(ctx, input.WebView)
1683	if widget == nil {
1684		return
1685	}
1686
1687	paneView := wsView.GetPaneView(input.PopupPane.ID)
1688	if paneView == nil {
1689		return
1690	}
1691	paneView.SetWebViewWidget(widget)
1692}
1693
1694// insertPopupStacked inserts a popup as a stacked pane on top of the parent.
1695func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
1696	log := logging.FromContext(ctx)
1697
1698	ws, wsView := c.getActiveWS()
1699	if ws == nil {
1700		return fmt.Errorf("no active workspace")
1701	}
1702
1703	parentNode := ws.FindPane(input.ParentPaneID)
1704	if parentNode == nil {
1705		return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1706	}
1707
1708	stackNode := c.resolveStackNode(input, parentNode, log)
1709	if stackNode == nil {
1710		return fmt.Errorf("failed to resolve stack node for popup")
1711	}
1712
1713	c.insertStackChild(stackNode, input.PopupPane)
1714
1715	if wsView == nil {
1716		// No workspace view to update; keep domain model consistent.
1717		ws.ActivePaneID = input.PopupPane.ID
1718	}
1719
1720	if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
1721		return err
1722	}
1723
1724	log.Info().
1725		Str("popup_pane", string(input.PopupPane.ID)).
1726		Int("stack_size", len(stackNode.Children)).
1727		Msg("popup inserted as stacked pane")
1728
1729	return nil
1730}
1731
1732func (c *WorkspaceCoordinator) resolveStackNode(
1733	input InsertPopupInput,
1734	parentNode *entity.PaneNode,
1735	log *zerolog.Logger,
1736) *entity.PaneNode {
1737	if parentNode.Parent != nil && parentNode.Parent.IsStacked {
1738		return parentNode.Parent
1739	}
1740	if parentNode.IsStacked {
1741		return parentNode
1742	}
1743	return c.convertToStackedNode(input, parentNode, log)
1744}
1745
1746func (c *WorkspaceCoordinator) convertToStackedNode(
1747	input InsertPopupInput,
1748	parentNode *entity.PaneNode,
1749	log *zerolog.Logger,
1750) *entity.PaneNode {
1751	originalPane := parentNode.Pane
1752	originalTitle := c.contentCoord.GetTitle(input.ParentPaneID)
1753	if originalTitle == "" {
1754		originalTitle = originalPane.Title
1755	}
1756	if originalTitle == "" {
1757		originalTitle = defaultPaneTitle
1758	}
1759	originalPane.Title = originalTitle
1760
1761	originalPaneChild := &entity.PaneNode{
1762		ID:     parentNode.ID + "_0",
1763		Pane:   originalPane,
1764		Parent: parentNode,
1765	}
1766
1767	parentNode.Pane = nil
1768	parentNode.IsStacked = true
1769	parentNode.Children = []*entity.PaneNode{originalPaneChild}
1770
1771	log.Debug().
1772		Str("node_id", parentNode.ID).
1773		Str("original_pane", string(originalPane.ID)).
1774		Msg("converted leaf to stacked node for popup")
1775
1776	return parentNode
1777}
1778
1779func (c *WorkspaceCoordinator) insertStackChild(stackNode *entity.PaneNode, pane *entity.Pane) {
1780	newChildNode := &entity.PaneNode{
1781		ID:     stackNode.ID + "_popup_" + string(pane.ID),
1782		Pane:   pane,
1783		Parent: stackNode,
1784	}
1785
1786	currentActiveIndex := stackNode.ActiveStackIndex
1787	if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1788		currentActiveIndex = len(stackNode.Children) - 1
1789		if currentActiveIndex < 0 {
1790			currentActiveIndex = -1
1791		}
1792	}
1793	insertIndex := currentActiveIndex + 1
1794
1795	stackNode.Children = append(stackNode.Children, nil)
1796	copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1797	stackNode.Children[insertIndex] = newChildNode
1798	stackNode.ActiveStackIndex = insertIndex
1799}
1800
1801func (c *WorkspaceCoordinator) attachPopupPaneView(
1802	ctx context.Context,
1803	input InsertPopupInput,
1804	wsView *component.WorkspaceView,
1805	stackNode *entity.PaneNode,
1806	log *zerolog.Logger,
1807) error {
1808	if wsView == nil {
1809		return nil
1810	}
1811	newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil)
1812	setupPaneViewHover(ctx, newPaneView, wsView)
1813	wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)
1814
1815	if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil {
1816		log.Error().Err(err).Msg("failed to add popup pane to stack")
1817		return err
1818	}
1819
1820	if input.WebView != nil {
1821		widget := c.contentCoord.WrapWidget(ctx, input.WebView)
1822		if widget != nil {
1823			newPaneView.SetWebViewWidget(widget)
1824		}
1825	}
1826
1827	if err := wsView.SetActivePaneID(input.PopupPane.ID); err != nil {
1828		log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1829	}
1830
1831	tr := wsView.TreeRenderer()
1832	if tr != nil {
1833		stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID))
1834		if stackedView != nil {
1835			capturedStackNode := stackNode
1836			stackedView.SetOnActivate(func(index int) {
1837				c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1838			})
1839		}
1840	}
1841	return nil
1842}
1843
1844// insertPopupTabbed creates a new tab for the popup.
1845func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input InsertPopupInput) error {
1846	log := logging.FromContext(ctx)
1847
1848	if c.onCreatePopupTab == nil {
1849		log.Warn().Msg("tabbed popup behavior requested but no tab handler configured, falling back to split")
1850		return c.insertPopupSplit(ctx, input)
1851	}
1852
1853	// Delegate to tab coordinator via callback
1854	if err := c.onCreatePopupTab(ctx, input); err != nil {
1855		return fmt.Errorf("create popup tab: %w", err)
1856	}
1857
1858	log.Info().
1859		Str("popup_pane", string(input.PopupPane.ID)).
1860		Msg("popup inserted as new tab")
1861
1862	return nil
1863}
1864
1865func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction usecase.ConsumeOrExpelDirection) error {
1866	log := logging.FromContext(ctx)
1867
1868	if c.panesUC == nil {
1869		log.Warn().Msg("panes use case not available")
1870		return nil
1871	}
1872
1873	ws, wsView := c.getActiveWS()
1874	if ws == nil {
1875		log.Warn().Msg("no active workspace")
1876		return nil
1877	}
1878
1879	activeNode := ws.ActivePane()
1880	if activeNode == nil {
1881		log.Warn().Msg("no active pane")
1882		return nil
1883	}
1884
1885	result, err := c.panesUC.ConsumeOrExpel(ctx, ws, activeNode, direction)
1886	if err != nil {
1887		return err
1888	}
1889	if result != nil && result.ErrorMessage != "" {
1890		c.ShowToastOnActivePane(ctx, result.ErrorMessage, component.ToastWarning)
1891		return nil
1892	}
1893
1894	if wsView != nil {
1895		if err := wsView.Rebuild(ctx); err != nil {
1896			log.Warn().Err(err).Msg("failed to rebuild workspace view")
1897		}
1898		c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1899		if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1900			log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1901		}
1902		wsView.FocusPane(ws.ActivePaneID)
1903	}
1904
1905	c.notifyStateChanged()
1906	return nil
1907}
1908
1909// ShowZoomToast displays a zoom level toast on the active pane.
1910func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) {
1911	_, wsView := c.getActiveWS()
1912	if wsView == nil {
1913		return
1914	}
1915
1916	paneView := wsView.GetActivePaneView()
1917	if paneView != nil {
1918		paneView.ShowZoomToast(ctx, zoomPercent)
1919	}
1920}
1921
1922// ShowToastOnActivePane displays a toast notification on the active pane.
1923func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, message string, level component.ToastLevel) {
1924	_, wsView := c.getActiveWS()
1925	if wsView == nil {
1926		return
1927	}
1928
1929	paneView := wsView.GetActivePaneView()
1930	if paneView != nil {
1931		paneView.ShowToast(ctx, message, level)
1932	}
1933}
1934
1935// Resize updates the active split ratio and applies it to GTK widgets.
1936func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDirection) error {
1937	log := logging.FromContext(ctx)
1938
1939	if c.panesUC == nil {
1940		log.Warn().Msg("panes use case not available")
1941		return nil
1942	}
1943
1944	ws, wsView := c.getActiveWS()
1945	if ws == nil {
1946		log.Warn().Msg("no active workspace")
1947		return nil
1948	}
1949
1950	target := ws.ActivePane()
1951	if target == nil {
1952		return nil
1953	}
1954
1955	cfg := config.Get()
1956	err := c.panesUC.Resize(ctx, ws, target, dir, cfg.Workspace.ResizeMode.StepPercent, cfg.Workspace.ResizeMode.MinPanePercent)
1957	if errors.Is(err, usecase.ErrNothingToResize) {
1958		c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
1959		return nil
1960	}
1961	if err != nil {
1962		return err
1963	}
1964
1965	if wsView != nil {
1966		c.updateSplitPositions(wsView, ws)
1967	}
1968
1969	c.notifyStateChanged()
1970	return nil
1971}
1972
1973func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID string, ratio float64) error {
1974	log := logging.FromContext(ctx)
1975
1976	if c.panesUC == nil {
1977		log.Warn().Msg("panes use case not available")
1978		return nil
1979	}
1980
1981	ws, _ := c.getActiveWS()
1982	if ws == nil {
1983		log.Warn().Msg("no active workspace")
1984		return nil
1985	}
1986
1987	cfg := config.Get()
1988	err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{
1989		Workspace:      ws,
1990		SplitNodeID:    splitNodeID,
1991		Ratio:          ratio,
1992		MinPanePercent: cfg.Workspace.ResizeMode.MinPanePercent,
1993	})
1994	if err != nil {
1995		return err
1996	}
1997
1998	c.notifyStateChanged()
1999	return nil
2000}
2001
2002func (c *WorkspaceCoordinator) updateSplitPositions(wsView *component.WorkspaceView, ws *entity.Workspace) {
2003	if wsView == nil || ws == nil || ws.Root == nil {
2004		return
2005	}
2006
2007	tr := wsView.TreeRenderer()
2008	if tr == nil {
2009		return
2010	}
2011
2012	ws.Root.Walk(func(node *entity.PaneNode) bool {
2013		if node.IsSplit() {
2014			_ = tr.UpdateSplitRatio(node.ID, node.SplitRatio)
2015		}
2016		return true
2017	})
2018}

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