v0.14.1 0da30ba

stacked_panes.go RawHistoryFolder

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

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