v0.12.0 d035193

browser.go RawHistoryFolder

1// Package services contains application services that orchestrate business logic.
2package services
3
4import (
5	"context"
6	"crypto/md5"
7	"database/sql"
8	"embed"
9	"errors"
10	"fmt"
11	"log"
12	"math"
13	neturl "net/url"
14	"time"
15
16	"github.com/bnema/dumber/internal/cache"
17	"github.com/bnema/dumber/internal/config"
18	"github.com/bnema/dumber/internal/db"
19	"github.com/bnema/dumber/pkg/webkit"
20)
21
22func clampToInt32(i int64) int32 {
23	if i > math.MaxInt32 {
24		i = math.MaxInt32
25	}
26	if i < math.MinInt32 {
27		i = math.MinInt32
28	}
29	return int32(i) //nolint:gosec // value is clamped to int32 bounds
30}
31
32// WindowTitleUpdater interface allows the service to update the window title
33// Decoupled from any specific GUI framework.
34type WindowTitleUpdater interface {
35	SetTitle(title string)
36}
37
38// BrowserService handles browser-related operations for the built-in browser.
39type BrowserService struct {
40	config             *config.Config
41	dbQueries          db.DatabaseQuerier
42	windowTitleUpdater WindowTitleUpdater
43	webView            *webkit.WebView
44	initialURL         string
45	guiBundle          string
46}
47
48// ServiceName returns the service name for frontend binding
49func (s *BrowserService) ServiceName() string {
50	return "BrowserService"
51}
52
53// NavigationResult represents the result of a navigation operation.
54type NavigationResult struct {
55	URL           string        `json:"url"`
56	Title         string        `json:"title"`
57	Success       bool          `json:"success"`
58	Error         string        `json:"error,omitempty"`
59	LoadTime      time.Duration `json:"load_time"`
60	RedirectChain []string      `json:"redirect_chain,omitempty"`
61}
62
63// HistoryEntry represents a simplified history entry for frontend.
64type HistoryEntry struct {
65	ID          int64     `json:"id"`
66	URL         string    `json:"url"`
67	Title       string    `json:"title"`
68	FaviconURL  string    `json:"favicon_url"`
69	VisitCount  int32     `json:"visit_count"`
70	LastVisited time.Time `json:"last_visited"`
71	CreatedAt   time.Time `json:"created_at"`
72}
73
74// NewBrowserService creates a new BrowserService instance.
75func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
76	return &BrowserService{
77		config:             cfg,
78		dbQueries:          queries,
79		windowTitleUpdater: nil,
80		webView:            nil,
81		initialURL:         "",
82		guiBundle:          "",
83	}
84}
85
86// SetWindowTitleUpdater sets the window title updater interface
87func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
88	s.windowTitleUpdater = updater
89}
90
91// AttachWebView connects a native WebKit WebView to this service for integration.
92func (s *BrowserService) AttachWebView(view *webkit.WebView) {
93	s.webView = view
94
95	// Register legacy favicon change handler (binary data - fallback)
96	// This is kept for backward compatibility but is rarely used
97	// Modern favicon handling uses the URI-based handler registered in buildPane()
98	view.RegisterFaviconChangedHandler(func(data []byte) {
99		if currentURL := view.GetCurrentURL(); currentURL != "" {
100			log.Printf("[favicon] Favicon detected for %s, data size: %d bytes", currentURL, len(data))
101			s.handleFaviconChanged(currentURL, data)
102		}
103	})
104
105	// Note: RegisterFaviconURIChangedHandler is now only called in buildPane()
106	// to avoid duplicate handler registration which causes multiple downloads
107	// of the same favicon and file handle race conditions
108}
109
110// LoadGUIBundle loads the unified GUI bundle from assets
111func (s *BrowserService) LoadGUIBundle(assets embed.FS) error {
112	log.Printf("[browser] Attempting to load GUI bundle from assets/gui/gui.min.js")
113	bundleBytes, err := assets.ReadFile("assets/gui/gui.min.js")
114	if err != nil {
115		log.Printf("[browser] ERROR: Failed to load GUI bundle: %v", err)
116		return fmt.Errorf("failed to load GUI bundle: %w", err)
117	}
118	s.guiBundle = string(bundleBytes)
119	log.Printf("[browser] GUI bundle loaded successfully into browser service, size: %d bytes", len(bundleBytes))
120	return nil
121}
122
123// GetGUIBundle returns the loaded GUI bundle string
124func (s *BrowserService) GetGUIBundle() string {
125	return s.guiBundle
126}
127
128// InjectToastSystem injects the GUI bundle and initializes the toast system
129func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error {
130	_ = ctx
131	if s.webView == nil {
132		return fmt.Errorf("webview not attached")
133	}
134	if s.guiBundle == "" {
135		return fmt.Errorf("GUI bundle not loaded")
136	}
137
138	// Set theme if provided
139	themeScript := ""
140	if theme != "" {
141		themeScript = fmt.Sprintf("window.__dumber_initial_theme = '%s';", theme)
142	}
143
144	// Inject the unified GUI bundle and initialize toast system after DOM ready
145	toastScript := s.guiBundle + ";" + themeScript +
146		"(function(){" +
147		"function initToast(){" +
148		"if(window.__dumber_gui && window.__dumber_gui.initializeToast){" +
149		"window.__dumber_gui.initializeToast().then(function(){" +
150		"console.log('✅ Toast system initialized via GUI bundle');" +
151		"}).catch(function(e){console.error('❌ Toast initialization failed:', e);});" +
152		"}else{console.error('❌ GUI bundle not properly loaded');}" +
153		"}" +
154		"if(document.readyState==='loading'){" +
155		"document.addEventListener('DOMContentLoaded',initToast);" +
156		"}else{initToast();}" +
157		"})();"
158
159	return s.webView.InjectScript(toastScript)
160}
161
162// Navigate handles navigation to a URL and records it in history.
163func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
164	startTime := time.Now()
165
166	if url == "" {
167		return &NavigationResult{
168			Success: false,
169			Error:   "URL cannot be empty",
170		}, nil
171	}
172
173	// Store the initial URL for frontend synchronization
174	if s.initialURL == "" {
175		s.initialURL = url
176	}
177
178	// Record the navigation in history
179	err := s.recordHistory(ctx, url, "")
180	if err != nil {
181		// Don't fail navigation if history recording fails
182		fmt.Printf("Failed to record history: %v\n", err)
183	}
184
185	// Favicon detection is now handled automatically by WebKit's native favicon database
186
187	return &NavigationResult{
188		URL:      url,
189		Success:  true,
190		LoadTime: time.Since(startTime),
191	}, nil
192}
193
194// UpdatePageTitle updates the title of the current page in history and the window title.
195func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
196	if url == "" || title == "" {
197		return fmt.Errorf("URL and title cannot be empty")
198	}
199
200	// Use AddOrUpdateHistory to update the title
201	titleNull := sql.NullString{String: title, Valid: true}
202	err := s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
203	if err != nil {
204		return err
205	}
206
207	// Update the window title if we have a title updater
208	if s.windowTitleUpdater != nil && title != "" {
209		windowTitle := fmt.Sprintf("Dumber - %s", title)
210		updater := s.windowTitleUpdater
211		if s.webView != nil {
212			s.webView.RunOnMainThread(func() {
213				updater.SetTitle(windowTitle)
214			})
215		} else {
216			updater.SetTitle(windowTitle)
217		}
218	}
219
220	return nil
221}
222
223// GetRecentHistory returns recent browser history entries.
224func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
225	if limit <= 0 {
226		limit = 50 // Default limit
227	}
228
229	entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
230	if err != nil {
231		return nil, err
232	}
233
234	result := make([]HistoryEntry, len(entries))
235	for i, entry := range entries {
236		// Defensive cast to int32 to prevent overflow
237		vc := clampToInt32(entry.VisitCount.Int64)
238		result[i] = HistoryEntry{
239			ID:          entry.ID,
240			URL:         entry.Url,
241			Title:       entry.Title.String,
242			FaviconURL:  entry.FaviconUrl.String,
243			VisitCount:  vc,
244			LastVisited: entry.LastVisited.Time,
245			CreatedAt:   entry.CreatedAt.Time,
246		}
247	}
248
249	return result, nil
250}
251
252// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
253func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
254	if limit <= 0 {
255		limit = 20 // Default limit for pagination
256	}
257	if offset < 0 {
258		offset = 0
259	}
260
261	entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
262	if err != nil {
263		return nil, err
264	}
265
266	result := make([]HistoryEntry, len(entries))
267	for i, entry := range entries {
268		// Defensive cast to int32 to prevent overflow
269		vc := clampToInt32(entry.VisitCount.Int64)
270		result[i] = HistoryEntry{
271			ID:          entry.ID,
272			URL:         entry.Url,
273			Title:       entry.Title.String,
274			FaviconURL:  entry.FaviconUrl.String,
275			VisitCount:  vc,
276			LastVisited: entry.LastVisited.Time,
277			CreatedAt:   entry.CreatedAt.Time,
278		}
279	}
280
281	return result, nil
282}
283
284// SearchHistory searches browser history.
285func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
286	if query == "" {
287		return s.GetRecentHistory(ctx, limit)
288	}
289
290	if limit <= 0 {
291		limit = 50
292	}
293
294	urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
295	titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
296
297	entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
298	if err != nil {
299		return nil, err
300	}
301
302	result := make([]HistoryEntry, len(entries))
303	for i, entry := range entries {
304		vc := clampToInt32(entry.VisitCount.Int64)
305		result[i] = HistoryEntry{
306			ID:          entry.ID,
307			URL:         entry.Url,
308			Title:       entry.Title.String,
309			FaviconURL:  entry.FaviconUrl.String,
310			VisitCount:  vc,
311			LastVisited: entry.LastVisited.Time,
312			CreatedAt:   entry.CreatedAt.Time,
313		}
314	}
315
316	return result, nil
317}
318
319// DeleteHistoryEntry removes a specific history entry.
320func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
321	return s.dbQueries.DeleteHistory(ctx, id)
322}
323
324// ClearHistory removes all history entries.
325func (s *BrowserService) ClearHistory(ctx context.Context) error {
326	_ = ctx
327	// Note: ClearAllHistory method doesn't exist in current schema
328	// This would need to be implemented in the database layer
329	return fmt.Errorf("clear all history not implemented yet")
330}
331
332// GetHistoryStats returns statistics about browser history.
333func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
334	// Get recent entries for basic stats
335	recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
336	if err != nil {
337		return nil, err
338	}
339
340	// Calculate basic statistics
341	stats := map[string]interface{}{
342		"total_entries": len(recentEntries),
343		"recent_count":  len(recentEntries),
344	}
345
346	// Add more detailed stats if we have entries
347	if len(recentEntries) > 0 {
348		stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
349		stats["newest_entry"] = recentEntries[0].CreatedAt.Time
350	}
351
352	return stats, nil
353}
354
355// GetConfig returns the current browser configuration.
356func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
357	_ = ctx
358	return s.config, nil
359}
360
361// UpdateConfig updates the browser configuration.
362func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
363	_ = ctx
364	// In a real implementation, you'd want to validate and persist the config
365	s.config = newConfig
366	return nil
367}
368
369// GetSearchShortcuts returns available search shortcuts.
370func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
371	return s.config.SearchShortcuts, nil
372}
373
374// recordHistory adds or updates a history entry.
375func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
376	// Use AddOrUpdateHistory which handles both cases
377	titleNull := sql.NullString{Valid: false}
378	if title != "" {
379		titleNull = sql.NullString{String: title, Valid: true}
380	}
381
382	return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
383}
384
385// Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
386var firefoxZoomLevels = []float64{0.3, 0.5, 0.67, 0.8, 0.9, 1.0, 1.1, 1.2, 1.33, 1.5, 1.7, 2.0, 2.4, 3.0, 4.0, 5.0}
387
388// ZoomIn increases the zoom level to the next Firefox zoom level.
389func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error) {
390	currentZoom, err := s.GetZoomLevel(ctx, url)
391	if err != nil {
392		return 1.0, err
393	}
394
395	newZoom := s.getNextZoomLevel(currentZoom, true)
396	err = s.SetZoomLevel(ctx, url, newZoom)
397	if err != nil {
398		return currentZoom, err
399	}
400
401	return newZoom, nil
402}
403
404// ZoomOut decreases the zoom level to the previous Firefox zoom level.
405func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, error) {
406	currentZoom, err := s.GetZoomLevel(ctx, url)
407	if err != nil {
408		return 1.0, err
409	}
410
411	newZoom := s.getNextZoomLevel(currentZoom, false)
412	err = s.SetZoomLevel(ctx, url, newZoom)
413	if err != nil {
414		return currentZoom, err
415	}
416
417	return newZoom, nil
418}
419
420// ResetZoom resets the zoom level to 1.0 for the current URL.
421func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, error) {
422	return s.setZoom(ctx, url, 1.0)
423}
424
425// GetZoomLevel retrieves the saved zoom level for a URL.
426func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
427	if url == "" {
428		return s.config.DefaultZoom, nil
429	}
430
431	key := zoomKeyFromURL(url)
432	zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
433	if err != nil {
434		if errors.Is(err, sql.ErrNoRows) {
435			// Fallback to exact URL record if present (backward compatibility)
436			zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
437			if err2 == nil {
438				return zl, nil
439			}
440			// No zoom setting found, return configured default
441			return s.config.DefaultZoom, nil
442		}
443		return s.config.DefaultZoom, err
444	}
445
446	return zoomLevel, nil
447}
448
449// SetZoomLevel sets the zoom level for a URL.
450func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
451	if url == "" {
452		return fmt.Errorf("URL cannot be empty")
453	}
454
455	// Clamp zoom level to reasonable bounds
456	if zoomLevel < zoomMin {
457		zoomLevel = zoomMin
458	} else if zoomLevel > zoomMax {
459		zoomLevel = zoomMax
460	}
461
462	key := zoomKeyFromURL(url)
463	return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
464}
465
466// GetCurrentURL returns the current URL (this would be implemented by the frontend)
467func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
468	if s.webView == nil {
469		return "", nil
470	}
471	return s.webView.GetCurrentURL(), nil
472}
473
474// GetInitialURL returns the initially navigated URL for frontend synchronization
475func (s *BrowserService) GetInitialURL(ctx context.Context) (string, error) {
476	return s.initialURL, nil
477}
478
479// CopyCurrentURL copies the current URL to clipboard (frontend-initiated)
480func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
481	// This is primarily for logging/recording the copy action
482	// The actual clipboard operation is handled by the frontend
483	if url != "" {
484		// Could record this action in analytics if needed
485		fmt.Printf("URL copied to clipboard: %s\n", url)
486	}
487	return nil
488}
489
490// GoBack provides navigation back functionality
491func (s *BrowserService) GoBack(ctx context.Context) error {
492	if s.webView != nil {
493		return s.webView.GoBack()
494	}
495	fmt.Println("Navigation: Go back")
496	return nil
497}
498
499// GoForward provides navigation forward functionality
500func (s *BrowserService) GoForward(ctx context.Context) error {
501	if s.webView != nil {
502		return s.webView.GoForward()
503	}
504	fmt.Println("Navigation: Go forward")
505	return nil
506}
507
508// getNextZoomLevel returns the next Firefox zoom level in the specified direction
509const (
510	zoomMin            = 0.25
511	zoomMax            = 5.0
512	recentHistoryLimit = 1000
513)
514
515func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
516	// Find the closest current zoom level
517	closestIndex := 0
518	minDiff := 10.0 // Large initial value
519
520	for i, level := range firefoxZoomLevels {
521		diff := currentZoom - level
522		if diff < 0 {
523			diff = -diff // abs value
524		}
525		if diff < minDiff {
526			minDiff = diff
527			closestIndex = i
528		}
529	}
530
531	if zoomIn {
532		// Move to next higher zoom level
533		if closestIndex < len(firefoxZoomLevels)-1 {
534			return firefoxZoomLevels[closestIndex+1]
535		}
536		return firefoxZoomLevels[closestIndex] // Already at max
537	}
538	// Move to next lower zoom level
539	if closestIndex > 0 {
540		return firefoxZoomLevels[closestIndex-1]
541	}
542	return firefoxZoomLevels[closestIndex] // Already at min
543}
544
545// setZoom sets the zoom level to a specific value
546func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
547	err := s.SetZoomLevel(ctx, url, zoomLevel)
548	if err != nil {
549		return 1.0, err
550	}
551	return zoomLevel, nil
552}
553
554// zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
555// Uses hostname if present; falls back to the full input URL string.
556func zoomKeyFromURL(raw string) string {
557	u, err := neturl.Parse(raw)
558	if err != nil || u.Host == "" {
559		return raw
560	}
561	return u.Host
562}
563
564// extractDomain extracts the domain from a URL for zoom level storage
565// Domain-based zoom allows settings to persist across different pages of the same site
566// extractDomain is no longer used; zoom is stored per URL for contract parity.
567
568// ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
569func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
570
571// handleFaviconChanged processes favicon data received from WebKit (legacy binary data method)
572func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
573	log.Printf("[favicon] Processing favicon for %s, data size: %d bytes", pageURL, len(pngData))
574
575	if len(pngData) == 0 {
576		log.Printf("[favicon] No favicon data for %s", pageURL)
577		return
578	}
579
580	ctx := context.Background()
581
582	// Parse URL for validation
583	parsedURL, err := neturl.Parse(pageURL)
584	if err != nil {
585		return
586	}
587
588	// Skip favicon update for localhost, file://, or special schemes
589	if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
590		return
591	}
592	if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
593		return
594	}
595
596	// Cache the PNG data directly
597	if faviconCache, err := cache.NewFaviconCache(); err == nil {
598		if err := faviconCache.CachePNGData(pageURL, pngData); err != nil {
599			// Log error but don't fail
600			log.Printf("[favicon] Failed to cache favicon for %s: %v", pageURL, err)
601		} else {
602			log.Printf("[favicon] Successfully cached favicon for %s (%d bytes)", pageURL, len(pngData))
603		}
604	} else {
605		log.Printf("[favicon] Failed to create favicon cache: %v", err)
606	}
607
608	// Store a reference to the cached file in the database
609	// We use a consistent path format for the favicon URL
610	faviconCacheURL := fmt.Sprintf("dumb://favicon/%x.png", md5.Sum([]byte(pageURL)))
611	faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}
612	if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
613		// Silently fail - favicon is not critical
614		log.Printf("[browser] Failed to update favicon URL in database for %s: %v", pageURL, err)
615	}
616}
617
618// ProcessFaviconURI is a public wrapper for handling favicon URI changes from any webview
619func (s *BrowserService) ProcessFaviconURI(pageURL string, faviconURI string) {
620	s.handleFaviconURIChanged(pageURL, faviconURI)
621}
622
623// handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database
624func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) {
625	log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI)
626
627	ctx := context.Background()
628
629	// Parse URL for validation
630	parsedURL, err := neturl.Parse(pageURL)
631	if err != nil {
632		log.Printf("[favicon] Invalid page URL: %s", pageURL)
633		return
634	}
635
636	// Skip favicon update for localhost, file://, or special schemes
637	if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
638		return
639	}
640	if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
641		return
642	}
643
644	// Store the favicon URI directly in the database
645	// The frontend will handle SVG vs PNG/ICO detection
646	faviconNullString := sql.NullString{String: faviconURI, Valid: true}
647	if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
648		log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
649	}
650
651	// Download and cache the favicon asynchronously (same approach as dmenu)
652	if faviconCache, err := cache.NewFaviconCache(); err == nil {
653		faviconCache.CacheAsync(faviconURI)
654		log.Printf("[favicon] Started async download of favicon from %s for page %s", faviconURI, pageURL)
655	} else {
656		log.Printf("[favicon] Failed to create favicon cache: %v", err)
657	}
658}
659
660// ColorPalettesResponse holds light and dark palettes for JSON marshaling
661type ColorPalettesResponse struct {
662	Light config.ColorPalette `json:"light"`
663	Dark  config.ColorPalette `json:"dark"`
664}
665
666// GetColorPalettesForMessaging returns the color palettes from config
667func (s *BrowserService) GetColorPalettesForMessaging() ColorPalettesResponse {
668	if s.config == nil {
669		return ColorPalettesResponse{}
670	}
671	return ColorPalettesResponse{
672		Light: s.config.Appearance.LightPalette,
673		Dark:  s.config.Appearance.DarkPalette,
674	}
675}

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