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