detached ccef613

refactor(arch): introduce coordinator and dispatcher architecture

Implement new clean architecture pattern with dedicated coordinators for:
- ContentCoordinator: WebView pool and content management
- TabCoordinator: Tab lifecycle management
- WorkspaceCoordinator: Pane layout and workspace operations
- NavigationCoordinator: URL navigation handling
- KeyboardDispatcher: Keyboard action routing

These components separate concerns and will improve maintainability and testability.
commit
ccef613c1875fc207dcb828ecfc1821c167bcf57
author
brice · · 2025-12-15 20:50 UTC
committer
brice · · 2025-12-15 18:27 UTC
parent
03c91d2
signature
unsigned
5 files · +1873 -0
A internal/ui/coordinator/content.go +243 -0
@@ -0,0 +1,243 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+)
14+
15+// ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
16+type ContentCoordinator struct {
17+ pool *webkit.WebViewPool
18+ widgetFactory layout.WidgetFactory
19+
20+ webViews map[entity.PaneID]*webkit.WebView
21+ paneTitles map[entity.PaneID]string
22+ titleMu sync.RWMutex
23+
24+ // Callback to get active workspace state (avoids circular dependency)
25+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
26+}
27+
28+// NewContentCoordinator creates a new ContentCoordinator.
29+func NewContentCoordinator(
30+ ctx context.Context,
31+ pool *webkit.WebViewPool,
32+ widgetFactory layout.WidgetFactory,
33+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
34+) *ContentCoordinator {
35+ log := logging.FromContext(ctx)
36+ log.Debug().Msg("creating content coordinator")
37+
38+ return &ContentCoordinator{
39+ pool: pool,
40+ widgetFactory: widgetFactory,
41+ webViews: make(map[entity.PaneID]*webkit.WebView),
42+ paneTitles: make(map[entity.PaneID]string),
43+ getActiveWS: getActiveWS,
44+ }
45+}
46+
47+// EnsureWebView acquires or reuses a WebView for the given pane.
48+func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
49+ log := logging.FromContext(ctx)
50+
51+ if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {
52+ return wv, nil
53+ }
54+
55+ if c.pool == nil {
56+ return nil, fmt.Errorf("webview pool not configured")
57+ }
58+
59+ wv, err := c.pool.Acquire(ctx)
60+ if err != nil {
61+ return nil, err
62+ }
63+
64+ c.webViews[paneID] = wv
65+
66+ // Set up title change callback
67+ wv.OnTitleChanged = func(title string) {
68+ c.onTitleChanged(ctx, paneID, title)
69+ }
70+
71+ log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
72+ return wv, nil
73+}
74+
75+// ReleaseWebView returns the WebView for a pane to the pool.
76+func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
77+ log := logging.FromContext(ctx)
78+
79+ wv, ok := c.webViews[paneID]
80+ if !ok || wv == nil {
81+ return
82+ }
83+ delete(c.webViews, paneID)
84+
85+ // Clean up title tracking
86+ c.titleMu.Lock()
87+ delete(c.paneTitles, paneID)
88+ c.titleMu.Unlock()
89+
90+ if c.pool != nil {
91+ c.pool.Release(wv)
92+ } else {
93+ wv.Destroy()
94+ }
95+
96+ log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
97+}
98+
99+// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
100+func (c *ContentCoordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
101+ log := logging.FromContext(ctx)
102+
103+ if ws == nil || wsView == nil || c.widgetFactory == nil {
104+ return
105+ }
106+
107+ for _, pane := range ws.AllPanes() {
108+ if pane == nil {
109+ continue
110+ }
111+
112+ wv, err := c.EnsureWebView(ctx, pane.ID)
113+ if err != nil {
114+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
115+ continue
116+ }
117+
118+ // Load the pane's URI if set and different from current
119+ if pane.URI != "" && pane.URI != wv.URI() {
120+ if err := wv.LoadURI(ctx, pane.URI); err != nil {
121+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
122+ }
123+ }
124+
125+ widget := c.WrapWidget(ctx, wv)
126+ if widget == nil {
127+ continue
128+ }
129+
130+ if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
131+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
132+ }
133+ }
134+}
135+
136+// WrapWidget converts a WebView to a layout.Widget for embedding.
137+func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
138+ log := logging.FromContext(ctx)
139+
140+ if wv == nil || c.widgetFactory == nil {
141+ log.Debug().Msg("cannot wrap nil webview or factory")
142+ return nil
143+ }
144+
145+ gtkView := wv.Widget()
146+ if gtkView == nil {
147+ return nil
148+ }
149+
150+ return c.widgetFactory.WrapWidget(&gtkView.Widget)
151+}
152+
153+// ActiveWebView returns the WebView for the active pane.
154+func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
155+ log := logging.FromContext(ctx)
156+
157+ ws, _ := c.getActiveWS()
158+ if ws == nil {
159+ log.Debug().Msg("no active workspace")
160+ return nil
161+ }
162+
163+ pane := ws.ActivePane()
164+ if pane == nil || pane.Pane == nil {
165+ log.Debug().Msg("no active pane")
166+ return nil
167+ }
168+
169+ return c.webViews[pane.Pane.ID]
170+}
171+
172+// GetWebView returns the WebView for a specific pane.
173+func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
174+ return c.webViews[paneID]
175+}
176+
177+// GetTitle returns the current title for a pane.
178+func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
179+ c.titleMu.RLock()
180+ defer c.titleMu.RUnlock()
181+ return c.paneTitles[paneID]
182+}
183+
184+// onTitleChanged updates title tracking when a WebView's title changes.
185+func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
186+ log := logging.FromContext(ctx)
187+
188+ // Update title map
189+ c.titleMu.Lock()
190+ c.paneTitles[paneID] = title
191+ c.titleMu.Unlock()
192+
193+ // Update domain model
194+ ws, wsView := c.getActiveWS()
195+ if ws != nil {
196+ paneNode := ws.FindPane(paneID)
197+ if paneNode != nil && paneNode.Pane != nil {
198+ paneNode.Pane.Title = title
199+ }
200+ }
201+
202+ // Update StackedView title bar if this pane is in a stack
203+ if wsView != nil {
204+ tr := wsView.TreeRenderer()
205+ if tr != nil {
206+ stackedView := tr.GetStackedViewForPane(string(paneID))
207+ if stackedView != nil {
208+ c.updateStackedPaneTitle(ctx, ws, stackedView, paneID, title)
209+ }
210+ }
211+ }
212+
213+ log.Debug().
214+ Str("pane_id", string(paneID)).
215+ Str("title", title).
216+ Msg("pane title updated")
217+}
218+
219+// updateStackedPaneTitle updates the title of a pane in a StackedView.
220+func (c *ContentCoordinator) updateStackedPaneTitle(ctx context.Context, ws *entity.Workspace, sv *layout.StackedView, paneID entity.PaneID, title string) {
221+ log := logging.FromContext(ctx)
222+
223+ if ws == nil {
224+ return
225+ }
226+
227+ paneNode := ws.FindPane(paneID)
228+ if paneNode == nil {
229+ return
230+ }
231+
232+ // If the pane is in a stacked parent, find its index
233+ if paneNode.Parent != nil && paneNode.Parent.IsStacked {
234+ for i, child := range paneNode.Parent.Children {
235+ if child.Pane != nil && child.Pane.ID == paneID {
236+ if err := sv.UpdateTitle(i, title); err != nil {
237+ log.Warn().Err(err).Int("index", i).Msg("failed to update stacked pane title")
238+ }
239+ return
240+ }
241+ }
242+ }
243+}
@@ -0,0 +1,243 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+)
14+
15+// ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
16+type ContentCoordinator struct {
17+ pool *webkit.WebViewPool
18+ widgetFactory layout.WidgetFactory
19+
20+ webViews map[entity.PaneID]*webkit.WebView
21+ paneTitles map[entity.PaneID]string
22+ titleMu sync.RWMutex
23+
24+ // Callback to get active workspace state (avoids circular dependency)
25+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
26+}
27+
28+// NewContentCoordinator creates a new ContentCoordinator.
29+func NewContentCoordinator(
30+ ctx context.Context,
31+ pool *webkit.WebViewPool,
32+ widgetFactory layout.WidgetFactory,
33+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
34+) *ContentCoordinator {
35+ log := logging.FromContext(ctx)
36+ log.Debug().Msg("creating content coordinator")
37+
38+ return &ContentCoordinator{
39+ pool: pool,
40+ widgetFactory: widgetFactory,
41+ webViews: make(map[entity.PaneID]*webkit.WebView),
42+ paneTitles: make(map[entity.PaneID]string),
43+ getActiveWS: getActiveWS,
44+ }
45+}
46+
47+// EnsureWebView acquires or reuses a WebView for the given pane.
48+func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
49+ log := logging.FromContext(ctx)
50+
51+ if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {
52+ return wv, nil
53+ }
54+
55+ if c.pool == nil {
56+ return nil, fmt.Errorf("webview pool not configured")
57+ }
58+
59+ wv, err := c.pool.Acquire(ctx)
60+ if err != nil {
61+ return nil, err
62+ }
63+
64+ c.webViews[paneID] = wv
65+
66+ // Set up title change callback
67+ wv.OnTitleChanged = func(title string) {
68+ c.onTitleChanged(ctx, paneID, title)
69+ }
70+
71+ log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
72+ return wv, nil
73+}
74+
75+// ReleaseWebView returns the WebView for a pane to the pool.
76+func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
77+ log := logging.FromContext(ctx)
78+
79+ wv, ok := c.webViews[paneID]
80+ if !ok || wv == nil {
81+ return
82+ }
83+ delete(c.webViews, paneID)
84+
85+ // Clean up title tracking
86+ c.titleMu.Lock()
87+ delete(c.paneTitles, paneID)
88+ c.titleMu.Unlock()
89+
90+ if c.pool != nil {
91+ c.pool.Release(wv)
92+ } else {
93+ wv.Destroy()
94+ }
95+
96+ log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
97+}
98+
99+// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
100+func (c *ContentCoordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
101+ log := logging.FromContext(ctx)
102+
103+ if ws == nil || wsView == nil || c.widgetFactory == nil {
104+ return
105+ }
106+
107+ for _, pane := range ws.AllPanes() {
108+ if pane == nil {
109+ continue
110+ }
111+
112+ wv, err := c.EnsureWebView(ctx, pane.ID)
113+ if err != nil {
114+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
115+ continue
116+ }
117+
118+ // Load the pane's URI if set and different from current
119+ if pane.URI != "" && pane.URI != wv.URI() {
120+ if err := wv.LoadURI(ctx, pane.URI); err != nil {
121+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
122+ }
123+ }
124+
125+ widget := c.WrapWidget(ctx, wv)
126+ if widget == nil {
127+ continue
128+ }
129+
130+ if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
131+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
132+ }
133+ }
134+}
135+
136+// WrapWidget converts a WebView to a layout.Widget for embedding.
137+func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
138+ log := logging.FromContext(ctx)
139+
140+ if wv == nil || c.widgetFactory == nil {
141+ log.Debug().Msg("cannot wrap nil webview or factory")
142+ return nil
143+ }
144+
145+ gtkView := wv.Widget()
146+ if gtkView == nil {
147+ return nil
148+ }
149+
150+ return c.widgetFactory.WrapWidget(&gtkView.Widget)
151+}
152+
153+// ActiveWebView returns the WebView for the active pane.
154+func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
155+ log := logging.FromContext(ctx)
156+
157+ ws, _ := c.getActiveWS()
158+ if ws == nil {
159+ log.Debug().Msg("no active workspace")
160+ return nil
161+ }
162+
163+ pane := ws.ActivePane()
164+ if pane == nil || pane.Pane == nil {
165+ log.Debug().Msg("no active pane")
166+ return nil
167+ }
168+
169+ return c.webViews[pane.Pane.ID]
170+}
171+
172+// GetWebView returns the WebView for a specific pane.
173+func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
174+ return c.webViews[paneID]
175+}
176+
177+// GetTitle returns the current title for a pane.
178+func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
179+ c.titleMu.RLock()
180+ defer c.titleMu.RUnlock()
181+ return c.paneTitles[paneID]
182+}
183+
184+// onTitleChanged updates title tracking when a WebView's title changes.
185+func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
186+ log := logging.FromContext(ctx)
187+
188+ // Update title map
189+ c.titleMu.Lock()
190+ c.paneTitles[paneID] = title
191+ c.titleMu.Unlock()
192+
193+ // Update domain model
194+ ws, wsView := c.getActiveWS()
195+ if ws != nil {
196+ paneNode := ws.FindPane(paneID)
197+ if paneNode != nil && paneNode.Pane != nil {
198+ paneNode.Pane.Title = title
199+ }
200+ }
201+
202+ // Update StackedView title bar if this pane is in a stack
203+ if wsView != nil {
204+ tr := wsView.TreeRenderer()
205+ if tr != nil {
206+ stackedView := tr.GetStackedViewForPane(string(paneID))
207+ if stackedView != nil {
208+ c.updateStackedPaneTitle(ctx, ws, stackedView, paneID, title)
209+ }
210+ }
211+ }
212+
213+ log.Debug().
214+ Str("pane_id", string(paneID)).
215+ Str("title", title).
216+ Msg("pane title updated")
217+}
218+
219+// updateStackedPaneTitle updates the title of a pane in a StackedView.
220+func (c *ContentCoordinator) updateStackedPaneTitle(ctx context.Context, ws *entity.Workspace, sv *layout.StackedView, paneID entity.PaneID, title string) {
221+ log := logging.FromContext(ctx)
222+
223+ if ws == nil {
224+ return
225+ }
226+
227+ paneNode := ws.FindPane(paneID)
228+ if paneNode == nil {
229+ return
230+ }
231+
232+ // If the pane is in a stacked parent, find its index
233+ if paneNode.Parent != nil && paneNode.Parent.IsStacked {
234+ for i, child := range paneNode.Parent.Children {
235+ if child.Pane != nil && child.Pane.ID == paneID {
236+ if err := sv.UpdateTitle(i, title); err != nil {
237+ log.Warn().Err(err).Int("index", i).Msg("failed to update stacked pane title")
238+ }
239+ return
240+ }
241+ }
242+ }
243+}
A internal/ui/coordinator/navigation.go +204 -0
@@ -0,0 +1,204 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+)
13+
14+// NavigationCoordinator handles URL navigation, history, and browser controls.
15+type NavigationCoordinator struct {
16+ navigateUC *usecase.NavigateUseCase
17+ contentCoord *ContentCoordinator
18+ omnibox *component.Omnibox
19+}
20+
21+// NewNavigationCoordinator creates a new NavigationCoordinator.
22+func NewNavigationCoordinator(
23+ ctx context.Context,
24+ navigateUC *usecase.NavigateUseCase,
25+ contentCoord *ContentCoordinator,
26+) *NavigationCoordinator {
27+ log := logging.FromContext(ctx)
28+ log.Debug().Msg("creating navigation coordinator")
29+
30+ return &NavigationCoordinator{
31+ navigateUC: navigateUC,
32+ contentCoord: contentCoord,
33+ }
34+}
35+
36+// SetOmnibox sets the omnibox reference for toggle operations.
37+func (c *NavigationCoordinator) SetOmnibox(omnibox *component.Omnibox) {
38+ c.omnibox = omnibox
39+}
40+
41+// Navigate loads a URL in the active pane using NavigateUseCase.
42+// This properly handles history recording and zoom application.
43+func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {
44+ log := logging.FromContext(ctx)
45+
46+ wv := c.contentCoord.ActiveWebView(ctx)
47+ if wv == nil {
48+ log.Warn().Str("url", url).Msg("no active webview for navigation")
49+ return fmt.Errorf("no active webview")
50+ }
51+
52+ // Get active pane ID for tracking
53+ ws, _ := c.contentCoord.getActiveWS()
54+ var paneID string
55+ if ws != nil {
56+ if pane := ws.ActivePane(); pane != nil && pane.Pane != nil {
57+ paneID = string(pane.Pane.ID)
58+ }
59+ }
60+
61+ // Use NavigateUseCase which handles history + zoom
62+ if c.navigateUC != nil {
63+ input := usecase.NavigateInput{
64+ URL: url,
65+ PaneID: paneID,
66+ WebView: wv, // webkit.WebView implements port.WebView
67+ }
68+ output, err := c.navigateUC.Execute(ctx, input)
69+ if err != nil {
70+ log.Error().Err(err).Str("url", url).Msg("navigation failed")
71+ return err
72+ }
73+ log.Debug().
74+ Str("url", url).
75+ Float64("zoom", output.AppliedZoom).
76+ Msg("navigation initiated via usecase")
77+ return nil
78+ }
79+
80+ // Fallback: direct navigation without usecase
81+ log.Warn().Msg("navigateUC not available, using direct LoadURI")
82+ if err := wv.LoadURI(ctx, url); err != nil {
83+ log.Error().Err(err).Str("url", url).Msg("failed to navigate")
84+ return err
85+ }
86+
87+ log.Debug().Str("url", url).Msg("navigated active pane (direct)")
88+ return nil
89+}
90+
91+// Reload reloads the current page.
92+func (c *NavigationCoordinator) Reload(ctx context.Context) error {
93+ log := logging.FromContext(ctx)
94+
95+ wv := c.contentCoord.ActiveWebView(ctx)
96+ if wv == nil {
97+ log.Debug().Msg("no active webview for reload")
98+ return nil
99+ }
100+
101+ if c.navigateUC != nil {
102+ return c.navigateUC.Reload(ctx, wv, false)
103+ }
104+
105+ return wv.Reload(ctx)
106+}
107+
108+// HardReload reloads the current page bypassing cache.
109+func (c *NavigationCoordinator) HardReload(ctx context.Context) error {
110+ log := logging.FromContext(ctx)
111+
112+ wv := c.contentCoord.ActiveWebView(ctx)
113+ if wv == nil {
114+ log.Debug().Msg("no active webview for hard reload")
115+ return nil
116+ }
117+
118+ if c.navigateUC != nil {
119+ return c.navigateUC.Reload(ctx, wv, true)
120+ }
121+
122+ return wv.ReloadBypassCache(ctx)
123+}
124+
125+// GoBack navigates back in history.
126+func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
127+ log := logging.FromContext(ctx)
128+
129+ wv := c.contentCoord.ActiveWebView(ctx)
130+ if wv == nil {
131+ log.Debug().Msg("no active webview for go back")
132+ return nil
133+ }
134+
135+ if c.navigateUC != nil {
136+ return c.navigateUC.GoBack(ctx, wv)
137+ }
138+
139+ return wv.GoBack(ctx)
140+}
141+
142+// GoForward navigates forward in history.
143+func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
144+ log := logging.FromContext(ctx)
145+
146+ wv := c.contentCoord.ActiveWebView(ctx)
147+ if wv == nil {
148+ log.Debug().Msg("no active webview for go forward")
149+ return nil
150+ }
151+
152+ if c.navigateUC != nil {
153+ return c.navigateUC.GoForward(ctx, wv)
154+ }
155+
156+ return wv.GoForward(ctx)
157+}
158+
159+// OpenOmnibox toggles the omnibox visibility.
160+func (c *NavigationCoordinator) OpenOmnibox(ctx context.Context) error {
161+ log := logging.FromContext(ctx)
162+
163+ if c.omnibox == nil {
164+ log.Error().Msg("omnibox not initialized")
165+ return fmt.Errorf("omnibox not initialized")
166+ }
167+
168+ log.Debug().Msg("toggling omnibox")
169+ c.omnibox.Toggle(ctx)
170+ return nil
171+}
172+
173+// OpenDevTools opens the WebKit inspector for the active WebView.
174+func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
175+ log := logging.FromContext(ctx)
176+
177+ wv := c.contentCoord.ActiveWebView(ctx)
178+ if wv == nil {
179+ log.Warn().Msg("no active webview for devtools")
180+ return fmt.Errorf("no active webview")
181+ }
182+
183+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")
184+
185+ // Type assert to access ShowDevTools (not in port.WebView interface)
186+ if webkitWV, ok := interface{}(wv).(*webkit.WebView); ok {
187+ return webkitWV.ShowDevTools()
188+ }
189+
190+ return fmt.Errorf("webview does not support devtools")
191+}
192+
193+// UpdateHistoryTitle updates the title of a history entry after page load.
194+func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
195+ log := logging.FromContext(ctx)
196+
197+ if c.navigateUC == nil {
198+ return
199+ }
200+
201+ if err := c.navigateUC.UpdateHistoryTitle(ctx, url, title); err != nil {
202+ log.Warn().Err(err).Str("url", url).Msg("failed to update history title")
203+ }
204+}
@@ -0,0 +1,204 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+)
13+
14+// NavigationCoordinator handles URL navigation, history, and browser controls.
15+type NavigationCoordinator struct {
16+ navigateUC *usecase.NavigateUseCase
17+ contentCoord *ContentCoordinator
18+ omnibox *component.Omnibox
19+}
20+
21+// NewNavigationCoordinator creates a new NavigationCoordinator.
22+func NewNavigationCoordinator(
23+ ctx context.Context,
24+ navigateUC *usecase.NavigateUseCase,
25+ contentCoord *ContentCoordinator,
26+) *NavigationCoordinator {
27+ log := logging.FromContext(ctx)
28+ log.Debug().Msg("creating navigation coordinator")
29+
30+ return &NavigationCoordinator{
31+ navigateUC: navigateUC,
32+ contentCoord: contentCoord,
33+ }
34+}
35+
36+// SetOmnibox sets the omnibox reference for toggle operations.
37+func (c *NavigationCoordinator) SetOmnibox(omnibox *component.Omnibox) {
38+ c.omnibox = omnibox
39+}
40+
41+// Navigate loads a URL in the active pane using NavigateUseCase.
42+// This properly handles history recording and zoom application.
43+func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {
44+ log := logging.FromContext(ctx)
45+
46+ wv := c.contentCoord.ActiveWebView(ctx)
47+ if wv == nil {
48+ log.Warn().Str("url", url).Msg("no active webview for navigation")
49+ return fmt.Errorf("no active webview")
50+ }
51+
52+ // Get active pane ID for tracking
53+ ws, _ := c.contentCoord.getActiveWS()
54+ var paneID string
55+ if ws != nil {
56+ if pane := ws.ActivePane(); pane != nil && pane.Pane != nil {
57+ paneID = string(pane.Pane.ID)
58+ }
59+ }
60+
61+ // Use NavigateUseCase which handles history + zoom
62+ if c.navigateUC != nil {
63+ input := usecase.NavigateInput{
64+ URL: url,
65+ PaneID: paneID,
66+ WebView: wv, // webkit.WebView implements port.WebView
67+ }
68+ output, err := c.navigateUC.Execute(ctx, input)
69+ if err != nil {
70+ log.Error().Err(err).Str("url", url).Msg("navigation failed")
71+ return err
72+ }
73+ log.Debug().
74+ Str("url", url).
75+ Float64("zoom", output.AppliedZoom).
76+ Msg("navigation initiated via usecase")
77+ return nil
78+ }
79+
80+ // Fallback: direct navigation without usecase
81+ log.Warn().Msg("navigateUC not available, using direct LoadURI")
82+ if err := wv.LoadURI(ctx, url); err != nil {
83+ log.Error().Err(err).Str("url", url).Msg("failed to navigate")
84+ return err
85+ }
86+
87+ log.Debug().Str("url", url).Msg("navigated active pane (direct)")
88+ return nil
89+}
90+
91+// Reload reloads the current page.
92+func (c *NavigationCoordinator) Reload(ctx context.Context) error {
93+ log := logging.FromContext(ctx)
94+
95+ wv := c.contentCoord.ActiveWebView(ctx)
96+ if wv == nil {
97+ log.Debug().Msg("no active webview for reload")
98+ return nil
99+ }
100+
101+ if c.navigateUC != nil {
102+ return c.navigateUC.Reload(ctx, wv, false)
103+ }
104+
105+ return wv.Reload(ctx)
106+}
107+
108+// HardReload reloads the current page bypassing cache.
109+func (c *NavigationCoordinator) HardReload(ctx context.Context) error {
110+ log := logging.FromContext(ctx)
111+
112+ wv := c.contentCoord.ActiveWebView(ctx)
113+ if wv == nil {
114+ log.Debug().Msg("no active webview for hard reload")
115+ return nil
116+ }
117+
118+ if c.navigateUC != nil {
119+ return c.navigateUC.Reload(ctx, wv, true)
120+ }
121+
122+ return wv.ReloadBypassCache(ctx)
123+}
124+
125+// GoBack navigates back in history.
126+func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
127+ log := logging.FromContext(ctx)
128+
129+ wv := c.contentCoord.ActiveWebView(ctx)
130+ if wv == nil {
131+ log.Debug().Msg("no active webview for go back")
132+ return nil
133+ }
134+
135+ if c.navigateUC != nil {
136+ return c.navigateUC.GoBack(ctx, wv)
137+ }
138+
139+ return wv.GoBack(ctx)
140+}
141+
142+// GoForward navigates forward in history.
143+func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
144+ log := logging.FromContext(ctx)
145+
146+ wv := c.contentCoord.ActiveWebView(ctx)
147+ if wv == nil {
148+ log.Debug().Msg("no active webview for go forward")
149+ return nil
150+ }
151+
152+ if c.navigateUC != nil {
153+ return c.navigateUC.GoForward(ctx, wv)
154+ }
155+
156+ return wv.GoForward(ctx)
157+}
158+
159+// OpenOmnibox toggles the omnibox visibility.
160+func (c *NavigationCoordinator) OpenOmnibox(ctx context.Context) error {
161+ log := logging.FromContext(ctx)
162+
163+ if c.omnibox == nil {
164+ log.Error().Msg("omnibox not initialized")
165+ return fmt.Errorf("omnibox not initialized")
166+ }
167+
168+ log.Debug().Msg("toggling omnibox")
169+ c.omnibox.Toggle(ctx)
170+ return nil
171+}
172+
173+// OpenDevTools opens the WebKit inspector for the active WebView.
174+func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
175+ log := logging.FromContext(ctx)
176+
177+ wv := c.contentCoord.ActiveWebView(ctx)
178+ if wv == nil {
179+ log.Warn().Msg("no active webview for devtools")
180+ return fmt.Errorf("no active webview")
181+ }
182+
183+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")
184+
185+ // Type assert to access ShowDevTools (not in port.WebView interface)
186+ if webkitWV, ok := interface{}(wv).(*webkit.WebView); ok {
187+ return webkitWV.ShowDevTools()
188+ }
189+
190+ return fmt.Errorf("webview does not support devtools")
191+}
192+
193+// UpdateHistoryTitle updates the title of a history entry after page load.
194+func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
195+ log := logging.FromContext(ctx)
196+
197+ if c.navigateUC == nil {
198+ return
199+ }
200+
201+ if err := c.navigateUC.UpdateHistoryTitle(ctx, url, title); err != nil {
202+ log.Warn().Err(err).Str("url", url).Msg("failed to update history title")
203+ }
204+}
A internal/ui/coordinator/tab.go +198 -0
@@ -0,0 +1,198 @@
1+package coordinator
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/window"
12+)
13+
14+// TabCoordinator manages tab lifecycle operations.
15+type TabCoordinator struct {
16+ tabsUC *usecase.ManageTabsUseCase
17+ tabs *entity.TabList
18+ mainWindow *window.MainWindow
19+ config *config.Config
20+
21+ // Callbacks to avoid circular dependencies
22+ onTabCreated func(ctx context.Context, tab *entity.Tab)
23+ onQuit func()
24+}
25+
26+// TabCoordinatorConfig holds configuration for TabCoordinator.
27+type TabCoordinatorConfig struct {
28+ TabsUC *usecase.ManageTabsUseCase
29+ Tabs *entity.TabList
30+ MainWindow *window.MainWindow
31+ Config *config.Config
32+}
33+
34+// NewTabCoordinator creates a new TabCoordinator.
35+func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordinator {
36+ log := logging.FromContext(ctx)
37+ log.Debug().Msg("creating tab coordinator")
38+
39+ return &TabCoordinator{
40+ tabsUC: cfg.TabsUC,
41+ tabs: cfg.Tabs,
42+ mainWindow: cfg.MainWindow,
43+ config: cfg.Config,
44+ }
45+}
46+
47+// SetOnTabCreated sets the callback for when a tab is created.
48+func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, tab *entity.Tab)) {
49+ c.onTabCreated = fn
50+}
51+
52+// SetOnQuit sets the callback for when the last tab is closed.
53+func (c *TabCoordinator) SetOnQuit(fn func()) {
54+ c.onQuit = fn
55+}
56+
57+// Create creates a new tab with the given initial URL.
58+func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
59+ log := logging.FromContext(ctx)
60+
61+ output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
62+ TabList: c.tabs,
63+ Name: "",
64+ InitialURL: initialURL,
65+ })
66+ if err != nil {
67+ log.Error().Err(err).Msg("failed to create tab")
68+ return nil, err
69+ }
70+
71+ // Update tab bar
72+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
73+ c.mainWindow.TabBar().AddTab(output.Tab)
74+ c.mainWindow.TabBar().SetActive(output.Tab.ID)
75+ }
76+
77+ // Update tab bar visibility
78+ c.UpdateBarVisibility(ctx)
79+
80+ // Notify app to create workspace view
81+ if c.onTabCreated != nil {
82+ c.onTabCreated(ctx, output.Tab)
83+ }
84+
85+ log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
86+ return output.Tab, nil
87+}
88+
89+// Close closes the active tab.
90+func (c *TabCoordinator) Close(ctx context.Context) error {
91+ log := logging.FromContext(ctx)
92+
93+ activeID := c.tabs.ActiveTabID
94+ if activeID == "" {
95+ log.Debug().Msg("no active tab to close")
96+ return nil
97+ }
98+
99+ wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)
100+ if err != nil {
101+ log.Error().Err(err).Msg("failed to close tab")
102+ return err
103+ }
104+
105+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
106+ c.mainWindow.TabBar().RemoveTab(activeID)
107+
108+ // Switch to new active tab if any
109+ if c.tabs.ActiveTabID != "" {
110+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
111+ }
112+ }
113+
114+ // Update tab bar visibility
115+ c.UpdateBarVisibility(ctx)
116+
117+ // Quit if no tabs left
118+ if wasLast && c.onQuit != nil {
119+ c.onQuit()
120+ }
121+
122+ log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
123+ return nil
124+}
125+
126+// SwitchNext switches to the next tab.
127+func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
128+ log := logging.FromContext(ctx)
129+
130+ if err := c.tabsUC.SwitchNext(ctx, c.tabs); err != nil {
131+ log.Error().Err(err).Msg("failed to switch to next tab")
132+ return err
133+ }
134+
135+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
136+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
137+ }
138+
139+ // Switch workspace view
140+ if c.onTabCreated != nil {
141+ // TODO: Add onTabSwitched callback for workspace view switching
142+ }
143+
144+ log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")
145+ return nil
146+}
147+
148+// SwitchPrev switches to the previous tab.
149+func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
150+ log := logging.FromContext(ctx)
151+
152+ if err := c.tabsUC.SwitchPrevious(ctx, c.tabs); err != nil {
153+ log.Error().Err(err).Msg("failed to switch to previous tab")
154+ return err
155+ }
156+
157+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
158+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
159+ }
160+
161+ log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to previous tab")
162+ return nil
163+}
164+
165+// UpdateBarVisibility shows or hides the tab bar based on tab count.
166+func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
167+ log := logging.FromContext(ctx)
168+
169+ // Check if feature is enabled
170+ hideEnabled := true
171+ if c.config != nil {
172+ hideEnabled = c.config.Workspace.HideTabBarWhenSingleTab
173+ }
174+
175+ if !hideEnabled {
176+ log.Debug().Msg("tab bar auto-hide disabled by config")
177+ return
178+ }
179+
180+ if c.mainWindow == nil || c.mainWindow.TabBar() == nil {
181+ log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
182+ return
183+ }
184+
185+ tabCount := c.tabs.Count()
186+ shouldShow := tabCount > 1
187+
188+ log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
189+ c.mainWindow.TabBar().SetVisible(shouldShow)
190+}
191+
192+// GetTabBar returns the tab bar component.
193+func (c *TabCoordinator) GetTabBar() *component.TabBar {
194+ if c.mainWindow != nil {
195+ return c.mainWindow.TabBar()
196+ }
197+ return nil
198+}
@@ -0,0 +1,198 @@
1+package coordinator
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/window"
12+)
13+
14+// TabCoordinator manages tab lifecycle operations.
15+type TabCoordinator struct {
16+ tabsUC *usecase.ManageTabsUseCase
17+ tabs *entity.TabList
18+ mainWindow *window.MainWindow
19+ config *config.Config
20+
21+ // Callbacks to avoid circular dependencies
22+ onTabCreated func(ctx context.Context, tab *entity.Tab)
23+ onQuit func()
24+}
25+
26+// TabCoordinatorConfig holds configuration for TabCoordinator.
27+type TabCoordinatorConfig struct {
28+ TabsUC *usecase.ManageTabsUseCase
29+ Tabs *entity.TabList
30+ MainWindow *window.MainWindow
31+ Config *config.Config
32+}
33+
34+// NewTabCoordinator creates a new TabCoordinator.
35+func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordinator {
36+ log := logging.FromContext(ctx)
37+ log.Debug().Msg("creating tab coordinator")
38+
39+ return &TabCoordinator{
40+ tabsUC: cfg.TabsUC,
41+ tabs: cfg.Tabs,
42+ mainWindow: cfg.MainWindow,
43+ config: cfg.Config,
44+ }
45+}
46+
47+// SetOnTabCreated sets the callback for when a tab is created.
48+func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, tab *entity.Tab)) {
49+ c.onTabCreated = fn
50+}
51+
52+// SetOnQuit sets the callback for when the last tab is closed.
53+func (c *TabCoordinator) SetOnQuit(fn func()) {
54+ c.onQuit = fn
55+}
56+
57+// Create creates a new tab with the given initial URL.
58+func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
59+ log := logging.FromContext(ctx)
60+
61+ output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
62+ TabList: c.tabs,
63+ Name: "",
64+ InitialURL: initialURL,
65+ })
66+ if err != nil {
67+ log.Error().Err(err).Msg("failed to create tab")
68+ return nil, err
69+ }
70+
71+ // Update tab bar
72+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
73+ c.mainWindow.TabBar().AddTab(output.Tab)
74+ c.mainWindow.TabBar().SetActive(output.Tab.ID)
75+ }
76+
77+ // Update tab bar visibility
78+ c.UpdateBarVisibility(ctx)
79+
80+ // Notify app to create workspace view
81+ if c.onTabCreated != nil {
82+ c.onTabCreated(ctx, output.Tab)
83+ }
84+
85+ log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
86+ return output.Tab, nil
87+}
88+
89+// Close closes the active tab.
90+func (c *TabCoordinator) Close(ctx context.Context) error {
91+ log := logging.FromContext(ctx)
92+
93+ activeID := c.tabs.ActiveTabID
94+ if activeID == "" {
95+ log.Debug().Msg("no active tab to close")
96+ return nil
97+ }
98+
99+ wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)
100+ if err != nil {
101+ log.Error().Err(err).Msg("failed to close tab")
102+ return err
103+ }
104+
105+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
106+ c.mainWindow.TabBar().RemoveTab(activeID)
107+
108+ // Switch to new active tab if any
109+ if c.tabs.ActiveTabID != "" {
110+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
111+ }
112+ }
113+
114+ // Update tab bar visibility
115+ c.UpdateBarVisibility(ctx)
116+
117+ // Quit if no tabs left
118+ if wasLast && c.onQuit != nil {
119+ c.onQuit()
120+ }
121+
122+ log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
123+ return nil
124+}
125+
126+// SwitchNext switches to the next tab.
127+func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
128+ log := logging.FromContext(ctx)
129+
130+ if err := c.tabsUC.SwitchNext(ctx, c.tabs); err != nil {
131+ log.Error().Err(err).Msg("failed to switch to next tab")
132+ return err
133+ }
134+
135+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
136+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
137+ }
138+
139+ // Switch workspace view
140+ if c.onTabCreated != nil {
141+ // TODO: Add onTabSwitched callback for workspace view switching
142+ }
143+
144+ log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to next tab")
145+ return nil
146+}
147+
148+// SwitchPrev switches to the previous tab.
149+func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
150+ log := logging.FromContext(ctx)
151+
152+ if err := c.tabsUC.SwitchPrevious(ctx, c.tabs); err != nil {
153+ log.Error().Err(err).Msg("failed to switch to previous tab")
154+ return err
155+ }
156+
157+ if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
158+ c.mainWindow.TabBar().SetActive(c.tabs.ActiveTabID)
159+ }
160+
161+ log.Debug().Str("tab_id", string(c.tabs.ActiveTabID)).Msg("switched to previous tab")
162+ return nil
163+}
164+
165+// UpdateBarVisibility shows or hides the tab bar based on tab count.
166+func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
167+ log := logging.FromContext(ctx)
168+
169+ // Check if feature is enabled
170+ hideEnabled := true
171+ if c.config != nil {
172+ hideEnabled = c.config.Workspace.HideTabBarWhenSingleTab
173+ }
174+
175+ if !hideEnabled {
176+ log.Debug().Msg("tab bar auto-hide disabled by config")
177+ return
178+ }
179+
180+ if c.mainWindow == nil || c.mainWindow.TabBar() == nil {
181+ log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
182+ return
183+ }
184+
185+ tabCount := c.tabs.Count()
186+ shouldShow := tabCount > 1
187+
188+ log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
189+ c.mainWindow.TabBar().SetVisible(shouldShow)
190+}
191+
192+// GetTabBar returns the tab bar component.
193+func (c *TabCoordinator) GetTabBar() *component.TabBar {
194+ if c.mainWindow != nil {
195+ return c.mainWindow.TabBar()
196+ }
197+ return nil
198+}
A internal/ui/coordinator/workspace.go +1104 -0
@@ -0,0 +1,1104 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/focus"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+)
14+
15+// WorkspaceCoordinator manages pane operations within a workspace.
16+type WorkspaceCoordinator struct {
17+ panesUC *usecase.ManagePanesUseCase
18+ focusMgr *focus.Manager
19+ stackedPaneMgr *component.StackedPaneManager
20+ widgetFactory layout.WidgetFactory
21+ contentCoord *ContentCoordinator
22+
23+ // Callbacks to avoid circular dependencies
24+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
25+ generateID func() string
26+ onCloseLastPane func(ctx context.Context) error
27+}
28+
29+// WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
30+type WorkspaceCoordinatorConfig struct {
31+ PanesUC *usecase.ManagePanesUseCase
32+ FocusMgr *focus.Manager
33+ StackedPaneMgr *component.StackedPaneManager
34+ WidgetFactory layout.WidgetFactory
35+ ContentCoord *ContentCoordinator
36+ GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
37+ GenerateID func() string
38+}
39+
40+// NewWorkspaceCoordinator creates a new WorkspaceCoordinator.
41+func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {
42+ log := logging.FromContext(ctx)
43+ log.Debug().Msg("creating workspace coordinator")
44+
45+ return &WorkspaceCoordinator{
46+ panesUC: cfg.PanesUC,
47+ focusMgr: cfg.FocusMgr,
48+ stackedPaneMgr: cfg.StackedPaneMgr,
49+ widgetFactory: cfg.WidgetFactory,
50+ contentCoord: cfg.ContentCoord,
51+ getActiveWS: cfg.GetActiveWS,
52+ generateID: cfg.GenerateID,
53+ }
54+}
55+
56+// SetOnCloseLastPane sets the callback for when the last pane is closed.
57+func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) error) {
58+ c.onCloseLastPane = fn
59+}
60+
61+// Split splits the active pane in the given direction.
62+func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
63+ log := logging.FromContext(ctx)
64+
65+ if c.panesUC == nil {
66+ log.Warn().Msg("panes use case not available")
67+ return nil
68+ }
69+
70+ ws, wsView := c.getActiveWS()
71+ if ws == nil {
72+ log.Warn().Msg("no active workspace")
73+ return nil
74+ }
75+
76+ activePane := ws.ActivePane()
77+ if activePane == nil {
78+ log.Warn().Msg("no active pane to split")
79+ return nil
80+ }
81+
82+ // Check if we're splitting from inside a stack (horizontal split around stack)
83+ isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
84+ activePane.Parent != nil && activePane.Parent.IsStacked
85+
86+ // Get the existing widget BEFORE domain changes (for incremental operation)
87+ var existingWidget layout.Widget
88+ if wsView != nil {
89+ if isStackSplit {
90+ // Stack split: get the StackedView widget
91+ tr := wsView.TreeRenderer()
92+ if tr != nil {
93+ stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
94+ if stackedView != nil {
95+ existingWidget = stackedView.Widget()
96+ }
97+ }
98+ } else {
99+ // Regular split: get the current root widget
100+ existingWidget = wsView.GetRootWidget()
101+ }
102+ }
103+
104+ output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
105+ Workspace: ws,
106+ TargetPane: activePane,
107+ Direction: direction,
108+ })
109+ if err != nil {
110+ log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
111+ return err
112+ }
113+
114+ // Remember old active pane before changing
115+ oldActivePaneID := activePane.Pane.ID
116+
117+ // Set the new pane as active
118+ ws.ActivePaneID = output.NewPaneNode.Pane.ID
119+
120+ // Update the workspace view
121+ if wsView != nil && existingWidget != nil {
122+ var splitErr error
123+ if isStackSplit {
124+ // Incremental stack split: reuse existing stack widget
125+ splitErr = c.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID)
126+ } else {
127+ // Incremental regular split: reuse existing root widget
128+ splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID)
129+ }
130+
131+ if splitErr != nil {
132+ log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
133+ // Fallback to full rebuild
134+ if err := wsView.Rebuild(); err != nil {
135+ log.Error().Err(err).Msg("failed to rebuild workspace view")
136+ }
137+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
138+ }
139+ wsView.FocusPane(ws.ActivePaneID)
140+ } else if wsView != nil {
141+ // No existing widget (shouldn't happen), fallback to rebuild
142+ if err := wsView.Rebuild(); err != nil {
143+ log.Error().Err(err).Msg("failed to rebuild workspace view")
144+ }
145+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
146+ wsView.FocusPane(ws.ActivePaneID)
147+ }
148+
149+ log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
150+
151+ return nil
152+}
153+
154+// doIncrementalStackSplit performs an incremental split around an existing stacked pane.
155+func (c *WorkspaceCoordinator) doIncrementalStackSplit(
156+ ctx context.Context,
157+ wsView *component.WorkspaceView,
158+ ws *entity.Workspace,
159+ output *usecase.SplitPaneOutput,
160+ direction usecase.SplitDirection,
161+ existingStackWidget layout.Widget,
162+ oldActivePaneID entity.PaneID,
163+) error {
164+ log := logging.FromContext(ctx)
165+ factory := wsView.Factory()
166+
167+ log.Debug().
168+ Str("direction", string(direction)).
169+ Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
170+ Str("old_active_pane_id", string(oldActivePaneID)).
171+ Msg("performing incremental stack split")
172+
173+ // 1. Deactivate the old active pane (in the stack)
174+ if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
175+ oldPaneView.SetActive(false)
176+ }
177+
178+ // 2. Unparent the existing stack widget from the workspace container
179+ // Clear rootWidget ref first so SetRootWidgetDirect won't double-remove
180+ wsView.ClearRootWidgetRef()
181+ wsView.Container().Remove(existingStackWidget)
182+
183+ // 3. Create new PaneView for the new pane (without WebView - will attach later)
184+ newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
185+
186+ // 4. Wrap the new PaneView in a StackedView
187+ newStackedView := layout.NewStackedView(factory)
188+ newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
189+
190+ // 5. Create a SplitView with the appropriate orientation and child placement
191+ var splitView *layout.SplitView
192+ if direction == usecase.SplitLeft {
193+ // New pane goes on the left, existing stack on the right
194+ splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
195+ newStackedView.Widget(), existingStackWidget, output.SplitRatio)
196+ } else {
197+ // New pane goes on the right, existing stack on the left (SplitRight)
198+ splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
199+ existingStackWidget, newStackedView.Widget(), output.SplitRatio)
200+ }
201+
202+ // 6. Replace the root widget in the workspace view
203+ wsView.SetRootWidgetDirect(splitView.Widget())
204+
205+ // 7. Register the new pane in tracking maps and activate it
206+ wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
207+ newPaneView.SetActive(true)
208+
209+ tr := wsView.TreeRenderer()
210+ if tr != nil {
211+ // Register the new pane's StackedView mapping
212+ tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
213+ // Register the split node widget
214+ tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
215+ }
216+
217+ // 8. Attach WebView only for the new pane
218+ wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
219+ if err != nil {
220+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
221+ return err
222+ }
223+
224+ // Load the default URL for new pane
225+ newPaneEntity := output.NewPaneNode.Pane
226+ if newPaneEntity.URI != "" {
227+ if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
228+ log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
229+ }
230+ }
231+
232+ widget := c.contentCoord.WrapWidget(ctx, wv)
233+ if widget != nil {
234+ newPaneView.SetWebViewWidget(widget)
235+ }
236+
237+ log.Debug().Msg("incremental stack split completed successfully")
238+ return nil
239+}
240+
241+// doIncrementalSplit performs an incremental split by reusing the existing widget tree.
242+// This avoids rebuilding the entire tree which would destroy existing WebView widgets.
243+func (c *WorkspaceCoordinator) doIncrementalSplit(
244+ ctx context.Context,
245+ wsView *component.WorkspaceView,
246+ ws *entity.Workspace,
247+ output *usecase.SplitPaneOutput,
248+ direction usecase.SplitDirection,
249+ existingRootWidget layout.Widget,
250+ oldActivePaneID entity.PaneID,
251+) error {
252+ log := logging.FromContext(ctx)
253+ factory := wsView.Factory()
254+
255+ log.Debug().
256+ Str("direction", string(direction)).
257+ Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
258+ Str("old_active_pane_id", string(oldActivePaneID)).
259+ Int("pane_count", ws.PaneCount()).
260+ Msg("performing incremental split")
261+
262+ // 1. Deactivate the old active pane
263+ if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
264+ oldPaneView.SetActive(false)
265+ }
266+
267+ // 2. Get the active pane's StackedView widget (what we're actually splitting)
268+ tr := wsView.TreeRenderer()
269+ if tr == nil {
270+ return nil
271+ }
272+
273+ activeStackedView := tr.GetStackedViewForPane(string(oldActivePaneID))
274+ if activeStackedView == nil {
275+ log.Warn().Msg("no stacked view found for active pane")
276+ return nil
277+ }
278+ activePaneWidget := activeStackedView.Widget()
279+
280+ // 3. Determine if this is a root split by checking domain tree
281+ // output.ParentNode is the NEW split node (h0)
282+ // output.ParentNode's parent in domain tree is the ORIGINAL parent (f0) or nil if root
283+ grandparentNode := output.ParentNode.Parent
284+ isRootSplit := grandparentNode == nil
285+
286+ log.Debug().
287+ Bool("is_root_split", isRootSplit).
288+ Str("grandparent_id", func() string {
289+ if grandparentNode != nil {
290+ return grandparentNode.ID
291+ }
292+ return "nil"
293+ }()).
294+ Msg("determined split type")
295+
296+ // 4. Create new PaneView for the new pane
297+ newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
298+
299+ // 5. Wrap the new PaneView in a StackedView
300+ newStackedView := layout.NewStackedView(factory)
301+ newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
302+
303+ // 6. Determine orientation based on direction
304+ var orientation layout.Orientation
305+ var existingFirst bool
306+ switch direction {
307+ case usecase.SplitRight:
308+ orientation = layout.OrientationHorizontal
309+ existingFirst = true
310+ case usecase.SplitLeft:
311+ orientation = layout.OrientationHorizontal
312+ existingFirst = false
313+ case usecase.SplitDown:
314+ orientation = layout.OrientationVertical
315+ existingFirst = true
316+ case usecase.SplitUp:
317+ orientation = layout.OrientationVertical
318+ existingFirst = false
319+ default:
320+ return nil
321+ }
322+
323+ // 7. Handle root vs non-root split differently
324+ if isRootSplit {
325+ // ROOT SPLIT: Remove from container, wrap in split, set as new root
326+ // Clear rootWidget ref first so SetRootWidgetDirect won't double-remove
327+ wsView.ClearRootWidgetRef()
328+ wsView.Container().Remove(existingRootWidget)
329+
330+ var splitView *layout.SplitView
331+ if existingFirst {
332+ splitView = layout.NewSplitView(ctx, factory, orientation,
333+ existingRootWidget, newStackedView.Widget(), output.SplitRatio)
334+ } else {
335+ splitView = layout.NewSplitView(ctx, factory, orientation,
336+ newStackedView.Widget(), existingRootWidget, output.SplitRatio)
337+ }
338+
339+ wsView.SetRootWidgetDirect(splitView.Widget())
340+ tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
341+
342+ log.Debug().Msg("root split: replaced root with new split view")
343+ } else {
344+ // NON-ROOT SPLIT: Find grandparent's widget from TreeRenderer
345+ grandparentWidget := tr.Lookup(grandparentNode.ID)
346+ if grandparentWidget == nil {
347+ log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found in TreeRenderer")
348+ return fmt.Errorf("grandparent widget not found")
349+ }
350+
351+ // Cast to PanedWidget
352+ panedWidget, ok := grandparentWidget.(layout.PanedWidget)
353+ if !ok {
354+ log.Warn().Msg("grandparent widget is not a PanedWidget, falling back")
355+ return fmt.Errorf("grandparent is not a PanedWidget")
356+ }
357+
358+ // Determine if active pane was in start or end position
359+ // The new split node (output.ParentNode) replaced the active pane's position
360+ // So we check if the new split node is Left or Right child of grandparent
361+ isStartChild := grandparentNode.Left() == output.ParentNode
362+
363+ log.Debug().
364+ Bool("is_start_child", isStartChild).
365+ Str("grandparent_left_id", func() string {
366+ if grandparentNode.Left() != nil {
367+ return grandparentNode.Left().ID
368+ }
369+ return "nil"
370+ }()).
371+ Str("new_parent_id", output.ParentNode.ID).
372+ Msg("determined position in grandparent")
373+
374+ // Remove the active pane widget from grandparent paned
375+ if isStartChild {
376+ panedWidget.SetStartChild(nil)
377+ } else {
378+ panedWidget.SetEndChild(nil)
379+ }
380+
381+ // Create new split view with active pane + new pane
382+ var splitView *layout.SplitView
383+ if existingFirst {
384+ splitView = layout.NewSplitView(ctx, factory, orientation,
385+ activePaneWidget, newStackedView.Widget(), output.SplitRatio)
386+ } else {
387+ splitView = layout.NewSplitView(ctx, factory, orientation,
388+ newStackedView.Widget(), activePaneWidget, output.SplitRatio)
389+ }
390+
391+ // Insert new split view in grandparent at same position
392+ if isStartChild {
393+ panedWidget.SetStartChild(splitView.Widget())
394+ } else {
395+ panedWidget.SetEndChild(splitView.Widget())
396+ }
397+
398+ tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
399+
400+ log.Debug().
401+ Bool("was_start_child", isStartChild).
402+ Msg("non-root split: replaced pane in grandparent with new split view")
403+ }
404+
405+ // 8. Register the new pane in tracking maps and activate it
406+ wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
407+ newPaneView.SetActive(true)
408+
409+ // Register the new pane's StackedView mapping
410+ tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
411+
412+ // 9. Attach WebView only for the new pane
413+ wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
414+ if err != nil {
415+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
416+ return err
417+ }
418+
419+ // Load the default URL for new pane
420+ newPaneEntity := output.NewPaneNode.Pane
421+ if newPaneEntity.URI != "" {
422+ if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
423+ log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
424+ }
425+ }
426+
427+ widget := c.contentCoord.WrapWidget(ctx, wv)
428+ if widget != nil {
429+ newPaneView.SetWebViewWidget(widget)
430+ }
431+
432+ log.Debug().Msg("incremental split completed successfully")
433+ return nil
434+}
435+
436+// ClosePane closes the active pane.
437+func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
438+ log := logging.FromContext(ctx)
439+
440+ if c.panesUC == nil {
441+ log.Warn().Msg("panes use case not available")
442+ return nil
443+ }
444+
445+ ws, wsView := c.getActiveWS()
446+ if ws == nil {
447+ log.Warn().Msg("no active workspace")
448+ return nil
449+ }
450+
451+ activePane := ws.ActivePane()
452+ if activePane == nil {
453+ log.Warn().Msg("no active pane to close")
454+ return nil
455+ }
456+ closingPaneID := activePane.Pane.ID
457+
458+ log.Debug().Str("pane_id", string(closingPaneID)).Msg("closing pane")
459+
460+ // Don't close the last pane - close the tab instead
461+ if ws.PaneCount() <= 1 {
462+ if c.onCloseLastPane != nil {
463+ return c.onCloseLastPane(ctx)
464+ }
465+ return nil
466+ }
467+
468+ // BEFORE domain changes: capture widget info for incremental close
469+ var parentNode *entity.PaneNode
470+ var siblingNode *entity.PaneNode
471+ var grandparentNode *entity.PaneNode
472+ var parentWidget layout.Widget
473+ var siblingIsStartChild bool // true if sibling is the start (left/top) child in parent
474+ var parentIsStartInGrand bool // true if parent is the start (left/top) child in grandparent
475+
476+ if wsView != nil {
477+ tr := wsView.TreeRenderer()
478+ if tr != nil && activePane.Parent != nil {
479+ parentNode = activePane.Parent
480+
481+ // Find sibling (the other child of parent)
482+ // If closing pane is start child, sibling is end child (and vice versa)
483+ if parentNode.Left() == activePane {
484+ siblingNode = parentNode.Right()
485+ siblingIsStartChild = false // sibling is end/right child
486+ } else {
487+ siblingNode = parentNode.Left()
488+ siblingIsStartChild = true // sibling is start/left child
489+ }
490+
491+ grandparentNode = parentNode.Parent
492+ parentWidget = tr.Lookup(parentNode.ID)
493+
494+ // Determine parent's position in grandparent (if grandparent exists)
495+ if grandparentNode != nil {
496+ parentIsStartInGrand = grandparentNode.Left() == parentNode
497+ }
498+
499+ log.Debug().
500+ Str("parent_id", parentNode.ID).
501+ Str("sibling_id", func() string {
502+ if siblingNode != nil {
503+ return siblingNode.ID
504+ }
505+ return "nil"
506+ }()).
507+ Bool("sibling_is_start", siblingIsStartChild).
508+ Bool("parent_is_start_in_grand", parentIsStartInGrand).
509+ Bool("has_grandparent", grandparentNode != nil).
510+ Bool("has_parent_widget", parentWidget != nil).
511+ Msg("captured close context")
512+ }
513+ }
514+
515+ // Now do domain changes
516+ _, err := c.panesUC.Close(ctx, ws, activePane)
517+ if err != nil {
518+ log.Error().Err(err).Msg("failed to close pane")
519+ return err
520+ }
521+
522+ // Try incremental close if we have the context
523+ if wsView != nil && parentNode != nil {
524+ var closeErr error
525+
526+ if parentNode.IsStacked {
527+ // Stacked close: remove pane from stack without rebuild
528+ closeErr = c.doIncrementalStackClose(ctx, wsView, closingPaneID, parentNode)
529+ } else if siblingNode != nil && parentWidget != nil {
530+ // Split close: promote sibling without rebuild
531+ closeErr = c.doIncrementalClose(ctx, wsView, ws, closingPaneID, siblingNode, parentNode, grandparentNode, parentWidget, siblingIsStartChild, parentIsStartInGrand)
532+ } else {
533+ closeErr = fmt.Errorf("missing context for incremental close")
534+ }
535+
536+ if closeErr != nil {
537+ log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild")
538+ // Fallback to full rebuild
539+ if err := wsView.Rebuild(); err != nil {
540+ log.Error().Err(err).Msg("failed to rebuild workspace view")
541+ }
542+ c.contentCoord.ReleaseWebView(ctx, closingPaneID)
543+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
544+ }
545+ wsView.FocusPane(ws.ActivePaneID)
546+ } else if wsView != nil {
547+ // Fallback to rebuild if we couldn't get context
548+ if err := wsView.Rebuild(); err != nil {
549+ log.Error().Err(err).Msg("failed to rebuild workspace view")
550+ }
551+ c.contentCoord.ReleaseWebView(ctx, closingPaneID)
552+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
553+ wsView.FocusPane(ws.ActivePaneID)
554+ }
555+
556+ log.Info().Msg("pane closed")
557+ return nil
558+}
559+
560+// doIncrementalClose performs incremental close by promoting sibling without rebuild.
561+func (c *WorkspaceCoordinator) doIncrementalClose(
562+ ctx context.Context,
563+ wsView *component.WorkspaceView,
564+ ws *entity.Workspace,
565+ closingPaneID entity.PaneID,
566+ siblingNode *entity.PaneNode,
567+ parentNode *entity.PaneNode,
568+ grandparentNode *entity.PaneNode,
569+ parentWidget layout.Widget,
570+ siblingIsStartChild bool, // true if sibling is start/left child in parent
571+ parentIsStartInGrand bool, // true if parent is start/left child in grandparent
572+) error {
573+ log := logging.FromContext(ctx)
574+ tr := wsView.TreeRenderer()
575+ if tr == nil {
576+ return fmt.Errorf("tree renderer not available")
577+ }
578+
579+ log.Debug().
580+ Str("closing_pane", string(closingPaneID)).
581+ Str("sibling_id", siblingNode.ID).
582+ Bool("sibling_is_leaf", siblingNode.IsLeaf()).
583+ Bool("sibling_is_start", siblingIsStartChild).
584+ Bool("parent_is_start_in_grand", parentIsStartInGrand).
585+ Msg("performing incremental close")
586+
587+ // Get sibling's widget
588+ var siblingWidget layout.Widget
589+ if siblingNode.IsLeaf() && siblingNode.Pane != nil {
590+ stackedView := tr.GetStackedViewForPane(string(siblingNode.Pane.ID))
591+ if stackedView != nil {
592+ siblingWidget = stackedView.Widget()
593+ }
594+ } else {
595+ // Sibling is a split node
596+ siblingWidget = tr.Lookup(siblingNode.ID)
597+ }
598+
599+ if siblingWidget == nil {
600+ return fmt.Errorf("sibling widget not found")
601+ }
602+
603+ // Cast parent widget to PanedWidget
604+ panedWidget, ok := parentWidget.(layout.PanedWidget)
605+ if !ok {
606+ return fmt.Errorf("parent widget is not a PanedWidget")
607+ }
608+
609+ // Remove BOTH children from parent paned before any reparenting
610+ // This is critical - GTK requires widgets to be unparented before reparenting
611+ // Order: unparent closing pane first, then sibling
612+ if siblingIsStartChild {
613+ // Sibling is start, closing pane is end
614+ panedWidget.SetEndChild(nil) // Unparent closing pane
615+ panedWidget.SetStartChild(nil) // Unparent sibling
616+ } else {
617+ // Sibling is end, closing pane is start
618+ panedWidget.SetStartChild(nil) // Unparent closing pane
619+ panedWidget.SetEndChild(nil) // Unparent sibling
620+ }
621+
622+ if grandparentNode == nil {
623+ // Sibling becomes new root
624+ // Remove the old root (parent paned) from container manually since
625+ // ClearRootWidgetRef only clears the reference, not the actual widget
626+ wsView.Container().Remove(parentWidget)
627+ wsView.ClearRootWidgetRef()
628+
629+ // Grab focus on sibling before reparenting to avoid GTK focus warnings
630+ siblingWidget.GrabFocus()
631+
632+ // Ensure sibling expands to fill container (was constrained by paned before)
633+ siblingWidget.SetHexpand(true)
634+ siblingWidget.SetVexpand(true)
635+
636+ wsView.SetRootWidgetDirect(siblingWidget)
637+
638+ log.Debug().Msg("sibling promoted to root")
639+ } else {
640+ // Replace parent with sibling in grandparent
641+ grandparentWidget := tr.Lookup(grandparentNode.ID)
642+ if grandparentWidget == nil {
643+ return fmt.Errorf("grandparent widget not found")
644+ }
645+
646+ grandPaned, ok := grandparentWidget.(layout.PanedWidget)
647+ if !ok {
648+ return fmt.Errorf("grandparent widget is not a PanedWidget")
649+ }
650+
651+ // Grab focus on sibling before reparenting to avoid GTK focus warnings
652+ siblingWidget.GrabFocus()
653+
654+ // Remove parent from grandparent using known position from domain tree
655+ if parentIsStartInGrand {
656+ grandPaned.SetStartChild(nil)
657+ } else {
658+ grandPaned.SetEndChild(nil)
659+ }
660+
661+ // Insert sibling at same position
662+ if parentIsStartInGrand {
663+ grandPaned.SetStartChild(siblingWidget)
664+ } else {
665+ grandPaned.SetEndChild(siblingWidget)
666+ }
667+
668+ log.Debug().Bool("was_start_child", parentIsStartInGrand).Msg("sibling promoted in grandparent")
669+ }
670+
671+ // Clean up tracking
672+ tr.UnregisterWidget(parentNode.ID)
673+ wsView.UnregisterPaneView(closingPaneID)
674+ tr.UnregisterPane(string(closingPaneID))
675+
676+ // Release the closing pane's webview
677+ c.contentCoord.ReleaseWebView(ctx, closingPaneID)
678+
679+ // Activate sibling
680+ if siblingNode.IsLeaf() && siblingNode.Pane != nil {
681+ if siblingPaneView := wsView.GetPaneView(siblingNode.Pane.ID); siblingPaneView != nil {
682+ siblingPaneView.SetActive(true)
683+ }
684+ }
685+
686+ log.Debug().Msg("incremental close completed successfully")
687+ return nil
688+}
689+
690+// doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
691+func (c *WorkspaceCoordinator) doIncrementalStackClose(
692+ ctx context.Context,
693+ wsView *component.WorkspaceView,
694+ closingPaneID entity.PaneID,
695+ stackNode *entity.PaneNode,
696+) error {
697+ log := logging.FromContext(ctx)
698+ tr := wsView.TreeRenderer()
699+ if tr == nil {
700+ return fmt.Errorf("tree renderer not available")
701+ }
702+
703+ log.Debug().
704+ Str("closing_pane", string(closingPaneID)).
705+ Str("stack_node", stackNode.ID).
706+ Int("stack_children", len(stackNode.Children)).
707+ Msg("performing incremental stack close")
708+
709+ // Get the StackedView for this pane
710+ stackedView := tr.GetStackedViewForPane(string(closingPaneID))
711+ if stackedView == nil {
712+ return fmt.Errorf("stacked view not found for pane %s", closingPaneID)
713+ }
714+
715+ // Find the index of the closing pane in the stack
716+ closingIndex := -1
717+ for i, child := range stackNode.Children {
718+ if child.Pane != nil && child.Pane.ID == closingPaneID {
719+ closingIndex = i
720+ break
721+ }
722+ }
723+
724+ if closingIndex < 0 {
725+ return fmt.Errorf("pane %s not found in stack", closingPaneID)
726+ }
727+
728+ // Remove the pane from the StackedView widget
729+ if err := stackedView.RemovePane(ctx, closingIndex); err != nil {
730+ return fmt.Errorf("failed to remove pane from stacked view: %w", err)
731+ }
732+
733+ // Clean up tracking
734+ wsView.UnregisterPaneView(closingPaneID)
735+ tr.UnregisterPane(string(closingPaneID))
736+
737+ // Release the closing pane's webview
738+ c.contentCoord.ReleaseWebView(ctx, closingPaneID)
739+
740+ log.Debug().
741+ Int("removed_index", closingIndex).
742+ Msg("incremental stack close completed successfully")
743+ return nil
744+}
745+
746+// FocusPane navigates focus to an adjacent pane.
747+func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
748+ log := logging.FromContext(ctx)
749+
750+ ws, wsView := c.getActiveWS()
751+ if ws == nil {
752+ log.Warn().Msg("no active workspace")
753+ return nil
754+ }
755+
756+ if wsView == nil {
757+ log.Warn().Msg("no active workspace view")
758+ return nil
759+ }
760+
761+ // Use geometric navigation if focus manager is available
762+ var newPane *entity.PaneNode
763+ var err error
764+
765+ if c.focusMgr != nil {
766+ newPane, err = c.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
767+ } else if c.panesUC != nil {
768+ // Fallback to structural navigation
769+ newPane, err = c.panesUC.NavigateFocus(ctx, ws, direction)
770+ } else {
771+ log.Warn().Msg("no navigation manager available")
772+ return nil
773+ }
774+
775+ if err != nil {
776+ log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
777+ return err
778+ }
779+
780+ if newPane == nil {
781+ log.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
782+ return nil
783+ }
784+
785+ // Update the workspace view's active pane
786+ if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
787+ log.Warn().Err(err).Msg("failed to update active pane in view")
788+ } else {
789+ wsView.FocusPane(newPane.Pane.ID)
790+ }
791+
792+ // Sync StackedView visibility if new pane is in a stack
793+ if newPane.Parent != nil && newPane.Parent.IsStacked {
794+ c.syncStackedViewActive(ctx, wsView, newPane)
795+ }
796+
797+ log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
798+
799+ return nil
800+}
801+
802+// syncStackedViewActive updates the StackedView's visibility to match the domain model.
803+func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView *component.WorkspaceView, paneNode *entity.PaneNode) {
804+ log := logging.FromContext(ctx)
805+
806+ if paneNode == nil || paneNode.Parent == nil || !paneNode.Parent.IsStacked {
807+ return
808+ }
809+
810+ tr := wsView.TreeRenderer()
811+ if tr == nil {
812+ return
813+ }
814+
815+ // Get the StackedView for this pane
816+ stackedView := tr.GetStackedViewForPane(string(paneNode.Pane.ID))
817+ if stackedView == nil {
818+ log.Warn().Str("pane_id", string(paneNode.Pane.ID)).Msg("stacked view not found for pane")
819+ return
820+ }
821+
822+ // Use the parent's ActiveStackIndex which was set by the focus manager
823+ stackIndex := paneNode.Parent.ActiveStackIndex
824+
825+ log.Debug().
826+ Str("pane_id", string(paneNode.Pane.ID)).
827+ Int("stack_index", stackIndex).
828+ Msg("syncing stacked view visibility")
829+
830+ if err := stackedView.SetActive(ctx, stackIndex); err != nil {
831+ log.Warn().Err(err).Msg("failed to set stacked view active index")
832+ }
833+}
834+
835+// StackPane adds a new pane stacked on top of the active pane.
836+func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
837+ log := logging.FromContext(ctx)
838+
839+ if c.stackedPaneMgr == nil {
840+ log.Warn().Msg("stacked pane manager not available")
841+ return nil
842+ }
843+
844+ ws, wsView := c.getActiveWS()
845+ if ws == nil {
846+ log.Warn().Msg("no active workspace")
847+ return nil
848+ }
849+
850+ activeNode := ws.ActivePane()
851+ if activeNode == nil || activeNode.Pane == nil {
852+ log.Warn().Msg("no active pane")
853+ return nil
854+ }
855+
856+ if wsView == nil {
857+ log.Warn().Msg("no workspace view")
858+ return nil
859+ }
860+
861+ activePaneID := activeNode.Pane.ID
862+
863+ // Create a new pane entity
864+ newPaneID := entity.PaneID(c.generateID())
865+ newPane := entity.NewPane(newPaneID)
866+ newPane.URI = "about:blank"
867+ newPane.Title = "Untitled"
868+
869+ // Get the original pane's current title
870+ originalTitle := c.contentCoord.GetTitle(activePaneID)
871+ if originalTitle == "" {
872+ originalTitle = activeNode.Pane.Title
873+ }
874+ if originalTitle == "" {
875+ originalTitle = "Untitled"
876+ }
877+
878+ // Update domain model: convert leaf to stacked if needed, add new pane
879+ var stackNode *entity.PaneNode
880+ var needsFirstPaneTitleUpdate bool
881+ if activeNode.IsStacked {
882+ // Already stacked, just add to it
883+ stackNode = activeNode
884+ } else {
885+ // Convert leaf node to stacked container
886+ originalPane := activeNode.Pane
887+ originalPane.Title = originalTitle
888+ originalPaneChild := &entity.PaneNode{
889+ ID: activeNode.ID + "_0",
890+ Pane: originalPane,
891+ Parent: activeNode,
892+ }
893+
894+ activeNode.Pane = nil
895+ activeNode.IsStacked = true
896+ activeNode.Children = []*entity.PaneNode{originalPaneChild}
897+ stackNode = activeNode
898+ needsFirstPaneTitleUpdate = true
899+
900+ log.Debug().
901+ Str("node_id", activeNode.ID).
902+ Str("original_pane", string(originalPane.ID)).
903+ Str("original_title", originalTitle).
904+ Msg("converted leaf to stacked node")
905+ }
906+
907+ // Create new child node for the new pane
908+ newChildNode := &entity.PaneNode{
909+ ID: stackNode.ID + "_" + string(newPaneID),
910+ Pane: newPane,
911+ Parent: stackNode,
912+ }
913+ stackNode.Children = append(stackNode.Children, newChildNode)
914+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
915+
916+ log.Debug().
917+ Int("stack_size", len(stackNode.Children)).
918+ Int("active_index", stackNode.ActiveStackIndex).
919+ Msg("domain tree updated")
920+
921+ // Create PaneView for the new pane
922+ newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
923+ wsView.RegisterPaneView(newPaneID, newPaneView)
924+
925+ // Add to the UI StackedView
926+ if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, activePaneID, newPaneView, "Untitled"); err != nil {
927+ log.Error().Err(err).Msg("failed to add pane to stack")
928+ return err
929+ }
930+
931+ // Update the first pane's title if we just converted from leaf to stacked
932+ if needsFirstPaneTitleUpdate {
933+ tr := wsView.TreeRenderer()
934+ if tr != nil {
935+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
936+ if stackedView != nil {
937+ if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
938+ log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
939+ } else {
940+ log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
941+ }
942+ }
943+ }
944+ }
945+
946+ // Get WebView and attach
947+ wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
948+ if err != nil {
949+ log.Warn().Err(err).Msg("failed to get webview for new pane")
950+ } else {
951+ widget := c.contentCoord.WrapWidget(ctx, wv)
952+ if widget != nil {
953+ newPaneView.SetWebViewWidget(widget)
954+ }
955+ // Load blank page
956+ if err := wv.LoadURI(ctx, "about:blank"); err != nil {
957+ log.Warn().Err(err).Msg("failed to load blank page")
958+ }
959+ }
960+
961+ // Update workspace active pane ID
962+ ws.ActivePaneID = newPaneID
963+
964+ // Update workspace view
965+ if err := wsView.SetActivePaneID(newPaneID); err != nil {
966+ log.Warn().Err(err).Msg("failed to set active pane")
967+ }
968+
969+ // Set up title bar click callback
970+ tr := wsView.TreeRenderer()
971+ if tr != nil {
972+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
973+ if stackedView != nil {
974+ capturedStackNode := stackNode
975+ stackedView.SetOnActivate(func(index int) {
976+ c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
977+ })
978+ }
979+ }
980+
981+ log.Info().
982+ Str("original_pane", string(activePaneID)).
983+ Str("new_pane", string(newPaneID)).
984+ Int("stack_size", len(stackNode.Children)).
985+ Msg("stacked new pane")
986+
987+ return nil
988+}
989+
990+// NavigateStack navigates up or down within a stacked pane container.
991+func (c *WorkspaceCoordinator) NavigateStack(ctx context.Context, direction string) error {
992+ log := logging.FromContext(ctx)
993+
994+ if c.stackedPaneMgr == nil {
995+ return nil
996+ }
997+
998+ ws, wsView := c.getActiveWS()
999+ if ws == nil || wsView == nil {
1000+ return nil
1001+ }
1002+
1003+ activePane := ws.ActivePane()
1004+ if activePane == nil || activePane.Pane == nil {
1005+ return nil
1006+ }
1007+
1008+ currentPaneID := activePane.Pane.ID
1009+
1010+ // Check if we're actually in a multi-pane stack
1011+ if !c.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
1012+ log.Debug().Msg("current pane is not in a multi-pane stack")
1013+ return nil
1014+ }
1015+
1016+ // Navigate within the stack
1017+ _, err := c.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
1018+ if err != nil {
1019+ log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
1020+ return err
1021+ }
1022+
1023+ log.Debug().
1024+ Str("direction", direction).
1025+ Str("current_pane", string(currentPaneID)).
1026+ Msg("navigated stack")
1027+
1028+ return nil
1029+}
1030+
1031+// onTitleBarClick handles clicks on title bars to switch the active pane in a stack.
1032+func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
1033+ log := logging.FromContext(ctx)
1034+
1035+ if stackNode == nil || sv == nil {
1036+ return
1037+ }
1038+
1039+ // Validate index
1040+ if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
1041+ log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
1042+ return
1043+ }
1044+
1045+ // Get the current active index
1046+ currentIndex := sv.ActiveIndex()
1047+ if clickedIndex == currentIndex {
1048+ log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
1049+ return
1050+ }
1051+
1052+ // Update the outgoing pane's title bar with its current webpage title
1053+ if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
1054+ outgoingChild := stackNode.Children[currentIndex]
1055+ if outgoingChild.Pane != nil {
1056+ outgoingPaneID := outgoingChild.Pane.ID
1057+ outgoingTitle := c.contentCoord.GetTitle(outgoingPaneID)
1058+ if outgoingTitle == "" {
1059+ outgoingTitle = outgoingChild.Pane.Title
1060+ }
1061+ if outgoingTitle != "" {
1062+ if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
1063+ log.Warn().Err(err).Msg("failed to update outgoing pane title")
1064+ }
1065+ }
1066+ }
1067+ }
1068+
1069+ // Get the pane ID at the clicked index
1070+ clickedChild := stackNode.Children[clickedIndex]
1071+ if clickedChild.Pane == nil {
1072+ log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
1073+ return
1074+ }
1075+ clickedPaneID := clickedChild.Pane.ID
1076+
1077+ // Update StackedView active index
1078+ if err := sv.SetActive(ctx, clickedIndex); err != nil {
1079+ log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
1080+ return
1081+ }
1082+
1083+ // Update domain model
1084+ stackNode.ActiveStackIndex = clickedIndex
1085+
1086+ // Update workspace active pane
1087+ ws, wsView := c.getActiveWS()
1088+ if ws != nil {
1089+ ws.ActivePaneID = clickedPaneID
1090+ }
1091+
1092+ // Update workspace view
1093+ if wsView != nil {
1094+ if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1095+ log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1096+ }
1097+ }
1098+
1099+ log.Info().
1100+ Int("from_index", currentIndex).
1101+ Int("to_index", clickedIndex).
1102+ Str("pane_id", string(clickedPaneID)).
1103+ Msg("switched active pane via title bar click")
1104+}
@@ -0,0 +1,1104 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/focus"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+)
14+
15+// WorkspaceCoordinator manages pane operations within a workspace.
16+type WorkspaceCoordinator struct {
17+ panesUC *usecase.ManagePanesUseCase
18+ focusMgr *focus.Manager
19+ stackedPaneMgr *component.StackedPaneManager
20+ widgetFactory layout.WidgetFactory
21+ contentCoord *ContentCoordinator
22+
23+ // Callbacks to avoid circular dependencies
24+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
25+ generateID func() string
26+ onCloseLastPane func(ctx context.Context) error
27+}
28+
29+// WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
30+type WorkspaceCoordinatorConfig struct {
31+ PanesUC *usecase.ManagePanesUseCase
32+ FocusMgr *focus.Manager
33+ StackedPaneMgr *component.StackedPaneManager
34+ WidgetFactory layout.WidgetFactory
35+ ContentCoord *ContentCoordinator
36+ GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
37+ GenerateID func() string
38+}
39+
40+// NewWorkspaceCoordinator creates a new WorkspaceCoordinator.
41+func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {
42+ log := logging.FromContext(ctx)
43+ log.Debug().Msg("creating workspace coordinator")
44+
45+ return &WorkspaceCoordinator{
46+ panesUC: cfg.PanesUC,
47+ focusMgr: cfg.FocusMgr,
48+ stackedPaneMgr: cfg.StackedPaneMgr,
49+ widgetFactory: cfg.WidgetFactory,
50+ contentCoord: cfg.ContentCoord,
51+ getActiveWS: cfg.GetActiveWS,
52+ generateID: cfg.GenerateID,
53+ }
54+}
55+
56+// SetOnCloseLastPane sets the callback for when the last pane is closed.
57+func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) error) {
58+ c.onCloseLastPane = fn
59+}
60+
61+// Split splits the active pane in the given direction.
62+func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
63+ log := logging.FromContext(ctx)
64+
65+ if c.panesUC == nil {
66+ log.Warn().Msg("panes use case not available")
67+ return nil
68+ }
69+
70+ ws, wsView := c.getActiveWS()
71+ if ws == nil {
72+ log.Warn().Msg("no active workspace")
73+ return nil
74+ }
75+
76+ activePane := ws.ActivePane()
77+ if activePane == nil {
78+ log.Warn().Msg("no active pane to split")
79+ return nil
80+ }
81+
82+ // Check if we're splitting from inside a stack (horizontal split around stack)
83+ isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
84+ activePane.Parent != nil && activePane.Parent.IsStacked
85+
86+ // Get the existing widget BEFORE domain changes (for incremental operation)
87+ var existingWidget layout.Widget
88+ if wsView != nil {
89+ if isStackSplit {
90+ // Stack split: get the StackedView widget
91+ tr := wsView.TreeRenderer()
92+ if tr != nil {
93+ stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
94+ if stackedView != nil {
95+ existingWidget = stackedView.Widget()
96+ }
97+ }
98+ } else {
99+ // Regular split: get the current root widget
100+ existingWidget = wsView.GetRootWidget()
101+ }
102+ }
103+
104+ output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
105+ Workspace: ws,
106+ TargetPane: activePane,
107+ Direction: direction,
108+ })
109+ if err != nil {
110+ log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
111+ return err
112+ }
113+
114+ // Remember old active pane before changing
115+ oldActivePaneID := activePane.Pane.ID
116+
117+ // Set the new pane as active
118+ ws.ActivePaneID = output.NewPaneNode.Pane.ID
119+
120+ // Update the workspace view
121+ if wsView != nil && existingWidget != nil {
122+ var splitErr error
123+ if isStackSplit {
124+ // Incremental stack split: reuse existing stack widget
125+ splitErr = c.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID)
126+ } else {
127+ // Incremental regular split: reuse existing root widget
128+ splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID)
129+ }
130+
131+ if splitErr != nil {
132+ log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
133+ // Fallback to full rebuild
134+ if err := wsView.Rebuild(); err != nil {
135+ log.Error().Err(err).Msg("failed to rebuild workspace view")
136+ }
137+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
138+ }
139+ wsView.FocusPane(ws.ActivePaneID)
140+ } else if wsView != nil {
141+ // No existing widget (shouldn't happen), fallback to rebuild
142+ if err := wsView.Rebuild(); err != nil {
143+ log.Error().Err(err).Msg("failed to rebuild workspace view")
144+ }
145+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
146+ wsView.FocusPane(ws.ActivePaneID)
147+ }
148+
149+ log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
150+
151+ return nil
152+}
153+
154+// doIncrementalStackSplit performs an incremental split around an existing stacked pane.
155+func (c *WorkspaceCoordinator) doIncrementalStackSplit(
156+ ctx context.Context,
157+ wsView *component.WorkspaceView,
158+ ws *entity.Workspace,
159+ output *usecase.SplitPaneOutput,
160+ direction usecase.SplitDirection,
161+ existingStackWidget layout.Widget,
162+ oldActivePaneID entity.PaneID,
163+) error {
164+ log := logging.FromContext(ctx)
165+ factory := wsView.Factory()
166+
167+ log.Debug().
168+ Str("direction", string(direction)).
169+ Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
170+ Str("old_active_pane_id", string(oldActivePaneID)).
171+ Msg("performing incremental stack split")
172+
173+ // 1. Deactivate the old active pane (in the stack)
174+ if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
175+ oldPaneView.SetActive(false)
176+ }
177+
178+ // 2. Unparent the existing stack widget from the workspace container
179+ // Clear rootWidget ref first so SetRootWidgetDirect won't double-remove
180+ wsView.ClearRootWidgetRef()
181+ wsView.Container().Remove(existingStackWidget)
182+
183+ // 3. Create new PaneView for the new pane (without WebView - will attach later)
184+ newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
185+
186+ // 4. Wrap the new PaneView in a StackedView
187+ newStackedView := layout.NewStackedView(factory)
188+ newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
189+
190+ // 5. Create a SplitView with the appropriate orientation and child placement
191+ var splitView *layout.SplitView
192+ if direction == usecase.SplitLeft {
193+ // New pane goes on the left, existing stack on the right
194+ splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
195+ newStackedView.Widget(), existingStackWidget, output.SplitRatio)
196+ } else {
197+ // New pane goes on the right, existing stack on the left (SplitRight)
198+ splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
199+ existingStackWidget, newStackedView.Widget(), output.SplitRatio)
200+ }
201+
202+ // 6. Replace the root widget in the workspace view
203+ wsView.SetRootWidgetDirect(splitView.Widget())
204+
205+ // 7. Register the new pane in tracking maps and activate it
206+ wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
207+ newPaneView.SetActive(true)
208+
209+ tr := wsView.TreeRenderer()
210+ if tr != nil {
211+ // Register the new pane's StackedView mapping
212+ tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
213+ // Register the split node widget
214+ tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
215+ }
216+
217+ // 8. Attach WebView only for the new pane
218+ wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
219+ if err != nil {
220+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
221+ return err
222+ }
223+
224+ // Load the default URL for new pane
225+ newPaneEntity := output.NewPaneNode.Pane
226+ if newPaneEntity.URI != "" {
227+ if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
228+ log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
229+ }
230+ }
231+
232+ widget := c.contentCoord.WrapWidget(ctx, wv)
233+ if widget != nil {
234+ newPaneView.SetWebViewWidget(widget)
235+ }
236+
237+ log.Debug().Msg("incremental stack split completed successfully")
238+ return nil
239+}
240+
241+// doIncrementalSplit performs an incremental split by reusing the existing widget tree.
242+// This avoids rebuilding the entire tree which would destroy existing WebView widgets.
243+func (c *WorkspaceCoordinator) doIncrementalSplit(
244+ ctx context.Context,
245+ wsView *component.WorkspaceView,
246+ ws *entity.Workspace,
247+ output *usecase.SplitPaneOutput,
248+ direction usecase.SplitDirection,
249+ existingRootWidget layout.Widget,
250+ oldActivePaneID entity.PaneID,
251+) error {
252+ log := logging.FromContext(ctx)
253+ factory := wsView.Factory()
254+
255+ log.Debug().
256+ Str("direction", string(direction)).
257+ Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).
258+ Str("old_active_pane_id", string(oldActivePaneID)).
259+ Int("pane_count", ws.PaneCount()).
260+ Msg("performing incremental split")
261+
262+ // 1. Deactivate the old active pane
263+ if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil {
264+ oldPaneView.SetActive(false)
265+ }
266+
267+ // 2. Get the active pane's StackedView widget (what we're actually splitting)
268+ tr := wsView.TreeRenderer()
269+ if tr == nil {
270+ return nil
271+ }
272+
273+ activeStackedView := tr.GetStackedViewForPane(string(oldActivePaneID))
274+ if activeStackedView == nil {
275+ log.Warn().Msg("no stacked view found for active pane")
276+ return nil
277+ }
278+ activePaneWidget := activeStackedView.Widget()
279+
280+ // 3. Determine if this is a root split by checking domain tree
281+ // output.ParentNode is the NEW split node (h0)
282+ // output.ParentNode's parent in domain tree is the ORIGINAL parent (f0) or nil if root
283+ grandparentNode := output.ParentNode.Parent
284+ isRootSplit := grandparentNode == nil
285+
286+ log.Debug().
287+ Bool("is_root_split", isRootSplit).
288+ Str("grandparent_id", func() string {
289+ if grandparentNode != nil {
290+ return grandparentNode.ID
291+ }
292+ return "nil"
293+ }()).
294+ Msg("determined split type")
295+
296+ // 4. Create new PaneView for the new pane
297+ newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil)
298+
299+ // 5. Wrap the new PaneView in a StackedView
300+ newStackedView := layout.NewStackedView(factory)
301+ newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
302+
303+ // 6. Determine orientation based on direction
304+ var orientation layout.Orientation
305+ var existingFirst bool
306+ switch direction {
307+ case usecase.SplitRight:
308+ orientation = layout.OrientationHorizontal
309+ existingFirst = true
310+ case usecase.SplitLeft:
311+ orientation = layout.OrientationHorizontal
312+ existingFirst = false
313+ case usecase.SplitDown:
314+ orientation = layout.OrientationVertical
315+ existingFirst = true
316+ case usecase.SplitUp:
317+ orientation = layout.OrientationVertical
318+ existingFirst = false
319+ default:
320+ return nil
321+ }
322+
323+ // 7. Handle root vs non-root split differently
324+ if isRootSplit {
325+ // ROOT SPLIT: Remove from container, wrap in split, set as new root
326+ // Clear rootWidget ref first so SetRootWidgetDirect won't double-remove
327+ wsView.ClearRootWidgetRef()
328+ wsView.Container().Remove(existingRootWidget)
329+
330+ var splitView *layout.SplitView
331+ if existingFirst {
332+ splitView = layout.NewSplitView(ctx, factory, orientation,
333+ existingRootWidget, newStackedView.Widget(), output.SplitRatio)
334+ } else {
335+ splitView = layout.NewSplitView(ctx, factory, orientation,
336+ newStackedView.Widget(), existingRootWidget, output.SplitRatio)
337+ }
338+
339+ wsView.SetRootWidgetDirect(splitView.Widget())
340+ tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
341+
342+ log.Debug().Msg("root split: replaced root with new split view")
343+ } else {
344+ // NON-ROOT SPLIT: Find grandparent's widget from TreeRenderer
345+ grandparentWidget := tr.Lookup(grandparentNode.ID)
346+ if grandparentWidget == nil {
347+ log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found in TreeRenderer")
348+ return fmt.Errorf("grandparent widget not found")
349+ }
350+
351+ // Cast to PanedWidget
352+ panedWidget, ok := grandparentWidget.(layout.PanedWidget)
353+ if !ok {
354+ log.Warn().Msg("grandparent widget is not a PanedWidget, falling back")
355+ return fmt.Errorf("grandparent is not a PanedWidget")
356+ }
357+
358+ // Determine if active pane was in start or end position
359+ // The new split node (output.ParentNode) replaced the active pane's position
360+ // So we check if the new split node is Left or Right child of grandparent
361+ isStartChild := grandparentNode.Left() == output.ParentNode
362+
363+ log.Debug().
364+ Bool("is_start_child", isStartChild).
365+ Str("grandparent_left_id", func() string {
366+ if grandparentNode.Left() != nil {
367+ return grandparentNode.Left().ID
368+ }
369+ return "nil"
370+ }()).
371+ Str("new_parent_id", output.ParentNode.ID).
372+ Msg("determined position in grandparent")
373+
374+ // Remove the active pane widget from grandparent paned
375+ if isStartChild {
376+ panedWidget.SetStartChild(nil)
377+ } else {
378+ panedWidget.SetEndChild(nil)
379+ }
380+
381+ // Create new split view with active pane + new pane
382+ var splitView *layout.SplitView
383+ if existingFirst {
384+ splitView = layout.NewSplitView(ctx, factory, orientation,
385+ activePaneWidget, newStackedView.Widget(), output.SplitRatio)
386+ } else {
387+ splitView = layout.NewSplitView(ctx, factory, orientation,
388+ newStackedView.Widget(), activePaneWidget, output.SplitRatio)
389+ }
390+
391+ // Insert new split view in grandparent at same position
392+ if isStartChild {
393+ panedWidget.SetStartChild(splitView.Widget())
394+ } else {
395+ panedWidget.SetEndChild(splitView.Widget())
396+ }
397+
398+ tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
399+
400+ log.Debug().
401+ Bool("was_start_child", isStartChild).
402+ Msg("non-root split: replaced pane in grandparent with new split view")
403+ }
404+
405+ // 8. Register the new pane in tracking maps and activate it
406+ wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView)
407+ newPaneView.SetActive(true)
408+
409+ // Register the new pane's StackedView mapping
410+ tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView)
411+
412+ // 9. Attach WebView only for the new pane
413+ wv, err := c.contentCoord.EnsureWebView(ctx, output.NewPaneNode.Pane.ID)
414+ if err != nil {
415+ log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane")
416+ return err
417+ }
418+
419+ // Load the default URL for new pane
420+ newPaneEntity := output.NewPaneNode.Pane
421+ if newPaneEntity.URI != "" {
422+ if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil {
423+ log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane")
424+ }
425+ }
426+
427+ widget := c.contentCoord.WrapWidget(ctx, wv)
428+ if widget != nil {
429+ newPaneView.SetWebViewWidget(widget)
430+ }
431+
432+ log.Debug().Msg("incremental split completed successfully")
433+ return nil
434+}
435+
436+// ClosePane closes the active pane.
437+func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
438+ log := logging.FromContext(ctx)
439+
440+ if c.panesUC == nil {
441+ log.Warn().Msg("panes use case not available")
442+ return nil
443+ }
444+
445+ ws, wsView := c.getActiveWS()
446+ if ws == nil {
447+ log.Warn().Msg("no active workspace")
448+ return nil
449+ }
450+
451+ activePane := ws.ActivePane()
452+ if activePane == nil {
453+ log.Warn().Msg("no active pane to close")
454+ return nil
455+ }
456+ closingPaneID := activePane.Pane.ID
457+
458+ log.Debug().Str("pane_id", string(closingPaneID)).Msg("closing pane")
459+
460+ // Don't close the last pane - close the tab instead
461+ if ws.PaneCount() <= 1 {
462+ if c.onCloseLastPane != nil {
463+ return c.onCloseLastPane(ctx)
464+ }
465+ return nil
466+ }
467+
468+ // BEFORE domain changes: capture widget info for incremental close
469+ var parentNode *entity.PaneNode
470+ var siblingNode *entity.PaneNode
471+ var grandparentNode *entity.PaneNode
472+ var parentWidget layout.Widget
473+ var siblingIsStartChild bool // true if sibling is the start (left/top) child in parent
474+ var parentIsStartInGrand bool // true if parent is the start (left/top) child in grandparent
475+
476+ if wsView != nil {
477+ tr := wsView.TreeRenderer()
478+ if tr != nil && activePane.Parent != nil {
479+ parentNode = activePane.Parent
480+
481+ // Find sibling (the other child of parent)
482+ // If closing pane is start child, sibling is end child (and vice versa)
483+ if parentNode.Left() == activePane {
484+ siblingNode = parentNode.Right()
485+ siblingIsStartChild = false // sibling is end/right child
486+ } else {
487+ siblingNode = parentNode.Left()
488+ siblingIsStartChild = true // sibling is start/left child
489+ }
490+
491+ grandparentNode = parentNode.Parent
492+ parentWidget = tr.Lookup(parentNode.ID)
493+
494+ // Determine parent's position in grandparent (if grandparent exists)
495+ if grandparentNode != nil {
496+ parentIsStartInGrand = grandparentNode.Left() == parentNode
497+ }
498+
499+ log.Debug().
500+ Str("parent_id", parentNode.ID).
501+ Str("sibling_id", func() string {
502+ if siblingNode != nil {
503+ return siblingNode.ID
504+ }
505+ return "nil"
506+ }()).
507+ Bool("sibling_is_start", siblingIsStartChild).
508+ Bool("parent_is_start_in_grand", parentIsStartInGrand).
509+ Bool("has_grandparent", grandparentNode != nil).
510+ Bool("has_parent_widget", parentWidget != nil).
511+ Msg("captured close context")
512+ }
513+ }
514+
515+ // Now do domain changes
516+ _, err := c.panesUC.Close(ctx, ws, activePane)
517+ if err != nil {
518+ log.Error().Err(err).Msg("failed to close pane")
519+ return err
520+ }
521+
522+ // Try incremental close if we have the context
523+ if wsView != nil && parentNode != nil {
524+ var closeErr error
525+
526+ if parentNode.IsStacked {
527+ // Stacked close: remove pane from stack without rebuild
528+ closeErr = c.doIncrementalStackClose(ctx, wsView, closingPaneID, parentNode)
529+ } else if siblingNode != nil && parentWidget != nil {
530+ // Split close: promote sibling without rebuild
531+ closeErr = c.doIncrementalClose(ctx, wsView, ws, closingPaneID, siblingNode, parentNode, grandparentNode, parentWidget, siblingIsStartChild, parentIsStartInGrand)
532+ } else {
533+ closeErr = fmt.Errorf("missing context for incremental close")
534+ }
535+
536+ if closeErr != nil {
537+ log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild")
538+ // Fallback to full rebuild
539+ if err := wsView.Rebuild(); err != nil {
540+ log.Error().Err(err).Msg("failed to rebuild workspace view")
541+ }
542+ c.contentCoord.ReleaseWebView(ctx, closingPaneID)
543+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
544+ }
545+ wsView.FocusPane(ws.ActivePaneID)
546+ } else if wsView != nil {
547+ // Fallback to rebuild if we couldn't get context
548+ if err := wsView.Rebuild(); err != nil {
549+ log.Error().Err(err).Msg("failed to rebuild workspace view")
550+ }
551+ c.contentCoord.ReleaseWebView(ctx, closingPaneID)
552+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
553+ wsView.FocusPane(ws.ActivePaneID)
554+ }
555+
556+ log.Info().Msg("pane closed")
557+ return nil
558+}
559+
560+// doIncrementalClose performs incremental close by promoting sibling without rebuild.
561+func (c *WorkspaceCoordinator) doIncrementalClose(
562+ ctx context.Context,
563+ wsView *component.WorkspaceView,
564+ ws *entity.Workspace,
565+ closingPaneID entity.PaneID,
566+ siblingNode *entity.PaneNode,
567+ parentNode *entity.PaneNode,
568+ grandparentNode *entity.PaneNode,
569+ parentWidget layout.Widget,
570+ siblingIsStartChild bool, // true if sibling is start/left child in parent
571+ parentIsStartInGrand bool, // true if parent is start/left child in grandparent
572+) error {
573+ log := logging.FromContext(ctx)
574+ tr := wsView.TreeRenderer()
575+ if tr == nil {
576+ return fmt.Errorf("tree renderer not available")
577+ }
578+
579+ log.Debug().
580+ Str("closing_pane", string(closingPaneID)).
581+ Str("sibling_id", siblingNode.ID).
582+ Bool("sibling_is_leaf", siblingNode.IsLeaf()).
583+ Bool("sibling_is_start", siblingIsStartChild).
584+ Bool("parent_is_start_in_grand", parentIsStartInGrand).
585+ Msg("performing incremental close")
586+
587+ // Get sibling's widget
588+ var siblingWidget layout.Widget
589+ if siblingNode.IsLeaf() && siblingNode.Pane != nil {
590+ stackedView := tr.GetStackedViewForPane(string(siblingNode.Pane.ID))
591+ if stackedView != nil {
592+ siblingWidget = stackedView.Widget()
593+ }
594+ } else {
595+ // Sibling is a split node
596+ siblingWidget = tr.Lookup(siblingNode.ID)
597+ }
598+
599+ if siblingWidget == nil {
600+ return fmt.Errorf("sibling widget not found")
601+ }
602+
603+ // Cast parent widget to PanedWidget
604+ panedWidget, ok := parentWidget.(layout.PanedWidget)
605+ if !ok {
606+ return fmt.Errorf("parent widget is not a PanedWidget")
607+ }
608+
609+ // Remove BOTH children from parent paned before any reparenting
610+ // This is critical - GTK requires widgets to be unparented before reparenting
611+ // Order: unparent closing pane first, then sibling
612+ if siblingIsStartChild {
613+ // Sibling is start, closing pane is end
614+ panedWidget.SetEndChild(nil) // Unparent closing pane
615+ panedWidget.SetStartChild(nil) // Unparent sibling
616+ } else {
617+ // Sibling is end, closing pane is start
618+ panedWidget.SetStartChild(nil) // Unparent closing pane
619+ panedWidget.SetEndChild(nil) // Unparent sibling
620+ }
621+
622+ if grandparentNode == nil {
623+ // Sibling becomes new root
624+ // Remove the old root (parent paned) from container manually since
625+ // ClearRootWidgetRef only clears the reference, not the actual widget
626+ wsView.Container().Remove(parentWidget)
627+ wsView.ClearRootWidgetRef()
628+
629+ // Grab focus on sibling before reparenting to avoid GTK focus warnings
630+ siblingWidget.GrabFocus()
631+
632+ // Ensure sibling expands to fill container (was constrained by paned before)
633+ siblingWidget.SetHexpand(true)
634+ siblingWidget.SetVexpand(true)
635+
636+ wsView.SetRootWidgetDirect(siblingWidget)
637+
638+ log.Debug().Msg("sibling promoted to root")
639+ } else {
640+ // Replace parent with sibling in grandparent
641+ grandparentWidget := tr.Lookup(grandparentNode.ID)
642+ if grandparentWidget == nil {
643+ return fmt.Errorf("grandparent widget not found")
644+ }
645+
646+ grandPaned, ok := grandparentWidget.(layout.PanedWidget)
647+ if !ok {
648+ return fmt.Errorf("grandparent widget is not a PanedWidget")
649+ }
650+
651+ // Grab focus on sibling before reparenting to avoid GTK focus warnings
652+ siblingWidget.GrabFocus()
653+
654+ // Remove parent from grandparent using known position from domain tree
655+ if parentIsStartInGrand {
656+ grandPaned.SetStartChild(nil)
657+ } else {
658+ grandPaned.SetEndChild(nil)
659+ }
660+
661+ // Insert sibling at same position
662+ if parentIsStartInGrand {
663+ grandPaned.SetStartChild(siblingWidget)
664+ } else {
665+ grandPaned.SetEndChild(siblingWidget)
666+ }
667+
668+ log.Debug().Bool("was_start_child", parentIsStartInGrand).Msg("sibling promoted in grandparent")
669+ }
670+
671+ // Clean up tracking
672+ tr.UnregisterWidget(parentNode.ID)
673+ wsView.UnregisterPaneView(closingPaneID)
674+ tr.UnregisterPane(string(closingPaneID))
675+
676+ // Release the closing pane's webview
677+ c.contentCoord.ReleaseWebView(ctx, closingPaneID)
678+
679+ // Activate sibling
680+ if siblingNode.IsLeaf() && siblingNode.Pane != nil {
681+ if siblingPaneView := wsView.GetPaneView(siblingNode.Pane.ID); siblingPaneView != nil {
682+ siblingPaneView.SetActive(true)
683+ }
684+ }
685+
686+ log.Debug().Msg("incremental close completed successfully")
687+ return nil
688+}
689+
690+// doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
691+func (c *WorkspaceCoordinator) doIncrementalStackClose(
692+ ctx context.Context,
693+ wsView *component.WorkspaceView,
694+ closingPaneID entity.PaneID,
695+ stackNode *entity.PaneNode,
696+) error {
697+ log := logging.FromContext(ctx)
698+ tr := wsView.TreeRenderer()
699+ if tr == nil {
700+ return fmt.Errorf("tree renderer not available")
701+ }
702+
703+ log.Debug().
704+ Str("closing_pane", string(closingPaneID)).
705+ Str("stack_node", stackNode.ID).
706+ Int("stack_children", len(stackNode.Children)).
707+ Msg("performing incremental stack close")
708+
709+ // Get the StackedView for this pane
710+ stackedView := tr.GetStackedViewForPane(string(closingPaneID))
711+ if stackedView == nil {
712+ return fmt.Errorf("stacked view not found for pane %s", closingPaneID)
713+ }
714+
715+ // Find the index of the closing pane in the stack
716+ closingIndex := -1
717+ for i, child := range stackNode.Children {
718+ if child.Pane != nil && child.Pane.ID == closingPaneID {
719+ closingIndex = i
720+ break
721+ }
722+ }
723+
724+ if closingIndex < 0 {
725+ return fmt.Errorf("pane %s not found in stack", closingPaneID)
726+ }
727+
728+ // Remove the pane from the StackedView widget
729+ if err := stackedView.RemovePane(ctx, closingIndex); err != nil {
730+ return fmt.Errorf("failed to remove pane from stacked view: %w", err)
731+ }
732+
733+ // Clean up tracking
734+ wsView.UnregisterPaneView(closingPaneID)
735+ tr.UnregisterPane(string(closingPaneID))
736+
737+ // Release the closing pane's webview
738+ c.contentCoord.ReleaseWebView(ctx, closingPaneID)
739+
740+ log.Debug().
741+ Int("removed_index", closingIndex).
742+ Msg("incremental stack close completed successfully")
743+ return nil
744+}
745+
746+// FocusPane navigates focus to an adjacent pane.
747+func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
748+ log := logging.FromContext(ctx)
749+
750+ ws, wsView := c.getActiveWS()
751+ if ws == nil {
752+ log.Warn().Msg("no active workspace")
753+ return nil
754+ }
755+
756+ if wsView == nil {
757+ log.Warn().Msg("no active workspace view")
758+ return nil
759+ }
760+
761+ // Use geometric navigation if focus manager is available
762+ var newPane *entity.PaneNode
763+ var err error
764+
765+ if c.focusMgr != nil {
766+ newPane, err = c.focusMgr.NavigateGeometric(ctx, ws, wsView, direction)
767+ } else if c.panesUC != nil {
768+ // Fallback to structural navigation
769+ newPane, err = c.panesUC.NavigateFocus(ctx, ws, direction)
770+ } else {
771+ log.Warn().Msg("no navigation manager available")
772+ return nil
773+ }
774+
775+ if err != nil {
776+ log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus")
777+ return err
778+ }
779+
780+ if newPane == nil {
781+ log.Debug().Str("direction", string(direction)).Msg("no pane in that direction")
782+ return nil
783+ }
784+
785+ // Update the workspace view's active pane
786+ if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
787+ log.Warn().Err(err).Msg("failed to update active pane in view")
788+ } else {
789+ wsView.FocusPane(newPane.Pane.ID)
790+ }
791+
792+ // Sync StackedView visibility if new pane is in a stack
793+ if newPane.Parent != nil && newPane.Parent.IsStacked {
794+ c.syncStackedViewActive(ctx, wsView, newPane)
795+ }
796+
797+ log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated")
798+
799+ return nil
800+}
801+
802+// syncStackedViewActive updates the StackedView's visibility to match the domain model.
803+func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView *component.WorkspaceView, paneNode *entity.PaneNode) {
804+ log := logging.FromContext(ctx)
805+
806+ if paneNode == nil || paneNode.Parent == nil || !paneNode.Parent.IsStacked {
807+ return
808+ }
809+
810+ tr := wsView.TreeRenderer()
811+ if tr == nil {
812+ return
813+ }
814+
815+ // Get the StackedView for this pane
816+ stackedView := tr.GetStackedViewForPane(string(paneNode.Pane.ID))
817+ if stackedView == nil {
818+ log.Warn().Str("pane_id", string(paneNode.Pane.ID)).Msg("stacked view not found for pane")
819+ return
820+ }
821+
822+ // Use the parent's ActiveStackIndex which was set by the focus manager
823+ stackIndex := paneNode.Parent.ActiveStackIndex
824+
825+ log.Debug().
826+ Str("pane_id", string(paneNode.Pane.ID)).
827+ Int("stack_index", stackIndex).
828+ Msg("syncing stacked view visibility")
829+
830+ if err := stackedView.SetActive(ctx, stackIndex); err != nil {
831+ log.Warn().Err(err).Msg("failed to set stacked view active index")
832+ }
833+}
834+
835+// StackPane adds a new pane stacked on top of the active pane.
836+func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
837+ log := logging.FromContext(ctx)
838+
839+ if c.stackedPaneMgr == nil {
840+ log.Warn().Msg("stacked pane manager not available")
841+ return nil
842+ }
843+
844+ ws, wsView := c.getActiveWS()
845+ if ws == nil {
846+ log.Warn().Msg("no active workspace")
847+ return nil
848+ }
849+
850+ activeNode := ws.ActivePane()
851+ if activeNode == nil || activeNode.Pane == nil {
852+ log.Warn().Msg("no active pane")
853+ return nil
854+ }
855+
856+ if wsView == nil {
857+ log.Warn().Msg("no workspace view")
858+ return nil
859+ }
860+
861+ activePaneID := activeNode.Pane.ID
862+
863+ // Create a new pane entity
864+ newPaneID := entity.PaneID(c.generateID())
865+ newPane := entity.NewPane(newPaneID)
866+ newPane.URI = "about:blank"
867+ newPane.Title = "Untitled"
868+
869+ // Get the original pane's current title
870+ originalTitle := c.contentCoord.GetTitle(activePaneID)
871+ if originalTitle == "" {
872+ originalTitle = activeNode.Pane.Title
873+ }
874+ if originalTitle == "" {
875+ originalTitle = "Untitled"
876+ }
877+
878+ // Update domain model: convert leaf to stacked if needed, add new pane
879+ var stackNode *entity.PaneNode
880+ var needsFirstPaneTitleUpdate bool
881+ if activeNode.IsStacked {
882+ // Already stacked, just add to it
883+ stackNode = activeNode
884+ } else {
885+ // Convert leaf node to stacked container
886+ originalPane := activeNode.Pane
887+ originalPane.Title = originalTitle
888+ originalPaneChild := &entity.PaneNode{
889+ ID: activeNode.ID + "_0",
890+ Pane: originalPane,
891+ Parent: activeNode,
892+ }
893+
894+ activeNode.Pane = nil
895+ activeNode.IsStacked = true
896+ activeNode.Children = []*entity.PaneNode{originalPaneChild}
897+ stackNode = activeNode
898+ needsFirstPaneTitleUpdate = true
899+
900+ log.Debug().
901+ Str("node_id", activeNode.ID).
902+ Str("original_pane", string(originalPane.ID)).
903+ Str("original_title", originalTitle).
904+ Msg("converted leaf to stacked node")
905+ }
906+
907+ // Create new child node for the new pane
908+ newChildNode := &entity.PaneNode{
909+ ID: stackNode.ID + "_" + string(newPaneID),
910+ Pane: newPane,
911+ Parent: stackNode,
912+ }
913+ stackNode.Children = append(stackNode.Children, newChildNode)
914+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
915+
916+ log.Debug().
917+ Int("stack_size", len(stackNode.Children)).
918+ Int("active_index", stackNode.ActiveStackIndex).
919+ Msg("domain tree updated")
920+
921+ // Create PaneView for the new pane
922+ newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
923+ wsView.RegisterPaneView(newPaneID, newPaneView)
924+
925+ // Add to the UI StackedView
926+ if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, activePaneID, newPaneView, "Untitled"); err != nil {
927+ log.Error().Err(err).Msg("failed to add pane to stack")
928+ return err
929+ }
930+
931+ // Update the first pane's title if we just converted from leaf to stacked
932+ if needsFirstPaneTitleUpdate {
933+ tr := wsView.TreeRenderer()
934+ if tr != nil {
935+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
936+ if stackedView != nil {
937+ if err := stackedView.UpdateTitle(0, originalTitle); err != nil {
938+ log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title")
939+ } else {
940+ log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView")
941+ }
942+ }
943+ }
944+ }
945+
946+ // Get WebView and attach
947+ wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
948+ if err != nil {
949+ log.Warn().Err(err).Msg("failed to get webview for new pane")
950+ } else {
951+ widget := c.contentCoord.WrapWidget(ctx, wv)
952+ if widget != nil {
953+ newPaneView.SetWebViewWidget(widget)
954+ }
955+ // Load blank page
956+ if err := wv.LoadURI(ctx, "about:blank"); err != nil {
957+ log.Warn().Err(err).Msg("failed to load blank page")
958+ }
959+ }
960+
961+ // Update workspace active pane ID
962+ ws.ActivePaneID = newPaneID
963+
964+ // Update workspace view
965+ if err := wsView.SetActivePaneID(newPaneID); err != nil {
966+ log.Warn().Err(err).Msg("failed to set active pane")
967+ }
968+
969+ // Set up title bar click callback
970+ tr := wsView.TreeRenderer()
971+ if tr != nil {
972+ stackedView := tr.GetStackedViewForPane(string(activePaneID))
973+ if stackedView != nil {
974+ capturedStackNode := stackNode
975+ stackedView.SetOnActivate(func(index int) {
976+ c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
977+ })
978+ }
979+ }
980+
981+ log.Info().
982+ Str("original_pane", string(activePaneID)).
983+ Str("new_pane", string(newPaneID)).
984+ Int("stack_size", len(stackNode.Children)).
985+ Msg("stacked new pane")
986+
987+ return nil
988+}
989+
990+// NavigateStack navigates up or down within a stacked pane container.
991+func (c *WorkspaceCoordinator) NavigateStack(ctx context.Context, direction string) error {
992+ log := logging.FromContext(ctx)
993+
994+ if c.stackedPaneMgr == nil {
995+ return nil
996+ }
997+
998+ ws, wsView := c.getActiveWS()
999+ if ws == nil || wsView == nil {
1000+ return nil
1001+ }
1002+
1003+ activePane := ws.ActivePane()
1004+ if activePane == nil || activePane.Pane == nil {
1005+ return nil
1006+ }
1007+
1008+ currentPaneID := activePane.Pane.ID
1009+
1010+ // Check if we're actually in a multi-pane stack
1011+ if !c.stackedPaneMgr.IsStacked(wsView, currentPaneID) {
1012+ log.Debug().Msg("current pane is not in a multi-pane stack")
1013+ return nil
1014+ }
1015+
1016+ // Navigate within the stack
1017+ _, err := c.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction)
1018+ if err != nil {
1019+ log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack")
1020+ return err
1021+ }
1022+
1023+ log.Debug().
1024+ Str("direction", direction).
1025+ Str("current_pane", string(currentPaneID)).
1026+ Msg("navigated stack")
1027+
1028+ return nil
1029+}
1030+
1031+// onTitleBarClick handles clicks on title bars to switch the active pane in a stack.
1032+func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) {
1033+ log := logging.FromContext(ctx)
1034+
1035+ if stackNode == nil || sv == nil {
1036+ return
1037+ }
1038+
1039+ // Validate index
1040+ if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) {
1041+ log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked")
1042+ return
1043+ }
1044+
1045+ // Get the current active index
1046+ currentIndex := sv.ActiveIndex()
1047+ if clickedIndex == currentIndex {
1048+ log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active")
1049+ return
1050+ }
1051+
1052+ // Update the outgoing pane's title bar with its current webpage title
1053+ if currentIndex >= 0 && currentIndex < len(stackNode.Children) {
1054+ outgoingChild := stackNode.Children[currentIndex]
1055+ if outgoingChild.Pane != nil {
1056+ outgoingPaneID := outgoingChild.Pane.ID
1057+ outgoingTitle := c.contentCoord.GetTitle(outgoingPaneID)
1058+ if outgoingTitle == "" {
1059+ outgoingTitle = outgoingChild.Pane.Title
1060+ }
1061+ if outgoingTitle != "" {
1062+ if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil {
1063+ log.Warn().Err(err).Msg("failed to update outgoing pane title")
1064+ }
1065+ }
1066+ }
1067+ }
1068+
1069+ // Get the pane ID at the clicked index
1070+ clickedChild := stackNode.Children[clickedIndex]
1071+ if clickedChild.Pane == nil {
1072+ log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane")
1073+ return
1074+ }
1075+ clickedPaneID := clickedChild.Pane.ID
1076+
1077+ // Update StackedView active index
1078+ if err := sv.SetActive(ctx, clickedIndex); err != nil {
1079+ log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
1080+ return
1081+ }
1082+
1083+ // Update domain model
1084+ stackNode.ActiveStackIndex = clickedIndex
1085+
1086+ // Update workspace active pane
1087+ ws, wsView := c.getActiveWS()
1088+ if ws != nil {
1089+ ws.ActivePaneID = clickedPaneID
1090+ }
1091+
1092+ // Update workspace view
1093+ if wsView != nil {
1094+ if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1095+ log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1096+ }
1097+ }
1098+
1099+ log.Info().
1100+ Int("from_index", currentIndex).
1101+ Int("to_index", clickedIndex).
1102+ Str("pane_id", string(clickedPaneID)).
1103+ Msg("switched active pane via title bar click")
1104+}
A internal/ui/dispatcher/keyboard.go +124 -0
@@ -0,0 +1,124 @@
1+package dispatcher
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/dumber/internal/ui/coordinator"
9+ "github.com/bnema/dumber/internal/ui/input"
10+)
11+
12+// KeyboardDispatcher routes keyboard actions to appropriate coordinators.
13+type KeyboardDispatcher struct {
14+ tabCoord *coordinator.TabCoordinator
15+ wsCoord *coordinator.WorkspaceCoordinator
16+ navCoord *coordinator.NavigationCoordinator
17+ onQuit func()
18+}
19+
20+// NewKeyboardDispatcher creates a new KeyboardDispatcher.
21+func NewKeyboardDispatcher(
22+ ctx context.Context,
23+ tabCoord *coordinator.TabCoordinator,
24+ wsCoord *coordinator.WorkspaceCoordinator,
25+ navCoord *coordinator.NavigationCoordinator,
26+) *KeyboardDispatcher {
27+ log := logging.FromContext(ctx)
28+ log.Debug().Msg("creating keyboard dispatcher")
29+
30+ return &KeyboardDispatcher{
31+ tabCoord: tabCoord,
32+ wsCoord: wsCoord,
33+ navCoord: navCoord,
34+ }
35+}
36+
37+// SetOnQuit sets the callback for quit action.
38+func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
39+ d.onQuit = fn
40+}
41+
42+// Dispatch routes a keyboard action to the appropriate coordinator.
43+func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
44+ log := logging.FromContext(ctx)
45+ log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
46+
47+ switch action {
48+ // Tab actions
49+ case input.ActionNewTab:
50+ _, err := d.tabCoord.Create(ctx, "about:blank")
51+ return err
52+ case input.ActionCloseTab:
53+ return d.tabCoord.Close(ctx)
54+ case input.ActionNextTab:
55+ return d.tabCoord.SwitchNext(ctx)
56+ case input.ActionPreviousTab:
57+ return d.tabCoord.SwitchPrev(ctx)
58+
59+ // Pane actions
60+ case input.ActionSplitRight:
61+ return d.wsCoord.Split(ctx, usecase.SplitRight)
62+ case input.ActionSplitLeft:
63+ return d.wsCoord.Split(ctx, usecase.SplitLeft)
64+ case input.ActionSplitUp:
65+ return d.wsCoord.Split(ctx, usecase.SplitUp)
66+ case input.ActionSplitDown:
67+ return d.wsCoord.Split(ctx, usecase.SplitDown)
68+ case input.ActionClosePane:
69+ return d.wsCoord.ClosePane(ctx)
70+ case input.ActionStackPane:
71+ return d.wsCoord.StackPane(ctx)
72+ case input.ActionFocusRight:
73+ return d.wsCoord.FocusPane(ctx, usecase.NavRight)
74+ case input.ActionFocusLeft:
75+ return d.wsCoord.FocusPane(ctx, usecase.NavLeft)
76+ case input.ActionFocusUp:
77+ return d.wsCoord.FocusPane(ctx, usecase.NavUp)
78+ case input.ActionFocusDown:
79+ return d.wsCoord.FocusPane(ctx, usecase.NavDown)
80+
81+ // Stack navigation
82+ case input.ActionStackNavUp:
83+ return d.wsCoord.NavigateStack(ctx, "up")
84+ case input.ActionStackNavDown:
85+ return d.wsCoord.NavigateStack(ctx, "down")
86+
87+ // Navigation
88+ case input.ActionGoBack:
89+ return d.navCoord.GoBack(ctx)
90+ case input.ActionGoForward:
91+ return d.navCoord.GoForward(ctx)
92+ case input.ActionReload:
93+ return d.navCoord.Reload(ctx)
94+ case input.ActionHardReload:
95+ return d.navCoord.HardReload(ctx)
96+
97+ // Zoom (stub - will be implemented later)
98+ case input.ActionZoomIn:
99+ log.Debug().Msg("zoom in action (not yet implemented)")
100+ case input.ActionZoomOut:
101+ log.Debug().Msg("zoom out action (not yet implemented)")
102+ case input.ActionZoomReset:
103+ log.Debug().Msg("zoom reset action (not yet implemented)")
104+
105+ // UI
106+ case input.ActionOpenOmnibox:
107+ return d.navCoord.OpenOmnibox(ctx)
108+ case input.ActionOpenDevTools:
109+ return d.navCoord.OpenDevTools(ctx)
110+ case input.ActionToggleFullscreen:
111+ log.Debug().Msg("toggle fullscreen action (not yet implemented)")
112+
113+ // Application
114+ case input.ActionQuit:
115+ if d.onQuit != nil {
116+ d.onQuit()
117+ }
118+
119+ default:
120+ log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
121+ }
122+
123+ return nil
124+}
@@ -0,0 +1,124 @@
1+package dispatcher
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/dumber/internal/ui/coordinator"
9+ "github.com/bnema/dumber/internal/ui/input"
10+)
11+
12+// KeyboardDispatcher routes keyboard actions to appropriate coordinators.
13+type KeyboardDispatcher struct {
14+ tabCoord *coordinator.TabCoordinator
15+ wsCoord *coordinator.WorkspaceCoordinator
16+ navCoord *coordinator.NavigationCoordinator
17+ onQuit func()
18+}
19+
20+// NewKeyboardDispatcher creates a new KeyboardDispatcher.
21+func NewKeyboardDispatcher(
22+ ctx context.Context,
23+ tabCoord *coordinator.TabCoordinator,
24+ wsCoord *coordinator.WorkspaceCoordinator,
25+ navCoord *coordinator.NavigationCoordinator,
26+) *KeyboardDispatcher {
27+ log := logging.FromContext(ctx)
28+ log.Debug().Msg("creating keyboard dispatcher")
29+
30+ return &KeyboardDispatcher{
31+ tabCoord: tabCoord,
32+ wsCoord: wsCoord,
33+ navCoord: navCoord,
34+ }
35+}
36+
37+// SetOnQuit sets the callback for quit action.
38+func (d *KeyboardDispatcher) SetOnQuit(fn func()) {
39+ d.onQuit = fn
40+}
41+
42+// Dispatch routes a keyboard action to the appropriate coordinator.
43+func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
44+ log := logging.FromContext(ctx)
45+ log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
46+
47+ switch action {
48+ // Tab actions
49+ case input.ActionNewTab:
50+ _, err := d.tabCoord.Create(ctx, "about:blank")
51+ return err
52+ case input.ActionCloseTab:
53+ return d.tabCoord.Close(ctx)
54+ case input.ActionNextTab:
55+ return d.tabCoord.SwitchNext(ctx)
56+ case input.ActionPreviousTab:
57+ return d.tabCoord.SwitchPrev(ctx)
58+
59+ // Pane actions
60+ case input.ActionSplitRight:
61+ return d.wsCoord.Split(ctx, usecase.SplitRight)
62+ case input.ActionSplitLeft:
63+ return d.wsCoord.Split(ctx, usecase.SplitLeft)
64+ case input.ActionSplitUp:
65+ return d.wsCoord.Split(ctx, usecase.SplitUp)
66+ case input.ActionSplitDown:
67+ return d.wsCoord.Split(ctx, usecase.SplitDown)
68+ case input.ActionClosePane:
69+ return d.wsCoord.ClosePane(ctx)
70+ case input.ActionStackPane:
71+ return d.wsCoord.StackPane(ctx)
72+ case input.ActionFocusRight:
73+ return d.wsCoord.FocusPane(ctx, usecase.NavRight)
74+ case input.ActionFocusLeft:
75+ return d.wsCoord.FocusPane(ctx, usecase.NavLeft)
76+ case input.ActionFocusUp:
77+ return d.wsCoord.FocusPane(ctx, usecase.NavUp)
78+ case input.ActionFocusDown:
79+ return d.wsCoord.FocusPane(ctx, usecase.NavDown)
80+
81+ // Stack navigation
82+ case input.ActionStackNavUp:
83+ return d.wsCoord.NavigateStack(ctx, "up")
84+ case input.ActionStackNavDown:
85+ return d.wsCoord.NavigateStack(ctx, "down")
86+
87+ // Navigation
88+ case input.ActionGoBack:
89+ return d.navCoord.GoBack(ctx)
90+ case input.ActionGoForward:
91+ return d.navCoord.GoForward(ctx)
92+ case input.ActionReload:
93+ return d.navCoord.Reload(ctx)
94+ case input.ActionHardReload:
95+ return d.navCoord.HardReload(ctx)
96+
97+ // Zoom (stub - will be implemented later)
98+ case input.ActionZoomIn:
99+ log.Debug().Msg("zoom in action (not yet implemented)")
100+ case input.ActionZoomOut:
101+ log.Debug().Msg("zoom out action (not yet implemented)")
102+ case input.ActionZoomReset:
103+ log.Debug().Msg("zoom reset action (not yet implemented)")
104+
105+ // UI
106+ case input.ActionOpenOmnibox:
107+ return d.navCoord.OpenOmnibox(ctx)
108+ case input.ActionOpenDevTools:
109+ return d.navCoord.OpenDevTools(ctx)
110+ case input.ActionToggleFullscreen:
111+ log.Debug().Msg("toggle fullscreen action (not yet implemented)")
112+
113+ // Application
114+ case input.ActionQuit:
115+ if d.onQuit != nil {
116+ d.onQuit()
117+ }
118+
119+ default:
120+ log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
121+ }
122+
123+ return nil
124+}

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