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