v0.12.1 1453d21

stacked_panes.go RawHistoryFolder

1package browser
2
3import (
4	"errors"
5	"fmt"
6	"log"
7	"sync/atomic"
8	"time"
9
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	// Create title bar for the new pane
102	newTitleBar := spm.createTitleBar(newLeaf)
103	spm.wm.setTitleBar(newLeaf, newTitleBar)
104
105	// Keep new container hidden initially - will be shown after transition
106	webkit.WidgetSetVisible(newContainer, false)
107	if newTitleBar != nil {
108		webkit.WidgetSetVisible(newTitleBar, false)
109	}
110
111	return newLeaf, nil
112}
113
114// addPaneToExistingStack adds a new pane to an existing stack
115func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) (*paneNode, int, error) {
116	stackNode := target.parent
117
118	// Find the current position of the target pane in the stack
119	currentIndex := -1
120	for i, pane := range stackNode.stackedPanes {
121		if pane == target {
122			currentIndex = i
123			break
124		}
125	}
126
127	if currentIndex == -1 {
128		return nil, 0, errors.New("target pane not found in stack")
129	}
130
131	// Insert the new pane right after the current pane
132	insertIndex := currentIndex + 1
133	log.Printf("[workspace] adding to existing stack: currentIndex=%d insertIndex=%d stackSize=%d",
134		currentIndex, insertIndex, len(stackNode.stackedPanes))
135
136	// Set parent relationship
137	newLeaf.parent = stackNode
138
139	// Insert the new pane at the correct position in the slice
140	stackNode.stackedPanes = append(stackNode.stackedPanes, nil)                       // Expand slice
141	copy(stackNode.stackedPanes[insertIndex+1:], stackNode.stackedPanes[insertIndex:]) // Shift elements
142	stackNode.stackedPanes[insertIndex] = newLeaf                                      // Insert new pane
143
144	// Unparent the new container before adding it to the stack (only if it has a parent)
145	if newLeaf.container != nil {
146		if webkit.WidgetGetParent(newLeaf.container) != nil {
147			webkit.WidgetUnparent(newLeaf.container)
148		}
149	}
150
151	// Insert widgets at correct position for Zellij-style layout
152	// Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2
153
154	// Find the widget to insert after (based on insertIndex)
155	var insertAfterWidget gtk.Widgetter
156	if insertIndex > 0 {
157		// Insert after the previous pane's container
158		prevPane := stackNode.stackedPanes[insertIndex-1]
159		if prevPane.container != nil {
160			insertAfterWidget = prevPane.container
161		}
162	}
163
164	if insertAfterWidget != nil {
165		// Insert titleBar after the previous pane's container
166		if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
167			box.InsertChildAfter(newLeaf.titleBar, insertAfterWidget)
168		}
169		// Insert container after the newly inserted titleBar
170		if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil && newLeaf.titleBar != nil {
171			box.InsertChildAfter(newLeaf.container, newLeaf.titleBar)
172		}
173		log.Printf("[workspace] inserted widgets at position %d (after widget %p)", insertIndex, insertAfterWidget)
174	} else {
175		// Insert at the beginning (insertIndex = 0)
176		if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
177			box.Prepend(newLeaf.container)
178		}
179		if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
180			box.Prepend(newLeaf.titleBar)
181		}
182		log.Printf("[workspace] prepended widgets at position 0")
183	}
184
185	return stackNode, insertIndex, nil
186}
187
188// convertToStackedContainer converts a simple pane to a stacked container
189func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNode) (*paneNode, int, error) {
190	log.Printf("[workspace] converting pane to stacked: %p", target)
191
192	// Create the wrapper container - this is what will be used by splitNode
193	stackWrapperContainer := gtk.NewBox(gtk.OrientationVertical, 0)
194	if stackWrapperContainer == nil {
195		return nil, 0, errors.New("failed to create stack wrapper container")
196	}
197	stackWrapperContainer.SetHExpand(true)
198	stackWrapperContainer.SetVExpand(true)
199	// Note: Don't call Realize() here - GTK4 will realize automatically when added to toplevel
200
201	// Add CSS class to the stack container for proper styling
202	stackWrapperContainer.AddCSSClass(stackContainerClass)
203
204	// Create the internal box for the actual stacked widgets (titles + webviews)
205	stackInternalBox := gtk.NewBox(gtk.OrientationVertical, 0)
206	if stackInternalBox == nil {
207		return nil, 0, errors.New("failed to create stack internal box")
208	}
209	stackInternalBox.SetHExpand(true)
210	stackInternalBox.SetVExpand(true)
211
212	// The internal box goes inside the wrapper
213	stackWrapperContainer.Append(stackInternalBox)
214
215	// Get the existing container and parent info
216	existingContainer := target.container
217	parent := target.parent
218
219	// Create title bar for the existing pane
220	titleBar := spm.createTitleBar(target)
221
222	// Keep existing container visible during transition to prevent rendering glitch
223	if titleBar != nil {
224		webkit.WidgetSetVisible(titleBar, false)
225	}
226
227	// Detach existing container from its current parent first
228	spm.detachFromParent(target, parent)
229
230	// Build the complete stack structure with hidden widgets
231	if titleBar != nil {
232		stackInternalBox.Append(titleBar)
233	}
234	if existingContainer != nil {
235		stackInternalBox.Append(existingContainer)
236	}
237
238	// Immediately reattach the stack wrapper to minimize visibility gap
239	spm.reattachToParent(stackWrapperContainer, target, parent)
240
241	// Keep target as a regular leaf node (it's just inside a stack now)
242	// Individual panes within a stack should NOT be marked as isStacked
243	// Only the stack container itself has isStacked=true
244	target.isStacked = false
245	target.isLeaf = true
246	// container stays the same (existingContainer)
247	spm.wm.setTitleBar(target, titleBar)
248
249	// Create the stack container node - container points to wrapper, stackWrapper points to internal box
250	stackNode := &paneNode{
251		isStacked:        true,
252		isLeaf:           false,
253		stackedPanes:     []*paneNode{target},
254		activeStackIndex: 0, // KEEP CURRENT PANE ACTIVE during transition (index 0)
255		parent:           parent,
256	}
257	spm.wm.setContainer(stackNode, stackWrapperContainer, "stack-wrapper") // Wrapper for GTK operations (splits, etc.)
258	spm.wm.setStackWrapper(stackNode, stackInternalBox)                    // Internal box for stack operations
259
260	// Update target's parent to be the stack node
261	target.parent = stackNode
262
263	// Update parent references (GTK operations already done above)
264	if parent == nil {
265		spm.wm.root = stackNode
266	} else {
267		if parent.left == target {
268			parent.left = stackNode
269		} else if parent.right == target {
270			parent.right = stackNode
271		}
272	}
273
274	// Set up the new pane
275	newLeaf.parent = stackNode
276
277	// Insert the new pane at index 1 (after the original pane)
278	insertIndex := 1
279	stackNode.stackedPanes = append(stackNode.stackedPanes, newLeaf)
280
281	// Unparent the new container before adding it to the stack
282	// This is critical: even freshly created WebViews might have internal parent refs
283	if newLeaf.container != nil {
284		parentWidget := webkit.WidgetGetParent(newLeaf.container)
285		if parentWidget != nil {
286			log.Printf("[workspace] unparenting new pane container %p from parent %p before stack append", newLeaf.container, parentWidget)
287			webkit.WidgetUnparent(newLeaf.container)
288			// Verify unparent succeeded
289			if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != nil {
290				log.Printf("[workspace] WARNING: container %p still has parent %p after unparent", newLeaf.container, finalParent)
291			}
292		}
293	}
294
295	// Add the new widgets to the internal stack box
296	if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
297		box.Append(newLeaf.titleBar)
298	}
299	if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
300		box.Append(newLeaf.container)
301	}
302
303	return stackNode, insertIndex, nil
304}
305
306// detachFromParent removes a pane from its current parent
307func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
308	if parent == nil {
309		// Target is the root - remove it from window
310		if spm.wm.window != nil {
311			spm.wm.window.SetChild(nil)
312		}
313		// Unparent if it has a GTK parent
314		if target.container != nil {
315			if webkit.WidgetGetParent(target.container) != nil {
316				webkit.WidgetUnparent(target.container)
317			}
318		}
319	} else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
320		// Target has a parent paned - remove it (automatically unparents in GTK4)
321		if parent.left == target {
322			paned.SetStartChild(nil)
323		} else if parent.right == target {
324			paned.SetEndChild(nil)
325		}
326	}
327}
328
329// reattachToParent attaches a container to the parent
330func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
331	if parent == nil {
332		if spm.wm.window != nil {
333			spm.wm.window.SetChild(container)
334		}
335	} else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
336		if parent.left == target {
337			paned.SetStartChild(container)
338		} else if parent.right == target {
339			paned.SetEndChild(container)
340		}
341	}
342}
343
344// finalizeStackCreation completes the stack creation process
345func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNode, insertIndex int) (*paneNode, error) {
346	// Update workspace state first
347	spm.wm.viewToNode[newLeaf.pane.webView] = newLeaf
348	spm.wm.ensureHover(newLeaf)
349	spm.wm.app.panes = append(spm.wm.app.panes, newLeaf.pane)
350	if newLeaf.pane.zoomController != nil {
351		newLeaf.pane.zoomController.ApplyInitialZoom()
352	}
353
354	// Mark stack operation timestamp to prevent focus conflicts
355	spm.wm.lastStackOperation = time.Now()
356
357	// This ensures Zellij-style layout where the previously active pane shows its current title
358	currentActiveIndex := stackNode.activeStackIndex
359	if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
360		currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
361		if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
362			currentTitle := currentlyActivePane.pane.webView.GetTitle()
363			if currentTitle != "" {
364				spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
365				log.Printf("[workspace] updated title bar for pane becoming inactive during stack creation: %s", currentTitle)
366			}
367		}
368	}
369
370	// Immediately switch to the new pane (don't use deprecated IdleAdd)
371	stackNode.activeStackIndex = insertIndex
372	spm.UpdateStackVisibility(stackNode)
373
374	// Set focus on the new pane synchronously
375	spm.wm.SetActivePane(newLeaf, SourceSplit)
376
377	log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
378		stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)
379	return newLeaf, nil
380}
381
382// UpdateStackVisibility updates the visibility of panes in a stack
383func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
384	log.Printf("[workspace] updateStackVisibility called: stackNode=%p", stackNode)
385
386	if stackNode == nil {
387		log.Printf("[workspace] updateStackVisibility aborted: stackNode is nil")
388		return
389	}
390
391	if !stackNode.isStacked {
392		log.Printf("[workspace] updateStackVisibility aborted: stackNode.isStacked=%v", stackNode.isStacked)
393		return
394	}
395
396	if len(stackNode.stackedPanes) == 0 {
397		log.Printf("[workspace] updateStackVisibility aborted: stackedPanes empty, len=%d", len(stackNode.stackedPanes))
398		return
399	}
400
401	activeIndex := stackNode.activeStackIndex
402	if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
403		log.Printf("[workspace] updateStackVisibility: correcting activeIndex from %d to 0", activeIndex)
404		activeIndex = 0
405		stackNode.activeStackIndex = activeIndex
406	}
407
408	log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes))
409
410	// CRITICAL: Process ALL panes in a single pass to prevent flickering
411	for i, pane := range stackNode.stackedPanes {
412		if i == activeIndex {
413			// Active pane: show container, NEVER show title bar
414			if pane.container != nil {
415				webkit.WidgetSetVisible(pane.container, true)
416			}
417			if pane.titleBar != nil {
418				webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
419			}
420			log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
421		} else {
422			// Inactive panes: hide container, show title bar
423			if pane.container != nil {
424				webkit.WidgetSetVisible(pane.container, false)
425			}
426			if pane.titleBar != nil {
427				webkit.WidgetSetVisible(pane.titleBar, true)
428
429				// Refresh title bar to pick up any newly downloaded favicons
430				if pane.pane != nil && pane.pane.webView != nil {
431					title := pane.pane.webView.GetTitle()
432					if title == "" {
433						title = "New Tab"
434					}
435					spm.updateTitleBarLabel(pane, title)
436				}
437			}
438		}
439	}
440}
441
442// NavigateStack handles navigation within a stacked pane container
443func (spm *StackedPaneManager) NavigateStack(direction string) bool {
444	if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
445		return false
446	}
447
448	// Find the stack container this pane belongs to
449	var stackNode *paneNode
450	current := spm.wm.GetActiveNode()
451
452	// Check if current pane is directly in a stack
453	if current.parent != nil && current.parent.isStacked {
454		stackNode = current.parent
455	} else {
456		// Current pane might be the stack container itself if it was the first pane converted to stack
457		if current.isStacked {
458			stackNode = current
459		}
460	}
461
462	if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
463		return false // Not in a stack or stack has only one pane
464	}
465
466	// Use the stackNode's activeStackIndex instead of searching for current pane
467	// This is more reliable than trying to match panes
468	currentIndex := stackNode.activeStackIndex
469	if currentIndex < 0 || currentIndex >= len(stackNode.stackedPanes) {
470		log.Printf("[workspace] navigateStack: invalid activeStackIndex=%d, resetting to 0", currentIndex)
471		currentIndex = 0
472		stackNode.activeStackIndex = 0
473	}
474
475	// Calculate new index based on direction
476	var newIndex int
477	switch direction {
478	case "up":
479		newIndex = currentIndex - 1
480		if newIndex < 0 {
481			newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
482		}
483	case "down":
484		newIndex = currentIndex + 1
485		if newIndex >= len(stackNode.stackedPanes) {
486			newIndex = 0 // Wrap to first
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 = "New Tab" // Fallback only when title is actually empty
528		}
529		pageURL = pane.pane.webView.GetCurrentURL()
530	} else {
531		titleText = "New Tab"
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		// TODO: Implement click handler with gtk.GestureClick
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	// TODO: Implement with gtk.GestureClick
614	// webkit.WidgetAttachClickHandler(newTitleBar, func() {
615	// 	spm.handleTitleBarClick(titleBarID)
616	// })
617
618	// Replace the old title bar in the stack
619	if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
620		// Find the correct insertion position based on the pane's index in the stack
621		paneIndex := -1
622		for i, stackedPane := range node.parent.stackedPanes {
623			if stackedPane == node {
624				paneIndex = i
625				break
626			}
627		}
628
629		// Remove old title bar
630		if node.titleBar != nil {
631			webkit.WidgetUnparent(node.titleBar)
632		}
633
634		// Update the node's title bar reference
635		spm.wm.setTitleBar(node, newTitleBar)
636
637		// Insert the new title bar at the correct position
638		if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
639			if paneIndex == 0 {
640				// First pane - insert at the beginning
641				box.Prepend(newTitleBar)
642			} else {
643				// Insert after the previous pane's container
644				prevPane := node.parent.stackedPanes[paneIndex-1]
645				if prevPane.container != nil {
646					box.InsertChildAfter(newTitleBar, prevPane.container)
647				}
648			}
649		}
650
651		log.Printf("[workspace] replaced title bar for pane %d: %p", paneIndex, node)
652	}
653}
654
655// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
656func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
657	titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
658	if titleBox == nil {
659		return nil
660	}
661
662	titleBox.AddCSSClass("stacked-pane-title")
663	titleBox.SetHExpand(true)
664	titleBox.SetVExpand(false)
665
666	placeholderLabel := gtk.NewLabel(faviconPlaceholder)
667	if placeholderLabel != nil {
668		placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
669		titleBox.Append(placeholderLabel)
670		webkit.WidgetSetVisible(placeholderLabel, true)
671	}
672
673	if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil {
674		spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) {
675			if err != nil {
676				log.Printf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err)
677				return
678			}
679
680			if texture != nil && titleBox != nil {
681				log.Printf("[favicon] Favicon ready to display in title bar for: %s", pageURL)
682
683				// Create favicon image
684				faviconImg := gtk.NewImageFromPaintable(texture)
685				faviconImg.SetPixelSize(16)
686				webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
687
688				// IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
689				// This handles the case where multiple async callbacks fire for the same title bar
690				if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil {
691					titleBox.Remove(placeholderLabel)
692				}
693
694				// Prepend favicon image (multiple calls are safe - GTK handles it)
695				titleBox.Prepend(faviconImg)
696				webkit.WidgetSetVisible(faviconImg, true)
697			}
698		})
699	}
700
701	// Create title label with the specified title
702	titleLabel := gtk.NewLabel(title)
703	if titleLabel == nil {
704		return titleBox
705	}
706
707	titleLabel.AddCSSClass("stacked-pane-title-text")
708	titleLabel.SetEllipsize(2)
709
710	titleBox.Append(titleLabel)
711	webkit.WidgetSetVisible(titleBox, true)
712	webkit.WidgetSetVisible(titleLabel, true)
713
714	return titleBox
715}
716
717// CloseStackedPane handles closing a pane that is part of a stack.
718// The logic mirrors the regular close path: we rebuild the tree, update focus,
719// and let WorkspaceManager perform the actual cleanup/destroy work so that all
720// bookkeeping stays consistent.
721func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
722	if node == nil || node.parent == nil || !node.parent.isStacked {
723		return errors.New("node is not part of a stacked pane")
724	}
725
726	stackNode := node.parent
727
728	// Locate the pane inside the stack slice.
729	nodeIndex := -1
730	for i, stackedPane := range stackNode.stackedPanes {
731		if stackedPane == node {
732			nodeIndex = i
733			break
734		}
735	}
736	if nodeIndex == -1 {
737		return errors.New("node not found in stack")
738	}
739
740	log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes))
741
742	// Detach hover/focus controllers before tearing down widgets to avoid GTK
743	// callbacks referencing freed memory during destruction.
744	if spm.wm != nil {
745		spm.wm.detachHover(node)
746		spm.wm.detachFocus(node)
747	}
748
749	// Remove the pane widgets from the stack container before mutating the tree.
750	stackBox := stackNode.stackWrapper
751	if stackBox == nil {
752		stackBox = stackNode.container
753	}
754	if box, ok := stackBox.(*gtk.Box); ok && box != nil {
755		if node.titleBar != nil {
756			box.Remove(node.titleBar)
757		}
758		if node.container != nil {
759			box.Remove(node.container)
760		}
761	}
762
763	// Remove the pane from the stack slice.
764	stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
765
766	switch len(stackNode.stackedPanes) {
767	case 0:
768		// Stack container is now empty: remove it from the layout entirely.
769		parent := stackNode.parent
770		if parent == nil {
771			// Stack was the root of the workspace — closing this pane exits the app.
772			if stackNode.container != nil {
773				if webkit.WidgetGetParent(stackNode.container) != nil {
774					webkit.WidgetUnparent(stackNode.container)
775				}
776			}
777			if spm.wm.window != nil {
778				spm.wm.window.SetChild(nil)
779			}
780			_, err := spm.wm.cleanupAndExit(node)
781			return err
782		}
783
784		// Detach the empty stack container from its GtkPaned parent.
785		if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
786			if parent.left == stackNode {
787				paned.SetStartChild(nil)
788			} else if parent.right == stackNode {
789				paned.SetEndChild(nil)
790			}
791			webkit.WidgetQueueAllocate(paned)
792		}
793		if stackNode.container != nil {
794			if webkit.WidgetGetParent(stackNode.container) != nil {
795				webkit.WidgetUnparent(stackNode.container)
796			}
797		}
798
799		sibling := spm.wm.getSibling(stackNode)
800		grandparent := parent.parent
801
802		spm.wm.promoteSibling(grandparent, parent, sibling)
803		spm.wm.swapContainers(grandparent, sibling)
804
805		generation := spm.wm.nextCleanupGeneration()
806		spm.wm.cleanupPane(node, generation)
807		spm.wm.decommissionParent(parent, generation)
808
809		if sibling != nil {
810			spm.wm.setFocusToLeaf(sibling)
811		} else {
812			spm.wm.updateMainPane()
813		}
814
815		stackNode.parent = nil
816		stackNode.container = nil
817		stackNode.stackWrapper = nil
818		stackNode.stackedPanes = nil
819
820		return nil
821
822	case 1:
823		// Promote the remaining pane back to a regular leaf node.
824		lastPane := stackNode.stackedPanes[0]
825		parent := stackNode.parent
826		lastPaneContainer := lastPane.container
827
828		if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
829			box.Remove(lastPane.titleBar)
830		}
831		if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
832			box.Remove(lastPaneContainer)
833		}
834		if stackNode.container != nil {
835			if webkit.WidgetGetParent(stackNode.container) != nil {
836				webkit.WidgetUnparent(stackNode.container)
837			}
838		}
839
840		if parent == nil {
841			spm.wm.root = lastPane
842			lastPane.parent = nil
843			if spm.wm.window != nil && lastPaneContainer != nil {
844				spm.wm.window.SetChild(lastPaneContainer)
845				webkit.WidgetQueueAllocate(lastPaneContainer)
846				webkit.WidgetShow(lastPaneContainer)
847			}
848		} else {
849			if parent.left == stackNode {
850				parent.left = lastPane
851			} else if parent.right == stackNode {
852				parent.right = lastPane
853			}
854			lastPane.parent = parent
855			if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil && lastPaneContainer != nil {
856				if parent.left == lastPane {
857					paned.SetStartChild(lastPaneContainer)
858				} else {
859					paned.SetEndChild(lastPaneContainer)
860				}
861				webkit.WidgetQueueAllocate(paned)
862			}
863			if lastPaneContainer != nil {
864				webkit.WidgetSetVisible(lastPaneContainer, true)
865			}
866		}
867
868		lastPane.isStacked = false
869		lastPane.stackedPanes = nil
870		lastPane.titleBar = nil
871		spm.wm.viewToNode[lastPane.pane.webView] = lastPane
872
873		generation := spm.wm.nextCleanupGeneration()
874		spm.wm.cleanupPane(node, generation)
875
876		spm.wm.SetActivePane(lastPane, SourceClose)
877		if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
878			webkit.WidgetShow(lastPaneWidget)
879			webkit.WidgetGrabFocus(lastPaneWidget)
880		}
881
882		stackNode.parent = nil
883		stackNode.container = nil
884		stackNode.stackWrapper = nil
885		stackNode.stackedPanes = nil
886
887		log.Printf("[workspace] converted stack back to regular pane")
888		return nil
889
890	default:
891		// Still multiple panes in stack — adjust active index and visibility.
892		if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
893			stackNode.activeStackIndex--
894		}
895		if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
896			stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
897		}
898
899		spm.UpdateStackVisibility(stackNode)
900		if spm.wm.GetActiveNode() == node {
901			newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
902			spm.wm.SetActivePane(newActivePane, SourceClose)
903		}
904
905		generation := spm.wm.nextCleanupGeneration()
906		spm.wm.cleanupPane(node, generation)
907
908		log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
909			len(stackNode.stackedPanes), stackNode.activeStackIndex)
910		return nil
911	}
912}
913
914// handleTitleBarClick handles clicks on title bars to switch the active pane
915func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uint64) {
916	pane, exists := spm.titleBarToPane[titleBarID]
917	if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
918		return
919	}
920
921	stackNode := pane.parent
922
923	// Find the index of the clicked pane
924	for i, p := range stackNode.stackedPanes {
925		if p == pane {
926			// Only switch if it's not already active
927			if i != stackNode.activeStackIndex {
928				log.Printf("[workspace] title bar clicked: switching from pane %d to pane %d",
929					stackNode.activeStackIndex, i)
930
931				// Update title bar for the pane transitioning from ACTIVE to INACTIVE
932				currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
933				if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
934					currentTitle := currentActivePane.pane.webView.GetTitle()
935					if currentTitle != "" {
936						spm.updateTitleBarLabel(currentActivePane, currentTitle)
937					}
938				}
939
940				stackNode.activeStackIndex = i
941				spm.UpdateStackVisibility(stackNode)
942				spm.wm.SetActivePane(pane, SourceStackNav)
943			}
944			return
945		}
946	}
947}

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