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