v0.14.0 76d1892

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.ensureHover(newLeaf)
347	spm.wm.app.panes = append(spm.wm.app.panes, newLeaf.pane)
348	if newLeaf.pane.zoomController != nil {
349		newLeaf.pane.zoomController.ApplyInitialZoom()
350	}
351
352	// Mark stack operation timestamp to prevent focus conflicts
353	spm.wm.lastStackOperation = time.Now()
354
355	// This ensures Zellij-style layout where the previously active pane shows its current title
356	currentActiveIndex := stackNode.activeStackIndex
357	if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
358		currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
359		if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
360			currentTitle := currentlyActivePane.pane.webView.GetTitle()
361			if currentTitle != "" {
362				spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
363				log.Printf("[workspace] updated title bar for pane becoming inactive during stack creation: %s", currentTitle)
364			}
365		}
366	}
367
368	// Immediately switch to the new pane (don't use deprecated IdleAdd)
369	stackNode.activeStackIndex = insertIndex
370	spm.UpdateStackVisibility(stackNode)
371
372	// Set focus on the new pane synchronously
373	spm.wm.SetActivePane(newLeaf, SourceSplit)
374
375	log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
376		stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)
377	return newLeaf, nil
378}
379
380// UpdateStackVisibility updates the visibility of panes in a stack
381func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
382	log.Printf("[workspace] updateStackVisibility called: stackNode=%p", stackNode)
383
384	if stackNode == nil {
385		log.Printf("[workspace] updateStackVisibility aborted: stackNode is nil")
386		return
387	}
388
389	if !stackNode.isStacked {
390		log.Printf("[workspace] updateStackVisibility aborted: stackNode.isStacked=%v", stackNode.isStacked)
391		return
392	}
393
394	if len(stackNode.stackedPanes) == 0 {
395		log.Printf("[workspace] updateStackVisibility aborted: stackedPanes empty, len=%d", len(stackNode.stackedPanes))
396		return
397	}
398
399	activeIndex := stackNode.activeStackIndex
400	if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
401		log.Printf("[workspace] updateStackVisibility: correcting activeIndex from %d to 0", activeIndex)
402		activeIndex = 0
403		stackNode.activeStackIndex = activeIndex
404	}
405
406	log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes))
407
408	// CRITICAL: Process ALL panes in a single pass to prevent flickering
409	for i, pane := range stackNode.stackedPanes {
410		if i == activeIndex {
411			// Active pane: show container, NEVER show title bar
412			if pane.container != nil {
413				webkit.WidgetSetVisible(pane.container, true)
414			}
415			if pane.titleBar != nil {
416				webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
417			}
418			log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
419		} else {
420			// Inactive panes: hide container, show title bar
421			if pane.container != nil {
422				webkit.WidgetSetVisible(pane.container, false)
423			}
424			if pane.titleBar != nil {
425				webkit.WidgetSetVisible(pane.titleBar, true)
426
427				// Refresh title bar to pick up any newly downloaded favicons
428				if pane.pane != nil && pane.pane.webView != nil {
429					title := pane.pane.webView.GetTitle()
430					if title == "" {
431						title = NewTabTitle
432					}
433					spm.updateTitleBarLabel(pane, title)
434				}
435			}
436		}
437	}
438}
439
440// NavigateStack handles navigation within a stacked pane container
441func (spm *StackedPaneManager) NavigateStack(direction string) bool {
442	if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
443		return false
444	}
445
446	// Find the stack container this pane belongs to
447	var stackNode *paneNode
448	current := spm.wm.GetActiveNode()
449
450	// Check if current pane is directly in a stack
451	if current.parent != nil && current.parent.isStacked {
452		stackNode = current.parent
453	} else {
454		// Current pane might be the stack container itself if it was the first pane converted to stack
455		if current.isStacked {
456			stackNode = current
457		}
458	}
459
460	if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
461		return false // Not in a stack or stack has only one pane
462	}
463
464	// Use the stackNode's activeStackIndex instead of searching for current pane
465	// This is more reliable than trying to match panes
466	currentIndex := stackNode.activeStackIndex
467	if currentIndex < 0 || currentIndex >= len(stackNode.stackedPanes) {
468		log.Printf("[workspace] navigateStack: invalid activeStackIndex=%d, resetting to 0", currentIndex)
469		currentIndex = 0
470		stackNode.activeStackIndex = 0
471	}
472
473	// Calculate new index based on direction
474	var newIndex int
475	switch direction {
476	case DirectionUp:
477		newIndex = currentIndex - 1
478		if newIndex < 0 {
479			// At the top of the stack - allow navigation outside
480			return false
481		}
482	case DirectionDown:
483		newIndex = currentIndex + 1
484		if newIndex >= len(stackNode.stackedPanes) {
485			// At the bottom of the stack - allow navigation outside
486			return false
487		}
488	default:
489		return false
490	}
491
492	if newIndex == currentIndex {
493		return false // No change
494	}
495
496	// Update title bar for the pane transitioning from ACTIVE to INACTIVE
497	// This ensures the collapsed pane shows its current page title (Zellij-style layout)
498	currentActivePane := stackNode.stackedPanes[currentIndex]
499	if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
500		currentTitle := currentActivePane.pane.webView.GetTitle()
501		if currentTitle != "" {
502			spm.updateTitleBarLabel(currentActivePane, currentTitle)
503			log.Printf("[workspace] updated title bar for pane transitioning to INACTIVE: %s", currentTitle)
504		}
505	}
506
507	// Update active stack index and visibility
508	stackNode.activeStackIndex = newIndex
509	spm.UpdateStackVisibility(stackNode)
510
511	// Focus the new active pane
512	newActivePane := stackNode.stackedPanes[newIndex]
513	spm.wm.SetActivePane(newActivePane, SourceStackNav)
514
515	log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
516		direction, currentIndex, newIndex, len(stackNode.stackedPanes))
517	return true
518}
519
520// createTitleBar creates a title bar widget for a pane in a stack
521func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter {
522	// Get the actual title and URL from the WebView
523	var titleText, pageURL string
524	if pane.pane != nil && pane.pane.webView != nil {
525		titleText = pane.pane.webView.GetTitle()
526		if titleText == "" {
527			titleText = NewTabTitle // Fallback only when title is actually empty
528		}
529		pageURL = pane.pane.webView.GetCurrentURL()
530	} else {
531		titleText = NewTabTitle
532	}
533
534	titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
535	if titleBar != nil {
536		// Store the mapping from titleBar ID to pane
537		titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
538		spm.titleBarToPane[titleBarID] = pane
539
540		// Attach click handler
541		webkit.WidgetAttachClickHandler(titleBar, func() {
542			spm.handleTitleBarClick(titleBarID)
543		})
544	}
545
546	return titleBar
547}
548
549// UpdateTitleBar updates the title bar label for a WebView in stacked panes
550func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title string) {
551	if spm.wm == nil || webView == nil || title == "" {
552		return
553	}
554
555	// Find the pane node for this WebView
556	node, exists := spm.wm.viewToNode[webView]
557	if !exists || node == nil || !node.isLeaf {
558		return
559	}
560
561	// Check if this pane is in a stack and has a title bar
562	if node.parent != nil && node.parent.isStacked && node.titleBar != nil {
563		// CRITICAL: Only update title bar for INACTIVE panes
564		// Active panes should never show their title bar
565		stackNode := node.parent
566		activeIndex := stackNode.activeStackIndex
567
568		// Find this pane's index in the stack
569		paneIndex := -1
570		for i, pane := range stackNode.stackedPanes {
571			if pane == node {
572				paneIndex = i
573				break
574			}
575		}
576
577		if paneIndex != -1 && paneIndex != activeIndex {
578			// This is an INACTIVE pane - safe to update title bar
579			spm.updateTitleBarLabel(node, title)
580			log.Printf("[workspace] updated title bar for INACTIVE WebView %d: %s", webView.ID(), title)
581		} else {
582			// This is the ACTIVE pane - title bar should remain hidden
583			log.Printf("[workspace] skipped title bar update for ACTIVE WebView %d: %s", webView.ID(), title)
584		}
585	}
586}
587
588// updateTitleBarLabel updates the label widget within a title bar by recreating it
589func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {
590	if node == nil || node.titleBar == nil || node.parent == nil || !node.parent.isStacked {
591		return
592	}
593
594	// Get the current URL from the WebView for favicon
595	var pageURL string
596	if node.pane != nil && node.pane.webView != nil {
597		pageURL = node.pane.webView.GetCurrentURL()
598	}
599
600	// Create new title bar with updated title and favicon
601	newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
602	if newTitleBar == nil {
603		log.Printf("[workspace] failed to create new title bar")
604		return
605	}
606
607	// Store the mapping from titleBar ID to pane and attach click handler
608	titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
609	spm.titleBarToPane[titleBarID] = node
610
611	// Attach click handler
612	webkit.WidgetAttachClickHandler(newTitleBar, func() {
613		spm.handleTitleBarClick(titleBarID)
614	})
615
616	// Replace the old title bar in the stack
617	if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
618		// Find the correct insertion position based on the pane's index in the stack
619		paneIndex := -1
620		for i, stackedPane := range node.parent.stackedPanes {
621			if stackedPane == node {
622				paneIndex = i
623				break
624			}
625		}
626
627		// Remove old title bar
628		if node.titleBar != nil {
629			webkit.WidgetUnparent(node.titleBar)
630		}
631
632		// Update the node's title bar reference
633		spm.wm.setTitleBar(node, newTitleBar)
634
635		// Insert the new title bar at the correct position
636		if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
637			if paneIndex == 0 {
638				// First pane - insert at the beginning
639				box.Prepend(newTitleBar)
640			} else {
641				// Insert after the previous pane's container
642				prevPane := node.parent.stackedPanes[paneIndex-1]
643				if prevPane.container != nil {
644					box.InsertChildAfter(newTitleBar, prevPane.container)
645				}
646			}
647		}
648
649		log.Printf("[workspace] replaced title bar for pane %d: %p", paneIndex, node)
650	}
651}
652
653// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
654func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
655	titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
656	if titleBox == nil {
657		return nil
658	}
659
660	titleBox.AddCSSClass("stacked-pane-title")
661	titleBox.SetHExpand(true)
662	titleBox.SetVExpand(false)
663
664	placeholderLabel := gtk.NewLabel(faviconPlaceholder)
665	if placeholderLabel != nil {
666		placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
667		titleBox.Append(placeholderLabel)
668		webkit.WidgetSetVisible(placeholderLabel, true)
669	}
670
671	if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil {
672		spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) {
673			if err != nil {
674				log.Printf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err)
675				return
676			}
677
678			if texture != nil && titleBox != nil {
679
680				// Create favicon image with UI scaling
681				faviconImg := gtk.NewImageFromPaintable(texture)
682				// Scale favicon size based on UI scale (base: 16px)
683				faviconSize := 16
684				if spm.wm != nil && spm.wm.app != nil && spm.wm.app.config != nil {
685					uiScale := spm.wm.app.config.Workspace.Styling.UIScale
686					if uiScale > 0 {
687						faviconSize = int(16 * uiScale)
688					}
689				}
690				faviconImg.SetPixelSize(faviconSize)
691				webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
692
693				// IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
694				// This handles the case where multiple async callbacks fire for the same title bar
695				if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil {
696					titleBox.Remove(placeholderLabel)
697				}
698
699				// Prepend favicon image (multiple calls are safe - GTK handles it)
700				titleBox.Prepend(faviconImg)
701				webkit.WidgetSetVisible(faviconImg, true)
702			}
703		})
704	}
705
706	// Create title label with the specified title
707	titleLabel := gtk.NewLabel(title)
708	if titleLabel == nil {
709		return titleBox
710	}
711
712	titleLabel.AddCSSClass("stacked-pane-title-text")
713	titleLabel.SetEllipsize(2)
714
715	titleBox.Append(titleLabel)
716	webkit.WidgetSetVisible(titleBox, true)
717	webkit.WidgetSetVisible(titleLabel, true)
718
719	return titleBox
720}
721
722// TODO(#8): Refactor this function to reduce cyclomatic complexity (49 -> <30)
723//
724// CloseStackedPane handles closing a pane that is part of a stack.
725// The logic mirrors the regular close path: we rebuild the tree, update focus,
726// and let WorkspaceManager perform the actual cleanup/destroy work so that all
727// bookkeeping stays consistent.
728func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
729	if node == nil || node.parent == nil || !node.parent.isStacked {
730		return errors.New("node is not part of a stacked pane")
731	}
732
733	stackNode := node.parent
734
735	// Locate the pane inside the stack slice.
736	nodeIndex := -1
737	for i, stackedPane := range stackNode.stackedPanes {
738		if stackedPane == node {
739			nodeIndex = i
740			break
741		}
742	}
743	if nodeIndex == -1 {
744		return errors.New("node not found in stack")
745	}
746
747	log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes))
748
749	// Detach hover/focus controllers before tearing down widgets to avoid GTK
750	// callbacks referencing freed memory during destruction.
751	if spm.wm != nil {
752		spm.wm.detachHover(node)
753		spm.wm.detachFocus(node)
754	}
755
756	// Remove the pane widgets from the stack container before mutating the tree.
757	stackBox := stackNode.stackWrapper
758	if stackBox == nil {
759		stackBox = stackNode.container
760	}
761	if box, ok := stackBox.(*gtk.Box); ok && box != nil {
762		if node.titleBar != nil {
763			box.Remove(node.titleBar)
764		}
765		if node.container != nil {
766			box.Remove(node.container)
767		}
768	}
769
770	// Remove the pane from the stack slice.
771	stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
772
773	switch len(stackNode.stackedPanes) {
774	case 0:
775		// Stack container is now empty: remove it from the layout entirely.
776		parent := stackNode.parent
777		if parent == nil {
778			// Stack was the root of the workspace — closing this pane exits the app.
779			if stackNode.container != nil {
780				if webkit.WidgetGetParent(stackNode.container) != nil {
781					webkit.WidgetUnparent(stackNode.container)
782				}
783			}
784			// Clear root container (handles both tab and non-tab environments)
785			spm.wm.clearRootContainer()
786			_, err := spm.wm.cleanupAndExit(node)
787			return err
788		}
789
790		// Detach the empty stack container from its GtkPaned parent.
791		if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
792			if parent.left == stackNode {
793				paned.SetStartChild(nil)
794			} else if parent.right == stackNode {
795				paned.SetEndChild(nil)
796			}
797			webkit.WidgetQueueAllocate(paned)
798		}
799		if stackNode.container != nil {
800			if webkit.WidgetGetParent(stackNode.container) != nil {
801				webkit.WidgetUnparent(stackNode.container)
802			}
803		}
804
805		sibling := spm.wm.getSibling(stackNode)
806		grandparent := parent.parent
807
808		spm.wm.promoteSibling(grandparent, parent, sibling)
809		spm.wm.swapContainers(grandparent, parent, sibling)
810
811		generation := spm.wm.nextCleanupGeneration()
812		spm.wm.cleanupPane(node, generation)
813		spm.wm.decommissionParent(parent, generation)
814
815		if sibling != nil {
816			spm.wm.setFocusToLeaf(sibling)
817		} else {
818			spm.wm.updateMainPane()
819		}
820
821		stackNode.parent = nil
822		stackNode.container = nil
823		stackNode.stackWrapper = nil
824		stackNode.stackedPanes = nil
825
826		return nil
827
828	case 1:
829		// Promote the remaining pane back to a regular leaf node.
830		lastPane := stackNode.stackedPanes[0]
831		parent := stackNode.parent
832		lastPaneContainer := lastPane.container
833
834		if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
835			box.Remove(lastPane.titleBar)
836		}
837		if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
838			box.Remove(lastPaneContainer)
839		}
840		if stackNode.container != nil {
841			if webkit.WidgetGetParent(stackNode.container) != nil {
842				webkit.WidgetUnparent(stackNode.container)
843			}
844		}
845
846		if parent == nil {
847			// Stack was root - make last pane the new root
848			spm.wm.root = lastPane
849			lastPane.parent = nil
850			if lastPaneContainer != nil {
851				spm.wm.setRootContainer(lastPaneContainer)
852				webkit.WidgetQueueAllocate(lastPaneContainer)
853				webkit.WidgetShow(lastPaneContainer)
854			}
855		} else {
856			if parent.left == stackNode {
857				parent.left = lastPane
858			} else if parent.right == stackNode {
859				parent.right = lastPane
860			}
861			lastPane.parent = parent
862			if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil && lastPaneContainer != nil {
863				if parent.left == lastPane {
864					paned.SetStartChild(lastPaneContainer)
865				} else {
866					paned.SetEndChild(lastPaneContainer)
867				}
868				webkit.WidgetQueueAllocate(paned)
869			}
870			if lastPaneContainer != nil {
871				webkit.WidgetSetVisible(lastPaneContainer, true)
872			}
873		}
874
875		lastPane.isStacked = false
876		lastPane.stackedPanes = nil
877		lastPane.titleBar = nil
878		spm.wm.viewToNode[lastPane.pane.webView] = lastPane
879
880		generation := spm.wm.nextCleanupGeneration()
881		spm.wm.cleanupPane(node, generation)
882
883		spm.wm.SetActivePane(lastPane, SourceClose)
884		if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
885			webkit.WidgetShow(lastPaneWidget)
886			webkit.WidgetGrabFocus(lastPaneWidget)
887		}
888
889		stackNode.parent = nil
890		stackNode.container = nil
891		stackNode.stackWrapper = nil
892		stackNode.stackedPanes = nil
893
894		log.Printf("[workspace] converted stack back to regular pane")
895		return nil
896
897	default:
898		// Still multiple panes in stack — adjust active index and visibility.
899		if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
900			stackNode.activeStackIndex--
901		}
902		if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
903			stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
904		}
905
906		spm.UpdateStackVisibility(stackNode)
907		if spm.wm.GetActiveNode() == node {
908			newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
909			spm.wm.SetActivePane(newActivePane, SourceClose)
910		}
911
912		generation := spm.wm.nextCleanupGeneration()
913		spm.wm.cleanupPane(node, generation)
914
915		log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
916			len(stackNode.stackedPanes), stackNode.activeStackIndex)
917		return nil
918	}
919}
920
921// handleTitleBarClick handles clicks on title bars to switch the active pane
922func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uint64) {
923	pane, exists := spm.titleBarToPane[titleBarID]
924	if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
925		return
926	}
927
928	stackNode := pane.parent
929
930	// Find the index of the clicked pane
931	for i, p := range stackNode.stackedPanes {
932		if p == pane {
933			// Only switch if it's not already active
934			if i != stackNode.activeStackIndex {
935				log.Printf("[workspace] title bar clicked: switching from pane %d to pane %d",
936					stackNode.activeStackIndex, i)
937
938				// Update title bar for the pane transitioning from ACTIVE to INACTIVE
939				currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
940				if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
941					currentTitle := currentActivePane.pane.webView.GetTitle()
942					if currentTitle != "" {
943						spm.updateTitleBarLabel(currentActivePane, currentTitle)
944					}
945				}
946
947				stackNode.activeStackIndex = i
948				spm.UpdateStackVisibility(stackNode)
949				spm.wm.SetActivePane(pane, SourceStackNav)
950			}
951			return
952		}
953	}
954}

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 9

Tags 42

↑↓ moveenter openesc close