v0.12.0 d035193

stacked_panes.go RawHistoryFolder

1package browser
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"log"
8	"sync/atomic"
9	"time"
10
11	"github.com/bnema/dumber/internal/cache"
12	"github.com/bnema/dumber/pkg/webkit"
13	"github.com/diamondburned/gotk4/pkg/gtk/v4"
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 - remove it from window
311		if spm.wm.window != nil {
312			spm.wm.window.SetChild(nil)
313		}
314		// Unparent if it has a GTK parent
315		if target.container != nil {
316			if webkit.WidgetGetParent(target.container) != nil {
317				webkit.WidgetUnparent(target.container)
318			}
319		}
320	} else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
321		// Target has a parent paned - remove it (automatically unparents in GTK4)
322		if parent.left == target {
323			paned.SetStartChild(nil)
324		} else if parent.right == target {
325			paned.SetEndChild(nil)
326		}
327	}
328}
329
330// reattachToParent attaches a container to the parent
331func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
332	if parent == nil {
333		if spm.wm.window != nil {
334			spm.wm.window.SetChild(container)
335		}
336	} else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
337		if parent.left == target {
338			paned.SetStartChild(container)
339		} else if parent.right == target {
340			paned.SetEndChild(container)
341		}
342	}
343}
344
345// finalizeStackCreation completes the stack creation process
346func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNode, insertIndex int) (*paneNode, error) {
347	// Update workspace state first
348	spm.wm.viewToNode[newLeaf.pane.webView] = newLeaf
349	spm.wm.ensureHover(newLeaf)
350	spm.wm.app.panes = append(spm.wm.app.panes, newLeaf.pane)
351	if newLeaf.pane.zoomController != nil {
352		newLeaf.pane.zoomController.ApplyInitialZoom()
353	}
354
355	// Mark stack operation timestamp to prevent focus conflicts
356	spm.wm.lastStackOperation = time.Now()
357
358	// This ensures Zellij-style layout where the previously active pane shows its current title
359	currentActiveIndex := stackNode.activeStackIndex
360	if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
361		currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
362		if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
363			currentTitle := currentlyActivePane.pane.webView.GetTitle()
364			if currentTitle != "" {
365				spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
366				log.Printf("[workspace] updated title bar for pane becoming inactive during stack creation: %s", currentTitle)
367			}
368		}
369	}
370
371	// Immediately switch to the new pane (don't use deprecated IdleAdd)
372	stackNode.activeStackIndex = insertIndex
373	spm.UpdateStackVisibility(stackNode)
374
375	// Set focus on the new pane synchronously
376	spm.wm.SetActivePane(newLeaf, SourceSplit)
377
378	log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
379		stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)
380	return newLeaf, nil
381}
382
383// UpdateStackVisibility updates the visibility of panes in a stack
384func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
385	log.Printf("[workspace] updateStackVisibility called: stackNode=%p", stackNode)
386
387	if stackNode == nil {
388		log.Printf("[workspace] updateStackVisibility aborted: stackNode is nil")
389		return
390	}
391
392	if !stackNode.isStacked {
393		log.Printf("[workspace] updateStackVisibility aborted: stackNode.isStacked=%v", stackNode.isStacked)
394		return
395	}
396
397	if len(stackNode.stackedPanes) == 0 {
398		log.Printf("[workspace] updateStackVisibility aborted: stackedPanes empty, len=%d", len(stackNode.stackedPanes))
399		return
400	}
401
402	activeIndex := stackNode.activeStackIndex
403	if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
404		log.Printf("[workspace] updateStackVisibility: correcting activeIndex from %d to 0", activeIndex)
405		activeIndex = 0
406		stackNode.activeStackIndex = activeIndex
407	}
408
409	log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes))
410
411	// CRITICAL: Process ALL panes in a single pass to prevent flickering
412	for i, pane := range stackNode.stackedPanes {
413		if i == activeIndex {
414			// Active pane: show container, NEVER show title bar
415			if pane.container != nil {
416				webkit.WidgetSetVisible(pane.container, true)
417			}
418			if pane.titleBar != nil {
419				webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
420			}
421			log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
422		} else {
423			// Inactive panes: hide container, show title bar
424			if pane.container != nil {
425				webkit.WidgetSetVisible(pane.container, false)
426			}
427			if pane.titleBar != nil {
428				webkit.WidgetSetVisible(pane.titleBar, true)
429
430				// Refresh title bar to pick up any newly downloaded favicons
431				if pane.pane != nil && pane.pane.webView != nil {
432					title := pane.pane.webView.GetTitle()
433					if title == "" {
434						title = "New Tab"
435					}
436					spm.updateTitleBarLabel(pane, title)
437				}
438			}
439		}
440	}
441}
442
443// NavigateStack handles navigation within a stacked pane container
444func (spm *StackedPaneManager) NavigateStack(direction string) bool {
445	if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
446		return false
447	}
448
449	// Find the stack container this pane belongs to
450	var stackNode *paneNode
451	current := spm.wm.GetActiveNode()
452
453	// Check if current pane is directly in a stack
454	if current.parent != nil && current.parent.isStacked {
455		stackNode = current.parent
456	} else {
457		// Current pane might be the stack container itself if it was the first pane converted to stack
458		if current.isStacked {
459			stackNode = current
460		}
461	}
462
463	if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
464		return false // Not in a stack or stack has only one pane
465	}
466
467	// Use the stackNode's activeStackIndex instead of searching for current pane
468	// This is more reliable than trying to match panes
469	currentIndex := stackNode.activeStackIndex
470	if currentIndex < 0 || currentIndex >= len(stackNode.stackedPanes) {
471		log.Printf("[workspace] navigateStack: invalid activeStackIndex=%d, resetting to 0", currentIndex)
472		currentIndex = 0
473		stackNode.activeStackIndex = 0
474	}
475
476	// Calculate new index based on direction
477	var newIndex int
478	switch direction {
479	case "up":
480		newIndex = currentIndex - 1
481		if newIndex < 0 {
482			newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
483		}
484	case "down":
485		newIndex = currentIndex + 1
486		if newIndex >= len(stackNode.stackedPanes) {
487			newIndex = 0 // Wrap to first
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 = "New Tab" // Fallback only when title is actually empty
529		}
530		pageURL = pane.pane.webView.GetCurrentURL()
531	} else {
532		titleText = "New Tab"
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		// TODO: Implement click handler with gtk.GestureClick
543		// webkit.WidgetAttachClickHandler(titleBar, func() {
544		// 	spm.handleTitleBarClick(titleBarID)
545		// })
546	}
547
548	return titleBar
549}
550
551// UpdateTitleBar updates the title bar label for a WebView in stacked panes
552func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title string) {
553	if spm.wm == nil || webView == nil || title == "" {
554		return
555	}
556
557	// Find the pane node for this WebView
558	node, exists := spm.wm.viewToNode[webView]
559	if !exists || node == nil || !node.isLeaf {
560		return
561	}
562
563	// Check if this pane is in a stack and has a title bar
564	if node.parent != nil && node.parent.isStacked && node.titleBar != nil {
565		// CRITICAL: Only update title bar for INACTIVE panes
566		// Active panes should never show their title bar
567		stackNode := node.parent
568		activeIndex := stackNode.activeStackIndex
569
570		// Find this pane's index in the stack
571		paneIndex := -1
572		for i, pane := range stackNode.stackedPanes {
573			if pane == node {
574				paneIndex = i
575				break
576			}
577		}
578
579		if paneIndex != -1 && paneIndex != activeIndex {
580			// This is an INACTIVE pane - safe to update title bar
581			spm.updateTitleBarLabel(node, title)
582			log.Printf("[workspace] updated title bar for INACTIVE WebView %d: %s", webView.ID(), title)
583		} else {
584			// This is the ACTIVE pane - title bar should remain hidden
585			log.Printf("[workspace] skipped title bar update for ACTIVE WebView %d: %s", webView.ID(), title)
586		}
587	}
588}
589
590// updateTitleBarLabel updates the label widget within a title bar by recreating it
591func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {
592	if node == nil || node.titleBar == nil || node.parent == nil || !node.parent.isStacked {
593		return
594	}
595
596	// Get the current URL from the WebView for favicon
597	var pageURL string
598	if node.pane != nil && node.pane.webView != nil {
599		pageURL = node.pane.webView.GetCurrentURL()
600	}
601
602	// Create new title bar with updated title and favicon
603	newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
604	if newTitleBar == nil {
605		log.Printf("[workspace] failed to create new title bar")
606		return
607	}
608
609	// Store the mapping from titleBar ID to pane and attach click handler
610	titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
611	spm.titleBarToPane[titleBarID] = node
612
613	// Attach click handler
614	// TODO: Implement with gtk.GestureClick
615	// webkit.WidgetAttachClickHandler(newTitleBar, func() {
616	// 	spm.handleTitleBarClick(titleBarID)
617	// })
618
619	// Replace the old title bar in the stack
620	if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
621		// Find the correct insertion position based on the pane's index in the stack
622		paneIndex := -1
623		for i, stackedPane := range node.parent.stackedPanes {
624			if stackedPane == node {
625				paneIndex = i
626				break
627			}
628		}
629
630		// Remove old title bar
631		if node.titleBar != nil {
632			webkit.WidgetUnparent(node.titleBar)
633		}
634
635		// Update the node's title bar reference
636		spm.wm.setTitleBar(node, newTitleBar)
637
638		// Insert the new title bar at the correct position
639		if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
640			if paneIndex == 0 {
641				// First pane - insert at the beginning
642				box.Prepend(newTitleBar)
643			} else {
644				// Insert after the previous pane's container
645				prevPane := node.parent.stackedPanes[paneIndex-1]
646				if prevPane.container != nil {
647					box.InsertChildAfter(newTitleBar, prevPane.container)
648				}
649			}
650		}
651
652		log.Printf("[workspace] replaced title bar for pane %d: %p", paneIndex, node)
653	}
654}
655
656// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
657func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
658	titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
659	if titleBox == nil {
660		return nil
661	}
662
663	titleBox.AddCSSClass("stacked-pane-title")
664	titleBox.SetHExpand(true)
665	titleBox.SetVExpand(false)
666
667	// Add favicon placeholder for debugging (always visible)
668	var faviconImg gtk.Widgetter
669	var debugReason string
670
671	if pageURL == "" {
672		debugReason = "empty pageURL"
673	} else if spm.wm == nil {
674		debugReason = "nil workspace manager"
675	} else if spm.wm.app == nil {
676		debugReason = "nil app"
677	} else if spm.wm.app.queries == nil {
678		debugReason = "nil queries"
679	} else {
680		// Query database for favicon URL
681		ctx := context.Background()
682		entry, err := spm.wm.app.queries.GetHistoryEntry(ctx, pageURL)
683		if err != nil {
684			debugReason = fmt.Sprintf("DB query failed: %v", err)
685			log.Printf("[favicon] Failed to get history entry for %s: %v", pageURL, err)
686		} else if !entry.FaviconUrl.Valid || entry.FaviconUrl.String == "" {
687			debugReason = "no favicon URL in DB"
688			log.Printf("[favicon] No favicon URL in DB for %s", pageURL)
689		} else {
690			faviconURL := entry.FaviconUrl.String
691			log.Printf("[favicon] Found favicon URL in DB for %s: %s", pageURL, faviconURL)
692
693			// Use favicon cache with favicon URL (same as dmenu)
694			faviconCache, err := cache.NewFaviconCache()
695			if err != nil {
696				debugReason = fmt.Sprintf("cache init failed: %v", err)
697				log.Printf("[favicon] Failed to create cache: %v", err)
698			} else {
699				faviconPath := faviconCache.GetCachedPath(faviconURL)
700				if faviconPath != "" {
701					log.Printf("[favicon] Loading cached favicon from: %s", faviconPath)
702					faviconImg = gtk.NewImageFromFile(faviconPath)
703					if faviconImg != nil {
704						if img, ok := faviconImg.(*gtk.Image); ok {
705							img.SetPixelSize(16)
706						}
707						webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
708						log.Printf("[favicon] Successfully created favicon image widget")
709					} else {
710						debugReason = "image widget creation failed"
711						log.Printf("[favicon] Failed to create image widget from %s", faviconPath)
712					}
713				} else {
714					debugReason = "not cached yet"
715					log.Printf("[favicon] Favicon not cached yet, starting download: %s", faviconURL)
716					// Start async download for next time (same as dmenu)
717					faviconCache.CacheAsync(faviconURL)
718				}
719			}
720		}
721	}
722
723	// Add favicon or placeholder
724	if faviconImg != nil {
725		titleBox.Append(faviconImg)
726		webkit.WidgetShow(faviconImg)
727	} else {
728		// DEBUG: Add visible placeholder label to show code is running
729		placeholderLabel := gtk.NewLabel(faviconPlaceholder)
730		if placeholderLabel != nil {
731			placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
732			titleBox.Append(placeholderLabel)
733			placeholderLabel.Show()
734			log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason)
735		}
736	}
737
738	// Create title label with the specified title
739	titleLabel := gtk.NewLabel(title)
740	if titleLabel == nil {
741		return titleBox
742	}
743
744	titleLabel.AddCSSClass("stacked-pane-title-text")
745	titleLabel.SetEllipsize(2) // pango.EllipsizeEnd = 2
746
747	titleBox.Append(titleLabel)
748	titleBox.Show()
749	titleLabel.Show()
750
751	return titleBox
752}
753
754// CloseStackedPane handles closing a pane that is part of a stack.
755// The logic mirrors the regular close path: we rebuild the tree, update focus,
756// and let WorkspaceManager perform the actual cleanup/destroy work so that all
757// bookkeeping stays consistent.
758func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
759	if node == nil || node.parent == nil || !node.parent.isStacked {
760		return errors.New("node is not part of a stacked pane")
761	}
762
763	stackNode := node.parent
764
765	// Locate the pane inside the stack slice.
766	nodeIndex := -1
767	for i, stackedPane := range stackNode.stackedPanes {
768		if stackedPane == node {
769			nodeIndex = i
770			break
771		}
772	}
773	if nodeIndex == -1 {
774		return errors.New("node not found in stack")
775	}
776
777	log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes))
778
779	// Detach hover/focus controllers before tearing down widgets to avoid GTK
780	// callbacks referencing freed memory during destruction.
781	if spm.wm != nil {
782		spm.wm.detachHover(node)
783		spm.wm.detachFocus(node)
784	}
785
786	// Remove the pane widgets from the stack container before mutating the tree.
787	stackBox := stackNode.stackWrapper
788	if stackBox == nil {
789		stackBox = stackNode.container
790	}
791	if box, ok := stackBox.(*gtk.Box); ok && box != nil {
792		if node.titleBar != nil {
793			box.Remove(node.titleBar)
794		}
795		if node.container != nil {
796			box.Remove(node.container)
797		}
798	}
799
800	// Remove the pane from the stack slice.
801	stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
802
803	switch len(stackNode.stackedPanes) {
804	case 0:
805		// Stack container is now empty: remove it from the layout entirely.
806		parent := stackNode.parent
807		if parent == nil {
808			// Stack was the root of the workspace — closing this pane exits the app.
809			if stackNode.container != nil {
810				if webkit.WidgetGetParent(stackNode.container) != nil {
811					webkit.WidgetUnparent(stackNode.container)
812				}
813			}
814			if spm.wm.window != nil {
815				spm.wm.window.SetChild(nil)
816			}
817			_, err := spm.wm.cleanupAndExit(node)
818			return err
819		}
820
821		// Detach the empty stack container from its GtkPaned parent.
822		if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
823			if parent.left == stackNode {
824				paned.SetStartChild(nil)
825			} else if parent.right == stackNode {
826				paned.SetEndChild(nil)
827			}
828			webkit.WidgetQueueAllocate(paned)
829		}
830		if stackNode.container != nil {
831			if webkit.WidgetGetParent(stackNode.container) != nil {
832				webkit.WidgetUnparent(stackNode.container)
833			}
834		}
835
836		sibling := spm.wm.getSibling(stackNode)
837		grandparent := parent.parent
838
839		spm.wm.promoteSibling(grandparent, parent, sibling)
840		spm.wm.swapContainers(grandparent, sibling)
841
842		generation := spm.wm.nextCleanupGeneration()
843		spm.wm.cleanupPane(node, generation)
844		spm.wm.decommissionParent(parent, generation)
845
846		if sibling != nil {
847			spm.wm.setFocusToLeaf(sibling)
848		} else {
849			spm.wm.updateMainPane()
850		}
851
852		stackNode.parent = nil
853		stackNode.container = nil
854		stackNode.stackWrapper = nil
855		stackNode.stackedPanes = nil
856
857		return nil
858
859	case 1:
860		// Promote the remaining pane back to a regular leaf node.
861		lastPane := stackNode.stackedPanes[0]
862		parent := stackNode.parent
863		lastPaneContainer := lastPane.container
864
865		if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
866			box.Remove(lastPane.titleBar)
867		}
868		if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
869			box.Remove(lastPaneContainer)
870		}
871		if stackNode.container != nil {
872			if webkit.WidgetGetParent(stackNode.container) != nil {
873				webkit.WidgetUnparent(stackNode.container)
874			}
875		}
876
877		if parent == nil {
878			spm.wm.root = lastPane
879			lastPane.parent = nil
880			if spm.wm.window != nil && lastPaneContainer != nil {
881				spm.wm.window.SetChild(lastPaneContainer)
882				webkit.WidgetQueueAllocate(lastPaneContainer)
883				webkit.WidgetShow(lastPaneContainer)
884			}
885		} else {
886			if parent.left == stackNode {
887				parent.left = lastPane
888			} else if parent.right == stackNode {
889				parent.right = lastPane
890			}
891			lastPane.parent = parent
892			if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil && lastPaneContainer != nil {
893				if parent.left == lastPane {
894					paned.SetStartChild(lastPaneContainer)
895				} else {
896					paned.SetEndChild(lastPaneContainer)
897				}
898				webkit.WidgetQueueAllocate(paned)
899			}
900			if lastPaneContainer != nil {
901				webkit.WidgetSetVisible(lastPaneContainer, true)
902			}
903		}
904
905		lastPane.isStacked = false
906		lastPane.stackedPanes = nil
907		lastPane.titleBar = nil
908		spm.wm.viewToNode[lastPane.pane.webView] = lastPane
909
910		generation := spm.wm.nextCleanupGeneration()
911		spm.wm.cleanupPane(node, generation)
912
913		spm.wm.SetActivePane(lastPane, SourceClose)
914		if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
915			webkit.WidgetShow(lastPaneWidget)
916			webkit.WidgetGrabFocus(lastPaneWidget)
917		}
918
919		stackNode.parent = nil
920		stackNode.container = nil
921		stackNode.stackWrapper = nil
922		stackNode.stackedPanes = nil
923
924		log.Printf("[workspace] converted stack back to regular pane")
925		return nil
926
927	default:
928		// Still multiple panes in stack — adjust active index and visibility.
929		if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
930			stackNode.activeStackIndex--
931		}
932		if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
933			stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
934		}
935
936		spm.UpdateStackVisibility(stackNode)
937		if spm.wm.GetActiveNode() == node {
938			newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
939			spm.wm.SetActivePane(newActivePane, SourceClose)
940		}
941
942		generation := spm.wm.nextCleanupGeneration()
943		spm.wm.cleanupPane(node, generation)
944
945		log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
946			len(stackNode.stackedPanes), stackNode.activeStackIndex)
947		return nil
948	}
949}
950
951// handleTitleBarClick handles clicks on title bars to switch the active pane
952func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uint64) {
953	pane, exists := spm.titleBarToPane[titleBarID]
954	if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
955		return
956	}
957
958	stackNode := pane.parent
959
960	// Find the index of the clicked pane
961	for i, p := range stackNode.stackedPanes {
962		if p == pane {
963			// Only switch if it's not already active
964			if i != stackNode.activeStackIndex {
965				log.Printf("[workspace] title bar clicked: switching from pane %d to pane %d",
966					stackNode.activeStackIndex, i)
967
968				// Update title bar for the pane transitioning from ACTIVE to INACTIVE
969				currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
970				if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
971					currentTitle := currentActivePane.pane.webView.GetTitle()
972					if currentTitle != "" {
973						spm.updateTitleBarLabel(currentActivePane, currentTitle)
974					}
975				}
976
977				stackNode.activeStackIndex = i
978				spm.UpdateStackVisibility(stackNode)
979				spm.wm.SetActivePane(pane, SourceStackNav)
980			}
981			return
982		}
983	}
984}

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