detached 0eedbbe

refactor(browser): split workspace_manager.go into focused modules for better maintainability

commit
0eedbbe3ac2f2074bf9dbcce4985c8f34c7d2130
author
Brice · · 2025-09-27 19:25 UTC
parent
4fb244f
signature
unsigned
7 files · +2368 -2279
A internal/app/browser/workspace_css.go +170 -0
@@ -0,0 +1,170 @@
1+// workspace_css.go - CSS generation and styling for workspace panes
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// Helper functions for stacked pane CSS colors
13+
14+// getStackTitleBg returns the background color for stack titles based on theme
15+func getStackTitleBg(isDark bool) string {
16+ if isDark {
17+ return "#404040"
18+ }
19+ return "#f0f0f0"
20+}
21+
22+// getStackTitleHoverBg returns the hover background color for stack titles based on theme
23+func getStackTitleHoverBg(isDark bool) string {
24+ if isDark {
25+ return "#505050"
26+ }
27+ return "#e0e0e0"
28+}
29+
30+// getStackTitleTextColor returns the text color for stack titles based on theme
31+func getStackTitleTextColor(isDark bool) string {
32+ if isDark {
33+ return "#ffffff"
34+ }
35+ return "#333333"
36+}
37+
38+// generateActivePaneCSS generates the CSS for workspace panes based on config
39+func (wm *WorkspaceManager) generateActivePaneCSS() string {
40+ cfg := config.Get()
41+ styling := cfg.Workspace.Styling
42+
43+ // Get appropriate border colors based on GTK theme preference
44+ var inactiveBorderColor, windowBackgroundColor string
45+ isDark := webkit.PrefersDarkTheme()
46+ if isDark {
47+ inactiveBorderColor = "#333333" // Dark border for dark theme
48+ windowBackgroundColor = "#2b2b2b" // Dark window background
49+ } else {
50+ inactiveBorderColor = "#dddddd" // Light border for light theme
51+ windowBackgroundColor = "#ffffff" // Light window background
52+ }
53+
54+ // Log the border color values for debugging
55+ log.Printf("[workspace] GTK prefers dark: %v, inactive border color: %s", isDark, inactiveBorderColor)
56+
57+ css := fmt.Sprintf(`window {
58+ background-color: %s;
59+ }
60+
61+ .workspace-pane {
62+ background-color: %s;
63+ border: %dpx solid %s;
64+ transition: border-color %dms ease-in-out;
65+ border-radius: %dpx;
66+ }
67+
68+ .workspace-pane.workspace-multi-pane {
69+ border: %dpx solid %s;
70+ border-radius: %dpx;
71+ }
72+
73+ .workspace-pane.workspace-multi-pane.workspace-pane-active {
74+ border-color: %s;
75+ }
76+
77+ /* Active border for ALL workspace panes (including stacked panes) */
78+ .workspace-pane.workspace-pane-active {
79+ border-color: %s !important;
80+ }
81+
82+ /* Stacked panes styling */
83+ .stacked-pane-container {
84+ background-color: %s;
85+ border-radius: %dpx;
86+ }
87+
88+ .stacked-pane-title {
89+ background-color: %s;
90+ border-bottom: 1px solid %s;
91+ padding: 4px 8px;
92+ min-height: 24px;
93+ transition: background-color %dms ease-in-out;
94+ }
95+
96+ .stacked-pane-title:hover {
97+ background-color: %s;
98+ }
99+
100+ .stacked-pane-title-text {
101+ font-size: 12px;
102+ color: %s;
103+ font-weight: 500;
104+ }
105+
106+ .stacked-pane-active {
107+ /* Active pane is fully visible */
108+ }
109+
110+ .stacked-pane-collapsed {
111+ /* Collapsed panes are hidden - handled in code via widget visibility */
112+ }`,
113+ windowBackgroundColor,
114+ windowBackgroundColor,
115+ styling.BorderWidth,
116+ inactiveBorderColor,
117+ styling.TransitionDuration,
118+ styling.BorderRadius,
119+ styling.BorderWidth,
120+ inactiveBorderColor,
121+ styling.BorderRadius,
122+ styling.BorderColor,
123+ styling.BorderColor, // NEW: .workspace-pane.workspace-pane-active border-color
124+ // Additional parameters for stacked pane styles
125+ windowBackgroundColor, // stacked-pane-container background
126+ styling.BorderRadius, // stacked-pane-container border-radius
127+ getStackTitleBg(isDark), // stacked-pane-title background
128+ inactiveBorderColor, // stacked-pane-title border-bottom
129+ styling.TransitionDuration, // stacked-pane-title transition
130+ getStackTitleHoverBg(isDark), // stacked-pane-title:hover background
131+ getStackTitleTextColor(isDark), // stacked-pane-title-text color
132+ )
133+
134+ // Log the actual CSS being generated
135+ log.Printf("[workspace] Generated CSS: %s", css)
136+
137+ return css
138+}
139+
140+// ensureStyles ensures that CSS styles are applied to the workspace
141+func (wm *WorkspaceManager) ensureStyles() {
142+ if wm == nil || wm.cssInitialized {
143+ return
144+ }
145+ activePaneCSS := wm.generateActivePaneCSS()
146+ webkit.AddCSSProvider(activePaneCSS)
147+ wm.cssInitialized = true
148+}
149+
150+// ensurePaneBaseClasses ensures all panes have the proper base CSS classes
151+func (wm *WorkspaceManager) ensurePaneBaseClasses() {
152+ if wm == nil {
153+ return
154+ }
155+
156+ leaves := wm.collectLeaves()
157+ for _, leaf := range leaves {
158+ if leaf != nil && leaf.container != nil {
159+ leaf.container.Execute(func(containerPtr uintptr) error {
160+ webkit.WidgetAddCSSClass(containerPtr, basePaneClass)
161+ if wm.hasMultiplePanes() {
162+ webkit.WidgetAddCSSClass(containerPtr, multiPaneClass)
163+ } else {
164+ webkit.WidgetRemoveCSSClass(containerPtr, multiPaneClass)
165+ }
166+ return nil
167+ })
168+ }
169+ }
170+}
@@ -0,0 +1,170 @@
1+// workspace_css.go - CSS generation and styling for workspace panes
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// Helper functions for stacked pane CSS colors
13+
14+// getStackTitleBg returns the background color for stack titles based on theme
15+func getStackTitleBg(isDark bool) string {
16+ if isDark {
17+ return "#404040"
18+ }
19+ return "#f0f0f0"
20+}
21+
22+// getStackTitleHoverBg returns the hover background color for stack titles based on theme
23+func getStackTitleHoverBg(isDark bool) string {
24+ if isDark {
25+ return "#505050"
26+ }
27+ return "#e0e0e0"
28+}
29+
30+// getStackTitleTextColor returns the text color for stack titles based on theme
31+func getStackTitleTextColor(isDark bool) string {
32+ if isDark {
33+ return "#ffffff"
34+ }
35+ return "#333333"
36+}
37+
38+// generateActivePaneCSS generates the CSS for workspace panes based on config
39+func (wm *WorkspaceManager) generateActivePaneCSS() string {
40+ cfg := config.Get()
41+ styling := cfg.Workspace.Styling
42+
43+ // Get appropriate border colors based on GTK theme preference
44+ var inactiveBorderColor, windowBackgroundColor string
45+ isDark := webkit.PrefersDarkTheme()
46+ if isDark {
47+ inactiveBorderColor = "#333333" // Dark border for dark theme
48+ windowBackgroundColor = "#2b2b2b" // Dark window background
49+ } else {
50+ inactiveBorderColor = "#dddddd" // Light border for light theme
51+ windowBackgroundColor = "#ffffff" // Light window background
52+ }
53+
54+ // Log the border color values for debugging
55+ log.Printf("[workspace] GTK prefers dark: %v, inactive border color: %s", isDark, inactiveBorderColor)
56+
57+ css := fmt.Sprintf(`window {
58+ background-color: %s;
59+ }
60+
61+ .workspace-pane {
62+ background-color: %s;
63+ border: %dpx solid %s;
64+ transition: border-color %dms ease-in-out;
65+ border-radius: %dpx;
66+ }
67+
68+ .workspace-pane.workspace-multi-pane {
69+ border: %dpx solid %s;
70+ border-radius: %dpx;
71+ }
72+
73+ .workspace-pane.workspace-multi-pane.workspace-pane-active {
74+ border-color: %s;
75+ }
76+
77+ /* Active border for ALL workspace panes (including stacked panes) */
78+ .workspace-pane.workspace-pane-active {
79+ border-color: %s !important;
80+ }
81+
82+ /* Stacked panes styling */
83+ .stacked-pane-container {
84+ background-color: %s;
85+ border-radius: %dpx;
86+ }
87+
88+ .stacked-pane-title {
89+ background-color: %s;
90+ border-bottom: 1px solid %s;
91+ padding: 4px 8px;
92+ min-height: 24px;
93+ transition: background-color %dms ease-in-out;
94+ }
95+
96+ .stacked-pane-title:hover {
97+ background-color: %s;
98+ }
99+
100+ .stacked-pane-title-text {
101+ font-size: 12px;
102+ color: %s;
103+ font-weight: 500;
104+ }
105+
106+ .stacked-pane-active {
107+ /* Active pane is fully visible */
108+ }
109+
110+ .stacked-pane-collapsed {
111+ /* Collapsed panes are hidden - handled in code via widget visibility */
112+ }`,
113+ windowBackgroundColor,
114+ windowBackgroundColor,
115+ styling.BorderWidth,
116+ inactiveBorderColor,
117+ styling.TransitionDuration,
118+ styling.BorderRadius,
119+ styling.BorderWidth,
120+ inactiveBorderColor,
121+ styling.BorderRadius,
122+ styling.BorderColor,
123+ styling.BorderColor, // NEW: .workspace-pane.workspace-pane-active border-color
124+ // Additional parameters for stacked pane styles
125+ windowBackgroundColor, // stacked-pane-container background
126+ styling.BorderRadius, // stacked-pane-container border-radius
127+ getStackTitleBg(isDark), // stacked-pane-title background
128+ inactiveBorderColor, // stacked-pane-title border-bottom
129+ styling.TransitionDuration, // stacked-pane-title transition
130+ getStackTitleHoverBg(isDark), // stacked-pane-title:hover background
131+ getStackTitleTextColor(isDark), // stacked-pane-title-text color
132+ )
133+
134+ // Log the actual CSS being generated
135+ log.Printf("[workspace] Generated CSS: %s", css)
136+
137+ return css
138+}
139+
140+// ensureStyles ensures that CSS styles are applied to the workspace
141+func (wm *WorkspaceManager) ensureStyles() {
142+ if wm == nil || wm.cssInitialized {
143+ return
144+ }
145+ activePaneCSS := wm.generateActivePaneCSS()
146+ webkit.AddCSSProvider(activePaneCSS)
147+ wm.cssInitialized = true
148+}
149+
150+// ensurePaneBaseClasses ensures all panes have the proper base CSS classes
151+func (wm *WorkspaceManager) ensurePaneBaseClasses() {
152+ if wm == nil {
153+ return
154+ }
155+
156+ leaves := wm.collectLeaves()
157+ for _, leaf := range leaves {
158+ if leaf != nil && leaf.container != nil {
159+ leaf.container.Execute(func(containerPtr uintptr) error {
160+ webkit.WidgetAddCSSClass(containerPtr, basePaneClass)
161+ if wm.hasMultiplePanes() {
162+ webkit.WidgetAddCSSClass(containerPtr, multiPaneClass)
163+ } else {
164+ webkit.WidgetRemoveCSSClass(containerPtr, multiPaneClass)
165+ }
166+ return nil
167+ })
168+ }
169+ }
170+}
M internal/app/browser/workspace_manager.go +1 -2279 large diff (2280 lines changed): collapsed
A internal/app/browser/workspace_navigation.go +473 -0
@@ -0,0 +1,473 @@
1+// workspace_navigation.go - Navigation and focus management for workspace panes
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "math"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/dumber/pkg/webkit"
12+)
13+
14+// DispatchPaneFocusEvent sends a workspace focus event to a pane's webview
15+func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) {
16+ if node == nil || node.pane == nil || node.pane.webView == nil {
17+ return
18+ }
19+
20+ detail := map[string]any{
21+ "active": active,
22+ "webview": fmt.Sprintf("%p", node.pane.webView),
23+ "webviewId": node.pane.webView.ID(),
24+ "hasGUI": node.pane.HasGUI(),
25+ "timestamp": time.Now().UnixMilli(),
26+ }
27+
28+ if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
29+ log.Printf("[workspace] failed to dispatch focus event: %v", err)
30+ } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
31+ log.Printf("[workspace] dispatched focus event for webview %s (active=%v)", node.pane.webView.ID(), active)
32+ }
33+}
34+
35+// focusAfterPaneMode restores focus when a pane mode operation completes without
36+// stealing focus from the active pane inside a stack that just spawned a sibling.
37+func (wm *WorkspaceManager) focusAfterPaneMode(node *paneNode) {
38+ wm.focusRespectingStack(node, "focus-after-pane-mode")
39+}
40+
41+// focusRespectingStack handles focus while respecting stacked pane hierarchies
42+func (wm *WorkspaceManager) focusRespectingStack(node *paneNode, reason string) {
43+ if node == nil {
44+ return
45+ }
46+
47+ if stack := node.parent; stack != nil && stack.isStacked {
48+ activeIndex := stack.activeStackIndex
49+ if activeIndex >= 0 && activeIndex < len(stack.stackedPanes) {
50+ activePane := stack.stackedPanes[activeIndex]
51+ if activePane != nil && activePane != node {
52+ if reason != "" {
53+ log.Printf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex)
54+ }
55+ wm.currentlyFocused = activePane
56+ wm.focusManager.SetActivePane(activePane)
57+ return
58+ }
59+ }
60+ }
61+
62+ wm.currentlyFocused = node
63+ wm.focusManager.SetActivePane(node)
64+}
65+
66+// focusByView changes focus to the pane containing the specified WebView
67+func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
68+ if wm == nil || view == nil {
69+ return
70+ }
71+
72+ // Throttle focus changes to prevent infinite loops
73+ wm.focusThrottleMutex.Lock()
74+ const focusThrottleInterval = 100 * time.Millisecond
75+ if time.Since(wm.lastFocusChange) < focusThrottleInterval {
76+ wm.focusThrottleMutex.Unlock()
77+ return
78+ }
79+ wm.lastFocusChange = time.Now()
80+ wm.focusThrottleMutex.Unlock()
81+
82+ if node, ok := wm.viewToNode[view]; ok {
83+ if wm.currentlyFocused != node {
84+ wm.focusRespectingStack(node, "focus-by-view")
85+ }
86+ }
87+}
88+
89+// ensureHover sets up hover handlers for a pane node
90+func (wm *WorkspaceManager) ensureHover(node *paneNode) {
91+ if wm == nil || node == nil || !node.isLeaf {
92+ return
93+ }
94+ if node.container == nil || node.hoverToken != 0 {
95+ return
96+ }
97+
98+ var token uintptr
99+ node.container.Execute(func(containerPtr uintptr) error {
100+ token = webkit.WidgetAddHoverHandler(containerPtr, func() {
101+ if wm == nil {
102+ return
103+ }
104+ wm.focusManager.SetActivePane(node)
105+ wm.currentlyFocused = node
106+ })
107+ return nil
108+ })
109+ node.hoverToken = token
110+ if token == 0 {
111+ log.Printf("[workspace] failed to attach hover handler")
112+ }
113+}
114+
115+// detachHover removes hover handlers from a pane node
116+func (wm *WorkspaceManager) detachHover(node *paneNode) {
117+ if wm == nil || node == nil || node.hoverToken == 0 {
118+ return
119+ }
120+ node.container.Execute(func(containerPtr uintptr) error {
121+ webkit.WidgetRemoveHoverHandler(containerPtr, node.hoverToken)
122+ return nil
123+ })
124+ node.hoverToken = 0
125+}
126+
127+// FocusNeighbor moves focus to the nearest pane in the requested direction using the
128+// actual widget geometry to determine adjacency. For stacked panes, "up" and "down"
129+// navigate within the stack.
130+func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
131+ if wm == nil {
132+ return false
133+ }
134+ switch strings.ToLower(direction) {
135+ case "up", "down":
136+ // Check if current pane is part of a stack and handle stack navigation
137+ if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) {
138+ return true
139+ }
140+ // Fall back to regular adjacency navigation
141+ return wm.focusAdjacent(strings.ToLower(direction))
142+ case "left", "right":
143+ return wm.focusAdjacent(strings.ToLower(direction))
144+ default:
145+ return false
146+ }
147+}
148+
149+// navigateStack handles navigation within a stacked pane container.
150+func (wm *WorkspaceManager) navigateStack(direction string) bool {
151+ if wm.currentlyFocused == nil {
152+ return false
153+ }
154+
155+ // Find the stack container this pane belongs to
156+ var stackNode *paneNode
157+ current := wm.currentlyFocused
158+
159+ // Check if current pane is directly in a stack
160+ if current.parent != nil && current.parent.isStacked {
161+ stackNode = current.parent
162+ } else {
163+ // Current pane might be the stack container itself if it was the first pane converted to stack
164+ if current.isStacked {
165+ stackNode = current
166+ }
167+ }
168+
169+ if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
170+ return false // Not in a stack or stack has only one pane
171+ }
172+
173+ // Find current pane's index in the stack
174+ currentIndex := -1
175+ for i, pane := range stackNode.stackedPanes {
176+ if pane == current || (pane.pane != nil && current.pane != nil && pane.pane.webView == current.pane.webView) {
177+ currentIndex = i
178+ break
179+ }
180+ }
181+
182+ if currentIndex == -1 {
183+ log.Printf("[workspace] navigateStack: current pane not found in stack")
184+ return false
185+ }
186+
187+ // Calculate new index based on direction
188+ var newIndex int
189+ switch direction {
190+ case "up":
191+ newIndex = currentIndex - 1
192+ if newIndex < 0 {
193+ newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
194+ }
195+ case "down":
196+ newIndex = currentIndex + 1
197+ if newIndex >= len(stackNode.stackedPanes) {
198+ newIndex = 0 // Wrap to first
199+ }
200+ default:
201+ return false
202+ }
203+
204+ if newIndex == currentIndex {
205+ return false // No change
206+ }
207+
208+ // Update active stack index and visibility
209+ stackNode.activeStackIndex = newIndex
210+ wm.stackedPaneManager.UpdateStackVisibility(stackNode)
211+
212+ // Focus the new active pane
213+ newActivePane := stackNode.stackedPanes[newIndex]
214+ wm.focusManager.SetActivePane(newActivePane)
215+ wm.currentlyFocused = newActivePane
216+
217+ log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
218+ direction, currentIndex, newIndex, len(stackNode.stackedPanes))
219+ return true
220+}
221+
222+// focusAdjacent uses geometry-based calculations to find and focus adjacent panes
223+func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
224+ if wm.currentlyFocused == nil || !wm.currentlyFocused.isLeaf || wm.currentlyFocused.container == nil {
225+ return false
226+ }
227+
228+ if neighbor := wm.structuralNeighbor(wm.currentlyFocused, direction); neighbor != nil {
229+ wm.focusManager.SetActivePane(neighbor)
230+ wm.currentlyFocused = neighbor
231+ return true
232+ }
233+
234+ var currentBounds webkit.WidgetBounds
235+ var ok bool
236+ wm.currentlyFocused.container.Execute(func(containerPtr uintptr) error {
237+ currentBounds, ok = webkit.WidgetGetBounds(containerPtr)
238+ return nil
239+ })
240+ if !ok {
241+ log.Printf("[workspace] unable to compute bounds for active pane")
242+ return false
243+ }
244+
245+ cx := currentBounds.X + currentBounds.Width/2.0
246+ cy := currentBounds.Y + currentBounds.Height/2.0
247+
248+ leaves := wm.collectLeaves()
249+ bestScore := math.MaxFloat64
250+ var best *paneNode
251+ var debugCandidates []string
252+
253+ for _, candidate := range leaves {
254+ if candidate == nil || candidate == wm.currentlyFocused || candidate.container == nil {
255+ continue
256+ }
257+ var bounds webkit.WidgetBounds
258+ var boundsOk bool
259+ candidate.container.Execute(func(containerPtr uintptr) error {
260+ bounds, boundsOk = webkit.WidgetGetBounds(containerPtr)
261+ return nil
262+ })
263+ ok := boundsOk
264+ if !ok {
265+ continue
266+ }
267+ tx := bounds.X + bounds.Width/2.0
268+ ty := bounds.Y + bounds.Height/2.0
269+
270+ dx := tx - cx
271+ dy := ty - cy
272+
273+ var score float64
274+ switch direction {
275+ case "left":
276+ if dx >= -focusEpsilon {
277+ continue
278+ }
279+ score = math.Abs(dx)*1000 + math.Abs(dy)
280+ case "right":
281+ if dx <= focusEpsilon {
282+ continue
283+ }
284+ score = math.Abs(dx)*1000 + math.Abs(dy)
285+ case "up":
286+ if dy >= -focusEpsilon {
287+ continue
288+ }
289+ score = math.Abs(dy)*1000 + math.Abs(dx)
290+ case "down":
291+ if dy <= focusEpsilon {
292+ continue
293+ }
294+ score = math.Abs(dy)*1000 + math.Abs(dx)
295+ }
296+
297+ if direction == "up" || direction == "down" {
298+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
299+ }
300+
301+ if score < bestScore {
302+ bestScore = score
303+ best = candidate
304+ }
305+ }
306+
307+ if best != nil {
308+ wm.focusManager.SetActivePane(best)
309+ wm.currentlyFocused = best
310+ return true
311+ }
312+
313+ if len(debugCandidates) > 0 {
314+ log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, wm.currentlyFocused.container, strings.Join(debugCandidates, "; "))
315+ }
316+ return false
317+}
318+
319+// structuralNeighbor finds neighbors based on the tree structure rather than geometry
320+func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
321+ if node == nil || node.container == nil {
322+ return nil
323+ }
324+
325+ var refBounds webkit.WidgetBounds
326+ var ok bool
327+ node.container.Execute(func(containerPtr uintptr) error {
328+ refBounds, ok = webkit.WidgetGetBounds(containerPtr)
329+ return nil
330+ })
331+ if !ok {
332+ return nil
333+ }
334+ cx := refBounds.X + refBounds.Width/2.0
335+ cy := refBounds.Y + refBounds.Height/2.0
336+ axisVertical := direction == "up" || direction == "down"
337+
338+ for parent := node.parent; parent != nil; parent = parent.parent {
339+ switch direction {
340+ case "up":
341+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {
342+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
343+ return leaf
344+ }
345+ }
346+ case "down":
347+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {
348+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
349+ return leaf
350+ }
351+ }
352+ case "left":
353+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {
354+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
355+ return leaf
356+ }
357+ }
358+ case "right":
359+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {
360+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
361+ return leaf
362+ }
363+ }
364+ }
365+ node = parent
366+ }
367+ return nil
368+}
369+
370+// closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
371+func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
372+ leaves := wm.collectLeavesFrom(node)
373+ bestScore := math.MaxFloat64
374+ var best *paneNode
375+ for _, leaf := range leaves {
376+ if leaf == nil || leaf.container == nil {
377+ continue
378+ }
379+ var bounds webkit.WidgetBounds
380+ var boundsOk bool
381+ leaf.container.Execute(func(containerPtr uintptr) error {
382+ bounds, boundsOk = webkit.WidgetGetBounds(containerPtr)
383+ return nil
384+ })
385+ ok := boundsOk
386+ if !ok {
387+ continue
388+ }
389+ tx := bounds.X + bounds.Width/2.0
390+ ty := bounds.Y + bounds.Height/2.0
391+ dx := tx - cx
392+ dy := ty - cy
393+ var score float64
394+ switch direction {
395+ case "left":
396+ if dx >= -focusEpsilon {
397+ continue
398+ }
399+ score = math.Abs(dx)*1000 + math.Abs(dy)
400+ case "right":
401+ if dx <= focusEpsilon {
402+ continue
403+ }
404+ score = math.Abs(dx)*1000 + math.Abs(dy)
405+ case "up":
406+ if dy >= -focusEpsilon {
407+ continue
408+ }
409+ score = math.Abs(dy)*1000 + math.Abs(dx)
410+ case "down":
411+ if dy <= focusEpsilon {
412+ continue
413+ }
414+ score = math.Abs(dy)*1000 + math.Abs(dx)
415+ default:
416+ continue
417+ }
418+ if score < bestScore {
419+ bestScore = score
420+ best = leaf
421+ }
422+ }
423+ if best == nil {
424+ return wm.boundaryFallback(node, direction)
425+ }
426+ return best
427+}
428+
429+// boundaryFallback provides fallback navigation when geometric search fails
430+func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
431+ return wm.boundaryFallbackWithDepth(node, direction, 0)
432+}
433+
434+// boundaryFallbackWithDepth provides recursive fallback with depth protection
435+func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode {
436+ // Prevent infinite recursion - max tree depth should be reasonable
437+ const maxDepth = 50
438+ if depth > maxDepth {
439+ log.Printf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption")
440+ return nil
441+ }
442+
443+ if node == nil {
444+ return nil
445+ }
446+ if node.isLeaf {
447+ return node
448+ }
449+ switch direction {
450+ case "up":
451+ if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
452+ return leaf
453+ }
454+ return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
455+ case "down":
456+ if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
457+ return leaf
458+ }
459+ return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
460+ case "left":
461+ if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
462+ return leaf
463+ }
464+ return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
465+ case "right":
466+ if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
467+ return leaf
468+ }
469+ return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
470+ default:
471+ return nil
472+ }
473+}
@@ -0,0 +1,473 @@
1+// workspace_navigation.go - Navigation and focus management for workspace panes
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "math"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/dumber/pkg/webkit"
12+)
13+
14+// DispatchPaneFocusEvent sends a workspace focus event to a pane's webview
15+func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) {
16+ if node == nil || node.pane == nil || node.pane.webView == nil {
17+ return
18+ }
19+
20+ detail := map[string]any{
21+ "active": active,
22+ "webview": fmt.Sprintf("%p", node.pane.webView),
23+ "webviewId": node.pane.webView.ID(),
24+ "hasGUI": node.pane.HasGUI(),
25+ "timestamp": time.Now().UnixMilli(),
26+ }
27+
28+ if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
29+ log.Printf("[workspace] failed to dispatch focus event: %v", err)
30+ } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
31+ log.Printf("[workspace] dispatched focus event for webview %s (active=%v)", node.pane.webView.ID(), active)
32+ }
33+}
34+
35+// focusAfterPaneMode restores focus when a pane mode operation completes without
36+// stealing focus from the active pane inside a stack that just spawned a sibling.
37+func (wm *WorkspaceManager) focusAfterPaneMode(node *paneNode) {
38+ wm.focusRespectingStack(node, "focus-after-pane-mode")
39+}
40+
41+// focusRespectingStack handles focus while respecting stacked pane hierarchies
42+func (wm *WorkspaceManager) focusRespectingStack(node *paneNode, reason string) {
43+ if node == nil {
44+ return
45+ }
46+
47+ if stack := node.parent; stack != nil && stack.isStacked {
48+ activeIndex := stack.activeStackIndex
49+ if activeIndex >= 0 && activeIndex < len(stack.stackedPanes) {
50+ activePane := stack.stackedPanes[activeIndex]
51+ if activePane != nil && activePane != node {
52+ if reason != "" {
53+ log.Printf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex)
54+ }
55+ wm.currentlyFocused = activePane
56+ wm.focusManager.SetActivePane(activePane)
57+ return
58+ }
59+ }
60+ }
61+
62+ wm.currentlyFocused = node
63+ wm.focusManager.SetActivePane(node)
64+}
65+
66+// focusByView changes focus to the pane containing the specified WebView
67+func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
68+ if wm == nil || view == nil {
69+ return
70+ }
71+
72+ // Throttle focus changes to prevent infinite loops
73+ wm.focusThrottleMutex.Lock()
74+ const focusThrottleInterval = 100 * time.Millisecond
75+ if time.Since(wm.lastFocusChange) < focusThrottleInterval {
76+ wm.focusThrottleMutex.Unlock()
77+ return
78+ }
79+ wm.lastFocusChange = time.Now()
80+ wm.focusThrottleMutex.Unlock()
81+
82+ if node, ok := wm.viewToNode[view]; ok {
83+ if wm.currentlyFocused != node {
84+ wm.focusRespectingStack(node, "focus-by-view")
85+ }
86+ }
87+}
88+
89+// ensureHover sets up hover handlers for a pane node
90+func (wm *WorkspaceManager) ensureHover(node *paneNode) {
91+ if wm == nil || node == nil || !node.isLeaf {
92+ return
93+ }
94+ if node.container == nil || node.hoverToken != 0 {
95+ return
96+ }
97+
98+ var token uintptr
99+ node.container.Execute(func(containerPtr uintptr) error {
100+ token = webkit.WidgetAddHoverHandler(containerPtr, func() {
101+ if wm == nil {
102+ return
103+ }
104+ wm.focusManager.SetActivePane(node)
105+ wm.currentlyFocused = node
106+ })
107+ return nil
108+ })
109+ node.hoverToken = token
110+ if token == 0 {
111+ log.Printf("[workspace] failed to attach hover handler")
112+ }
113+}
114+
115+// detachHover removes hover handlers from a pane node
116+func (wm *WorkspaceManager) detachHover(node *paneNode) {
117+ if wm == nil || node == nil || node.hoverToken == 0 {
118+ return
119+ }
120+ node.container.Execute(func(containerPtr uintptr) error {
121+ webkit.WidgetRemoveHoverHandler(containerPtr, node.hoverToken)
122+ return nil
123+ })
124+ node.hoverToken = 0
125+}
126+
127+// FocusNeighbor moves focus to the nearest pane in the requested direction using the
128+// actual widget geometry to determine adjacency. For stacked panes, "up" and "down"
129+// navigate within the stack.
130+func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
131+ if wm == nil {
132+ return false
133+ }
134+ switch strings.ToLower(direction) {
135+ case "up", "down":
136+ // Check if current pane is part of a stack and handle stack navigation
137+ if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) {
138+ return true
139+ }
140+ // Fall back to regular adjacency navigation
141+ return wm.focusAdjacent(strings.ToLower(direction))
142+ case "left", "right":
143+ return wm.focusAdjacent(strings.ToLower(direction))
144+ default:
145+ return false
146+ }
147+}
148+
149+// navigateStack handles navigation within a stacked pane container.
150+func (wm *WorkspaceManager) navigateStack(direction string) bool {
151+ if wm.currentlyFocused == nil {
152+ return false
153+ }
154+
155+ // Find the stack container this pane belongs to
156+ var stackNode *paneNode
157+ current := wm.currentlyFocused
158+
159+ // Check if current pane is directly in a stack
160+ if current.parent != nil && current.parent.isStacked {
161+ stackNode = current.parent
162+ } else {
163+ // Current pane might be the stack container itself if it was the first pane converted to stack
164+ if current.isStacked {
165+ stackNode = current
166+ }
167+ }
168+
169+ if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
170+ return false // Not in a stack or stack has only one pane
171+ }
172+
173+ // Find current pane's index in the stack
174+ currentIndex := -1
175+ for i, pane := range stackNode.stackedPanes {
176+ if pane == current || (pane.pane != nil && current.pane != nil && pane.pane.webView == current.pane.webView) {
177+ currentIndex = i
178+ break
179+ }
180+ }
181+
182+ if currentIndex == -1 {
183+ log.Printf("[workspace] navigateStack: current pane not found in stack")
184+ return false
185+ }
186+
187+ // Calculate new index based on direction
188+ var newIndex int
189+ switch direction {
190+ case "up":
191+ newIndex = currentIndex - 1
192+ if newIndex < 0 {
193+ newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
194+ }
195+ case "down":
196+ newIndex = currentIndex + 1
197+ if newIndex >= len(stackNode.stackedPanes) {
198+ newIndex = 0 // Wrap to first
199+ }
200+ default:
201+ return false
202+ }
203+
204+ if newIndex == currentIndex {
205+ return false // No change
206+ }
207+
208+ // Update active stack index and visibility
209+ stackNode.activeStackIndex = newIndex
210+ wm.stackedPaneManager.UpdateStackVisibility(stackNode)
211+
212+ // Focus the new active pane
213+ newActivePane := stackNode.stackedPanes[newIndex]
214+ wm.focusManager.SetActivePane(newActivePane)
215+ wm.currentlyFocused = newActivePane
216+
217+ log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
218+ direction, currentIndex, newIndex, len(stackNode.stackedPanes))
219+ return true
220+}
221+
222+// focusAdjacent uses geometry-based calculations to find and focus adjacent panes
223+func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
224+ if wm.currentlyFocused == nil || !wm.currentlyFocused.isLeaf || wm.currentlyFocused.container == nil {
225+ return false
226+ }
227+
228+ if neighbor := wm.structuralNeighbor(wm.currentlyFocused, direction); neighbor != nil {
229+ wm.focusManager.SetActivePane(neighbor)
230+ wm.currentlyFocused = neighbor
231+ return true
232+ }
233+
234+ var currentBounds webkit.WidgetBounds
235+ var ok bool
236+ wm.currentlyFocused.container.Execute(func(containerPtr uintptr) error {
237+ currentBounds, ok = webkit.WidgetGetBounds(containerPtr)
238+ return nil
239+ })
240+ if !ok {
241+ log.Printf("[workspace] unable to compute bounds for active pane")
242+ return false
243+ }
244+
245+ cx := currentBounds.X + currentBounds.Width/2.0
246+ cy := currentBounds.Y + currentBounds.Height/2.0
247+
248+ leaves := wm.collectLeaves()
249+ bestScore := math.MaxFloat64
250+ var best *paneNode
251+ var debugCandidates []string
252+
253+ for _, candidate := range leaves {
254+ if candidate == nil || candidate == wm.currentlyFocused || candidate.container == nil {
255+ continue
256+ }
257+ var bounds webkit.WidgetBounds
258+ var boundsOk bool
259+ candidate.container.Execute(func(containerPtr uintptr) error {
260+ bounds, boundsOk = webkit.WidgetGetBounds(containerPtr)
261+ return nil
262+ })
263+ ok := boundsOk
264+ if !ok {
265+ continue
266+ }
267+ tx := bounds.X + bounds.Width/2.0
268+ ty := bounds.Y + bounds.Height/2.0
269+
270+ dx := tx - cx
271+ dy := ty - cy
272+
273+ var score float64
274+ switch direction {
275+ case "left":
276+ if dx >= -focusEpsilon {
277+ continue
278+ }
279+ score = math.Abs(dx)*1000 + math.Abs(dy)
280+ case "right":
281+ if dx <= focusEpsilon {
282+ continue
283+ }
284+ score = math.Abs(dx)*1000 + math.Abs(dy)
285+ case "up":
286+ if dy >= -focusEpsilon {
287+ continue
288+ }
289+ score = math.Abs(dy)*1000 + math.Abs(dx)
290+ case "down":
291+ if dy <= focusEpsilon {
292+ continue
293+ }
294+ score = math.Abs(dy)*1000 + math.Abs(dx)
295+ }
296+
297+ if direction == "up" || direction == "down" {
298+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
299+ }
300+
301+ if score < bestScore {
302+ bestScore = score
303+ best = candidate
304+ }
305+ }
306+
307+ if best != nil {
308+ wm.focusManager.SetActivePane(best)
309+ wm.currentlyFocused = best
310+ return true
311+ }
312+
313+ if len(debugCandidates) > 0 {
314+ log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, wm.currentlyFocused.container, strings.Join(debugCandidates, "; "))
315+ }
316+ return false
317+}
318+
319+// structuralNeighbor finds neighbors based on the tree structure rather than geometry
320+func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
321+ if node == nil || node.container == nil {
322+ return nil
323+ }
324+
325+ var refBounds webkit.WidgetBounds
326+ var ok bool
327+ node.container.Execute(func(containerPtr uintptr) error {
328+ refBounds, ok = webkit.WidgetGetBounds(containerPtr)
329+ return nil
330+ })
331+ if !ok {
332+ return nil
333+ }
334+ cx := refBounds.X + refBounds.Width/2.0
335+ cy := refBounds.Y + refBounds.Height/2.0
336+ axisVertical := direction == "up" || direction == "down"
337+
338+ for parent := node.parent; parent != nil; parent = parent.parent {
339+ switch direction {
340+ case "up":
341+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {
342+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
343+ return leaf
344+ }
345+ }
346+ case "down":
347+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {
348+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
349+ return leaf
350+ }
351+ }
352+ case "left":
353+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {
354+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
355+ return leaf
356+ }
357+ }
358+ case "right":
359+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {
360+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
361+ return leaf
362+ }
363+ }
364+ }
365+ node = parent
366+ }
367+ return nil
368+}
369+
370+// closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
371+func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
372+ leaves := wm.collectLeavesFrom(node)
373+ bestScore := math.MaxFloat64
374+ var best *paneNode
375+ for _, leaf := range leaves {
376+ if leaf == nil || leaf.container == nil {
377+ continue
378+ }
379+ var bounds webkit.WidgetBounds
380+ var boundsOk bool
381+ leaf.container.Execute(func(containerPtr uintptr) error {
382+ bounds, boundsOk = webkit.WidgetGetBounds(containerPtr)
383+ return nil
384+ })
385+ ok := boundsOk
386+ if !ok {
387+ continue
388+ }
389+ tx := bounds.X + bounds.Width/2.0
390+ ty := bounds.Y + bounds.Height/2.0
391+ dx := tx - cx
392+ dy := ty - cy
393+ var score float64
394+ switch direction {
395+ case "left":
396+ if dx >= -focusEpsilon {
397+ continue
398+ }
399+ score = math.Abs(dx)*1000 + math.Abs(dy)
400+ case "right":
401+ if dx <= focusEpsilon {
402+ continue
403+ }
404+ score = math.Abs(dx)*1000 + math.Abs(dy)
405+ case "up":
406+ if dy >= -focusEpsilon {
407+ continue
408+ }
409+ score = math.Abs(dy)*1000 + math.Abs(dx)
410+ case "down":
411+ if dy <= focusEpsilon {
412+ continue
413+ }
414+ score = math.Abs(dy)*1000 + math.Abs(dx)
415+ default:
416+ continue
417+ }
418+ if score < bestScore {
419+ bestScore = score
420+ best = leaf
421+ }
422+ }
423+ if best == nil {
424+ return wm.boundaryFallback(node, direction)
425+ }
426+ return best
427+}
428+
429+// boundaryFallback provides fallback navigation when geometric search fails
430+func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
431+ return wm.boundaryFallbackWithDepth(node, direction, 0)
432+}
433+
434+// boundaryFallbackWithDepth provides recursive fallback with depth protection
435+func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode {
436+ // Prevent infinite recursion - max tree depth should be reasonable
437+ const maxDepth = 50
438+ if depth > maxDepth {
439+ log.Printf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption")
440+ return nil
441+ }
442+
443+ if node == nil {
444+ return nil
445+ }
446+ if node.isLeaf {
447+ return node
448+ }
449+ switch direction {
450+ case "up":
451+ if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
452+ return leaf
453+ }
454+ return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
455+ case "down":
456+ if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
457+ return leaf
458+ }
459+ return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
460+ case "left":
461+ if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
462+ return leaf
463+ }
464+ return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
465+ case "right":
466+ if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
467+ return leaf
468+ }
469+ return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
470+ default:
471+ return nil
472+ }
473+}
A internal/app/browser/workspace_pane_ops.go +929 -0
@@ -0,0 +1,929 @@
1+// workspace_pane_ops.go - Pane creation, splitting, closing and tree operations
2+package browser
3+
4+import (
5+ "errors"
6+ "log"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+)
10+
11+// collectLeaves returns all leaf nodes in the workspace tree
12+func (wm *WorkspaceManager) collectLeaves() []*paneNode {
13+ return wm.collectLeavesFrom(wm.root)
14+}
15+
16+// collectLeavesFrom collects all leaf nodes from a given subtree
17+func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
18+ var leaves []*paneNode
19+ visited := make(map[*paneNode]bool)
20+
21+ var walk func(*paneNode, int)
22+ walk = func(n *paneNode, depth int) {
23+ // Prevent infinite recursion and cycles
24+ const maxDepth = 50
25+ if n == nil || depth > maxDepth {
26+ return
27+ }
28+ if visited[n] {
29+ log.Printf("[workspace] collectLeavesFrom: cycle detected in tree")
30+ return
31+ }
32+ visited[n] = true
33+
34+ if n.isLeaf {
35+ leaves = append(leaves, n)
36+ return
37+ }
38+
39+ // Handle stacked panes - only include the currently ACTIVE pane as a leaf
40+ // This prevents multiple CSS classes and ensures correct focus management
41+ if n.isStacked && len(n.stackedPanes) > 0 {
42+ activeIndex := n.activeStackIndex
43+ if activeIndex >= 0 && activeIndex < len(n.stackedPanes) {
44+ // Only the active pane in the stack counts as a leaf for focus/navigation
45+ activePaneInStack := n.stackedPanes[activeIndex]
46+ walk(activePaneInStack, depth+1)
47+ }
48+ return
49+ }
50+
51+ // Handle regular split nodes
52+ walk(n.left, depth+1)
53+ walk(n.right, depth+1)
54+ }
55+ walk(node, 0)
56+ return leaves
57+}
58+
59+// createWebView creates a new WebView with the workspace's configuration
60+func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
61+ if wm == nil || wm.createWebViewFn == nil {
62+ return nil, errors.New("workspace manager missing webview factory")
63+ }
64+ return wm.createWebViewFn()
65+}
66+
67+// createPane creates a new BrowserPane for the given WebView
68+func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
69+ if wm == nil || wm.createPaneFn == nil {
70+ return nil, errors.New("workspace manager missing pane factory")
71+ }
72+ return wm.createPaneFn(view)
73+}
74+
75+// clonePaneState sets up a new pane with cloned state from another pane
76+func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
77+ if wm == nil || target == nil {
78+ return
79+ }
80+ if target.pane == nil || target.pane.webView == nil {
81+ return
82+ }
83+
84+ const blankURL = "about:blank"
85+
86+ if err := target.pane.webView.LoadURL(blankURL); err != nil {
87+ log.Printf("[workspace] failed to prepare blank pane: %v", err)
88+ }
89+
90+ // Wait for about:blank to load before opening omnibox
91+ target.pane.webView.RegisterURIChangedHandler(func(uri string) {
92+ if uri == blankURL {
93+ // Defer omnibox opening to allow page to fully initialize
94+ webkit.IdleAdd(func() bool {
95+ if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil {
96+ log.Printf("[workspace] failed to open omnibox: %v", injErr)
97+ }
98+ return false // Remove idle callback
99+ })
100+ }
101+ })
102+}
103+
104+// closeCurrentPane closes the currently focused pane
105+func (wm *WorkspaceManager) closeCurrentPane() {
106+ if wm == nil || wm.currentlyFocused == nil {
107+ return
108+ }
109+ if err := wm.closePane(wm.currentlyFocused); err != nil {
110+ log.Printf("[workspace] close current pane failed: %v", err)
111+ }
112+}
113+
114+// leftmostLeaf finds the leftmost leaf node in a subtree
115+func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
116+ for node != nil && !node.isLeaf {
117+ if node.left != nil {
118+ node = node.left
119+ continue
120+ }
121+ node = node.right
122+ }
123+ return node
124+}
125+
126+// findReplacementRoot finds a suitable replacement when closing the current root pane
127+func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
128+ if wm == nil || wm.root == nil {
129+ return nil
130+ }
131+
132+ // If root is being closed and there are other panes, find a replacement
133+ leaves := wm.collectLeaves()
134+ for _, leaf := range leaves {
135+ if leaf != excludeNode && leaf != nil && leaf.isLeaf {
136+ // Find the topmost ancestor that's not the current root
137+ current := leaf
138+ for current.parent != nil && current.parent != wm.root {
139+ current = current.parent
140+ }
141+
142+ // If this leaf is a direct child of root, or root only has one subtree,
143+ // we can promote this subtree to be the new root
144+ if current.parent == wm.root {
145+ // If the sibling is being excluded, promote this subtree
146+ var sibling *paneNode
147+ if wm.root.left == current {
148+ sibling = wm.root.right
149+ } else {
150+ sibling = wm.root.left
151+ }
152+
153+ if sibling == excludeNode {
154+ // The sibling is being closed, so promote this subtree
155+ return current
156+ }
157+ }
158+
159+ // Otherwise, return the first suitable leaf
160+ return leaf
161+ }
162+ }
163+
164+ return nil
165+}
166+
167+// updateMainPane updates the main pane reference based on current state
168+func (wm *WorkspaceManager) updateMainPane() {
169+ if len(wm.app.panes) == 1 {
170+ if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
171+ wm.mainPane = leaf
172+ }
173+ return
174+ }
175+
176+ if wm.mainPane == nil || !wm.mainPane.isLeaf {
177+ if wm.currentlyFocused != nil && wm.currentlyFocused.isLeaf {
178+ wm.mainPane = wm.currentlyFocused
179+ }
180+ }
181+}
182+
183+// Helper methods to support clean pane removal from workspace tracking
184+// These methods implement the interface expected by BrowserPane.CleanupFromWorkspace()
185+
186+// removeFromMaps removes a WebView from all workspace tracking maps
187+func (wm *WorkspaceManager) removeFromMaps(webView *webkit.WebView) {
188+ if wm == nil || webView == nil {
189+ return
190+ }
191+
192+ delete(wm.viewToNode, webView)
193+ delete(wm.lastSplitMsg, webView)
194+ delete(wm.lastExitMsg, webView)
195+ log.Printf("[workspace] removed webview %p from tracking maps", webView)
196+}
197+
198+// removeFromAppPanes removes a BrowserPane from the app.panes slice
199+func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) {
200+ if wm == nil || wm.app == nil || pane == nil {
201+ return
202+ }
203+
204+ for i, p := range wm.app.panes {
205+ if p == pane {
206+ wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
207+ log.Printf("[workspace] removed pane from app.panes slice, remaining: %d", len(wm.app.panes))
208+ return
209+ }
210+ }
211+}
212+
213+// insertPopupPane inserts a pre-created popup pane into the workspace
214+func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {
215+ if target == nil {
216+ return errors.New("insert target cannot be nil")
217+ }
218+
219+ // Accept both leaf panes and stacked panes as valid targets
220+ if !target.isLeaf && !target.isStacked {
221+ return errors.New("insert target must be a leaf pane or stacked pane")
222+ }
223+
224+ // For leaf panes, require a pane
225+ if target.isLeaf && target.pane == nil {
226+ return errors.New("leaf target missing pane")
227+ }
228+
229+ // For stacked panes, we trust the caller - production stacks have varied structures
230+ // Do not validate internal stack structure here
231+
232+ if newPane == nil || newPane.webView == nil {
233+ return errors.New("new pane missing webview")
234+ }
235+
236+ if handler := newPane.MessageHandler(); handler != nil {
237+ handler.SetWorkspaceObserver(wm)
238+ }
239+
240+ newContainer := newPane.webView.RootWidget()
241+ if newContainer == 0 {
242+ return errors.New("new pane missing container")
243+ }
244+
245+ webkit.WidgetSetHExpand(newContainer, true)
246+ webkit.WidgetSetVExpand(newContainer, true)
247+ webkit.WidgetRealizeInContainer(newContainer)
248+
249+ // Also realize the WebView widget itself for proper popup rendering
250+ webViewWidget := newPane.webView.Widget()
251+ if webViewWidget != 0 {
252+ webkit.WidgetRealizeInContainer(webViewWidget)
253+ }
254+
255+ if target.container == nil {
256+ return errors.New("existing pane missing container")
257+ }
258+
259+ orientation, existingFirst := mapDirection(direction)
260+ log.Printf("[workspace] inserting popup direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
261+
262+ paned := webkit.NewPaned(orientation)
263+ if paned == 0 {
264+ return errors.New("failed to create GtkPaned")
265+ }
266+ webkit.WidgetSetHExpand(paned, true)
267+ webkit.WidgetSetVExpand(paned, true)
268+ webkit.PanedSetResizeStart(paned, true)
269+ webkit.PanedSetResizeEnd(paned, true)
270+
271+ newLeaf := &paneNode{
272+ pane: newPane,
273+ isLeaf: true,
274+ }
275+ wm.initializePaneWidgets(newLeaf, newContainer)
276+
277+ split := &paneNode{
278+ parent: target.parent,
279+ orientation: orientation,
280+ isLeaf: false,
281+ }
282+ wm.initializePaneWidgets(split, paned)
283+
284+ parent := split.parent
285+
286+ // Detach existing container from its current GTK parent before inserting into new paned.
287+ existingContainer := target.getContainerPtr()
288+ if parent == nil {
289+ // Target is the root - remove it from the window
290+ log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
291+ if wm.window != nil {
292+ wm.window.SetChild(0)
293+ }
294+ } else if parent.container != nil {
295+ // Target has a parent paned - unparent it from there
296+ log.Printf("[workspace] unparenting existing container from parent paned")
297+ err := parent.container.Execute(func(parentPtr uintptr) error {
298+ log.Printf("[workspace] executing unparent: parentPtr=%#x", parentPtr)
299+ if parent.left == target {
300+ webkit.PanedSetStartChild(parentPtr, 0)
301+ } else if parent.right == target {
302+ webkit.PanedSetEndChild(parentPtr, 0)
303+ }
304+ webkit.WidgetQueueAllocate(parentPtr)
305+ return nil
306+ })
307+ if err != nil {
308+ log.Printf("[workspace] error during parent unparent: %v", err)
309+ }
310+ }
311+
312+ // Add both containers to the new paned
313+ if existingFirst {
314+ split.left = target
315+ split.right = newLeaf
316+ webkit.PanedSetStartChild(paned, existingContainer)
317+ webkit.PanedSetEndChild(paned, newContainer)
318+ log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
319+ } else {
320+ split.left = newLeaf
321+ split.right = target
322+ webkit.PanedSetStartChild(paned, newContainer)
323+ webkit.PanedSetEndChild(paned, existingContainer)
324+ log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
325+ }
326+
327+ target.parent = split
328+ newLeaf.parent = split
329+
330+ if parent == nil {
331+ wm.root = split
332+ if wm.window != nil {
333+ wm.window.SetChild(paned)
334+ webkit.WidgetQueueAllocate(paned)
335+ log.Printf("[workspace] paned set as window child: paned=%#x", paned)
336+ }
337+ } else {
338+ err := parent.container.Execute(func(parentPtr uintptr) error {
339+ log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parentPtr, paned)
340+ if parent.left == target {
341+ parent.left = split
342+ webkit.PanedSetStartChild(parentPtr, paned)
343+ } else if parent.right == target {
344+ parent.right = split
345+ webkit.PanedSetEndChild(parentPtr, paned)
346+ }
347+ webkit.WidgetQueueAllocate(parentPtr)
348+ webkit.WidgetQueueAllocate(paned)
349+ log.Printf("[workspace] paned inserted into parent successfully")
350+ return nil
351+ })
352+ if err != nil {
353+ log.Printf("[workspace] error during parent reparent: %v", err)
354+ }
355+ }
356+
357+ webkit.WidgetShow(paned)
358+
359+ wm.viewToNode[newPane.webView] = newLeaf
360+ wm.ensureHover(newLeaf)
361+ wm.ensureHover(target)
362+ wm.app.panes = append(wm.app.panes, newPane)
363+ if newPane.zoomController != nil {
364+ newPane.zoomController.ApplyInitialZoom()
365+ }
366+
367+ // Update CSS classes for all panes now that we have multiple panes
368+ wm.ensurePaneBaseClasses()
369+
370+ webkit.IdleAdd(func() bool {
371+ if newContainer != 0 {
372+ webkit.WidgetShow(newContainer)
373+ }
374+ wm.currentlyFocused = newLeaf
375+ wm.focusManager.SetActivePane(newLeaf)
376+ return false
377+ })
378+
379+ return nil
380+}
381+
382+// splitNode splits a target pane into two panes in the specified direction
383+func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*paneNode, error) {
384+ if target == nil || !target.isLeaf || target.pane == nil {
385+ return nil, errors.New("split target must be a leaf pane")
386+ }
387+
388+ // IMPORTANT: If the target is inside a stacked pane, we need to split AROUND the stack,
389+ // not the individual pane or the stack itself. We create a new split at the stack's parent level.
390+ originalTarget := target
391+ var stackContainer *paneNode
392+ if target.parent != nil && target.parent.isStacked {
393+ // Target is in a stack - we'll create the split around the entire stack
394+ stackContainer = target.parent
395+ log.Printf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer)
396+ }
397+
398+ // Determine what we're actually splitting
399+ var splitTarget *paneNode
400+ var splitTargetContainer *SafeWidget
401+
402+ if stackContainer != nil {
403+ // Case: splitting from inside a stack - we split around the entire stack
404+ splitTarget = stackContainer
405+ splitTargetContainer = stackContainer.container
406+ log.Printf("[workspace] splitting around stack: stackContainer=%p", splitTarget)
407+ } else {
408+ // Case: normal split from a simple pane
409+ splitTarget = target
410+ splitTargetContainer = target.container
411+ log.Printf("[workspace] normal split from pane: target=%p", splitTarget)
412+ }
413+
414+ newView, err := wm.createWebView()
415+ if err != nil {
416+ return nil, err
417+ }
418+
419+ newPane, err := wm.createPane(newView)
420+ if err != nil {
421+ return nil, err
422+ }
423+
424+ if handler := newPane.MessageHandler(); handler != nil {
425+ handler.SetWorkspaceObserver(wm)
426+ }
427+
428+ newContainer := newPane.webView.RootWidget()
429+ if newContainer == 0 {
430+ return nil, errors.New("new pane missing container")
431+ }
432+ webkit.WidgetSetHExpand(newContainer, true)
433+ webkit.WidgetSetVExpand(newContainer, true)
434+ webkit.WidgetRealizeInContainer(newContainer)
435+
436+ // Use the determined split target container
437+ if splitTargetContainer == nil {
438+ return nil, errors.New("split target missing container")
439+ }
440+
441+ // Only apply GTK operations if this is a simple widget (leaf pane)
442+ // For stacked containers, we need to be more careful
443+ if splitTarget.isLeaf {
444+ splitTargetContainer.Execute(func(containerPtr uintptr) error {
445+ webkit.WidgetSetHExpand(containerPtr, true)
446+ webkit.WidgetSetVExpand(containerPtr, true)
447+ webkit.WidgetRealizeInContainer(containerPtr)
448+ return nil
449+ })
450+ } else if splitTarget.isStacked {
451+ // Stack containers need the same setup as regular panes for proper splitting
452+ splitTargetContainer.Execute(func(containerPtr uintptr) error {
453+ webkit.WidgetSetHExpand(containerPtr, true)
454+ webkit.WidgetSetVExpand(containerPtr, true)
455+ webkit.WidgetRealizeInContainer(containerPtr)
456+ log.Printf("[workspace] configured stack wrapper for split: %#x", containerPtr)
457+ return nil
458+ })
459+ }
460+
461+ orientation, existingFirst := mapDirection(direction)
462+ log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v",
463+ direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked)
464+
465+ paned := webkit.NewPaned(orientation)
466+ if paned == 0 {
467+ return nil, errors.New("failed to create GtkPaned")
468+ }
469+ webkit.WidgetSetHExpand(paned, true)
470+ webkit.WidgetSetVExpand(paned, true)
471+ webkit.PanedSetResizeStart(paned, true)
472+ webkit.PanedSetResizeEnd(paned, true)
473+
474+ newLeaf := &paneNode{
475+ pane: newPane,
476+ isLeaf: true,
477+ }
478+ wm.initializePaneWidgets(newLeaf, newContainer)
479+
480+ split := &paneNode{
481+ parent: splitTarget.parent,
482+ orientation: orientation,
483+ isLeaf: false,
484+ }
485+ wm.initializePaneWidgets(split, paned)
486+
487+ parent := split.parent
488+
489+ // Detach split target container from its current GTK parent before inserting into new paned.
490+ if parent == nil {
491+ // Split target is the root - remove it from the window
492+ log.Printf("[workspace] removing split target container=%#x from window", splitTargetContainer)
493+ if wm.window != nil {
494+ wm.window.SetChild(0)
495+ }
496+ // For root widgets, only unparent if they actually have a GTK parent
497+ splitTargetContainer.Execute(func(containerPtr uintptr) error {
498+ if webkit.WidgetGetParent(containerPtr) != 0 {
499+ webkit.WidgetUnparent(containerPtr)
500+ }
501+ return nil
502+ })
503+ } else if parent.container != nil {
504+ // Split target has a parent paned - remove it (automatically unparents in GTK4)
505+ log.Printf("[workspace] unparenting split target container=%#x from parent paned=%#x", splitTargetContainer, parent.container)
506+ parent.container.Execute(func(panedPtr uintptr) error {
507+ if parent.left == splitTarget {
508+ webkit.PanedSetStartChild(panedPtr, 0)
509+ } else if parent.right == splitTarget {
510+ webkit.PanedSetEndChild(panedPtr, 0)
511+ }
512+ // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed
513+ webkit.WidgetQueueAllocate(panedPtr)
514+ return nil
515+ })
516+ }
517+
518+ // Set up the tree structure first
519+ if existingFirst {
520+ split.left = splitTarget
521+ split.right = newLeaf
522+ } else {
523+ split.left = newLeaf
524+ split.right = splitTarget
525+ }
526+
527+ splitTarget.parent = split
528+ newLeaf.parent = split
529+
530+ // Update tree root/parent references
531+ if parent == nil {
532+ wm.root = split
533+ } else {
534+ if parent.left == splitTarget {
535+ parent.left = split
536+ } else if parent.right == splitTarget {
537+ parent.right = split
538+ }
539+ }
540+
541+ // GTK4 handles widget operations automatically - no need to force redraw
542+
543+ // Add both containers to the new paned
544+ if existingFirst {
545+ splitTargetContainer.Execute(func(splitPtr uintptr) error {
546+ webkit.PanedSetStartChild(paned, splitPtr)
547+ webkit.PanedSetEndChild(paned, newContainer)
548+ log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitPtr, newContainer)
549+ return nil
550+ })
551+ } else {
552+ splitTargetContainer.Execute(func(splitPtr uintptr) error {
553+ webkit.PanedSetStartChild(paned, newContainer)
554+ webkit.PanedSetEndChild(paned, splitPtr)
555+ log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitPtr)
556+ return nil
557+ })
558+ }
559+
560+ // Attach the new paned to its parent
561+ if parent == nil {
562+ if wm.window != nil {
563+ wm.window.SetChild(paned)
564+ webkit.WidgetQueueAllocate(paned)
565+ log.Printf("[workspace] paned set as window child: paned=%#x", paned)
566+ }
567+ } else {
568+ parent.container.Execute(func(parentPtr uintptr) error {
569+ if parent.left == split {
570+ webkit.PanedSetStartChild(parentPtr, paned)
571+ } else if parent.right == split {
572+ webkit.PanedSetEndChild(parentPtr, paned)
573+ }
574+ webkit.WidgetQueueAllocate(parentPtr)
575+ webkit.WidgetQueueAllocate(paned)
576+ log.Printf("[workspace] paned inserted into parent=%#x", parentPtr)
577+ return nil
578+ })
579+ }
580+
581+ webkit.WidgetShow(paned)
582+
583+ wm.viewToNode[newPane.webView] = newLeaf
584+ wm.ensureHover(newLeaf)
585+
586+ // Don't ensure hover on originalTarget when splitting from stack - it competes with new pane focus
587+ // The originalTarget is still in the stack and should not interfere with the new split pane
588+ if originalTarget == target {
589+ // Only ensure hover on target if it's a regular (non-stack) split
590+ wm.ensureHover(target)
591+ }
592+
593+ wm.app.panes = append(wm.app.panes, newPane)
594+ if newPane.zoomController != nil {
595+ newPane.zoomController.ApplyInitialZoom()
596+ }
597+
598+ // Update CSS classes for all panes now that we have multiple panes
599+ wm.ensurePaneBaseClasses()
600+
601+ webkit.IdleAdd(func() bool {
602+ if newContainer != 0 {
603+ webkit.WidgetShow(newContainer)
604+ }
605+ wm.currentlyFocused = newLeaf
606+ wm.focusManager.SetActivePane(newLeaf)
607+ return false
608+ })
609+
610+ return newLeaf, nil
611+}
612+
613+// closePane closes a specific pane and handles tree restructuring
614+func (wm *WorkspaceManager) closePane(node *paneNode) error {
615+ if wm == nil || node == nil || !node.isLeaf {
616+ return errors.New("close target must be a leaf pane")
617+ }
618+ if node.pane == nil || node.pane.webView == nil {
619+ return errors.New("close target missing webview")
620+ }
621+
622+ // Handle closing panes in a stack
623+ if node.parent != nil && node.parent.isStacked {
624+ return wm.stackedPaneManager.CloseStackedPane(node)
625+ }
626+
627+ if node.parent != nil && node.parent.container != nil {
628+ node.parent.container.Execute(func(panedPtr uintptr) error {
629+ if node.parent.left == node {
630+ webkit.PanedSetStartChild(panedPtr, 0)
631+ // GTK4 automatically unparents when setting child to 0
632+ } else if node.parent.right == node {
633+ webkit.PanedSetEndChild(panedPtr, 0)
634+ // GTK4 automatically unparents when setting child to 0
635+ }
636+ return nil
637+ })
638+ } else if node.container != nil {
639+ // Only manually unparent if widget wasn't auto-unparented above
640+ node.container.Execute(func(containerPtr uintptr) error {
641+ if webkit.WidgetGetParent(containerPtr) != 0 {
642+ webkit.WidgetUnparent(containerPtr)
643+ }
644+ return nil
645+ })
646+ }
647+
648+ remaining := len(wm.app.panes)
649+ willBeLastPane := remaining <= 1
650+ paneCleaned := false
651+ ensureCleanup := func() {
652+ if paneCleaned {
653+ return
654+ }
655+ node.pane.Cleanup()
656+ paneCleaned = true
657+ }
658+
659+ if remaining == 2 {
660+ var sibling *paneNode
661+ if node.parent != nil {
662+ if node.parent.left == node {
663+ sibling = node.parent.right
664+ } else if node.parent.right == node {
665+ sibling = node.parent.left
666+ }
667+ }
668+ if sibling != nil && sibling.isLeaf && sibling.pane != nil && sibling.pane.webView != nil {
669+ if sibling.isPopup && !node.isPopup {
670+ parentNode := node.parent
671+ var parentContainer *SafeWidget
672+ if parentNode != nil {
673+ parentContainer = parentNode.container
674+ }
675+
676+ // Detach remaining GTK widgets before destroying the WebViews so GTK
677+ // does not attempt to dispose still-parented children.
678+ if parentContainer != nil {
679+ parentContainer.Execute(func(panedPtr uintptr) error {
680+ if parentNode.left == sibling {
681+ webkit.PanedSetStartChild(panedPtr, 0)
682+ } else if parentNode.right == sibling {
683+ webkit.PanedSetEndChild(panedPtr, 0)
684+ }
685+ return nil
686+ })
687+ }
688+ if sibling.container != nil {
689+ sibling.container.Execute(func(containerPtr uintptr) error {
690+ webkit.WidgetUnparent(containerPtr)
691+ return nil
692+ })
693+ }
694+ if parentContainer != nil && wm.window != nil {
695+ wm.window.SetChild(0)
696+ }
697+
698+ log.Printf("[workspace] closing primary pane with related popup present; exiting")
699+ ensureCleanup()
700+ sibling.pane.Cleanup()
701+
702+ wm.detachHover(node)
703+ wm.detachHover(sibling)
704+
705+ // Clean up workspace tracking for both panes
706+ node.pane.CleanupFromWorkspace(wm)
707+ sibling.pane.CleanupFromWorkspace(wm)
708+
709+ if node.pane.webView != nil {
710+ if err := node.pane.webView.Destroy(); err != nil {
711+ log.Printf("[workspace] failed to destroy primary webview: %v", err)
712+ }
713+ }
714+ if sibling.pane.webView != nil {
715+ if err := sibling.pane.webView.Destroy(); err != nil {
716+ log.Printf("[workspace] failed to destroy popup webview: %v", err)
717+ }
718+ }
719+
720+ wm.root = nil
721+ wm.currentlyFocused = nil
722+ wm.mainPane = nil
723+
724+ webkit.QuitMainLoop()
725+ return nil
726+ }
727+ }
728+ }
729+ if willBeLastPane && wm.root == node {
730+ log.Printf("[workspace] closing final pane; exiting browser")
731+ ensureCleanup()
732+ wm.detachHover(node)
733+ if err := node.pane.webView.Destroy(); err != nil {
734+ log.Printf("[workspace] failed to destroy webview: %v", err)
735+ }
736+ webkit.QuitMainLoop()
737+ return nil
738+ }
739+
740+ parent := node.parent
741+ if parent == nil {
742+ // This is the root pane (no parent in tree structure)
743+ if node != wm.root {
744+ return errors.New("inconsistent state: node has no parent but is not root")
745+ }
746+
747+ // Check if we can find a replacement root
748+ replacement := wm.findReplacementRoot(node)
749+ if replacement == nil {
750+ // No other panes exist, this is the final pane
751+ log.Printf("[workspace] closing final pane; exiting browser")
752+ ensureCleanup()
753+ wm.detachHover(node)
754+ if err := node.pane.webView.Destroy(); err != nil {
755+ log.Printf("[workspace] failed to destroy webview: %v", err)
756+ }
757+ webkit.QuitMainLoop()
758+ return nil
759+ }
760+
761+ // We have a replacement - delegate root status and close this pane
762+ log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container)
763+
764+ // Clean up workspace tracking
765+ node.pane.CleanupFromWorkspace(wm)
766+
767+ if wm.mainPane == node {
768+ wm.mainPane = nil
769+ }
770+
771+ // Clear current active if it's the node being closed
772+ if wm.currentlyFocused == node {
773+ wm.currentlyFocused = nil
774+ }
775+
776+ // Set replacement as new root
777+ wm.root = replacement
778+ replacement.parent = nil
779+
780+ // Replace window child directly (GTK handles reparenting automatically)
781+ if wm.window != nil && replacement.container != nil {
782+ replacement.container.Execute(func(containerPtr uintptr) error {
783+ wm.window.SetChild(containerPtr)
784+ webkit.WidgetQueueAllocate(containerPtr)
785+ webkit.WidgetShow(containerPtr)
786+ return nil
787+ })
788+ }
789+
790+ // Focus a suitable pane
791+ focusTarget := wm.leftmostLeaf(replacement)
792+ if focusTarget != nil {
793+ wm.currentlyFocused = focusTarget
794+ wm.focusManager.SetActivePane(focusTarget)
795+ }
796+
797+ // Destroy the webview and detach hover AFTER rearranging hierarchy
798+ // Only destroy the webview if this is the final pane, otherwise just clean up
799+ ensureCleanup()
800+ wm.detachHover(node)
801+ switch {
802+ case node.isPopup:
803+ if err := node.pane.webView.Destroy(); err != nil {
804+ log.Printf("[workspace] failed to destroy popup webview: %v", err)
805+ }
806+ case willBeLastPane:
807+ // This is the last pane, safe to destroy completely
808+ if err := node.pane.webView.Destroy(); err != nil {
809+ log.Printf("[workspace] failed to destroy webview: %v", err)
810+ }
811+ default:
812+ // Multiple panes remain, don't destroy the window - just clean up the webview
813+ log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
814+ // TODO: Add a method to destroy just the webview without the window
815+ }
816+
817+ wm.updateMainPane()
818+ // Update CSS classes after pane count changes
819+ wm.ensurePaneBaseClasses()
820+ log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
821+ return nil
822+ }
823+
824+ var sibling *paneNode
825+ if parent.left == node {
826+ sibling = parent.right
827+ } else {
828+ sibling = parent.left
829+ }
830+ if sibling == nil {
831+ return errors.New("pane close failed: missing sibling")
832+ }
833+
834+ log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)
835+
836+ // Clean up workspace tracking
837+ node.pane.CleanupFromWorkspace(wm)
838+
839+ if wm.mainPane == node {
840+ wm.mainPane = nil
841+ }
842+
843+ // Clear current active if it's the node being closed
844+ if wm.currentlyFocused == node {
845+ wm.currentlyFocused = nil
846+ }
847+
848+ grand := parent.parent
849+ if grand == nil {
850+ // Parent is the root node. Promote sibling to become the new root.
851+ log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)
852+
853+ // Update tree structure first
854+ wm.root = sibling
855+ sibling.parent = nil
856+
857+ // For root promotion, GTK4 handles unparenting automatically when we set the new window child
858+ if wm.window != nil && sibling.container != nil {
859+ sibling.container.Execute(func(containerPtr uintptr) error {
860+ wm.window.SetChild(containerPtr)
861+ webkit.WidgetQueueAllocate(containerPtr)
862+ webkit.WidgetShow(containerPtr)
863+ log.Printf("[workspace] successfully promoted sibling to root: %#x", containerPtr)
864+ return nil
865+ })
866+ }
867+ } else {
868+ // Parent has a grandparent, so promote sibling to take parent's place
869+ log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container)
870+
871+ // Update tree structure
872+ sibling.parent = grand
873+ if grand.left == parent {
874+ grand.left = sibling
875+ } else {
876+ grand.right = sibling
877+ }
878+
879+ // Move sibling to grandparent's paned widget
880+ // GTK4 handles unparenting automatically when we set the new paned child
881+ grand.container.Execute(func(grandPtr uintptr) error {
882+ sibling.container.Execute(func(siblingPtr uintptr) error {
883+ if grand.left == sibling {
884+ webkit.PanedSetStartChild(grandPtr, siblingPtr)
885+ } else {
886+ webkit.PanedSetEndChild(grandPtr, siblingPtr)
887+ }
888+ webkit.WidgetQueueAllocate(siblingPtr)
889+ webkit.WidgetShow(siblingPtr)
890+ log.Printf("[workspace] successfully promoted sibling to parent position: %#x", siblingPtr)
891+ return nil
892+ })
893+ return nil
894+ })
895+ }
896+
897+ // Find a suitable focus target
898+ focusTarget := wm.leftmostLeaf(sibling)
899+ if focusTarget != nil {
900+ wm.currentlyFocused = focusTarget
901+ wm.focusManager.SetActivePane(focusTarget)
902+ }
903+
904+ // Clean up the node being closed
905+ ensureCleanup()
906+ wm.detachHover(node)
907+
908+ switch {
909+ case node.isPopup:
910+ if err := node.pane.webView.Destroy(); err != nil {
911+ log.Printf("[workspace] failed to destroy popup webview: %v", err)
912+ }
913+ case willBeLastPane:
914+ // This is the last pane, safe to destroy completely
915+ if err := node.pane.webView.Destroy(); err != nil {
916+ log.Printf("[workspace] failed to destroy webview: %v", err)
917+ }
918+ default:
919+ // Multiple panes remain, don't destroy the window - just clean up the webview
920+ log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
921+ // TODO: Add a method to destroy just the webview without the window
922+ }
923+
924+ wm.updateMainPane()
925+ // Update CSS classes after pane count changes
926+ wm.ensurePaneBaseClasses()
927+ log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
928+ return nil
929+}
@@ -0,0 +1,929 @@
1+// workspace_pane_ops.go - Pane creation, splitting, closing and tree operations
2+package browser
3+
4+import (
5+ "errors"
6+ "log"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+)
10+
11+// collectLeaves returns all leaf nodes in the workspace tree
12+func (wm *WorkspaceManager) collectLeaves() []*paneNode {
13+ return wm.collectLeavesFrom(wm.root)
14+}
15+
16+// collectLeavesFrom collects all leaf nodes from a given subtree
17+func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
18+ var leaves []*paneNode
19+ visited := make(map[*paneNode]bool)
20+
21+ var walk func(*paneNode, int)
22+ walk = func(n *paneNode, depth int) {
23+ // Prevent infinite recursion and cycles
24+ const maxDepth = 50
25+ if n == nil || depth > maxDepth {
26+ return
27+ }
28+ if visited[n] {
29+ log.Printf("[workspace] collectLeavesFrom: cycle detected in tree")
30+ return
31+ }
32+ visited[n] = true
33+
34+ if n.isLeaf {
35+ leaves = append(leaves, n)
36+ return
37+ }
38+
39+ // Handle stacked panes - only include the currently ACTIVE pane as a leaf
40+ // This prevents multiple CSS classes and ensures correct focus management
41+ if n.isStacked && len(n.stackedPanes) > 0 {
42+ activeIndex := n.activeStackIndex
43+ if activeIndex >= 0 && activeIndex < len(n.stackedPanes) {
44+ // Only the active pane in the stack counts as a leaf for focus/navigation
45+ activePaneInStack := n.stackedPanes[activeIndex]
46+ walk(activePaneInStack, depth+1)
47+ }
48+ return
49+ }
50+
51+ // Handle regular split nodes
52+ walk(n.left, depth+1)
53+ walk(n.right, depth+1)
54+ }
55+ walk(node, 0)
56+ return leaves
57+}
58+
59+// createWebView creates a new WebView with the workspace's configuration
60+func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
61+ if wm == nil || wm.createWebViewFn == nil {
62+ return nil, errors.New("workspace manager missing webview factory")
63+ }
64+ return wm.createWebViewFn()
65+}
66+
67+// createPane creates a new BrowserPane for the given WebView
68+func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
69+ if wm == nil || wm.createPaneFn == nil {
70+ return nil, errors.New("workspace manager missing pane factory")
71+ }
72+ return wm.createPaneFn(view)
73+}
74+
75+// clonePaneState sets up a new pane with cloned state from another pane
76+func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
77+ if wm == nil || target == nil {
78+ return
79+ }
80+ if target.pane == nil || target.pane.webView == nil {
81+ return
82+ }
83+
84+ const blankURL = "about:blank"
85+
86+ if err := target.pane.webView.LoadURL(blankURL); err != nil {
87+ log.Printf("[workspace] failed to prepare blank pane: %v", err)
88+ }
89+
90+ // Wait for about:blank to load before opening omnibox
91+ target.pane.webView.RegisterURIChangedHandler(func(uri string) {
92+ if uri == blankURL {
93+ // Defer omnibox opening to allow page to fully initialize
94+ webkit.IdleAdd(func() bool {
95+ if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil {
96+ log.Printf("[workspace] failed to open omnibox: %v", injErr)
97+ }
98+ return false // Remove idle callback
99+ })
100+ }
101+ })
102+}
103+
104+// closeCurrentPane closes the currently focused pane
105+func (wm *WorkspaceManager) closeCurrentPane() {
106+ if wm == nil || wm.currentlyFocused == nil {
107+ return
108+ }
109+ if err := wm.closePane(wm.currentlyFocused); err != nil {
110+ log.Printf("[workspace] close current pane failed: %v", err)
111+ }
112+}
113+
114+// leftmostLeaf finds the leftmost leaf node in a subtree
115+func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
116+ for node != nil && !node.isLeaf {
117+ if node.left != nil {
118+ node = node.left
119+ continue
120+ }
121+ node = node.right
122+ }
123+ return node
124+}
125+
126+// findReplacementRoot finds a suitable replacement when closing the current root pane
127+func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
128+ if wm == nil || wm.root == nil {
129+ return nil
130+ }
131+
132+ // If root is being closed and there are other panes, find a replacement
133+ leaves := wm.collectLeaves()
134+ for _, leaf := range leaves {
135+ if leaf != excludeNode && leaf != nil && leaf.isLeaf {
136+ // Find the topmost ancestor that's not the current root
137+ current := leaf
138+ for current.parent != nil && current.parent != wm.root {
139+ current = current.parent
140+ }
141+
142+ // If this leaf is a direct child of root, or root only has one subtree,
143+ // we can promote this subtree to be the new root
144+ if current.parent == wm.root {
145+ // If the sibling is being excluded, promote this subtree
146+ var sibling *paneNode
147+ if wm.root.left == current {
148+ sibling = wm.root.right
149+ } else {
150+ sibling = wm.root.left
151+ }
152+
153+ if sibling == excludeNode {
154+ // The sibling is being closed, so promote this subtree
155+ return current
156+ }
157+ }
158+
159+ // Otherwise, return the first suitable leaf
160+ return leaf
161+ }
162+ }
163+
164+ return nil
165+}
166+
167+// updateMainPane updates the main pane reference based on current state
168+func (wm *WorkspaceManager) updateMainPane() {
169+ if len(wm.app.panes) == 1 {
170+ if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
171+ wm.mainPane = leaf
172+ }
173+ return
174+ }
175+
176+ if wm.mainPane == nil || !wm.mainPane.isLeaf {
177+ if wm.currentlyFocused != nil && wm.currentlyFocused.isLeaf {
178+ wm.mainPane = wm.currentlyFocused
179+ }
180+ }
181+}
182+
183+// Helper methods to support clean pane removal from workspace tracking
184+// These methods implement the interface expected by BrowserPane.CleanupFromWorkspace()
185+
186+// removeFromMaps removes a WebView from all workspace tracking maps
187+func (wm *WorkspaceManager) removeFromMaps(webView *webkit.WebView) {
188+ if wm == nil || webView == nil {
189+ return
190+ }
191+
192+ delete(wm.viewToNode, webView)
193+ delete(wm.lastSplitMsg, webView)
194+ delete(wm.lastExitMsg, webView)
195+ log.Printf("[workspace] removed webview %p from tracking maps", webView)
196+}
197+
198+// removeFromAppPanes removes a BrowserPane from the app.panes slice
199+func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) {
200+ if wm == nil || wm.app == nil || pane == nil {
201+ return
202+ }
203+
204+ for i, p := range wm.app.panes {
205+ if p == pane {
206+ wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
207+ log.Printf("[workspace] removed pane from app.panes slice, remaining: %d", len(wm.app.panes))
208+ return
209+ }
210+ }
211+}
212+
213+// insertPopupPane inserts a pre-created popup pane into the workspace
214+func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {
215+ if target == nil {
216+ return errors.New("insert target cannot be nil")
217+ }
218+
219+ // Accept both leaf panes and stacked panes as valid targets
220+ if !target.isLeaf && !target.isStacked {
221+ return errors.New("insert target must be a leaf pane or stacked pane")
222+ }
223+
224+ // For leaf panes, require a pane
225+ if target.isLeaf && target.pane == nil {
226+ return errors.New("leaf target missing pane")
227+ }
228+
229+ // For stacked panes, we trust the caller - production stacks have varied structures
230+ // Do not validate internal stack structure here
231+
232+ if newPane == nil || newPane.webView == nil {
233+ return errors.New("new pane missing webview")
234+ }
235+
236+ if handler := newPane.MessageHandler(); handler != nil {
237+ handler.SetWorkspaceObserver(wm)
238+ }
239+
240+ newContainer := newPane.webView.RootWidget()
241+ if newContainer == 0 {
242+ return errors.New("new pane missing container")
243+ }
244+
245+ webkit.WidgetSetHExpand(newContainer, true)
246+ webkit.WidgetSetVExpand(newContainer, true)
247+ webkit.WidgetRealizeInContainer(newContainer)
248+
249+ // Also realize the WebView widget itself for proper popup rendering
250+ webViewWidget := newPane.webView.Widget()
251+ if webViewWidget != 0 {
252+ webkit.WidgetRealizeInContainer(webViewWidget)
253+ }
254+
255+ if target.container == nil {
256+ return errors.New("existing pane missing container")
257+ }
258+
259+ orientation, existingFirst := mapDirection(direction)
260+ log.Printf("[workspace] inserting popup direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
261+
262+ paned := webkit.NewPaned(orientation)
263+ if paned == 0 {
264+ return errors.New("failed to create GtkPaned")
265+ }
266+ webkit.WidgetSetHExpand(paned, true)
267+ webkit.WidgetSetVExpand(paned, true)
268+ webkit.PanedSetResizeStart(paned, true)
269+ webkit.PanedSetResizeEnd(paned, true)
270+
271+ newLeaf := &paneNode{
272+ pane: newPane,
273+ isLeaf: true,
274+ }
275+ wm.initializePaneWidgets(newLeaf, newContainer)
276+
277+ split := &paneNode{
278+ parent: target.parent,
279+ orientation: orientation,
280+ isLeaf: false,
281+ }
282+ wm.initializePaneWidgets(split, paned)
283+
284+ parent := split.parent
285+
286+ // Detach existing container from its current GTK parent before inserting into new paned.
287+ existingContainer := target.getContainerPtr()
288+ if parent == nil {
289+ // Target is the root - remove it from the window
290+ log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
291+ if wm.window != nil {
292+ wm.window.SetChild(0)
293+ }
294+ } else if parent.container != nil {
295+ // Target has a parent paned - unparent it from there
296+ log.Printf("[workspace] unparenting existing container from parent paned")
297+ err := parent.container.Execute(func(parentPtr uintptr) error {
298+ log.Printf("[workspace] executing unparent: parentPtr=%#x", parentPtr)
299+ if parent.left == target {
300+ webkit.PanedSetStartChild(parentPtr, 0)
301+ } else if parent.right == target {
302+ webkit.PanedSetEndChild(parentPtr, 0)
303+ }
304+ webkit.WidgetQueueAllocate(parentPtr)
305+ return nil
306+ })
307+ if err != nil {
308+ log.Printf("[workspace] error during parent unparent: %v", err)
309+ }
310+ }
311+
312+ // Add both containers to the new paned
313+ if existingFirst {
314+ split.left = target
315+ split.right = newLeaf
316+ webkit.PanedSetStartChild(paned, existingContainer)
317+ webkit.PanedSetEndChild(paned, newContainer)
318+ log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
319+ } else {
320+ split.left = newLeaf
321+ split.right = target
322+ webkit.PanedSetStartChild(paned, newContainer)
323+ webkit.PanedSetEndChild(paned, existingContainer)
324+ log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
325+ }
326+
327+ target.parent = split
328+ newLeaf.parent = split
329+
330+ if parent == nil {
331+ wm.root = split
332+ if wm.window != nil {
333+ wm.window.SetChild(paned)
334+ webkit.WidgetQueueAllocate(paned)
335+ log.Printf("[workspace] paned set as window child: paned=%#x", paned)
336+ }
337+ } else {
338+ err := parent.container.Execute(func(parentPtr uintptr) error {
339+ log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parentPtr, paned)
340+ if parent.left == target {
341+ parent.left = split
342+ webkit.PanedSetStartChild(parentPtr, paned)
343+ } else if parent.right == target {
344+ parent.right = split
345+ webkit.PanedSetEndChild(parentPtr, paned)
346+ }
347+ webkit.WidgetQueueAllocate(parentPtr)
348+ webkit.WidgetQueueAllocate(paned)
349+ log.Printf("[workspace] paned inserted into parent successfully")
350+ return nil
351+ })
352+ if err != nil {
353+ log.Printf("[workspace] error during parent reparent: %v", err)
354+ }
355+ }
356+
357+ webkit.WidgetShow(paned)
358+
359+ wm.viewToNode[newPane.webView] = newLeaf
360+ wm.ensureHover(newLeaf)
361+ wm.ensureHover(target)
362+ wm.app.panes = append(wm.app.panes, newPane)
363+ if newPane.zoomController != nil {
364+ newPane.zoomController.ApplyInitialZoom()
365+ }
366+
367+ // Update CSS classes for all panes now that we have multiple panes
368+ wm.ensurePaneBaseClasses()
369+
370+ webkit.IdleAdd(func() bool {
371+ if newContainer != 0 {
372+ webkit.WidgetShow(newContainer)
373+ }
374+ wm.currentlyFocused = newLeaf
375+ wm.focusManager.SetActivePane(newLeaf)
376+ return false
377+ })
378+
379+ return nil
380+}
381+
382+// splitNode splits a target pane into two panes in the specified direction
383+func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*paneNode, error) {
384+ if target == nil || !target.isLeaf || target.pane == nil {
385+ return nil, errors.New("split target must be a leaf pane")
386+ }
387+
388+ // IMPORTANT: If the target is inside a stacked pane, we need to split AROUND the stack,
389+ // not the individual pane or the stack itself. We create a new split at the stack's parent level.
390+ originalTarget := target
391+ var stackContainer *paneNode
392+ if target.parent != nil && target.parent.isStacked {
393+ // Target is in a stack - we'll create the split around the entire stack
394+ stackContainer = target.parent
395+ log.Printf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer)
396+ }
397+
398+ // Determine what we're actually splitting
399+ var splitTarget *paneNode
400+ var splitTargetContainer *SafeWidget
401+
402+ if stackContainer != nil {
403+ // Case: splitting from inside a stack - we split around the entire stack
404+ splitTarget = stackContainer
405+ splitTargetContainer = stackContainer.container
406+ log.Printf("[workspace] splitting around stack: stackContainer=%p", splitTarget)
407+ } else {
408+ // Case: normal split from a simple pane
409+ splitTarget = target
410+ splitTargetContainer = target.container
411+ log.Printf("[workspace] normal split from pane: target=%p", splitTarget)
412+ }
413+
414+ newView, err := wm.createWebView()
415+ if err != nil {
416+ return nil, err
417+ }
418+
419+ newPane, err := wm.createPane(newView)
420+ if err != nil {
421+ return nil, err
422+ }
423+
424+ if handler := newPane.MessageHandler(); handler != nil {
425+ handler.SetWorkspaceObserver(wm)
426+ }
427+
428+ newContainer := newPane.webView.RootWidget()
429+ if newContainer == 0 {
430+ return nil, errors.New("new pane missing container")
431+ }
432+ webkit.WidgetSetHExpand(newContainer, true)
433+ webkit.WidgetSetVExpand(newContainer, true)
434+ webkit.WidgetRealizeInContainer(newContainer)
435+
436+ // Use the determined split target container
437+ if splitTargetContainer == nil {
438+ return nil, errors.New("split target missing container")
439+ }
440+
441+ // Only apply GTK operations if this is a simple widget (leaf pane)
442+ // For stacked containers, we need to be more careful
443+ if splitTarget.isLeaf {
444+ splitTargetContainer.Execute(func(containerPtr uintptr) error {
445+ webkit.WidgetSetHExpand(containerPtr, true)
446+ webkit.WidgetSetVExpand(containerPtr, true)
447+ webkit.WidgetRealizeInContainer(containerPtr)
448+ return nil
449+ })
450+ } else if splitTarget.isStacked {
451+ // Stack containers need the same setup as regular panes for proper splitting
452+ splitTargetContainer.Execute(func(containerPtr uintptr) error {
453+ webkit.WidgetSetHExpand(containerPtr, true)
454+ webkit.WidgetSetVExpand(containerPtr, true)
455+ webkit.WidgetRealizeInContainer(containerPtr)
456+ log.Printf("[workspace] configured stack wrapper for split: %#x", containerPtr)
457+ return nil
458+ })
459+ }
460+
461+ orientation, existingFirst := mapDirection(direction)
462+ log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v",
463+ direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked)
464+
465+ paned := webkit.NewPaned(orientation)
466+ if paned == 0 {
467+ return nil, errors.New("failed to create GtkPaned")
468+ }
469+ webkit.WidgetSetHExpand(paned, true)
470+ webkit.WidgetSetVExpand(paned, true)
471+ webkit.PanedSetResizeStart(paned, true)
472+ webkit.PanedSetResizeEnd(paned, true)
473+
474+ newLeaf := &paneNode{
475+ pane: newPane,
476+ isLeaf: true,
477+ }
478+ wm.initializePaneWidgets(newLeaf, newContainer)
479+
480+ split := &paneNode{
481+ parent: splitTarget.parent,
482+ orientation: orientation,
483+ isLeaf: false,
484+ }
485+ wm.initializePaneWidgets(split, paned)
486+
487+ parent := split.parent
488+
489+ // Detach split target container from its current GTK parent before inserting into new paned.
490+ if parent == nil {
491+ // Split target is the root - remove it from the window
492+ log.Printf("[workspace] removing split target container=%#x from window", splitTargetContainer)
493+ if wm.window != nil {
494+ wm.window.SetChild(0)
495+ }
496+ // For root widgets, only unparent if they actually have a GTK parent
497+ splitTargetContainer.Execute(func(containerPtr uintptr) error {
498+ if webkit.WidgetGetParent(containerPtr) != 0 {
499+ webkit.WidgetUnparent(containerPtr)
500+ }
501+ return nil
502+ })
503+ } else if parent.container != nil {
504+ // Split target has a parent paned - remove it (automatically unparents in GTK4)
505+ log.Printf("[workspace] unparenting split target container=%#x from parent paned=%#x", splitTargetContainer, parent.container)
506+ parent.container.Execute(func(panedPtr uintptr) error {
507+ if parent.left == splitTarget {
508+ webkit.PanedSetStartChild(panedPtr, 0)
509+ } else if parent.right == splitTarget {
510+ webkit.PanedSetEndChild(panedPtr, 0)
511+ }
512+ // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed
513+ webkit.WidgetQueueAllocate(panedPtr)
514+ return nil
515+ })
516+ }
517+
518+ // Set up the tree structure first
519+ if existingFirst {
520+ split.left = splitTarget
521+ split.right = newLeaf
522+ } else {
523+ split.left = newLeaf
524+ split.right = splitTarget
525+ }
526+
527+ splitTarget.parent = split
528+ newLeaf.parent = split
529+
530+ // Update tree root/parent references
531+ if parent == nil {
532+ wm.root = split
533+ } else {
534+ if parent.left == splitTarget {
535+ parent.left = split
536+ } else if parent.right == splitTarget {
537+ parent.right = split
538+ }
539+ }
540+
541+ // GTK4 handles widget operations automatically - no need to force redraw
542+
543+ // Add both containers to the new paned
544+ if existingFirst {
545+ splitTargetContainer.Execute(func(splitPtr uintptr) error {
546+ webkit.PanedSetStartChild(paned, splitPtr)
547+ webkit.PanedSetEndChild(paned, newContainer)
548+ log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitPtr, newContainer)
549+ return nil
550+ })
551+ } else {
552+ splitTargetContainer.Execute(func(splitPtr uintptr) error {
553+ webkit.PanedSetStartChild(paned, newContainer)
554+ webkit.PanedSetEndChild(paned, splitPtr)
555+ log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitPtr)
556+ return nil
557+ })
558+ }
559+
560+ // Attach the new paned to its parent
561+ if parent == nil {
562+ if wm.window != nil {
563+ wm.window.SetChild(paned)
564+ webkit.WidgetQueueAllocate(paned)
565+ log.Printf("[workspace] paned set as window child: paned=%#x", paned)
566+ }
567+ } else {
568+ parent.container.Execute(func(parentPtr uintptr) error {
569+ if parent.left == split {
570+ webkit.PanedSetStartChild(parentPtr, paned)
571+ } else if parent.right == split {
572+ webkit.PanedSetEndChild(parentPtr, paned)
573+ }
574+ webkit.WidgetQueueAllocate(parentPtr)
575+ webkit.WidgetQueueAllocate(paned)
576+ log.Printf("[workspace] paned inserted into parent=%#x", parentPtr)
577+ return nil
578+ })
579+ }
580+
581+ webkit.WidgetShow(paned)
582+
583+ wm.viewToNode[newPane.webView] = newLeaf
584+ wm.ensureHover(newLeaf)
585+
586+ // Don't ensure hover on originalTarget when splitting from stack - it competes with new pane focus
587+ // The originalTarget is still in the stack and should not interfere with the new split pane
588+ if originalTarget == target {
589+ // Only ensure hover on target if it's a regular (non-stack) split
590+ wm.ensureHover(target)
591+ }
592+
593+ wm.app.panes = append(wm.app.panes, newPane)
594+ if newPane.zoomController != nil {
595+ newPane.zoomController.ApplyInitialZoom()
596+ }
597+
598+ // Update CSS classes for all panes now that we have multiple panes
599+ wm.ensurePaneBaseClasses()
600+
601+ webkit.IdleAdd(func() bool {
602+ if newContainer != 0 {
603+ webkit.WidgetShow(newContainer)
604+ }
605+ wm.currentlyFocused = newLeaf
606+ wm.focusManager.SetActivePane(newLeaf)
607+ return false
608+ })
609+
610+ return newLeaf, nil
611+}
612+
613+// closePane closes a specific pane and handles tree restructuring
614+func (wm *WorkspaceManager) closePane(node *paneNode) error {
615+ if wm == nil || node == nil || !node.isLeaf {
616+ return errors.New("close target must be a leaf pane")
617+ }
618+ if node.pane == nil || node.pane.webView == nil {
619+ return errors.New("close target missing webview")
620+ }
621+
622+ // Handle closing panes in a stack
623+ if node.parent != nil && node.parent.isStacked {
624+ return wm.stackedPaneManager.CloseStackedPane(node)
625+ }
626+
627+ if node.parent != nil && node.parent.container != nil {
628+ node.parent.container.Execute(func(panedPtr uintptr) error {
629+ if node.parent.left == node {
630+ webkit.PanedSetStartChild(panedPtr, 0)
631+ // GTK4 automatically unparents when setting child to 0
632+ } else if node.parent.right == node {
633+ webkit.PanedSetEndChild(panedPtr, 0)
634+ // GTK4 automatically unparents when setting child to 0
635+ }
636+ return nil
637+ })
638+ } else if node.container != nil {
639+ // Only manually unparent if widget wasn't auto-unparented above
640+ node.container.Execute(func(containerPtr uintptr) error {
641+ if webkit.WidgetGetParent(containerPtr) != 0 {
642+ webkit.WidgetUnparent(containerPtr)
643+ }
644+ return nil
645+ })
646+ }
647+
648+ remaining := len(wm.app.panes)
649+ willBeLastPane := remaining <= 1
650+ paneCleaned := false
651+ ensureCleanup := func() {
652+ if paneCleaned {
653+ return
654+ }
655+ node.pane.Cleanup()
656+ paneCleaned = true
657+ }
658+
659+ if remaining == 2 {
660+ var sibling *paneNode
661+ if node.parent != nil {
662+ if node.parent.left == node {
663+ sibling = node.parent.right
664+ } else if node.parent.right == node {
665+ sibling = node.parent.left
666+ }
667+ }
668+ if sibling != nil && sibling.isLeaf && sibling.pane != nil && sibling.pane.webView != nil {
669+ if sibling.isPopup && !node.isPopup {
670+ parentNode := node.parent
671+ var parentContainer *SafeWidget
672+ if parentNode != nil {
673+ parentContainer = parentNode.container
674+ }
675+
676+ // Detach remaining GTK widgets before destroying the WebViews so GTK
677+ // does not attempt to dispose still-parented children.
678+ if parentContainer != nil {
679+ parentContainer.Execute(func(panedPtr uintptr) error {
680+ if parentNode.left == sibling {
681+ webkit.PanedSetStartChild(panedPtr, 0)
682+ } else if parentNode.right == sibling {
683+ webkit.PanedSetEndChild(panedPtr, 0)
684+ }
685+ return nil
686+ })
687+ }
688+ if sibling.container != nil {
689+ sibling.container.Execute(func(containerPtr uintptr) error {
690+ webkit.WidgetUnparent(containerPtr)
691+ return nil
692+ })
693+ }
694+ if parentContainer != nil && wm.window != nil {
695+ wm.window.SetChild(0)
696+ }
697+
698+ log.Printf("[workspace] closing primary pane with related popup present; exiting")
699+ ensureCleanup()
700+ sibling.pane.Cleanup()
701+
702+ wm.detachHover(node)
703+ wm.detachHover(sibling)
704+
705+ // Clean up workspace tracking for both panes
706+ node.pane.CleanupFromWorkspace(wm)
707+ sibling.pane.CleanupFromWorkspace(wm)
708+
709+ if node.pane.webView != nil {
710+ if err := node.pane.webView.Destroy(); err != nil {
711+ log.Printf("[workspace] failed to destroy primary webview: %v", err)
712+ }
713+ }
714+ if sibling.pane.webView != nil {
715+ if err := sibling.pane.webView.Destroy(); err != nil {
716+ log.Printf("[workspace] failed to destroy popup webview: %v", err)
717+ }
718+ }
719+
720+ wm.root = nil
721+ wm.currentlyFocused = nil
722+ wm.mainPane = nil
723+
724+ webkit.QuitMainLoop()
725+ return nil
726+ }
727+ }
728+ }
729+ if willBeLastPane && wm.root == node {
730+ log.Printf("[workspace] closing final pane; exiting browser")
731+ ensureCleanup()
732+ wm.detachHover(node)
733+ if err := node.pane.webView.Destroy(); err != nil {
734+ log.Printf("[workspace] failed to destroy webview: %v", err)
735+ }
736+ webkit.QuitMainLoop()
737+ return nil
738+ }
739+
740+ parent := node.parent
741+ if parent == nil {
742+ // This is the root pane (no parent in tree structure)
743+ if node != wm.root {
744+ return errors.New("inconsistent state: node has no parent but is not root")
745+ }
746+
747+ // Check if we can find a replacement root
748+ replacement := wm.findReplacementRoot(node)
749+ if replacement == nil {
750+ // No other panes exist, this is the final pane
751+ log.Printf("[workspace] closing final pane; exiting browser")
752+ ensureCleanup()
753+ wm.detachHover(node)
754+ if err := node.pane.webView.Destroy(); err != nil {
755+ log.Printf("[workspace] failed to destroy webview: %v", err)
756+ }
757+ webkit.QuitMainLoop()
758+ return nil
759+ }
760+
761+ // We have a replacement - delegate root status and close this pane
762+ log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container)
763+
764+ // Clean up workspace tracking
765+ node.pane.CleanupFromWorkspace(wm)
766+
767+ if wm.mainPane == node {
768+ wm.mainPane = nil
769+ }
770+
771+ // Clear current active if it's the node being closed
772+ if wm.currentlyFocused == node {
773+ wm.currentlyFocused = nil
774+ }
775+
776+ // Set replacement as new root
777+ wm.root = replacement
778+ replacement.parent = nil
779+
780+ // Replace window child directly (GTK handles reparenting automatically)
781+ if wm.window != nil && replacement.container != nil {
782+ replacement.container.Execute(func(containerPtr uintptr) error {
783+ wm.window.SetChild(containerPtr)
784+ webkit.WidgetQueueAllocate(containerPtr)
785+ webkit.WidgetShow(containerPtr)
786+ return nil
787+ })
788+ }
789+
790+ // Focus a suitable pane
791+ focusTarget := wm.leftmostLeaf(replacement)
792+ if focusTarget != nil {
793+ wm.currentlyFocused = focusTarget
794+ wm.focusManager.SetActivePane(focusTarget)
795+ }
796+
797+ // Destroy the webview and detach hover AFTER rearranging hierarchy
798+ // Only destroy the webview if this is the final pane, otherwise just clean up
799+ ensureCleanup()
800+ wm.detachHover(node)
801+ switch {
802+ case node.isPopup:
803+ if err := node.pane.webView.Destroy(); err != nil {
804+ log.Printf("[workspace] failed to destroy popup webview: %v", err)
805+ }
806+ case willBeLastPane:
807+ // This is the last pane, safe to destroy completely
808+ if err := node.pane.webView.Destroy(); err != nil {
809+ log.Printf("[workspace] failed to destroy webview: %v", err)
810+ }
811+ default:
812+ // Multiple panes remain, don't destroy the window - just clean up the webview
813+ log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
814+ // TODO: Add a method to destroy just the webview without the window
815+ }
816+
817+ wm.updateMainPane()
818+ // Update CSS classes after pane count changes
819+ wm.ensurePaneBaseClasses()
820+ log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
821+ return nil
822+ }
823+
824+ var sibling *paneNode
825+ if parent.left == node {
826+ sibling = parent.right
827+ } else {
828+ sibling = parent.left
829+ }
830+ if sibling == nil {
831+ return errors.New("pane close failed: missing sibling")
832+ }
833+
834+ log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)
835+
836+ // Clean up workspace tracking
837+ node.pane.CleanupFromWorkspace(wm)
838+
839+ if wm.mainPane == node {
840+ wm.mainPane = nil
841+ }
842+
843+ // Clear current active if it's the node being closed
844+ if wm.currentlyFocused == node {
845+ wm.currentlyFocused = nil
846+ }
847+
848+ grand := parent.parent
849+ if grand == nil {
850+ // Parent is the root node. Promote sibling to become the new root.
851+ log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)
852+
853+ // Update tree structure first
854+ wm.root = sibling
855+ sibling.parent = nil
856+
857+ // For root promotion, GTK4 handles unparenting automatically when we set the new window child
858+ if wm.window != nil && sibling.container != nil {
859+ sibling.container.Execute(func(containerPtr uintptr) error {
860+ wm.window.SetChild(containerPtr)
861+ webkit.WidgetQueueAllocate(containerPtr)
862+ webkit.WidgetShow(containerPtr)
863+ log.Printf("[workspace] successfully promoted sibling to root: %#x", containerPtr)
864+ return nil
865+ })
866+ }
867+ } else {
868+ // Parent has a grandparent, so promote sibling to take parent's place
869+ log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container)
870+
871+ // Update tree structure
872+ sibling.parent = grand
873+ if grand.left == parent {
874+ grand.left = sibling
875+ } else {
876+ grand.right = sibling
877+ }
878+
879+ // Move sibling to grandparent's paned widget
880+ // GTK4 handles unparenting automatically when we set the new paned child
881+ grand.container.Execute(func(grandPtr uintptr) error {
882+ sibling.container.Execute(func(siblingPtr uintptr) error {
883+ if grand.left == sibling {
884+ webkit.PanedSetStartChild(grandPtr, siblingPtr)
885+ } else {
886+ webkit.PanedSetEndChild(grandPtr, siblingPtr)
887+ }
888+ webkit.WidgetQueueAllocate(siblingPtr)
889+ webkit.WidgetShow(siblingPtr)
890+ log.Printf("[workspace] successfully promoted sibling to parent position: %#x", siblingPtr)
891+ return nil
892+ })
893+ return nil
894+ })
895+ }
896+
897+ // Find a suitable focus target
898+ focusTarget := wm.leftmostLeaf(sibling)
899+ if focusTarget != nil {
900+ wm.currentlyFocused = focusTarget
901+ wm.focusManager.SetActivePane(focusTarget)
902+ }
903+
904+ // Clean up the node being closed
905+ ensureCleanup()
906+ wm.detachHover(node)
907+
908+ switch {
909+ case node.isPopup:
910+ if err := node.pane.webView.Destroy(); err != nil {
911+ log.Printf("[workspace] failed to destroy popup webview: %v", err)
912+ }
913+ case willBeLastPane:
914+ // This is the last pane, safe to destroy completely
915+ if err := node.pane.webView.Destroy(); err != nil {
916+ log.Printf("[workspace] failed to destroy webview: %v", err)
917+ }
918+ default:
919+ // Multiple panes remain, don't destroy the window - just clean up the webview
920+ log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
921+ // TODO: Add a method to destroy just the webview without the window
922+ }
923+
924+ wm.updateMainPane()
925+ // Update CSS classes after pane count changes
926+ wm.ensurePaneBaseClasses()
927+ log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
928+ return nil
929+}
A internal/app/browser/workspace_popup.go +605 -0
@@ -0,0 +1,605 @@
1+// workspace_popup.go - Popup window handling and related pane management
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/app/messaging"
11+ "github.com/bnema/dumber/pkg/webkit"
12+)
13+
14+// HandlePopup handles popup window creation requests from WebViews
15+func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {
16+ log.Printf("[workspace] HandlePopup called for URL: %s", url)
17+
18+ // Check for frame type markers added by WebKit layer
19+ isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank")
20+ isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup")
21+
22+ // Clean the URL by removing our markers
23+ if isBlankTarget || isPopupTarget {
24+ if isBlankTarget {
25+ url = strings.TrimSuffix(url, "#__dumber_frame_blank")
26+ log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url)
27+ } else {
28+ url = strings.TrimSuffix(url, "#__dumber_frame_popup")
29+ log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url)
30+ }
31+ }
32+
33+ if wm == nil || source == nil {
34+ log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup")
35+ return nil
36+ }
37+
38+ node := wm.viewToNode[source]
39+ if node == nil {
40+ log.Printf("[workspace] popup from unknown webview - allowing native popup")
41+ return nil
42+ }
43+
44+ // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
45+
46+ cfg := wm.app.config
47+ if cfg == nil {
48+ log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
49+ return nil
50+ }
51+
52+ popCfg := cfg.Workspace.Popups
53+ log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement)
54+
55+ if !popCfg.OpenInNewPane {
56+ log.Printf("[workspace] Popup creation disabled in config - allowing native popup")
57+ return nil
58+ }
59+
60+ // Smart detection path: create temporary view and decide placement once type is known
61+ if popCfg.EnableSmartDetection {
62+ webkitCfg, err := wm.app.buildWebkitConfig()
63+ if err != nil {
64+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
65+ return nil
66+ }
67+ webkitCfg.CreateWindow = false
68+ // Create as related to avoid WindowFeatures crash; we'll decide final placement later
69+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
70+ if err != nil {
71+ log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
72+ return nil
73+ }
74+
75+ // Register detection callback
76+ newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
77+ wm.RunOnUI(func() {
78+ wm.handleDetectedWindowType(node, newView, url, t, feat)
79+ })
80+ })
81+
82+ // Fallback: if detection never fires, treat as popup as before
83+ go func() {
84+ time.Sleep(1500 * time.Millisecond)
85+ if newView != nil {
86+ wm.RunOnUI(func() {
87+ if wm.viewToNode[newView] == nil { // not yet placed
88+ wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
89+ }
90+ })
91+ }
92+ }()
93+
94+ return newView
95+ }
96+
97+ // Legacy path preserved
98+ webkitCfg, err := wm.app.buildWebkitConfig()
99+ if err != nil {
100+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
101+ return nil
102+ }
103+ webkitCfg.CreateWindow = false
104+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
105+ if err != nil {
106+ log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
107+ return nil
108+ }
109+
110+ // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
111+
112+ // Create a pane for the new WebView
113+ newPane, err := wm.createPane(newView)
114+ if err != nil {
115+ log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
116+ return nil
117+ }
118+
119+ // Determine placement direction
120+ direction := strings.ToLower(popCfg.Placement)
121+ if direction == "" {
122+ direction = "right"
123+ }
124+
125+ // Determine target node for splitting
126+ target := node
127+ if !popCfg.FollowPaneContext && wm.currentlyFocused != nil {
128+ target = wm.currentlyFocused
129+ }
130+
131+ // Add the popup pane to the workspace using manual pane insertion
132+ if err := wm.insertPopupPane(target, newPane, direction); err != nil {
133+ log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
134+ return nil
135+ }
136+
137+ // Apply different behavior based on target type
138+ if isBlankTarget {
139+ log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
140+ // For _blank targets, just ensure GUI - no popup-specific behavior
141+ } else {
142+ log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
143+ // Mark as popup for auto-close handling (OAuth flows, etc.)
144+ newNode := wm.viewToNode[newView]
145+ if newNode != nil {
146+ newNode.isPopup = true
147+ log.Printf("[workspace] Marked pane as popup for auto-close handling")
148+
149+ // Register close handler for popup auto-close on window.close()
150+ newView.RegisterCloseHandler(func() {
151+ log.Printf("[workspace] Popup requesting close via window.close()")
152+ // Look up the node at close time
153+ if node := wm.viewToNode[newView]; node != nil && node.isPopup {
154+ log.Printf("[workspace] Closing popup pane")
155+ // Brief delay to allow any final redirects to complete
156+ time.AfterFunc(200*time.Millisecond, func() {
157+ webkit.IdleAdd(func() bool {
158+ if err := wm.closePane(node); err != nil {
159+ log.Printf("[workspace] Failed to close popup pane: %v", err)
160+ }
161+ return false
162+ })
163+ })
164+ } else {
165+ log.Printf("[workspace] Could not find popup node for close handler")
166+ }
167+ })
168+ } else {
169+ log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
170+ }
171+ }
172+
173+ // Ensure GUI components are available in the new pane
174+ wm.ensureGUIInPane(newPane)
175+
176+ // Inject GUI components into the popup pane
177+ wm.ensureGUIInPane(newPane)
178+
179+ // Load the URL if provided
180+ if url != "" {
181+ paneType := "popup"
182+ if isBlankTarget {
183+ paneType = "_blank target"
184+ }
185+ log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
186+ if err := newView.LoadURL(url); err != nil {
187+ log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
188+ }
189+ // Ensure the WebView is visible after loading URL
190+ if err := newView.Show(); err != nil {
191+ log.Printf("[workspace] failed to show popup WebView: %v", err)
192+ }
193+ }
194+
195+ if isBlankTarget {
196+ log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
197+ } else {
198+ log.Printf("[workspace] Created popup pane for URL: %s", url)
199+ }
200+ return newView
201+}
202+
203+// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
204+// Note: OAuth detection is now handled by the main-world.js injection script
205+func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
206+ log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
207+ log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
208+}
209+
210+// applyWindowFeatures applies window features to a WebView based on intent
211+func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
212+ if intent == nil {
213+ return
214+ }
215+
216+ features := &webkit.WindowFeatures{}
217+
218+ // Apply dimensions if specified
219+ if intent.Width != nil {
220+ features.Width = *intent.Width
221+ }
222+ if intent.Height != nil {
223+ features.Height = *intent.Height
224+ }
225+
226+ // Apply visibility features based on window type
227+ defaultToolbar := !isPopup
228+ defaultLocation := !isPopup
229+ defaultMenubar := !isPopup
230+
231+ if intent.Toolbar != nil {
232+ features.ToolbarVisible = *intent.Toolbar
233+ } else {
234+ features.ToolbarVisible = defaultToolbar
235+ }
236+
237+ if intent.Location != nil {
238+ features.LocationbarVisible = *intent.Location
239+ } else {
240+ features.LocationbarVisible = defaultLocation
241+ }
242+
243+ if intent.Menubar != nil {
244+ features.MenubarVisible = *intent.Menubar
245+ } else {
246+ features.MenubarVisible = defaultMenubar
247+ }
248+
249+ if intent.Resizable != nil {
250+ features.Resizable = *intent.Resizable
251+ } else {
252+ features.Resizable = true // Usually resizable unless explicitly disabled
253+ }
254+
255+ view.SetWindowFeatures(features)
256+ windowTypeStr := "tab"
257+ if isPopup {
258+ windowTypeStr = "popup"
259+ }
260+ log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
261+ windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
262+}
263+
264+// handleIntentAsTab creates a tab pane based on window.open intent
265+func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
266+ log.Printf("[workspace] Handling intent as tab: %s", url)
267+
268+ webkitCfg, err := wm.app.buildWebkitConfig()
269+ if err != nil {
270+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
271+ return nil
272+ }
273+ webkitCfg.CreateWindow = false
274+
275+ newView, err := webkit.NewWebView(webkitCfg)
276+ if err != nil {
277+ log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
278+ return nil
279+ }
280+
281+ newPane, err := wm.createPane(newView)
282+ if err != nil {
283+ log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
284+ return nil
285+ }
286+
287+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
288+ if direction == "" {
289+ direction = "right"
290+ }
291+
292+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
293+ log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
294+ return nil
295+ }
296+
297+ node := wm.viewToNode[newView]
298+ if node != nil {
299+ node.windowType = webkit.WindowTypeTab
300+ node.isRelated = false
301+
302+ // Apply window features from JavaScript intent
303+ wm.applyWindowFeatures(newView, intent, false)
304+ }
305+
306+ wm.ensureGUIInPane(newPane)
307+
308+ if url != "" {
309+ if err := newView.LoadURL(url); err != nil {
310+ log.Printf("[workspace] failed to load tab URL: %v", err)
311+ }
312+ if err := newView.Show(); err != nil {
313+ log.Printf("[workspace] failed to show tab WebView: %v", err)
314+ }
315+ }
316+
317+ log.Printf("[workspace] Created tab pane for URL: %s", url)
318+ return newView
319+}
320+
321+// handleIntentAsPopup creates a related popup pane based on window.open intent
322+func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
323+ log.Printf("[workspace] Handling intent as popup: %s", url)
324+
325+ webkitCfg, err := wm.app.buildWebkitConfig()
326+ if err != nil {
327+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
328+ return nil
329+ }
330+ webkitCfg.CreateWindow = false
331+
332+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
333+ if err != nil {
334+ log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
335+ return nil
336+ }
337+
338+ // Log the parent-popup WebView ID relationship for OAuth auto-close
339+ parentWebViewID := sourceNode.pane.webView.ID()
340+ popupWebViewID := newView.ID()
341+ log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
342+
343+ // Store popup WebView ID in parent's localStorage for OAuth callback lookup
344+ storeScript := fmt.Sprintf(`
345+ try {
346+ const parentWebViewId = '%s';
347+ const popupWebViewId = '%s';
348+ const popupMapping = {
349+ parentId: parentWebViewId,
350+ popupId: popupWebViewId,
351+ timestamp: Date.now(),
352+ url: '%s'
353+ };
354+ localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
355+ console.log('[workspace] Stored popup mapping:', popupMapping);
356+ } catch(e) {
357+ console.warn('[workspace] Failed to store popup mapping:', e);
358+ }
359+ `, parentWebViewID, popupWebViewID, url)
360+
361+ // Inject into parent WebView so it can find its popup later
362+ if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
363+ log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
364+ }
365+
366+ newPane, err := wm.createPane(newView)
367+ if err != nil {
368+ log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
369+ return nil
370+ }
371+
372+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
373+ if direction == "" {
374+ direction = "right"
375+ }
376+
377+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
378+ log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
379+ return nil
380+ }
381+
382+ node := wm.viewToNode[newView]
383+ var requestID string
384+ if node != nil {
385+ node.windowType = webkit.WindowTypePopup
386+ node.isRelated = true
387+ node.parentPane = sourceNode
388+ node.isPopup = true
389+ node.autoClose = wm.shouldAutoClose(url)
390+
391+ // Store requestID for deduplication cleanup
392+ if intent != nil {
393+ requestID = intent.RequestID
394+ }
395+
396+ // Apply window features from JavaScript intent
397+ wm.applyWindowFeatures(newView, intent, true)
398+ }
399+
400+ // Register close handler for popup auto-close
401+ newView.RegisterCloseHandler(func() {
402+ log.Printf("[workspace] Popup requesting close via window.close()")
403+
404+ // Clear the RequestID from deduplicator to allow new popups with same ID
405+ if requestID != "" && wm.paneDeduplicator != nil {
406+ wm.paneDeduplicator.ClearRequestID(requestID)
407+ }
408+
409+ if n := wm.viewToNode[newView]; n != nil && n.isPopup {
410+ time.AfterFunc(200*time.Millisecond, func() {
411+ webkit.IdleAdd(func() bool {
412+ if err := wm.closePane(n); err != nil {
413+ log.Printf("[workspace] Failed to close popup pane: %v", err)
414+ }
415+ return false
416+ })
417+ })
418+ }
419+ })
420+
421+ // URL-based auto-close for OAuth popups
422+ if node != nil && node.isPopup && node.autoClose {
423+ wm.registerOAuthAutoClose(newView, url)
424+ }
425+
426+ wm.ensureGUIInPane(newPane)
427+
428+ if url != "" {
429+ if err := newView.LoadURL(url); err != nil {
430+ log.Printf("[workspace] failed to load popup URL: %v", err)
431+ }
432+ if err := newView.Show(); err != nil {
433+ log.Printf("[workspace] failed to show popup WebView: %v", err)
434+ }
435+ }
436+
437+ log.Printf("[workspace] Created popup pane for URL: %s", url)
438+ return newView
439+}
440+
441+// insertIndependentPane inserts a new independent pane next to the source
442+func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
443+ newPane, err := wm.createPane(webView)
444+ if err != nil {
445+ return err
446+ }
447+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
448+ if direction == "" {
449+ direction = "right"
450+ }
451+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive
452+ return err
453+ }
454+ node := wm.viewToNode[webView]
455+ if node != nil {
456+ node.windowType = webkit.WindowTypeTab
457+ node.isRelated = false
458+ }
459+ if url != "" {
460+ _ = webView.LoadURL(url)
461+ }
462+ return nil
463+}
464+
465+// configureRelatedPopup creates a related view and inserts it
466+func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
467+ // Use the WebView that was already created and returned to WebKit
468+ related := webView
469+ newPane, err := wm.createPane(related)
470+ if err != nil {
471+ log.Printf("[workspace] failed to create related popup pane: %v", err)
472+ return
473+ }
474+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
475+ if direction == "" {
476+ direction = "right"
477+ }
478+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
479+ log.Printf("[workspace] failed to insert related popup pane: %v", err)
480+ return
481+ }
482+ node := wm.viewToNode[related]
483+ if node != nil {
484+ node.windowType = webkit.WindowTypePopup
485+ node.windowFeatures = feat
486+ node.isRelated = true
487+ node.parentPane = sourceNode
488+ node.isPopup = true
489+ // Heuristic + config for auto-close intent
490+ node.autoClose = wm.shouldAutoClose(url)
491+ }
492+ // Pipe into existing auto-close flow only for popups (confirmed by detection)
493+ related.RegisterCloseHandler(func() {
494+ log.Printf("[workspace] Popup requesting close via window.close()")
495+ if n := wm.viewToNode[related]; n != nil && n.isPopup {
496+ time.AfterFunc(200*time.Millisecond, func() {
497+ webkit.IdleAdd(func() bool {
498+ if err := wm.closePane(n); err != nil {
499+ log.Printf("[workspace] Failed to close popup pane: %v", err)
500+ }
501+ return false
502+ })
503+ })
504+ }
505+ })
506+
507+ // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
508+ if node != nil && node.isPopup && node.autoClose {
509+ wm.registerOAuthAutoClose(related, url)
510+ }
511+ if url != "" {
512+ _ = related.LoadURL(url)
513+ }
514+}
515+
516+// shouldAutoClose checks simple OAuth-like URL patterns and config flag
517+func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
518+ log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
519+
520+ if wm == nil || wm.app == nil || wm.app.config == nil {
521+ log.Printf("[workspace] shouldAutoClose: missing config, returning true")
522+ return true
523+ }
524+ if !wm.app.config.Workspace.Popups.OAuthAutoClose {
525+ log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
526+ return false
527+ }
528+
529+ u := strings.ToLower(url)
530+ log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
531+
532+ // RFC 6749 compliant OAuth 2.0 URL patterns
533+ oauthPatterns := []string{
534+ // Standard OAuth endpoints
535+ "oauth", "authorize", "authorization",
536+ // Standard callback/redirect patterns
537+ "callback", "redirect", "auth/callback",
538+ // OpenID Connect patterns
539+ "oidc", "openid",
540+ // Common OAuth parameter indicators
541+ "response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
542+ // Standard OAuth response parameters
543+ "code=", "access_token=", "id_token=", "token_type=",
544+ // Error response parameters
545+ "error=", "error_description=",
546+ }
547+
548+ // Check for OAuth patterns in URL
549+ for _, pattern := range oauthPatterns {
550+ if strings.Contains(u, pattern) {
551+ log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
552+ return true
553+ }
554+ }
555+
556+ log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
557+ return false
558+}
559+
560+// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
561+func (wm *WorkspaceManager) RunOnUI(fn func()) {
562+ if fn != nil {
563+ fn()
564+ }
565+}
566+
567+// handleDetectedWindowType handles window type detection from smart detection path
568+func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
569+ if wm.viewToNode[webView] != nil {
570+ return // Already placed
571+ }
572+
573+ log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
574+
575+ switch windowType {
576+ case webkit.WindowTypeTab:
577+ // For tabs, create a NEW independent WebView (can't use the related one)
578+ webkitCfg, err := wm.app.buildWebkitConfig()
579+ if err != nil {
580+ log.Printf("[workspace] failed to build webkit config for tab: %v", err)
581+ return
582+ }
583+ webkitCfg.CreateWindow = false
584+
585+ // Create independent WebView like handleIntentAsTab does
586+ independentView, err := webkit.NewWebView(webkitCfg)
587+ if err != nil {
588+ log.Printf("[workspace] failed to create independent tab WebView: %v", err)
589+ return
590+ }
591+
592+ // The related webView was just for detection - we don't use it for tabs
593+ // Insert the new independent view as a tab
594+ if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
595+ log.Printf("[workspace] Failed to insert independent pane: %v", err)
596+ }
597+
598+ case webkit.WindowTypePopup:
599+ // For popups, use the related WebView we already created
600+ wm.configureRelatedPopup(sourceNode, webView, url, features)
601+ default:
602+ // Fallback to popup behavior
603+ wm.configureRelatedPopup(sourceNode, webView, url, features)
604+ }
605+}
@@ -0,0 +1,605 @@
1+// workspace_popup.go - Popup window handling and related pane management
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/app/messaging"
11+ "github.com/bnema/dumber/pkg/webkit"
12+)
13+
14+// HandlePopup handles popup window creation requests from WebViews
15+func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {
16+ log.Printf("[workspace] HandlePopup called for URL: %s", url)
17+
18+ // Check for frame type markers added by WebKit layer
19+ isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank")
20+ isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup")
21+
22+ // Clean the URL by removing our markers
23+ if isBlankTarget || isPopupTarget {
24+ if isBlankTarget {
25+ url = strings.TrimSuffix(url, "#__dumber_frame_blank")
26+ log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url)
27+ } else {
28+ url = strings.TrimSuffix(url, "#__dumber_frame_popup")
29+ log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url)
30+ }
31+ }
32+
33+ if wm == nil || source == nil {
34+ log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup")
35+ return nil
36+ }
37+
38+ node := wm.viewToNode[source]
39+ if node == nil {
40+ log.Printf("[workspace] popup from unknown webview - allowing native popup")
41+ return nil
42+ }
43+
44+ // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
45+
46+ cfg := wm.app.config
47+ if cfg == nil {
48+ log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
49+ return nil
50+ }
51+
52+ popCfg := cfg.Workspace.Popups
53+ log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement)
54+
55+ if !popCfg.OpenInNewPane {
56+ log.Printf("[workspace] Popup creation disabled in config - allowing native popup")
57+ return nil
58+ }
59+
60+ // Smart detection path: create temporary view and decide placement once type is known
61+ if popCfg.EnableSmartDetection {
62+ webkitCfg, err := wm.app.buildWebkitConfig()
63+ if err != nil {
64+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
65+ return nil
66+ }
67+ webkitCfg.CreateWindow = false
68+ // Create as related to avoid WindowFeatures crash; we'll decide final placement later
69+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
70+ if err != nil {
71+ log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
72+ return nil
73+ }
74+
75+ // Register detection callback
76+ newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
77+ wm.RunOnUI(func() {
78+ wm.handleDetectedWindowType(node, newView, url, t, feat)
79+ })
80+ })
81+
82+ // Fallback: if detection never fires, treat as popup as before
83+ go func() {
84+ time.Sleep(1500 * time.Millisecond)
85+ if newView != nil {
86+ wm.RunOnUI(func() {
87+ if wm.viewToNode[newView] == nil { // not yet placed
88+ wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
89+ }
90+ })
91+ }
92+ }()
93+
94+ return newView
95+ }
96+
97+ // Legacy path preserved
98+ webkitCfg, err := wm.app.buildWebkitConfig()
99+ if err != nil {
100+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
101+ return nil
102+ }
103+ webkitCfg.CreateWindow = false
104+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
105+ if err != nil {
106+ log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
107+ return nil
108+ }
109+
110+ // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
111+
112+ // Create a pane for the new WebView
113+ newPane, err := wm.createPane(newView)
114+ if err != nil {
115+ log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
116+ return nil
117+ }
118+
119+ // Determine placement direction
120+ direction := strings.ToLower(popCfg.Placement)
121+ if direction == "" {
122+ direction = "right"
123+ }
124+
125+ // Determine target node for splitting
126+ target := node
127+ if !popCfg.FollowPaneContext && wm.currentlyFocused != nil {
128+ target = wm.currentlyFocused
129+ }
130+
131+ // Add the popup pane to the workspace using manual pane insertion
132+ if err := wm.insertPopupPane(target, newPane, direction); err != nil {
133+ log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
134+ return nil
135+ }
136+
137+ // Apply different behavior based on target type
138+ if isBlankTarget {
139+ log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
140+ // For _blank targets, just ensure GUI - no popup-specific behavior
141+ } else {
142+ log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
143+ // Mark as popup for auto-close handling (OAuth flows, etc.)
144+ newNode := wm.viewToNode[newView]
145+ if newNode != nil {
146+ newNode.isPopup = true
147+ log.Printf("[workspace] Marked pane as popup for auto-close handling")
148+
149+ // Register close handler for popup auto-close on window.close()
150+ newView.RegisterCloseHandler(func() {
151+ log.Printf("[workspace] Popup requesting close via window.close()")
152+ // Look up the node at close time
153+ if node := wm.viewToNode[newView]; node != nil && node.isPopup {
154+ log.Printf("[workspace] Closing popup pane")
155+ // Brief delay to allow any final redirects to complete
156+ time.AfterFunc(200*time.Millisecond, func() {
157+ webkit.IdleAdd(func() bool {
158+ if err := wm.closePane(node); err != nil {
159+ log.Printf("[workspace] Failed to close popup pane: %v", err)
160+ }
161+ return false
162+ })
163+ })
164+ } else {
165+ log.Printf("[workspace] Could not find popup node for close handler")
166+ }
167+ })
168+ } else {
169+ log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
170+ }
171+ }
172+
173+ // Ensure GUI components are available in the new pane
174+ wm.ensureGUIInPane(newPane)
175+
176+ // Inject GUI components into the popup pane
177+ wm.ensureGUIInPane(newPane)
178+
179+ // Load the URL if provided
180+ if url != "" {
181+ paneType := "popup"
182+ if isBlankTarget {
183+ paneType = "_blank target"
184+ }
185+ log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
186+ if err := newView.LoadURL(url); err != nil {
187+ log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
188+ }
189+ // Ensure the WebView is visible after loading URL
190+ if err := newView.Show(); err != nil {
191+ log.Printf("[workspace] failed to show popup WebView: %v", err)
192+ }
193+ }
194+
195+ if isBlankTarget {
196+ log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
197+ } else {
198+ log.Printf("[workspace] Created popup pane for URL: %s", url)
199+ }
200+ return newView
201+}
202+
203+// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
204+// Note: OAuth detection is now handled by the main-world.js injection script
205+func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
206+ log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
207+ log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
208+}
209+
210+// applyWindowFeatures applies window features to a WebView based on intent
211+func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
212+ if intent == nil {
213+ return
214+ }
215+
216+ features := &webkit.WindowFeatures{}
217+
218+ // Apply dimensions if specified
219+ if intent.Width != nil {
220+ features.Width = *intent.Width
221+ }
222+ if intent.Height != nil {
223+ features.Height = *intent.Height
224+ }
225+
226+ // Apply visibility features based on window type
227+ defaultToolbar := !isPopup
228+ defaultLocation := !isPopup
229+ defaultMenubar := !isPopup
230+
231+ if intent.Toolbar != nil {
232+ features.ToolbarVisible = *intent.Toolbar
233+ } else {
234+ features.ToolbarVisible = defaultToolbar
235+ }
236+
237+ if intent.Location != nil {
238+ features.LocationbarVisible = *intent.Location
239+ } else {
240+ features.LocationbarVisible = defaultLocation
241+ }
242+
243+ if intent.Menubar != nil {
244+ features.MenubarVisible = *intent.Menubar
245+ } else {
246+ features.MenubarVisible = defaultMenubar
247+ }
248+
249+ if intent.Resizable != nil {
250+ features.Resizable = *intent.Resizable
251+ } else {
252+ features.Resizable = true // Usually resizable unless explicitly disabled
253+ }
254+
255+ view.SetWindowFeatures(features)
256+ windowTypeStr := "tab"
257+ if isPopup {
258+ windowTypeStr = "popup"
259+ }
260+ log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
261+ windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
262+}
263+
264+// handleIntentAsTab creates a tab pane based on window.open intent
265+func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
266+ log.Printf("[workspace] Handling intent as tab: %s", url)
267+
268+ webkitCfg, err := wm.app.buildWebkitConfig()
269+ if err != nil {
270+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
271+ return nil
272+ }
273+ webkitCfg.CreateWindow = false
274+
275+ newView, err := webkit.NewWebView(webkitCfg)
276+ if err != nil {
277+ log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
278+ return nil
279+ }
280+
281+ newPane, err := wm.createPane(newView)
282+ if err != nil {
283+ log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
284+ return nil
285+ }
286+
287+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
288+ if direction == "" {
289+ direction = "right"
290+ }
291+
292+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
293+ log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
294+ return nil
295+ }
296+
297+ node := wm.viewToNode[newView]
298+ if node != nil {
299+ node.windowType = webkit.WindowTypeTab
300+ node.isRelated = false
301+
302+ // Apply window features from JavaScript intent
303+ wm.applyWindowFeatures(newView, intent, false)
304+ }
305+
306+ wm.ensureGUIInPane(newPane)
307+
308+ if url != "" {
309+ if err := newView.LoadURL(url); err != nil {
310+ log.Printf("[workspace] failed to load tab URL: %v", err)
311+ }
312+ if err := newView.Show(); err != nil {
313+ log.Printf("[workspace] failed to show tab WebView: %v", err)
314+ }
315+ }
316+
317+ log.Printf("[workspace] Created tab pane for URL: %s", url)
318+ return newView
319+}
320+
321+// handleIntentAsPopup creates a related popup pane based on window.open intent
322+func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
323+ log.Printf("[workspace] Handling intent as popup: %s", url)
324+
325+ webkitCfg, err := wm.app.buildWebkitConfig()
326+ if err != nil {
327+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
328+ return nil
329+ }
330+ webkitCfg.CreateWindow = false
331+
332+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
333+ if err != nil {
334+ log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
335+ return nil
336+ }
337+
338+ // Log the parent-popup WebView ID relationship for OAuth auto-close
339+ parentWebViewID := sourceNode.pane.webView.ID()
340+ popupWebViewID := newView.ID()
341+ log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
342+
343+ // Store popup WebView ID in parent's localStorage for OAuth callback lookup
344+ storeScript := fmt.Sprintf(`
345+ try {
346+ const parentWebViewId = '%s';
347+ const popupWebViewId = '%s';
348+ const popupMapping = {
349+ parentId: parentWebViewId,
350+ popupId: popupWebViewId,
351+ timestamp: Date.now(),
352+ url: '%s'
353+ };
354+ localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
355+ console.log('[workspace] Stored popup mapping:', popupMapping);
356+ } catch(e) {
357+ console.warn('[workspace] Failed to store popup mapping:', e);
358+ }
359+ `, parentWebViewID, popupWebViewID, url)
360+
361+ // Inject into parent WebView so it can find its popup later
362+ if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
363+ log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
364+ }
365+
366+ newPane, err := wm.createPane(newView)
367+ if err != nil {
368+ log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
369+ return nil
370+ }
371+
372+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
373+ if direction == "" {
374+ direction = "right"
375+ }
376+
377+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
378+ log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
379+ return nil
380+ }
381+
382+ node := wm.viewToNode[newView]
383+ var requestID string
384+ if node != nil {
385+ node.windowType = webkit.WindowTypePopup
386+ node.isRelated = true
387+ node.parentPane = sourceNode
388+ node.isPopup = true
389+ node.autoClose = wm.shouldAutoClose(url)
390+
391+ // Store requestID for deduplication cleanup
392+ if intent != nil {
393+ requestID = intent.RequestID
394+ }
395+
396+ // Apply window features from JavaScript intent
397+ wm.applyWindowFeatures(newView, intent, true)
398+ }
399+
400+ // Register close handler for popup auto-close
401+ newView.RegisterCloseHandler(func() {
402+ log.Printf("[workspace] Popup requesting close via window.close()")
403+
404+ // Clear the RequestID from deduplicator to allow new popups with same ID
405+ if requestID != "" && wm.paneDeduplicator != nil {
406+ wm.paneDeduplicator.ClearRequestID(requestID)
407+ }
408+
409+ if n := wm.viewToNode[newView]; n != nil && n.isPopup {
410+ time.AfterFunc(200*time.Millisecond, func() {
411+ webkit.IdleAdd(func() bool {
412+ if err := wm.closePane(n); err != nil {
413+ log.Printf("[workspace] Failed to close popup pane: %v", err)
414+ }
415+ return false
416+ })
417+ })
418+ }
419+ })
420+
421+ // URL-based auto-close for OAuth popups
422+ if node != nil && node.isPopup && node.autoClose {
423+ wm.registerOAuthAutoClose(newView, url)
424+ }
425+
426+ wm.ensureGUIInPane(newPane)
427+
428+ if url != "" {
429+ if err := newView.LoadURL(url); err != nil {
430+ log.Printf("[workspace] failed to load popup URL: %v", err)
431+ }
432+ if err := newView.Show(); err != nil {
433+ log.Printf("[workspace] failed to show popup WebView: %v", err)
434+ }
435+ }
436+
437+ log.Printf("[workspace] Created popup pane for URL: %s", url)
438+ return newView
439+}
440+
441+// insertIndependentPane inserts a new independent pane next to the source
442+func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
443+ newPane, err := wm.createPane(webView)
444+ if err != nil {
445+ return err
446+ }
447+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
448+ if direction == "" {
449+ direction = "right"
450+ }
451+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive
452+ return err
453+ }
454+ node := wm.viewToNode[webView]
455+ if node != nil {
456+ node.windowType = webkit.WindowTypeTab
457+ node.isRelated = false
458+ }
459+ if url != "" {
460+ _ = webView.LoadURL(url)
461+ }
462+ return nil
463+}
464+
465+// configureRelatedPopup creates a related view and inserts it
466+func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
467+ // Use the WebView that was already created and returned to WebKit
468+ related := webView
469+ newPane, err := wm.createPane(related)
470+ if err != nil {
471+ log.Printf("[workspace] failed to create related popup pane: %v", err)
472+ return
473+ }
474+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
475+ if direction == "" {
476+ direction = "right"
477+ }
478+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
479+ log.Printf("[workspace] failed to insert related popup pane: %v", err)
480+ return
481+ }
482+ node := wm.viewToNode[related]
483+ if node != nil {
484+ node.windowType = webkit.WindowTypePopup
485+ node.windowFeatures = feat
486+ node.isRelated = true
487+ node.parentPane = sourceNode
488+ node.isPopup = true
489+ // Heuristic + config for auto-close intent
490+ node.autoClose = wm.shouldAutoClose(url)
491+ }
492+ // Pipe into existing auto-close flow only for popups (confirmed by detection)
493+ related.RegisterCloseHandler(func() {
494+ log.Printf("[workspace] Popup requesting close via window.close()")
495+ if n := wm.viewToNode[related]; n != nil && n.isPopup {
496+ time.AfterFunc(200*time.Millisecond, func() {
497+ webkit.IdleAdd(func() bool {
498+ if err := wm.closePane(n); err != nil {
499+ log.Printf("[workspace] Failed to close popup pane: %v", err)
500+ }
501+ return false
502+ })
503+ })
504+ }
505+ })
506+
507+ // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
508+ if node != nil && node.isPopup && node.autoClose {
509+ wm.registerOAuthAutoClose(related, url)
510+ }
511+ if url != "" {
512+ _ = related.LoadURL(url)
513+ }
514+}
515+
516+// shouldAutoClose checks simple OAuth-like URL patterns and config flag
517+func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
518+ log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
519+
520+ if wm == nil || wm.app == nil || wm.app.config == nil {
521+ log.Printf("[workspace] shouldAutoClose: missing config, returning true")
522+ return true
523+ }
524+ if !wm.app.config.Workspace.Popups.OAuthAutoClose {
525+ log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
526+ return false
527+ }
528+
529+ u := strings.ToLower(url)
530+ log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
531+
532+ // RFC 6749 compliant OAuth 2.0 URL patterns
533+ oauthPatterns := []string{
534+ // Standard OAuth endpoints
535+ "oauth", "authorize", "authorization",
536+ // Standard callback/redirect patterns
537+ "callback", "redirect", "auth/callback",
538+ // OpenID Connect patterns
539+ "oidc", "openid",
540+ // Common OAuth parameter indicators
541+ "response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
542+ // Standard OAuth response parameters
543+ "code=", "access_token=", "id_token=", "token_type=",
544+ // Error response parameters
545+ "error=", "error_description=",
546+ }
547+
548+ // Check for OAuth patterns in URL
549+ for _, pattern := range oauthPatterns {
550+ if strings.Contains(u, pattern) {
551+ log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
552+ return true
553+ }
554+ }
555+
556+ log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
557+ return false
558+}
559+
560+// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
561+func (wm *WorkspaceManager) RunOnUI(fn func()) {
562+ if fn != nil {
563+ fn()
564+ }
565+}
566+
567+// handleDetectedWindowType handles window type detection from smart detection path
568+func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
569+ if wm.viewToNode[webView] != nil {
570+ return // Already placed
571+ }
572+
573+ log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
574+
575+ switch windowType {
576+ case webkit.WindowTypeTab:
577+ // For tabs, create a NEW independent WebView (can't use the related one)
578+ webkitCfg, err := wm.app.buildWebkitConfig()
579+ if err != nil {
580+ log.Printf("[workspace] failed to build webkit config for tab: %v", err)
581+ return
582+ }
583+ webkitCfg.CreateWindow = false
584+
585+ // Create independent WebView like handleIntentAsTab does
586+ independentView, err := webkit.NewWebView(webkitCfg)
587+ if err != nil {
588+ log.Printf("[workspace] failed to create independent tab WebView: %v", err)
589+ return
590+ }
591+
592+ // The related webView was just for detection - we don't use it for tabs
593+ // Insert the new independent view as a tab
594+ if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
595+ log.Printf("[workspace] Failed to insert independent pane: %v", err)
596+ }
597+
598+ case webkit.WindowTypePopup:
599+ // For popups, use the related WebView we already created
600+ wm.configureRelatedPopup(sourceNode, webView, url, features)
601+ default:
602+ // Fallback to popup behavior
603+ wm.configureRelatedPopup(sourceNode, webView, url, features)
604+ }
605+}
A internal/app/browser/workspace_types.go +46 -0
@@ -0,0 +1,46 @@
1+// workspace_types.go - Type definitions and constants for workspace management
2+package browser
3+
4+import (
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// paneNode represents a node in the workspace pane tree structure.
9+// It can be either a leaf node (containing a browser pane) or a branch node
10+// (containing child nodes for split panes or stacked panes).
11+type paneNode struct {
12+ pane *BrowserPane
13+ parent *paneNode
14+ left *paneNode
15+ right *paneNode
16+
17+ // Widget management with proper lifecycle tracking
18+ container *SafeWidget // Main container (GtkPaned for branch nodes, wrapper GtkBox for stacked nodes, WebView container for leaves)
19+ orientation webkit.Orientation
20+ isLeaf bool
21+ isPopup bool // Deprecated: use windowType instead
22+
23+ // Window type tracking
24+ windowType webkit.WindowType // Tab or Popup
25+ windowFeatures *webkit.WindowFeatures // Features if popup
26+ isRelated bool // Shares context
27+ parentPane *paneNode // Parent for related views
28+ autoClose bool // Auto-close on OAuth success
29+ hoverToken uintptr
30+
31+ // Stacked panes support with proper widget management
32+ isStacked bool // Whether this node contains stacked panes
33+ stackedPanes []*paneNode // List of stacked panes (if isStacked)
34+ activeStackIndex int // Index of currently visible pane in stack
35+ titleBar *SafeWidget // GtkBox for title bar (when collapsed)
36+ stackWrapper *SafeWidget // Internal GtkBox containing the actual stacked widgets (titles + webviews)
37+}
38+
39+// Workspace CSS class constants
40+const (
41+ basePaneClass = "workspace-pane"
42+ multiPaneClass = "workspace-multi-pane"
43+)
44+
45+// Focus calculation epsilon for geometric comparisons
46+const focusEpsilon = 1e-3
@@ -0,0 +1,46 @@
1+// workspace_types.go - Type definitions and constants for workspace management
2+package browser
3+
4+import (
5+ "github.com/bnema/dumber/pkg/webkit"
6+)
7+
8+// paneNode represents a node in the workspace pane tree structure.
9+// It can be either a leaf node (containing a browser pane) or a branch node
10+// (containing child nodes for split panes or stacked panes).
11+type paneNode struct {
12+ pane *BrowserPane
13+ parent *paneNode
14+ left *paneNode
15+ right *paneNode
16+
17+ // Widget management with proper lifecycle tracking
18+ container *SafeWidget // Main container (GtkPaned for branch nodes, wrapper GtkBox for stacked nodes, WebView container for leaves)
19+ orientation webkit.Orientation
20+ isLeaf bool
21+ isPopup bool // Deprecated: use windowType instead
22+
23+ // Window type tracking
24+ windowType webkit.WindowType // Tab or Popup
25+ windowFeatures *webkit.WindowFeatures // Features if popup
26+ isRelated bool // Shares context
27+ parentPane *paneNode // Parent for related views
28+ autoClose bool // Auto-close on OAuth success
29+ hoverToken uintptr
30+
31+ // Stacked panes support with proper widget management
32+ isStacked bool // Whether this node contains stacked panes
33+ stackedPanes []*paneNode // List of stacked panes (if isStacked)
34+ activeStackIndex int // Index of currently visible pane in stack
35+ titleBar *SafeWidget // GtkBox for title bar (when collapsed)
36+ stackWrapper *SafeWidget // Internal GtkBox containing the actual stacked widgets (titles + webviews)
37+}
38+
39+// Workspace CSS class constants
40+const (
41+ basePaneClass = "workspace-pane"
42+ multiPaneClass = "workspace-multi-pane"
43+)
44+
45+// Focus calculation epsilon for geometric comparisons
46+const focusEpsilon = 1e-3
A internal/app/browser/workspace_utils.go +144 -0
@@ -0,0 +1,144 @@
1+// workspace_utils.go - Utility functions and helpers for workspace management
2+package browser
3+
4+import (
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+)
10+
11+// hasMultiplePanes returns true if there are multiple panes in the workspace
12+func (wm *WorkspaceManager) hasMultiplePanes() bool {
13+ return wm != nil && wm.app != nil && len(wm.app.panes) > 1
14+}
15+
16+// UpdateTitleBar updates the title bar label for a WebView in stacked panes
17+func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) {
18+ // Delegate to StackedPaneManager which has the correct logic
19+ if wm.stackedPaneManager != nil {
20+ wm.stackedPaneManager.UpdateTitleBar(webView, title)
21+ }
22+}
23+
24+// ensureGUIInPane lazily loads GUI components into a pane when it gains focus
25+// ensureGUIInPane is now a no-op since GUI is injected globally by WebKit
26+// This prevents duplicate GUI injection that was causing duplicate log messages
27+func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
28+ if pane == nil {
29+ return
30+ }
31+
32+ // GUI is already injected globally via WebKit's enableUserContentManager
33+ // Just mark the pane as having GUI to prevent unnecessary calls
34+ if !pane.HasGUI() {
35+ pane.SetHasGUI(true)
36+ pane.SetGUIComponent("manager", true)
37+ pane.SetGUIComponent("omnibox", true)
38+ }
39+}
40+
41+// mapDirection maps a direction string to GTK orientation and positioning
42+func mapDirection(direction string) (webkit.Orientation, bool) {
43+ switch direction {
44+ case "left":
45+ return webkit.OrientationHorizontal, false
46+ case "up":
47+ return webkit.OrientationVertical, false
48+ case "down":
49+ return webkit.OrientationVertical, true
50+ default:
51+ return webkit.OrientationHorizontal, true
52+ }
53+}
54+
55+// Widget management utilities
56+
57+// validateWidgetsForReparenting validates that all widgets needed for reparenting are valid
58+func (wm *WorkspaceManager) validateWidgetsForReparenting(sibling, parent, grand *paneNode) error {
59+ // Validate sibling container (this is the most critical one)
60+ if sibling.container == nil {
61+ return errors.New("sibling container is nil")
62+ }
63+ if !sibling.container.IsValid() {
64+ return fmt.Errorf("sibling container %s is not valid", sibling.container.String())
65+ }
66+
67+ // Validate parent container if present
68+ if parent != nil && parent.container != nil && !parent.container.IsValid() {
69+ return fmt.Errorf("parent container %s is not valid", parent.container.String())
70+ }
71+
72+ // Validate grandparent container if present
73+ if grand != nil && grand.container != nil && !grand.container.IsValid() {
74+ return fmt.Errorf("grandparent container %s is not valid", grand.container.String())
75+ }
76+
77+ return nil
78+}
79+
80+// safeWidgetOperation performs a widget operation with proper locking and validation
81+func (wm *WorkspaceManager) safeWidgetOperation(operation func() error) error {
82+ wm.widgetMutex.Lock()
83+ defer wm.widgetMutex.Unlock()
84+ return operation()
85+}
86+
87+// registerWidget registers a widget with the registry and returns a SafeWidget
88+func (wm *WorkspaceManager) registerWidget(ptr uintptr, typeInfo string) *SafeWidget {
89+ if ptr == 0 {
90+ return nil
91+ }
92+ return wm.widgetRegistry.Register(ptr, typeInfo)
93+}
94+
95+// initializePaneWidgets sets up SafeWidget wrappers for a paneNode
96+func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) {
97+ // Register the main container
98+ node.container = wm.registerWidget(containerPtr, "pane-container")
99+
100+ // Initialize other widget fields as nil (will be set when needed)
101+ node.titleBar = nil
102+ node.stackWrapper = nil
103+}
104+
105+// Helper functions for safe widget operations
106+
107+// getContainerPtr returns the container widget pointer for a pane node
108+func (node *paneNode) getContainerPtr() uintptr {
109+ if node.container != nil {
110+ return node.container.Ptr()
111+ }
112+ return 0
113+}
114+
115+// getTitleBarPtr returns the title bar widget pointer for a pane node
116+func (node *paneNode) getTitleBarPtr() uintptr {
117+ if node.titleBar != nil {
118+ return node.titleBar.Ptr()
119+ }
120+ return 0
121+}
122+
123+// getStackWrapperPtr returns the stack wrapper widget pointer for a pane node
124+func (node *paneNode) getStackWrapperPtr() uintptr {
125+ if node.stackWrapper != nil {
126+ return node.stackWrapper.Ptr()
127+ }
128+ return 0
129+}
130+
131+// setContainer sets the SafeWidget container
132+func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) {
133+ node.container = wm.registerWidget(ptr, typeInfo)
134+}
135+
136+// setTitleBar sets the SafeWidget titleBar
137+func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) {
138+ node.titleBar = wm.registerWidget(ptr, "title-bar")
139+}
140+
141+// setStackWrapper sets the SafeWidget stackWrapper
142+func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) {
143+ node.stackWrapper = wm.registerWidget(ptr, "stack-wrapper")
144+}
@@ -0,0 +1,144 @@
1+// workspace_utils.go - Utility functions and helpers for workspace management
2+package browser
3+
4+import (
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+)
10+
11+// hasMultiplePanes returns true if there are multiple panes in the workspace
12+func (wm *WorkspaceManager) hasMultiplePanes() bool {
13+ return wm != nil && wm.app != nil && len(wm.app.panes) > 1
14+}
15+
16+// UpdateTitleBar updates the title bar label for a WebView in stacked panes
17+func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) {
18+ // Delegate to StackedPaneManager which has the correct logic
19+ if wm.stackedPaneManager != nil {
20+ wm.stackedPaneManager.UpdateTitleBar(webView, title)
21+ }
22+}
23+
24+// ensureGUIInPane lazily loads GUI components into a pane when it gains focus
25+// ensureGUIInPane is now a no-op since GUI is injected globally by WebKit
26+// This prevents duplicate GUI injection that was causing duplicate log messages
27+func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
28+ if pane == nil {
29+ return
30+ }
31+
32+ // GUI is already injected globally via WebKit's enableUserContentManager
33+ // Just mark the pane as having GUI to prevent unnecessary calls
34+ if !pane.HasGUI() {
35+ pane.SetHasGUI(true)
36+ pane.SetGUIComponent("manager", true)
37+ pane.SetGUIComponent("omnibox", true)
38+ }
39+}
40+
41+// mapDirection maps a direction string to GTK orientation and positioning
42+func mapDirection(direction string) (webkit.Orientation, bool) {
43+ switch direction {
44+ case "left":
45+ return webkit.OrientationHorizontal, false
46+ case "up":
47+ return webkit.OrientationVertical, false
48+ case "down":
49+ return webkit.OrientationVertical, true
50+ default:
51+ return webkit.OrientationHorizontal, true
52+ }
53+}
54+
55+// Widget management utilities
56+
57+// validateWidgetsForReparenting validates that all widgets needed for reparenting are valid
58+func (wm *WorkspaceManager) validateWidgetsForReparenting(sibling, parent, grand *paneNode) error {
59+ // Validate sibling container (this is the most critical one)
60+ if sibling.container == nil {
61+ return errors.New("sibling container is nil")
62+ }
63+ if !sibling.container.IsValid() {
64+ return fmt.Errorf("sibling container %s is not valid", sibling.container.String())
65+ }
66+
67+ // Validate parent container if present
68+ if parent != nil && parent.container != nil && !parent.container.IsValid() {
69+ return fmt.Errorf("parent container %s is not valid", parent.container.String())
70+ }
71+
72+ // Validate grandparent container if present
73+ if grand != nil && grand.container != nil && !grand.container.IsValid() {
74+ return fmt.Errorf("grandparent container %s is not valid", grand.container.String())
75+ }
76+
77+ return nil
78+}
79+
80+// safeWidgetOperation performs a widget operation with proper locking and validation
81+func (wm *WorkspaceManager) safeWidgetOperation(operation func() error) error {
82+ wm.widgetMutex.Lock()
83+ defer wm.widgetMutex.Unlock()
84+ return operation()
85+}
86+
87+// registerWidget registers a widget with the registry and returns a SafeWidget
88+func (wm *WorkspaceManager) registerWidget(ptr uintptr, typeInfo string) *SafeWidget {
89+ if ptr == 0 {
90+ return nil
91+ }
92+ return wm.widgetRegistry.Register(ptr, typeInfo)
93+}
94+
95+// initializePaneWidgets sets up SafeWidget wrappers for a paneNode
96+func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) {
97+ // Register the main container
98+ node.container = wm.registerWidget(containerPtr, "pane-container")
99+
100+ // Initialize other widget fields as nil (will be set when needed)
101+ node.titleBar = nil
102+ node.stackWrapper = nil
103+}
104+
105+// Helper functions for safe widget operations
106+
107+// getContainerPtr returns the container widget pointer for a pane node
108+func (node *paneNode) getContainerPtr() uintptr {
109+ if node.container != nil {
110+ return node.container.Ptr()
111+ }
112+ return 0
113+}
114+
115+// getTitleBarPtr returns the title bar widget pointer for a pane node
116+func (node *paneNode) getTitleBarPtr() uintptr {
117+ if node.titleBar != nil {
118+ return node.titleBar.Ptr()
119+ }
120+ return 0
121+}
122+
123+// getStackWrapperPtr returns the stack wrapper widget pointer for a pane node
124+func (node *paneNode) getStackWrapperPtr() uintptr {
125+ if node.stackWrapper != nil {
126+ return node.stackWrapper.Ptr()
127+ }
128+ return 0
129+}
130+
131+// setContainer sets the SafeWidget container
132+func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) {
133+ node.container = wm.registerWidget(ptr, typeInfo)
134+}
135+
136+// setTitleBar sets the SafeWidget titleBar
137+func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) {
138+ node.titleBar = wm.registerWidget(ptr, "title-bar")
139+}
140+
141+// setStackWrapper sets the SafeWidget stackWrapper
142+func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) {
143+ node.stackWrapper = wm.registerWidget(ptr, "stack-wrapper")
144+}

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