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(>kView.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(>kView.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 | + | } | |||