v0.15.0 9006957

stacked_panes.go RawHistoryFolder

1package browser
2
3import (
4	"errors"
5	"fmt"
6	"sync/atomic"
7	"time"
8
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/bnema/dumber/pkg/webkit"
11	"github.com/diamondburned/gotk4/pkg/gdk/v4"
12	"github.com/diamondburned/gotk4/pkg/gtk/v4"
13)
14
15const (
16	// faviconPlaceholder is the emoji displayed when no favicon is available
17	faviconPlaceholder = "🌐"
18)
19
20// StackedPaneManager handles all stacked pane operations
21type StackedPaneManager struct {
22	wm             *WorkspaceManager
23	titleBarToPane map[uint64]*paneNode
24	nextTitleBarID uint64
25}
26
27// NewStackedPaneManager creates a new stacked pane manager
28func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {
29	return &StackedPaneManager{
30		wm:             wm,
31		titleBarToPane: make(map[uint64]*paneNode),
32	}
33}
34
35// StackPane creates a new pane stacked on top of the target pane.
36// This is the main entry point for creating stacked panes.
37func (spm *StackedPaneManager) StackPane(target *paneNode) (*paneNode, error) {
38	if target == nil || !target.isLeaf || target.pane == nil {
39		return nil, errors.New("stack target must be a leaf pane")
40	}
41
42	// Create the new pane first
43	newLeaf, err := spm.prepareNewStackedPane()
44	if err != nil {
45		return nil, fmt.Errorf("failed to prepare new stacked pane: %w", err)
46	}
47
48	var stackNode *paneNode
49	var insertIndex int
50
51	// Check if target is already in a stack
52	if target.parent != nil && target.parent.isStacked {
53		// Target is already in a stack - add to existing stack
54		stackNode, insertIndex, err = spm.addPaneToExistingStack(target, newLeaf)
55		if err != nil {
56			return nil, fmt.Errorf("failed to add pane to existing stack: %w", err)
57		}
58	} else {
59		// Target is not stacked - create initial stack
60		stackNode, insertIndex, err = spm.convertToStackedContainer(target, newLeaf)
61		if err != nil {
62			return nil, fmt.Errorf("failed to convert to stacked container: %w", err)
63		}
64	}
65
66	// Finalize the stack creation
67	return spm.finalizeStackCreation(stackNode, newLeaf, insertIndex)
68}
69
70// prepareNewStackedPane creates a new pane and its container
71func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) {
72	newView, err := spm.wm.createWebView()
73	if err != nil {
74		return nil, err
75	}
76
77	newPane, err := spm.wm.createPane(newView)
78	if err != nil {
79		return nil, err
80	}
81
82	if handler := newPane.MessageHandler(); handler != nil {
83		handler.SetWorkspaceObserver(spm.wm)
84	}
85
86	newContainer := newPane.webView.RootWidget()
87	if newContainer == nil {
88		return nil, errors.New("new pane missing container")
89	}
90	webkit.WidgetSetHExpand(newContainer, true)
91	webkit.WidgetSetVExpand(newContainer, true)
92
93	// Create the new leaf node
94	newLeaf := &paneNode{
95		pane:   newPane,
96		isLeaf: true,
97	}
98	// Initialize widgets properly using workspace manager helper
99	spm.wm.initializePaneWidgets(newLeaf, newContainer)
100
101	// After wrapping the WebView in an overlay, operate on that container.
102	newContainer = newLeaf.container
103	if newContainer == nil {
104		return nil, errors.New("new stacked pane missing container overlay")
105	}
106	webkit.WidgetSetHExpand(newContainer, true)
107	webkit.WidgetSetVExpand(newContainer, true)
108
109	// Create title bar for the new pane
110	newTitleBar := spm.createTitleBar(newLeaf)
111	spm.wm.setTitleBar(newLeaf, newTitleBar)
112
113	// Keep new container hidden initially - will be shown after transition
114	webkit.WidgetSetVisible(newContainer, false)
115	if newTitleBar != nil {
116		webkit.WidgetSetVisible(newTitleBar, false)
117	}
118
119	return newLeaf, nil
120}
121
122// addPaneToExistingStack adds a new pane to an existing stack
123func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) (*paneNode, int, error) {
124	stackNode := target.parent
125
126	// Find the current position of the target pane in the stack
127	currentIndex := -1
128	for i, pane := range stackNode.stackedPanes {
129		if pane == target {
130			currentIndex = i
131			break
132		}
133	}
134
135	if currentIndex == -1 {
136		return nil, 0, errors.New("target pane not found in stack")
137	}
138
139	// Insert the new pane right after the current pane
140	insertIndex := currentIndex + 1
141	logging.Debug(fmt.Sprintf("[workspace] adding to existing stack: currentIndex=%d insertIndex=%d stackSize=%d",
142		currentIndex, insertIndex, len(stackNode.stackedPanes)))
143
144	// Set parent relationship
145	newLeaf.parent = stackNode
146
147	// Insert the new pane at the correct position in the slice
148	stackNode.stackedPanes = append(stackNode.stackedPanes, nil)                       // Expand slice
149	copy(stackNode.stackedPanes[insertIndex+1:], stackNode.stackedPanes[insertIndex:]) // Shift elements
150	stackNode.stackedPanes[insertIndex] = newLeaf                                      // Insert new pane
151
152	// Unparent the new container before adding it to the stack (only if it has a parent)
153	if newLeaf.container != nil {
154		if webkit.WidgetGetParent(newLeaf.container) != nil {
155			webkit.WidgetUnparent(newLeaf.container)
156		}
157	}
158
159	// Insert widgets at correct position for Zellij-style layout
160	// Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2
161
162	// Find the widget to insert after (based on insertIndex)
163	var insertAfterWidget gtk.Widgetter
164	if insertIndex > 0 {
165		// Insert after the previous pane's container
166		prevPane := stackNode.stackedPanes[insertIndex-1]
167		if prevPane.container != nil {
168			insertAfterWidget = prevPane.container
169		}
170	}
171
172	if insertAfterWidget != nil {
173		// Insert titleBar after the previous pane's container
174		if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
175			box.InsertChildAfter(newLeaf.titleBar, insertAfterWidget)
176		}
177		// Insert container after the newly inserted titleBar
178		if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil && newLeaf.titleBar != nil {
179			box.InsertChildAfter(newLeaf.container, newLeaf.titleBar)
180		}
181		logging.Debug(fmt.Sprintf("[workspace] inserted widgets at position %d (after widget %p)", insertIndex, insertAfterWidget))
182	} else {
183		// Insert at the beginning (insertIndex = 0)
184		if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
185			box.Prepend(newLeaf.container)
186		}
187		if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
188			box.Prepend(newLeaf.titleBar)
189		}
190		logging.Debug("[workspace] prepended widgets at position 0")
191	}
192
193	return stackNode, insertIndex, nil
194}
195
196// convertToStackedContainer converts a simple pane to a stacked container
197func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNode) (*paneNode, int, error) {
198	logging.Debug(fmt.Sprintf("[workspace] converting pane to stacked: %p", target))
199
200	// Create the wrapper container - this is what will be used by splitNode
201	stackWrapperContainer := gtk.NewBox(gtk.OrientationVertical, 0)
202	if stackWrapperContainer == nil {
203		return nil, 0, errors.New("failed to create stack wrapper container")
204	}
205	stackWrapperContainer.SetHExpand(true)
206	stackWrapperContainer.SetVExpand(true)
207	// Note: Don't call Realize() here - GTK4 will realize automatically when added to toplevel
208
209	// Add CSS class to the stack container for proper styling
210	stackWrapperContainer.AddCSSClass(stackContainerClass)
211
212	// Create the internal box for the actual stacked widgets (titles + webviews)
213	stackInternalBox := gtk.NewBox(gtk.OrientationVertical, 0)
214	if stackInternalBox == nil {
215		return nil, 0, errors.New("failed to create stack internal box")
216	}
217	stackInternalBox.SetHExpand(true)
218	stackInternalBox.SetVExpand(true)
219
220	// The internal box goes inside the wrapper
221	stackWrapperContainer.Append(stackInternalBox)
222
223	// Get the existing container and parent info
224	existingContainer := target.container
225	parent := target.parent
226
227	// Create title bar for the existing pane
228	titleBar := spm.createTitleBar(target)
229
230	// Keep existing container visible during transition to prevent rendering glitch
231	if titleBar != nil {
232		webkit.WidgetSetVisible(titleBar, false)
233	}
234
235	// Detach existing container from its current parent first
236	spm.detachFromParent(target, parent)
237
238	// Build the complete stack structure with hidden widgets
239	if titleBar != nil {
240		stackInternalBox.Append(titleBar)
241	}
242	if existingContainer != nil {
243		stackInternalBox.Append(existingContainer)
244	}
245
246	// Immediately reattach the stack wrapper to minimize visibility gap
247	spm.reattachToParent(stackWrapperContainer, target, parent)
248
249	// Keep target as a regular leaf node (it's just inside a stack now)
250	// Individual panes within a stack should NOT be marked as isStacked
251	// Only the stack container itself has isStacked=true
252	target.isStacked = false
253	target.isLeaf = true
254	// container stays the same (existingContainer)
255	spm.wm.setTitleBar(target, titleBar)
256
257	// Create the stack container node - container points to wrapper, stackWrapper points to internal box
258	stackNode := &paneNode{
259		isStacked:        true,
260		isLeaf:           false,
261		stackedPanes:     []*paneNode{target},
262		activeStackIndex: 0, // KEEP CURRENT PANE ACTIVE during transition (index 0)
263		parent:           parent,
264	}
265	spm.wm.setContainer(stackNode, stackWrapperContainer, "stack-wrapper") // Wrapper for GTK operations (splits, etc.)
266	spm.wm.setStackWrapper(stackNode, stackInternalBox)                    // Internal box for stack operations
267
268	// Update target's parent to be the stack node
269	target.parent = stackNode
270
271	// Update parent references (GTK operations already done above)
272	if parent == nil {
273		spm.wm.root = stackNode
274	} else {
275		if parent.left == target {
276			parent.left = stackNode
277		} else if parent.right == target {
278			parent.right = stackNode
279		}
280	}
281
282	// Set up the new pane
283	newLeaf.parent = stackNode
284
285	// Insert the new pane at index 1 (after the original pane)
286	insertIndex := 1
287	stackNode.stackedPanes = append(stackNode.stackedPanes, newLeaf)
288
289	// Unparent the new container before adding it to the stack
290	// This is critical: even freshly created WebViews might have internal parent refs
291	if newLeaf.container != nil {
292		parentWidget := webkit.WidgetGetParent(newLeaf.container)
293		if parentWidget != nil {
294			logging.Debug(fmt.Sprintf("[workspace] unparenting new pane container %p from parent %p before stack append", newLeaf.container, parentWidget))
295			webkit.WidgetUnparent(newLeaf.container)
296			// Verify unparent succeeded
297			if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != nil {
298				logging.Warn(fmt.Sprintf("[workspace] container %p still has parent %p after unparent", newLeaf.container, finalParent))
299			}
300		}
301	}
302
303	// Add the new widgets to the internal stack box
304	if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
305		box.Append(newLeaf.titleBar)
306	}
307	if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
308		box.Append(newLeaf.container)
309	}
310
311	return stackNode, insertIndex, nil
312}
313
314// detachFromParent removes a pane from its current parent
315func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
316	if parent == nil {
317		// Target is the root - clear it (handles both tab and non-tab environments)
318		spm.wm.clearRootContainer()
319		// Unparent if it has a GTK parent
320		if target.container != nil {
321			if webkit.WidgetGetParent(target.container) != nil {
322				webkit.WidgetUnparent(target.container)
323			}
324		}
325	} else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
326		// Target has a parent paned - remove it (automatically unparents in GTK4)
327		if parent.left == target {
328			paned.SetStartChild(nil)
329		} else if parent.right == target {
330			paned.SetEndChild(nil)
331		}
332	}
333}
334
335// reattachToParent attaches a container to the parent
336func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
337	if parent == nil {
338		// Attach as root (handles both tab and non-tab environments)
339		spm.wm.setRootContainer(container)
340	} else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
341		if parent.left == target {
342			paned.SetStartChild(container)
343		} else if parent.right == target {
344			paned.SetEndChild(container)
345		}
346	}
347}
348
349// finalizeStackCreation completes the stack creation process
350func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNode, insertIndex int) (*paneNode, error) {
351	// Update workspace state first
352	spm.wm.viewToNode[newLeaf.pane.webView] = newLeaf
353	spm.wm.setupPopupHandling(newLeaf.pane.webView, newLeaf)
354	spm.wm.ensureHover(newLeaf)
355	spm.wm.app.panes = append(spm.wm.app.panes, newLeaf.pane)
356	if newLeaf.pane.zoomController != nil {
357		newLeaf.pane.zoomController.ApplyInitialZoom()
358	}
359
360	// Mark stack operation timestamp to prevent focus conflicts
361	spm.wm.lastStackOperation = time.Now()
362
363	// This ensures Zellij-style layout where the previously active pane shows its current title
364	currentActiveIndex := stackNode.activeStackIndex
365	if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
366		currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
367		if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
368			currentTitle := currentlyActivePane.pane.webView.GetTitle()
369			if currentTitle != "" {
370				spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
371				logging.Debug(fmt.Sprintf("[workspace] updated title bar for pane becoming inactive during stack creation: %s", currentTitle))
372			}
373		}
374	}
375
376	// Immediately switch to the new pane (don't use deprecated IdleAdd)
377	stackNode.activeStackIndex = insertIndex
378	spm.UpdateStackVisibility(stackNode)
379
380	// Set focus on the new pane synchronously
381	spm.wm.SetActivePane(newLeaf, SourceSplit)
382
383	logging.Debug(fmt.Sprintf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
384		stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex))
385	return newLeaf, nil
386}
387
388// UpdateStackVisibility updates the visibility of panes in a stack
389func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
390	logging.Debug(fmt.Sprintf("[workspace] updateStackVisibility called: stackNode=%p", stackNode))
391
392	if stackNode == nil {
393		logging.Debug("[workspace] updateStackVisibility aborted: stackNode is nil")
394		return
395	}
396
397	if !stackNode.isStacked {
398		logging.Debug(fmt.Sprintf("[workspace] updateStackVisibility aborted: stackNode.isStacked=%v", stackNode.isStacked))
399		return
400	}
401
402	if len(stackNode.stackedPanes) == 0 {
403		logging.Debug(fmt.Sprintf("[workspace] updateStackVisibility aborted: stackedPanes empty, len=%d", len(stackNode.stackedPanes)))
404		return
405	}
406
407	activeIndex := stackNode.activeStackIndex
408	if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
409		logging.Debug(fmt.Sprintf("[workspace] updateStackVisibility: correcting activeIndex from %d to 0", activeIndex))
410		activeIndex = 0
411		stackNode.activeStackIndex = activeIndex
412	}
413
414	logging.Debug(fmt.Sprintf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes)))
415
416	// CRITICAL: Process ALL panes in a single pass to prevent flickering
417	for i, pane := range stackNode.stackedPanes {
418		if i == activeIndex {
419			// Active pane: show container, NEVER show title bar
420			if pane.container != nil {
421				webkit.WidgetSetVisible(pane.container, true)
422			}
423			if pane.titleBar != nil {
424				webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
425			}
426			logging.Debug(fmt.Sprintf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i))
427		} else {
428			// Inactive panes: hide container, show title bar
429			if pane.container != nil {
430				webkit.WidgetSetVisible(pane.container, false)
431			}
432			if pane.titleBar != nil {
433				webkit.WidgetSetVisible(pane.titleBar, true)
434
435				// Refresh title bar to pick up any newly downloaded favicons
436				if pane.pane != nil && pane.pane.webView != nil {
437					title := pane.pane.webView.GetTitle()
438					if title == "" {
439						title = NewTabTitle
440					}
441					spm.updateTitleBarLabel(pane, title)
442				}
443			}
444		}
445	}
446}
447
448// NavigateStack handles navigation within a stacked pane container
449func (spm *StackedPaneManager) NavigateStack(direction string) bool {
450	if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
451		return false
452	}
453
454	// Find the stack container this pane belongs to
455	var stackNode *paneNode
456	current := spm.wm.GetActiveNode()
457
458	// Check if current pane is directly in a stack
459	if current.parent != nil && current.parent.isStacked {
460		stackNode = current.parent
461	} else {
462		// Current pane might be the stack container itself if it was the first pane converted to stack
463		if current.isStacked {
464			stackNode = current
465		}
466	}
467
468	if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
469		return false // Not in a stack or stack has only one pane
470	}
471
472	// Use the stackNode's activeStackIndex instead of searching for current pane
473	// This is more reliable than trying to match panes
474	currentIndex := stackNode.activeStackIndex
475	if currentIndex < 0 || currentIndex >= len(stackNode.stackedPanes) {
476		logging.Debug(fmt.Sprintf("[workspace] navigateStack: invalid activeStackIndex=%d, resetting to 0", currentIndex))
477		currentIndex = 0
478		stackNode.activeStackIndex = 0
479	}
480
481	// Calculate new index based on direction
482	var newIndex int
483	switch direction {
484	case DirectionUp:
485		newIndex = currentIndex - 1
486		if newIndex < 0 {
487			// At the top of the stack - allow navigation outside
488			return false
489		}
490	case DirectionDown:
491		newIndex = currentIndex + 1
492		if newIndex >= len(stackNode.stackedPanes) {
493			// At the bottom of the stack - allow navigation outside
494			return false
495		}
496	default:
497		return false
498	}
499
500	if newIndex == currentIndex {
501		return false // No change
502	}
503
504	// Update title bar for the pane transitioning from ACTIVE to INACTIVE
505	// This ensures the collapsed pane shows its current page title (Zellij-style layout)
506	currentActivePane := stackNode.stackedPanes[currentIndex]
507	if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
508		currentTitle := currentActivePane.pane.webView.GetTitle()
509		if currentTitle != "" {
510			spm.updateTitleBarLabel(currentActivePane, currentTitle)
511			logging.Debug(fmt.Sprintf("[workspace] updated title bar for pane transitioning to INACTIVE: %s", currentTitle))
512		}
513	}
514
515	// Update active stack index and visibility
516	stackNode.activeStackIndex = newIndex
517	spm.UpdateStackVisibility(stackNode)
518
519	// Focus the new active pane
520	newActivePane := stackNode.stackedPanes[newIndex]
521	spm.wm.SetActivePane(newActivePane, SourceStackNav)
522
523	logging.Debug(fmt.Sprintf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
524		direction, currentIndex, newIndex, len(stackNode.stackedPanes)))
525	return true
526}
527
528// createTitleBar creates a title bar widget for a pane in a stack
529func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter {
530	// Get the actual title and URL from the WebView
531	var titleText, pageURL string
532	if pane.pane != nil && pane.pane.webView != nil {
533		titleText = pane.pane.webView.GetTitle()
534		if titleText == "" {
535			titleText = NewTabTitle // Fallback only when title is actually empty
536		}
537		pageURL = pane.pane.webView.GetCurrentURL()
538	} else {
539		titleText = NewTabTitle
540	}
541
542	titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
543	if titleBar != nil {
544		// Store the mapping from titleBar ID to pane
545		titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
546		spm.titleBarToPane[titleBarID] = pane
547
548		// Attach click handler
549		webkit.WidgetAttachClickHandler(titleBar, func() {
550			spm.handleTitleBarClick(titleBarID)
551		})
552	}
553
554	return titleBar
555}
556
557// UpdateTitleBar updates the title bar label for a WebView in stacked panes
558func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title string) {
559	if spm.wm == nil || webView == nil || title == "" {
560		return
561	}
562
563	// Find the pane node for this WebView
564	node, exists := spm.wm.viewToNode[webView]
565	if !exists || node == nil || !node.isLeaf {
566		return
567	}
568
569	// Check if this pane is in a stack and has a title bar
570	if node.parent != nil && node.parent.isStacked && node.titleBar != nil {
571		// CRITICAL: Only update title bar for INACTIVE panes
572		// Active panes should never show their title bar
573		stackNode := node.parent
574		activeIndex := stackNode.activeStackIndex
575
576		// Find this pane's index in the stack
577		paneIndex := -1
578		for i, pane := range stackNode.stackedPanes {
579			if pane == node {
580				paneIndex = i
581				break
582			}
583		}
584
585		if paneIndex != -1 && paneIndex != activeIndex {
586			// This is an INACTIVE pane - safe to update title bar
587			spm.updateTitleBarLabel(node, title)
588			logging.Debug(fmt.Sprintf("[workspace] updated title bar for INACTIVE WebView %d: %s", webView.ID(), title))
589		} else {
590			// This is the ACTIVE pane - title bar should remain hidden
591			logging.Debug(fmt.Sprintf("[workspace] skipped title bar update for ACTIVE WebView %d: %s", webView.ID(), title))
592		}
593	}
594}
595
596// updateTitleBarLabel updates the label widget within a title bar by recreating it
597func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {
598	if node == nil || node.titleBar == nil || node.parent == nil || !node.parent.isStacked {
599		return
600	}
601
602	// Get the current URL from the WebView for favicon
603	var pageURL string
604	if node.pane != nil && node.pane.webView != nil {
605		pageURL = node.pane.webView.GetCurrentURL()
606	}
607
608	// Create new title bar with updated title and favicon
609	newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
610	if newTitleBar == nil {
611		logging.Error("[workspace] failed to create new title bar")
612		return
613	}
614
615	// Store the mapping from titleBar ID to pane and attach click handler
616	titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
617	spm.titleBarToPane[titleBarID] = node
618
619	// Attach click handler
620	webkit.WidgetAttachClickHandler(newTitleBar, func() {
621		spm.handleTitleBarClick(titleBarID)
622	})
623
624	// Replace the old title bar in the stack
625	if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
626		// Find the correct insertion position based on the pane's index in the stack
627		paneIndex := -1
628		for i, stackedPane := range node.parent.stackedPanes {
629			if stackedPane == node {
630				paneIndex = i
631				break
632			}
633		}
634
635		// Remove old title bar
636		if node.titleBar != nil {
637			webkit.WidgetUnparent(node.titleBar)
638		}
639
640		// Update the node's title bar reference
641		spm.wm.setTitleBar(node, newTitleBar)
642
643		// Insert the new title bar at the correct position
644		if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
645			if paneIndex == 0 {
646				// First pane - insert at the beginning
647				box.Prepend(newTitleBar)
648			} else {
649				// Insert after the previous pane's container
650				prevPane := node.parent.stackedPanes[paneIndex-1]
651				if prevPane.container != nil {
652					box.InsertChildAfter(newTitleBar, prevPane.container)
653				}
654			}
655		}
656
657		logging.Debug(fmt.Sprintf("[workspace] replaced title bar for pane %d: %p", paneIndex, node))
658	}
659}
660
661// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
662func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
663	titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
664	if titleBox == nil {
665		return nil
666	}
667
668	titleBox.AddCSSClass("stacked-pane-title")
669	titleBox.SetHExpand(true)
670	titleBox.SetVExpand(false)
671
672	placeholderLabel := gtk.NewLabel(faviconPlaceholder)
673	if placeholderLabel != nil {
674		placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
675		titleBox.Append(placeholderLabel)
676		webkit.WidgetSetVisible(placeholderLabel, true)
677	}
678
679	if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil {
680		spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) {
681			if err != nil {
682				logging.Warn(fmt.Sprintf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err))
683				return
684			}
685
686			if texture != nil && titleBox != nil {
687
688				// Create favicon image with UI scaling
689				faviconImg := gtk.NewImageFromPaintable(texture)
690				// Scale favicon size based on UI scale (base: 16px)
691				faviconSize := 16
692				if spm.wm != nil && spm.wm.app != nil && spm.wm.app.config != nil {
693					uiScale := spm.wm.app.config.Workspace.Styling.UIScale
694					if uiScale > 0 {
695						faviconSize = int(16 * uiScale)
696					}
697				}
698				faviconImg.SetPixelSize(faviconSize)
699				webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
700
701				// IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
702				// This handles the case where multiple async callbacks fire for the same title bar
703				if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil {
704					titleBox.Remove(placeholderLabel)
705				}
706
707				// Prepend favicon image (multiple calls are safe - GTK handles it)
708				titleBox.Prepend(faviconImg)
709				webkit.WidgetSetVisible(faviconImg, true)
710			}
711		})
712	}
713
714	// Create title label with the specified title
715	titleLabel := gtk.NewLabel(title)
716	if titleLabel == nil {
717		return titleBox
718	}
719
720	titleLabel.AddCSSClass("stacked-pane-title-text")
721	titleLabel.SetEllipsize(2)
722
723	titleBox.Append(titleLabel)
724	webkit.WidgetSetVisible(titleBox, true)
725	webkit.WidgetSetVisible(titleLabel, true)
726
727	return titleBox
728}
729
730// TODO(#8): Refactor this function to reduce cyclomatic complexity (49 -> <30)
731//
732// CloseStackedPane handles closing a pane that is part of a stack.
733// The logic mirrors the regular close path: we rebuild the tree, update focus,
734// and let WorkspaceManager perform the actual cleanup/destroy work so that all
735// bookkeeping stays consistent.
736func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
737	if node == nil || node.parent == nil || !node.parent.isStacked {
738		return errors.New("node is not part of a stacked pane")
739	}
740
741	stackNode := node.parent
742
743	// Locate the pane inside the stack slice.
744	nodeIndex := -1
745	for i, stackedPane := range stackNode.stackedPanes {
746		if stackedPane == node {
747			nodeIndex = i
748			break
749		}
750	}
751	if nodeIndex == -1 {
752		return errors.New("node not found in stack")
753	}
754
755	logging.Debug(fmt.Sprintf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes)))
756
757	// Detach hover/focus controllers before tearing down widgets to avoid GTK
758	// callbacks referencing freed memory during destruction.
759	if spm.wm != nil {
760		spm.wm.detachHover(node)
761		spm.wm.detachFocus(node)
762	}
763
764	// Remove the pane widgets from the stack container before mutating the tree.
765	stackBox := stackNode.stackWrapper
766	if stackBox == nil {
767		stackBox = stackNode.container
768	}
769	if box, ok := stackBox.(*gtk.Box); ok && box != nil {
770		if node.titleBar != nil {
771			box.Remove(node.titleBar)
772		}
773		if node.container != nil {
774			box.Remove(node.container)
775		}
776	}
777
778	// Remove the pane from the stack slice.
779	stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
780
781	switch len(stackNode.stackedPanes) {
782	case 0:
783		// Stack container is now empty: remove it from the layout entirely.
784		parent := stackNode.parent
785		if parent == nil {
786			// Stack was the root of the workspace — closing this pane exits the app.
787			if stackNode.container != nil {
788				if webkit.WidgetGetParent(stackNode.container) != nil {
789					webkit.WidgetUnparent(stackNode.container)
790				}
791			}
792			// Clear root container (handles both tab and non-tab environments)
793			spm.wm.clearRootContainer()
794			_, err := spm.wm.cleanupAndExit(node)
795			return err
796		}
797
798		// Detach the empty stack container from its GtkPaned parent.
799		if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
800			if parent.left == stackNode {
801				paned.SetStartChild(nil)
802			} else if parent.right == stackNode {
803				paned.SetEndChild(nil)
804			}
805			webkit.WidgetQueueAllocate(paned)
806		}
807		if stackNode.container != nil {
808			if webkit.WidgetGetParent(stackNode.container) != nil {
809				webkit.WidgetUnparent(stackNode.container)
810			}
811		}
812
813		sibling := spm.wm.getSibling(stackNode)
814		grandparent := parent.parent
815
816		spm.wm.promoteSibling(grandparent, parent, sibling)
817		spm.wm.swapContainers(grandparent, parent, sibling)
818
819		generation := spm.wm.nextCleanupGeneration()
820		spm.wm.cleanupPane(node, generation)
821		spm.wm.decommissionParent(parent, generation)
822
823		if sibling != nil {
824			spm.wm.setFocusToLeaf(sibling)
825		} else {
826			spm.wm.updateMainPane()
827		}
828
829		stackNode.parent = nil
830		stackNode.container = nil
831		stackNode.stackWrapper = nil
832		stackNode.stackedPanes = nil
833
834		return nil
835
836	case 1:
837		// Promote the remaining pane back to a regular leaf node.
838		lastPane := stackNode.stackedPanes[0]
839		parent := stackNode.parent
840		lastPaneContainer := lastPane.container
841
842		if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
843			box.Remove(lastPane.titleBar)
844		}
845		if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
846			box.Remove(lastPaneContainer)
847		}
848		if stackNode.container != nil {
849			if webkit.WidgetGetParent(stackNode.container) != nil {
850				webkit.WidgetUnparent(stackNode.container)
851			}
852		}
853
854		if parent == nil {
855			// Stack was root - make last pane the new root
856			spm.wm.root = lastPane
857			lastPane.parent = nil
858			if lastPaneContainer != nil {
859				spm.wm.setRootContainer(lastPaneContainer)
860				webkit.WidgetQueueAllocate(lastPaneContainer)
861				webkit.WidgetShow(lastPaneContainer)
862			}
863		} else {
864			if parent.left == stackNode {
865				parent.left = lastPane
866			} else if parent.right == stackNode {
867				parent.right = lastPane
868			}
869			lastPane.parent = parent
870			if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil && lastPaneContainer != nil {
871				if parent.left == lastPane {
872					paned.SetStartChild(lastPaneContainer)
873				} else {
874					paned.SetEndChild(lastPaneContainer)
875				}
876				webkit.WidgetQueueAllocate(paned)
877			}
878			if lastPaneContainer != nil {
879				webkit.WidgetSetVisible(lastPaneContainer, true)
880			}
881		}
882
883		lastPane.isStacked = false
884		lastPane.stackedPanes = nil
885		lastPane.titleBar = nil
886		spm.wm.viewToNode[lastPane.pane.webView] = lastPane
887		spm.wm.setupPopupHandling(lastPane.pane.webView, lastPane)
888
889		generation := spm.wm.nextCleanupGeneration()
890		spm.wm.cleanupPane(node, generation)
891
892		spm.wm.SetActivePane(lastPane, SourceClose)
893		if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
894			webkit.WidgetShow(lastPaneWidget)
895			webkit.WidgetGrabFocus(lastPaneWidget)
896		}
897
898		stackNode.parent = nil
899		stackNode.container = nil
900		stackNode.stackWrapper = nil
901		stackNode.stackedPanes = nil
902
903		logging.Debug("[workspace] converted stack back to regular pane")
904		return nil
905
906	default:
907		// Still multiple panes in stack — adjust active index and visibility.
908		if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
909			stackNode.activeStackIndex--
910		}
911		if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
912			stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
913		}
914
915		spm.UpdateStackVisibility(stackNode)
916		if spm.wm.GetActiveNode() == node {
917			newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
918			spm.wm.SetActivePane(newActivePane, SourceClose)
919		}
920
921		generation := spm.wm.nextCleanupGeneration()
922		spm.wm.cleanupPane(node, generation)
923
924		logging.Debug(fmt.Sprintf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
925			len(stackNode.stackedPanes), stackNode.activeStackIndex))
926		return nil
927	}
928}
929
930// handleTitleBarClick handles clicks on title bars to switch the active pane
931func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uint64) {
932	pane, exists := spm.titleBarToPane[titleBarID]
933	if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
934		return
935	}
936
937	stackNode := pane.parent
938
939	// Find the index of the clicked pane
940	for i, p := range stackNode.stackedPanes {
941		if p == pane {
942			// Only switch if it's not already active
943			if i != stackNode.activeStackIndex {
944				logging.Debug(fmt.Sprintf("[workspace] title bar clicked: switching from pane %d to pane %d",
945					stackNode.activeStackIndex, i))
946
947				// Update title bar for the pane transitioning from ACTIVE to INACTIVE
948				currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
949				if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
950					currentTitle := currentActivePane.pane.webView.GetTitle()
951					if currentTitle != "" {
952						spm.updateTitleBarLabel(currentActivePane, currentTitle)
953					}
954				}
955
956				stackNode.activeStackIndex = i
957				spm.UpdateStackVisibility(stackNode)
958				spm.wm.SetActivePane(pane, SourceStackNav)
959			}
960			return
961		}
962	}
963}

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