1package ui
2
3import (
4 "context"
5 "fmt"
6 "sort"
7
8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/dumber/internal/shared/syncdispatch"
13 "github.com/bnema/dumber/internal/ui/component"
14 "github.com/bnema/dumber/internal/ui/coordinator"
15 "github.com/bnema/dumber/internal/ui/focus"
16 "github.com/bnema/dumber/internal/ui/input"
17 "github.com/bnema/dumber/internal/ui/layout"
18 "github.com/bnema/dumber/internal/ui/window"
19 "github.com/bnema/puregotk/v4/glib"
20)
21
22type browserWindow struct {
23 id string
24 initialURL string
25 tabs *entity.TabList // per-window tab list (single source of truth for tab state)
26 mainWindow *window.MainWindow
27 appToaster *component.Toaster
28 modeToaster *component.Toaster
29 touchpadNavIndicator *component.TouchpadNavigationIndicator
30 borderMgr *focus.BorderManager
31 sessionManager *component.SessionManager
32 tabPicker *component.TabPicker
33 tabPickerWidget layout.Widget
34 tabPickerPaneID entity.PaneID
35 insertAccentUC *usecase.InsertAccentUseCase
36 accentPicker *component.AccentPicker
37 keyboardHandler *input.KeyboardHandler
38 globalShortcutHandler *input.GlobalShortcutHandler
39 permissionDialog port.PermissionDialogPresenter
40 webrtcIndicator *component.WebRTCPermissionIndicator
41 historySidebar *component.HistorySidebar
42 historySidebarReloader historySidebarReloader
43 sidebarVisible bool
44}
45
46func (bw *browserWindow) detachInputForDestroy() {
47 if bw == nil {
48 return
49 }
50 if bw.keyboardHandler != nil {
51 bw.keyboardHandler.DetachForDestroy()
52 }
53 if bw.globalShortcutHandler != nil {
54 bw.globalShortcutHandler.DetachForDestroy()
55 }
56}
57
58func (bw *browserWindow) teardownForDestroy() {
59 if bw == nil {
60 return
61 }
62 bw.detachInputForDestroy()
63 bw.clearShellState()
64}
65
66func (bw *browserWindow) clearShellState() {
67 if bw == nil {
68 return
69 }
70 // Destroy the history sidebar before releasing the reference so its
71 // context, debounce timer, callbacks, and in-flight goroutines are
72 // cleaned up before the window itself is torn down.
73 if bw.historySidebar != nil {
74 bw.historySidebar.Destroy()
75 }
76 if bw.touchpadNavIndicator != nil {
77 bw.touchpadNavIndicator.Destroy()
78 }
79 bw.appToaster = nil
80 bw.modeToaster = nil
81 bw.touchpadNavIndicator = nil
82 bw.borderMgr = nil
83 bw.sessionManager = nil
84 bw.tabPicker = nil
85 bw.tabPickerWidget = nil
86 bw.tabPickerPaneID = ""
87 bw.insertAccentUC = nil
88 bw.accentPicker = nil
89 bw.keyboardHandler = nil
90 bw.globalShortcutHandler = nil
91 bw.permissionDialog = nil
92 bw.webrtcIndicator = nil
93 bw.historySidebar = nil
94 bw.historySidebarReloader = nil
95}
96
97func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
98 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
99 return
100 }
101
102 bw.initToasterOverlay(a)
103 bw.initTouchpadNavigationIndicator()
104 bw.initBorderOverlay(a)
105 bw.initAccentPicker(ctx, a)
106 bw.initSessionManager(ctx, a)
107 bw.initTabPicker(ctx, a)
108 bw.initHistorySidebar(ctx, a)
109}
110
111func (bw *browserWindow) initToasterOverlay(a *App) {
112 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
113 return
114 }
115
116 bw.appToaster = component.NewToaster(a.widgetFactory)
117 if bw.appToaster != nil {
118 if widget := bw.appToaster.Widget(); widget != nil {
119 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
120 bw.mainWindow.AddOverlay(gtkWidget)
121 }
122 }
123 }
124
125 bw.modeToaster = component.NewToaster(a.widgetFactory)
126 if bw.modeToaster != nil {
127 if widget := bw.modeToaster.Widget(); widget != nil {
128 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
129 bw.mainWindow.AddOverlay(gtkWidget)
130 }
131 }
132 }
133}
134
135func (bw *browserWindow) initTouchpadNavigationIndicator() {
136 if bw == nil || bw.mainWindow == nil {
137 return
138 }
139 bw.touchpadNavIndicator = component.NewTouchpadNavigationIndicator()
140 if bw.touchpadNavIndicator == nil {
141 return
142 }
143 if widget := bw.touchpadNavIndicator.Widget(); widget != nil {
144 bw.mainWindow.AddNonMeasuringOverlay(widget)
145 }
146}
147
148func (bw *browserWindow) initBorderOverlay(a *App) {
149 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
150 return
151 }
152
153 bw.borderMgr = focus.NewBorderManager(a.widgetFactory)
154 if bw.borderMgr == nil {
155 return
156 }
157 if widget := bw.borderMgr.Widget(); widget != nil {
158 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
159 bw.mainWindow.AddOverlay(gtkWidget)
160 }
161 }
162}
163
164func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
165 log := logging.FromContext(ctx)
166 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil {
167 log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker")
168 return
169 }
170
171 bw.accentPicker = component.NewAccentPicker(a.widgetFactory)
172 if bw.accentPicker == nil {
173 log.Warn().Msg("failed to create accent picker")
174 return
175 }
176 if widget := bw.accentPicker.Widget(); widget != nil {
177 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
178 bw.mainWindow.AddOverlay(gtkWidget)
179 }
180 }
181
182 a.accentFocusProvider = a.deps.AccentFocusProvider
183 bw.insertAccentUC = usecase.NewInsertAccentUseCase(
184 a.accentFocusProvider,
185 bw.accentPicker,
186 func(fn func()) {
187 cb := glib.SourceFunc(func(_ uintptr) bool {
188 fn()
189 return false
190 })
191 glib.IdleAdd(&cb, 0)
192 },
193 )
194
195 log.Debug().Msg("accent picker initialized")
196}
197
198func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) {
199 log := logging.FromContext(ctx)
200 if bw == nil || a == nil || a.deps == nil || bw.mainWindow == nil {
201 log.Debug().Msg("deps or main window not available, skipping session manager")
202 return
203 }
204 runtimeCfg := a.runtimeConfigSnapshot().UI
205
206 var listSessionsUC *usecase.ListSessionsUseCase
207 var deleteSessionUC *usecase.DeleteSessionUseCase
208 if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
209 listSessionsUC = usecase.NewListSessionsUseCase(
210 a.deps.SessionRepo,
211 a.deps.SessionStateRepo,
212 )
213 deleteSessionUC = usecase.NewDeleteSessionUseCase(
214 a.deps.SessionStateRepo,
215 a.deps.SessionRepo,
216 )
217 }
218
219 bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
220 ListSessionsUC: listSessionsUC,
221 DeleteSessionUC: deleteSessionUC,
222 CurrentSession: a.deps.CurrentSessionID,
223 UIScale: runtimeCfg.DefaultUIScale,
224 OnClose: func() {
225 log.Debug().Msg("session manager closed")
226 },
227 OnOpen: func(sessionID entity.SessionID) {
228 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
229 if spawner := a.deps.SessionSpawner; spawner == nil {
230 log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
231 return
232 } else if err := spawner.SpawnWithSession(sessionID); err != nil {
233 log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
234 }
235 },
236 OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
237 if bw.appToaster != nil {
238 bw.appToaster.Show(ctx, message, level)
239 }
240 },
241 })
242
243 if bw.sessionManager == nil {
244 log.Warn().Msg("failed to create session manager")
245 return
246 }
247 if widget := bw.sessionManager.Widget(); widget != nil {
248 bw.mainWindow.AddOverlay(widget)
249 }
250 log.Debug().Msg("session manager initialized")
251}
252
253func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
254 log := logging.FromContext(ctx)
255 if bw == nil || a == nil || a.deps == nil {
256 log.Debug().Msg("deps/config not available, skipping tab picker")
257 return
258 }
259 runtimeCfg := a.runtimeConfigSnapshot().UI
260
261 bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
262 UIScale: runtimeCfg.DefaultUIScale,
263 OnClose: func() {
264 log.Debug().Msg("tab picker closed")
265 },
266 OnSelect: func(item component.TabPickerItem) {
267 cb := glib.SourceFunc(func(_ uintptr) bool {
268 var targetID entity.TabID
269 if !item.IsNew {
270 targetID = item.TabID
271 }
272 if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil {
273 log.Warn().Err(err).Msg("move pane to tab failed")
274 }
275 return false
276 })
277 glib.IdleAdd(&cb, 0)
278 },
279 })
280
281 if bw.tabPicker == nil {
282 log.Warn().Msg("failed to create tab picker")
283 return
284 }
285
286 log.Debug().Msg("tab picker initialized")
287}
288
289func (bw *browserWindow) ensureTabs() {
290 if bw != nil && bw.tabs == nil {
291 bw.tabs = entity.NewTabList()
292 }
293}
294
295func (a *App) hasBrowserWindow(target *browserWindow) bool {
296 if a == nil || target == nil || a.browserWindows == nil {
297 return false
298 }
299 bw, ok := a.browserWindows[target.id]
300 return ok && bw == target
301}
302
303func (a *App) registerBrowserWindow(bw *browserWindow) {
304 if bw == nil {
305 return
306 }
307 bw.ensureTabs()
308 if a.browserWindows == nil {
309 a.browserWindows = make(map[string]*browserWindow)
310 }
311 a.browserWindows[bw.id] = bw
312
313 // Track registration order, guarding against duplicates.
314 for _, id := range a.browserWindowOrder {
315 if id == bw.id {
316 return
317 }
318 }
319 a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
320}
321
322func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
323 if tab == nil {
324 return
325 }
326 if ctx == nil {
327 ctx = context.Background()
328 }
329
330 a.releaseFloatingSessionsForTab(ctx, tab.ID)
331 if a.contentCoord != nil && tab.Workspace != nil {
332 for _, pane := range tab.Workspace.AllPanes() {
333 if pane == nil {
334 continue
335 }
336 a.contentCoord.ReleaseWebView(ctx, pane.ID)
337 }
338 }
339 delete(a.workspaceViews, tab.ID)
340 delete(a.windowForTab, tab.ID)
341 if a.tabs != nil && a.tabs.Find(tab.ID) != nil {
342 a.tabs.Remove(tab.ID)
343 }
344}
345
346func (a *App) removeBrowserWindow(id string) {
347 if id == "" || a.browserWindows == nil {
348 return
349 }
350 removed := a.browserWindows[id]
351 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
352
353 a.removeBrowserWindowOrder(id)
354 a.releaseBrowserWindowTabs(context.Background(), id, removed)
355 if removed != nil {
356 removed.teardownForDestroy()
357 }
358 if a.contentCoord != nil {
359 a.contentCoord.ClearPopupNamedContextsForWindow(id)
360 }
361 delete(a.browserWindows, id)
362
363 fallback := a.deterministicBrowserWindowFallback()
364 a.updateLastFocusedWindowAfterRemoval(id, fallback)
365 if wasMainWindow {
366 a.clearMainBrowserWindowAfterRemoval(fallback)
367 }
368}
369
370func (a *App) removeBrowserWindowOrder(id string) {
371 for i, wid := range a.browserWindowOrder {
372 if wid == id {
373 a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
374 return
375 }
376 }
377}
378
379func (a *App) releaseBrowserWindowTabs(ctx context.Context, windowID string, bw *browserWindow) {
380 ownedTabs := a.collectBrowserWindowTabs(windowID, bw)
381 for _, tabID := range sortedTabIDs(ownedTabs) {
382 a.releaseTabWorkspace(ctx, ownedTabs[tabID])
383 }
384 a.cleanupStaleTabMappingsForWindow(windowID)
385}
386
387func (a *App) collectBrowserWindowTabs(windowID string, bw *browserWindow) map[entity.TabID]*entity.Tab {
388 ownedTabs := make(map[entity.TabID]*entity.Tab)
389 if bw != nil && bw.tabs != nil {
390 for _, tab := range bw.tabs.Tabs {
391 if tab != nil {
392 ownedTabs[tab.ID] = tab
393 }
394 }
395 }
396 for tabID, owner := range a.windowForTab {
397 if owner == nil || owner.id != windowID {
398 continue
399 }
400 if tab := tabFromBrowserWindow(owner, tabID); tab != nil {
401 ownedTabs[tabID] = tab
402 continue
403 }
404 if a.tabs != nil {
405 ownedTabs[tabID] = a.tabs.Find(tabID)
406 }
407 }
408 return ownedTabs
409}
410
411func tabFromBrowserWindow(bw *browserWindow, tabID entity.TabID) *entity.Tab {
412 if bw == nil || bw.tabs == nil {
413 return nil
414 }
415 return bw.tabs.Find(tabID)
416}
417
418func sortedTabIDs(tabs map[entity.TabID]*entity.Tab) []entity.TabID {
419 ids := make([]entity.TabID, 0, len(tabs))
420 for tabID := range tabs {
421 ids = append(ids, tabID)
422 }
423 sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
424 return ids
425}
426
427func (a *App) cleanupStaleTabMappingsForWindow(windowID string) {
428 for tabID, bw := range a.windowForTab {
429 if bw == nil || bw.id != windowID {
430 continue
431 }
432 delete(a.workspaceViews, tabID)
433 delete(a.windowForTab, tabID)
434 if a.tabs != nil && a.tabs.Find(tabID) != nil {
435 a.tabs.Remove(tabID)
436 }
437 }
438}
439
440func (a *App) updateLastFocusedWindowAfterRemoval(removedID string, fallback *browserWindow) {
441 if a.lastFocusedWindowID != removedID {
442 return
443 }
444 if fallback != nil {
445 a.lastFocusedWindowID = fallback.id
446 return
447 }
448 a.lastFocusedWindowID = ""
449}
450
451func (a *App) clearMainBrowserWindowAfterRemoval(fallback *browserWindow) {
452 a.clearResizeModeBorder()
453 if fallback != nil {
454 a.activateBrowserWindow(fallback)
455 return
456 }
457 a.lastFocusedWindowID = ""
458 a.mainWindow = nil
459 a.keyboardHandler = nil
460 a.globalShortcutHandler = nil
461 if a.tabCoord != nil {
462 a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
463 }
464}
465
466func (a *App) cleanupTransientBrowserWindowForDestroy(bw *browserWindow) {
467 if bw == nil {
468 return
469 }
470 bw.teardownForDestroy()
471 if a.contentCoord != nil {
472 a.contentCoord.ClearPopupNamedContextsForWindow(bw.id)
473 }
474 fallback := a.deterministicBrowserWindowFallback()
475 if a.lastFocusedWindowID == bw.id {
476 if fallback != nil {
477 a.lastFocusedWindowID = fallback.id
478 } else {
479 a.lastFocusedWindowID = ""
480 }
481 }
482 if bw.mainWindow == a.mainWindow {
483 a.clearResizeModeBorder()
484 if fallback != nil {
485 a.activateBrowserWindow(fallback)
486 return
487 }
488 a.lastFocusedWindowID = ""
489 a.mainWindow = nil
490 a.keyboardHandler = nil
491 a.globalShortcutHandler = nil
492 if a.tabCoord != nil {
493 a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
494 }
495 }
496}
497
498func (a *App) deterministicBrowserWindowFallback() *browserWindow {
499 if a.browserWindows == nil {
500 return nil
501 }
502 for _, id := range a.browserWindowOrder {
503 bw := a.browserWindows[id]
504 if id == "" || bw == nil || bw.mainWindow == nil {
505 continue
506 }
507 return bw
508 }
509 ids := make([]string, 0, len(a.browserWindows))
510 for id, bw := range a.browserWindows {
511 if id == "" || bw == nil || bw.mainWindow == nil {
512 continue
513 }
514 ids = append(ids, id)
515 }
516 sort.Strings(ids)
517 if len(ids) == 0 {
518 return nil
519 }
520 return a.browserWindows[ids[0]]
521}
522
523func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
524 log := logging.FromContext(ctx)
525 log.Debug().
526 Str("url_host", logging.SafeURLHost(url)).
527 Msg("ui: open fresh window dispatch requested")
528
529 dispatch := a.dispatchOnMainThread
530 if dispatch == nil {
531 dispatch = func(label string, fn func()) syncdispatch.SyncDispatchResult {
532 if fn != nil {
533 fn()
534 }
535 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchInline}
536 }
537 }
538
539 var openErr error
540 var windowCountBefore int
541 var windowCountAfter int
542 var hasTabCoord bool
543 var hasTabsUC bool
544 result := dispatch("ui.open_fresh_window", func() {
545 windowCountBefore = len(a.browserWindows)
546 hasTabCoord = a.tabCoord != nil
547 hasTabsUC = a.tabsUC != nil
548 log.Debug().
549 Str("url_host", logging.SafeURLHost(url)).
550 Int("window_count_before", windowCountBefore).
551 Bool("has_tab_coord", hasTabCoord).
552 Bool("has_tabs_uc", hasTabsUC).
553 Msg("ui: open fresh window main-thread work started")
554 openErr = a.openFreshWindow(ctx, url)
555 windowCountAfter = len(a.browserWindows)
556 })
557 if !result.Completed() {
558 log.Warn().
559 Str("url_host", logging.SafeURLHost(url)).
560 Dur("elapsed", result.Elapsed).
561 Str("dispatch_status", string(result.Status)).
562 Msg("ui: open fresh window skipped after main-thread dispatch did not complete")
563 return fmt.Errorf("main thread dispatch did not complete: %s", result.Status)
564 }
565 if openErr != nil {
566 log.Warn().Err(openErr).
567 Str("url_host", logging.SafeURLHost(url)).
568 Dur("elapsed", result.Elapsed).
569 Str("dispatch_status", string(result.Status)).
570 Int("window_count_after", windowCountAfter).
571 Msg("ui: open fresh window failed")
572 return openErr
573 }
574
575 log.Debug().
576 Str("url_host", logging.SafeURLHost(url)).
577 Dur("elapsed", result.Elapsed).
578 Str("dispatch_status", string(result.Status)).
579 Int("window_count_after", windowCountAfter).
580 Msg("ui: open fresh window completed")
581 return nil
582}