v0.31.0 bf0756b

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	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}

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