1package ui
2
3import (
4 "context"
5 "sort"
6
7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/dumber/internal/ui/coordinator"
13 "github.com/bnema/dumber/internal/ui/focus"
14 "github.com/bnema/dumber/internal/ui/input"
15 "github.com/bnema/dumber/internal/ui/layout"
16 "github.com/bnema/dumber/internal/ui/window"
17 "github.com/bnema/puregotk/v4/glib"
18)
19
20type browserWindow struct {
21 id string
22 initialURL string
23 tabs *entity.TabList // per-window tab list (single source of truth for tab state)
24 mainWindow *window.MainWindow
25 appToaster *component.Toaster
26 modeToaster *component.Toaster
27 borderMgr *focus.BorderManager
28 sessionManager *component.SessionManager
29 tabPicker *component.TabPicker
30 tabPickerWidget layout.Widget
31 tabPickerPaneID entity.PaneID
32 insertAccentUC *usecase.InsertAccentUseCase
33 accentPicker *component.AccentPicker
34 keyboardHandler *input.KeyboardHandler
35 globalShortcutHandler *input.GlobalShortcutHandler
36 permissionDialog port.PermissionDialogPresenter
37 webrtcIndicator *component.WebRTCPermissionIndicator
38}
39
40func (bw *browserWindow) clearShellState() {
41 if bw == nil {
42 return
43 }
44 bw.appToaster = nil
45 bw.modeToaster = nil
46 bw.borderMgr = nil
47 bw.sessionManager = nil
48 bw.tabPicker = nil
49 bw.tabPickerWidget = nil
50 bw.tabPickerPaneID = ""
51 bw.insertAccentUC = nil
52 bw.accentPicker = nil
53 bw.keyboardHandler = nil
54 bw.globalShortcutHandler = nil
55 bw.permissionDialog = nil
56 bw.webrtcIndicator = nil
57}
58
59func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
60 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
61 return
62 }
63
64 bw.initToasterOverlay(a)
65 bw.initBorderOverlay(a)
66 bw.initAccentPicker(ctx, a)
67 bw.initSessionManager(ctx, a)
68 bw.initTabPicker(ctx, a)
69}
70
71func (bw *browserWindow) initToasterOverlay(a *App) {
72 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
73 return
74 }
75
76 bw.appToaster = component.NewToaster(a.widgetFactory)
77 if bw.appToaster != nil {
78 if widget := bw.appToaster.Widget(); widget != nil {
79 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
80 bw.mainWindow.AddOverlay(gtkWidget)
81 }
82 }
83 }
84
85 bw.modeToaster = component.NewToaster(a.widgetFactory)
86 if bw.modeToaster != nil {
87 if widget := bw.modeToaster.Widget(); widget != nil {
88 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
89 bw.mainWindow.AddOverlay(gtkWidget)
90 }
91 }
92 }
93}
94
95func (bw *browserWindow) initBorderOverlay(a *App) {
96 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
97 return
98 }
99
100 bw.borderMgr = focus.NewBorderManager(a.widgetFactory)
101 if bw.borderMgr == nil {
102 return
103 }
104 if widget := bw.borderMgr.Widget(); widget != nil {
105 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
106 bw.mainWindow.AddOverlay(gtkWidget)
107 }
108 }
109}
110
111func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
112 log := logging.FromContext(ctx)
113 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil {
114 log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker")
115 return
116 }
117
118 bw.accentPicker = component.NewAccentPicker(a.widgetFactory)
119 if bw.accentPicker == nil {
120 log.Warn().Msg("failed to create accent picker")
121 return
122 }
123 if widget := bw.accentPicker.Widget(); widget != nil {
124 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
125 bw.mainWindow.AddOverlay(gtkWidget)
126 }
127 }
128
129 a.accentFocusProvider = a.deps.AccentFocusProvider
130 bw.insertAccentUC = usecase.NewInsertAccentUseCase(
131 a.accentFocusProvider,
132 bw.accentPicker,
133 func(fn func()) {
134 cb := glib.SourceFunc(func(_ uintptr) bool {
135 fn()
136 return false
137 })
138 glib.IdleAdd(&cb, 0)
139 },
140 )
141
142 log.Debug().Msg("accent picker initialized")
143}
144
145func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) {
146 log := logging.FromContext(ctx)
147 if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil || bw.mainWindow == nil {
148 log.Debug().Msg("deps or main window not available, skipping session manager")
149 return
150 }
151
152 var listSessionsUC *usecase.ListSessionsUseCase
153 var deleteSessionUC *usecase.DeleteSessionUseCase
154 if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
155 listSessionsUC = usecase.NewListSessionsUseCase(
156 a.deps.SessionRepo,
157 a.deps.SessionStateRepo,
158 )
159 deleteSessionUC = usecase.NewDeleteSessionUseCase(
160 a.deps.SessionStateRepo,
161 a.deps.SessionRepo,
162 )
163 }
164
165 bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
166 ListSessionsUC: listSessionsUC,
167 DeleteSessionUC: deleteSessionUC,
168 CurrentSession: a.deps.CurrentSessionID,
169 UIScale: a.deps.Config.DefaultUIScale,
170 OnClose: func() {
171 log.Debug().Msg("session manager closed")
172 },
173 OnOpen: func(sessionID entity.SessionID) {
174 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
175 if spawner := a.deps.SessionSpawner; spawner == nil {
176 log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
177 return
178 } else if err := spawner.SpawnWithSession(sessionID); err != nil {
179 log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
180 }
181 },
182 OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
183 if bw.appToaster != nil {
184 bw.appToaster.Show(ctx, message, level)
185 }
186 },
187 })
188
189 if bw.sessionManager == nil {
190 log.Warn().Msg("failed to create session manager")
191 return
192 }
193 if widget := bw.sessionManager.Widget(); widget != nil {
194 bw.mainWindow.AddOverlay(widget)
195 }
196 log.Debug().Msg("session manager initialized")
197}
198
199func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
200 log := logging.FromContext(ctx)
201 if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil {
202 log.Debug().Msg("deps/config not available, skipping tab picker")
203 return
204 }
205
206 bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
207 UIScale: a.deps.Config.DefaultUIScale,
208 OnClose: func() {
209 log.Debug().Msg("tab picker closed")
210 },
211 OnSelect: func(item component.TabPickerItem) {
212 cb := glib.SourceFunc(func(_ uintptr) bool {
213 var targetID entity.TabID
214 if !item.IsNew {
215 targetID = item.TabID
216 }
217 if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil {
218 log.Warn().Err(err).Msg("move pane to tab failed")
219 }
220 return false
221 })
222 glib.IdleAdd(&cb, 0)
223 },
224 })
225
226 if bw.tabPicker == nil {
227 log.Warn().Msg("failed to create tab picker")
228 return
229 }
230
231 log.Debug().Msg("tab picker initialized")
232}
233
234func (bw *browserWindow) ensureTabs() {
235 if bw != nil && bw.tabs == nil {
236 bw.tabs = entity.NewTabList()
237 }
238}
239
240func (a *App) registerBrowserWindow(bw *browserWindow) {
241 if bw == nil {
242 return
243 }
244 bw.ensureTabs()
245 if a.browserWindows == nil {
246 a.browserWindows = make(map[string]*browserWindow)
247 }
248 a.browserWindows[bw.id] = bw
249
250 // Track registration order, guarding against duplicates.
251 for _, id := range a.browserWindowOrder {
252 if id == bw.id {
253 return
254 }
255 }
256 a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
257}
258
259func (a *App) removeBrowserWindow(id string) {
260 if id == "" || a.browserWindows == nil {
261 return
262 }
263 removed := a.browserWindows[id]
264
265 // Remove from registration order
266 for i, wid := range a.browserWindowOrder {
267 if wid == id {
268 a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
269 break
270 }
271 }
272 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
273 ownedTabIDs := make([]entity.TabID, 0)
274 for tabID, bw := range a.windowForTab {
275 if bw != nil && bw.id == id {
276 ownedTabIDs = append(ownedTabIDs, tabID)
277 }
278 }
279 for _, tabID := range ownedTabIDs {
280 a.releaseFloatingSessionsForTab(context.Background(), tabID)
281 delete(a.workspaceViews, tabID)
282 delete(a.windowForTab, tabID)
283 if a.tabs != nil && a.tabs.Find(tabID) != nil {
284 a.tabs.Remove(tabID)
285 }
286 }
287 if removed != nil {
288 removed.clearShellState()
289 }
290 if a.contentCoord != nil {
291 a.contentCoord.ClearPopupNamedContextsForWindow(id)
292 }
293 delete(a.browserWindows, id)
294 fallback := a.deterministicBrowserWindowFallback()
295 if a.lastFocusedWindowID == id {
296 if fallback != nil {
297 a.lastFocusedWindowID = fallback.id
298 } else {
299 a.lastFocusedWindowID = ""
300 }
301 }
302 if wasMainWindow {
303 a.clearResizeModeBorder()
304 if fallback != nil {
305 a.activateBrowserWindow(fallback)
306 return
307 }
308 a.lastFocusedWindowID = ""
309 a.mainWindow = nil
310 a.keyboardHandler = nil
311 a.globalShortcutHandler = nil
312 if a.tabCoord != nil {
313 a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
314 }
315 }
316}
317
318func (a *App) deterministicBrowserWindowFallback() *browserWindow {
319 if a.browserWindows == nil {
320 return nil
321 }
322 for _, id := range a.browserWindowOrder {
323 bw := a.browserWindows[id]
324 if id == "" || bw == nil || bw.mainWindow == nil {
325 continue
326 }
327 return bw
328 }
329 ids := make([]string, 0, len(a.browserWindows))
330 for id, bw := range a.browserWindows {
331 if id == "" || bw == nil || bw.mainWindow == nil {
332 continue
333 }
334 ids = append(ids, id)
335 }
336 sort.Strings(ids)
337 if len(ids) == 0 {
338 return nil
339 }
340 return a.browserWindows[ids[0]]
341}
342
343func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
344 dispatch := a.dispatchOnMainThread
345 if dispatch == nil {
346 dispatch = func(fn func()) { fn() }
347 }
348
349 var openErr error
350 dispatch(func() {
351 openErr = a.openFreshWindow(ctx, url)
352 })
353
354 return openErr
355}