main 5041353

floating_pane.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"unsafe"
8
9	"github.com/bnema/dumber/internal/application/port"
10	"github.com/bnema/dumber/internal/domain/entity"
11	"github.com/bnema/dumber/internal/ui/component"
12	"github.com/bnema/dumber/internal/ui/layout"
13	"github.com/bnema/puregotk/v4/gtk"
14)
15
16type floatingWorkspaceSession struct {
17	paneID              entity.PaneID
18	pane                *component.FloatingPane
19	paneView            *component.PaneView
20	webView             port.WebView
21	overlay             layout.OverlayWidget
22	widget              layout.Widget
23	focusWidget         layout.Widget
24	omnibox             *component.Omnibox
25	omniboxWidget       layout.Widget
26	resizeWatcherActive bool
27	// resizeTickCallback keeps the purego callback live until its GTK source is
28	// removed. It is cleared exactly once with resizeTickID.
29	resizeTickCallback *gtk.TickCallback
30	resizeTickID       uint
31	appliedWidth       int
32	appliedHeight      int
33}
34
35type floatingSessionKey struct {
36	tabID     entity.TabID
37	sessionID string
38}
39
40func normalizeFloatingSessionID(sessionID string) string {
41	sessionID = strings.TrimSpace(sessionID)
42	if sessionID == "" {
43		return floatingSessionIDDefault
44	}
45	return sessionID
46}
47
48func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
49	return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
50}
51
52func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
53	return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
54}
55
56func decorateFloatingOverlay(overlay layout.OverlayWidget) {
57	if overlay == nil {
58		return
59	}
60
61	overlay.SetHexpand(false)
62	overlay.SetVexpand(false)
63	overlay.AddCssClass("floating-pane-container")
64	overlay.AddCssClass("pane-border")
65	overlay.AddCssClass("pane-active")
66}
67
68// showFloatingWidget makes a floating pane widget interactive and visible.
69// Visibility is controlled by CSS class to animate opacity while keeping the
70// widget mapped so WebKit can redraw without a hard pop.
71func showFloatingWidget(w layout.Widget) {
72	if w == nil {
73		return
74	}
75	w.AddCssClass(floatingPaneVisibleClass)
76	w.SetCanTarget(true)
77	w.SetCanFocus(true)
78}
79
80// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
81// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
82// widget mapped and WebKit retains its rendered surface in GPU memory.
83func hideFloatingWidget(w layout.Widget) {
84	if w == nil {
85		return
86	}
87	w.RemoveCssClass(floatingPaneVisibleClass)
88	w.SetCanTarget(false)
89	w.SetCanFocus(false)
90}
91
92// setFloatingWidgetShown applies show or hide based on the visible flag.
93func setFloatingWidgetShown(w layout.Widget, visible bool) {
94	if visible {
95		showFloatingWidget(w)
96	} else {
97		hideFloatingWidget(w)
98	}
99}
100
101func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
102	if workspaceOverlay == nil || floatingOverlay == nil {
103		return
104	}
105
106	workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
107	workspaceOverlay.SetClipOverlay(floatingOverlay, false)
108}
109
110func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
111	if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
112		return 0, 0, 0, 0, false
113	}
114
115	width = desiredWidth
116	height = desiredHeight
117	if width > overlayWidth {
118		width = overlayWidth
119	}
120	if height > overlayHeight {
121		height = overlayHeight
122	}
123
124	x = (overlayWidth - width) / 2
125	y = (overlayHeight - height) / 2
126	if x < 0 {
127		x = 0
128	}
129	if y < 0 {
130		y = 0
131	}
132
133	return x, y, width, height, true
134}
135
136type gtkAllocationLayout [4]int32
137
138const gtkAllocationLayoutSize = 16
139
140var (
141	_ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
142	_ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
143)
144
145// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
146// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
147// 32-bit signed integers {x, y, width, height} in native endianness.
148// If GTK struct layout changes, revisit offsets and writes below.
149//
150//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
151func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
152	if allocationPtr == nil {
153		return false
154	}
155
156	const (
157		gtkAllocationOffsetX      = uintptr(0)
158		gtkAllocationOffsetY      = uintptr(4)
159		gtkAllocationOffsetWidth  = uintptr(8)
160		gtkAllocationOffsetHeight = uintptr(12)
161	)
162
163	// GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
164	base := unsafe.Pointer(allocationPtr)
165	*(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetX)) = int32(x)
166	*(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetY)) = int32(y)
167	*(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetWidth)) = int32(width)
168	*(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetHeight)) = int32(height)
169	return true
170}
171
172func (a *App) floatingAllocationForWidget(
173	tabID entity.TabID,
174	widgetPtr uintptr,
175	overlayWidth, overlayHeight int,
176) (x, y, width, height int, ok bool) {
177	for _, session := range a.floatingSessions.forTab(tabID) {
178		if session.widget == nil {
179			continue
180		}
181
182		gtkWidget := session.widget.GtkWidget()
183		if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
184			continue
185		}
186
187		desiredWidth := session.appliedWidth
188		desiredHeight := session.appliedHeight
189		if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
190			session.pane.Resize()
191			desiredWidth, desiredHeight = session.pane.Dimensions()
192		}
193
194		return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
195	}
196
197	return 0, 0, 0, 0, false
198}
199
200func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
201	if workspaceOverlay == nil {
202		return
203	}
204
205	workspaceWidget := workspaceOverlay.GtkWidget()
206	if workspaceWidget == nil {
207		return
208	}
209
210	gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
211	if gtkOverlay == nil {
212		return
213	}
214
215	cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
216		widget := gtk.WidgetNewFromInternalPtr(widgetPtr)
217		if widget != nil {
218			widgetPtr = widget.GoPointer()
219		}
220		overlayWidth := workspaceOverlay.GetAllocatedWidth()
221		overlayHeight := workspaceOverlay.GetAllocatedHeight()
222		x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
223		if !ok {
224			return false
225		}
226		return writeOverlayAllocation(allocationPtr, x, y, width, height)
227	}
228
229	gtkOverlay.ConnectGetChildPosition(&cb)
230}
231
232// currentFloatingWidthPct returns the configured floating pane width percentage.
233func (a *App) currentFloatingWidthPct() float64 {
234	return a.runtimeConfigSnapshot().UI.Workspace.FloatingPane.WidthPct
235}
236
237// currentFloatingHeightPct returns the configured floating pane height percentage.
238func (a *App) currentFloatingHeightPct() float64 {
239	return a.runtimeConfigSnapshot().UI.Workspace.FloatingPane.HeightPct
240}
241
242func (a *App) ensureFloatingSession(
243	ctx context.Context,
244	tabID entity.TabID,
245	sessionID string,
246	wsView *component.WorkspaceView,
247) (*floatingWorkspaceSession, error) {
248	key := floatingSessionMapKey(tabID, sessionID)
249	if session, ok := a.floatingSessions[key]; ok && session != nil {
250		if wsView == nil {
251			return session, nil
252		}
253		session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
254		if session.widget != nil {
255			wsView.AddWorkspaceOverlayWidget(session.widget)
256			configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
257		}
258		if session.paneView != nil {
259			wsView.RegisterPaneView(session.paneID, session.paneView)
260			if session.focusWidget == nil {
261				session.focusWidget = session.paneView.WebViewWidget()
262			}
263		}
264		return session, nil
265	}
266
267	if wsView == nil {
268		return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
269	}
270	if a.contentCoord == nil {
271		return nil, fmt.Errorf("content coordinator not initialized")
272	}
273	if a.widgetFactory == nil {
274		return nil, fmt.Errorf("widget factory not initialized")
275	}
276
277	paneID := floatingPaneIDForSession(tabID, sessionID)
278	wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
279	if err != nil {
280		return nil, fmt.Errorf("ensure floating webview: %w", err)
281	}
282
283	webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
284	if webViewWidget == nil {
285		return nil, fmt.Errorf("wrap floating webview widget")
286	}
287	webViewWidget.SetHexpand(true)
288	webViewWidget.SetVexpand(true)
289
290	// Wrap the WebView in a PaneView so it gets progress bar, loading
291	// skeleton, and link status overlays — the same widget hierarchy as
292	// regular workspace panes. This allows the content coordinator's
293	// existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
294	// and update the floating pane automatically.
295	pv := component.NewPaneView(ctx, a.widgetFactory, paneID, webViewWidget)
296	pv.SetActive(true)
297	// Hide loading skeleton — floating panes have their own themed container.
298	pv.HideLoadingSkeleton()
299	wsView.RegisterPaneView(paneID, pv)
300
301	// Use the PaneView's overlay directly as the floating overlay.
302	// This avoids nesting two overlays which causes GTK4 allocation
303	// issues (inner overlay expanding to fill the parent's allocation).
304	pvOverlay := pv.Overlay()
305	decorateFloatingOverlay(pvOverlay)
306	pvOverlay.SetHalign(gtk.AlignCenterValue)
307	pvOverlay.SetValign(gtk.AlignCenterValue)
308	hideFloatingWidget(pvOverlay)
309	wsView.AddWorkspaceOverlayWidget(pvOverlay)
310	configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
311
312	floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
313		WidthPct:       a.currentFloatingWidthPct(),
314		HeightPct:      a.currentFloatingHeightPct(),
315		FallbackWidth:  floatingPaneFallbackWidth,
316		FallbackHeight: floatingPaneFallbackHeight,
317		OnNavigate: func(navCtx context.Context, url string) error {
318			return wv.LoadURI(navCtx, url)
319		},
320	})
321
322	session := &floatingWorkspaceSession{
323		paneID:      paneID,
324		pane:        floatingPane,
325		paneView:    pv,
326		webView:     wv,
327		overlay:     pvOverlay,
328		widget:      pvOverlay,
329		focusWidget: webViewWidget,
330	}
331	a.floatingSessions[key] = session
332	return session, nil
333}
334
335func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
336	// Snapshot: releaseFloatingSession deletes from the registry.
337	sessions := a.floatingSessions.forTabSnapshot(tabID)
338	if len(sessions) == 0 {
339		return
340	}
341
342	for key, session := range sessions {
343		a.releaseFloatingSession(ctx, key, session)
344	}
345	a.syncFloatingFocus()
346}
347
348func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
349	activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
350	if activeTab == nil {
351		return floatingSessionKey{}, nil, false
352	}
353	return a.floatingSessions.visibleForTab(activeTab.ID)
354}
355
356func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
357	key, session, ok := a.activeFloatingSessionEntry()
358	if !ok {
359		return nil, key.tabID
360	}
361	return session, key.tabID
362}
363
364func (a *App) releaseFloatingSession(ctx context.Context, key floatingSessionKey, session *floatingWorkspaceSession) {
365	if session == nil {
366		return
367	}
368
369	a.stopFloatingResizeWatcher(session)
370	if session.pane != nil {
371		session.pane.Hide(ctx)
372	}
373	a.hideFloatingOmnibox(ctx, session)
374	if wsView := a.workspaceViews[key.tabID]; wsView != nil {
375		if session.widget != nil {
376			wsView.RemoveWorkspaceOverlayWidget(session.widget)
377		}
378		if session.paneID != "" {
379			wsView.UnregisterPaneView(session.paneID)
380		}
381	}
382	if a.contentCoord != nil && session.paneID != "" {
383		a.contentCoord.ReleaseWebView(ctx, session.paneID)
384	}
385	delete(a.floatingSessions, key)
386
387	session.appliedWidth = 0
388	session.appliedHeight = 0
389	session.focusWidget = nil
390	session.paneView = nil
391	session.widget = nil
392	session.overlay = nil
393	session.webView = nil
394	session.pane = nil
395	// Session is fully released/zeroed; caller must drop this reference and not reuse it.
396}
397
398func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
399	if session := a.floatingSessionByPaneID(paneID); session != nil && session.pane != nil {
400		session.pane.RecordLoadedURL(url)
401	}
402}
403
404func (a *App) syncFloatingFocus() {
405	for _, wsView := range a.workspaceViews {
406		if wsView != nil {
407			wsView.SetHoverFocusLocked(false)
408		}
409	}
410
411	if a.contentCoord == nil {
412		return
413	}
414
415	activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
416	if activeTab == nil {
417		a.contentCoord.ClearActivePaneOverride()
418		return
419	}
420
421	if session, _ := a.activeFloatingSession(); session != nil {
422		if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
423			wsView.SetHoverFocusLocked(true)
424		}
425		a.startFloatingResizeWatcher(session)
426		a.contentCoord.SetActivePaneOverride(session.paneID)
427		return
428	}
429
430	a.contentCoord.ClearActivePaneOverride()
431}
432
433func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
434	if session == nil || session.overlay == nil || a.widgetFactory == nil {
435		return
436	}
437
438	if session.omnibox == nil {
439		cfg := a.omniboxCfg
440		cfg.OnNavigate = func(navCtx context.Context, url string) error {
441			if session.pane == nil {
442				return fmt.Errorf("floating pane is not available")
443			}
444			if navCtx == nil {
445				navCtx = ctx
446			}
447			return session.pane.Navigate(navCtx, url)
448		}
449		cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
450			a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
451		}
452
453		omnibox := component.NewOmnibox(ctx, cfg)
454		if omnibox == nil {
455			return
456		}
457
458		omnibox.SetParentOverlay(session.overlay)
459		omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
460		if omniboxWidget == nil {
461			return
462		}
463
464		session.overlay.AddOverlay(omniboxWidget)
465		session.overlay.SetClipOverlay(omniboxWidget, false)
466		session.overlay.SetMeasureOverlay(omniboxWidget, false)
467
468		session.omnibox = omnibox
469		session.omniboxWidget = omniboxWidget
470	}
471
472	session.pane.SetOmniboxVisible(true)
473	session.omnibox.Show(ctx, "")
474}
475
476func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
477	if session == nil || session.pane == nil {
478		return
479	}
480
481	session.pane.SetOmniboxVisible(false)
482	if session.omnibox == nil {
483		return
484	}
485
486	session.omnibox.Hide(ctx)
487	if session.overlay != nil && session.omniboxWidget != nil {
488		if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
489			session.overlay.RemoveOverlay(session.omniboxWidget)
490		} else if parent != nil {
491			session.omniboxWidget.Unparent()
492		}
493	}
494	session.omnibox = nil
495	session.omniboxWidget = nil
496}
497
498func floatingFocusWidget(session *floatingWorkspaceSession) layout.Widget {
499	if session == nil {
500		return nil
501	}
502	if session.focusWidget != nil {
503		return session.focusWidget
504	}
505	if session.paneView != nil {
506		return session.paneView.WebViewWidget()
507	}
508	return nil
509}
510
511func focusFloatingSessionContent(session *floatingWorkspaceSession) {
512	if session == nil || session.pane == nil {
513		return
514	}
515	if !session.pane.IsVisible() || session.pane.IsOmniboxVisible() {
516		return
517	}
518	w := floatingFocusWidget(session)
519	if w == nil {
520		return
521	}
522	w.GrabFocus()
523}
524
525func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
526	if session == nil || session.pane == nil {
527		return
528	}
529	session.pane.Resize()
530	if session.widget == nil {
531		return
532	}
533	width, height := session.pane.Dimensions()
534	if session.appliedWidth == width && session.appliedHeight == height {
535		return
536	}
537	session.widget.SetSizeRequest(width, height)
538	session.appliedWidth = width
539	session.appliedHeight = height
540	if a.contentCoord != nil {
541		syncCtx := context.Background()
542		if a.deps != nil && a.deps.Ctx != nil {
543			syncCtx = a.deps.Ctx
544		}
545		a.contentCoord.SyncWebViewViewport(syncCtx, session.paneID, "floating-resize")
546	}
547}
548
549func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
550	if session == nil || session.pane == nil {
551		return false
552	}
553	if !session.pane.IsVisible() {
554		session.resizeWatcherActive = false
555		session.resizeTickID = 0
556		return false
557	}
558
559	a.resizeFloatingWidget(session)
560	return true
561}
562
563func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
564	_, session, _ := a.floatingSessions.byPaneID(paneID)
565	return session
566}
567
568// popupOwnerWindowIDForPane scopes named contexts to the top-level owner. A
569// floating pane is mapped through its session's tab, never by parsing PaneID.
570func (a *App) popupOwnerWindowIDForPane(paneID entity.PaneID) (string, bool) {
571	if key, _, ok := a.floatingSessions.byPaneID(paneID); ok {
572		if bw := a.browserWindowForTab(key.tabID); bw != nil {
573			return bw.id, true
574		}
575		return "", false
576	}
577	if bw := a.browserWindowForAnyPane(paneID); bw != nil {
578		return bw.id, true
579	}
580	return "", false
581}
582
583func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
584	if session == nil || session.overlay == nil || session.resizeWatcherActive {
585		return
586	}
587
588	overlayWidget := session.overlay.GtkWidget()
589	if overlayWidget == nil {
590		return
591	}
592
593	session.resizeWatcherActive = true
594	paneID := session.paneID
595	callback := new(gtk.TickCallback)
596	*callback = func(_ uintptr, _ uintptr, _ uintptr) bool {
597		liveSession := a.floatingSessionByPaneID(paneID)
598		if liveSession == nil {
599			session.releaseResizeTickCallback()
600			return false
601		}
602
603		keepRunning := a.handleFloatingViewportTick(liveSession)
604		if !keepRunning {
605			liveSession.releaseResizeTickCallback()
606		}
607		return keepRunning
608	}
609	session.resizeTickCallback = callback
610	session.resizeTickID = overlayWidget.AddTickCallback(callback, 0, nil)
611}
612
613func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
614	if session == nil {
615		return
616	}
617
618	tickID := session.resizeTickID
619	session.releaseResizeTickCallback()
620	if tickID != 0 && session.overlay != nil {
621		if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
622			overlayWidget.RemoveTickCallback(tickID)
623		}
624	}
625}
626
627// releaseResizeTickCallback releases the raw purego callback slot. It is safe
628// to call after GTK has already removed a callback that returned false.
629func (session *floatingWorkspaceSession) releaseResizeTickCallback() {
630	if session == nil {
631		return
632	}
633	callback := session.resizeTickCallback
634	session.resizeTickID = 0
635	session.resizeTickCallback = nil
636	session.resizeWatcherActive = false
637	if callback != nil {
638		// AddTickCallback creates a purego function-pointer slot. Clearing the Go
639		// pointer alone cannot return that finite runtime slot to the ledger.
640		_ = unrefTickCallback(callback)
641	}
642}
643
644func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
645	if session == nil || session.pane == nil {
646		return
647	}
648
649	a.stopFloatingResizeWatcher(session)
650	session.pane.Hide(ctx)
651	a.hideFloatingOmnibox(ctx, session)
652	hideFloatingWidget(session.widget)
653	// Force a fresh size request on next show so WebKit gets a new
654	// allocation cycle and repaints content after rapid toggles.
655	session.appliedWidth = 0
656	session.appliedHeight = 0
657}
658
659func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
660	for _, session := range a.floatingSessions.forTab(tabID) {
661		if session == except || session.pane == nil {
662			continue
663		}
664		if !session.pane.IsVisible() {
665			continue
666		}
667		a.hideFloatingSession(ctx, session)
668	}
669}
670
671func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
672	session, _ := a.activeFloatingSession()
673	if session == nil {
674		return false
675	}
676	a.hideFloatingSession(ctx, session)
677	a.syncFloatingFocus()
678	return true
679}
680
681func (a *App) handleGlobalEscape(ctx context.Context) bool {
682	return a.closeActiveFloatingPane(ctx)
683}
684
685func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
686	key, session, ok := a.activeFloatingSessionEntry()
687	if !ok {
688		return false
689	}
690	a.releaseFloatingSession(ctx, key, session)
691	a.syncFloatingFocus()
692	return true
693}
694
695// ToggleFloatingPane toggles the active workspace floating pane visibility.
696func (a *App) ToggleFloatingPane(ctx context.Context) error {
697	activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
698	if activeTab == nil {
699		return nil
700	}
701	if a.closeActiveFloatingPane(ctx) {
702		return nil
703	}
704	return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
705}
706
707// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
708func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
709	return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
710}
711
712// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
713func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
714	return a.openFloatingPaneSession(ctx, sessionID, url)
715}
716
717func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
718	activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
719	if activeTab == nil {
720		return nil
721	}
722
723	wsView := a.workspaceViews[activeTab.ID]
724	session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
725	if err != nil {
726		return err
727	}
728	hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
729	isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
730
731	if hasURL && isProfileSession && session.pane.IsVisible() {
732		a.hideFloatingSession(ctx, session)
733		a.syncFloatingFocus()
734		return nil
735	}
736
737	a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
738
739	if hasURL {
740		if isProfileSession && session.pane.SessionStarted() {
741			session.pane.Show()
742		} else {
743			if err := session.pane.ShowURL(ctx, url[0]); err != nil {
744				return err
745			}
746		}
747	} else {
748		if err := session.pane.ShowToggle(ctx); err != nil {
749			return err
750		}
751	}
752
753	a.resizeFloatingWidget(session)
754	setFloatingWidgetShown(session.widget, session.pane.IsVisible())
755	if session.pane.IsOmniboxVisible() {
756		a.showFloatingOmnibox(ctx, session)
757	} else {
758		a.hideFloatingOmnibox(ctx, session)
759		focusFloatingSessionContent(session)
760	}
761	a.startFloatingResizeWatcher(session)
762	a.syncFloatingFocus()
763
764	return nil
765}

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