detached eac9484

feat(workspace): implement binary tree pane management system

commit
eac94840c97db096a6c0d9ff9cc0d3209289f49f
author
bamen · · 2025-09-19 03:41 UTC
parent
ba9ac75
signature
unsigned
3 files · +1530 -0
A internal/app/browser/pane.go +100 -0
@@ -0,0 +1,100 @@
1+package browser
2+
3+import (
4+ "log"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/app/control"
8+ "github.com/bnema/dumber/internal/app/messaging"
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// BrowserPane bundles the per-pane WebView and its controllers.
13+type BrowserPane struct {
14+ webView *webkit.WebView
15+ navigationController *control.NavigationController
16+ zoomController *control.ZoomController
17+ clipboardController *control.ClipboardController
18+ messageHandler *messaging.Handler
19+ shortcutHandler *ShortcutHandler
20+
21+ // Multi-pane support
22+ id string
23+ hasGUI bool
24+ guiComponents map[string]bool
25+ lastFocusTime time.Time
26+}
27+
28+func (p *BrowserPane) WebView() *webkit.WebView { return p.webView }
29+
30+func (p *BrowserPane) MessageHandler() *messaging.Handler { return p.messageHandler }
31+
32+func (p *BrowserPane) NavigationController() *control.NavigationController {
33+ return p.navigationController
34+}
35+
36+func (p *BrowserPane) ZoomController() *control.ZoomController { return p.zoomController }
37+
38+func (p *BrowserPane) ClipboardController() *control.ClipboardController {
39+ return p.clipboardController
40+}
41+
42+func (p *BrowserPane) ShortcutHandler() *ShortcutHandler { return p.shortcutHandler }
43+
44+// ID returns the unique pane identifier
45+func (p *BrowserPane) ID() string { return p.id }
46+
47+// HasGUI returns whether GUI components are injected
48+func (p *BrowserPane) HasGUI() bool { return p.hasGUI }
49+
50+// SetHasGUI marks GUI components as injected
51+func (p *BrowserPane) SetHasGUI(has bool) { p.hasGUI = has }
52+
53+// SetID sets the pane identifier
54+func (p *BrowserPane) SetID(id string) { p.id = id }
55+
56+// UpdateLastFocus updates the last focus time
57+func (p *BrowserPane) UpdateLastFocus() { p.lastFocusTime = time.Now() }
58+
59+// Initialize GUI component tracking
60+func (p *BrowserPane) initializeGUITracking() {
61+ if p.guiComponents == nil {
62+ p.guiComponents = make(map[string]bool)
63+ }
64+}
65+
66+// HasGUIComponent checks if a specific GUI component is loaded
67+func (p *BrowserPane) HasGUIComponent(component string) bool {
68+ if p.guiComponents == nil {
69+ return false
70+ }
71+ return p.guiComponents[component]
72+}
73+
74+// SetGUIComponent marks a GUI component as loaded
75+func (p *BrowserPane) SetGUIComponent(component string, loaded bool) {
76+ p.initializeGUITracking()
77+ p.guiComponents[component] = loaded
78+}
79+
80+// Cleanup releases pane resources
81+func (p *BrowserPane) Cleanup() {
82+ // Cleanup shortcuts
83+ if p.shortcutHandler != nil {
84+ // TODO: Add cleanup method to ShortcutHandler
85+ log.Printf("[pane-%s] Cleaned up shortcuts", p.id)
86+ }
87+
88+ // Notify JS to cleanup GUI
89+ if p.webView != nil && p.hasGUI {
90+ p.webView.InjectScript(`
91+ if (window.__dumber_gui_manager) {
92+ window.__dumber_gui_manager.destroy();
93+ }
94+ `)
95+ }
96+
97+ // Clear tracking
98+ p.guiComponents = nil
99+ p.hasGUI = false
100+}
@@ -0,0 +1,100 @@
1+package browser
2+
3+import (
4+ "log"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/app/control"
8+ "github.com/bnema/dumber/internal/app/messaging"
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// BrowserPane bundles the per-pane WebView and its controllers.
13+type BrowserPane struct {
14+ webView *webkit.WebView
15+ navigationController *control.NavigationController
16+ zoomController *control.ZoomController
17+ clipboardController *control.ClipboardController
18+ messageHandler *messaging.Handler
19+ shortcutHandler *ShortcutHandler
20+
21+ // Multi-pane support
22+ id string
23+ hasGUI bool
24+ guiComponents map[string]bool
25+ lastFocusTime time.Time
26+}
27+
28+func (p *BrowserPane) WebView() *webkit.WebView { return p.webView }
29+
30+func (p *BrowserPane) MessageHandler() *messaging.Handler { return p.messageHandler }
31+
32+func (p *BrowserPane) NavigationController() *control.NavigationController {
33+ return p.navigationController
34+}
35+
36+func (p *BrowserPane) ZoomController() *control.ZoomController { return p.zoomController }
37+
38+func (p *BrowserPane) ClipboardController() *control.ClipboardController {
39+ return p.clipboardController
40+}
41+
42+func (p *BrowserPane) ShortcutHandler() *ShortcutHandler { return p.shortcutHandler }
43+
44+// ID returns the unique pane identifier
45+func (p *BrowserPane) ID() string { return p.id }
46+
47+// HasGUI returns whether GUI components are injected
48+func (p *BrowserPane) HasGUI() bool { return p.hasGUI }
49+
50+// SetHasGUI marks GUI components as injected
51+func (p *BrowserPane) SetHasGUI(has bool) { p.hasGUI = has }
52+
53+// SetID sets the pane identifier
54+func (p *BrowserPane) SetID(id string) { p.id = id }
55+
56+// UpdateLastFocus updates the last focus time
57+func (p *BrowserPane) UpdateLastFocus() { p.lastFocusTime = time.Now() }
58+
59+// Initialize GUI component tracking
60+func (p *BrowserPane) initializeGUITracking() {
61+ if p.guiComponents == nil {
62+ p.guiComponents = make(map[string]bool)
63+ }
64+}
65+
66+// HasGUIComponent checks if a specific GUI component is loaded
67+func (p *BrowserPane) HasGUIComponent(component string) bool {
68+ if p.guiComponents == nil {
69+ return false
70+ }
71+ return p.guiComponents[component]
72+}
73+
74+// SetGUIComponent marks a GUI component as loaded
75+func (p *BrowserPane) SetGUIComponent(component string, loaded bool) {
76+ p.initializeGUITracking()
77+ p.guiComponents[component] = loaded
78+}
79+
80+// Cleanup releases pane resources
81+func (p *BrowserPane) Cleanup() {
82+ // Cleanup shortcuts
83+ if p.shortcutHandler != nil {
84+ // TODO: Add cleanup method to ShortcutHandler
85+ log.Printf("[pane-%s] Cleaned up shortcuts", p.id)
86+ }
87+
88+ // Notify JS to cleanup GUI
89+ if p.webView != nil && p.hasGUI {
90+ p.webView.InjectScript(`
91+ if (window.__dumber_gui_manager) {
92+ window.__dumber_gui_manager.destroy();
93+ }
94+ `)
95+ }
96+
97+ // Clear tracking
98+ p.guiComponents = nil
99+ p.hasGUI = false
100+}
A internal/app/browser/workspace_manager.go +1241 -0
@@ -0,0 +1,1241 @@
1+package browser
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log"
7+ "math"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/app/messaging"
13+ "github.com/bnema/dumber/pkg/webkit"
14+)
15+
16+type paneNode struct {
17+ pane *BrowserPane
18+ parent *paneNode
19+ left *paneNode
20+ right *paneNode
21+ container uintptr // GtkPaned for branch nodes, stable WebView container for leaves
22+ orientation webkit.Orientation
23+ isLeaf bool
24+ hoverToken uintptr
25+}
26+
27+// WorkspaceManager coordinates Zellij-style pane operations.
28+type WorkspaceManager struct {
29+ app *BrowserApp
30+ window *webkit.Window
31+ root *paneNode
32+ active *paneNode
33+ mainPane *paneNode
34+ viewToNode map[*webkit.WebView]*paneNode
35+ lastSplitMsg map[*webkit.WebView]time.Time
36+ lastExitMsg map[*webkit.WebView]time.Time
37+ paneModeActive bool
38+ splitting bool
39+ cssInitialized bool
40+ createWebViewFn func() (*webkit.WebView, error)
41+ createPaneFn func(*webkit.WebView) (*BrowserPane, error)
42+
43+ // Coordination fields for preventing duplicate events
44+ paneModeSource *webkit.WebView // Which webview initiated pane mode
45+ lastPaneModeEntry time.Time // When pane mode was last entered
46+ paneMutex sync.Mutex // Protects pane mode state
47+}
48+
49+const (
50+ // TODO : should be defined via Config + Defaults
51+ activePaneCSS = `.workspace-pane-active {
52+ border: 2px solid @theme_selected_bg_color;
53+ border-radius: 0px;
54+ transition: border-color 120ms ease-in-out;
55+}`
56+ activePaneClass = "workspace-pane-active"
57+)
58+
59+// registerWorkspaceShortcuts registers global workspace navigation shortcuts on the given webView.
60+func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView) {
61+ if wm == nil || webView == nil {
62+ return
63+ }
64+
65+ registerFocusMove := func(key, dir string) {
66+ _ = webView.RegisterKeyboardShortcut(key, func() {
67+ log.Printf("[workspace] %s callback triggered on webView=%p", key, webView)
68+ if wm.app == nil {
69+ log.Printf("[workspace] %s rejected: app is nil", key)
70+ return
71+ }
72+
73+ // Only allow the active webview to handle workspace navigation shortcuts
74+ if wm.app.activePane == nil || wm.app.activePane.webView != webView {
75+ log.Printf("[workspace] %s rejected: not active pane (active=%p, caller=%p)",
76+ key, wm.app.activePane.webView, webView)
77+ return
78+ }
79+
80+ log.Printf("[workspace] %s calling FocusNeighbor(%s)", key, dir)
81+ if wm.FocusNeighbor(dir) {
82+ log.Printf("Shortcut: focus pane %s", dir)
83+ } else {
84+ log.Printf("[workspace] FocusNeighbor(%s) returned false", dir)
85+ }
86+ })
87+ }
88+
89+ registerFocusMove("alt+ArrowLeft", "left")
90+ registerFocusMove("alt+ArrowRight", "right")
91+ registerFocusMove("alt+ArrowUp", "up")
92+ registerFocusMove("alt+ArrowDown", "down")
93+ registerFocusMove("cmdorctrl+ArrowUp", "up")
94+ registerFocusMove("cmdorctrl+ArrowDown", "down")
95+
96+ // Reduced logging: only log shortcuts registration during initialization, not on every hover
97+ if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
98+ log.Printf("[workspace] registered navigation shortcuts on webView=%p", webView)
99+ }
100+}
101+
102+// NewWorkspaceManager builds a workspace manager rooted at the provided pane.
103+func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
104+ manager := &WorkspaceManager{
105+ app: app,
106+ window: rootPane.webView.Window(),
107+ viewToNode: make(map[*webkit.WebView]*paneNode),
108+ lastSplitMsg: make(map[*webkit.WebView]time.Time),
109+ lastExitMsg: make(map[*webkit.WebView]time.Time),
110+ }
111+ manager.createWebViewFn = func() (*webkit.WebView, error) {
112+ if manager.app == nil {
113+ return nil, errors.New("workspace manager missing app reference")
114+ }
115+ cfg, err := manager.app.buildWebkitConfig()
116+ if err != nil {
117+ return nil, err
118+ }
119+ cfg.CreateWindow = false
120+ return webkit.NewWebView(cfg)
121+ }
122+ manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
123+ if manager.app == nil {
124+ return nil, errors.New("workspace manager missing app reference")
125+ }
126+ return manager.app.createPaneForView(view)
127+ }
128+ manager.ensureStyles()
129+
130+ rootContainer := rootPane.webView.RootWidget()
131+ root := &paneNode{
132+ pane: rootPane,
133+ container: rootContainer,
134+ isLeaf: true,
135+ }
136+ manager.root = root
137+ manager.active = root
138+ manager.mainPane = root
139+ manager.viewToNode[rootPane.webView] = root
140+ manager.ensureHover(root)
141+
142+ if handler := rootPane.MessageHandler(); handler != nil {
143+ handler.SetWorkspaceObserver(manager)
144+ }
145+
146+ app.workspace = manager
147+ manager.focusNode(root)
148+ manager.registerWorkspaceShortcuts(root.pane.webView)
149+ return manager
150+}
151+
152+// OnWorkspaceMessage implements messaging.WorkspaceObserver.
153+func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
154+ node := wm.viewToNode[source]
155+ if node == nil {
156+ log.Printf("[workspace] message from unknown webview: event=%s", msg.Event)
157+ return
158+ }
159+
160+ switch event := strings.ToLower(msg.Event); event {
161+ case "pane-mode-entered":
162+ // Use mutex to prevent race conditions when multiple webviews try to enter pane mode
163+ wm.paneMutex.Lock()
164+ defer wm.paneMutex.Unlock()
165+
166+ // Check if pane mode was already entered recently (within 200ms debounce window)
167+ if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond {
168+ log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p",
169+ time.Since(wm.lastPaneModeEntry).Seconds()*1000, source)
170+ return
171+ }
172+
173+ // Check if pane mode is already active from a different source
174+ if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source {
175+ log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)",
176+ wm.paneModeSource, source)
177+ return
178+ }
179+
180+ log.Printf("[workspace] pane-mode-entered accepted: source=%p", source)
181+ wm.paneModeActive = true
182+ wm.paneModeSource = source
183+ wm.lastPaneModeEntry = time.Now()
184+ wm.focusNode(node)
185+ case "pane-confirmed", "pane-cancelled", "pane-mode-exited":
186+ // Debounce pane-mode-exited events to prevent duplicate focus calls
187+ if event == "pane-mode-exited" {
188+ if last, ok := wm.lastExitMsg[source]; ok {
189+ if time.Since(last) < 100*time.Millisecond {
190+ log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
191+ return
192+ }
193+ }
194+ wm.lastExitMsg[source] = time.Now()
195+ }
196+
197+ wm.paneMutex.Lock()
198+ wm.paneModeActive = false
199+ wm.paneModeSource = nil
200+ wm.paneMutex.Unlock()
201+
202+ wm.focusNode(node)
203+ case "pane-close":
204+ if !wm.paneModeActive {
205+ log.Printf("[workspace] close requested outside pane mode; ignoring")
206+ break
207+ }
208+
209+ wm.paneMutex.Lock()
210+ wm.paneModeActive = false
211+ wm.paneModeSource = nil
212+ wm.paneMutex.Unlock()
213+
214+ // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus
215+ wm.closeCurrentPane()
216+ case "pane-split":
217+ wm.focusNode(node)
218+ direction := strings.ToLower(msg.Direction)
219+ if direction == "" {
220+ direction = "right"
221+ }
222+ if last, ok := wm.lastSplitMsg[source]; ok {
223+ if time.Since(last) < 200*time.Millisecond {
224+ log.Printf("[workspace] split ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
225+ return
226+ }
227+ }
228+ if wm.splitting {
229+ log.Printf("[workspace] split ignored: operation already in progress")
230+ return
231+ }
232+ wm.splitting = true
233+ wm.lastSplitMsg[source] = time.Now()
234+ newNode, err := wm.splitNode(node, direction)
235+ if err != nil {
236+ log.Printf("[workspace] split failed: %v", err)
237+ wm.splitting = false
238+ return
239+ }
240+ wm.clonePaneState(node, newNode)
241+ wm.splitting = false
242+ default:
243+ log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
244+ }
245+}
246+
247+func (wm *WorkspaceManager) focusNode(node *paneNode) {
248+ if node == nil || !node.isLeaf || node.pane == nil || node.pane.webView == nil {
249+ return
250+ }
251+
252+ // Don't focus a node that's not in our viewToNode map (likely destroyed)
253+ if _, exists := wm.viewToNode[node.pane.webView]; !exists {
254+ log.Printf("[workspace] focusNode: skipping destroyed/unknown webView=%p", node.pane.webView)
255+ return
256+ }
257+
258+ previous := wm.active
259+ var previousContainer uintptr
260+ var previousWebView *webkit.WebView
261+ if previous != nil && previous != node && previous.pane != nil && previous.pane.webView != nil {
262+ previousContainer = previous.container
263+ previousWebView = previous.pane.webView
264+ detail := map[string]any{
265+ "active": false,
266+ "webview": fmt.Sprintf("%p", previous.pane.webView),
267+ "paneId": previous.pane.ID(),
268+ "timestamp": time.Now().UnixMilli(),
269+ }
270+ if err := previous.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
271+ log.Printf("[workspace] failed to notify focus loss: %v", err)
272+ } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
273+ log.Printf("[workspace] notified focus loss for pane %s", previous.pane.ID())
274+ }
275+ }
276+
277+ if wm.active != nil && wm.active.container != 0 {
278+ webkit.WidgetRemoveCSSClass(wm.active.container, activePaneClass)
279+ }
280+
281+ wm.active = node
282+ wm.app.activePane = node.pane
283+ wm.app.webView = node.pane.webView
284+ wm.app.zoomController = node.pane.zoomController
285+ wm.app.navigationController = node.pane.navigationController
286+ wm.app.clipboardController = node.pane.clipboardController
287+ wm.app.messageHandler = node.pane.messageHandler
288+ wm.app.shortcutHandler = node.pane.shortcutHandler
289+
290+ // Re-register workspace navigation shortcuts on the newly focused webView
291+ wm.registerWorkspaceShortcuts(node.pane.webView)
292+
293+ if handler := node.pane.messageHandler; handler != nil {
294+ handler.SetWorkspaceObserver(wm)
295+ }
296+
297+ if wm.app.browserService != nil {
298+ wm.app.browserService.AttachWebView(node.pane.webView)
299+ }
300+
301+ container := node.container
302+ viewWidget := node.pane.webView.Widget()
303+ if container != 0 && container != previousContainer {
304+ webkit.WidgetAddCSSClass(container, activePaneClass)
305+ if !webkit.WidgetIsValid(container) {
306+ log.Printf("[workspace] focus aborted: container invalid widget=%#x", container)
307+ return
308+ }
309+ webkit.IdleAdd(func() bool {
310+ if !webkit.WidgetIsValid(container) {
311+ log.Printf("[workspace] focus aborted during idle: container invalid widget=%#x", container)
312+ return false
313+ }
314+ parent := webkit.WidgetGetParent(container)
315+ if parent != 0 {
316+ webkit.WidgetRealizeInContainer(container)
317+ if viewWidget != 0 {
318+ webkit.WidgetGrabFocus(viewWidget)
319+ }
320+ // Consolidated focus operation log
321+ if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
322+ log.Printf("[workspace] focus operations completed: container=%#x parent=%#x viewWidget=%#x", container, parent, viewWidget)
323+ }
324+ } else {
325+ log.Printf("[workspace] focus deferred: widget not parented")
326+ }
327+ return false // Remove idle callback
328+ })
329+ }
330+
331+ if node.pane != nil && node.pane.webView != nil && node.pane.webView != previousWebView {
332+ // Update pane focus time
333+ node.pane.UpdateLastFocus()
334+
335+ // Enhanced focus event with pane ID and GUI status
336+ detail := map[string]any{
337+ "active": true,
338+ "webview": fmt.Sprintf("%p", node.pane.webView),
339+ "paneId": node.pane.ID(),
340+ "hasGUI": node.pane.HasGUI(),
341+ "timestamp": time.Now().UnixMilli(),
342+ }
343+
344+ // Dispatch focus event to new active pane
345+ if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
346+ log.Printf("[workspace] failed to notify focus gain: %v", err)
347+ } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
348+ log.Printf("[workspace] notified focus gain for pane %s", node.pane.ID())
349+ }
350+
351+ // Lazy-load GUI components if first focus
352+ if !node.pane.HasGUI() {
353+ wm.ensureGUIInPane(node.pane)
354+ }
355+ }
356+}
357+
358+func (wm *WorkspaceManager) ensureStyles() {
359+ if wm == nil || wm.cssInitialized {
360+ return
361+ }
362+ webkit.AddCSSProvider(activePaneCSS)
363+ wm.cssInitialized = true
364+}
365+
366+func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
367+ if wm == nil || view == nil {
368+ return
369+ }
370+ if node, ok := wm.viewToNode[view]; ok {
371+ if wm.active != node {
372+ wm.focusNode(node)
373+ }
374+ }
375+}
376+
377+func (wm *WorkspaceManager) ensureHover(node *paneNode) {
378+ if wm == nil || node == nil || !node.isLeaf {
379+ return
380+ }
381+ if node.container == 0 || node.hoverToken != 0 {
382+ return
383+ }
384+
385+ token := webkit.WidgetAddHoverHandler(node.container, func() {
386+ if wm == nil {
387+ return
388+ }
389+ wm.focusNode(node)
390+ })
391+ node.hoverToken = token
392+ if token == 0 {
393+ log.Printf("[workspace] failed to attach hover handler: widget=%#x", node.container)
394+ }
395+}
396+
397+func (wm *WorkspaceManager) detachHover(node *paneNode) {
398+ if wm == nil || node == nil || node.hoverToken == 0 {
399+ return
400+ }
401+ webkit.WidgetRemoveHoverHandler(node.container, node.hoverToken)
402+ node.hoverToken = 0
403+}
404+
405+// FocusNeighbor moves focus to the nearest pane in the requested direction using the
406+// actual widget geometry to determine adjacency.
407+func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
408+ if wm == nil {
409+ return false
410+ }
411+ switch strings.ToLower(direction) {
412+ case "left", "right", "up", "down":
413+ return wm.focusAdjacent(strings.ToLower(direction))
414+ default:
415+ return false
416+ }
417+}
418+
419+const focusEpsilon = 1e-3
420+
421+func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
422+ if wm.active == nil || !wm.active.isLeaf || wm.active.container == 0 {
423+ return false
424+ }
425+
426+ if neighbor := wm.structuralNeighbor(wm.active, direction); neighbor != nil {
427+ wm.focusNode(neighbor)
428+ return true
429+ }
430+
431+ currentBounds, ok := webkit.WidgetGetBounds(wm.active.container)
432+ if !ok {
433+ log.Printf("[workspace] unable to compute bounds for active pane")
434+ return false
435+ }
436+
437+ cx := currentBounds.X + currentBounds.Width/2.0
438+ cy := currentBounds.Y + currentBounds.Height/2.0
439+
440+ leaves := wm.collectLeaves()
441+ bestScore := math.MaxFloat64
442+ var best *paneNode
443+ var debugCandidates []string
444+
445+ for _, candidate := range leaves {
446+ if candidate == nil || candidate == wm.active || candidate.container == 0 {
447+ continue
448+ }
449+ bounds, ok := webkit.WidgetGetBounds(candidate.container)
450+ if !ok {
451+ continue
452+ }
453+ tx := bounds.X + bounds.Width/2.0
454+ ty := bounds.Y + bounds.Height/2.0
455+
456+ dx := tx - cx
457+ dy := ty - cy
458+
459+ var score float64
460+ switch direction {
461+ case "left":
462+ if dx >= -focusEpsilon {
463+ continue
464+ }
465+ score = math.Abs(dx)*1000 + math.Abs(dy)
466+ case "right":
467+ if dx <= focusEpsilon {
468+ continue
469+ }
470+ score = math.Abs(dx)*1000 + math.Abs(dy)
471+ case "up":
472+ if dy >= -focusEpsilon {
473+ continue
474+ }
475+ score = math.Abs(dy)*1000 + math.Abs(dx)
476+ case "down":
477+ if dy <= focusEpsilon {
478+ continue
479+ }
480+ score = math.Abs(dy)*1000 + math.Abs(dx)
481+ }
482+
483+ if direction == "up" || direction == "down" {
484+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
485+ }
486+
487+ if score < bestScore {
488+ bestScore = score
489+ best = candidate
490+ }
491+ }
492+
493+ if best != nil {
494+ wm.focusNode(best)
495+ return true
496+ }
497+
498+ if len(debugCandidates) > 0 {
499+ log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, wm.active.container, strings.Join(debugCandidates, "; "))
500+ }
501+ return false
502+}
503+
504+func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
505+ if node == nil || node.container == 0 {
506+ return nil
507+ }
508+
509+ refBounds, ok := webkit.WidgetGetBounds(node.container)
510+ if !ok {
511+ return nil
512+ }
513+ cx := refBounds.X + refBounds.Width/2.0
514+ cy := refBounds.Y + refBounds.Height/2.0
515+ axisVertical := direction == "up" || direction == "down"
516+
517+ for parent := node.parent; parent != nil; parent = parent.parent {
518+ switch direction {
519+ case "up":
520+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {
521+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
522+ return leaf
523+ }
524+ }
525+ case "down":
526+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {
527+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
528+ return leaf
529+ }
530+ }
531+ case "left":
532+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {
533+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
534+ return leaf
535+ }
536+ }
537+ case "right":
538+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {
539+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
540+ return leaf
541+ }
542+ }
543+ }
544+ node = parent
545+ }
546+ return nil
547+}
548+
549+func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
550+ leaves := wm.collectLeavesFrom(node)
551+ bestScore := math.MaxFloat64
552+ var best *paneNode
553+ for _, leaf := range leaves {
554+ if leaf == nil || leaf.container == 0 {
555+ continue
556+ }
557+ bounds, ok := webkit.WidgetGetBounds(leaf.container)
558+ if !ok {
559+ continue
560+ }
561+ tx := bounds.X + bounds.Width/2.0
562+ ty := bounds.Y + bounds.Height/2.0
563+ dx := tx - cx
564+ dy := ty - cy
565+ var score float64
566+ switch direction {
567+ case "left":
568+ if dx >= -focusEpsilon {
569+ continue
570+ }
571+ score = math.Abs(dx)*1000 + math.Abs(dy)
572+ case "right":
573+ if dx <= focusEpsilon {
574+ continue
575+ }
576+ score = math.Abs(dx)*1000 + math.Abs(dy)
577+ case "up":
578+ if dy >= -focusEpsilon {
579+ continue
580+ }
581+ score = math.Abs(dy)*1000 + math.Abs(dx)
582+ case "down":
583+ if dy <= focusEpsilon {
584+ continue
585+ }
586+ score = math.Abs(dy)*1000 + math.Abs(dx)
587+ default:
588+ continue
589+ }
590+ if score < bestScore {
591+ bestScore = score
592+ best = leaf
593+ }
594+ }
595+ if best == nil {
596+ return wm.boundaryFallback(node, direction)
597+ }
598+ return best
599+}
600+
601+func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
602+ if node == nil {
603+ return nil
604+ }
605+ if node.isLeaf {
606+ return node
607+ }
608+ switch direction {
609+ case "up":
610+ if leaf := wm.boundaryFallback(node.right, direction); leaf != nil {
611+ return leaf
612+ }
613+ return wm.boundaryFallback(node.left, direction)
614+ case "down":
615+ if leaf := wm.boundaryFallback(node.left, direction); leaf != nil {
616+ return leaf
617+ }
618+ return wm.boundaryFallback(node.right, direction)
619+ case "left":
620+ if leaf := wm.boundaryFallback(node.right, direction); leaf != nil {
621+ return leaf
622+ }
623+ return wm.boundaryFallback(node.left, direction)
624+ case "right":
625+ if leaf := wm.boundaryFallback(node.left, direction); leaf != nil {
626+ return leaf
627+ }
628+ return wm.boundaryFallback(node.right, direction)
629+ default:
630+ return nil
631+ }
632+}
633+
634+func (wm *WorkspaceManager) collectLeaves() []*paneNode {
635+ return wm.collectLeavesFrom(wm.root)
636+}
637+
638+func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
639+ var leaves []*paneNode
640+ var walk func(*paneNode)
641+ walk = func(n *paneNode) {
642+ if n == nil {
643+ return
644+ }
645+ if n.isLeaf {
646+ leaves = append(leaves, n)
647+ return
648+ }
649+ walk(n.left)
650+ walk(n.right)
651+ }
652+ walk(node)
653+ return leaves
654+}
655+
656+func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
657+ if wm == nil || wm.createWebViewFn == nil {
658+ return nil, errors.New("workspace manager missing webview factory")
659+ }
660+ return wm.createWebViewFn()
661+}
662+
663+func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
664+ if wm == nil || wm.createPaneFn == nil {
665+ return nil, errors.New("workspace manager missing pane factory")
666+ }
667+ return wm.createPaneFn(view)
668+}
669+
670+func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*paneNode, error) {
671+ if target == nil || !target.isLeaf || target.pane == nil {
672+ return nil, errors.New("split target must be a leaf pane")
673+ }
674+
675+ newView, err := wm.createWebView()
676+ if err != nil {
677+ return nil, err
678+ }
679+
680+ newPane, err := wm.createPane(newView)
681+ if err != nil {
682+ return nil, err
683+ }
684+
685+ if handler := newPane.MessageHandler(); handler != nil {
686+ handler.SetWorkspaceObserver(wm)
687+ }
688+
689+ newContainer := newPane.webView.RootWidget()
690+ if newContainer == 0 {
691+ return nil, errors.New("new pane missing container")
692+ }
693+ webkit.WidgetSetHExpand(newContainer, true)
694+ webkit.WidgetSetVExpand(newContainer, true)
695+ webkit.WidgetRealizeInContainer(newContainer)
696+
697+ existingContainer := target.container
698+ if existingContainer == 0 {
699+ return nil, errors.New("existing pane missing container")
700+ }
701+ webkit.WidgetSetHExpand(existingContainer, true)
702+ webkit.WidgetSetVExpand(existingContainer, true)
703+ webkit.WidgetRealizeInContainer(existingContainer)
704+
705+ orientation, existingFirst := mapDirection(direction)
706+ log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
707+
708+ paned := webkit.NewPaned(orientation)
709+ if paned == 0 {
710+ return nil, errors.New("failed to create GtkPaned")
711+ }
712+ webkit.WidgetSetHExpand(paned, true)
713+ webkit.WidgetSetVExpand(paned, true)
714+ webkit.PanedSetResizeStart(paned, true)
715+ webkit.PanedSetResizeEnd(paned, true)
716+
717+ newLeaf := &paneNode{
718+ pane: newPane,
719+ container: newContainer,
720+ isLeaf: true,
721+ }
722+ split := &paneNode{
723+ parent: target.parent,
724+ container: paned,
725+ orientation: orientation,
726+ isLeaf: false,
727+ }
728+
729+ parent := split.parent
730+
731+ // Detach existing container from its current GTK parent before inserting into new paned.
732+ if parent == nil {
733+ // Target is the root - remove it from the window
734+ log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
735+ if wm.window != nil {
736+ wm.window.SetChild(0)
737+ }
738+ } else if parent.container != 0 {
739+ // Target has a parent paned - unparent it from there
740+ log.Printf("[workspace] unparenting existing container=%#x from parent paned=%#x", existingContainer, parent.container)
741+ if parent.left == target {
742+ webkit.PanedSetStartChild(parent.container, 0)
743+ } else if parent.right == target {
744+ webkit.PanedSetEndChild(parent.container, 0)
745+ }
746+ webkit.WidgetQueueAllocate(parent.container)
747+ }
748+
749+ // Add both containers to the new paned
750+ // Note: GTK4 may emit a critical error about widget parenting here when splitting
751+ // the root pane, but the operation still succeeds. This is a limitation of GTK's
752+ // widget hierarchy tracking when widgets are moved between window and paned containers.
753+ if existingFirst {
754+ split.left = target
755+ split.right = newLeaf
756+ webkit.PanedSetStartChild(paned, existingContainer)
757+ webkit.PanedSetEndChild(paned, newContainer)
758+ log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
759+ } else {
760+ split.left = newLeaf
761+ split.right = target
762+ webkit.PanedSetStartChild(paned, newContainer)
763+ webkit.PanedSetEndChild(paned, existingContainer)
764+ log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
765+ }
766+
767+ target.parent = split
768+ newLeaf.parent = split
769+
770+ if parent == nil {
771+ wm.root = split
772+ if wm.window != nil {
773+ wm.window.SetChild(paned)
774+ webkit.WidgetQueueAllocate(paned)
775+ log.Printf("[workspace] paned set as window child: paned=%#x", paned)
776+ }
777+ } else {
778+ if parent.left == target {
779+ parent.left = split
780+ webkit.PanedSetStartChild(parent.container, paned)
781+ } else if parent.right == target {
782+ parent.right = split
783+ webkit.PanedSetEndChild(parent.container, paned)
784+ }
785+ webkit.WidgetQueueAllocate(parent.container)
786+ webkit.WidgetQueueAllocate(paned)
787+ log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
788+ }
789+
790+ webkit.WidgetShow(paned)
791+
792+ wm.viewToNode[newPane.webView] = newLeaf
793+ wm.ensureHover(newLeaf)
794+ wm.ensureHover(target)
795+ wm.app.panes = append(wm.app.panes, newPane)
796+ if newPane.zoomController != nil {
797+ newPane.zoomController.ApplyInitialZoom()
798+ }
799+
800+ webkit.IdleAdd(func() bool {
801+ if newContainer != 0 {
802+ webkit.WidgetShow(newContainer)
803+ }
804+ wm.focusNode(newLeaf)
805+ return false
806+ })
807+
808+ return newLeaf, nil
809+}
810+
811+func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
812+ if wm == nil || target == nil {
813+ return
814+ }
815+ if target.pane == nil || target.pane.webView == nil {
816+ return
817+ }
818+
819+ const blankURL = "about:blank"
820+
821+ if err := target.pane.webView.LoadURL(blankURL); err != nil {
822+ log.Printf("[workspace] failed to prepare blank pane: %v", err)
823+ }
824+
825+ // Wait for about:blank to load before opening omnibox
826+ target.pane.webView.RegisterURIChangedHandler(func(uri string) {
827+ if uri == blankURL {
828+ // Defer omnibox opening to allow page to fully initialize
829+ webkit.IdleAdd(func() bool {
830+ if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil {
831+ log.Printf("[workspace] failed to open omnibox: %v", injErr)
832+ }
833+ return false // Remove idle callback
834+ })
835+ }
836+ })
837+}
838+
839+func (wm *WorkspaceManager) closeCurrentPane() {
840+ if wm == nil || wm.active == nil {
841+ return
842+ }
843+ if err := wm.closePane(wm.active); err != nil {
844+ log.Printf("[workspace] close current pane failed: %v", err)
845+ }
846+}
847+
848+func (wm *WorkspaceManager) closePane(node *paneNode) error {
849+ if wm == nil || node == nil || !node.isLeaf {
850+ return errors.New("close target must be a leaf pane")
851+ }
852+ if node.pane == nil || node.pane.webView == nil {
853+ return errors.New("close target missing webview")
854+ }
855+
856+ remaining := len(wm.app.panes)
857+ willBeLastPane := remaining <= 1
858+ if willBeLastPane && wm.root == node {
859+ log.Printf("[workspace] closing final pane; exiting browser")
860+ wm.detachHover(node)
861+ node.pane.webView.Destroy()
862+ webkit.QuitMainLoop()
863+ return nil
864+ }
865+
866+ parent := node.parent
867+ if parent == nil {
868+ // This is the root pane (no parent in tree structure)
869+ if node != wm.root {
870+ return errors.New("inconsistent state: node has no parent but is not root")
871+ }
872+
873+ // Check if we can find a replacement root
874+ replacement := wm.findReplacementRoot(node)
875+ if replacement == nil {
876+ // No other panes exist, this is the final pane
877+ log.Printf("[workspace] closing final pane; exiting browser")
878+ wm.detachHover(node)
879+ node.pane.webView.Destroy()
880+ webkit.QuitMainLoop()
881+ return nil
882+ }
883+
884+ // We have a replacement - delegate root status and close this pane
885+ log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container)
886+
887+ // Clean up references first
888+ delete(wm.viewToNode, node.pane.webView)
889+ delete(wm.lastSplitMsg, node.pane.webView)
890+ delete(wm.lastExitMsg, node.pane.webView)
891+
892+ for i, pane := range wm.app.panes {
893+ if pane == node.pane {
894+ wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
895+ break
896+ }
897+ }
898+
899+ if wm.mainPane == node {
900+ wm.mainPane = nil
901+ }
902+
903+ // Clear current active if it's the node being closed
904+ if wm.active == node {
905+ wm.active = nil
906+ }
907+
908+ // Set replacement as new root
909+ wm.root = replacement
910+ replacement.parent = nil
911+
912+ // Replace window child directly (GTK handles reparenting automatically)
913+ if wm.window != nil {
914+ wm.window.SetChild(replacement.container)
915+ if replacement.container != 0 {
916+ webkit.WidgetQueueAllocate(replacement.container)
917+ webkit.WidgetShow(replacement.container)
918+ }
919+ }
920+
921+ // Focus a suitable pane
922+ focusTarget := wm.leftmostLeaf(replacement)
923+ if focusTarget != nil {
924+ wm.focusNode(focusTarget)
925+ }
926+
927+ // Destroy the webview and detach hover AFTER rearranging hierarchy
928+ // Only destroy the webview if this is the final pane, otherwise just clean up
929+ wm.detachHover(node)
930+ if willBeLastPane {
931+ // This is the last pane, safe to destroy completely
932+ node.pane.webView.Destroy()
933+ } else {
934+ // Multiple panes remain, don't destroy the window - just clean up the webview
935+ log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
936+ // TODO: Add a method to destroy just the webview without the window
937+ }
938+
939+ wm.updateMainPane()
940+ log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
941+ return nil
942+ }
943+
944+ var sibling *paneNode
945+ if parent.left == node {
946+ sibling = parent.right
947+ } else {
948+ sibling = parent.left
949+ }
950+ if sibling == nil {
951+ return errors.New("pane close failed: missing sibling")
952+ }
953+
954+ log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)
955+
956+ // Find focus target before modifying the tree structure
957+ focusTarget := wm.leftmostLeaf(sibling)
958+ if focusTarget == nil {
959+ focusTarget = wm.leftmostLeaf(wm.root)
960+ }
961+
962+ // Clean up references first
963+ delete(wm.viewToNode, node.pane.webView)
964+ delete(wm.lastSplitMsg, node.pane.webView)
965+ delete(wm.lastExitMsg, node.pane.webView)
966+
967+ for i, pane := range wm.app.panes {
968+ if pane == node.pane {
969+ wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
970+ break
971+ }
972+ }
973+
974+ if wm.mainPane == node {
975+ wm.mainPane = nil
976+ }
977+
978+ // Clear current active if it's the node being closed
979+ if wm.active == node {
980+ wm.active = nil
981+ }
982+
983+ grand := parent.parent
984+ if grand == nil {
985+ // Parent is the root node. We need to restructure.
986+ // If going from 2 panes to 1, promote sibling to become the new root
987+ if remaining == 2 { // Will become 1 after this close
988+ log.Printf("[workspace] promoting sibling to root (last 2 panes): container=%#x", sibling.container)
989+ wm.root = sibling
990+ sibling.parent = nil
991+
992+ // Unparent the sibling from the paned first, then set it as window child
993+ if wm.window != nil {
994+ // GTK requires explicit unparenting before reparenting
995+ if parent.left == sibling {
996+ webkit.PanedSetStartChild(parent.container, 0)
997+ } else {
998+ webkit.PanedSetEndChild(parent.container, 0)
999+ }
1000+ // Now we can safely set it as window child
1001+ wm.window.SetChild(sibling.container)
1002+ if sibling.container != 0 {
1003+ webkit.WidgetQueueAllocate(sibling.container)
1004+ }
1005+ }
1006+ } else {
1007+ // More than 2 panes remain, so keep the tree structure
1008+ // The sibling should remain as a child of root, but we need to remove the parent paned
1009+ log.Printf("[workspace] removing parent paned, keeping sibling under root: container=%#x", sibling.container)
1010+ // This shouldn't happen in a proper binary tree structure
1011+ log.Printf("[workspace] ERROR: unexpected tree state - parent is root but more than 2 panes remain")
1012+ return errors.New("unexpected tree state during pane closure")
1013+ }
1014+ } else {
1015+ // Parent has a grandparent, so promote sibling to take parent's place
1016+ log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container)
1017+ sibling.parent = grand
1018+ if grand.container != 0 && !grand.isLeaf {
1019+ if grand.left == parent {
1020+ grand.left = sibling
1021+ webkit.PanedSetStartChild(grand.container, sibling.container)
1022+ } else if grand.right == parent {
1023+ grand.right = sibling
1024+ webkit.PanedSetEndChild(grand.container, sibling.container)
1025+ }
1026+ webkit.WidgetQueueAllocate(grand.container)
1027+ }
1028+ }
1029+
1030+ // Keep sibling subtree visible
1031+ if sibling.container != 0 {
1032+ webkit.WidgetShow(sibling.container)
1033+ }
1034+
1035+ // Clean up parent node references
1036+ parent.left = nil
1037+ parent.right = nil
1038+ node.parent = nil
1039+
1040+ // Focus the target pane
1041+ if focusTarget != nil && focusTarget != node {
1042+ wm.focusNode(focusTarget)
1043+ }
1044+
1045+ // Destroy the webview and detach hover AFTER all hierarchy changes are complete
1046+ // Only destroy the webview if this is the final pane, otherwise just clean up
1047+ wm.detachHover(node)
1048+ if willBeLastPane {
1049+ // This is the last pane, safe to destroy completely
1050+ node.pane.webView.Destroy()
1051+ } else {
1052+ // Multiple panes remain, don't destroy the window - just clean up the webview
1053+ log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1054+ // TODO: Add a method to destroy just the webview without the window
1055+ }
1056+
1057+ wm.updateMainPane()
1058+ log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
1059+ return nil
1060+}
1061+
1062+func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
1063+ for node != nil && !node.isLeaf {
1064+ if node.left != nil {
1065+ node = node.left
1066+ continue
1067+ }
1068+ node = node.right
1069+ }
1070+ return node
1071+}
1072+
1073+// findReplacementRoot finds a suitable replacement when closing the current root pane
1074+func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
1075+ if wm == nil || wm.root == nil {
1076+ return nil
1077+ }
1078+
1079+ // If root is being closed and there are other panes, find a replacement
1080+ leaves := wm.collectLeaves()
1081+ for _, leaf := range leaves {
1082+ if leaf != excludeNode && leaf != nil && leaf.isLeaf {
1083+ // Find the topmost ancestor that's not the current root
1084+ current := leaf
1085+ for current.parent != nil && current.parent != wm.root {
1086+ current = current.parent
1087+ }
1088+
1089+ // If this leaf is a direct child of root, or root only has one subtree,
1090+ // we can promote this subtree to be the new root
1091+ if current.parent == wm.root {
1092+ // If the sibling is being excluded, promote this subtree
1093+ var sibling *paneNode
1094+ if wm.root.left == current {
1095+ sibling = wm.root.right
1096+ } else {
1097+ sibling = wm.root.left
1098+ }
1099+
1100+ if sibling == excludeNode {
1101+ // The sibling is being closed, so promote this subtree
1102+ return current
1103+ }
1104+ }
1105+
1106+ // Otherwise, return the first suitable leaf
1107+ return leaf
1108+ }
1109+ }
1110+
1111+ return nil
1112+}
1113+
1114+func (wm *WorkspaceManager) updateMainPane() {
1115+ if len(wm.app.panes) == 1 {
1116+ if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
1117+ wm.mainPane = leaf
1118+ }
1119+ return
1120+ }
1121+
1122+ if wm.mainPane == nil || !wm.mainPane.isLeaf {
1123+ if wm.active != nil && wm.active.isLeaf {
1124+ wm.mainPane = wm.active
1125+ }
1126+ }
1127+}
1128+
1129+func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) bool {
1130+ if wm == nil || source == nil {
1131+ return false
1132+ }
1133+
1134+ node := wm.viewToNode[source]
1135+ if node == nil {
1136+ log.Printf("[workspace] popup from unknown webview")
1137+ return false
1138+ }
1139+
1140+ cfg := wm.app.config
1141+ if cfg == nil {
1142+ return false
1143+ }
1144+
1145+ popCfg := cfg.Workspace.Popups
1146+ if !popCfg.OpenInNewPane {
1147+ return false
1148+ }
1149+
1150+ direction := strings.ToLower(popCfg.Placement)
1151+ if direction == "" {
1152+ direction = "right"
1153+ }
1154+
1155+ target := node
1156+ if !popCfg.FollowPaneContext && wm.active != nil {
1157+ target = wm.active
1158+ }
1159+
1160+ newNode, err := wm.splitNode(target, direction)
1161+ if err != nil {
1162+ log.Printf("[workspace] popup split failed: %v", err)
1163+ return false
1164+ }
1165+
1166+ if url == "" {
1167+ return true
1168+ }
1169+
1170+ if newNode == nil || newNode.pane == nil {
1171+ return true
1172+ }
1173+
1174+ if newNode.pane.navigationController != nil {
1175+ if err := newNode.pane.navigationController.NavigateToURL(url); err != nil {
1176+ log.Printf("[workspace] popup navigation failed: %v", err)
1177+ if newNode.pane.webView != nil {
1178+ if loadErr := newNode.pane.webView.LoadURL(url); loadErr != nil {
1179+ log.Printf("[workspace] popup load fallback failed: %v", loadErr)
1180+ }
1181+ }
1182+ }
1183+ return true
1184+ }
1185+
1186+ if newNode.pane.webView != nil {
1187+ if err := newNode.pane.webView.LoadURL(url); err != nil {
1188+ log.Printf("[workspace] popup load failed: %v", err)
1189+ }
1190+ }
1191+
1192+ return true
1193+}
1194+
1195+// ensureGUIInPane lazily loads GUI components into a pane when it gains focus
1196+func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
1197+ if pane == nil || pane.HasGUI() {
1198+ return
1199+ }
1200+
1201+ log.Printf("[workspace] Injecting GUI components into pane %s", pane.ID())
1202+
1203+ // Inject GUI manager and omnibox
1204+ script := fmt.Sprintf(`
1205+ (async function() {
1206+ try {
1207+ // Set up GUI manager for this pane
1208+ window.__dumber_pane.active = true;
1209+ window.__dumber_gui_bootstrap && window.__dumber_gui_bootstrap('%s');
1210+
1211+ // For now, mark as ready - full GUIManager will be implemented later
1212+ console.log('[workspace] GUI components ready for pane %s');
1213+ } catch (error) {
1214+ console.error('[workspace] Failed to initialize GUI:', error);
1215+ }
1216+ })();
1217+ `, pane.ID(), pane.ID())
1218+
1219+ if err := pane.WebView().InjectScript(script); err != nil {
1220+ log.Printf("[workspace] Failed to inject GUI into pane %s: %v", pane.ID(), err)
1221+ return
1222+ }
1223+
1224+ // Mark GUI as injected
1225+ pane.SetHasGUI(true)
1226+ pane.SetGUIComponent("manager", true)
1227+ pane.SetGUIComponent("omnibox", true)
1228+}
1229+
1230+func mapDirection(direction string) (webkit.Orientation, bool) {
1231+ switch direction {
1232+ case "left":
1233+ return webkit.OrientationHorizontal, false
1234+ case "up":
1235+ return webkit.OrientationVertical, false
1236+ case "down":
1237+ return webkit.OrientationVertical, true
1238+ default:
1239+ return webkit.OrientationHorizontal, true
1240+ }
1241+}
@@ -0,0 +1,1241 @@
1+package browser
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log"
7+ "math"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/app/messaging"
13+ "github.com/bnema/dumber/pkg/webkit"
14+)
15+
16+type paneNode struct {
17+ pane *BrowserPane
18+ parent *paneNode
19+ left *paneNode
20+ right *paneNode
21+ container uintptr // GtkPaned for branch nodes, stable WebView container for leaves
22+ orientation webkit.Orientation
23+ isLeaf bool
24+ hoverToken uintptr
25+}
26+
27+// WorkspaceManager coordinates Zellij-style pane operations.
28+type WorkspaceManager struct {
29+ app *BrowserApp
30+ window *webkit.Window
31+ root *paneNode
32+ active *paneNode
33+ mainPane *paneNode
34+ viewToNode map[*webkit.WebView]*paneNode
35+ lastSplitMsg map[*webkit.WebView]time.Time
36+ lastExitMsg map[*webkit.WebView]time.Time
37+ paneModeActive bool
38+ splitting bool
39+ cssInitialized bool
40+ createWebViewFn func() (*webkit.WebView, error)
41+ createPaneFn func(*webkit.WebView) (*BrowserPane, error)
42+
43+ // Coordination fields for preventing duplicate events
44+ paneModeSource *webkit.WebView // Which webview initiated pane mode
45+ lastPaneModeEntry time.Time // When pane mode was last entered
46+ paneMutex sync.Mutex // Protects pane mode state
47+}
48+
49+const (
50+ // TODO : should be defined via Config + Defaults
51+ activePaneCSS = `.workspace-pane-active {
52+ border: 2px solid @theme_selected_bg_color;
53+ border-radius: 0px;
54+ transition: border-color 120ms ease-in-out;
55+}`
56+ activePaneClass = "workspace-pane-active"
57+)
58+
59+// registerWorkspaceShortcuts registers global workspace navigation shortcuts on the given webView.
60+func (wm *WorkspaceManager) registerWorkspaceShortcuts(webView *webkit.WebView) {
61+ if wm == nil || webView == nil {
62+ return
63+ }
64+
65+ registerFocusMove := func(key, dir string) {
66+ _ = webView.RegisterKeyboardShortcut(key, func() {
67+ log.Printf("[workspace] %s callback triggered on webView=%p", key, webView)
68+ if wm.app == nil {
69+ log.Printf("[workspace] %s rejected: app is nil", key)
70+ return
71+ }
72+
73+ // Only allow the active webview to handle workspace navigation shortcuts
74+ if wm.app.activePane == nil || wm.app.activePane.webView != webView {
75+ log.Printf("[workspace] %s rejected: not active pane (active=%p, caller=%p)",
76+ key, wm.app.activePane.webView, webView)
77+ return
78+ }
79+
80+ log.Printf("[workspace] %s calling FocusNeighbor(%s)", key, dir)
81+ if wm.FocusNeighbor(dir) {
82+ log.Printf("Shortcut: focus pane %s", dir)
83+ } else {
84+ log.Printf("[workspace] FocusNeighbor(%s) returned false", dir)
85+ }
86+ })
87+ }
88+
89+ registerFocusMove("alt+ArrowLeft", "left")
90+ registerFocusMove("alt+ArrowRight", "right")
91+ registerFocusMove("alt+ArrowUp", "up")
92+ registerFocusMove("alt+ArrowDown", "down")
93+ registerFocusMove("cmdorctrl+ArrowUp", "up")
94+ registerFocusMove("cmdorctrl+ArrowDown", "down")
95+
96+ // Reduced logging: only log shortcuts registration during initialization, not on every hover
97+ if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
98+ log.Printf("[workspace] registered navigation shortcuts on webView=%p", webView)
99+ }
100+}
101+
102+// NewWorkspaceManager builds a workspace manager rooted at the provided pane.
103+func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
104+ manager := &WorkspaceManager{
105+ app: app,
106+ window: rootPane.webView.Window(),
107+ viewToNode: make(map[*webkit.WebView]*paneNode),
108+ lastSplitMsg: make(map[*webkit.WebView]time.Time),
109+ lastExitMsg: make(map[*webkit.WebView]time.Time),
110+ }
111+ manager.createWebViewFn = func() (*webkit.WebView, error) {
112+ if manager.app == nil {
113+ return nil, errors.New("workspace manager missing app reference")
114+ }
115+ cfg, err := manager.app.buildWebkitConfig()
116+ if err != nil {
117+ return nil, err
118+ }
119+ cfg.CreateWindow = false
120+ return webkit.NewWebView(cfg)
121+ }
122+ manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
123+ if manager.app == nil {
124+ return nil, errors.New("workspace manager missing app reference")
125+ }
126+ return manager.app.createPaneForView(view)
127+ }
128+ manager.ensureStyles()
129+
130+ rootContainer := rootPane.webView.RootWidget()
131+ root := &paneNode{
132+ pane: rootPane,
133+ container: rootContainer,
134+ isLeaf: true,
135+ }
136+ manager.root = root
137+ manager.active = root
138+ manager.mainPane = root
139+ manager.viewToNode[rootPane.webView] = root
140+ manager.ensureHover(root)
141+
142+ if handler := rootPane.MessageHandler(); handler != nil {
143+ handler.SetWorkspaceObserver(manager)
144+ }
145+
146+ app.workspace = manager
147+ manager.focusNode(root)
148+ manager.registerWorkspaceShortcuts(root.pane.webView)
149+ return manager
150+}
151+
152+// OnWorkspaceMessage implements messaging.WorkspaceObserver.
153+func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
154+ node := wm.viewToNode[source]
155+ if node == nil {
156+ log.Printf("[workspace] message from unknown webview: event=%s", msg.Event)
157+ return
158+ }
159+
160+ switch event := strings.ToLower(msg.Event); event {
161+ case "pane-mode-entered":
162+ // Use mutex to prevent race conditions when multiple webviews try to enter pane mode
163+ wm.paneMutex.Lock()
164+ defer wm.paneMutex.Unlock()
165+
166+ // Check if pane mode was already entered recently (within 200ms debounce window)
167+ if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond {
168+ log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p",
169+ time.Since(wm.lastPaneModeEntry).Seconds()*1000, source)
170+ return
171+ }
172+
173+ // Check if pane mode is already active from a different source
174+ if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source {
175+ log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)",
176+ wm.paneModeSource, source)
177+ return
178+ }
179+
180+ log.Printf("[workspace] pane-mode-entered accepted: source=%p", source)
181+ wm.paneModeActive = true
182+ wm.paneModeSource = source
183+ wm.lastPaneModeEntry = time.Now()
184+ wm.focusNode(node)
185+ case "pane-confirmed", "pane-cancelled", "pane-mode-exited":
186+ // Debounce pane-mode-exited events to prevent duplicate focus calls
187+ if event == "pane-mode-exited" {
188+ if last, ok := wm.lastExitMsg[source]; ok {
189+ if time.Since(last) < 100*time.Millisecond {
190+ log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
191+ return
192+ }
193+ }
194+ wm.lastExitMsg[source] = time.Now()
195+ }
196+
197+ wm.paneMutex.Lock()
198+ wm.paneModeActive = false
199+ wm.paneModeSource = nil
200+ wm.paneMutex.Unlock()
201+
202+ wm.focusNode(node)
203+ case "pane-close":
204+ if !wm.paneModeActive {
205+ log.Printf("[workspace] close requested outside pane mode; ignoring")
206+ break
207+ }
208+
209+ wm.paneMutex.Lock()
210+ wm.paneModeActive = false
211+ wm.paneModeSource = nil
212+ wm.paneMutex.Unlock()
213+
214+ // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus
215+ wm.closeCurrentPane()
216+ case "pane-split":
217+ wm.focusNode(node)
218+ direction := strings.ToLower(msg.Direction)
219+ if direction == "" {
220+ direction = "right"
221+ }
222+ if last, ok := wm.lastSplitMsg[source]; ok {
223+ if time.Since(last) < 200*time.Millisecond {
224+ log.Printf("[workspace] split ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
225+ return
226+ }
227+ }
228+ if wm.splitting {
229+ log.Printf("[workspace] split ignored: operation already in progress")
230+ return
231+ }
232+ wm.splitting = true
233+ wm.lastSplitMsg[source] = time.Now()
234+ newNode, err := wm.splitNode(node, direction)
235+ if err != nil {
236+ log.Printf("[workspace] split failed: %v", err)
237+ wm.splitting = false
238+ return
239+ }
240+ wm.clonePaneState(node, newNode)
241+ wm.splitting = false
242+ default:
243+ log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
244+ }
245+}
246+
247+func (wm *WorkspaceManager) focusNode(node *paneNode) {
248+ if node == nil || !node.isLeaf || node.pane == nil || node.pane.webView == nil {
249+ return
250+ }
251+
252+ // Don't focus a node that's not in our viewToNode map (likely destroyed)
253+ if _, exists := wm.viewToNode[node.pane.webView]; !exists {
254+ log.Printf("[workspace] focusNode: skipping destroyed/unknown webView=%p", node.pane.webView)
255+ return
256+ }
257+
258+ previous := wm.active
259+ var previousContainer uintptr
260+ var previousWebView *webkit.WebView
261+ if previous != nil && previous != node && previous.pane != nil && previous.pane.webView != nil {
262+ previousContainer = previous.container
263+ previousWebView = previous.pane.webView
264+ detail := map[string]any{
265+ "active": false,
266+ "webview": fmt.Sprintf("%p", previous.pane.webView),
267+ "paneId": previous.pane.ID(),
268+ "timestamp": time.Now().UnixMilli(),
269+ }
270+ if err := previous.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
271+ log.Printf("[workspace] failed to notify focus loss: %v", err)
272+ } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
273+ log.Printf("[workspace] notified focus loss for pane %s", previous.pane.ID())
274+ }
275+ }
276+
277+ if wm.active != nil && wm.active.container != 0 {
278+ webkit.WidgetRemoveCSSClass(wm.active.container, activePaneClass)
279+ }
280+
281+ wm.active = node
282+ wm.app.activePane = node.pane
283+ wm.app.webView = node.pane.webView
284+ wm.app.zoomController = node.pane.zoomController
285+ wm.app.navigationController = node.pane.navigationController
286+ wm.app.clipboardController = node.pane.clipboardController
287+ wm.app.messageHandler = node.pane.messageHandler
288+ wm.app.shortcutHandler = node.pane.shortcutHandler
289+
290+ // Re-register workspace navigation shortcuts on the newly focused webView
291+ wm.registerWorkspaceShortcuts(node.pane.webView)
292+
293+ if handler := node.pane.messageHandler; handler != nil {
294+ handler.SetWorkspaceObserver(wm)
295+ }
296+
297+ if wm.app.browserService != nil {
298+ wm.app.browserService.AttachWebView(node.pane.webView)
299+ }
300+
301+ container := node.container
302+ viewWidget := node.pane.webView.Widget()
303+ if container != 0 && container != previousContainer {
304+ webkit.WidgetAddCSSClass(container, activePaneClass)
305+ if !webkit.WidgetIsValid(container) {
306+ log.Printf("[workspace] focus aborted: container invalid widget=%#x", container)
307+ return
308+ }
309+ webkit.IdleAdd(func() bool {
310+ if !webkit.WidgetIsValid(container) {
311+ log.Printf("[workspace] focus aborted during idle: container invalid widget=%#x", container)
312+ return false
313+ }
314+ parent := webkit.WidgetGetParent(container)
315+ if parent != 0 {
316+ webkit.WidgetRealizeInContainer(container)
317+ if viewWidget != 0 {
318+ webkit.WidgetGrabFocus(viewWidget)
319+ }
320+ // Consolidated focus operation log
321+ if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
322+ log.Printf("[workspace] focus operations completed: container=%#x parent=%#x viewWidget=%#x", container, parent, viewWidget)
323+ }
324+ } else {
325+ log.Printf("[workspace] focus deferred: widget not parented")
326+ }
327+ return false // Remove idle callback
328+ })
329+ }
330+
331+ if node.pane != nil && node.pane.webView != nil && node.pane.webView != previousWebView {
332+ // Update pane focus time
333+ node.pane.UpdateLastFocus()
334+
335+ // Enhanced focus event with pane ID and GUI status
336+ detail := map[string]any{
337+ "active": true,
338+ "webview": fmt.Sprintf("%p", node.pane.webView),
339+ "paneId": node.pane.ID(),
340+ "hasGUI": node.pane.HasGUI(),
341+ "timestamp": time.Now().UnixMilli(),
342+ }
343+
344+ // Dispatch focus event to new active pane
345+ if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
346+ log.Printf("[workspace] failed to notify focus gain: %v", err)
347+ } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
348+ log.Printf("[workspace] notified focus gain for pane %s", node.pane.ID())
349+ }
350+
351+ // Lazy-load GUI components if first focus
352+ if !node.pane.HasGUI() {
353+ wm.ensureGUIInPane(node.pane)
354+ }
355+ }
356+}
357+
358+func (wm *WorkspaceManager) ensureStyles() {
359+ if wm == nil || wm.cssInitialized {
360+ return
361+ }
362+ webkit.AddCSSProvider(activePaneCSS)
363+ wm.cssInitialized = true
364+}
365+
366+func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
367+ if wm == nil || view == nil {
368+ return
369+ }
370+ if node, ok := wm.viewToNode[view]; ok {
371+ if wm.active != node {
372+ wm.focusNode(node)
373+ }
374+ }
375+}
376+
377+func (wm *WorkspaceManager) ensureHover(node *paneNode) {
378+ if wm == nil || node == nil || !node.isLeaf {
379+ return
380+ }
381+ if node.container == 0 || node.hoverToken != 0 {
382+ return
383+ }
384+
385+ token := webkit.WidgetAddHoverHandler(node.container, func() {
386+ if wm == nil {
387+ return
388+ }
389+ wm.focusNode(node)
390+ })
391+ node.hoverToken = token
392+ if token == 0 {
393+ log.Printf("[workspace] failed to attach hover handler: widget=%#x", node.container)
394+ }
395+}
396+
397+func (wm *WorkspaceManager) detachHover(node *paneNode) {
398+ if wm == nil || node == nil || node.hoverToken == 0 {
399+ return
400+ }
401+ webkit.WidgetRemoveHoverHandler(node.container, node.hoverToken)
402+ node.hoverToken = 0
403+}
404+
405+// FocusNeighbor moves focus to the nearest pane in the requested direction using the
406+// actual widget geometry to determine adjacency.
407+func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
408+ if wm == nil {
409+ return false
410+ }
411+ switch strings.ToLower(direction) {
412+ case "left", "right", "up", "down":
413+ return wm.focusAdjacent(strings.ToLower(direction))
414+ default:
415+ return false
416+ }
417+}
418+
419+const focusEpsilon = 1e-3
420+
421+func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
422+ if wm.active == nil || !wm.active.isLeaf || wm.active.container == 0 {
423+ return false
424+ }
425+
426+ if neighbor := wm.structuralNeighbor(wm.active, direction); neighbor != nil {
427+ wm.focusNode(neighbor)
428+ return true
429+ }
430+
431+ currentBounds, ok := webkit.WidgetGetBounds(wm.active.container)
432+ if !ok {
433+ log.Printf("[workspace] unable to compute bounds for active pane")
434+ return false
435+ }
436+
437+ cx := currentBounds.X + currentBounds.Width/2.0
438+ cy := currentBounds.Y + currentBounds.Height/2.0
439+
440+ leaves := wm.collectLeaves()
441+ bestScore := math.MaxFloat64
442+ var best *paneNode
443+ var debugCandidates []string
444+
445+ for _, candidate := range leaves {
446+ if candidate == nil || candidate == wm.active || candidate.container == 0 {
447+ continue
448+ }
449+ bounds, ok := webkit.WidgetGetBounds(candidate.container)
450+ if !ok {
451+ continue
452+ }
453+ tx := bounds.X + bounds.Width/2.0
454+ ty := bounds.Y + bounds.Height/2.0
455+
456+ dx := tx - cx
457+ dy := ty - cy
458+
459+ var score float64
460+ switch direction {
461+ case "left":
462+ if dx >= -focusEpsilon {
463+ continue
464+ }
465+ score = math.Abs(dx)*1000 + math.Abs(dy)
466+ case "right":
467+ if dx <= focusEpsilon {
468+ continue
469+ }
470+ score = math.Abs(dx)*1000 + math.Abs(dy)
471+ case "up":
472+ if dy >= -focusEpsilon {
473+ continue
474+ }
475+ score = math.Abs(dy)*1000 + math.Abs(dx)
476+ case "down":
477+ if dy <= focusEpsilon {
478+ continue
479+ }
480+ score = math.Abs(dy)*1000 + math.Abs(dx)
481+ }
482+
483+ if direction == "up" || direction == "down" {
484+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
485+ }
486+
487+ if score < bestScore {
488+ bestScore = score
489+ best = candidate
490+ }
491+ }
492+
493+ if best != nil {
494+ wm.focusNode(best)
495+ return true
496+ }
497+
498+ if len(debugCandidates) > 0 {
499+ log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, wm.active.container, strings.Join(debugCandidates, "; "))
500+ }
501+ return false
502+}
503+
504+func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
505+ if node == nil || node.container == 0 {
506+ return nil
507+ }
508+
509+ refBounds, ok := webkit.WidgetGetBounds(node.container)
510+ if !ok {
511+ return nil
512+ }
513+ cx := refBounds.X + refBounds.Width/2.0
514+ cy := refBounds.Y + refBounds.Height/2.0
515+ axisVertical := direction == "up" || direction == "down"
516+
517+ for parent := node.parent; parent != nil; parent = parent.parent {
518+ switch direction {
519+ case "up":
520+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {
521+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
522+ return leaf
523+ }
524+ }
525+ case "down":
526+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {
527+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
528+ return leaf
529+ }
530+ }
531+ case "left":
532+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {
533+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
534+ return leaf
535+ }
536+ }
537+ case "right":
538+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {
539+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
540+ return leaf
541+ }
542+ }
543+ }
544+ node = parent
545+ }
546+ return nil
547+}
548+
549+func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
550+ leaves := wm.collectLeavesFrom(node)
551+ bestScore := math.MaxFloat64
552+ var best *paneNode
553+ for _, leaf := range leaves {
554+ if leaf == nil || leaf.container == 0 {
555+ continue
556+ }
557+ bounds, ok := webkit.WidgetGetBounds(leaf.container)
558+ if !ok {
559+ continue
560+ }
561+ tx := bounds.X + bounds.Width/2.0
562+ ty := bounds.Y + bounds.Height/2.0
563+ dx := tx - cx
564+ dy := ty - cy
565+ var score float64
566+ switch direction {
567+ case "left":
568+ if dx >= -focusEpsilon {
569+ continue
570+ }
571+ score = math.Abs(dx)*1000 + math.Abs(dy)
572+ case "right":
573+ if dx <= focusEpsilon {
574+ continue
575+ }
576+ score = math.Abs(dx)*1000 + math.Abs(dy)
577+ case "up":
578+ if dy >= -focusEpsilon {
579+ continue
580+ }
581+ score = math.Abs(dy)*1000 + math.Abs(dx)
582+ case "down":
583+ if dy <= focusEpsilon {
584+ continue
585+ }
586+ score = math.Abs(dy)*1000 + math.Abs(dx)
587+ default:
588+ continue
589+ }
590+ if score < bestScore {
591+ bestScore = score
592+ best = leaf
593+ }
594+ }
595+ if best == nil {
596+ return wm.boundaryFallback(node, direction)
597+ }
598+ return best
599+}
600+
601+func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
602+ if node == nil {
603+ return nil
604+ }
605+ if node.isLeaf {
606+ return node
607+ }
608+ switch direction {
609+ case "up":
610+ if leaf := wm.boundaryFallback(node.right, direction); leaf != nil {
611+ return leaf
612+ }
613+ return wm.boundaryFallback(node.left, direction)
614+ case "down":
615+ if leaf := wm.boundaryFallback(node.left, direction); leaf != nil {
616+ return leaf
617+ }
618+ return wm.boundaryFallback(node.right, direction)
619+ case "left":
620+ if leaf := wm.boundaryFallback(node.right, direction); leaf != nil {
621+ return leaf
622+ }
623+ return wm.boundaryFallback(node.left, direction)
624+ case "right":
625+ if leaf := wm.boundaryFallback(node.left, direction); leaf != nil {
626+ return leaf
627+ }
628+ return wm.boundaryFallback(node.right, direction)
629+ default:
630+ return nil
631+ }
632+}
633+
634+func (wm *WorkspaceManager) collectLeaves() []*paneNode {
635+ return wm.collectLeavesFrom(wm.root)
636+}
637+
638+func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
639+ var leaves []*paneNode
640+ var walk func(*paneNode)
641+ walk = func(n *paneNode) {
642+ if n == nil {
643+ return
644+ }
645+ if n.isLeaf {
646+ leaves = append(leaves, n)
647+ return
648+ }
649+ walk(n.left)
650+ walk(n.right)
651+ }
652+ walk(node)
653+ return leaves
654+}
655+
656+func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
657+ if wm == nil || wm.createWebViewFn == nil {
658+ return nil, errors.New("workspace manager missing webview factory")
659+ }
660+ return wm.createWebViewFn()
661+}
662+
663+func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
664+ if wm == nil || wm.createPaneFn == nil {
665+ return nil, errors.New("workspace manager missing pane factory")
666+ }
667+ return wm.createPaneFn(view)
668+}
669+
670+func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*paneNode, error) {
671+ if target == nil || !target.isLeaf || target.pane == nil {
672+ return nil, errors.New("split target must be a leaf pane")
673+ }
674+
675+ newView, err := wm.createWebView()
676+ if err != nil {
677+ return nil, err
678+ }
679+
680+ newPane, err := wm.createPane(newView)
681+ if err != nil {
682+ return nil, err
683+ }
684+
685+ if handler := newPane.MessageHandler(); handler != nil {
686+ handler.SetWorkspaceObserver(wm)
687+ }
688+
689+ newContainer := newPane.webView.RootWidget()
690+ if newContainer == 0 {
691+ return nil, errors.New("new pane missing container")
692+ }
693+ webkit.WidgetSetHExpand(newContainer, true)
694+ webkit.WidgetSetVExpand(newContainer, true)
695+ webkit.WidgetRealizeInContainer(newContainer)
696+
697+ existingContainer := target.container
698+ if existingContainer == 0 {
699+ return nil, errors.New("existing pane missing container")
700+ }
701+ webkit.WidgetSetHExpand(existingContainer, true)
702+ webkit.WidgetSetVExpand(existingContainer, true)
703+ webkit.WidgetRealizeInContainer(existingContainer)
704+
705+ orientation, existingFirst := mapDirection(direction)
706+ log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
707+
708+ paned := webkit.NewPaned(orientation)
709+ if paned == 0 {
710+ return nil, errors.New("failed to create GtkPaned")
711+ }
712+ webkit.WidgetSetHExpand(paned, true)
713+ webkit.WidgetSetVExpand(paned, true)
714+ webkit.PanedSetResizeStart(paned, true)
715+ webkit.PanedSetResizeEnd(paned, true)
716+
717+ newLeaf := &paneNode{
718+ pane: newPane,
719+ container: newContainer,
720+ isLeaf: true,
721+ }
722+ split := &paneNode{
723+ parent: target.parent,
724+ container: paned,
725+ orientation: orientation,
726+ isLeaf: false,
727+ }
728+
729+ parent := split.parent
730+
731+ // Detach existing container from its current GTK parent before inserting into new paned.
732+ if parent == nil {
733+ // Target is the root - remove it from the window
734+ log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
735+ if wm.window != nil {
736+ wm.window.SetChild(0)
737+ }
738+ } else if parent.container != 0 {
739+ // Target has a parent paned - unparent it from there
740+ log.Printf("[workspace] unparenting existing container=%#x from parent paned=%#x", existingContainer, parent.container)
741+ if parent.left == target {
742+ webkit.PanedSetStartChild(parent.container, 0)
743+ } else if parent.right == target {
744+ webkit.PanedSetEndChild(parent.container, 0)
745+ }
746+ webkit.WidgetQueueAllocate(parent.container)
747+ }
748+
749+ // Add both containers to the new paned
750+ // Note: GTK4 may emit a critical error about widget parenting here when splitting
751+ // the root pane, but the operation still succeeds. This is a limitation of GTK's
752+ // widget hierarchy tracking when widgets are moved between window and paned containers.
753+ if existingFirst {
754+ split.left = target
755+ split.right = newLeaf
756+ webkit.PanedSetStartChild(paned, existingContainer)
757+ webkit.PanedSetEndChild(paned, newContainer)
758+ log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
759+ } else {
760+ split.left = newLeaf
761+ split.right = target
762+ webkit.PanedSetStartChild(paned, newContainer)
763+ webkit.PanedSetEndChild(paned, existingContainer)
764+ log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
765+ }
766+
767+ target.parent = split
768+ newLeaf.parent = split
769+
770+ if parent == nil {
771+ wm.root = split
772+ if wm.window != nil {
773+ wm.window.SetChild(paned)
774+ webkit.WidgetQueueAllocate(paned)
775+ log.Printf("[workspace] paned set as window child: paned=%#x", paned)
776+ }
777+ } else {
778+ if parent.left == target {
779+ parent.left = split
780+ webkit.PanedSetStartChild(parent.container, paned)
781+ } else if parent.right == target {
782+ parent.right = split
783+ webkit.PanedSetEndChild(parent.container, paned)
784+ }
785+ webkit.WidgetQueueAllocate(parent.container)
786+ webkit.WidgetQueueAllocate(paned)
787+ log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
788+ }
789+
790+ webkit.WidgetShow(paned)
791+
792+ wm.viewToNode[newPane.webView] = newLeaf
793+ wm.ensureHover(newLeaf)
794+ wm.ensureHover(target)
795+ wm.app.panes = append(wm.app.panes, newPane)
796+ if newPane.zoomController != nil {
797+ newPane.zoomController.ApplyInitialZoom()
798+ }
799+
800+ webkit.IdleAdd(func() bool {
801+ if newContainer != 0 {
802+ webkit.WidgetShow(newContainer)
803+ }
804+ wm.focusNode(newLeaf)
805+ return false
806+ })
807+
808+ return newLeaf, nil
809+}
810+
811+func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
812+ if wm == nil || target == nil {
813+ return
814+ }
815+ if target.pane == nil || target.pane.webView == nil {
816+ return
817+ }
818+
819+ const blankURL = "about:blank"
820+
821+ if err := target.pane.webView.LoadURL(blankURL); err != nil {
822+ log.Printf("[workspace] failed to prepare blank pane: %v", err)
823+ }
824+
825+ // Wait for about:blank to load before opening omnibox
826+ target.pane.webView.RegisterURIChangedHandler(func(uri string) {
827+ if uri == blankURL {
828+ // Defer omnibox opening to allow page to fully initialize
829+ webkit.IdleAdd(func() bool {
830+ if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil {
831+ log.Printf("[workspace] failed to open omnibox: %v", injErr)
832+ }
833+ return false // Remove idle callback
834+ })
835+ }
836+ })
837+}
838+
839+func (wm *WorkspaceManager) closeCurrentPane() {
840+ if wm == nil || wm.active == nil {
841+ return
842+ }
843+ if err := wm.closePane(wm.active); err != nil {
844+ log.Printf("[workspace] close current pane failed: %v", err)
845+ }
846+}
847+
848+func (wm *WorkspaceManager) closePane(node *paneNode) error {
849+ if wm == nil || node == nil || !node.isLeaf {
850+ return errors.New("close target must be a leaf pane")
851+ }
852+ if node.pane == nil || node.pane.webView == nil {
853+ return errors.New("close target missing webview")
854+ }
855+
856+ remaining := len(wm.app.panes)
857+ willBeLastPane := remaining <= 1
858+ if willBeLastPane && wm.root == node {
859+ log.Printf("[workspace] closing final pane; exiting browser")
860+ wm.detachHover(node)
861+ node.pane.webView.Destroy()
862+ webkit.QuitMainLoop()
863+ return nil
864+ }
865+
866+ parent := node.parent
867+ if parent == nil {
868+ // This is the root pane (no parent in tree structure)
869+ if node != wm.root {
870+ return errors.New("inconsistent state: node has no parent but is not root")
871+ }
872+
873+ // Check if we can find a replacement root
874+ replacement := wm.findReplacementRoot(node)
875+ if replacement == nil {
876+ // No other panes exist, this is the final pane
877+ log.Printf("[workspace] closing final pane; exiting browser")
878+ wm.detachHover(node)
879+ node.pane.webView.Destroy()
880+ webkit.QuitMainLoop()
881+ return nil
882+ }
883+
884+ // We have a replacement - delegate root status and close this pane
885+ log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container)
886+
887+ // Clean up references first
888+ delete(wm.viewToNode, node.pane.webView)
889+ delete(wm.lastSplitMsg, node.pane.webView)
890+ delete(wm.lastExitMsg, node.pane.webView)
891+
892+ for i, pane := range wm.app.panes {
893+ if pane == node.pane {
894+ wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
895+ break
896+ }
897+ }
898+
899+ if wm.mainPane == node {
900+ wm.mainPane = nil
901+ }
902+
903+ // Clear current active if it's the node being closed
904+ if wm.active == node {
905+ wm.active = nil
906+ }
907+
908+ // Set replacement as new root
909+ wm.root = replacement
910+ replacement.parent = nil
911+
912+ // Replace window child directly (GTK handles reparenting automatically)
913+ if wm.window != nil {
914+ wm.window.SetChild(replacement.container)
915+ if replacement.container != 0 {
916+ webkit.WidgetQueueAllocate(replacement.container)
917+ webkit.WidgetShow(replacement.container)
918+ }
919+ }
920+
921+ // Focus a suitable pane
922+ focusTarget := wm.leftmostLeaf(replacement)
923+ if focusTarget != nil {
924+ wm.focusNode(focusTarget)
925+ }
926+
927+ // Destroy the webview and detach hover AFTER rearranging hierarchy
928+ // Only destroy the webview if this is the final pane, otherwise just clean up
929+ wm.detachHover(node)
930+ if willBeLastPane {
931+ // This is the last pane, safe to destroy completely
932+ node.pane.webView.Destroy()
933+ } else {
934+ // Multiple panes remain, don't destroy the window - just clean up the webview
935+ log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
936+ // TODO: Add a method to destroy just the webview without the window
937+ }
938+
939+ wm.updateMainPane()
940+ log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
941+ return nil
942+ }
943+
944+ var sibling *paneNode
945+ if parent.left == node {
946+ sibling = parent.right
947+ } else {
948+ sibling = parent.left
949+ }
950+ if sibling == nil {
951+ return errors.New("pane close failed: missing sibling")
952+ }
953+
954+ log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)
955+
956+ // Find focus target before modifying the tree structure
957+ focusTarget := wm.leftmostLeaf(sibling)
958+ if focusTarget == nil {
959+ focusTarget = wm.leftmostLeaf(wm.root)
960+ }
961+
962+ // Clean up references first
963+ delete(wm.viewToNode, node.pane.webView)
964+ delete(wm.lastSplitMsg, node.pane.webView)
965+ delete(wm.lastExitMsg, node.pane.webView)
966+
967+ for i, pane := range wm.app.panes {
968+ if pane == node.pane {
969+ wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
970+ break
971+ }
972+ }
973+
974+ if wm.mainPane == node {
975+ wm.mainPane = nil
976+ }
977+
978+ // Clear current active if it's the node being closed
979+ if wm.active == node {
980+ wm.active = nil
981+ }
982+
983+ grand := parent.parent
984+ if grand == nil {
985+ // Parent is the root node. We need to restructure.
986+ // If going from 2 panes to 1, promote sibling to become the new root
987+ if remaining == 2 { // Will become 1 after this close
988+ log.Printf("[workspace] promoting sibling to root (last 2 panes): container=%#x", sibling.container)
989+ wm.root = sibling
990+ sibling.parent = nil
991+
992+ // Unparent the sibling from the paned first, then set it as window child
993+ if wm.window != nil {
994+ // GTK requires explicit unparenting before reparenting
995+ if parent.left == sibling {
996+ webkit.PanedSetStartChild(parent.container, 0)
997+ } else {
998+ webkit.PanedSetEndChild(parent.container, 0)
999+ }
1000+ // Now we can safely set it as window child
1001+ wm.window.SetChild(sibling.container)
1002+ if sibling.container != 0 {
1003+ webkit.WidgetQueueAllocate(sibling.container)
1004+ }
1005+ }
1006+ } else {
1007+ // More than 2 panes remain, so keep the tree structure
1008+ // The sibling should remain as a child of root, but we need to remove the parent paned
1009+ log.Printf("[workspace] removing parent paned, keeping sibling under root: container=%#x", sibling.container)
1010+ // This shouldn't happen in a proper binary tree structure
1011+ log.Printf("[workspace] ERROR: unexpected tree state - parent is root but more than 2 panes remain")
1012+ return errors.New("unexpected tree state during pane closure")
1013+ }
1014+ } else {
1015+ // Parent has a grandparent, so promote sibling to take parent's place
1016+ log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container)
1017+ sibling.parent = grand
1018+ if grand.container != 0 && !grand.isLeaf {
1019+ if grand.left == parent {
1020+ grand.left = sibling
1021+ webkit.PanedSetStartChild(grand.container, sibling.container)
1022+ } else if grand.right == parent {
1023+ grand.right = sibling
1024+ webkit.PanedSetEndChild(grand.container, sibling.container)
1025+ }
1026+ webkit.WidgetQueueAllocate(grand.container)
1027+ }
1028+ }
1029+
1030+ // Keep sibling subtree visible
1031+ if sibling.container != 0 {
1032+ webkit.WidgetShow(sibling.container)
1033+ }
1034+
1035+ // Clean up parent node references
1036+ parent.left = nil
1037+ parent.right = nil
1038+ node.parent = nil
1039+
1040+ // Focus the target pane
1041+ if focusTarget != nil && focusTarget != node {
1042+ wm.focusNode(focusTarget)
1043+ }
1044+
1045+ // Destroy the webview and detach hover AFTER all hierarchy changes are complete
1046+ // Only destroy the webview if this is the final pane, otherwise just clean up
1047+ wm.detachHover(node)
1048+ if willBeLastPane {
1049+ // This is the last pane, safe to destroy completely
1050+ node.pane.webView.Destroy()
1051+ } else {
1052+ // Multiple panes remain, don't destroy the window - just clean up the webview
1053+ log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1054+ // TODO: Add a method to destroy just the webview without the window
1055+ }
1056+
1057+ wm.updateMainPane()
1058+ log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
1059+ return nil
1060+}
1061+
1062+func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
1063+ for node != nil && !node.isLeaf {
1064+ if node.left != nil {
1065+ node = node.left
1066+ continue
1067+ }
1068+ node = node.right
1069+ }
1070+ return node
1071+}
1072+
1073+// findReplacementRoot finds a suitable replacement when closing the current root pane
1074+func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
1075+ if wm == nil || wm.root == nil {
1076+ return nil
1077+ }
1078+
1079+ // If root is being closed and there are other panes, find a replacement
1080+ leaves := wm.collectLeaves()
1081+ for _, leaf := range leaves {
1082+ if leaf != excludeNode && leaf != nil && leaf.isLeaf {
1083+ // Find the topmost ancestor that's not the current root
1084+ current := leaf
1085+ for current.parent != nil && current.parent != wm.root {
1086+ current = current.parent
1087+ }
1088+
1089+ // If this leaf is a direct child of root, or root only has one subtree,
1090+ // we can promote this subtree to be the new root
1091+ if current.parent == wm.root {
1092+ // If the sibling is being excluded, promote this subtree
1093+ var sibling *paneNode
1094+ if wm.root.left == current {
1095+ sibling = wm.root.right
1096+ } else {
1097+ sibling = wm.root.left
1098+ }
1099+
1100+ if sibling == excludeNode {
1101+ // The sibling is being closed, so promote this subtree
1102+ return current
1103+ }
1104+ }
1105+
1106+ // Otherwise, return the first suitable leaf
1107+ return leaf
1108+ }
1109+ }
1110+
1111+ return nil
1112+}
1113+
1114+func (wm *WorkspaceManager) updateMainPane() {
1115+ if len(wm.app.panes) == 1 {
1116+ if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
1117+ wm.mainPane = leaf
1118+ }
1119+ return
1120+ }
1121+
1122+ if wm.mainPane == nil || !wm.mainPane.isLeaf {
1123+ if wm.active != nil && wm.active.isLeaf {
1124+ wm.mainPane = wm.active
1125+ }
1126+ }
1127+}
1128+
1129+func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) bool {
1130+ if wm == nil || source == nil {
1131+ return false
1132+ }
1133+
1134+ node := wm.viewToNode[source]
1135+ if node == nil {
1136+ log.Printf("[workspace] popup from unknown webview")
1137+ return false
1138+ }
1139+
1140+ cfg := wm.app.config
1141+ if cfg == nil {
1142+ return false
1143+ }
1144+
1145+ popCfg := cfg.Workspace.Popups
1146+ if !popCfg.OpenInNewPane {
1147+ return false
1148+ }
1149+
1150+ direction := strings.ToLower(popCfg.Placement)
1151+ if direction == "" {
1152+ direction = "right"
1153+ }
1154+
1155+ target := node
1156+ if !popCfg.FollowPaneContext && wm.active != nil {
1157+ target = wm.active
1158+ }
1159+
1160+ newNode, err := wm.splitNode(target, direction)
1161+ if err != nil {
1162+ log.Printf("[workspace] popup split failed: %v", err)
1163+ return false
1164+ }
1165+
1166+ if url == "" {
1167+ return true
1168+ }
1169+
1170+ if newNode == nil || newNode.pane == nil {
1171+ return true
1172+ }
1173+
1174+ if newNode.pane.navigationController != nil {
1175+ if err := newNode.pane.navigationController.NavigateToURL(url); err != nil {
1176+ log.Printf("[workspace] popup navigation failed: %v", err)
1177+ if newNode.pane.webView != nil {
1178+ if loadErr := newNode.pane.webView.LoadURL(url); loadErr != nil {
1179+ log.Printf("[workspace] popup load fallback failed: %v", loadErr)
1180+ }
1181+ }
1182+ }
1183+ return true
1184+ }
1185+
1186+ if newNode.pane.webView != nil {
1187+ if err := newNode.pane.webView.LoadURL(url); err != nil {
1188+ log.Printf("[workspace] popup load failed: %v", err)
1189+ }
1190+ }
1191+
1192+ return true
1193+}
1194+
1195+// ensureGUIInPane lazily loads GUI components into a pane when it gains focus
1196+func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
1197+ if pane == nil || pane.HasGUI() {
1198+ return
1199+ }
1200+
1201+ log.Printf("[workspace] Injecting GUI components into pane %s", pane.ID())
1202+
1203+ // Inject GUI manager and omnibox
1204+ script := fmt.Sprintf(`
1205+ (async function() {
1206+ try {
1207+ // Set up GUI manager for this pane
1208+ window.__dumber_pane.active = true;
1209+ window.__dumber_gui_bootstrap && window.__dumber_gui_bootstrap('%s');
1210+
1211+ // For now, mark as ready - full GUIManager will be implemented later
1212+ console.log('[workspace] GUI components ready for pane %s');
1213+ } catch (error) {
1214+ console.error('[workspace] Failed to initialize GUI:', error);
1215+ }
1216+ })();
1217+ `, pane.ID(), pane.ID())
1218+
1219+ if err := pane.WebView().InjectScript(script); err != nil {
1220+ log.Printf("[workspace] Failed to inject GUI into pane %s: %v", pane.ID(), err)
1221+ return
1222+ }
1223+
1224+ // Mark GUI as injected
1225+ pane.SetHasGUI(true)
1226+ pane.SetGUIComponent("manager", true)
1227+ pane.SetGUIComponent("omnibox", true)
1228+}
1229+
1230+func mapDirection(direction string) (webkit.Orientation, bool) {
1231+ switch direction {
1232+ case "left":
1233+ return webkit.OrientationHorizontal, false
1234+ case "up":
1235+ return webkit.OrientationVertical, false
1236+ case "down":
1237+ return webkit.OrientationVertical, true
1238+ default:
1239+ return webkit.OrientationHorizontal, true
1240+ }
1241+}
A internal/app/browser/workspace_manager_test.go +189 -0
@@ -0,0 +1,189 @@
1+package browser
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/pkg/webkit"
7+)
8+
9+func newTestWorkspaceManager(t *testing.T) *WorkspaceManager {
10+ t.Helper()
11+ webkit.ResetWidgetStubsForTesting()
12+
13+ rootView, err := webkit.NewWebView(&webkit.Config{})
14+ if err != nil {
15+ t.Fatalf("failed to create root webview: %v", err)
16+ }
17+
18+ rootPane := &BrowserPane{webView: rootView}
19+
20+ app := &BrowserApp{}
21+ app.panes = []*BrowserPane{rootPane}
22+ app.activePane = rootPane
23+ app.webView = rootView
24+
25+ wm := NewWorkspaceManager(app, rootPane)
26+ wm.createWebViewFn = func() (*webkit.WebView, error) {
27+ return webkit.NewWebView(&webkit.Config{})
28+ }
29+ wm.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
30+ return &BrowserPane{webView: view}, nil
31+ }
32+
33+ return wm
34+}
35+
36+func TestSplitNodeCreatesExpectedTree(t *testing.T) {
37+ cases := []struct {
38+ name string
39+ direction string
40+ expectOrientation webkit.Orientation
41+ existingIsStart bool
42+ }{
43+ {name: "Right", direction: "right", expectOrientation: webkit.OrientationHorizontal, existingIsStart: true},
44+ {name: "Left", direction: "left", expectOrientation: webkit.OrientationHorizontal, existingIsStart: false},
45+ {name: "Up", direction: "up", expectOrientation: webkit.OrientationVertical, existingIsStart: false},
46+ {name: "Down", direction: "down", expectOrientation: webkit.OrientationVertical, existingIsStart: true},
47+ }
48+
49+ for _, tc := range cases {
50+ t.Run(tc.name, func(t *testing.T) {
51+ wm := newTestWorkspaceManager(t)
52+ original := wm.active
53+
54+ newLeaf, err := wm.splitNode(original, tc.direction)
55+ if err != nil {
56+ t.Fatalf("splitNode(%s) returned error: %v", tc.direction, err)
57+ }
58+
59+ parent := newLeaf.parent
60+ if parent == nil {
61+ t.Fatalf("expected new leaf to have parent")
62+ }
63+
64+ if parent.orientation != tc.expectOrientation {
65+ t.Fatalf("expected orientation %v got %v", tc.expectOrientation, parent.orientation)
66+ }
67+
68+ if tc.existingIsStart {
69+ if parent.left != original {
70+ t.Fatalf("expected original pane to remain as start child")
71+ }
72+ if parent.right != newLeaf {
73+ t.Fatalf("expected new pane to be end child")
74+ }
75+ } else {
76+ if parent.left != newLeaf {
77+ t.Fatalf("expected new pane to be start child")
78+ }
79+ if parent.right != original {
80+ t.Fatalf("expected original pane to be end child")
81+ }
82+ }
83+
84+ if wm.root != parent {
85+ t.Fatalf("expected parent to become new root")
86+ }
87+
88+ if len(wm.app.panes) != 2 {
89+ t.Fatalf("expected 2 panes registered, got %d", len(wm.app.panes))
90+ }
91+
92+ mapped := wm.viewToNode[newLeaf.pane.webView]
93+ if mapped != newLeaf {
94+ t.Fatalf("expected new webview to map to new leaf")
95+ }
96+ })
97+ }
98+}
99+
100+func TestClosePanePromotesSibling(t *testing.T) {
101+ wm := newTestWorkspaceManager(t)
102+ original := wm.active
103+
104+ newLeaf, err := wm.splitNode(original, "right")
105+ if err != nil {
106+ t.Fatalf("split failed: %v", err)
107+ }
108+
109+ if err := wm.closePane(newLeaf); err != nil {
110+ t.Fatalf("closePane failed: %v", err)
111+ }
112+
113+ if wm.root != original {
114+ t.Fatalf("expected original leaf to become root after closing sibling")
115+ }
116+
117+ if original.parent != nil {
118+ t.Fatalf("expected original leaf to have no parent after promotion")
119+ }
120+
121+ if len(wm.app.panes) != 1 {
122+ t.Fatalf("expected single pane remaining, got %d", len(wm.app.panes))
123+ }
124+
125+ if wm.app.panes[0] != original.pane {
126+ t.Fatalf("expected remaining pane to match original")
127+ }
128+
129+ if wm.active != original {
130+ t.Fatalf("expected focus to move to promoted pane")
131+ }
132+}
133+
134+func TestFocusNeighborWithBounds(t *testing.T) {
135+ wm := newTestWorkspaceManager(t)
136+ left := wm.active
137+
138+ right, err := wm.splitNode(left, "right")
139+ if err != nil {
140+ t.Fatalf("split failed: %v", err)
141+ }
142+
143+ if right == nil {
144+ t.Fatalf("expected new right leaf")
145+ }
146+
147+ // Provide geometry hints for focus calculations.
148+ webkit.SetWidgetBoundsForTesting(left.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 100})
149+ webkit.SetWidgetBoundsForTesting(right.container, webkit.WidgetBounds{X: 120, Y: 0, Width: 100, Height: 100})
150+
151+ if !wm.FocusNeighbor("left") {
152+ t.Fatalf("expected focus neighbor left to succeed")
153+ }
154+
155+ if wm.active != left {
156+ t.Fatalf("expected focus to move to left pane")
157+ }
158+
159+ // Vertical split from the left pane to test up/down logic.
160+ bottom, err := wm.splitNode(left, "down")
161+ if err != nil {
162+ t.Fatalf("vertical split failed: %v", err)
163+ }
164+
165+ top := bottom.parent.left
166+
167+ webkit.SetWidgetBoundsForTesting(top.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 90})
168+ webkit.SetWidgetBoundsForTesting(bottom.container, webkit.WidgetBounds{X: 0, Y: 120, Width: 100, Height: 90})
169+
170+ // Active pane should be bottom after split. Move focus up.
171+ if wm.active != bottom {
172+ t.Fatalf("expected bottom pane to be active after split")
173+ }
174+
175+ if !wm.FocusNeighbor("up") {
176+ t.Fatalf("expected focus neighbor up to succeed")
177+ }
178+
179+ if wm.active != top {
180+ t.Fatalf("expected focus to move to top pane")
181+ }
182+}
183+
184+func TestFocusNeighborWithoutPeer(t *testing.T) {
185+ wm := newTestWorkspaceManager(t)
186+ if wm.FocusNeighbor("left") {
187+ t.Fatalf("expected focus move to fail when only one pane exists")
188+ }
189+}
@@ -0,0 +1,189 @@
1+package browser
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/pkg/webkit"
7+)
8+
9+func newTestWorkspaceManager(t *testing.T) *WorkspaceManager {
10+ t.Helper()
11+ webkit.ResetWidgetStubsForTesting()
12+
13+ rootView, err := webkit.NewWebView(&webkit.Config{})
14+ if err != nil {
15+ t.Fatalf("failed to create root webview: %v", err)
16+ }
17+
18+ rootPane := &BrowserPane{webView: rootView}
19+
20+ app := &BrowserApp{}
21+ app.panes = []*BrowserPane{rootPane}
22+ app.activePane = rootPane
23+ app.webView = rootView
24+
25+ wm := NewWorkspaceManager(app, rootPane)
26+ wm.createWebViewFn = func() (*webkit.WebView, error) {
27+ return webkit.NewWebView(&webkit.Config{})
28+ }
29+ wm.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
30+ return &BrowserPane{webView: view}, nil
31+ }
32+
33+ return wm
34+}
35+
36+func TestSplitNodeCreatesExpectedTree(t *testing.T) {
37+ cases := []struct {
38+ name string
39+ direction string
40+ expectOrientation webkit.Orientation
41+ existingIsStart bool
42+ }{
43+ {name: "Right", direction: "right", expectOrientation: webkit.OrientationHorizontal, existingIsStart: true},
44+ {name: "Left", direction: "left", expectOrientation: webkit.OrientationHorizontal, existingIsStart: false},
45+ {name: "Up", direction: "up", expectOrientation: webkit.OrientationVertical, existingIsStart: false},
46+ {name: "Down", direction: "down", expectOrientation: webkit.OrientationVertical, existingIsStart: true},
47+ }
48+
49+ for _, tc := range cases {
50+ t.Run(tc.name, func(t *testing.T) {
51+ wm := newTestWorkspaceManager(t)
52+ original := wm.active
53+
54+ newLeaf, err := wm.splitNode(original, tc.direction)
55+ if err != nil {
56+ t.Fatalf("splitNode(%s) returned error: %v", tc.direction, err)
57+ }
58+
59+ parent := newLeaf.parent
60+ if parent == nil {
61+ t.Fatalf("expected new leaf to have parent")
62+ }
63+
64+ if parent.orientation != tc.expectOrientation {
65+ t.Fatalf("expected orientation %v got %v", tc.expectOrientation, parent.orientation)
66+ }
67+
68+ if tc.existingIsStart {
69+ if parent.left != original {
70+ t.Fatalf("expected original pane to remain as start child")
71+ }
72+ if parent.right != newLeaf {
73+ t.Fatalf("expected new pane to be end child")
74+ }
75+ } else {
76+ if parent.left != newLeaf {
77+ t.Fatalf("expected new pane to be start child")
78+ }
79+ if parent.right != original {
80+ t.Fatalf("expected original pane to be end child")
81+ }
82+ }
83+
84+ if wm.root != parent {
85+ t.Fatalf("expected parent to become new root")
86+ }
87+
88+ if len(wm.app.panes) != 2 {
89+ t.Fatalf("expected 2 panes registered, got %d", len(wm.app.panes))
90+ }
91+
92+ mapped := wm.viewToNode[newLeaf.pane.webView]
93+ if mapped != newLeaf {
94+ t.Fatalf("expected new webview to map to new leaf")
95+ }
96+ })
97+ }
98+}
99+
100+func TestClosePanePromotesSibling(t *testing.T) {
101+ wm := newTestWorkspaceManager(t)
102+ original := wm.active
103+
104+ newLeaf, err := wm.splitNode(original, "right")
105+ if err != nil {
106+ t.Fatalf("split failed: %v", err)
107+ }
108+
109+ if err := wm.closePane(newLeaf); err != nil {
110+ t.Fatalf("closePane failed: %v", err)
111+ }
112+
113+ if wm.root != original {
114+ t.Fatalf("expected original leaf to become root after closing sibling")
115+ }
116+
117+ if original.parent != nil {
118+ t.Fatalf("expected original leaf to have no parent after promotion")
119+ }
120+
121+ if len(wm.app.panes) != 1 {
122+ t.Fatalf("expected single pane remaining, got %d", len(wm.app.panes))
123+ }
124+
125+ if wm.app.panes[0] != original.pane {
126+ t.Fatalf("expected remaining pane to match original")
127+ }
128+
129+ if wm.active != original {
130+ t.Fatalf("expected focus to move to promoted pane")
131+ }
132+}
133+
134+func TestFocusNeighborWithBounds(t *testing.T) {
135+ wm := newTestWorkspaceManager(t)
136+ left := wm.active
137+
138+ right, err := wm.splitNode(left, "right")
139+ if err != nil {
140+ t.Fatalf("split failed: %v", err)
141+ }
142+
143+ if right == nil {
144+ t.Fatalf("expected new right leaf")
145+ }
146+
147+ // Provide geometry hints for focus calculations.
148+ webkit.SetWidgetBoundsForTesting(left.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 100})
149+ webkit.SetWidgetBoundsForTesting(right.container, webkit.WidgetBounds{X: 120, Y: 0, Width: 100, Height: 100})
150+
151+ if !wm.FocusNeighbor("left") {
152+ t.Fatalf("expected focus neighbor left to succeed")
153+ }
154+
155+ if wm.active != left {
156+ t.Fatalf("expected focus to move to left pane")
157+ }
158+
159+ // Vertical split from the left pane to test up/down logic.
160+ bottom, err := wm.splitNode(left, "down")
161+ if err != nil {
162+ t.Fatalf("vertical split failed: %v", err)
163+ }
164+
165+ top := bottom.parent.left
166+
167+ webkit.SetWidgetBoundsForTesting(top.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 90})
168+ webkit.SetWidgetBoundsForTesting(bottom.container, webkit.WidgetBounds{X: 0, Y: 120, Width: 100, Height: 90})
169+
170+ // Active pane should be bottom after split. Move focus up.
171+ if wm.active != bottom {
172+ t.Fatalf("expected bottom pane to be active after split")
173+ }
174+
175+ if !wm.FocusNeighbor("up") {
176+ t.Fatalf("expected focus neighbor up to succeed")
177+ }
178+
179+ if wm.active != top {
180+ t.Fatalf("expected focus to move to top pane")
181+ }
182+}
183+
184+func TestFocusNeighborWithoutPeer(t *testing.T) {
185+ wm := newTestWorkspaceManager(t)
186+ if wm.FocusNeighbor("left") {
187+ t.Fatalf("expected focus move to fail when only one pane exists")
188+ }
189+}

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