v0.11.0 dc50343

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

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