v0.13.0 976176a

workspace_popup.go RawHistoryFolder

1// workspace_popup.go - Popup window handling and related pane management
2package browser
3
4import (
5	"log"
6	"strings"
7	"time"
8
9	"github.com/bnema/dumber/internal/config"
10	"github.com/bnema/dumber/pkg/webkit"
11	webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12	"github.com/diamondburned/gotk4/pkg/glib/v2"
13)
14
15const (
16	// Default popup window dimensions - sized for OAuth flows
17	defaultPopupMinWidth  = 500 // Minimum width for OAuth popups
18	defaultPopupMinHeight = 600 // Minimum height for OAuth popups
19)
20
21// HandlePopup is DEPRECATED and removed.
22// Popup lifecycle now uses WebKit's native create/ready-to-show/close signals.
23// See setupPopupHandling(), handlePopupReadyToShow(), and handlePopupClose() instead.
24
25// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
26// Note: OAuth detection is now handled by the main-world.js injection script
27func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
28	log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
29	log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
30}
31
32// insertIndependentPane inserts a new independent pane next to the source
33func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
34	newPane, err := wm.createPane(webView)
35	if err != nil {
36		return err
37	}
38	direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
39	if direction == "" {
40		direction = DirectionRight
41	}
42	// Use splitNode with existing pane instead of insertPopupPane
43	if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
44		return err
45	}
46	node := wm.viewToNode[webView]
47	if node != nil {
48		node.windowType = webkit.WindowTypeTab
49		node.isRelated = false
50	}
51	if url != "" {
52		_ = webView.LoadURL(url)
53	}
54	return nil
55}
56
57// configureRelatedPopup creates a related view and inserts it
58func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
59	// Use the WebView that was already created and returned to WebKit
60	related := webView
61	newPane, err := wm.createPane(related)
62	if err != nil {
63		log.Printf("[workspace] failed to create related popup pane: %v", err)
64		return
65	}
66	direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
67	if direction == "" {
68		direction = DirectionRight
69	}
70	// Use splitNode with existing pane instead of insertPopupPane
71	if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
72		log.Printf("[workspace] failed to insert related popup pane: %v", err)
73		return
74	}
75	node := wm.viewToNode[related]
76	popupWebViewID := related.IDString()
77
78	if node != nil {
79		node.windowType = webkit.WindowTypePopup
80		node.windowFeatures = feat
81		node.isRelated = true
82		node.parentPane = sourceNode
83		node.isPopup = true
84		// Heuristic + config for auto-close intent
85		node.autoClose = wm.shouldAutoClose(url)
86
87		// Apply minimum size constraints to prevent compression
88		var width, height int
89		if feat != nil {
90			width = feat.Width
91			height = feat.Height
92		}
93		wm.applyPopupSizeConstraints(related, width, height)
94	}
95
96	// Track this popup in the parent's activePopupChildren
97	// This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
98	if sourceNode != nil && popupWebViewID != "" {
99		if sourceNode.activePopupChildren == nil {
100			sourceNode.activePopupChildren = make([]string, 0)
101		}
102		sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
103		log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
104
105		// Register navigation policy handler for parent to block script-initiated navigations
106		// when it has active popup children (prevents OAuth from hijacking parent pane)
107		if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
108			sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
109				// Always allow user-initiated navigations
110				if isUserGesture {
111					return true
112				}
113
114				// Block script-initiated navigations if this pane has active popup children
115				if len(sourceNode.activePopupChildren) > 0 {
116					log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
117					return false
118				}
119
120				// Allow if no active popups
121				return true
122			})
123			log.Printf("[workspace] Registered navigation policy handler for parent pane")
124		}
125	}
126
127	// Pipe into existing auto-close flow only for popups (confirmed by detection)
128	related.RegisterCloseHandler(func() {
129		log.Printf("[workspace] Popup requesting close via window.close()")
130
131		// Remove this popup from parent's activePopupChildren
132		if sourceNode != nil && popupWebViewID != "" {
133			for i, childID := range sourceNode.activePopupChildren {
134				if childID == popupWebViewID {
135					sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
136					log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
137					break
138				}
139			}
140		}
141
142		if n := wm.viewToNode[related]; n != nil && n.isPopup {
143			time.AfterFunc(200*time.Millisecond, func() {
144				wm.scheduleIdleGuarded(func() bool {
145					if n == nil || !n.widgetValid {
146						return false
147					}
148					if err := wm.ClosePane(n); err != nil {
149						log.Printf("[workspace] Failed to close popup pane: %v", err)
150					}
151					return false
152				}, n)
153			})
154		}
155	})
156
157	// URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
158	if node != nil && node.isPopup && node.autoClose {
159		wm.registerOAuthAutoClose(related, url)
160	}
161	if url != "" {
162		_ = related.LoadURL(url)
163	}
164}
165
166// applyPopupSizeConstraints applies minimum size constraints to prevent OAuth popup compression
167func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, width, height int) {
168	minWidth := defaultPopupMinWidth
169	minHeight := defaultPopupMinHeight
170
171	// Use provided dimensions if valid
172	if width > 0 {
173		minWidth = width
174	}
175	if height > 0 {
176		minHeight = height
177	}
178
179	// Apply to container
180	if container := view.RootWidget(); container != nil {
181		webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
182		webkit.WidgetQueueResize(container)
183		log.Printf("[workspace] Applied minimum size %dx%d to popup container=%p", minWidth, minHeight, container)
184	}
185
186	// Apply to WebView widget
187	if webViewWidget := view.Widget(); webViewWidget != nil {
188		webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
189		webkit.WidgetQueueResize(webViewWidget)
190		log.Printf("[workspace] Applied minimum size %dx%d to popup webview=%p", minWidth, minHeight, webViewWidget)
191	}
192}
193
194// shouldAutoClose checks simple OAuth-like URL patterns and config flag
195func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
196	log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
197
198	if wm == nil || wm.app == nil || wm.app.config == nil {
199		log.Printf("[workspace] shouldAutoClose: missing config, returning true")
200		return true
201	}
202	if !wm.app.config.Workspace.Popups.OAuthAutoClose {
203		log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
204		return false
205	}
206
207	u := strings.ToLower(url)
208	log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
209
210	// RFC 6749 compliant OAuth 2.0 URL patterns
211	oauthPatterns := []string{
212		// Standard OAuth endpoints
213		"oauth", "authorize", "authorization",
214		// Standard callback/redirect patterns
215		"callback", "redirect", "auth/callback",
216		// OpenID Connect patterns
217		"oidc", "openid",
218		// Common OAuth parameter indicators
219		"response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
220		// Standard OAuth response parameters
221		"code=", "access_token=", "id_token=", "token_type=",
222		// Error response parameters
223		"error=", "error_description=",
224	}
225
226	// Check for OAuth patterns in URL
227	for _, pattern := range oauthPatterns {
228		if strings.Contains(u, pattern) {
229			log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
230			return true
231		}
232	}
233
234	log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
235	return false
236}
237
238// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
239func (wm *WorkspaceManager) RunOnUI(fn func()) {
240	if fn != nil {
241		fn()
242	}
243}
244
245// handleDetectedWindowType handles window type detection from smart detection path
246func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
247	if wm.viewToNode[webView] != nil {
248		return // Already placed
249	}
250
251	log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
252
253	switch windowType {
254	case webkit.WindowTypeTab:
255		// For tabs, create a NEW independent WebView (can't use the related one)
256		webkitCfg, err := wm.app.buildWebkitConfig()
257		if err != nil {
258			log.Printf("[workspace] failed to build webkit config for tab: %v", err)
259			return
260		}
261		// Tabs are embedded in workspace paned containers - no separate window needed
262		webkitCfg.CreateWindow = false
263
264		// Create independent WebView matching our standard tab insertion path
265		independentView, err := webkit.NewWebView(webkitCfg)
266		if err != nil {
267			log.Printf("[workspace] failed to create independent tab WebView: %v", err)
268			return
269		}
270
271		// The related webView was just for detection - we don't use it for tabs
272		// Insert the new independent view as a tab
273		if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
274			log.Printf("[workspace] Failed to insert independent pane: %v", err)
275		}
276
277	case webkit.WindowTypePopup:
278		// For popups, use the related WebView we already created
279		wm.configureRelatedPopup(sourceNode, webView, url, features)
280	default:
281		// Fallback to popup behavior
282		wm.configureRelatedPopup(sourceNode, webView, url, features)
283	}
284}
285
286// setupPopupHandling connects WebView's create signal for popup lifecycle management
287// This implements the WebKit-native popup lifecycle as described in POPUP_REFACTORING_PLAN.md
288func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) {
289	if webView == nil || parentNode == nil {
290		return
291	}
292
293	webView.RegisterPopupCreateHandler(func(navAction *webkitgtk.NavigationAction) *webkit.WebView {
294		// Get URL from navigation action
295		req := navAction.Request()
296		if req == nil {
297			log.Printf("[workspace] create signal: no request in navigation action")
298			return nil
299		}
300		url := req.URI()
301
302		log.Printf("[workspace] create signal received for URL: %s", url)
303
304		// Check if popups are enabled in config
305		if wm.app.config != nil && !wm.app.config.Workspace.Popups.OpenInNewPane {
306			log.Printf("[workspace] Popup creation disabled in config - blocking popup")
307			return nil
308		}
309
310		// CRITICAL: Create a BARE gotk4 WebView as RELATED to parent (no wrapping, no initialization)
311		// WebKit requires the related-view property to be set during construction for proper lifecycle
312		// This ensures session/cookie sharing and proper WindowFeatures initialization
313		// All initialization (wrapping, container, scripts) happens in ready-to-show
314		log.Printf("[workspace] Step 1: Creating bare gotk4 WebView with related-view property")
315		barePopupView := webkit.NewBareRelatedWebView(webView.GtkWebView())
316		if barePopupView == nil {
317			log.Printf("[workspace] Failed to create bare related popup WebView - blocking popup")
318			return nil
319		}
320		log.Printf("[workspace] Step 1 complete: bare related gotk4 WebView created=%p (parent=%p)", barePopupView, webView.GtkWebView())
321
322		// Wrap the bare view immediately to get a proper ID
323		// BUT don't initialize it yet - that happens in ready-to-show
324		log.Printf("[workspace] Step 2: Wrapping bare view (no initialization)")
325		wrappedView := webkit.WrapBareWebView(barePopupView)
326		if wrappedView == nil {
327			log.Printf("[workspace] Failed to wrap bare popup WebView - blocking popup")
328			return nil
329		}
330
331		// Use the wrapper's ID for tracking
332		popupID := wrappedView.ID()
333		log.Printf("[workspace] Step 2 complete: wrapped view created with ID=%d", popupID)
334
335		// Store wrapped view (not initialized) for initialization in ready-to-show
336		log.Printf("[workspace] Step 3: Storing in pendingPopups map")
337		wm.pendingPopups[popupID] = &pendingPopup{
338			wrappedView: wrappedView,
339			parentView:  webView,
340			parentNode:  parentNode,
341			url:         url,
342		}
343		log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
344
345		// CRITICAL: Do NOT connect signals before returning to WebKit
346		// Connecting signals might trigger WebKit to access WindowFeatures before it's set
347		// Instead, we'll connect them after WebKit has processed the return
348		log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)")
349
350		// Use GLib idle_add to connect signals AFTER this callback returns
351		// This ensures WebKit has finished configuring WindowFeatures before we touch the view
352		glib.IdleAdd(func() bool {
353			log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID)
354
355			// Connect ready-to-show signal NOW (after WebKit has configured the view)
356			barePopupView.ConnectReadyToShow(func() {
357				log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
358				wm.handlePopupReadyToShow(popupID)
359			})
360			log.Printf("[workspace] Idle callback: ready-to-show connected")
361
362			// Connect close handler
363			barePopupView.ConnectClose(func() {
364				log.Printf("[workspace] close signal received for popup ID=%d", popupID)
365				wm.handlePopupClose(popupID)
366			})
367			log.Printf("[workspace] Idle callback: close signal connected")
368
369			return false // Don't repeat
370		})
371
372		log.Printf("[workspace] Step 4 complete: signal connections scheduled")
373
374		log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")
375
376		// Return the wrapped view - WebKit will extract the underlying gotk4 view and configure it
377		return wrappedView
378	})
379}
380
381// handlePopupReadyToShow inserts popup pane when WebKit says it's ready
382func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
383	info, ok := wm.pendingPopups[popupID]
384	if !ok {
385		log.Printf("[workspace] ready-to-show for unknown popup ID: %d", popupID)
386		return
387	}
388	delete(wm.pendingPopups, popupID)
389
390	log.Printf("[workspace] ready-to-show received for popup ID=%d, starting full initialization", popupID)
391
392	// NOW it's safe to initialize (WebKit has configured WindowProperties)
393	// We already have the wrapped view from setupPopupHandling
394	wrappedView := info.wrappedView
395
396	// Build webkit config for initialization
397	webkitCfg, err := wm.app.buildWebkitConfig()
398	if err != nil {
399		log.Printf("[workspace] Failed to build webkit config for popup initialization: %v", err)
400		return
401	}
402	webkitCfg.CreateWindow = false // Embedded in workspace
403
404	// Initialize the wrapped view with full configuration
405	if err := wrappedView.InitializeFromBare(webkitCfg); err != nil {
406		log.Printf("[workspace] Failed to initialize popup WebView: %v", err)
407		return
408	}
409
410	log.Printf("[workspace] Successfully initialized popup WebView ID %d", wrappedView.ID())
411
412	// Create pane for the fully initialized WebView
413	popupPane, err := wm.createPane(wrappedView)
414	if err != nil {
415		log.Printf("[workspace] Failed to create popup pane: %v", err)
416		return
417	}
418
419	log.Printf("[workspace] Created popup pane, now inserting into workspace")
420
421	// NOW it's safe to insert into workspace based on configured behavior
422	behavior := wm.app.config.Workspace.Popups.Behavior
423	log.Printf("[workspace] Popup behavior: %s", behavior)
424
425	switch behavior {
426	case config.PopupBehaviorSplit:
427		// Split pane behavior (default) - use splitNode with existing popup pane
428		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
429		if direction == "" {
430			direction = DirectionRight
431		}
432		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
433			log.Printf("[workspace] Failed to insert split popup pane: %v", err)
434			return
435		}
436
437	case config.PopupBehaviorStacked:
438		// Stacked pane behavior
439		_, err := wm.stackedPaneManager.StackPane(info.parentNode)
440		if err != nil {
441			log.Printf("[workspace] Failed to stack existing pane: %v", err)
442			return
443		}
444		// Insert the popup pane in the same container - use splitNode with existing popup pane
445		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
446		if direction == "" {
447			direction = DirectionRight
448		}
449		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
450			log.Printf("[workspace] Failed to insert stacked popup pane: %v", err)
451			return
452		}
453
454	case config.PopupBehaviorTabbed:
455		// Tabbed behavior - TODO: implement proper tabbed interface
456		log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split")
457		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
458		if direction == "" {
459			direction = DirectionRight
460		}
461		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
462			log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err)
463			return
464		}
465
466	case config.PopupBehaviorWindowed:
467		// Windowed behavior - opens in new workspace/window
468		// TODO: implement new window creation with GTK
469		log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split")
470		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
471		if direction == "" {
472			direction = DirectionRight
473		}
474		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
475			log.Printf("[workspace] Failed to insert windowed popup pane: %v", err)
476			return
477		}
478
479	default:
480		// Fallback to split if unknown behavior
481		log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior)
482		direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
483		if direction == "" {
484			direction = DirectionRight
485		}
486		if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
487			log.Printf("[workspace] Failed to insert popup pane: %v", err)
488			return
489		}
490	}
491
492	// Configure the popup node
493	node := wm.viewToNode[wrappedView]
494	if node != nil {
495		node.windowType = webkit.WindowTypePopup
496		node.isRelated = true
497		node.parentPane = info.parentNode
498		node.isPopup = true
499		node.autoClose = wm.shouldAutoClose(info.url)
500		node.popupID = popupID // Store popupID for close signal lookup
501
502		// Track this popup in the parent's activePopupChildren
503		if info.parentNode != nil {
504			popupWebViewID := wrappedView.IDString()
505			if info.parentNode.activePopupChildren == nil {
506				info.parentNode.activePopupChildren = make([]string, 0)
507			}
508			info.parentNode.activePopupChildren = append(info.parentNode.activePopupChildren, popupWebViewID)
509			log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)",
510				popupWebViewID, len(info.parentNode.activePopupChildren))
511
512			// Register navigation policy handler for parent
513			if info.parentNode.pane != nil && info.parentNode.pane.WebView() != nil {
514				info.parentNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
515					if isUserGesture {
516						return true
517					}
518					if len(info.parentNode.activePopupChildren) > 0 {
519						log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s",
520							len(info.parentNode.activePopupChildren), url)
521						return false
522					}
523					return true
524				})
525			}
526		}
527
528		// URL-based auto-close for OAuth popups
529		if node.autoClose {
530			wm.registerOAuthAutoClose(wrappedView, info.url)
531		}
532	}
533
534	wm.ensureGUIInPane(popupPane)
535
536	// Widgets are already shown by splitNode - no need to show again
537	// GTK4 handles visibility automatically during reparenting
538
539	log.Printf("[workspace] Popup pane inserted and visible for URL: %s", info.url)
540}
541
542// handlePopupClose responds to WebKit's close signal
543func (wm *WorkspaceManager) handlePopupClose(popupID uint64) {
544	log.Printf("[workspace] close signal received for popup native=%d", popupID)
545
546	// Check if it's in pending (not yet shown)
547	if info, ok := wm.pendingPopups[popupID]; ok {
548		delete(wm.pendingPopups, popupID)
549		log.Printf("[workspace] Popup closed before ready-to-show, cleaning up")
550		// Just remove from pending, WebKit will handle cleanup
551		_ = info // avoid unused variable
552		return
553	}
554
555	// Find pane by popupID (stored in node during ready-to-show)
556	var targetNode *paneNode
557	for _, node := range wm.viewToNode {
558		if node != nil && node.popupID == popupID {
559			targetNode = node
560			break
561		}
562	}
563
564	if targetNode == nil {
565		log.Printf("[workspace] close signal for unknown popup ID=%d, already closed?", popupID)
566		return
567	}
568
569	// Remove from parent's activePopupChildren
570	// Find the wrapped WebView for this node to get its IDString
571	var wrappedViewID string
572	for webView, node := range wm.viewToNode {
573		if node == targetNode {
574			wrappedViewID = webView.IDString()
575			break
576		}
577	}
578
579	if targetNode.parentPane != nil && wrappedViewID != "" {
580		for i, childID := range targetNode.parentPane.activePopupChildren {
581			if childID == wrappedViewID {
582				targetNode.parentPane.activePopupChildren = append(
583					targetNode.parentPane.activePopupChildren[:i],
584					targetNode.parentPane.activePopupChildren[i+1:]...)
585				log.Printf("[workspace] Removed popup from parent's activePopupChildren (remaining: %d)",
586					len(targetNode.parentPane.activePopupChildren))
587				break
588			}
589		}
590	}
591
592	// Clean close via existing ClosePane (WebKit already stopped)
593	log.Printf("[workspace] Closing popup pane via ClosePane")
594	if err := wm.ClosePane(targetNode); err != nil {
595		log.Printf("[workspace] Error closing popup pane: %v", err)
596	}
597}

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