detached 6ab096f

fix(tabs): tab container hierarchy and workspace isolation

- Fix ROOT SPLIT and pane operations to use TabManager.ContentArea instead of window.SetChild
- Fix tab switching to use current workspace root instead of stale cached container
- Fix pane close promotion to remove old paned from ContentArea before adding sibling
- Add helper methods setRootContainer/clearRootContainer for tab-aware container management
- Fix workspace pane mode to work with active tab's workspace instead of closure workspace
- Remove unused tab.container field - always get current root from workspace.GetRootWidget()
- Add tab mode visual borders using margins on ContentArea
- Move tab mode action keys from global shortcuts to EventControllerKey with active check
commit
6ab096f1ea49f25991a5e3f7836c44e07af68a75
author
bnema · · 2025-11-18 03:18 UTC
parent
6b46846
signature
unsigned
7 files · +386 -125
M internal/app/browser/stacked_panes.go +10 -13
@@ -307,10 +307,8 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
307307 // detachFromParent removes a pane from its current parent
308308 func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
309309 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- }
310+ // Target is the root - clear it (handles both tab and non-tab environments)
311+ spm.wm.clearRootContainer()
314312 // Unparent if it has a GTK parent
315313 if target.container != nil {
316314 if webkit.WidgetGetParent(target.container) != nil {
@@ -330,9 +328,8 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo
330328 // reattachToParent attaches a container to the parent
331329 func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
332330 if parent == nil {
333- if spm.wm.window != nil {
334- spm.wm.window.SetChild(container)
335- }
331+ // Attach as root (handles both tab and non-tab environments)
332+ spm.wm.setRootContainer(container)
336333 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
337334 if parent.left == target {
338335 paned.SetStartChild(container)
@@ -784,9 +781,8 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
784781 webkit.WidgetUnparent(stackNode.container)
785782 }
786783 }
787- if spm.wm.window != nil {
788- spm.wm.window.SetChild(nil)
789- }
784+ // Clear root container (handles both tab and non-tab environments)
785+ spm.wm.clearRootContainer()
790786 _, err := spm.wm.cleanupAndExit(node)
791787 return err
792788 }
@@ -810,7 +806,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
810806 grandparent := parent.parent
811807
812808 spm.wm.promoteSibling(grandparent, parent, sibling)
813- spm.wm.swapContainers(grandparent, sibling)
809+ spm.wm.swapContainers(grandparent, parent, sibling)
814810
815811 generation := spm.wm.nextCleanupGeneration()
816812 spm.wm.cleanupPane(node, generation)
@@ -848,10 +844,11 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
848844 }
849845
850846 if parent == nil {
847+ // Stack was root - make last pane the new root
851848 spm.wm.root = lastPane
852849 lastPane.parent = nil
853- if spm.wm.window != nil && lastPaneContainer != nil {
854- spm.wm.window.SetChild(lastPaneContainer)
850+ if lastPaneContainer != nil {
851+ spm.wm.setRootContainer(lastPaneContainer)
855852 webkit.WidgetQueueAllocate(lastPaneContainer)
856853 webkit.WidgetShow(lastPaneContainer)
857854 }
@@ -307,10 +307,8 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
307 // detachFromParent removes a pane from its current parent307 // detachFromParent removes a pane from its current parent
308 func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {308 func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
309 if parent == nil {309 if parent == nil {
310- // Target is the root - remove it from window310+ // Target is the root - clear it (handles both tab and non-tab environments)
311- if spm.wm.window != nil {311+ spm.wm.clearRootContainer()
312- spm.wm.window.SetChild(nil)
313- }
314 // Unparent if it has a GTK parent312 // Unparent if it has a GTK parent
315 if target.container != nil {313 if target.container != nil {
316 if webkit.WidgetGetParent(target.container) != nil {314 if webkit.WidgetGetParent(target.container) != nil {
@@ -330,9 +328,8 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo
330 // reattachToParent attaches a container to the parent328 // reattachToParent attaches a container to the parent
331 func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {329 func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
332 if parent == nil {330 if parent == nil {
333- if spm.wm.window != nil {331+ // Attach as root (handles both tab and non-tab environments)
334- spm.wm.window.SetChild(container)332+ spm.wm.setRootContainer(container)
335- }
336 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {333 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
337 if parent.left == target {334 if parent.left == target {
338 paned.SetStartChild(container)335 paned.SetStartChild(container)
@@ -784,9 +781,8 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
784 webkit.WidgetUnparent(stackNode.container)781 webkit.WidgetUnparent(stackNode.container)
785 }782 }
786 }783 }
787- if spm.wm.window != nil {784+ // Clear root container (handles both tab and non-tab environments)
788- spm.wm.window.SetChild(nil)785+ spm.wm.clearRootContainer()
789- }
790 _, err := spm.wm.cleanupAndExit(node)786 _, err := spm.wm.cleanupAndExit(node)
791 return err787 return err
792 }788 }
@@ -810,7 +806,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
810 grandparent := parent.parent806 grandparent := parent.parent
811 807
812 spm.wm.promoteSibling(grandparent, parent, sibling)808 spm.wm.promoteSibling(grandparent, parent, sibling)
813- spm.wm.swapContainers(grandparent, sibling)809+ spm.wm.swapContainers(grandparent, parent, sibling)
814 810
815 generation := spm.wm.nextCleanupGeneration()811 generation := spm.wm.nextCleanupGeneration()
816 spm.wm.cleanupPane(node, generation)812 spm.wm.cleanupPane(node, generation)
@@ -848,10 +844,11 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
848 }844 }
849 845
850 if parent == nil {846 if parent == nil {
847+ // Stack was root - make last pane the new root
851 spm.wm.root = lastPane848 spm.wm.root = lastPane
852 lastPane.parent = nil849 lastPane.parent = nil
853- if spm.wm.window != nil && lastPaneContainer != nil {850+ if lastPaneContainer != nil {
854- spm.wm.window.SetChild(lastPaneContainer)851+ spm.wm.setRootContainer(lastPaneContainer)
855 webkit.WidgetQueueAllocate(lastPaneContainer)852 webkit.WidgetQueueAllocate(lastPaneContainer)
856 webkit.WidgetShow(lastPaneContainer)853 webkit.WidgetShow(lastPaneContainer)
857 }854 }
M internal/app/browser/tab_manager.go +44 -24
@@ -22,7 +22,7 @@ type TabManager struct {
2222 // GTK widgets
2323 rootContainer gtk.Widgetter // Main vertical box containing tab bar + content
2424 tabBar gtk.Widgetter // Horizontal tab bar container
25- contentArea gtk.Widgetter // Container for active workspace
25+ ContentArea gtk.Widgetter // Container for active workspace (exported for border styling)
2626
2727 // Modal state
2828 tabModeActive bool
@@ -39,7 +39,6 @@ type Tab struct {
3939 customTitle string // User-provided custom name (persists across page loads)
4040 workspace *WorkspaceManager
4141 titleButton gtk.Widgetter
42- container gtk.Widgetter
4342 isActive bool
4443 }
4544
@@ -126,7 +125,7 @@ func (tm *TabManager) createRootContainer() error {
126125 // Store references
127126 tm.rootContainer = rootBox
128127 tm.tabBar = tabBar
129- tm.contentArea = contentArea
128+ tm.ContentArea = contentArea
130129
131130 logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
132131 return nil
@@ -184,12 +183,12 @@ func (tm *TabManager) createTabInternal(url string) error {
184183 workspace := NewWorkspaceManager(tm.app, pane)
185184 tab.workspace = workspace
186185
187- // Get workspace root container
186+ // Verify workspace root exists
188187 rootWidget := workspace.GetRootWidget()
189188 if rootWidget == nil {
190189 return fmt.Errorf("workspace root widget is nil")
191190 }
192- tab.container = rootWidget
191+ logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace))
193192
194193 // Load the URL in the workspace's pane
195194 if url != "" {
@@ -253,13 +252,24 @@ func (tm *TabManager) switchToTabInternal(index int) error {
253252 oldTab := tm.tabs[tm.activeIndex]
254253 oldTab.isActive = false
255254
255+ // Clear GTK focus from old tab's workspace to prevent it from catching keyboard events
256+ if oldTab.workspace != nil {
257+ oldTab.workspace.ClearFocus()
258+ }
259+
256260 // Hide old workspace container and remove from content area
257- if oldTab.container != nil {
258- webkit.RunOnMainThread(func() {
259- if contentBox, ok := tm.contentArea.(*gtk.Box); ok {
260- contentBox.Remove(oldTab.container)
261- }
262- })
261+ // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container,
262+ // because splits/stacks change the workspace root
263+ if oldTab.workspace != nil {
264+ currentRoot := oldTab.workspace.GetRootWidget()
265+ if currentRoot != nil {
266+ webkit.RunOnMainThread(func() {
267+ if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
268+ contentBox.Remove(currentRoot)
269+ logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot))
270+ }
271+ })
272+ }
263273 }
264274
265275 // Remove active CSS class from old button
@@ -272,13 +282,18 @@ func (tm *TabManager) switchToTabInternal(index int) error {
272282 tm.activeIndex = index
273283
274284 // Add new workspace container to content area
275- if newTab.container != nil {
276- webkit.RunOnMainThread(func() {
277- if contentBox, ok := tm.contentArea.(*gtk.Box); ok {
278- contentBox.Append(newTab.container)
279- webkit.WidgetSetVisible(newTab.container, true)
280- }
281- })
285+ // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
286+ if newTab.workspace != nil {
287+ currentRoot := newTab.workspace.GetRootWidget()
288+ if currentRoot != nil {
289+ webkit.RunOnMainThread(func() {
290+ if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
291+ contentBox.Append(currentRoot)
292+ webkit.WidgetSetVisible(currentRoot, true)
293+ logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot))
294+ }
295+ })
296+ }
282297 }
283298
284299 // Add active CSS class to new button
@@ -329,12 +344,17 @@ func (tm *TabManager) closeTabInternal(index int) error {
329344 tm.removeTabFromBar(tab)
330345
331346 // Remove workspace container from content area if it's currently visible
332- if tab.isActive && tab.container != nil {
333- webkit.RunOnMainThread(func() {
334- if contentBox, ok := tm.contentArea.(*gtk.Box); ok {
335- contentBox.Remove(tab.container)
336- }
337- })
347+ // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
348+ if tab.isActive && tab.workspace != nil {
349+ currentRoot := tab.workspace.GetRootWidget()
350+ if currentRoot != nil {
351+ webkit.RunOnMainThread(func() {
352+ if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
353+ contentBox.Remove(currentRoot)
354+ logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot))
355+ }
356+ })
357+ }
338358 }
339359
340360 // Remove from slice
@@ -22,7 +22,7 @@ type TabManager struct {
22 // GTK widgets22 // GTK widgets
23 rootContainer gtk.Widgetter // Main vertical box containing tab bar + content23 rootContainer gtk.Widgetter // Main vertical box containing tab bar + content
24 tabBar gtk.Widgetter // Horizontal tab bar container24 tabBar gtk.Widgetter // Horizontal tab bar container
25- contentArea gtk.Widgetter // Container for active workspace25+ ContentArea gtk.Widgetter // Container for active workspace (exported for border styling)
26 26
27 // Modal state27 // Modal state
28 tabModeActive bool28 tabModeActive bool
@@ -39,7 +39,6 @@ type Tab struct {
39 customTitle string // User-provided custom name (persists across page loads)39 customTitle string // User-provided custom name (persists across page loads)
40 workspace *WorkspaceManager40 workspace *WorkspaceManager
41 titleButton gtk.Widgetter41 titleButton gtk.Widgetter
42- container gtk.Widgetter
43 isActive bool42 isActive bool
44 }43 }
45 44
@@ -126,7 +125,7 @@ func (tm *TabManager) createRootContainer() error {
126 // Store references125 // Store references
127 tm.rootContainer = rootBox126 tm.rootContainer = rootBox
128 tm.tabBar = tabBar127 tm.tabBar = tabBar
129- tm.contentArea = contentArea128+ tm.ContentArea = contentArea
130 129
131 logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))130 logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
132 return nil131 return nil
@@ -184,12 +183,12 @@ func (tm *TabManager) createTabInternal(url string) error {
184 workspace := NewWorkspaceManager(tm.app, pane)183 workspace := NewWorkspaceManager(tm.app, pane)
185 tab.workspace = workspace184 tab.workspace = workspace
186 185
187- // Get workspace root container186+ // Verify workspace root exists
188 rootWidget := workspace.GetRootWidget()187 rootWidget := workspace.GetRootWidget()
189 if rootWidget == nil {188 if rootWidget == nil {
190 return fmt.Errorf("workspace root widget is nil")189 return fmt.Errorf("workspace root widget is nil")
191 }190 }
192- tab.container = rootWidget191+ logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace))
193 192
194 // Load the URL in the workspace's pane193 // Load the URL in the workspace's pane
195 if url != "" {194 if url != "" {
@@ -253,13 +252,24 @@ func (tm *TabManager) switchToTabInternal(index int) error {
253 oldTab := tm.tabs[tm.activeIndex]252 oldTab := tm.tabs[tm.activeIndex]
254 oldTab.isActive = false253 oldTab.isActive = false
255 254
255+ // Clear GTK focus from old tab's workspace to prevent it from catching keyboard events
256+ if oldTab.workspace != nil {
257+ oldTab.workspace.ClearFocus()
258+ }
259+
256 // Hide old workspace container and remove from content area260 // Hide old workspace container and remove from content area
257- if oldTab.container != nil {261+ // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container,
258- webkit.RunOnMainThread(func() {262+ // because splits/stacks change the workspace root
259- if contentBox, ok := tm.contentArea.(*gtk.Box); ok {263+ if oldTab.workspace != nil {
260- contentBox.Remove(oldTab.container)264+ currentRoot := oldTab.workspace.GetRootWidget()
261- }265+ if currentRoot != nil {
262- })266+ webkit.RunOnMainThread(func() {
267+ if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
268+ contentBox.Remove(currentRoot)
269+ logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot))
270+ }
271+ })
272+ }
263 }273 }
264 274
265 // Remove active CSS class from old button275 // Remove active CSS class from old button
@@ -272,13 +282,18 @@ func (tm *TabManager) switchToTabInternal(index int) error {
272 tm.activeIndex = index282 tm.activeIndex = index
273 283
274 // Add new workspace container to content area284 // Add new workspace container to content area
275- if newTab.container != nil {285+ // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
276- webkit.RunOnMainThread(func() {286+ if newTab.workspace != nil {
277- if contentBox, ok := tm.contentArea.(*gtk.Box); ok {287+ currentRoot := newTab.workspace.GetRootWidget()
278- contentBox.Append(newTab.container)288+ if currentRoot != nil {
279- webkit.WidgetSetVisible(newTab.container, true)289+ webkit.RunOnMainThread(func() {
280- }290+ if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
281- })291+ contentBox.Append(currentRoot)
292+ webkit.WidgetSetVisible(currentRoot, true)
293+ logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot))
294+ }
295+ })
296+ }
282 }297 }
283 298
284 // Add active CSS class to new button299 // Add active CSS class to new button
@@ -329,12 +344,17 @@ func (tm *TabManager) closeTabInternal(index int) error {
329 tm.removeTabFromBar(tab)344 tm.removeTabFromBar(tab)
330 345
331 // Remove workspace container from content area if it's currently visible346 // Remove workspace container from content area if it's currently visible
332- if tab.isActive && tab.container != nil {347+ // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
333- webkit.RunOnMainThread(func() {348+ if tab.isActive && tab.workspace != nil {
334- if contentBox, ok := tm.contentArea.(*gtk.Box); ok {349+ currentRoot := tab.workspace.GetRootWidget()
335- contentBox.Remove(tab.container)350+ if currentRoot != nil {
336- }351+ webkit.RunOnMainThread(func() {
337- })352+ if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
353+ contentBox.Remove(currentRoot)
354+ logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot))
355+ }
356+ })
357+ }
338 }358 }
339 359
340 // Remove from slice360 // Remove from slice
M internal/app/browser/tab_mode.go +34 -12
@@ -205,34 +205,56 @@ func (tm *TabManager) handleRenameTab() {
205205 }
206206
207207 // applyTabModeBorder applies visual indicator when tab mode is active.
208-// This adds an orange border/highlight to the tab bar.
208+// This adds an orange border around the entire workspace area.
209209 func (tm *TabManager) applyTabModeBorder() {
210210 logging.Debug("[tab-mode] applyTabModeBorder: entry")
211211
212- if tm.window == nil {
213- logging.Debug("[tab-mode] applyTabModeBorder: window is nil, returning")
212+ if tm.window == nil || tm.ContentArea == nil {
213+ logging.Debug("[tab-mode] applyTabModeBorder: window or ContentArea is nil, returning")
214214 return
215215 }
216216
217- logging.Debug("[tab-mode] applyTabModeBorder: window exists, converting to gtk.Window")
218- gtkWindow := tm.window.AsWindow()
219- logging.Debug("[tab-mode] applyTabModeBorder: got gtk.Window, about to add CSS class")
217+ logging.Debug("[tab-mode] applyTabModeBorder: applying margins to content area")
218+
219+ // Apply 4px margins to content area to create space for the border
220+ webkit.WidgetSetMargin(tm.ContentArea, 4)
220221
221- // Add CSS class to window for tab mode visual indicator
222- // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern)
222+ // Add CSS class to window for background color (the "border" color shows in the margin space)
223+ gtkWindow := tm.window.AsWindow()
223224 webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active")
224- logging.Debug("[tab-mode] applyTabModeBorder: CSS class added successfully")
225+
226+ // Force resize/allocation to apply margin changes immediately
227+ webkit.WidgetQueueResize(tm.ContentArea)
228+ webkit.WidgetQueueAllocate(tm.ContentArea)
229+
230+ // Queue redraw to show changes
231+ webkit.WidgetQueueDraw(gtkWindow)
232+ webkit.WidgetQueueDraw(tm.ContentArea)
233+
234+ logging.Debug("[tab-mode] applyTabModeBorder: border applied successfully")
225235 }
226236
227237 // removeTabModeBorder removes the visual indicator when exiting tab mode.
228238 func (tm *TabManager) removeTabModeBorder() {
229- if tm.window == nil {
239+ if tm.window == nil || tm.ContentArea == nil {
230240 return
231241 }
232242
243+ // Remove margins from content area
244+ webkit.WidgetSetMargin(tm.ContentArea, 0)
245+
233246 // Remove CSS class from window
234- // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern)
235- webkit.WidgetRemoveCSSClass(tm.window.AsWindow(), "tab-mode-active")
247+ gtkWindow := tm.window.AsWindow()
248+ webkit.WidgetRemoveCSSClass(gtkWindow, "tab-mode-active")
249+
250+ // Force resize/allocation to apply margin changes immediately
251+ webkit.WidgetQueueResize(tm.ContentArea)
252+ webkit.WidgetQueueAllocate(tm.ContentArea)
253+
254+ // Queue redraw to show changes
255+ webkit.WidgetQueueDraw(gtkWindow)
256+ webkit.WidgetQueueDraw(tm.ContentArea)
257+
236258 logging.Debug("[tab-mode] Removed tab mode visual indicator")
237259 }
238260
@@ -205,34 +205,56 @@ func (tm *TabManager) handleRenameTab() {
205 }205 }
206 206
207 // applyTabModeBorder applies visual indicator when tab mode is active.207 // applyTabModeBorder applies visual indicator when tab mode is active.
208-// This adds an orange border/highlight to the tab bar.208+// This adds an orange border around the entire workspace area.
209 func (tm *TabManager) applyTabModeBorder() {209 func (tm *TabManager) applyTabModeBorder() {
210 logging.Debug("[tab-mode] applyTabModeBorder: entry")210 logging.Debug("[tab-mode] applyTabModeBorder: entry")
211 211
212- if tm.window == nil {212+ if tm.window == nil || tm.ContentArea == nil {
213- logging.Debug("[tab-mode] applyTabModeBorder: window is nil, returning")213+ logging.Debug("[tab-mode] applyTabModeBorder: window or ContentArea is nil, returning")
214 return214 return
215 }215 }
216 216
217- logging.Debug("[tab-mode] applyTabModeBorder: window exists, converting to gtk.Window")217+ logging.Debug("[tab-mode] applyTabModeBorder: applying margins to content area")
218- gtkWindow := tm.window.AsWindow()218+
219- logging.Debug("[tab-mode] applyTabModeBorder: got gtk.Window, about to add CSS class")219+ // Apply 4px margins to content area to create space for the border
220+ webkit.WidgetSetMargin(tm.ContentArea, 4)
220 221
221- // Add CSS class to window for tab mode visual indicator222+ // Add CSS class to window for background color (the "border" color shows in the margin space)
222- // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern)223+ gtkWindow := tm.window.AsWindow()
223 webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active")224 webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active")
224- logging.Debug("[tab-mode] applyTabModeBorder: CSS class added successfully")225+
226+ // Force resize/allocation to apply margin changes immediately
227+ webkit.WidgetQueueResize(tm.ContentArea)
228+ webkit.WidgetQueueAllocate(tm.ContentArea)
229+
230+ // Queue redraw to show changes
231+ webkit.WidgetQueueDraw(gtkWindow)
232+ webkit.WidgetQueueDraw(tm.ContentArea)
233+
234+ logging.Debug("[tab-mode] applyTabModeBorder: border applied successfully")
225 }235 }
226 236
227 // removeTabModeBorder removes the visual indicator when exiting tab mode.237 // removeTabModeBorder removes the visual indicator when exiting tab mode.
228 func (tm *TabManager) removeTabModeBorder() {238 func (tm *TabManager) removeTabModeBorder() {
229- if tm.window == nil {239+ if tm.window == nil || tm.ContentArea == nil {
230 return240 return
231 }241 }
232 242
243+ // Remove margins from content area
244+ webkit.WidgetSetMargin(tm.ContentArea, 0)
245+
233 // Remove CSS class from window246 // Remove CSS class from window
234- // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern)247+ gtkWindow := tm.window.AsWindow()
235- webkit.WidgetRemoveCSSClass(tm.window.AsWindow(), "tab-mode-active")248+ webkit.WidgetRemoveCSSClass(gtkWindow, "tab-mode-active")
249+
250+ // Force resize/allocation to apply margin changes immediately
251+ webkit.WidgetQueueResize(tm.ContentArea)
252+ webkit.WidgetQueueAllocate(tm.ContentArea)
253+
254+ // Queue redraw to show changes
255+ webkit.WidgetQueueDraw(gtkWindow)
256+ webkit.WidgetQueueDraw(tm.ContentArea)
257+
236 logging.Debug("[tab-mode] Removed tab mode visual indicator")258 logging.Debug("[tab-mode] Removed tab mode visual indicator")
237 }259 }
238 260
M internal/app/browser/window_shortcuts.go +83 -22
@@ -68,23 +68,15 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
6868 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
6969 {"ctrl+shift+p", h.handlePrint, "Print page"},
7070 {"F12", h.handleDevTools, "Developer tools"},
71- // Pane management
72- {"ctrl+p", h.handlePaneMode, "Enter pane mode"},
71+ // Pane management: Ctrl+P handled by WebView keyboard bridge (not window shortcuts)
7372 // Tab management
7473 {"ctrl+t", h.handleTabMode, "Enter tab mode"},
7574 // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts
7675 // This prevents GTK's default focus cycling behavior
77- // Tab mode actions (only active when tab mode is enabled)
78- {"n", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},
79- {"c", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},
80- {"x", func() { h.handleTabModeAction("close-tab") }, "Close tab (tab mode)"},
81- {"l", func() { h.handleTabModeAction("next-tab") }, "Next tab (tab mode)"},
82- {"h", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"},
83- {"r", func() { h.handleTabModeAction("rename-tab") }, "Rename tab (tab mode)"},
84- {"Tab", func() { h.handleTabModeAction("next-tab") }, "Next tab (tab mode)"},
85- {"shift+Tab", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"},
86- {"Return", func() { h.handleTabModeAction("confirm") }, "Confirm (tab mode)"},
87- {"Escape", func() { h.handleTabModeAction("cancel") }, "Cancel (tab mode)"},
76+ // NOTE: Tab mode action keys (n, c, x, l, h, r, Tab, Escape, Enter) are NOT registered
77+ // as global shortcuts. They are handled by the WebView keyboard bridge which checks
78+ // if tab mode is active before processing them. This prevents them from consuming
79+ // keys when tab mode is not active (allowing normal typing in web pages).
8880 // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts)
8981 // This supports all keyboard layouts (QWERTY, AZERTY, etc.)
9082 // Page reload shortcuts
@@ -115,6 +107,9 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
115107 shortcut.key, shortcut.desc)
116108 }
117109
110+ // NOTE: Ctrl+P is NOT registered here - it's handled by WebView's keyboard bridge
111+ // (RegisterPaneModeHandler in workspace_manager.go) which calls app.workspace.EnterPaneMode()
112+
118113 return nil
119114 }
120115
@@ -136,7 +131,15 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
136131
137132 // GDK key constants
138133 const (
139- gdkKeyTab = 0xff09
134+ gdkKeyTab = 0xff09
135+ gdkKeyEscape = 0xff1b
136+ gdkKeyReturn = 0xff0d
137+ gdkKeyN = 0x006e
138+ gdkKeyC = 0x0063
139+ gdkKeyX = 0x0078
140+ gdkKeyL = 0x006c
141+ gdkKeyH = 0x0068
142+ gdkKeyR = 0x0072
140143 )
141144
142145 // Map hardware keycodes to tab indices
@@ -155,6 +158,10 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
155158 }
156159
157160 keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
161+ // IMPORTANT: Let WebView's keyboard bridge handle pane mode action keys first
162+ // During pane mode, single-letter keys (h,j,k,l,x,s,v, etc.) must reach the WebView
163+ // So we only intercept specific shortcuts here and let everything else through
164+
158165 // Handle Ctrl+Tab for next tab
159166 if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
160167 log.Printf("[window-shortcuts] Ctrl+Tab -> next tab")
@@ -179,11 +186,62 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
179186 }
180187 }
181188
189+ // Handle tab mode action keys (only when tab mode is active)
190+ // These keys need to be checked here so they can propagate when tab mode is not active
191+ if h.isTabModeActive() {
192+ handled := false
193+ action := ""
194+
195+ // Check for no modifiers (plain key press)
196+ if !state.Has(gdk.ControlMask) && !state.Has(gdk.AltMask) {
197+ switch keyval {
198+ case gdkKeyN, gdkKeyC:
199+ action = "new-tab"
200+ handled = true
201+ case gdkKeyX:
202+ action = "close-tab"
203+ handled = true
204+ case gdkKeyL:
205+ action = "next-tab"
206+ handled = true
207+ case gdkKeyH:
208+ action = "previous-tab"
209+ handled = true
210+ case gdkKeyR:
211+ action = "rename-tab"
212+ handled = true
213+ case gdkKeyTab:
214+ action = "next-tab"
215+ handled = true
216+ case gdkKeyReturn:
217+ action = "confirm"
218+ handled = true
219+ case gdkKeyEscape:
220+ action = "cancel"
221+ handled = true
222+ }
223+ }
224+
225+ // Handle Shift+Tab for previous tab in tab mode
226+ if keyval == gdkKeyTab && state.Has(gdk.ShiftMask) && !state.Has(gdk.ControlMask) {
227+ action = "previous-tab"
228+ handled = true
229+ }
230+
231+ if handled {
232+ log.Printf("[window-shortcuts] Tab mode action: %s", action)
233+ h.handleTabModeAction(action)
234+ return true // Consume event
235+ }
236+ }
237+
238+ // Let all other keys propagate to WebView's keyboard bridge
239+ // This includes Ctrl+P, Ctrl+T, and all pane mode action keys
182240 return false
183241 })
184242
185243 gtkWindow.AddController(keyController)
186- log.Printf("[window-shortcuts] Registered hardware keycode shortcuts for Alt+number (AZERTY support)")
244+ log.Printf("[window-shortcuts] Registered capture phase shortcuts: Ctrl+Tab, Alt+number")
187245
188246 return nil
189247 }
@@ -564,16 +622,19 @@ func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) {
564622 }
565623 }
566624
567-// handleTabModeAction handles tab mode action keys (n, x, l, h, etc.)
568-// Only processes actions when tab mode is active, otherwise lets keys pass through to WebView
569-func (h *WindowShortcutHandler) handleTabModeAction(action string) {
625+// isTabModeActive checks if tab mode is currently active
626+func (h *WindowShortcutHandler) isTabModeActive() bool {
570627 if h.app == nil || h.app.tabManager == nil {
571- return // Tab manager not available, let key pass through
628+ return false
572629 }
630+ return h.app.tabManager.IsTabModeActive()
631+}
573632
574- // Check if tab mode is active
575- if !h.app.tabManager.IsTabModeActive() {
576- return // Tab mode not active, let key pass through to WebView
633+// handleTabModeAction handles tab mode action keys (n, x, l, h, etc.)
634+// Only processes actions when tab mode is active
635+func (h *WindowShortcutHandler) handleTabModeAction(action string) {
636+ if h.app == nil || h.app.tabManager == nil {
637+ return
577638 }
578639
579640 // Tab mode is active, handle the action
@@ -68,23 +68,15 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
68 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},68 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
69 {"ctrl+shift+p", h.handlePrint, "Print page"},69 {"ctrl+shift+p", h.handlePrint, "Print page"},
70 {"F12", h.handleDevTools, "Developer tools"},70 {"F12", h.handleDevTools, "Developer tools"},
71- // Pane management71+ // Pane management: Ctrl+P handled by WebView keyboard bridge (not window shortcuts)
72- {"ctrl+p", h.handlePaneMode, "Enter pane mode"},
73 // Tab management72 // Tab management
74 {"ctrl+t", h.handleTabMode, "Enter tab mode"},73 {"ctrl+t", h.handleTabMode, "Enter tab mode"},
75 // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts74 // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts
76 // This prevents GTK's default focus cycling behavior75 // This prevents GTK's default focus cycling behavior
77- // Tab mode actions (only active when tab mode is enabled)76+ // NOTE: Tab mode action keys (n, c, x, l, h, r, Tab, Escape, Enter) are NOT registered
78- {"n", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},77+ // as global shortcuts. They are handled by the WebView keyboard bridge which checks
79- {"c", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},78+ // if tab mode is active before processing them. This prevents them from consuming
80- {"x", func() { h.handleTabModeAction("close-tab") }, "Close tab (tab mode)"},79+ // keys when tab mode is not active (allowing normal typing in web pages).
81- {"l", func() { h.handleTabModeAction("next-tab") }, "Next tab (tab mode)"},
82- {"h", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"},
83- {"r", func() { h.handleTabModeAction("rename-tab") }, "Rename tab (tab mode)"},
84- {"Tab", func() { h.handleTabModeAction("next-tab") }, "Next tab (tab mode)"},
85- {"shift+Tab", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"},
86- {"Return", func() { h.handleTabModeAction("confirm") }, "Confirm (tab mode)"},
87- {"Escape", func() { h.handleTabModeAction("cancel") }, "Cancel (tab mode)"},
88 // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts)80 // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts)
89 // This supports all keyboard layouts (QWERTY, AZERTY, etc.)81 // This supports all keyboard layouts (QWERTY, AZERTY, etc.)
90 // Page reload shortcuts82 // Page reload shortcuts
@@ -115,6 +107,9 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
115 shortcut.key, shortcut.desc)107 shortcut.key, shortcut.desc)
116 }108 }
117 109
110+ // NOTE: Ctrl+P is NOT registered here - it's handled by WebView's keyboard bridge
111+ // (RegisterPaneModeHandler in workspace_manager.go) which calls app.workspace.EnterPaneMode()
112+
118 return nil113 return nil
119 }114 }
120 115
@@ -136,7 +131,15 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
136 131
137 // GDK key constants132 // GDK key constants
138 const (133 const (
139- gdkKeyTab = 0xff09134+ gdkKeyTab = 0xff09
135+ gdkKeyEscape = 0xff1b
136+ gdkKeyReturn = 0xff0d
137+ gdkKeyN = 0x006e
138+ gdkKeyC = 0x0063
139+ gdkKeyX = 0x0078
140+ gdkKeyL = 0x006c
141+ gdkKeyH = 0x0068
142+ gdkKeyR = 0x0072
140 )143 )
141 144
142 // Map hardware keycodes to tab indices145 // Map hardware keycodes to tab indices
@@ -155,6 +158,10 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
155 }158 }
156 159
157 keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {160 keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
161+ // IMPORTANT: Let WebView's keyboard bridge handle pane mode action keys first
162+ // During pane mode, single-letter keys (h,j,k,l,x,s,v, etc.) must reach the WebView
163+ // So we only intercept specific shortcuts here and let everything else through
164+
158 // Handle Ctrl+Tab for next tab165 // Handle Ctrl+Tab for next tab
159 if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {166 if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
160 log.Printf("[window-shortcuts] Ctrl+Tab -> next tab")167 log.Printf("[window-shortcuts] Ctrl+Tab -> next tab")
@@ -179,11 +186,62 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
179 }186 }
180 }187 }
181 188
189+ // Handle tab mode action keys (only when tab mode is active)
190+ // These keys need to be checked here so they can propagate when tab mode is not active
191+ if h.isTabModeActive() {
192+ handled := false
193+ action := ""
194+
195+ // Check for no modifiers (plain key press)
196+ if !state.Has(gdk.ControlMask) && !state.Has(gdk.AltMask) {
197+ switch keyval {
198+ case gdkKeyN, gdkKeyC:
199+ action = "new-tab"
200+ handled = true
201+ case gdkKeyX:
202+ action = "close-tab"
203+ handled = true
204+ case gdkKeyL:
205+ action = "next-tab"
206+ handled = true
207+ case gdkKeyH:
208+ action = "previous-tab"
209+ handled = true
210+ case gdkKeyR:
211+ action = "rename-tab"
212+ handled = true
213+ case gdkKeyTab:
214+ action = "next-tab"
215+ handled = true
216+ case gdkKeyReturn:
217+ action = "confirm"
218+ handled = true
219+ case gdkKeyEscape:
220+ action = "cancel"
221+ handled = true
222+ }
223+ }
224+
225+ // Handle Shift+Tab for previous tab in tab mode
226+ if keyval == gdkKeyTab && state.Has(gdk.ShiftMask) && !state.Has(gdk.ControlMask) {
227+ action = "previous-tab"
228+ handled = true
229+ }
230+
231+ if handled {
232+ log.Printf("[window-shortcuts] Tab mode action: %s", action)
233+ h.handleTabModeAction(action)
234+ return true // Consume event
235+ }
236+ }
237+
238+ // Let all other keys propagate to WebView's keyboard bridge
239+ // This includes Ctrl+P, Ctrl+T, and all pane mode action keys
182 return false240 return false
183 })241 })
184 242
185 gtkWindow.AddController(keyController)243 gtkWindow.AddController(keyController)
186- log.Printf("[window-shortcuts] Registered hardware keycode shortcuts for Alt+number (AZERTY support)")244+ log.Printf("[window-shortcuts] Registered capture phase shortcuts: Ctrl+Tab, Alt+number")
187 245
188 return nil246 return nil
189 }247 }
@@ -564,16 +622,19 @@ func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) {
564 }622 }
565 }623 }
566 624
567-// handleTabModeAction handles tab mode action keys (n, x, l, h, etc.)625+// isTabModeActive checks if tab mode is currently active
568-// Only processes actions when tab mode is active, otherwise lets keys pass through to WebView626+func (h *WindowShortcutHandler) isTabModeActive() bool {
569-func (h *WindowShortcutHandler) handleTabModeAction(action string) {
570 if h.app == nil || h.app.tabManager == nil {627 if h.app == nil || h.app.tabManager == nil {
571- return // Tab manager not available, let key pass through628+ return false
572 }629 }
630+ return h.app.tabManager.IsTabModeActive()
631+}
573 632
574- // Check if tab mode is active633+// handleTabModeAction handles tab mode action keys (n, x, l, h, etc.)
575- if !h.app.tabManager.IsTabModeActive() {634+// Only processes actions when tab mode is active
576- return // Tab mode not active, let key pass through to WebView635+func (h *WindowShortcutHandler) handleTabModeAction(action string) {
636+ if h.app == nil || h.app.tabManager == nil {
637+ return
577 }638 }
578 639
579 // Tab mode is active, handle the action640 // Tab mode is active, handle the action
M internal/app/browser/workspace_manager.go +106 -17
@@ -149,7 +149,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
149149 handler.SetWorkspaceObserver(manager)
150150 }
151151
152- app.workspace = manager
152+ // CRITICAL: Register navigation handler (including pane mode) for the root pane
153+ // This must happen here because when creating the first tab, app.workspace is nil
154+ // in createPaneForView, so it doesn't register the handler
155+ manager.RegisterNavigationHandler(rootPane.webView)
156+
157+ // NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager.
158+ // TabManager sets app.workspace during tab switching (including initial tab creation).
159+ // This prevents workspaces from interfering with each other when multiple tabs exist.
153160
154161 // Configure debug settings from app config
155162 if app.config != nil {
@@ -414,15 +421,20 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
414421 return wm.FocusNeighbor(direction)
415422 })
416423
417- // Register pane mode callbacks for Ctrl+P and pane mode actions
424+ // Register pane mode callbacks for Ctrl+P and pane mode action keys
418425 webView.RegisterPaneModeHandler(func(action string) bool {
419426 if wm == nil {
420427 return false
421428 }
422429
423430 if action == "enter" {
424- wm.EnterPaneMode()
425- return true
431+ // CRITICAL FIX: Use app.workspace instead of closure's wm
432+ // This ensures Ctrl+P always targets the ACTIVE tab's workspace
433+ if wm.app != nil && wm.app.workspace != nil {
434+ wm.app.workspace.EnterPaneMode()
435+ return true
436+ }
437+ return false
426438 }
427439
428440 if action == "exit" {
@@ -438,18 +450,24 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
438450 return false // No mode active, pass through to DOM
439451 }
440452
441- // For other actions, only handle if pane mode is active
442- wm.paneMutex.Lock()
443- isActive := wm.paneModeActive
444- wm.paneMutex.Unlock()
453+ // For other actions, use active workspace (same pattern as "enter")
454+ // This ensures action keys work on the correct tab
455+ if wm.app != nil && wm.app.workspace != nil {
456+ // Check if pane mode is active on the active workspace
457+ activeWs := wm.app.workspace
458+ activeWs.paneMutex.Lock()
459+ isActive := activeWs.paneModeActive
460+ activeWs.paneMutex.Unlock()
461+
462+ if !isActive {
463+ return false // Not in pane mode, allow normal behavior
464+ }
445465
446- if !isActive {
447- return false // Not in pane mode, allow normal behavior
466+ // Pane mode is active, handle the action on active workspace
467+ activeWs.HandlePaneAction(action)
468+ return true
448469 }
449-
450- // Pane mode is active, handle the action
451- wm.HandlePaneAction(action)
452- return true
470+ return false
453471 }, func() bool {
454472 // Check if pane mode is currently active
455473 wm.paneMutex.Lock()
@@ -568,17 +586,43 @@ func (wm *WorkspaceManager) GetActivePane() *BrowserPane {
568586 // RestoreFocus restores focus to the active pane in this workspace.
569587 // Called when switching to a tab to ensure the correct pane has focus.
570588 func (wm *WorkspaceManager) RestoreFocus() {
571- // Focus is managed automatically by the focus state machine
572- // when the workspace becomes visible
589+ log.Printf("[workspace] RestoreFocus: workspace %p", wm)
590+
591+ // Get the active pane and explicitly grab GTK focus
573592 activeNode := wm.GetActiveNode()
574593 if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil {
575- // Request focus on the active pane
594+ // Explicitly grab GTK focus to this WebView
595+ // This ensures keyboard events go to the active tab's WebView
596+ // Note: RestoreFocus is called from switchToTabInternal which is already on the main thread
597+ webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget())
598+ log.Printf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm)
599+
600+ // Also request focus via FSM
576601 if wm.focusStateMachine != nil {
577602 wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic)
578603 }
579604 }
580605 }
581606
607+// IsInActiveTab returns whether this workspace is the app's current active workspace.
608+// This is checked dynamically by comparing with app.workspace (updated during tab switching).
609+func (wm *WorkspaceManager) IsInActiveTab() bool {
610+ if wm.app == nil {
611+ return false
612+ }
613+ // The active workspace is the one currently assigned to app.workspace
614+ isActive := wm.app.workspace == wm
615+ log.Printf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive)
616+ return isActive
617+}
618+
619+// ClearFocus removes GTK focus from all panes in this workspace.
620+// This prevents inactive tab's WebViews from catching keyboard events.
621+func (wm *WorkspaceManager) ClearFocus() {
622+ log.Printf("[workspace] ClearFocus: workspace %p", wm)
623+ // No state to clear - IsInActiveTab() checks dynamically
624+}
625+
582626 // GetRootWidget returns the root GTK widget for this workspace.
583627 // This is the container that should be added to the tab's content area.
584628 func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {
@@ -587,3 +631,48 @@ func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {
587631 }
588632 return nil
589633 }
634+
635+// setRootContainer attaches a widget as the workspace root to TabManager.ContentArea.
636+// The tab system is always present (even with a single tab), so this always uses ContentArea.
637+func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) {
638+ if container == nil {
639+ return
640+ }
641+
642+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
643+ log.Printf("[workspace] ERROR: Cannot set root - tab manager not initialized")
644+ return
645+ }
646+
647+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
648+ if !ok || contentBox == nil {
649+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
650+ return
651+ }
652+
653+ contentBox.Append(container)
654+ webkit.WidgetSetVisible(container, true)
655+ log.Printf("[workspace] Attached root container %p to ContentArea", container)
656+}
657+
658+// clearRootContainer removes the current workspace root from TabManager.ContentArea.
659+// The tab system is always present (even with a single tab), so this always uses ContentArea.
660+func (wm *WorkspaceManager) clearRootContainer() {
661+ if wm.root == nil || wm.root.container == nil {
662+ return
663+ }
664+
665+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
666+ log.Printf("[workspace] ERROR: Cannot clear root - tab manager not initialized")
667+ return
668+ }
669+
670+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
671+ if !ok || contentBox == nil {
672+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
673+ return
674+ }
675+
676+ contentBox.Remove(wm.root.container)
677+ log.Printf("[workspace] Removed root container %p from ContentArea", wm.root.container)
678+}
@@ -149,7 +149,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
149 handler.SetWorkspaceObserver(manager)149 handler.SetWorkspaceObserver(manager)
150 }150 }
151 151
152- app.workspace = manager152+ // CRITICAL: Register navigation handler (including pane mode) for the root pane
153+ // This must happen here because when creating the first tab, app.workspace is nil
154+ // in createPaneForView, so it doesn't register the handler
155+ manager.RegisterNavigationHandler(rootPane.webView)
156+
157+ // NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager.
158+ // TabManager sets app.workspace during tab switching (including initial tab creation).
159+ // This prevents workspaces from interfering with each other when multiple tabs exist.
153 160
154 // Configure debug settings from app config161 // Configure debug settings from app config
155 if app.config != nil {162 if app.config != nil {
@@ -414,15 +421,20 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
414 return wm.FocusNeighbor(direction)421 return wm.FocusNeighbor(direction)
415 })422 })
416 423
417- // Register pane mode callbacks for Ctrl+P and pane mode actions424+ // Register pane mode callbacks for Ctrl+P and pane mode action keys
418 webView.RegisterPaneModeHandler(func(action string) bool {425 webView.RegisterPaneModeHandler(func(action string) bool {
419 if wm == nil {426 if wm == nil {
420 return false427 return false
421 }428 }
422 429
423 if action == "enter" {430 if action == "enter" {
424- wm.EnterPaneMode()431+ // CRITICAL FIX: Use app.workspace instead of closure's wm
425- return true432+ // This ensures Ctrl+P always targets the ACTIVE tab's workspace
433+ if wm.app != nil && wm.app.workspace != nil {
434+ wm.app.workspace.EnterPaneMode()
435+ return true
436+ }
437+ return false
426 }438 }
427 439
428 if action == "exit" {440 if action == "exit" {
@@ -438,18 +450,24 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
438 return false // No mode active, pass through to DOM450 return false // No mode active, pass through to DOM
439 }451 }
440 452
441- // For other actions, only handle if pane mode is active453+ // For other actions, use active workspace (same pattern as "enter")
442- wm.paneMutex.Lock()454+ // This ensures action keys work on the correct tab
443- isActive := wm.paneModeActive455+ if wm.app != nil && wm.app.workspace != nil {
444- wm.paneMutex.Unlock()456+ // Check if pane mode is active on the active workspace
457+ activeWs := wm.app.workspace
458+ activeWs.paneMutex.Lock()
459+ isActive := activeWs.paneModeActive
460+ activeWs.paneMutex.Unlock()
461+
462+ if !isActive {
463+ return false // Not in pane mode, allow normal behavior
464+ }
445 465
446- if !isActive {466+ // Pane mode is active, handle the action on active workspace
447- return false // Not in pane mode, allow normal behavior467+ activeWs.HandlePaneAction(action)
468+ return true
448 }469 }
449-470+ return false
450- // Pane mode is active, handle the action
451- wm.HandlePaneAction(action)
452- return true
453 }, func() bool {471 }, func() bool {
454 // Check if pane mode is currently active472 // Check if pane mode is currently active
455 wm.paneMutex.Lock()473 wm.paneMutex.Lock()
@@ -568,17 +586,43 @@ func (wm *WorkspaceManager) GetActivePane() *BrowserPane {
568 // RestoreFocus restores focus to the active pane in this workspace.586 // RestoreFocus restores focus to the active pane in this workspace.
569 // Called when switching to a tab to ensure the correct pane has focus.587 // Called when switching to a tab to ensure the correct pane has focus.
570 func (wm *WorkspaceManager) RestoreFocus() {588 func (wm *WorkspaceManager) RestoreFocus() {
571- // Focus is managed automatically by the focus state machine589+ log.Printf("[workspace] RestoreFocus: workspace %p", wm)
572- // when the workspace becomes visible590+
591+ // Get the active pane and explicitly grab GTK focus
573 activeNode := wm.GetActiveNode()592 activeNode := wm.GetActiveNode()
574 if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil {593 if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil {
575- // Request focus on the active pane594+ // Explicitly grab GTK focus to this WebView
595+ // This ensures keyboard events go to the active tab's WebView
596+ // Note: RestoreFocus is called from switchToTabInternal which is already on the main thread
597+ webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget())
598+ log.Printf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm)
599+
600+ // Also request focus via FSM
576 if wm.focusStateMachine != nil {601 if wm.focusStateMachine != nil {
577 wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic)602 wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic)
578 }603 }
579 }604 }
580 }605 }
581 606
607+// IsInActiveTab returns whether this workspace is the app's current active workspace.
608+// This is checked dynamically by comparing with app.workspace (updated during tab switching).
609+func (wm *WorkspaceManager) IsInActiveTab() bool {
610+ if wm.app == nil {
611+ return false
612+ }
613+ // The active workspace is the one currently assigned to app.workspace
614+ isActive := wm.app.workspace == wm
615+ log.Printf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive)
616+ return isActive
617+}
618+
619+// ClearFocus removes GTK focus from all panes in this workspace.
620+// This prevents inactive tab's WebViews from catching keyboard events.
621+func (wm *WorkspaceManager) ClearFocus() {
622+ log.Printf("[workspace] ClearFocus: workspace %p", wm)
623+ // No state to clear - IsInActiveTab() checks dynamically
624+}
625+
582 // GetRootWidget returns the root GTK widget for this workspace.626 // GetRootWidget returns the root GTK widget for this workspace.
583 // This is the container that should be added to the tab's content area.627 // This is the container that should be added to the tab's content area.
584 func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {628 func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {
@@ -587,3 +631,48 @@ func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {
587 }631 }
588 return nil632 return nil
589 }633 }
634+
635+// setRootContainer attaches a widget as the workspace root to TabManager.ContentArea.
636+// The tab system is always present (even with a single tab), so this always uses ContentArea.
637+func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) {
638+ if container == nil {
639+ return
640+ }
641+
642+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
643+ log.Printf("[workspace] ERROR: Cannot set root - tab manager not initialized")
644+ return
645+ }
646+
647+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
648+ if !ok || contentBox == nil {
649+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
650+ return
651+ }
652+
653+ contentBox.Append(container)
654+ webkit.WidgetSetVisible(container, true)
655+ log.Printf("[workspace] Attached root container %p to ContentArea", container)
656+}
657+
658+// clearRootContainer removes the current workspace root from TabManager.ContentArea.
659+// The tab system is always present (even with a single tab), so this always uses ContentArea.
660+func (wm *WorkspaceManager) clearRootContainer() {
661+ if wm.root == nil || wm.root.container == nil {
662+ return
663+ }
664+
665+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
666+ log.Printf("[workspace] ERROR: Cannot clear root - tab manager not initialized")
667+ return
668+ }
669+
670+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
671+ if !ok || contentBox == nil {
672+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
673+ return
674+ }
675+
676+ contentBox.Remove(wm.root.container)
677+ log.Printf("[workspace] Removed root container %p from ContentArea", wm.root.container)
678+}
M internal/app/browser/workspace_pane_mode.go +31 -10
@@ -5,11 +5,14 @@ import (
55 "log"
66
77 "github.com/bnema/dumber/pkg/webkit"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
89 )
910
1011 // EnterPaneMode activates pane mode on the currently focused pane
1112 // This is called directly from the keyboard bridge, not from JavaScript
1213 func (wm *WorkspaceManager) EnterPaneMode() {
14+ // Note: Caller (RegisterPaneModeHandler) ensures this is called on app.workspace (active tab)
15+
1316 wm.paneMutex.Lock()
1417 defer wm.paneMutex.Unlock()
1518
@@ -26,7 +29,7 @@ func (wm *WorkspaceManager) EnterPaneMode() {
2629 return
2730 }
2831
29- log.Printf("[pane-mode] Entering pane mode on pane %p", activeNode)
32+ log.Printf("[pane-mode] Entering pane mode on pane %p (workspace=%p)", activeNode, wm)
3033
3134 wm.paneModeActive = true
3235 wm.paneModeActivePane = activeNode
@@ -67,6 +70,9 @@ func (wm *WorkspaceManager) ExitPaneMode(reason string) {
6770 // HandlePaneAction processes pane mode actions (close, split, etc.)
6871 // Called from keyboard bridge when user presses action keys in pane mode
6972 func (wm *WorkspaceManager) HandlePaneAction(action string) {
73+ // Note: No IsInActiveTab check here - caller (RegisterPaneModeHandler) ensures
74+ // this is called on app.workspace (active tab's workspace)
75+
7076 wm.paneMutex.Lock()
7177
7278 if !wm.paneModeActive {
@@ -201,29 +207,44 @@ func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) {
201207
202208 // applyPaneModeBorder applies pane mode visual indicator using GTK margins
203209 func (wm *WorkspaceManager) applyPaneModeBorder() {
204- if wm.root == nil || wm.root.container == nil || wm.window == nil {
210+ if wm.window == nil {
211+ return
212+ }
213+
214+ // Determine which container to apply margins to:
215+ // - If tab manager exists, apply to its ContentArea (so window background shows through)
216+ // - Otherwise, apply to workspace root (direct child of window)
217+ var targetContainer gtk.Widgetter
218+ if wm.app != nil && wm.app.tabManager != nil && wm.app.tabManager.ContentArea != nil {
219+ // Tab environment: apply margins to tab manager's content area
220+ targetContainer = wm.app.tabManager.ContentArea
221+ log.Printf("[pane-mode] Using tab manager's content area for border")
222+ } else if wm.root != nil && wm.root.container != nil {
223+ // Non-tab environment: apply margins to workspace root
224+ targetContainer = wm.root.container
225+ log.Printf("[pane-mode] Using workspace root for border")
226+ } else {
205227 return
206228 }
207229
208230 // Save the container reference so we can remove margins from it later
209- // (even if wm.root.container changes due to splits)
210- wm.paneModeContainer = wm.root.container
231+ wm.paneModeContainer = targetContainer
211232
212- // Apply 4px margins to root container to create space for the border
213- webkit.WidgetSetMargin(wm.root.container, 4)
233+ // Apply 4px margins to create space for the border
234+ webkit.WidgetSetMargin(targetContainer, 4)
214235
215236 // Add CSS class to window for background color (the "border" color shows in the margin space)
216237 webkit.WidgetAddCSSClass(wm.window.AsWindow(), "pane-mode-active")
217238
218239 // Force resize/allocation to apply margin changes immediately
219- webkit.WidgetQueueResize(wm.root.container)
220- webkit.WidgetQueueAllocate(wm.root.container)
240+ webkit.WidgetQueueResize(targetContainer)
241+ webkit.WidgetQueueAllocate(targetContainer)
221242
222243 // Queue redraw to show changes
223244 webkit.WidgetQueueDraw(wm.window.AsWindow())
224- webkit.WidgetQueueDraw(wm.root.container)
245+ webkit.WidgetQueueDraw(targetContainer)
225246
226- log.Printf("[pane-mode] Applied border to workspace root using margins (container=%p)", wm.root.container)
247+ log.Printf("[pane-mode] Applied border using margins (container=%p)", targetContainer)
227248 }
228249
229250 // removePaneModeBorder removes the pane mode visual indicator
@@ -5,11 +5,14 @@ import (
5 "log"5 "log"
6 6
7 "github.com/bnema/dumber/pkg/webkit"7 "github.com/bnema/dumber/pkg/webkit"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
8 )9 )
9 10
10 // EnterPaneMode activates pane mode on the currently focused pane11 // EnterPaneMode activates pane mode on the currently focused pane
11 // This is called directly from the keyboard bridge, not from JavaScript12 // This is called directly from the keyboard bridge, not from JavaScript
12 func (wm *WorkspaceManager) EnterPaneMode() {13 func (wm *WorkspaceManager) EnterPaneMode() {
14+ // Note: Caller (RegisterPaneModeHandler) ensures this is called on app.workspace (active tab)
15+
13 wm.paneMutex.Lock()16 wm.paneMutex.Lock()
14 defer wm.paneMutex.Unlock()17 defer wm.paneMutex.Unlock()
15 18
@@ -26,7 +29,7 @@ func (wm *WorkspaceManager) EnterPaneMode() {
26 return29 return
27 }30 }
28 31
29- log.Printf("[pane-mode] Entering pane mode on pane %p", activeNode)32+ log.Printf("[pane-mode] Entering pane mode on pane %p (workspace=%p)", activeNode, wm)
30 33
31 wm.paneModeActive = true34 wm.paneModeActive = true
32 wm.paneModeActivePane = activeNode35 wm.paneModeActivePane = activeNode
@@ -67,6 +70,9 @@ func (wm *WorkspaceManager) ExitPaneMode(reason string) {
67 // HandlePaneAction processes pane mode actions (close, split, etc.)70 // HandlePaneAction processes pane mode actions (close, split, etc.)
68 // Called from keyboard bridge when user presses action keys in pane mode71 // Called from keyboard bridge when user presses action keys in pane mode
69 func (wm *WorkspaceManager) HandlePaneAction(action string) {72 func (wm *WorkspaceManager) HandlePaneAction(action string) {
73+ // Note: No IsInActiveTab check here - caller (RegisterPaneModeHandler) ensures
74+ // this is called on app.workspace (active tab's workspace)
75+
70 wm.paneMutex.Lock()76 wm.paneMutex.Lock()
71 77
72 if !wm.paneModeActive {78 if !wm.paneModeActive {
@@ -201,29 +207,44 @@ func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) {
201 207
202 // applyPaneModeBorder applies pane mode visual indicator using GTK margins208 // applyPaneModeBorder applies pane mode visual indicator using GTK margins
203 func (wm *WorkspaceManager) applyPaneModeBorder() {209 func (wm *WorkspaceManager) applyPaneModeBorder() {
204- if wm.root == nil || wm.root.container == nil || wm.window == nil {210+ if wm.window == nil {
211+ return
212+ }
213+
214+ // Determine which container to apply margins to:
215+ // - If tab manager exists, apply to its ContentArea (so window background shows through)
216+ // - Otherwise, apply to workspace root (direct child of window)
217+ var targetContainer gtk.Widgetter
218+ if wm.app != nil && wm.app.tabManager != nil && wm.app.tabManager.ContentArea != nil {
219+ // Tab environment: apply margins to tab manager's content area
220+ targetContainer = wm.app.tabManager.ContentArea
221+ log.Printf("[pane-mode] Using tab manager's content area for border")
222+ } else if wm.root != nil && wm.root.container != nil {
223+ // Non-tab environment: apply margins to workspace root
224+ targetContainer = wm.root.container
225+ log.Printf("[pane-mode] Using workspace root for border")
226+ } else {
205 return227 return
206 }228 }
207 229
208 // Save the container reference so we can remove margins from it later230 // Save the container reference so we can remove margins from it later
209- // (even if wm.root.container changes due to splits)231+ wm.paneModeContainer = targetContainer
210- wm.paneModeContainer = wm.root.container
211 232
212- // Apply 4px margins to root container to create space for the border233+ // Apply 4px margins to create space for the border
213- webkit.WidgetSetMargin(wm.root.container, 4)234+ webkit.WidgetSetMargin(targetContainer, 4)
214 235
215 // Add CSS class to window for background color (the "border" color shows in the margin space)236 // Add CSS class to window for background color (the "border" color shows in the margin space)
216 webkit.WidgetAddCSSClass(wm.window.AsWindow(), "pane-mode-active")237 webkit.WidgetAddCSSClass(wm.window.AsWindow(), "pane-mode-active")
217 238
218 // Force resize/allocation to apply margin changes immediately239 // Force resize/allocation to apply margin changes immediately
219- webkit.WidgetQueueResize(wm.root.container)240+ webkit.WidgetQueueResize(targetContainer)
220- webkit.WidgetQueueAllocate(wm.root.container)241+ webkit.WidgetQueueAllocate(targetContainer)
221 242
222 // Queue redraw to show changes243 // Queue redraw to show changes
223 webkit.WidgetQueueDraw(wm.window.AsWindow())244 webkit.WidgetQueueDraw(wm.window.AsWindow())
224- webkit.WidgetQueueDraw(wm.root.container)245+ webkit.WidgetQueueDraw(targetContainer)
225 246
226- log.Printf("[pane-mode] Applied border to workspace root using margins (container=%p)", wm.root.container)247+ log.Printf("[pane-mode] Applied border using margins (container=%p)", targetContainer)
227 }248 }
228 249
229 // removePaneModeBorder removes the pane mode visual indicator250 // removePaneModeBorder removes the pane mode visual indicator
M internal/app/browser/workspace_pane_ops.go +78 -27
@@ -3,11 +3,13 @@ package browser
33
44 import (
55 "errors"
6+ "fmt"
67 "log"
78
8- "github.com/bnema/dumber/pkg/webkit"
99 "github.com/diamondburned/gotk4/pkg/gdk/v4"
1010 "github.com/diamondburned/gotk4/pkg/gtk/v4"
11+
12+ "github.com/bnema/dumber/pkg/webkit"
1113 )
1214
1315 // closeContext manages the context for a pane close operation
@@ -529,19 +531,28 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existi
529531
530532 // GTK4 handles widget operations automatically - no need to force redraw
531533
532- // Handle root split: attach paned to window FIRST, then let GTK handle reparenting
534+ // Handle root split: attach paned to ContentArea (tab system is always present)
533535 if parent == nil {
534- // ROOT SPLIT: Attach empty paned to window, then add children
535- // GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild
536- if wm.window != nil {
537- // Clear any existing window child (the splitTargetContainer)
538- wm.window.SetChild(nil)
539-
540- // Attach the new paned to window FIRST
541- wm.window.SetChild(paned)
542- log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned)
536+ // ROOT SPLIT: Manipulate TabManager.ContentArea
537+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
538+ log.Printf("[workspace] ERROR: Cannot perform root split - tab manager not initialized")
539+ return nil, fmt.Errorf("tab manager not initialized")
543540 }
544541
542+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
543+ if !ok || contentBox == nil {
544+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
545+ return nil, fmt.Errorf("ContentArea is not a Box")
546+ }
547+
548+ // Remove the old workspace root from ContentArea
549+ contentBox.Remove(splitTargetContainer)
550+ log.Printf("[workspace] ROOT SPLIT: removed old root %p from ContentArea", splitTargetContainer)
551+
552+ // Attach the new paned to ContentArea FIRST
553+ contentBox.Append(paned)
554+ log.Printf("[workspace] ROOT SPLIT: attached paned %p to ContentArea", paned)
555+
545556 // Now add children to the paned - GTK will handle reparenting automatically
546557 if existingFirst {
547558 paned.SetStartChild(splitTargetContainer)
@@ -710,12 +721,41 @@ func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, si
710721 }
711722
712723 // swapContainers updates GTK widget hierarchy for promoted siblings
713-func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
724+func (wm *WorkspaceManager) swapContainers(grand *paneNode, parent *paneNode, sibling *paneNode) {
714725 if grand == nil {
726+ // Sibling is being promoted to root
715727 // When TreeRebalancer is enabled, skip GTK attachment here as it will be
716728 // handled by the rebalancer's promotion transaction to avoid double-attachment
717729 if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
718- wm.attachRoot(sibling)
730+ // NOTE: Can't use attachRoot() here because wm.root has already been updated
731+ // to point to the sibling by promoteSibling(). We need to manually swap in ContentArea:
732+ // the old paned (parent) is still in ContentArea and needs to be removed,
733+ // then the sibling needs to be added.
734+
735+ // The sibling has already been unparented from the old paned earlier in the close logic,
736+ // so it has no GTK parent now.
737+
738+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
739+ log.Printf("[workspace] ERROR: Cannot promote sibling to root - tab manager not initialized")
740+ return
741+ }
742+
743+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
744+ if !ok || contentBox == nil {
745+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
746+ return
747+ }
748+
749+ // CRITICAL: Remove the old paned from ContentArea first
750+ if parent != nil && parent.container != nil {
751+ contentBox.Remove(parent.container)
752+ log.Printf("[workspace] Removed old paned %p from ContentArea", parent.container)
753+ }
754+
755+ // Now add sibling to ContentArea as the new root
756+ contentBox.Append(sibling.container)
757+ webkit.WidgetSetVisible(sibling.container, true)
758+ log.Printf("[workspace] Promoted sibling %p to root in ContentArea", sibling.container)
719759 }
720760 return
721761 }
@@ -827,34 +867,45 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
827867 }
828868 }
829869
830-// attachRoot attaches a node as the new window root
870+// attachRoot attaches a node as the new root in TabManager.ContentArea.
871+// NOTE: This is typically called AFTER wm.root has already been updated to point to this node.
872+// The old root container (if any) will be orphaned in ContentArea and garbage collected.
831873 func (wm *WorkspaceManager) attachRoot(root *paneNode) {
832- if root == nil || root.container == nil || wm.window == nil {
874+ if root == nil || root.container == nil {
833875 return
834876 }
835877
836878 // Note: In gotk4, reference counting is handled automatically by Go's GC
837879 // No need for manual Ref()/Unref() calls
838880
839- // Detach from previous container (paned, stack, etc.) before replacing the window child.
840- // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent
881+ // Detach from previous container (paned, stack, etc.) before attaching to ContentArea
882+ // While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent
841883 if parent := webkit.WidgetGetParent(root.container); parent != nil {
842- log.Printf("[workspace] unparenting widget %p from parent %p before window attach", root.container, parent)
884+ log.Printf("[workspace] unparenting widget %p from parent %p before root attach", root.container, parent)
843885 webkit.WidgetUnparent(root.container)
844886 // Note: In gotk4, the Go object reference remains valid after unparent
845887 // Only the GTK parent relationship is cleared
846888 }
847889
848- // Clear any existing window child to avoid GTK warnings when swapping roots.
849- wm.window.SetChild(nil)
850-
851890 if root.container != nil {
852891 webkit.WidgetSetHExpand(root.container, true)
853892 webkit.WidgetSetVExpand(root.container, true)
854893 }
855894
856- // GTK4 will automatically unparent root.container from its previous parent when we attach it here
857- wm.window.SetChild(root.container)
895+ // Add to ContentArea (the old root will be orphaned and garbage collected)
896+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
897+ log.Printf("[workspace] ERROR: Cannot attach root - tab manager not initialized")
898+ return
899+ }
900+
901+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
902+ if !ok || contentBox == nil {
903+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
904+ return
905+ }
906+
907+ contentBox.Append(root.container)
908+ webkit.WidgetSetVisible(root.container, true)
858909 if root.container != nil {
859910 webkit.WidgetQueueAllocate(root.container)
860911 webkit.WidgetShow(root.container)
@@ -1051,8 +1102,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
10511102
10521103 // STEP 7: GTK widget reparenting
10531104 // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
1054- // For window: we already manually unparented above, so now we can safely attach
1055- wm.swapContainers(grandparent, sibling)
1105+ // For root promotion: we need to remove the old paned from ContentArea
1106+ wm.swapContainers(grandparent, parent, sibling)
10561107
10571108 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
10581109 if sibling != nil && sibling.container != nil {
@@ -1137,10 +1188,10 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
11371188 }
11381189 }
11391190 } else {
1140- // Stack was root
1191+ // Stack was root - attach remaining pane as new root
11411192 wm.root = remaining
11421193 if remaining.container != nil {
1143- wm.window.SetChild(remaining.container)
1194+ wm.setRootContainer(remaining.container)
11441195 }
11451196 }
11461197
@@ -3,11 +3,13 @@ package browser
3 3
4 import (4 import (
5 "errors"5 "errors"
6+ "fmt"
6 "log"7 "log"
7 8
8- "github.com/bnema/dumber/pkg/webkit"
9 "github.com/diamondburned/gotk4/pkg/gdk/v4"9 "github.com/diamondburned/gotk4/pkg/gdk/v4"
10 "github.com/diamondburned/gotk4/pkg/gtk/v4"10 "github.com/diamondburned/gotk4/pkg/gtk/v4"
11+
12+ "github.com/bnema/dumber/pkg/webkit"
11 )13 )
12 14
13 // closeContext manages the context for a pane close operation15 // closeContext manages the context for a pane close operation
@@ -529,19 +531,28 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existi
529 531
530 // GTK4 handles widget operations automatically - no need to force redraw532 // GTK4 handles widget operations automatically - no need to force redraw
531 533
532- // Handle root split: attach paned to window FIRST, then let GTK handle reparenting534+ // Handle root split: attach paned to ContentArea (tab system is always present)
533 if parent == nil {535 if parent == nil {
534- // ROOT SPLIT: Attach empty paned to window, then add children536+ // ROOT SPLIT: Manipulate TabManager.ContentArea
535- // GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild537+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
536- if wm.window != nil {538+ log.Printf("[workspace] ERROR: Cannot perform root split - tab manager not initialized")
537- // Clear any existing window child (the splitTargetContainer)539+ return nil, fmt.Errorf("tab manager not initialized")
538- wm.window.SetChild(nil)
539-
540- // Attach the new paned to window FIRST
541- wm.window.SetChild(paned)
542- log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned)
543 }540 }
544 541
542+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
543+ if !ok || contentBox == nil {
544+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
545+ return nil, fmt.Errorf("ContentArea is not a Box")
546+ }
547+
548+ // Remove the old workspace root from ContentArea
549+ contentBox.Remove(splitTargetContainer)
550+ log.Printf("[workspace] ROOT SPLIT: removed old root %p from ContentArea", splitTargetContainer)
551+
552+ // Attach the new paned to ContentArea FIRST
553+ contentBox.Append(paned)
554+ log.Printf("[workspace] ROOT SPLIT: attached paned %p to ContentArea", paned)
555+
545 // Now add children to the paned - GTK will handle reparenting automatically556 // Now add children to the paned - GTK will handle reparenting automatically
546 if existingFirst {557 if existingFirst {
547 paned.SetStartChild(splitTargetContainer)558 paned.SetStartChild(splitTargetContainer)
@@ -710,12 +721,41 @@ func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, si
710 }721 }
711 722
712 // swapContainers updates GTK widget hierarchy for promoted siblings723 // swapContainers updates GTK widget hierarchy for promoted siblings
713-func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {724+func (wm *WorkspaceManager) swapContainers(grand *paneNode, parent *paneNode, sibling *paneNode) {
714 if grand == nil {725 if grand == nil {
726+ // Sibling is being promoted to root
715 // When TreeRebalancer is enabled, skip GTK attachment here as it will be727 // When TreeRebalancer is enabled, skip GTK attachment here as it will be
716 // handled by the rebalancer's promotion transaction to avoid double-attachment728 // handled by the rebalancer's promotion transaction to avoid double-attachment
717 if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {729 if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
718- wm.attachRoot(sibling)730+ // NOTE: Can't use attachRoot() here because wm.root has already been updated
731+ // to point to the sibling by promoteSibling(). We need to manually swap in ContentArea:
732+ // the old paned (parent) is still in ContentArea and needs to be removed,
733+ // then the sibling needs to be added.
734+
735+ // The sibling has already been unparented from the old paned earlier in the close logic,
736+ // so it has no GTK parent now.
737+
738+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
739+ log.Printf("[workspace] ERROR: Cannot promote sibling to root - tab manager not initialized")
740+ return
741+ }
742+
743+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
744+ if !ok || contentBox == nil {
745+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
746+ return
747+ }
748+
749+ // CRITICAL: Remove the old paned from ContentArea first
750+ if parent != nil && parent.container != nil {
751+ contentBox.Remove(parent.container)
752+ log.Printf("[workspace] Removed old paned %p from ContentArea", parent.container)
753+ }
754+
755+ // Now add sibling to ContentArea as the new root
756+ contentBox.Append(sibling.container)
757+ webkit.WidgetSetVisible(sibling.container, true)
758+ log.Printf("[workspace] Promoted sibling %p to root in ContentArea", sibling.container)
719 }759 }
720 return760 return
721 }761 }
@@ -827,34 +867,45 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
827 }867 }
828 }868 }
829 869
830-// attachRoot attaches a node as the new window root870+// attachRoot attaches a node as the new root in TabManager.ContentArea.
871+// NOTE: This is typically called AFTER wm.root has already been updated to point to this node.
872+// The old root container (if any) will be orphaned in ContentArea and garbage collected.
831 func (wm *WorkspaceManager) attachRoot(root *paneNode) {873 func (wm *WorkspaceManager) attachRoot(root *paneNode) {
832- if root == nil || root.container == nil || wm.window == nil {874+ if root == nil || root.container == nil {
833 return875 return
834 }876 }
835 877
836 // Note: In gotk4, reference counting is handled automatically by Go's GC878 // Note: In gotk4, reference counting is handled automatically by Go's GC
837 // No need for manual Ref()/Unref() calls879 // No need for manual Ref()/Unref() calls
838 880
839- // Detach from previous container (paned, stack, etc.) before replacing the window child.881+ // Detach from previous container (paned, stack, etc.) before attaching to ContentArea
840- // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent882+ // While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent
841 if parent := webkit.WidgetGetParent(root.container); parent != nil {883 if parent := webkit.WidgetGetParent(root.container); parent != nil {
842- log.Printf("[workspace] unparenting widget %p from parent %p before window attach", root.container, parent)884+ log.Printf("[workspace] unparenting widget %p from parent %p before root attach", root.container, parent)
843 webkit.WidgetUnparent(root.container)885 webkit.WidgetUnparent(root.container)
844 // Note: In gotk4, the Go object reference remains valid after unparent886 // Note: In gotk4, the Go object reference remains valid after unparent
845 // Only the GTK parent relationship is cleared887 // Only the GTK parent relationship is cleared
846 }888 }
847 889
848- // Clear any existing window child to avoid GTK warnings when swapping roots.
849- wm.window.SetChild(nil)
850-
851 if root.container != nil {890 if root.container != nil {
852 webkit.WidgetSetHExpand(root.container, true)891 webkit.WidgetSetHExpand(root.container, true)
853 webkit.WidgetSetVExpand(root.container, true)892 webkit.WidgetSetVExpand(root.container, true)
854 }893 }
855 894
856- // GTK4 will automatically unparent root.container from its previous parent when we attach it here895+ // Add to ContentArea (the old root will be orphaned and garbage collected)
857- wm.window.SetChild(root.container)896+ if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
897+ log.Printf("[workspace] ERROR: Cannot attach root - tab manager not initialized")
898+ return
899+ }
900+
901+ contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
902+ if !ok || contentBox == nil {
903+ log.Printf("[workspace] ERROR: ContentArea is not a Box")
904+ return
905+ }
906+
907+ contentBox.Append(root.container)
908+ webkit.WidgetSetVisible(root.container, true)
858 if root.container != nil {909 if root.container != nil {
859 webkit.WidgetQueueAllocate(root.container)910 webkit.WidgetQueueAllocate(root.container)
860 webkit.WidgetShow(root.container)911 webkit.WidgetShow(root.container)
@@ -1051,8 +1102,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1051 1102
1052 // STEP 7: GTK widget reparenting1103 // STEP 7: GTK widget reparenting
1053 // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent1104 // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
1054- // For window: we already manually unparented above, so now we can safely attach1105+ // For root promotion: we need to remove the old paned from ContentArea
1055- wm.swapContainers(grandparent, sibling)1106+ wm.swapContainers(grandparent, parent, sibling)
1056 1107
1057 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.1108 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1058 if sibling != nil && sibling.container != nil {1109 if sibling != nil && sibling.container != nil {
@@ -1137,10 +1188,10 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
1137 }1188 }
1138 }1189 }
1139 } else {1190 } else {
1140- // Stack was root1191+ // Stack was root - attach remaining pane as new root
1141 wm.root = remaining1192 wm.root = remaining
1142 if remaining.container != nil {1193 if remaining.container != nil {
1143- wm.window.SetChild(remaining.container)1194+ wm.setRootContainer(remaining.container)
1144 }1195 }
1145 }1196 }
1146 1197

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