1package ui
2
3import (
4 "context"
5 "fmt"
6 "maps"
7 "strings"
8
9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/ui/component"
12)
13
14func (a *App) createEmptyBrowserWindow(ctx context.Context) (*browserWindow, error) {
15 created, err := a.createBrowserWindow(ctx, "")
16 if err != nil {
17 return nil, err
18 }
19 a.registerBrowserWindow(created)
20 return created, nil
21}
22
23func (a *App) cleanupEjectTargetWindow(bw *browserWindow) {
24 if bw == nil {
25 return
26 }
27 if bw.tabs != nil {
28 for _, tab := range bw.tabs.Tabs {
29 if tab == nil {
30 continue
31 }
32 delete(a.windowForTab, tab.ID)
33 delete(a.workspaceViews, tab.ID)
34 if a.tabs != nil {
35 a.tabs.Remove(tab.ID)
36 }
37 }
38 }
39 a.removeBrowserWindow(bw.id)
40 if bw.mainWindow != nil {
41 bw.mainWindow.Destroy()
42 }
43}
44
45type ejectRollbackSnapshot struct {
46 sourceTabs *entity.TabList
47 sourceSnapshot *entity.TabList
48 targetTabs *entity.TabList
49 targetSnapshot *entity.TabList
50 globalSnapshot *entity.TabList
51 windowForTab map[entity.TabID]*browserWindow
52 workspaceViews map[entity.TabID]*component.WorkspaceView
53}
54
55func (a *App) captureEjectRollbackSnapshot(sourceTabs, targetTabs *entity.TabList) ejectRollbackSnapshot {
56 return ejectRollbackSnapshot{
57 sourceTabs: sourceTabs,
58 sourceSnapshot: sourceTabs.Snapshot(),
59 targetTabs: targetTabs,
60 targetSnapshot: targetTabs.Snapshot(),
61 globalSnapshot: a.tabs.Snapshot(),
62 windowForTab: cloneWindowForTabMap(a.windowForTab),
63 workspaceViews: cloneWorkspaceViewsMap(a.workspaceViews),
64 }
65}
66
67func (a *App) restoreEjectSnapshots(snapshot ejectRollbackSnapshot) {
68 if snapshot.sourceTabs != nil {
69 snapshot.sourceTabs.ReplaceFrom(snapshot.sourceSnapshot)
70 }
71 if snapshot.targetTabs != nil {
72 snapshot.targetTabs.ReplaceFrom(snapshot.targetSnapshot)
73 }
74 if a.tabs != nil {
75 a.tabs.ReplaceFrom(snapshot.globalSnapshot)
76 }
77 a.windowForTab = snapshot.windowForTab
78 a.workspaceViews = snapshot.workspaceViews
79}
80
81func (a *App) rollbackEjectTargetUIFailure(targetWindow *browserWindow, snapshot ejectRollbackSnapshot) error {
82 a.restoreEjectSnapshots(snapshot)
83 a.cleanupEjectTargetWindow(targetWindow)
84 return fmt.Errorf("failed to create target workspace view")
85}
86
87func cloneWindowForTabMap(source map[entity.TabID]*browserWindow) map[entity.TabID]*browserWindow {
88 cloned := make(map[entity.TabID]*browserWindow, len(source))
89 maps.Copy(cloned, source)
90 return cloned
91}
92
93func cloneWorkspaceViewsMap(source map[entity.TabID]*component.WorkspaceView) map[entity.TabID]*component.WorkspaceView {
94 cloned := make(map[entity.TabID]*component.WorkspaceView, len(source))
95 maps.Copy(cloned, source)
96 return cloned
97}
98
99func (a *App) activePaneEjectSource(requestedPaneID entity.PaneID) (*browserWindow, *entity.TabList, *entity.Tab, entity.PaneID) {
100 sourceWindow := a.lastFocusedBrowserWindow()
101 sourceTabs := a.tabListForBrowserWindow(sourceWindow)
102 sourceTab := a.activeTabForBrowserWindow(sourceWindow)
103 if sourceWindow == nil || sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil {
104 return nil, nil, nil, ""
105 }
106 sourcePaneID := sourceTab.Workspace.ActivePaneID
107 if requestedPaneID != "" && requestedPaneID != sourcePaneID {
108 return nil, nil, nil, ""
109 }
110 sourceNode := sourceTab.Workspace.FindPane(sourcePaneID)
111 if sourcePaneID == "" || sourceNode == nil || sourceNode.Pane == nil || !isEjectableWorkspacePane(sourceNode.Pane) {
112 return nil, nil, nil, ""
113 }
114 return sourceWindow, sourceTabs, sourceTab, sourcePaneID
115}
116
117func isEjectableWorkspacePane(pane *entity.Pane) bool {
118 if pane == nil {
119 return false
120 }
121 return pane.WindowType == entity.WindowMain && !strings.HasPrefix(pane.URI, "dumb://")
122}
123
124func (a *App) EjectActivePaneToWindow(ctx context.Context, requestedPaneID entity.PaneID) error {
125 if a.extractPaneToTabListUC == nil {
126 return nil
127 }
128
129 sourceWindow, sourceTabs, sourceTab, sourcePaneID := a.activePaneEjectSource(requestedPaneID)
130 if sourceWindow == nil {
131 return nil
132 }
133
134 targetWindow, err := a.createEmptyBrowserWindow(ctx)
135 if err != nil {
136 return err
137 }
138 targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
139 if targetTabs == nil {
140 a.cleanupEjectTargetWindow(targetWindow)
141 return fmt.Errorf("target tab list is required")
142 }
143
144 rollbackSnapshot := a.captureEjectRollbackSnapshot(sourceTabs, targetTabs)
145
146 out, err := a.extractPaneToTabListUC.Execute(usecase.ExtractPaneToTabListInput{
147 SourceTabs: sourceTabs,
148 SourceTabID: sourceTab.ID,
149 SourcePaneID: sourcePaneID,
150 TargetTabs: targetTabs,
151 })
152 if err != nil {
153 a.cleanupEjectTargetWindow(targetWindow)
154 return err
155 }
156 if out == nil || out.NewTab == nil {
157 a.cleanupEjectTargetWindow(targetWindow)
158 return nil
159 }
160
161 a.setBrowserWindowForTab(out.NewTab.ID, targetWindow)
162 a.syncDerivedGlobalTabMirror(out.NewTab)
163 if a.tabs != nil {
164 a.tabs.SetActive(out.NewTab.ID)
165 }
166 a.ensureTargetTabUI(ctx, out.NewTab, targetWindow)
167 if a.workspaceViews[out.NewTab.ID] == nil {
168 return a.rollbackEjectTargetUIFailure(targetWindow, rollbackSnapshot)
169 }
170
171 if out.SourceTabClosed {
172 a.removeSourceTabUI(sourceTab.ID, sourceWindow)
173 delete(a.windowForTab, sourceTab.ID)
174 a.switchSourceWindowToActiveTab(ctx, sourceWindow, sourceTabs)
175 } else {
176 a.rebuildAndAttachWorkspace(ctx, sourceTab.ID, sourceTab)
177 }
178
179 a.rebuildAndAttachWorkspace(ctx, out.NewTab.ID, out.NewTab)
180 if a.contentCoord != nil && out.NewTab.Workspace != nil {
181 a.contentCoord.SyncWebViewViewport(ctx, out.NewTab.Workspace.ActivePaneID, "eject-pane-to-window")
182 }
183
184 a.updateEjectWindowChrome(ctx, sourceWindow, targetWindow, targetTabs, out.NewTab.ID)
185
186 if out.SourceTabClosed && sourceTabs.Count() == 0 {
187 a.removeEmptySourceBrowserWindow(sourceWindow)
188 }
189
190 targetWindows := a.orderedBrowserWindowsForSnapshot()
191 a.replaceGlobalTabsFromRuntimeWindows(targetWindows, targetWindow)
192
193 a.activateBrowserWindow(targetWindow)
194 a.switchWorkspaceView(ctx, out.NewTab.ID)
195 if targetWindow.mainWindow != nil {
196 targetWindow.mainWindow.Show()
197 }
198 a.MarkDirty()
199 return nil
200}
201
202func (a *App) updateEjectWindowChrome(
203 ctx context.Context,
204 sourceWindow, targetWindow *browserWindow,
205 targetTabs *entity.TabList,
206 newTabID entity.TabID,
207) {
208 targetTabs.SetActive(newTabID)
209 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
210 tabBar.SetActive(newTabID)
211 }
212 if a.tabCoord != nil {
213 a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(sourceWindow))
214 a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(targetWindow))
215 }
216 a.updateBrowserWindowTabBarVisibility(sourceWindow)
217 a.updateBrowserWindowTabBarVisibility(targetWindow)
218}
219
220func (a *App) switchSourceWindowToActiveTab(ctx context.Context, bw *browserWindow, tabs *entity.TabList) {
221 if bw == nil || tabs == nil || tabs.Count() == 0 {
222 return
223 }
224 activeTab := tabs.ActiveTab()
225 if activeTab == nil {
226 return
227 }
228 if tabBar := a.tabBarForBrowserWindow(bw); tabBar != nil {
229 tabBar.SetActive(activeTab.ID)
230 }
231 a.switchWorkspaceView(ctx, activeTab.ID)
232}
233
234func (a *App) removeEmptySourceBrowserWindow(bw *browserWindow) {
235 if bw == nil || a.tabCountForBrowserWindow(bw) != 0 {
236 return
237 }
238 a.removeBrowserWindow(bw.id)
239 if bw.mainWindow != nil {
240 bw.mainWindow.Destroy()
241 }
242}
243
244func (a *App) orderedBrowserWindowsForSnapshot() []*browserWindow {
245 windows := make([]*browserWindow, 0, len(a.browserWindows))
246 for _, id := range a.windowOrder() {
247 if bw := a.browserWindows[id]; bw != nil {
248 windows = append(windows, bw)
249 }
250 }
251 return windows
252}