browser_window.go RawHistoryFolder

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}

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