1package ui
2
3import (
4 "context"
5 "fmt"
6 "slices"
7 "sort"
8
9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"
12 domainurl "github.com/bnema/dumber/internal/domain/url"
13 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/dumber/internal/shared/syncdispatch"
15 "github.com/bnema/dumber/internal/ui/component"
16 "github.com/bnema/dumber/internal/ui/coordinator"
17 "github.com/bnema/dumber/internal/ui/focus"
18 "github.com/bnema/dumber/internal/ui/input"
19 "github.com/bnema/dumber/internal/ui/layout"
20 "github.com/bnema/dumber/internal/ui/window"
21 "github.com/bnema/puregotk/v4/glib"
22)
23
24type nativeSidebarKind string
25
26const (
27 nativeSidebarNone nativeSidebarKind = ""
28 nativeSidebarHistory nativeSidebarKind = "history"
29 nativeSidebarFavorites nativeSidebarKind = "favorites"
30)
31
32type browserWindow struct {
33 id string
34 initialURL string
35 tabs *entity.TabList // per-window tab list (single source of truth for tab state)
36 mainWindow *window.MainWindow
37 appToaster *component.Toaster
38 modeToaster *component.Toaster
39 touchpadNavIndicator *component.TouchpadNavigationIndicator
40 borderMgr *focus.BorderManager
41 sessionManager *component.SessionManager
42 tabPicker *component.TabPicker
43 tabPickerWidget layout.Widget
44 tabPickerPaneID entity.PaneID
45 insertAccentUC *usecase.InsertAccentUseCase
46 accentPicker *component.AccentPicker
47 keyboardHandler *input.KeyboardHandler
48 globalShortcutHandler *input.GlobalShortcutHandler
49 permissionDialog port.PermissionDialogPresenter
50 webrtcIndicator *component.WebRTCPermissionIndicator
51 historySidebar *component.HistorySidebar
52 favoritesSidebar *component.FavoritesSidebar
53 historySidebarReloader historySidebarReloader
54 sidebarVisible bool
55 activeSidebarKind nativeSidebarKind
56
57 // vimModePaneID tracks this window's pane-local Vim Mode accent/pulse owner.
58 vimModePaneID entity.PaneID
59 // vimNavigationHighlightedWebViews holds every WebView with a semantic Vim
60 // navigation target that must be cleared when this window leaves Vim Mode.
61 vimNavigationHighlightedWebViews []port.WebView
62 vimNavigationHighlightedWebViewIDs map[port.WebViewID]struct{}
63}
64
65func (bw *browserWindow) detachInputForDestroy() {
66 if bw == nil {
67 return
68 }
69 if bw.keyboardHandler != nil {
70 bw.keyboardHandler.DetachForDestroy()
71 }
72 if bw.globalShortcutHandler != nil {
73 bw.globalShortcutHandler.DetachForDestroy()
74 }
75}
76
77func (bw *browserWindow) teardownForDestroy() {
78 if bw == nil {
79 return
80 }
81 bw.detachInputForDestroy()
82 bw.clearShellState()
83}
84
85func (bw *browserWindow) clearShellState() {
86 if bw == nil {
87 return
88 }
89 // Destroy the history sidebar before releasing the reference so its
90 // context, debounce timer, callbacks, and in-flight goroutines are
91 // cleaned up before the window itself is torn down.
92 if bw.historySidebar != nil {
93 bw.historySidebar.Destroy()
94 }
95 if bw.favoritesSidebar != nil {
96 bw.favoritesSidebar.Destroy()
97 }
98 if bw.touchpadNavIndicator != nil {
99 bw.touchpadNavIndicator.Destroy()
100 }
101 bw.appToaster = nil
102 bw.modeToaster = nil
103 bw.touchpadNavIndicator = nil
104 bw.borderMgr = nil
105 bw.sessionManager = nil
106 bw.tabPicker = nil
107 bw.tabPickerWidget = nil
108 bw.tabPickerPaneID = ""
109 bw.insertAccentUC = nil
110 bw.accentPicker = nil
111 bw.keyboardHandler = nil
112 bw.globalShortcutHandler = nil
113 bw.permissionDialog = nil
114 bw.webrtcIndicator = nil
115 bw.historySidebar = nil
116 bw.favoritesSidebar = nil
117 bw.historySidebarReloader = nil
118 bw.activeSidebarKind = nativeSidebarNone
119 bw.vimModePaneID = ""
120 bw.vimNavigationHighlightedWebViews = nil
121 bw.vimNavigationHighlightedWebViewIDs = nil
122}
123
124func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
125 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
126 return
127 }
128
129 // Hidden panels (history, favorites) are constructed on first request via
130 // ensureHistorySidebar/ensureFavoritesSidebar so window creation performs
131 // no hidden-panel queries or widget row rebuilds.
132 bw.initToasterOverlay(a)
133 bw.initTouchpadNavigationIndicator()
134 bw.initBorderOverlay(a)
135 bw.initAccentPicker(ctx, a)
136 bw.initSessionManager(ctx, a)
137 bw.initTabPicker(ctx, a)
138}
139
140func (bw *browserWindow) initToasterOverlay(a *App) {
141 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
142 return
143 }
144
145 bw.appToaster = component.NewToaster(a.widgetFactory)
146 if bw.appToaster != nil {
147 if widget := bw.appToaster.Widget(); widget != nil {
148 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
149 bw.mainWindow.AddOverlay(gtkWidget)
150 }
151 }
152 }
153
154 bw.modeToaster = component.NewToaster(a.widgetFactory)
155 if bw.modeToaster != nil {
156 if widget := bw.modeToaster.Widget(); widget != nil {
157 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
158 bw.mainWindow.AddOverlay(gtkWidget)
159 }
160 }
161 }
162}
163
164func (bw *browserWindow) initTouchpadNavigationIndicator() {
165 if bw == nil || bw.mainWindow == nil {
166 return
167 }
168 bw.touchpadNavIndicator = component.NewTouchpadNavigationIndicator()
169 if bw.touchpadNavIndicator == nil {
170 return
171 }
172 if widget := bw.touchpadNavIndicator.Widget(); widget != nil {
173 bw.mainWindow.AddNonMeasuringOverlay(widget)
174 }
175}
176
177func (bw *browserWindow) initBorderOverlay(a *App) {
178 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
179 return
180 }
181
182 bw.borderMgr = focus.NewBorderManager(a.widgetFactory)
183 if bw.borderMgr == nil {
184 return
185 }
186 if widget := bw.borderMgr.Widget(); widget != nil {
187 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
188 bw.mainWindow.AddOverlay(gtkWidget)
189 }
190 }
191}
192
193func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
194 log := logging.FromContext(ctx)
195 if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil {
196 log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker")
197 return
198 }
199
200 bw.accentPicker = component.NewAccentPicker(a.widgetFactory)
201 if bw.accentPicker == nil {
202 log.Warn().Msg("failed to create accent picker")
203 return
204 }
205 if widget := bw.accentPicker.Widget(); widget != nil {
206 if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
207 bw.mainWindow.AddOverlay(gtkWidget)
208 }
209 }
210
211 a.accentFocusProvider = a.deps.AccentFocusProvider
212 bw.insertAccentUC = usecase.NewInsertAccentUseCase(
213 a.accentFocusProvider,
214 bw.accentPicker,
215 func(fn func()) {
216 cb := glib.SourceFunc(func(_ uintptr) bool {
217 fn()
218 return false
219 })
220 glib.IdleAdd(&cb, 0)
221 },
222 )
223
224 log.Debug().Msg("accent picker initialized")
225}
226
227func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) {
228 log := logging.FromContext(ctx)
229 if bw == nil || a == nil || a.deps == nil || bw.mainWindow == nil {
230 log.Debug().Msg("deps or main window not available, skipping session manager")
231 return
232 }
233 runtimeCfg := a.runtimeConfigSnapshot().UI
234
235 var listSessionsUC *usecase.ListSessionsUseCase
236 var deleteSessionUC *usecase.DeleteSessionUseCase
237 if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
238 listSessionsUC = usecase.NewListSessionsUseCase(
239 a.deps.SessionRepo,
240 a.deps.SessionStateRepo,
241 )
242 deleteSessionUC = usecase.NewDeleteSessionUseCase(
243 a.deps.SessionStateRepo,
244 a.deps.SessionRepo,
245 )
246 }
247
248 bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
249 ListSessionsUC: listSessionsUC,
250 DeleteSessionUC: deleteSessionUC,
251 CurrentSession: a.deps.CurrentSessionID,
252 UIScale: runtimeCfg.DefaultUIScale,
253 OnClose: func() {
254 log.Debug().Msg("session manager closed")
255 },
256 OnOpen: func(sessionID entity.SessionID) {
257 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
258 if spawner := a.deps.SessionSpawner; spawner == nil {
259 log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
260 return
261 } else if err := spawner.SpawnWithSession(sessionID); err != nil {
262 log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
263 }
264 },
265 OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
266 if bw.appToaster != nil {
267 bw.appToaster.Show(ctx, message, level)
268 }
269 },
270 })
271
272 if bw.sessionManager == nil {
273 log.Warn().Msg("failed to create session manager")
274 return
275 }
276 if widget := bw.sessionManager.Widget(); widget != nil {
277 bw.mainWindow.AddOverlay(widget)
278 }
279 log.Debug().Msg("session manager initialized")
280}
281
282func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
283 log := logging.FromContext(ctx)
284 if bw == nil || a == nil || a.deps == nil {
285 log.Debug().Msg("deps/config not available, skipping tab picker")
286 return
287 }
288 runtimeCfg := a.runtimeConfigSnapshot().UI
289
290 bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
291 UIScale: runtimeCfg.DefaultUIScale,
292 OnClose: func() {
293 log.Debug().Msg("tab picker closed")
294 },
295 OnSelect: func(item component.TabPickerItem) {
296 cb := glib.SourceFunc(func(_ uintptr) bool {
297 var targetID entity.TabID
298 if !item.IsNew {
299 targetID = item.TabID
300 }
301 if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil {
302 log.Warn().Err(err).Msg("move pane to tab failed")
303 }
304 return false
305 })
306 glib.IdleAdd(&cb, 0)
307 },
308 })
309
310 if bw.tabPicker == nil {
311 log.Warn().Msg("failed to create tab picker")
312 return
313 }
314
315 log.Debug().Msg("tab picker initialized")
316}
317
318func (bw *browserWindow) ensureTabs() {
319 if bw != nil && bw.tabs == nil {
320 bw.tabs = entity.NewTabList()
321 }
322}
323
324func (a *App) hasBrowserWindow(target *browserWindow) bool {
325 if a == nil || target == nil || a.browserWindows == nil {
326 return false
327 }
328 bw, ok := a.browserWindows[target.id]
329 return ok && bw == target
330}
331
332func (a *App) registerBrowserWindow(bw *browserWindow) {
333 if bw == nil {
334 return
335 }
336 bw.ensureTabs()
337 if a.browserWindows == nil {
338 a.browserWindows = make(map[string]*browserWindow)
339 }
340 a.browserWindows[bw.id] = bw
341
342 // Track registration order, guarding against duplicates.
343 if slices.Contains(a.browserWindowOrder, bw.id) {
344 return
345 }
346 a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
347 a.residencyNoteWindowsChanged()
348}
349
350func (a *App) releaseTabWorkspace(ctx context.Context, tab *entity.Tab) {
351 if tab == nil {
352 return
353 }
354 if ctx == nil {
355 ctx = context.Background()
356 }
357
358 a.releaseFloatingSessionsForTab(ctx, tab.ID)
359 if a.contentCoord != nil && tab.Workspace != nil {
360 for _, pane := range tab.Workspace.AllPanes() {
361 if pane == nil {
362 continue
363 }
364 a.contentCoord.ReleaseWebView(ctx, pane.ID)
365 }
366 }
367 delete(a.workspaceViews, tab.ID)
368 delete(a.windowForTab, tab.ID)
369 if a.tabs != nil && a.tabs.Find(tab.ID) != nil {
370 a.tabs.Remove(tab.ID)
371 }
372}
373
374func (a *App) removeBrowserWindow(id string) {
375 if id == "" || a.browserWindows == nil {
376 return
377 }
378 removed := a.browserWindows[id]
379 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
380
381 a.removeBrowserWindowOrder(id)
382 a.releaseNativePopupsForBrowserWindow(id)
383 a.releaseBrowserWindowTabs(context.Background(), id, removed)
384 if removed != nil {
385 removed.teardownForDestroy()
386 }
387 if a.contentCoord != nil {
388 a.contentCoord.ClearPopupNamedContextsForWindow(id)
389 }
390 delete(a.browserWindows, id)
391
392 fallback := a.deterministicBrowserWindowFallback()
393 a.updateLastFocusedWindowAfterRemoval(id, fallback)
394 if wasMainWindow {
395 a.clearMainBrowserWindowAfterRemoval(fallback)
396 }
397 a.residencyAfterWindowRemoved()
398}
399
400func (a *App) releaseNativePopupsForBrowserWindow(windowID string) {
401 for popupID, popup := range a.nativePopupWindows {
402 if popup != nil && popup.parentWindowID == windowID {
403 a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
404 }
405 }
406}
407
408func (a *App) removeBrowserWindowOrder(id string) {
409 for i, wid := range a.browserWindowOrder {
410 if wid == id {
411 a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
412 return
413 }
414 }
415}
416
417func (a *App) releaseBrowserWindowTabs(ctx context.Context, windowID string, bw *browserWindow) {
418 ownedTabs := a.collectBrowserWindowTabs(windowID, bw)
419 for _, tabID := range sortedTabIDs(ownedTabs) {
420 a.releaseTabWorkspace(ctx, ownedTabs[tabID])
421 }
422 a.cleanupStaleTabMappingsForWindow(windowID)
423}
424
425func (a *App) collectBrowserWindowTabs(windowID string, bw *browserWindow) map[entity.TabID]*entity.Tab {
426 ownedTabs := make(map[entity.TabID]*entity.Tab)
427 if bw != nil && bw.tabs != nil {
428 for _, tab := range bw.tabs.Tabs {
429 if tab != nil {
430 ownedTabs[tab.ID] = tab
431 }
432 }
433 }
434 for tabID, owner := range a.windowForTab {
435 if owner == nil || owner.id != windowID {
436 continue
437 }
438 if tab := tabFromBrowserWindow(owner, tabID); tab != nil {
439 ownedTabs[tabID] = tab
440 continue
441 }
442 if a.tabs != nil {
443 ownedTabs[tabID] = a.tabs.Find(tabID)
444 }
445 }
446 return ownedTabs
447}
448
449func tabFromBrowserWindow(bw *browserWindow, tabID entity.TabID) *entity.Tab {
450 if bw == nil || bw.tabs == nil {
451 return nil
452 }
453 return bw.tabs.Find(tabID)
454}
455
456func sortedTabIDs(tabs map[entity.TabID]*entity.Tab) []entity.TabID {
457 ids := make([]entity.TabID, 0, len(tabs))
458 for tabID := range tabs {
459 ids = append(ids, tabID)
460 }
461 slices.Sort(ids)
462 return ids
463}
464
465func (a *App) cleanupStaleTabMappingsForWindow(windowID string) {
466 for tabID, bw := range a.windowForTab {
467 if bw == nil || bw.id != windowID {
468 continue
469 }
470 delete(a.workspaceViews, tabID)
471 delete(a.windowForTab, tabID)
472 if a.tabs != nil && a.tabs.Find(tabID) != nil {
473 a.tabs.Remove(tabID)
474 }
475 }
476}
477
478func (a *App) updateLastFocusedWindowAfterRemoval(removedID string, fallback *browserWindow) {
479 if a.lastFocusedWindowID != removedID {
480 return
481 }
482 if fallback != nil {
483 a.lastFocusedWindowID = fallback.id
484 return
485 }
486 a.lastFocusedWindowID = ""
487}
488
489func (a *App) clearMainBrowserWindowAfterRemoval(fallback *browserWindow) {
490 a.clearResizeModeBorder()
491 if fallback != nil {
492 a.activateBrowserWindow(fallback)
493 return
494 }
495 a.lastFocusedWindowID = ""
496 a.mainWindow = nil
497 a.keyboardHandler = nil
498 a.globalShortcutHandler = nil
499 if a.tabCoord != nil {
500 a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
501 }
502}
503
504func (a *App) cleanupTransientBrowserWindowForDestroy(bw *browserWindow) {
505 if bw == nil {
506 return
507 }
508 bw.teardownForDestroy()
509 if a.contentCoord != nil {
510 a.contentCoord.ClearPopupNamedContextsForWindow(bw.id)
511 }
512 fallback := a.deterministicBrowserWindowFallback()
513 if a.lastFocusedWindowID == bw.id {
514 if fallback != nil {
515 a.lastFocusedWindowID = fallback.id
516 } else {
517 a.lastFocusedWindowID = ""
518 }
519 }
520 if bw.mainWindow == a.mainWindow {
521 a.clearResizeModeBorder()
522 if fallback != nil {
523 a.activateBrowserWindow(fallback)
524 return
525 }
526 a.lastFocusedWindowID = ""
527 a.mainWindow = nil
528 a.keyboardHandler = nil
529 a.globalShortcutHandler = nil
530 if a.tabCoord != nil {
531 a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
532 }
533 }
534}
535
536func (a *App) deterministicBrowserWindowFallback() *browserWindow {
537 if a.browserWindows == nil {
538 return nil
539 }
540 for _, id := range a.browserWindowOrder {
541 bw := a.browserWindows[id]
542 if id == "" || bw == nil || bw.mainWindow == nil {
543 continue
544 }
545 return bw
546 }
547 ids := make([]string, 0, len(a.browserWindows))
548 for id, bw := range a.browserWindows {
549 if id == "" || bw == nil || bw.mainWindow == nil {
550 continue
551 }
552 ids = append(ids, id)
553 }
554 sort.Strings(ids)
555 if len(ids) == 0 {
556 return nil
557 }
558 return a.browserWindows[ids[0]]
559}
560
561func (a *App) dispatchWindowURLWork(ctx context.Context, label, url string, work func(context.Context) error) error {
562 log := logging.FromContext(ctx)
563 log.Debug().Str("url_host", logging.SafeURLHost(url)).Str("dispatch_label", label).Msg("ui: window URL dispatch requested")
564
565 dispatch := a.dispatchOnMainThread
566 if dispatch == nil {
567 dispatch = func(label string, fn func()) syncdispatch.SyncDispatchResult {
568 if fn != nil {
569 fn()
570 }
571 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchInline}
572 }
573 }
574
575 var workErr error
576 var windowCountBefore, windowCountAfter int
577 var hasTabCoord, hasTabsUC bool
578 result := dispatch(label, func() {
579 windowCountBefore = len(a.browserWindows)
580 hasTabCoord = a.tabCoord != nil
581 hasTabsUC = a.tabsUC != nil
582 log.Debug().
583 Str("url_host", logging.SafeURLHost(url)).
584 Str("dispatch_label", label).
585 Int("window_count_before", windowCountBefore).
586 Bool("has_tab_coord", hasTabCoord).
587 Bool("has_tabs_uc", hasTabsUC).
588 Msg("ui: window URL main-thread work started")
589 workErr = work(ctx)
590 windowCountAfter = len(a.browserWindows)
591 })
592 if !result.Completed() {
593 log.Warn().
594 Str("url_host", logging.SafeURLHost(url)).
595 Str("dispatch_label", label).
596 Dur("elapsed", result.Elapsed).
597 Str("dispatch_status", string(result.Status)).
598 Msg("ui: window URL skipped after main-thread dispatch did not complete")
599 return fmt.Errorf("main thread dispatch did not complete: %s", result.Status)
600 }
601 if workErr != nil {
602 log.Warn().Err(workErr).
603 Str("url_host", logging.SafeURLHost(url)).
604 Str("dispatch_label", label).
605 Dur("elapsed", result.Elapsed).
606 Str("dispatch_status", string(result.Status)).
607 Int("window_count_after", windowCountAfter).
608 Msg("ui: window URL operation failed")
609 return workErr
610 }
611 log.Debug().
612 Str("url_host", logging.SafeURLHost(url)).
613 Str("dispatch_label", label).
614 Dur("elapsed", result.Elapsed).
615 Str("dispatch_status", string(result.Status)).
616 Int("window_count_after", windowCountAfter).
617 Msg("ui: window URL operation completed")
618 return nil
619}
620
621func (a *App) OpenExternalURL(ctx context.Context, url string) error {
622 url = domainurl.Normalize(url)
623 return a.dispatchWindowURLWork(ctx, "ui.open_external_url", url, func(ctx context.Context) error {
624 return a.openExternalURLOnMainThread(ctx, url, a.runtimeConfigSnapshot())
625 })
626}
627
628func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
629 url = domainurl.Normalize(url)
630 return a.dispatchWindowURLWork(ctx, "ui.open_fresh_window", url, func(ctx context.Context) error {
631 return a.openFreshWindow(ctx, url)
632 })
633}
634
635func (a *App) openExternalURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error {
636 externalLinks := cfg.UI.Workspace.ExternalLinks
637 if externalLinks.Behavior == entity.ExternalLinkBehaviorWindowed || externalLinks.Behavior == "" {
638 return a.openFreshWindow(ctx, url)
639 }
640
641 err := a.openExternalURLInFocusedWindow(ctx, url, externalLinks)
642 if err == nil {
643 return nil
644 }
645 logging.FromContext(ctx).Warn().
646 Err(err).
647 Str("url_host", logging.SafeURLHost(url)).
648 Str("behavior", string(externalLinks.Behavior)).
649 Msg("ui: external URL placement failed; opening fresh window")
650 return a.openFreshWindow(ctx, url)
651}
652
653func (a *App) openExternalURLInFocusedWindow(ctx context.Context, url string, cfg entity.ExternalLinksConfig) error {
654 target, activeTab, err := a.externalURLTarget()
655 if err != nil {
656 return err
657 }
658
659 switch cfg.Behavior {
660 case entity.ExternalLinkBehaviorTabbed:
661 if err := a.createExternalURLTab(ctx, target, url); err != nil {
662 return err
663 }
664 case entity.ExternalLinkBehaviorSplit:
665 if err := a.placeExternalURLInPane(ctx, activeTab.Workspace, url, func(ctx context.Context) error {
666 return a.wsCoord.SplitWithURLWithoutOmnibox(ctx, externalLinkSplitDirection(cfg.Placement), url)
667 }); err != nil {
668 return err
669 }
670 case entity.ExternalLinkBehaviorStacked:
671 if err := a.placeExternalURLInPane(ctx, activeTab.Workspace, url, func(ctx context.Context) error {
672 return a.wsCoord.StackPaneWithURLWithoutOmnibox(ctx, url)
673 }); err != nil {
674 return err
675 }
676 default:
677 return fmt.Errorf("unsupported external URL behavior %q", cfg.Behavior)
678 }
679
680 if target.mainWindow != nil {
681 target.mainWindow.Show()
682 }
683 a.activateBrowserWindow(target)
684 return nil
685}
686
687func (a *App) externalURLTarget() (*browserWindow, *entity.Tab, error) {
688 target := a.lastFocusedBrowserWindow()
689 if target == nil || target.id == "" || a.browserWindows[target.id] != target {
690 return nil, nil, fmt.Errorf("last-focused browser window is missing or stale")
691 }
692 activeTab := a.activeTabForBrowserWindow(target)
693 if activeTab == nil || activeTab.Workspace == nil || activeTab.Workspace.ActivePane() == nil {
694 return nil, nil, fmt.Errorf("last-focused browser window has no active tab and pane")
695 }
696 return target, activeTab, nil
697}
698
699func (a *App) createExternalURLTab(ctx context.Context, target *browserWindow, url string) error {
700 if a.tabCoord == nil {
701 return fmt.Errorf("tab coordinator not available")
702 }
703 created, err := a.tabCoord.Create(ctx, a.tabTargetForBrowserWindow(target), url)
704 if err != nil {
705 a.rollbackExternalTabCreation(ctx, target, created)
706 return err
707 }
708 if created == nil || created.Workspace == nil || created.Workspace.ActivePane() == nil ||
709 created.Workspace.ActivePane().Pane == nil || created.Workspace.ActivePane().Pane.URI != url ||
710 a.activeTabForBrowserWindow(target) != created || a.workspaceViews[created.ID] == nil {
711 a.rollbackExternalTabCreation(ctx, target, created)
712 return fmt.Errorf("created tab did not become an active UI target")
713 }
714 return nil
715}
716
717func (a *App) placeExternalURLInPane(ctx context.Context, ws *entity.Workspace, url string, place func(context.Context) error) error {
718 if a.wsCoord == nil {
719 return fmt.Errorf("workspace coordinator not available")
720 }
721 before := ws.ActivePaneID
722 if err := place(ctx); err != nil {
723 a.rollbackExternalPaneCreation(ctx, ws, before)
724 return err
725 }
726 if err := verifyExternalPaneCreation(ws, before, url); err != nil {
727 a.rollbackExternalPaneCreation(ctx, ws, before)
728 return err
729 }
730 return nil
731}
732
733func (a *App) rollbackExternalPaneCreation(ctx context.Context, ws *entity.Workspace, previous entity.PaneID) {
734 if a.panesUC == nil {
735 logging.FromContext(ctx).Warn().Msg("ui: cannot roll back external URL pane; panes use case is unavailable")
736 return
737 }
738 if ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
739 return
740 }
741 created := ws.ActivePane()
742 if created == nil || created.Pane == nil {
743 return
744 }
745 createdID := created.Pane.ID
746 if _, err := a.panesUC.Close(ctx, ws, created); err != nil {
747 logging.FromContext(ctx).Warn().Err(err).Msg("ui: failed to roll back external URL pane")
748 return
749 }
750 ws.ActivePaneID = previous
751 if a.contentCoord != nil {
752 a.contentCoord.ReleaseWebView(ctx, createdID)
753 }
754 for tabID, view := range a.workspaceViews {
755 owner := a.browserWindowForTab(tabID)
756 if owner == nil || owner.tabs == nil || view == nil {
757 continue
758 }
759 tab := owner.tabs.Find(tabID)
760 if tab == nil || tab.Workspace == nil || tab.Workspace.ID != ws.ID {
761 continue
762 }
763 if err := view.Rebuild(ctx); err != nil {
764 logging.FromContext(ctx).Warn().Err(err).Msg("ui: failed to rebuild after external URL pane rollback")
765 }
766 break
767 }
768}
769
770func (a *App) rollbackExternalTabCreation(ctx context.Context, target *browserWindow, created *entity.Tab) {
771 if target == nil || created == nil {
772 return
773 }
774 if tabs := a.tabListForBrowserWindow(target); tabs != nil {
775 tabs.Remove(created.ID)
776 }
777 if target.mainWindow != nil && target.mainWindow.TabBar() != nil {
778 target.mainWindow.TabBar().RemoveTab(created.ID)
779 }
780 a.releaseTabWorkspace(ctx, created)
781}
782
783func externalLinkSplitDirection(placement entity.ExternalLinkPlacement) usecase.SplitDirection {
784 switch placement {
785 case entity.ExternalLinkPlacementLeft:
786 return usecase.SplitLeft
787 case entity.ExternalLinkPlacementTop:
788 return usecase.SplitUp
789 case entity.ExternalLinkPlacementBottom:
790 return usecase.SplitDown
791 default:
792 return usecase.SplitRight
793 }
794}
795
796func verifyExternalPaneCreation(ws *entity.Workspace, previous entity.PaneID, url string) error {
797 if ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
798 return fmt.Errorf("external URL pane was not activated")
799 }
800 active := ws.ActivePane()
801 if active == nil || active.Pane == nil || domainurl.Normalize(active.Pane.URI) != domainurl.Normalize(url) {
802 return fmt.Errorf("external URL pane was not created with the requested URL")
803 }
804 return nil
805}