v0.14.0 76d1892

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.ensureHover(root)
175
176	if handler := rootPane.MessageHandler(); handler != nil {
177		handler.SetWorkspaceObserver(manager)
178	}
179
180	// CRITICAL: Register navigation handler (including pane mode) for the root pane
181	// This must happen here because when creating the first tab, app.workspace is nil
182	// in createPaneForView, so it doesn't register the handler
183	manager.RegisterNavigationHandler(rootPane.webView)
184
185	// NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager.
186	// TabManager sets app.workspace during tab switching (including initial tab creation).
187	// This prevents workspaces from interfering with each other when multiple tabs exist.
188
189	// Configure debug settings from app config
190	if app.config != nil {
191		manager.focusStateMachine.ConfigureDebug(
192			app.config.Debug.EnableFocusDebug,
193			false, // CSS debug removed
194			app.config.Debug.EnableFocusMetrics,
195		)
196
197		manager.debugPaneClose = app.config.Debug.EnablePaneCloseDebug
198	}
199
200	manager.diagnostics = NewWorkspaceDiagnostics(manager.debugPaneClose)
201
202	// Initialize focus state machine after all setup is complete
203	if err := manager.focusStateMachine.Initialize(); err != nil {
204		log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
205	}
206
207	// Ensure hover controllers are attached to all panes for mouse-driven focus changes
208	manager.attachHoverHandlersToAllPanes()
209
210	if manager.debugPaneClose {
211		manager.dumpTreeState("workspace_init")
212	}
213
214	return manager
215}
216
217// shouldDebounce checks if an operation should be debounced based on timing
218func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool {
219	if last, ok := wm.lastSplitMsg[source]; ok {
220		if elapsed := time.Since(last); elapsed < threshold {
221			log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000)
222			return true
223		}
224	}
225	wm.lastSplitMsg[source] = time.Now()
226	return false
227}
228
229// withSplitLock executes an operation with atomic splitting lock protection
230func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error {
231	if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
232		log.Printf("[workspace] %s ignored: operation in progress", operation)
233		return fmt.Errorf("operation in progress")
234	}
235
236	defer func() {
237		atomic.StoreInt32(&wm.splitting, 0)
238		if r := recover(); r != nil {
239			log.Printf("[workspace] %s panicked: %v", operation, r)
240			panic(r)
241		}
242	}()
243
244	return fn()
245}
246
247// cleanupPopupStorage removes localStorage entries for a closed popup
248func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) {
249	if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" {
250		return
251	}
252
253	requestID := targetNode.requestID
254	parentWebViewID := parentNode.pane.WebView().ID()
255
256	script := fmt.Sprintf(`
257		try {
258			localStorage.removeItem('popup_mapping_%d');
259			const keys = ['popup_%s_parent_info', 'popup_%s_parent_action',
260			             'popup_%s_message_to_popup', 'popup_%s_message_to_parent',
261			             'oauth_callback_%s'];
262			keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} });
263			console.log('[workspace] Cleaned popup localStorage');
264		} catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); }
265	`, parentWebViewID, requestID, requestID, requestID, requestID, webviewID)
266
267	if err := parentNode.pane.WebView().InjectScript(script); err != nil {
268		log.Printf("[workspace] localStorage cleanup failed: %v", err)
269	} else {
270		log.Printf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID)
271	}
272}
273
274// OnWorkspaceMessage implements messaging.WorkspaceObserver.
275func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
276	node := wm.viewToNode[source]
277
278	if msg.WebViewID != "" {
279		if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil {
280			if explicitNode.pane != nil && explicitNode.pane.webView != nil {
281				node = explicitNode
282				source = explicitNode.pane.webView
283			}
284		}
285	}
286
287	if node == nil {
288		log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID)
289		return
290	}
291
292	switch event := strings.ToLower(msg.Event); event {
293	case "pane-split":
294		wm.SetActivePane(node, SourceProgrammatic)
295		direction := strings.ToLower(msg.Direction)
296		if direction == "" {
297			direction = DirectionRight
298		}
299
300		if wm.shouldDebounce(source, 200*time.Millisecond) {
301			return
302		}
303
304		err := wm.withSplitLock("split", func() error {
305			newNode, err := wm.SplitPane(node, direction)
306			if err != nil {
307				if glib.MainContextDefault().IsOwner() {
308					glib.MainContextDefault().Iteration(false)
309				}
310				return err
311			}
312			wm.clonePaneState(node, newNode)
313			return nil
314		})
315
316		if err != nil {
317			log.Printf("[workspace] split failed: %v", err)
318		}
319
320	case "pane-stack":
321		wm.SetActivePane(node, SourceProgrammatic)
322
323		if wm.shouldDebounce(source, 200*time.Millisecond) {
324			return
325		}
326
327		err := wm.withSplitLock("stack", func() error {
328			newNode, err := wm.stackedPaneManager.StackPane(node)
329			if err != nil {
330				if glib.MainContextDefault().IsOwner() {
331					glib.MainContextDefault().Iteration(false)
332				}
333				return err
334			}
335			wm.clonePaneState(node, newNode)
336			return nil
337		})
338
339		if err != nil {
340			log.Printf("[workspace] stack failed: %v", err)
341		}
342
343	case "close-popup":
344		if msg.WebViewID == "" {
345			log.Printf("[workspace] close-popup: empty webviewId, ignoring")
346			return
347		}
348
349		// Find popup pane by webview ID
350		var targetNode *paneNode
351		for webView, node := range wm.viewToNode {
352			if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID {
353				targetNode = node
354				break
355			}
356		}
357
358		if targetNode == nil {
359			log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
360			return
361		}
362
363		if !targetNode.isPopup {
364			log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
365			return
366		}
367
368		log.Printf("[workspace] Closing popup due to %s", msg.Reason)
369
370		// Remove from parent's active popup list
371		if targetNode.parentPane != nil {
372			parentNode := targetNode.parentPane
373			for i, childID := range parentNode.activePopupChildren {
374				if childID == msg.WebViewID {
375					parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
376					log.Printf("[workspace] Removed popup from parent (remaining: %d)", len(parentNode.activePopupChildren))
377					break
378				}
379			}
380
381			wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID)
382		}
383
384		if err := wm.ClosePane(targetNode); err != nil {
385			log.Printf("[workspace] Failed to close popup: %v", err)
386		}
387
388	default:
389		log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
390	}
391}
392
393func (wm *WorkspaceManager) findNodeByWebViewID(id string) *paneNode {
394	if wm == nil || id == "" {
395		return nil
396	}
397
398	for webView, node := range wm.viewToNode {
399		if webView != nil && webView.IDString() == id {
400			return node
401		}
402	}
403	return nil
404}
405
406// GetActiveNode returns the currently active pane node
407func (wm *WorkspaceManager) GetActiveNode() *paneNode {
408	if wm == nil || wm.focusStateMachine == nil {
409		return nil
410	}
411	return wm.focusStateMachine.GetActivePane()
412}
413
414// SetActivePane requests focus change through the focus state machine with debouncing
415func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
416	now := time.Now()
417
418	// Check if we should debounce this focus request (except for urgent system events)
419	if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
420		// Too soon and same target - ignore this request to prevent rapid oscillation
421		return
422	}
423
424	// Update tracking and proceed with focus change
425	wm.lastFocusTime = now
426	wm.lastFocusTarget = node
427
428	if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
429		log.Printf("[workspace] Focus request failed: %v", err)
430	}
431}
432
433// GetNodeForWebView returns the pane node associated with a WebView
434func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
435	return wm.viewToNode[webView]
436}
437
438// RegisterNavigationHandler sets up navigation handling for a webview
439func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
440	if webView == nil {
441		return
442	}
443
444	// Register workspace navigation callback for alt+arrow pane navigation
445	webView.RegisterWorkspaceNavigationHandler(func(direction string) bool {
446		if wm == nil {
447			return false
448		}
449		return wm.FocusNeighbor(direction)
450	})
451
452	// Register pane mode callbacks for Ctrl+P and pane mode action keys
453	webView.RegisterPaneModeHandler(func(action string) bool {
454		if wm == nil {
455			return false
456		}
457
458		if action == "enter" {
459			// CRITICAL FIX: Use app.workspace instead of closure's wm
460			// This ensures Ctrl+P always targets the ACTIVE tab's workspace
461			if wm.app != nil && wm.app.workspace != nil {
462				wm.app.workspace.EnterPaneMode()
463				return true
464			}
465			return false
466		}
467
468		if action == "exit" {
469			// Check if pane mode is active
470			wm.paneMutex.Lock()
471			wasActive := wm.paneModeActive
472			wm.paneMutex.Unlock()
473
474			if wasActive {
475				wm.ExitPaneMode("escape")
476				return true // Mode was active, we handled it
477			}
478			return false // No mode active, pass through to DOM
479		}
480
481		// For other actions, use active workspace (same pattern as "enter")
482		// This ensures action keys work on the correct tab
483		if wm.app != nil && wm.app.workspace != nil {
484			// Check if pane mode is active on the active workspace
485			activeWs := wm.app.workspace
486			activeWs.paneMutex.Lock()
487			isActive := activeWs.paneModeActive
488			activeWs.paneMutex.Unlock()
489
490			if !isActive {
491				return false // Not in pane mode, allow normal behavior
492			}
493
494			// Pane mode is active, handle the action on active workspace
495			activeWs.HandlePaneAction(action)
496			return true
497		}
498		return false
499	}, func() bool {
500		// Check if pane mode is currently active
501		wm.paneMutex.Lock()
502		defer wm.paneMutex.Unlock()
503		return wm.paneModeActive
504	})
505
506	// Register middle-click link handler to open links in new panes
507	webView.RegisterMiddleClickLinkHandler(func(linkURL string) bool {
508		if wm == nil {
509			return false
510		}
511
512		log.Printf("[workspace] Middle-click/Ctrl+click on link, opening in new pane: %s", linkURL)
513
514		// Get the node for this webview
515		node := wm.GetNodeForWebView(webView)
516		if node == nil {
517			log.Printf("[workspace] Cannot find node for middle-clicked link")
518			return false
519		}
520
521		// Get behavior from BlankTargetBehavior config
522		behavior := "stacked" // Default
523		if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.BlankTargetBehavior != "" {
524			behavior = strings.ToLower(wm.app.config.Workspace.Popups.BlankTargetBehavior)
525		}
526
527		var newNode *paneNode
528		var err error
529
530		switch behavior {
531		case "stacked":
532			// Stack the current pane - this also creates a new pane in the stack
533			log.Printf("[workspace] Using stacked behavior for gesture link")
534			newNode, err = wm.stackedPaneManager.StackPane(node)
535			if err != nil {
536				log.Printf("[workspace] Failed to stack pane for gesture link: %v", err)
537				return false
538			}
539			// newNode is the new pane that was added to the stack
540			log.Printf("[workspace] StackPane created new pane in stack: %p", newNode)
541
542		case "split":
543			// Regular split behavior
544			log.Printf("[workspace] Using split behavior for gesture link")
545			direction := "right"
546			if wm.app.config.Workspace.Popups.Placement != "" {
547				direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
548			}
549			newNode, err = wm.SplitPane(node, direction)
550
551		case "tabbed":
552			// Tabbed not yet implemented, fall back to split
553			log.Printf("[workspace] WARNING: Tabbed behavior not yet implemented for gesture links, falling back to split")
554			direction := "right"
555			if wm.app.config.Workspace.Popups.Placement != "" {
556				direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
557			}
558			newNode, err = wm.SplitPane(node, direction)
559
560		default:
561			// Unknown behavior, fall back to split
562			log.Printf("[workspace] WARNING: Unknown behavior '%s' for gesture links, falling back to split", behavior)
563			direction := "right"
564			if wm.app.config.Workspace.Popups.Placement != "" {
565				direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
566			}
567			newNode, err = wm.SplitPane(node, direction)
568		}
569
570		if err != nil {
571			log.Printf("[workspace] Failed to handle gesture link: %v", err)
572			return false
573		}
574
575		// Navigate the new pane to the URL
576		if newNode != nil && newNode.pane != nil && newNode.pane.WebView() != nil {
577			if err := newNode.pane.WebView().LoadURL(linkURL); err != nil {
578				log.Printf("[workspace] Failed to load URL in new pane: %v", err)
579				return false
580			}
581
582			// Set the new pane as active
583			wm.SetActivePane(newNode, SourceProgrammatic)
584		}
585
586		return true // Indicate we handled the click
587	})
588
589	log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
590}
591
592// GetAllPanes returns all BrowserPanes in this workspace.
593// This is used by TabManager to update app-level panes reference.
594func (wm *WorkspaceManager) GetAllPanes() []*BrowserPane {
595	panes := make([]*BrowserPane, 0, len(wm.viewToNode))
596	for _, node := range wm.viewToNode {
597		if node.pane != nil {
598			panes = append(panes, node.pane)
599		}
600	}
601	return panes
602}
603
604// GetActivePane returns the currently active BrowserPane in this workspace.
605// This is used by TabManager to update app-level activePane reference.
606func (wm *WorkspaceManager) GetActivePane() *BrowserPane {
607	node := wm.GetActiveNode()
608	if node != nil && node.pane != nil {
609		return node.pane
610	}
611	return nil
612}
613
614// RestoreFocus restores focus to the active pane in this workspace.
615// Called when switching to a tab to ensure the correct pane has focus.
616func (wm *WorkspaceManager) RestoreFocus() {
617	log.Printf("[workspace] RestoreFocus: workspace %p", wm)
618
619	// Get the active pane and explicitly grab GTK focus
620	activeNode := wm.GetActiveNode()
621	if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil {
622		// Explicitly grab GTK focus to this WebView
623		// This ensures keyboard events go to the active tab's WebView
624		// Note: RestoreFocus is called from switchToTabInternal which is already on the main thread
625		webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget())
626		log.Printf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm)
627
628		// Also request focus via FSM
629		if wm.focusStateMachine != nil {
630			wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic)
631		}
632	}
633}
634
635// IsInActiveTab returns whether this workspace is the app's current active workspace.
636// This is checked dynamically by comparing with app.workspace (updated during tab switching).
637func (wm *WorkspaceManager) IsInActiveTab() bool {
638	if wm.app == nil {
639		return false
640	}
641	// The active workspace is the one currently assigned to app.workspace
642	isActive := wm.app.workspace == wm
643	log.Printf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive)
644	return isActive
645}
646
647// ClearFocus removes GTK focus from all panes in this workspace.
648// This prevents inactive tab's WebViews from catching keyboard events.
649func (wm *WorkspaceManager) ClearFocus() {
650	log.Printf("[workspace] ClearFocus: workspace %p", wm)
651	// No state to clear - IsInActiveTab() checks dynamically
652}
653
654// GetRootWidget returns the root GTK widget for this workspace.
655// This is the container that should be added to the tab's content area.
656func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {
657	if wm.root != nil && wm.root.container != nil {
658		return wm.root.container
659	}
660	return nil
661}
662
663// setRootContainer attaches a widget as the workspace root to TabManager.ContentArea.
664// The tab system is always present (even with a single tab), so this always uses ContentArea.
665func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) {
666	if container == nil {
667		return
668	}
669
670	if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
671		log.Printf("[workspace] ERROR: Cannot set root - tab manager not initialized")
672		return
673	}
674
675	contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
676	if !ok || contentBox == nil {
677		log.Printf("[workspace] ERROR: ContentArea is not a Box")
678		return
679	}
680
681	contentBox.Append(container)
682	webkit.WidgetSetVisible(container, true)
683	log.Printf("[workspace] Attached root container %p to ContentArea", container)
684}
685
686// clearRootContainer removes the current workspace root from TabManager.ContentArea.
687// The tab system is always present (even with a single tab), so this always uses ContentArea.
688func (wm *WorkspaceManager) clearRootContainer() {
689	if wm.root == nil || wm.root.container == nil {
690		return
691	}
692
693	if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
694		log.Printf("[workspace] ERROR: Cannot clear root - tab manager not initialized")
695		return
696	}
697
698	contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
699	if !ok || contentBox == nil {
700		log.Printf("[workspace] ERROR: ContentArea is not a Box")
701		return
702	}
703
704	contentBox.Remove(wm.root.container)
705	log.Printf("[workspace] Removed root container %p from ContentArea", wm.root.container)
706}

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