browser_window.go RawHistoryFolder

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.openExternalURLOnMainThread(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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close