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