v0.15.0 9006957

tab_manager.go RawHistoryFolder

1package browser
2
3import (
4	"fmt"
5	"sync"
6	"time"
7
8	"github.com/bnema/dumber/internal/config"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/bnema/dumber/pkg/webkit"
11	"github.com/diamondburned/gotk4/pkg/gtk/v4"
12)
13
14// TabManager manages the global tab system for the browser.
15// Each tab contains an independent workspace with its own pane tree.
16type TabManager struct {
17	tabs        []*Tab
18	activeIndex int
19	window      *webkit.Window
20	app         *BrowserApp
21
22	// GTK widgets
23	rootContainer gtk.Widgetter // Main vertical box containing tab bar + content
24	tabBar        gtk.Widgetter // Horizontal tab bar container
25	ContentArea   gtk.Widgetter // Container for active workspace (exported for border styling)
26	progressBar   gtk.Widgetter // Thin load progress indicator
27
28	// Border overlays (float over content without layout shift)
29	paneModeOverlay gtk.Widgetter // Border overlay for pane mode
30	tabModeOverlay  gtk.Widgetter // Border overlay for tab mode
31
32	// Modal state
33	tabModeActive bool
34	tabModeTimer  *time.Timer
35	lastTabCreate time.Time // Debounce guard for rapid tab creation shortcuts
36
37	// Rename state
38	renameInProgress bool
39	renamingTab      *Tab
40	// Stores original focus behaviour while inline rename is active
41	renamePrevFocusOnClick bool
42	renamePrevCanFocus     bool
43
44	// Tab button click handling (pattern from StackedPaneManager)
45	buttonToTab  map[uint64]*Tab // Maps button ID to tab for click handling
46	nextButtonID uint64          // Atomic counter for generating unique button IDs
47
48	// Synchronization
49	mu sync.RWMutex
50}
51
52// Tab represents a single browser tab containing a complete workspace.
53type Tab struct {
54	id          string
55	title       string
56	customTitle string // User-provided custom name (persists across page loads)
57	workspace   *WorkspaceManager
58	titleButton gtk.Widgetter
59	isActive    bool
60	progress    float64
61	isLoading   bool
62	webViews    map[*webkit.WebView]struct{}
63}
64
65// NewTabManager creates a new tab manager instance.
66func NewTabManager(app *BrowserApp, window *webkit.Window) *TabManager {
67	tm := &TabManager{
68		tabs:        make([]*Tab, 0),
69		activeIndex: -1,
70		window:      window,
71		app:         app,
72		buttonToTab: make(map[uint64]*Tab),
73	}
74
75	logging.Info("[tabs] Tab manager initialized")
76	return tm
77}
78
79// Initialize sets up the tab bar and creates the initial tab.
80func (tm *TabManager) Initialize(initialURL string) error {
81	tm.mu.Lock()
82	defer tm.mu.Unlock()
83
84	logging.Info("[tabs] Initializing tab system")
85
86	// Create root container layout
87	if err := tm.createRootContainer(); err != nil {
88		return fmt.Errorf("failed to create root container: %w", err)
89	}
90
91	// Create initial tab
92	if err := tm.createTabInternal(initialURL); err != nil {
93		return fmt.Errorf("failed to create initial tab: %w", err)
94	}
95
96	logging.Info("[tabs] Tab system initialized with 1 tab")
97	return nil
98}
99
100// createRootContainer creates the main vertical container with tab bar and content area.
101func (tm *TabManager) createRootContainer() error {
102	logging.Info("[tabs] Creating root container with tab bar")
103
104	// Get tab bar position from config
105	cfg := tm.getConfig()
106	position := cfg.Workspace.TabBarPosition
107	if position != "top" && position != "bottom" {
108		position = "bottom" // Fallback to default
109	}
110
111	logging.Debug(fmt.Sprintf("[tabs] Tab bar position: %s", position))
112
113	// Create main vertical box (tab bar + content area)
114	rootBox := gtk.NewBox(gtk.OrientationVertical, 0)
115	if rootBox == nil {
116		return fmt.Errorf("failed to create root container box")
117	}
118
119	rootBox.SetHExpand(true)
120	rootBox.SetVExpand(true)
121
122	// Create tab bar
123	tabBar := tm.createTabBar()
124	if tabBar == nil {
125		return fmt.Errorf("failed to create tab bar")
126	}
127
128	// Create content area for active workspace
129	contentArea := gtk.NewBox(gtk.OrientationVertical, 0)
130	if contentArea == nil {
131		return fmt.Errorf("failed to create content area")
132	}
133	contentArea.SetHExpand(true)
134	contentArea.SetVExpand(true)
135	contentArea.AddCSSClass("tab-content-area")
136
137	// Create progress bar (displayed at the bottom regardless of tab bar position)
138	progressBar := tm.createProgressBar()
139
140	// Add widgets in order based on position
141	if position == "top" {
142		rootBox.Append(tabBar)
143		rootBox.Append(contentArea)
144	} else {
145		rootBox.Append(contentArea)
146		rootBox.Append(tabBar)
147	}
148
149	// Wrap everything in an overlay so the progress bar can float without resizing layout
150	rootOverlay := gtk.NewOverlay()
151	if rootOverlay == nil {
152		return fmt.Errorf("failed to create root overlay")
153	}
154	rootOverlay.SetHExpand(true)
155	rootOverlay.SetVExpand(true)
156	rootOverlay.SetChild(rootBox)
157	if progressBar != nil {
158		if bar, ok := progressBar.(*gtk.ProgressBar); ok && bar != nil {
159			bar.SetHAlign(gtk.AlignFill)
160			bar.SetVAlign(gtk.AlignEnd)
161		}
162		rootOverlay.AddOverlay(progressBar)
163	}
164
165	// Create border overlays (hidden by default, shown when pane/tab mode is active)
166	paneModeOverlay := tm.createBorderOverlay("pane-mode-border")
167	tabModeOverlay := tm.createBorderOverlay("tab-mode-border")
168
169	if paneModeOverlay != nil {
170		rootOverlay.AddOverlay(paneModeOverlay)
171		webkit.WidgetSetVisible(paneModeOverlay, false)
172	}
173
174	if tabModeOverlay != nil {
175		rootOverlay.AddOverlay(tabModeOverlay)
176		webkit.WidgetSetVisible(tabModeOverlay, false)
177	}
178
179	// Store references
180	tm.rootContainer = rootOverlay
181	tm.tabBar = tabBar
182	tm.ContentArea = contentArea
183	tm.progressBar = progressBar
184	tm.paneModeOverlay = paneModeOverlay
185	tm.tabModeOverlay = tabModeOverlay
186
187	logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
188	return nil
189}
190
191// CreateTab creates a new tab and switches to it.
192// URL can be empty string to use default homepage.
193func (tm *TabManager) CreateTab(url string) error {
194	tm.mu.Lock()
195	defer tm.mu.Unlock()
196
197	return tm.createTabInternal(url)
198}
199
200// createTabInternal creates a new tab (must be called with lock held).
201func (tm *TabManager) createTabInternal(url string) error {
202	logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))
203
204	const tabCreateDebounce = 200 * time.Millisecond
205	if !tm.lastTabCreate.IsZero() && time.Since(tm.lastTabCreate) < tabCreateDebounce {
206		logging.Warn("[tabs] Ignoring rapid tab creation request (debounced)")
207		return nil
208	}
209
210	// Use default URL if empty
211	if url == "" {
212		url = "about:blank"
213	}
214
215	// Generate unique tab ID and default name
216	tabNumber := len(tm.tabs) + 1
217	tabID := fmt.Sprintf("tab-%d-%d", tabNumber, time.Now().Unix())
218	defaultTitle := fmt.Sprintf("Tab %d", tabNumber)
219
220	// Create tab structure
221	tab := &Tab{
222		id:       tabID,
223		title:    defaultTitle,
224		isActive: false,
225		webViews: make(map[*webkit.WebView]struct{}),
226	}
227
228	// Create WebView for this tab
229	cfg, err := tm.app.buildWebkitConfig()
230	if err != nil {
231		return fmt.Errorf("failed to build webkit config: %w", err)
232	}
233	cfg.CreateWindow = false // Tab webviews are embedded, don't create their own window
234
235	view, err := webkit.NewWebView(cfg)
236	if err != nil {
237		return fmt.Errorf("failed to create webview: %w", err)
238	}
239
240	// Register with content blocking service for network/cosmetic filtering
241	tm.app.RegisterWebViewForFiltering(view)
242
243	// Create pane for this WebView
244	pane, err := tm.app.createPaneForView(view)
245	if err != nil {
246		return fmt.Errorf("failed to create pane: %w", err)
247	}
248
249	// Create workspace for this tab
250	workspace := NewWorkspaceManager(tm.app, pane)
251	tab.workspace = workspace
252	tm.registerWebViewLocked(tab, view)
253
254	// Verify workspace root exists
255	rootWidget := workspace.GetRootWidget()
256	if rootWidget == nil {
257		return fmt.Errorf("workspace root widget is nil")
258	}
259	logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace))
260
261	// Load the URL in the workspace's pane
262	if url != "" {
263		if err := view.LoadURL(url); err != nil {
264			logging.Warn(fmt.Sprintf("[tabs] Failed to load URL %s: %v", url, err))
265		}
266	}
267
268	// Create tab button in tab bar
269	button := tm.createTabButton(tab)
270	if button == nil {
271		return fmt.Errorf("failed to create tab button")
272	}
273	tab.titleButton = button
274
275	// Add button to tab bar
276	tm.addTabToBar(tab)
277
278	// Add to tabs slice
279	tm.tabs = append(tm.tabs, tab)
280	tm.lastTabCreate = time.Now()
281
282	// Attach click handler with unique ID (pattern from stacked panes)
283	tm.attachTabClickHandler(tab.titleButton, tab)
284
285	// Switch to new tab
286	newIndex := len(tm.tabs) - 1
287	if err := tm.switchToTabInternal(newIndex); err != nil {
288		return fmt.Errorf("failed to switch to new tab: %w", err)
289	}
290
291	// Update tab bar visibility (hide if only 1 tab)
292	tm.updateTabBarVisibility()
293
294	logging.Info(fmt.Sprintf("[tabs] Created tab %s (total: %d)", tabID, len(tm.tabs)))
295	return nil
296}
297
298// SwitchToTab switches to the tab at the given index.
299func (tm *TabManager) SwitchToTab(index int) error {
300	tm.mu.Lock()
301	defer tm.mu.Unlock()
302
303	return tm.switchToTabInternal(index)
304}
305
306// switchToTabInternal switches to a tab (must be called with lock held).
307func (tm *TabManager) switchToTabInternal(index int) error {
308	// Validate index
309	if index < 0 || index >= len(tm.tabs) {
310		logging.Warn(fmt.Sprintf("[tabs] Invalid tab index: %d (valid: 0-%d)", index, len(tm.tabs)-1))
311		return fmt.Errorf("tab index %d out of range", index)
312	}
313
314	// Check if already active
315	if index == tm.activeIndex {
316		logging.Debug(fmt.Sprintf("[tabs] Tab %d already active", index))
317		return nil
318	}
319
320	logging.Info(fmt.Sprintf("[tabs] Switching from tab %d to %d", tm.activeIndex, index))
321
322	// Hide current tab if any
323	if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
324		oldTab := tm.tabs[tm.activeIndex]
325		oldTab.isActive = false
326
327		// Clear GTK focus from old tab's workspace to prevent it from catching keyboard events
328		if oldTab.workspace != nil {
329			oldTab.workspace.ClearFocus()
330		}
331
332		// Hide old workspace container and remove from content area
333		// CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container,
334		// because splits/stacks change the workspace root
335		if oldTab.workspace != nil {
336			currentRoot := oldTab.workspace.GetRootWidget()
337			if currentRoot != nil {
338				webkit.RunOnMainThread(func() {
339					if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
340						contentBox.Remove(currentRoot)
341						logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot))
342					}
343				})
344			}
345		}
346
347		// Remove active CSS class from old button
348		tm.setTabActiveStyle(oldTab, false)
349	}
350
351	// Show new tab
352	newTab := tm.tabs[index]
353	newTab.isActive = true
354	tm.activeIndex = index
355
356	// Add new workspace container to content area
357	// CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
358	if newTab.workspace != nil {
359		currentRoot := newTab.workspace.GetRootWidget()
360		if currentRoot != nil {
361			webkit.RunOnMainThread(func() {
362				if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
363					contentBox.Append(currentRoot)
364					webkit.WidgetSetVisible(currentRoot, true)
365					logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot))
366				}
367			})
368		}
369	}
370
371	// Add active CSS class to new button
372	tm.setTabActiveStyle(newTab, true)
373
374	// Update app-level workspace/panes references for compatibility
375	if newTab.workspace != nil {
376		tm.app.workspace = newTab.workspace
377		tm.app.panes = newTab.workspace.GetAllPanes()
378		tm.app.activePane = newTab.workspace.GetActivePane()
379
380		// Focus the new workspace's active pane
381		newTab.workspace.RestoreFocus()
382	}
383
384	// Sync progress bar with the newly active tab
385	tm.updateProgressBarLocked()
386
387	logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))
388	return nil
389}
390
391// CloseTab closes the tab at the given index.
392func (tm *TabManager) CloseTab(index int) error {
393	tm.mu.Lock()
394	defer tm.mu.Unlock()
395
396	return tm.closeTabInternal(index)
397}
398
399// closeTabInternal closes a tab (must be called with lock held).
400func (tm *TabManager) closeTabInternal(index int) error {
401	// Validate index
402	if index < 0 || index >= len(tm.tabs) {
403		logging.Warn(fmt.Sprintf("[tabs] Cannot close: invalid tab index %d", index))
404		return fmt.Errorf("tab index %d out of range", index)
405	}
406
407	// Prevent closing last tab
408	if len(tm.tabs) == 1 {
409		logging.Warn("[tabs] Cannot close last tab")
410		return fmt.Errorf("cannot close the last tab")
411	}
412
413	tab := tm.tabs[index]
414	logging.Info(fmt.Sprintf("[tabs] Closing tab %d (%s)", index, tab.id))
415
416	// Cleanup workspace (WorkspaceManager cleanup is handled automatically when tabs are removed)
417
418	// Clean up button mapping (find and remove this tab's button ID)
419	for buttonID, mappedTab := range tm.buttonToTab {
420		if mappedTab == tab {
421			delete(tm.buttonToTab, buttonID)
422			logging.Debug(fmt.Sprintf("[tabs] Cleaned up button mapping for closed tab %s (buttonID=%d)", tab.id, buttonID))
423			break
424		}
425	}
426
427	// Remove tab button from tab bar
428	tm.removeTabFromBar(tab)
429
430	// Remove workspace container from content area if it's currently visible
431	// CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
432	if tab.isActive && tab.workspace != nil {
433		currentRoot := tab.workspace.GetRootWidget()
434		if currentRoot != nil {
435			webkit.RunOnMainThread(func() {
436				if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
437					contentBox.Remove(currentRoot)
438					logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot))
439				}
440			})
441		}
442	}
443
444	// Remove from slice
445	tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
446
447	// Determine which tab to switch to
448	newIndex := index
449	if index >= len(tm.tabs) {
450		newIndex = len(tm.tabs) - 1
451	}
452
453	// Update active index and switch
454	if index == tm.activeIndex {
455		tm.activeIndex = -1 // Temporarily invalid
456		if err := tm.switchToTabInternal(newIndex); err != nil {
457			return fmt.Errorf("failed to switch after close: %w", err)
458		}
459	} else if index < tm.activeIndex {
460		// Adjust active index if a tab before it was closed
461		tm.activeIndex--
462	}
463
464	// Update tab bar visibility (hide if only 1 tab)
465	tm.updateTabBarVisibility()
466
467	// Update progress bar (active tab may have changed after close)
468	tm.updateProgressBarLocked()
469
470	logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))
471	return nil
472}
473
474// RenameTab sets a custom title for the tab at the given index.
475func (tm *TabManager) RenameTab(index int, customTitle string) error {
476	tm.mu.Lock()
477	defer tm.mu.Unlock()
478
479	if index < 0 || index >= len(tm.tabs) {
480		return fmt.Errorf("tab index %d out of range", index)
481	}
482
483	tab := tm.tabs[index]
484	tab.customTitle = customTitle
485
486	logging.Info(fmt.Sprintf("[tabs] Renamed tab %d to '%s'", index, customTitle))
487
488	// Update tab button label
489	tm.updateTabButton(tab)
490
491	return nil
492}
493
494// Note: Tab titles are static (Tab 1, Tab 2, etc.) and don't change based on page titles.
495// This is different from stacked panes which show page titles.
496// Users can only change tab names via explicit rename action (Alt+T → r)
497
498// GetActiveTab returns the currently active tab.
499func (tm *TabManager) GetActiveTab() *Tab {
500	tm.mu.RLock()
501	defer tm.mu.RUnlock()
502
503	if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
504		return tm.tabs[tm.activeIndex]
505	}
506	return nil
507}
508
509// GetTabCount returns the number of open tabs.
510func (tm *TabManager) GetTabCount() int {
511	tm.mu.RLock()
512	defer tm.mu.RUnlock()
513
514	return len(tm.tabs)
515}
516
517// IsTabModeActive returns whether tab mode is currently active.
518func (tm *TabManager) IsTabModeActive() bool {
519	tm.mu.RLock()
520	defer tm.mu.RUnlock()
521
522	return tm.tabModeActive
523}
524
525// IsRenameInProgress returns whether a tab rename is currently in progress.
526func (tm *TabManager) IsRenameInProgress() bool {
527	tm.mu.RLock()
528	defer tm.mu.RUnlock()
529
530	return tm.renameInProgress
531}
532
533// NextTab switches to the next tab (wraps around).
534func (tm *TabManager) NextTab() error {
535	tm.mu.Lock()
536	defer tm.mu.Unlock()
537
538	if len(tm.tabs) <= 1 {
539		return nil // No other tab to switch to
540	}
541
542	nextIndex := (tm.activeIndex + 1) % len(tm.tabs)
543	return tm.switchToTabInternal(nextIndex)
544}
545
546// PreviousTab switches to the previous tab (wraps around).
547func (tm *TabManager) PreviousTab() error {
548	tm.mu.Lock()
549	defer tm.mu.Unlock()
550
551	if len(tm.tabs) <= 1 {
552		return nil // No other tab to switch to
553	}
554
555	prevIndex := tm.activeIndex - 1
556	if prevIndex < 0 {
557		prevIndex = len(tm.tabs) - 1
558	}
559	return tm.switchToTabInternal(prevIndex)
560}
561
562// GetRootContainer returns the main container widget that should be set as window child.
563func (tm *TabManager) GetRootContainer() gtk.Widgetter {
564	tm.mu.RLock()
565	defer tm.mu.RUnlock()
566
567	return tm.rootContainer
568}
569
570// GetWindow returns the GTK window backing the tab manager.
571func (tm *TabManager) GetWindow() *webkit.Window {
572	tm.mu.RLock()
573	defer tm.mu.RUnlock()
574
575	return tm.window
576}
577
578// Cleanup performs cleanup of all tabs and resources.
579func (tm *TabManager) Cleanup() {
580	tm.mu.Lock()
581	defer tm.mu.Unlock()
582
583	logging.Info("[tabs] Cleaning up tab manager")
584
585	// Stop tab mode timer if active
586	if tm.tabModeTimer != nil {
587		tm.tabModeTimer.Stop()
588	}
589
590	// Cleanup all tabs (workspaces cleanup automatically when dereferenced)
591
592	logging.Info("[tabs] Tab manager cleanup complete")
593}
594
595// updateTabBarVisibility shows or hides the tab bar based on tab count.
596// Tab bar is hidden when there's only 1 tab (no need to show it).
597func (tm *TabManager) updateTabBarVisibility() {
598	if tm.tabBar == nil {
599		return
600	}
601
602	tabCount := len(tm.tabs)
603	shouldShow := tabCount > 1
604
605	webkit.WidgetSetVisible(tm.tabBar, shouldShow)
606
607	if shouldShow {
608		logging.Debug(fmt.Sprintf("[tabs] Tab bar visible (%d tabs)", tabCount))
609	} else {
610		logging.Debug("[tabs] Tab bar hidden (only 1 tab)")
611	}
612}
613
614// GetConfig is a helper to get the current configuration.
615func (tm *TabManager) getConfig() *config.Config {
616	return config.Get()
617}
618
619// updateProgressForWebView updates progress state for the tab that owns the given WebView.
620func (tm *TabManager) updateProgressForWebView(view *webkit.WebView, progress float64, loading bool) {
621	if view == nil || tm == nil {
622		return
623	}
624
625	tm.mu.Lock()
626	defer tm.mu.Unlock()
627
628	tab := tm.findTabForViewLocked(view)
629	if tab == nil {
630		return
631	}
632
633	// Clamp progress
634	if progress < 0 {
635		progress = 0
636	}
637	if progress > 1 {
638		progress = 1
639	}
640
641	// Only mark loading when progress is incomplete and the event indicates an active load.
642	tab.isLoading = loading && progress < 1.0
643	tab.progress = progress
644
645	tm.updateProgressBarLocked()
646}
647
648// findTabForViewLocked finds the tab owning the given WebView. Caller must hold tm.mu.
649func (tm *TabManager) findTabForViewLocked(view *webkit.WebView) *Tab {
650	for _, tab := range tm.tabs {
651		if _, ok := tab.webViews[view]; ok {
652			return tab
653		}
654	}
655	return nil
656}
657
658// registerWebViewLocked associates a WebView with a tab. Caller must hold tm.mu.
659func (tm *TabManager) registerWebViewLocked(tab *Tab, view *webkit.WebView) {
660	if tab == nil || view == nil {
661		return
662	}
663	if tab.webViews == nil {
664		tab.webViews = make(map[*webkit.WebView]struct{})
665	}
666	tab.webViews[view] = struct{}{}
667}
668
669// registerWebViewForWorkspace registers a webview to the tab that owns the workspace.
670func (tm *TabManager) registerWebViewForWorkspace(view *webkit.WebView, workspace *WorkspaceManager) {
671	if tm == nil || view == nil || workspace == nil {
672		return
673	}
674
675	tm.mu.Lock()
676	defer tm.mu.Unlock()
677
678	for _, tab := range tm.tabs {
679		if tab.workspace == workspace {
680			tm.registerWebViewLocked(tab, view)
681			return
682		}
683	}
684}
685
686// updateProgressBarLocked syncs the shared progress bar to the active tab. Caller must hold tm.mu.
687func (tm *TabManager) updateProgressBarLocked() {
688	if tm.progressBar == nil {
689		return
690	}
691
692	bar, ok := tm.progressBar.(*gtk.ProgressBar)
693	if !ok || bar == nil {
694		return
695	}
696
697	// No active tab -> hide bar
698	if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) {
699		webkit.RunOnMainThread(func() {
700			bar.SetFraction(0.0)
701			webkit.WidgetSetVisible(bar, false)
702		})
703		return
704	}
705
706	activeTab := tm.tabs[tm.activeIndex]
707	visible := activeTab.isLoading
708	fraction := activeTab.progress
709	if !visible {
710		fraction = 0.0
711	}
712
713	webkit.RunOnMainThread(func() {
714		bar.SetFraction(fraction)
715		webkit.WidgetSetVisible(bar, visible)
716	})
717}

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