v0.14.1 0da30ba

workspace_manager.go RawHistoryFolder

1// workspace_manager.go - Core workspace management and coordination
2package browser
3
4import (
5	"errors"
6	"fmt"
7	"log"
8	"strings"
9	"sync"
10	"sync/atomic"
11	"time"
12
13	"github.com/bnema/dumber/internal/app/messaging"
14	"github.com/bnema/dumber/pkg/webkit"
15	"github.com/diamondburned/gotk4/pkg/glib/v2"
16	"github.com/diamondburned/gotk4/pkg/gtk/v4"
17)
18
19// WorkspaceManager coordinates Zellij-style pane operations.
20type WorkspaceManager struct {
21	app             *BrowserApp
22	window          *webkit.Window
23	root            *paneNode
24	mainPane        *paneNode
25	viewToNode      map[*webkit.WebView]*paneNode
26	lastSplitMsg    map[*webkit.WebView]time.Time
27	lastExitMsg     map[*webkit.WebView]time.Time
28	paneModeActive  bool
29	splitting       int32 // atomic: 0=false, 1=true
30	cssInitialized  bool
31	createWebViewFn func() (*webkit.WebView, error)
32	createPaneFn    func(*webkit.WebView) (*BrowserPane, error)
33
34	// Coordination fields for preventing duplicate events
35	paneModeSource     *webkit.WebView // Which webview initiated pane mode
36	paneModeActivePane *paneNode       // Which pane node has pane mode active
37	paneModeContainer  gtk.Widgetter   // The container widget that has margin applied
38	paneMutex          sync.Mutex      // Protects pane mode state
39
40	// Focus throttling fields to prevent infinite loops
41	lastFocusChange    time.Time  // When focus was last changed
42	focusThrottleMutex sync.Mutex // Protects focus throttling state
43
44	// Stack operation timing to prevent focus conflicts
45	lastStackOperation time.Time // When a stack operation was last performed
46
47	// NEW: Pane creation deduplicator
48	// Popups waiting for ready-to-show lifecycle callback
49	pendingPopups map[uint64]*pendingPopup
50
51	// Specialized managers for different pane operations
52	stackedPaneManager *StackedPaneManager
53	focusStateMachine  *FocusStateMachine
54
55	// Focus debouncing to prevent rapid oscillation from any source
56	lastFocusTime   time.Time
57	lastFocusTarget *paneNode
58	focusDebounce   time.Duration
59
60	// Validation and safety systems (debug-only, controlled by DUMBER_DEBUG_WORKSPACE env)
61	treeValidator         *TreeValidator         // Debug-only tree validation
62	treeRebalancer        *TreeRebalancer        // Rebalance after close operations
63	geometryValidator     *GeometryValidator     // Validate split constraints
64	stackLifecycleManager *StackLifecycleManager // Stack pane lifecycle
65
66	// Debug instrumentation helpers
67	debugLevel     DebugLevel
68	debugPaneClose bool
69	diagnostics    *WorkspaceDiagnostics
70
71	// Cleanup tracking
72	cleanupCounter uint
73	pendingIdle    map[uintptr][]*paneNode
74}
75
76// resolveWindow ensures the workspace has a reference to the GTK window.
77func (wm *WorkspaceManager) resolveWindow() *webkit.Window {
78	if wm == nil {
79		return nil
80	}
81
82	if wm.window != nil {
83		return wm.window
84	}
85
86	if wm.app != nil {
87		if wm.app.tabManager != nil {
88			if win := wm.app.tabManager.GetWindow(); win != nil {
89				wm.window = win
90				return win
91			}
92		}
93		if wm.app.webView != nil {
94			if win := wm.app.webView.Window(); win != nil {
95				wm.window = win
96				return win
97			}
98		}
99	}
100
101	return nil
102}
103
104// Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
105
106// NewWorkspaceManager builds a workspace manager rooted at the provided pane.
107func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
108	debugLevel := getDebugLevel()
109
110	manager := &WorkspaceManager{
111		app:           app,
112		window:        rootPane.webView.Window(),
113		viewToNode:    make(map[*webkit.WebView]*paneNode),
114		lastSplitMsg:  make(map[*webkit.WebView]time.Time),
115		lastExitMsg:   make(map[*webkit.WebView]time.Time),
116		pendingPopups: make(map[uint64]*pendingPopup),
117		focusDebounce: 150 * time.Millisecond,
118		debugLevel:    debugLevel,
119	}
120
121	// Initialize validation components (opt-in based on debug level)
122	if debugLevel >= DebugBasic {
123		manager.treeValidator = NewTreeValidator(true, debugLevel == DebugFull)
124		manager.geometryValidator = NewGeometryValidator()
125		manager.geometryValidator.SetDebugMode(debugLevel == DebugFull)
126	} else {
127		// Production mode: disable validators
128		manager.treeValidator = NewTreeValidator(false, false)
129		manager.geometryValidator = NewGeometryValidator()
130	}
131
132	// Initialize tree rebalancer (always needed for proper close operations)
133	manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator)
134
135	// Initialize stack lifecycle manager
136	manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator)
137
138	// Initialize existing specialized managers (now enhanced with bulletproof components)
139	manager.stackedPaneManager = NewStackedPaneManager(manager)
140	manager.focusStateMachine = NewFocusStateMachine(manager)
141	manager.createWebViewFn = func() (*webkit.WebView, error) {
142		if manager.app == nil {
143			return nil, errors.New("workspace manager missing app reference")
144		}
145		cfg, err := manager.app.buildWebkitConfig()
146		if err != nil {
147			return nil, err
148		}
149		// CRITICAL: Pane WebViews are embedded inside the workspace paned containers,
150		// so they do not need their own toplevel GtkWindow. Setting CreateWindow=false
151		// prevents GTK "widget already has parent" errors when adding to paned containers.
152		cfg.CreateWindow = false
153		return webkit.NewWebView(cfg)
154	}
155	manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
156		if manager.app == nil {
157			return nil, errors.New("workspace manager missing app reference")
158		}
159		return manager.app.createPaneForView(view)
160	}
161	manager.ensureWorkspaceStyles()
162
163	rootContainer := rootPane.webView.RootWidget()
164	root := &paneNode{
165		pane:   rootPane,
166		isLeaf: true,
167	}
168	// Initialize widgets properly
169	manager.initializePaneWidgets(root, rootContainer)
170
171	manager.root = root
172	manager.mainPane = root
173	manager.viewToNode[rootPane.webView] = root
174	manager.setupPopupHandling(rootPane.webView, root)
175	manager.ensureHover(root)
176
177	if handler := rootPane.MessageHandler(); handler != nil {
178		handler.SetWorkspaceObserver(manager)
179	}
180
181	// CRITICAL: Register navigation handler (including pane mode) for the root pane
182	// This must happen here because when creating the first tab, app.workspace is nil
183	// in createPaneForView, so it doesn't register the handler
184	manager.RegisterNavigationHandler(rootPane.webView)
185
186	// NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager.
187	// TabManager sets app.workspace during tab switching (including initial tab creation).
188	// This prevents workspaces from interfering with each other when multiple tabs exist.
189
190	// Configure debug settings from app config
191	if app.config != nil {
192		manager.focusStateMachine.ConfigureDebug(
193			app.config.Debug.EnableFocusDebug,
194			false, // CSS debug removed
195			app.config.Debug.EnableFocusMetrics,
196		)
197
198		manager.debugPaneClose = app.config.Debug.EnablePaneCloseDebug
199	}
200
201	manager.diagnostics = NewWorkspaceDiagnostics(manager.debugPaneClose)
202
203	// Initialize focus state machine after all setup is complete
204	if err := manager.focusStateMachine.Initialize(); err != nil {
205		log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
206	}
207
208	// Ensure hover controllers are attached to all panes for mouse-driven focus changes
209	manager.attachHoverHandlersToAllPanes()
210
211	if manager.debugPaneClose {
212		manager.dumpTreeState("workspace_init")
213	}
214
215	return manager
216}
217
218// shouldDebounce checks if an operation should be debounced based on timing
219func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool {
220	if last, ok := wm.lastSplitMsg[source]; ok {
221		if elapsed := time.Since(last); elapsed < threshold {
222			log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000)
223			return true
224		}
225	}
226	wm.lastSplitMsg[source] = time.Now()
227	return false
228}
229
230// withSplitLock executes an operation with atomic splitting lock protection
231func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error {
232	if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
233		log.Printf("[workspace] %s ignored: operation in progress", operation)
234		return fmt.Errorf("operation in progress")
235	}
236
237	defer func() {
238		atomic.StoreInt32(&wm.splitting, 0)
239		if r := recover(); r != nil {
240			log.Printf("[workspace] %s panicked: %v", operation, r)
241			panic(r)
242		}
243	}()
244
245	return fn()
246}
247
248// cleanupPopupStorage removes localStorage entries for a closed popup
249func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) {
250	if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" {
251		return
252	}
253
254	requestID := targetNode.requestID
255	parentWebViewID := parentNode.pane.WebView().ID()
256
257	script := fmt.Sprintf(`
258		try {
259			localStorage.removeItem('popup_mapping_%d');
260			const keys = ['popup_%s_parent_info', 'popup_%s_parent_action',
261			             'popup_%s_message_to_popup', 'popup_%s_message_to_parent',
262			             'oauth_callback_%s'];
263			keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} });
264			console.log('[workspace] Cleaned popup localStorage');
265		} catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); }
266	`, parentWebViewID, requestID, requestID, requestID, requestID, webviewID)
267
268	if err := parentNode.pane.WebView().InjectScript(script); err != nil {
269		log.Printf("[workspace] localStorage cleanup failed: %v", err)
270	} else {
271		log.Printf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID)
272	}
273}
274
275// OnWorkspaceMessage implements messaging.WorkspaceObserver.
276func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
277	node := wm.viewToNode[source]
278
279	if msg.WebViewID != "" {
280		if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil {
281			if explicitNode.pane != nil && explicitNode.pane.webView != nil {
282				node = explicitNode
283				source = explicitNode.pane.webView
284			}
285		}
286	}
287
288	if node == nil {
289		log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID)
290		return
291	}
292
293	switch event := strings.ToLower(msg.Event); event {
294	case "pane-split":
295		wm.SetActivePane(node, SourceProgrammatic)
296		direction := strings.ToLower(msg.Direction)
297		if direction == "" {
298			direction = DirectionRight
299		}
300
301		if wm.shouldDebounce(source, 200*time.Millisecond) {
302			return
303		}
304
305		err := wm.withSplitLock("split", func() error {
306			newNode, err := wm.SplitPane(node, direction)
307			if err != nil {
308				if glib.MainContextDefault().IsOwner() {
309					glib.MainContextDefault().Iteration(false)
310				}
311				return err
312			}
313			wm.clonePaneState(node, newNode)
314			return nil
315		})
316
317		if err != nil {
318			log.Printf("[workspace] split failed: %v", err)
319		}
320
321	case "pane-stack":
322		wm.SetActivePane(node, SourceProgrammatic)
323
324		if wm.shouldDebounce(source, 200*time.Millisecond) {
325			return
326		}
327
328		err := wm.withSplitLock("stack", func() error {
329			newNode, err := wm.stackedPaneManager.StackPane(node)
330			if err != nil {
331				if glib.MainContextDefault().IsOwner() {
332					glib.MainContextDefault().Iteration(false)
333				}
334				return err
335			}
336			wm.clonePaneState(node, newNode)
337			return nil
338		})
339
340		if err != nil {
341			log.Printf("[workspace] stack failed: %v", err)
342		}
343
344	case "close-popup":
345		if msg.WebViewID == "" {
346			log.Printf("[workspace] close-popup: empty webviewId, ignoring")
347			return
348		}
349
350		// Find popup pane by webview ID
351		var targetNode *paneNode
352		for webView, node := range wm.viewToNode {
353			if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID {
354				targetNode = node
355				break
356			}
357		}
358
359		if targetNode == nil {
360			log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
361			return
362		}
363
364		if !targetNode.isPopup {
365			log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
366			return
367		}
368
369		log.Printf("[workspace] Closing popup due to %s", msg.Reason)
370
371		// Remove from parent's active popup list
372		if targetNode.parentPane != nil {
373			parentNode := targetNode.parentPane
374			for i, childID := range parentNode.activePopupChildren {
375				if childID == msg.WebViewID {
376					parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
377					log.Printf("[workspace] Removed popup from parent (remaining: %d)", len(parentNode.activePopupChildren))
378					break
379				}
380			}
381
382			wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID)
383		}
384
385		if err := wm.ClosePane(targetNode); err != nil {
386			log.Printf("[workspace] Failed to close popup: %v", err)
387		}
388
389	default:
390		log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
391	}
392}
393
394func (wm *WorkspaceManager) findNodeByWebViewID(id string) *paneNode {
395	if wm == nil || id == "" {
396		return nil
397	}
398
399	for webView, node := range wm.viewToNode {
400		if webView != nil && webView.IDString() == id {
401			return node
402		}
403	}
404	return nil
405}
406
407// GetActiveNode returns the currently active pane node
408func (wm *WorkspaceManager) GetActiveNode() *paneNode {
409	if wm == nil || wm.focusStateMachine == nil {
410		return nil
411	}
412	return wm.focusStateMachine.GetActivePane()
413}
414
415// SetActivePane requests focus change through the focus state machine with debouncing
416func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
417	now := time.Now()
418
419	// Check if we should debounce this focus request (except for urgent system events)
420	if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
421		// Too soon and same target - ignore this request to prevent rapid oscillation
422		return
423	}
424
425	// Update tracking and proceed with focus change
426	wm.lastFocusTime = now
427	wm.lastFocusTarget = node
428
429	if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
430		log.Printf("[workspace] Focus request failed: %v", err)
431	}
432}
433
434// GetNodeForWebView returns the pane node associated with a WebView
435func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
436	return wm.viewToNode[webView]
437}
438
439// RegisterNavigationHandler sets up navigation handling for a webview
440func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
441	if webView == nil {
442		return
443	}
444
445	// Register workspace navigation callback for alt+arrow pane navigation
446	webView.RegisterWorkspaceNavigationHandler(func(direction string) bool {
447		if wm == nil {
448			return false
449		}
450		return wm.FocusNeighbor(direction)
451	})
452
453	// Register pane mode callbacks for Ctrl+P and pane mode action keys
454	webView.RegisterPaneModeHandler(func(action string) bool {
455		if wm == nil {
456			return false
457		}
458
459		if action == "enter" {
460			// CRITICAL FIX: Use app.workspace instead of closure's wm
461			// This ensures Ctrl+P always targets the ACTIVE tab's workspace
462			if wm.app != nil && wm.app.workspace != nil {
463				wm.app.workspace.EnterPaneMode()
464				return true
465			}
466			return false
467		}
468
469		if action == "exit" {
470			// Check if pane mode is active
471			wm.paneMutex.Lock()
472			wasActive := wm.paneModeActive
473			wm.paneMutex.Unlock()
474
475			if wasActive {
476				wm.ExitPaneMode("escape")
477				return true // Mode was active, we handled it
478			}
479			return false // No mode active, pass through to DOM
480		}
481
482		// For other actions, use active workspace (same pattern as "enter")
483		// This ensures action keys work on the correct tab
484		if wm.app != nil && wm.app.workspace != nil {
485			// Check if pane mode is active on the active workspace
486			activeWs := wm.app.workspace
487			activeWs.paneMutex.Lock()
488			isActive := activeWs.paneModeActive
489			activeWs.paneMutex.Unlock()
490
491			if !isActive {
492				return false // Not in pane mode, allow normal behavior
493			}
494
495			// Pane mode is active, handle the action on active workspace
496			activeWs.HandlePaneAction(action)
497			return true
498		}
499		return false
500	}, func() bool {
501		// Check if pane mode is currently active
502		wm.paneMutex.Lock()
503		defer wm.paneMutex.Unlock()
504		return wm.paneModeActive
505	})
506
507	// Register middle-click link handler to open links in new panes
508	webView.RegisterMiddleClickLinkHandler(func(linkURL string) bool {
509		if wm == nil {
510			return false
511		}
512
513		log.Printf("[workspace] Middle-click/Ctrl+click on link, opening in new pane: %s", linkURL)
514
515		// Get the node for this webview
516		node := wm.GetNodeForWebView(webView)
517		if node == nil {
518			log.Printf("[workspace] Cannot find node for middle-clicked link")
519			return false
520		}
521
522		// Get behavior from BlankTargetBehavior config
523		behavior := "stacked" // Default
524		if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.BlankTargetBehavior != "" {
525			behavior = strings.ToLower(wm.app.config.Workspace.Popups.BlankTargetBehavior)
526		}
527
528		var newNode *paneNode
529		var err error
530
531		switch behavior {
532		case "stacked":
533			// Stack the current pane - this also creates a new pane in the stack
534			log.Printf("[workspace] Using stacked behavior for gesture link")
535			newNode, err = wm.stackedPaneManager.StackPane(node)
536			if err != nil {
537				log.Printf("[workspace] Failed to stack pane for gesture link: %v", err)
538				return false
539			}
540			// newNode is the new pane that was added to the stack
541			log.Printf("[workspace] StackPane created new pane in stack: %p", newNode)
542
543		case "split":
544			// Regular split behavior
545			log.Printf("[workspace] Using split behavior for gesture link")
546			direction := "right"
547			if wm.app.config.Workspace.Popups.Placement != "" {
548				direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
549			}
550			newNode, err = wm.SplitPane(node, direction)
551
552		case "tabbed":
553			// Tabbed not yet implemented, fall back to split
554			log.Printf("[workspace] WARNING: Tabbed behavior not yet implemented for gesture links, falling back to split")
555			direction := "right"
556			if wm.app.config.Workspace.Popups.Placement != "" {
557				direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
558			}
559			newNode, err = wm.SplitPane(node, direction)
560
561		default:
562			// Unknown behavior, fall back to split
563			log.Printf("[workspace] WARNING: Unknown behavior '%s' for gesture links, falling back to split", behavior)
564			direction := "right"
565			if wm.app.config.Workspace.Popups.Placement != "" {
566				direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
567			}
568			newNode, err = wm.SplitPane(node, direction)
569		}
570
571		if err != nil {
572			log.Printf("[workspace] Failed to handle gesture link: %v", err)
573			return false
574		}
575
576		// Navigate the new pane to the URL
577		if newNode != nil && newNode.pane != nil && newNode.pane.WebView() != nil {
578			if err := newNode.pane.WebView().LoadURL(linkURL); err != nil {
579				log.Printf("[workspace] Failed to load URL in new pane: %v", err)
580				return false
581			}
582
583			// Set the new pane as active
584			wm.SetActivePane(newNode, SourceProgrammatic)
585		}
586
587		return true // Indicate we handled the click
588	})
589
590	log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
591}
592
593// GetAllPanes returns all BrowserPanes in this workspace.
594// This is used by TabManager to update app-level panes reference.
595func (wm *WorkspaceManager) GetAllPanes() []*BrowserPane {
596	panes := make([]*BrowserPane, 0, len(wm.viewToNode))
597	for _, node := range wm.viewToNode {
598		if node.pane != nil {
599			panes = append(panes, node.pane)
600		}
601	}
602	return panes
603}
604
605// GetActivePane returns the currently active BrowserPane in this workspace.
606// This is used by TabManager to update app-level activePane reference.
607func (wm *WorkspaceManager) GetActivePane() *BrowserPane {
608	node := wm.GetActiveNode()
609	if node != nil && node.pane != nil {
610		return node.pane
611	}
612	return nil
613}
614
615// RestoreFocus restores focus to the active pane in this workspace.
616// Called when switching to a tab to ensure the correct pane has focus.
617func (wm *WorkspaceManager) RestoreFocus() {
618	log.Printf("[workspace] RestoreFocus: workspace %p", wm)
619
620	// Get the active pane and explicitly grab GTK focus
621	activeNode := wm.GetActiveNode()
622	if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil {
623		// Explicitly grab GTK focus to this WebView
624		// This ensures keyboard events go to the active tab's WebView
625		// Note: RestoreFocus is called from switchToTabInternal which is already on the main thread
626		webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget())
627		log.Printf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm)
628
629		// Also request focus via FSM
630		if wm.focusStateMachine != nil {
631			wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic)
632		}
633	}
634}
635
636// IsInActiveTab returns whether this workspace is the app's current active workspace.
637// This is checked dynamically by comparing with app.workspace (updated during tab switching).
638func (wm *WorkspaceManager) IsInActiveTab() bool {
639	if wm.app == nil {
640		return false
641	}
642	// The active workspace is the one currently assigned to app.workspace
643	isActive := wm.app.workspace == wm
644	log.Printf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive)
645	return isActive
646}
647
648// ClearFocus removes GTK focus from all panes in this workspace.
649// This prevents inactive tab's WebViews from catching keyboard events.
650func (wm *WorkspaceManager) ClearFocus() {
651	log.Printf("[workspace] ClearFocus: workspace %p", wm)
652	// No state to clear - IsInActiveTab() checks dynamically
653}
654
655// GetRootWidget returns the root GTK widget for this workspace.
656// This is the container that should be added to the tab's content area.
657func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {
658	if wm.root != nil && wm.root.container != nil {
659		return wm.root.container
660	}
661	return nil
662}
663
664// setRootContainer attaches a widget as the workspace root to TabManager.ContentArea.
665// The tab system is always present (even with a single tab), so this always uses ContentArea.
666func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) {
667	if container == nil {
668		return
669	}
670
671	if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
672		log.Printf("[workspace] ERROR: Cannot set root - tab manager not initialized")
673		return
674	}
675
676	contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
677	if !ok || contentBox == nil {
678		log.Printf("[workspace] ERROR: ContentArea is not a Box")
679		return
680	}
681
682	contentBox.Append(container)
683	webkit.WidgetSetVisible(container, true)
684	log.Printf("[workspace] Attached root container %p to ContentArea", container)
685}
686
687// clearRootContainer removes the current workspace root from TabManager.ContentArea.
688// The tab system is always present (even with a single tab), so this always uses ContentArea.
689func (wm *WorkspaceManager) clearRootContainer() {
690	if wm.root == nil || wm.root.container == nil {
691		return
692	}
693
694	if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
695		log.Printf("[workspace] ERROR: Cannot clear root - tab manager not initialized")
696		return
697	}
698
699	contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
700	if !ok || contentBox == nil {
701		log.Printf("[workspace] ERROR: ContentArea is not a Box")
702		return
703	}
704
705	contentBox.Remove(wm.root.container)
706	log.Printf("[workspace] Removed root container %p from ContentArea", wm.root.container)
707}

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