v0.32.0 0617278

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

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