v0.27.2 5960dd8

github.go RawHistoryFolder

1// Package updater provides update checking and downloading functionality.
2package updater
3
4import (
5	"archive/tar"
6	"bufio"
7	"compress/gzip"
8	"context"
9	"crypto/sha256"
10	"encoding/hex"
11	"encoding/json"
12	"errors"
13	"fmt"
14	"io"
15	"math/rand"
16	"net"
17	"net/http"
18	"net/url"
19	"os"
20	"path/filepath"
21	"regexp"
22	"runtime"
23	"strconv"
24	"strings"
25	"syscall"
26	"time"
27
28	"github.com/bnema/dumber/internal/application/port"
29	"github.com/bnema/dumber/internal/domain/entity"
30	"github.com/bnema/dumber/internal/infrastructure/env"
31	"github.com/bnema/dumber/internal/logging"
32)
33
34const (
35	// GitHub API endpoint for latest release.
36	githubAPIURL = "https://api.github.com/repos/bnema/dumber/releases/latest"
37
38	// Download URL pattern for the release archive.
39	// Uses version-less filename for stable "latest" download URL.
40	downloadURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/dumber_%s_%s.tar.gz"
41
42	// Checksums file URL pattern.
43	checksumsURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/checksums.txt"
44
45	// HTTP client timeout for API requests.
46	apiTimeout = 10 * time.Second
47
48	// HTTP client timeout for downloads.
49	downloadTimeout = 5 * time.Minute
50
51	// File permission for directories and executables.
52	execPerm = 0o755
53
54	// Maximum size for extracted binary (100MB) - prevents decompression bombs.
55	maxBinarySize = 100 * 1024 * 1024
56
57	// Maximum archive download size (150MB) - prevents unbounded downloads.
58	maxArchiveSize = 150 * 1024 * 1024
59
60	// Minimum binary size (1MB) - prevents truncated/corrupt binaries.
61	minBinarySize = 1 * 1024 * 1024
62
63	// Allowed GitHub download host.
64	allowedDownloadHost = "github.com"
65
66	// Maximum number of attempts for retryable network requests.
67	maxRetryAttempts = 3
68
69	// Base delay used for exponential backoff between retries.
70	retryBaseDelay = 250 * time.Millisecond
71
72	// Maximum delay cap for exponential backoff between retries.
73	retryMaxDelay = 2 * time.Second
74
75	// Max random jitter added to each retry backoff.
76	retryJitterMax = 200 * time.Millisecond
77)
78
79// githubRelease represents the GitHub API response for a release.
80type githubRelease struct {
81	TagName     string    `json:"tag_name"`
82	HTMLURL     string    `json:"html_url"`
83	PublishedAt time.Time `json:"published_at"`
84	Body        string    `json:"body"`
85}
86
87// GitHubChecker implements UpdateChecker using the GitHub API.
88type GitHubChecker struct {
89	client    *http.Client
90	randInt63 func(n int64) int64
91	sleep     func(ctx context.Context, d time.Duration) error
92}
93
94// NewGitHubChecker creates a new GitHub-based update checker.
95func NewGitHubChecker() *GitHubChecker {
96	return &GitHubChecker{
97		client:    &http.Client{Timeout: apiTimeout},
98		randInt63: rand.Int63n,
99		sleep:     waitForBackoff,
100	}
101}
102
103// CheckForUpdate checks if a newer version is available on GitHub.
104func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error) {
105	log := logging.FromContext(ctx)
106
107	// Skip check for dev builds.
108	if currentVersion == "" || currentVersion == "dev" {
109		log.Debug().Str("version", currentVersion).Msg("skipping update check for dev build")
110		return &entity.UpdateInfo{
111			CurrentVersion: currentVersion,
112			LatestVersion:  currentVersion,
113			IsNewer:        false,
114		}, nil
115	}
116
117	// Skip check for package manager installs (Flatpak, AUR).
118	// These should use their respective package managers for updates.
119	if env.IsFlatpak() || env.IsPacman() {
120		log.Debug().
121			Bool("flatpak", env.IsFlatpak()).
122			Bool("pacman", env.IsPacman()).
123			Msg("skipping update check for package manager install")
124		return &entity.UpdateInfo{
125			CurrentVersion: currentVersion,
126			LatestVersion:  currentVersion,
127			IsNewer:        false,
128		}, nil
129	}
130
131	req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody)
132	if err != nil {
133		return nil, fmt.Errorf("failed to create request: %w", err)
134	}
135	req.Header.Set("Accept", "application/vnd.github.v3+json")
136	req.Header.Set("User-Agent", "dumber-browser/"+currentVersion)
137
138	resp, err := g.doRequestWithRetry(ctx, req)
139	if err != nil {
140		if isRetryableRequestError(err) {
141			log.Debug().Err(err).Msg("transient update check failure; skipping check")
142			return nil, fmt.Errorf("%w: %w", port.ErrUpdateCheckTransient, err)
143		}
144		return nil, fmt.Errorf("failed to fetch latest release: %w", err)
145	}
146	defer func() { _ = resp.Body.Close() }()
147
148	if resp.StatusCode != http.StatusOK {
149		if isRetryableStatus(resp.StatusCode) {
150			log.Debug().Int("status", resp.StatusCode).Msg("transient update check API status; skipping check")
151			return nil, fmt.Errorf("%w: github API returned status %d", port.ErrUpdateCheckTransient, resp.StatusCode)
152		}
153		return nil, fmt.Errorf("github API returned status %d", resp.StatusCode)
154	}
155
156	var release githubRelease
157	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
158		return nil, fmt.Errorf("failed to decode release: %w", err)
159	}
160
161	latestVersion := strings.TrimPrefix(release.TagName, "v")
162	currentClean := strings.TrimPrefix(currentVersion, "v")
163
164	isNewer := compareVersions(currentClean, latestVersion) < 0
165
166	// Build download URL based on current architecture.
167	archName := getArchName()
168	downloadURL := fmt.Sprintf(downloadURLTemplate, runtime.GOOS, archName)
169
170	log.Debug().
171		Str("current", currentVersion).
172		Str("latest", latestVersion).
173		Bool("is_newer", isNewer).
174		Msg("update check completed")
175
176	return &entity.UpdateInfo{
177		CurrentVersion: currentVersion,
178		LatestVersion:  latestVersion,
179		IsNewer:        isNewer,
180		ReleaseURL:     release.HTMLURL,
181		DownloadURL:    downloadURL,
182		PublishedAt:    release.PublishedAt,
183		ReleaseNotes:   release.Body,
184	}, nil
185}
186
187// getArchName returns the architecture name used in release assets.
188func getArchName() string {
189	switch runtime.GOARCH {
190	case "amd64":
191		return "x86_64"
192	case "386":
193		return "i386"
194	default:
195		return runtime.GOARCH
196	}
197}
198
199// compareVersions compares two semantic versions.
200// Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2.
201func compareVersions(v1, v2 string) int {
202	// Parse version components.
203	parse := func(v string) (major, minor, patch int) {
204		// Remove any pre-release suffix for comparison.
205		v = regexp.MustCompile(`-.*$`).ReplaceAllString(v, "")
206		parts := strings.Split(v, ".")
207		if len(parts) >= 1 {
208			major, _ = strconv.Atoi(parts[0])
209		}
210		if len(parts) >= 2 {
211			minor, _ = strconv.Atoi(parts[1])
212		}
213		if len(parts) >= 3 {
214			patch, _ = strconv.Atoi(parts[2])
215		}
216		return
217	}
218
219	maj1, min1, pat1 := parse(v1)
220	maj2, min2, pat2 := parse(v2)
221
222	if maj1 != maj2 {
223		if maj1 < maj2 {
224			return -1
225		}
226		return 1
227	}
228	if min1 != min2 {
229		if min1 < min2 {
230			return -1
231		}
232		return 1
233	}
234	if pat1 != pat2 {
235		if pat1 < pat2 {
236			return -1
237		}
238		return 1
239	}
240	return 0
241}
242
243// GitHubDownloader implements UpdateDownloader for GitHub releases.
244type GitHubDownloader struct {
245	client    *http.Client
246	randInt63 func(n int64) int64
247	sleep     func(ctx context.Context, d time.Duration) error
248}
249
250// NewGitHubDownloader creates a new GitHub release downloader.
251func NewGitHubDownloader() *GitHubDownloader {
252	return &GitHubDownloader{
253		client:    &http.Client{Timeout: downloadTimeout},
254		randInt63: rand.Int63n,
255		sleep:     waitForBackoff,
256	}
257}
258
259func isRetryableStatus(status int) bool {
260	if status == http.StatusForbidden || status == http.StatusTooManyRequests || status == http.StatusRequestTimeout {
261		return true
262	}
263	return status >= http.StatusInternalServerError
264}
265
266func isRetryableRequestError(err error) bool {
267	if err == nil {
268		return false
269	}
270	if errors.Is(err, context.Canceled) {
271		return false
272	}
273	if errors.Is(err, context.DeadlineExceeded) {
274		return true
275	}
276
277	var netErr net.Error
278	if errors.As(err, &netErr) {
279		if netErr.Timeout() {
280			return true
281		}
282		//nolint:staticcheck // net.Error.Temporary is deprecated but still useful for transient detection
283		if netErr.Temporary() {
284			return true
285		}
286	}
287
288	var opErr *net.OpError
289	if errors.As(err, &opErr) {
290		if isTransientSyscallError(opErr.Err) {
291			return true
292		}
293	}
294
295	return false
296}
297
298func isTransientSyscallError(err error) bool {
299	var errno syscall.Errno
300	if !errors.As(err, &errno) {
301		return false
302	}
303	switch errno {
304	case syscall.ECONNRESET, syscall.ECONNREFUSED,
305		syscall.EADDRNOTAVAIL, syscall.ENETUNREACH,
306		syscall.EHOSTUNREACH:
307		return true
308	}
309	return false
310}
311
312func waitForBackoff(ctx context.Context, d time.Duration) error {
313	timer := time.NewTimer(d)
314	defer timer.Stop()
315
316	select {
317	case <-ctx.Done():
318		return ctx.Err()
319	case <-timer.C:
320		return nil
321	}
322}
323
324func retryDelayForAttempt(attempt int, randInt63 func(n int64) int64) time.Duration {
325	if attempt < 1 {
326		attempt = 1
327	}
328	delay := retryBaseDelay
329	for i := 1; i < attempt; i++ {
330		if delay >= retryMaxDelay {
331			delay = retryMaxDelay
332			break
333		}
334		delay *= 2
335		if delay > retryMaxDelay {
336			delay = retryMaxDelay
337		}
338	}
339
340	if randInt63 != nil && retryJitterMax > 0 {
341		delay += time.Duration(randInt63(int64(retryJitterMax)))
342	}
343
344	if delay > retryMaxDelay {
345		delay = retryMaxDelay
346	}
347
348	return delay
349}
350
351// doRequestWithRetryHelper retries the same request instance across attempts.
352// It assumes req.Body is non-consumable (for example http.NoBody); it does not clone
353// the request or rewind req.Body. Callers needing retries with a real body must provide
354// a fresh cloned request per attempt or use a rewindable body implementation.
355func doRequestWithRetryHelper(
356	ctx context.Context,
357	client *http.Client,
358	req *http.Request,
359	sleep func(ctx context.Context, d time.Duration) error,
360	randInt63 func(n int64) int64,
361) (*http.Response, error) {
362	for attempt := 1; ; attempt++ {
363		if attempt > maxRetryAttempts {
364			return nil, fmt.Errorf("request failed after retries")
365		}
366		resp, err := client.Do(req)
367		if err != nil {
368			if !isRetryableRequestError(err) || attempt == maxRetryAttempts {
369				return nil, err
370			}
371			if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
372				return nil, waitErr
373			}
374			continue
375		}
376
377		if !isRetryableStatus(resp.StatusCode) || attempt == maxRetryAttempts {
378			return resp, nil
379		}
380
381		_ = resp.Body.Close()
382		if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
383			return nil, waitErr
384		}
385	}
386}
387
388func (g *GitHubChecker) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
389	return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
390}
391
392// validateDownloadURL ensures the URL is a valid GitHub releases URL.
393func validateDownloadURL(downloadURL string) error {
394	parsed, err := url.Parse(downloadURL)
395	if err != nil {
396		return fmt.Errorf("invalid URL: %w", err)
397	}
398
399	if parsed.Scheme != "https" {
400		return fmt.Errorf("URL must use HTTPS, got %s", parsed.Scheme)
401	}
402
403	if parsed.Host != allowedDownloadHost {
404		return fmt.Errorf("URL must be from %s, got %s", allowedDownloadHost, parsed.Host)
405	}
406
407	// Verify it's a releases download path: /owner/repo/releases/...
408	if !strings.Contains(parsed.Path, "/releases/") {
409		return fmt.Errorf("URL must be a GitHub releases URL")
410	}
411
412	return nil
413}
414
415// fetchChecksums downloads the checksums.txt file and returns a map of filename -> sha256.
416func (g *GitHubDownloader) fetchChecksums(ctx context.Context) (map[string]string, error) {
417	log := logging.FromContext(ctx)
418
419	req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumsURLTemplate, http.NoBody)
420	if err != nil {
421		return nil, fmt.Errorf("failed to create checksums request: %w", err)
422	}
423	req.Header.Set("User-Agent", "dumber-browser")
424
425	resp, err := g.doRequestWithRetry(ctx, req)
426	if err != nil {
427		return nil, fmt.Errorf("failed to fetch checksums: %w", err)
428	}
429	defer func() { _ = resp.Body.Close() }()
430
431	if resp.StatusCode != http.StatusOK {
432		return nil, fmt.Errorf("checksums fetch failed with status %d", resp.StatusCode)
433	}
434
435	checksums := make(map[string]string)
436	scanner := bufio.NewScanner(resp.Body)
437	for scanner.Scan() {
438		line := strings.TrimSpace(scanner.Text())
439		if line == "" {
440			continue
441		}
442		// Format: "sha256  filename" (two spaces between)
443		parts := strings.Fields(line)
444		if len(parts) >= 2 {
445			hash := parts[0]
446			filename := parts[len(parts)-1] // Last field is filename
447			checksums[filename] = hash
448		}
449	}
450
451	if err := scanner.Err(); err != nil {
452		return nil, fmt.Errorf("failed to parse checksums: %w", err)
453	}
454
455	log.Debug().Int("count", len(checksums)).Msg("fetched checksums")
456	return checksums, nil
457}
458
459// verifyChecksum verifies the SHA256 checksum of a file.
460func verifyChecksum(filePath, expectedHash string) error {
461	file, err := os.Open(filePath)
462	if err != nil {
463		return fmt.Errorf("failed to open file for checksum: %w", err)
464	}
465	defer func() { _ = file.Close() }()
466
467	hash := sha256.New()
468	if _, err := io.Copy(hash, file); err != nil {
469		return fmt.Errorf("failed to compute checksum: %w", err)
470	}
471
472	actualHash := hex.EncodeToString(hash.Sum(nil))
473	if !strings.EqualFold(actualHash, expectedHash) {
474		return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
475	}
476
477	return nil
478}
479
480// getExpectedChecksum fetches checksums and returns the hash for the given URL.
481func (g *GitHubDownloader) getExpectedChecksum(ctx context.Context, downloadURL string) (string, error) {
482	checksums, err := g.fetchChecksums(ctx)
483	if err != nil {
484		return "", fmt.Errorf("failed to fetch checksums: %w", err)
485	}
486
487	parsedURL, err := url.Parse(downloadURL)
488	if err != nil {
489		return "", fmt.Errorf("failed to parse download URL: %w", err)
490	}
491
492	archiveFilename := filepath.Base(parsedURL.Path)
493	expectedHash, ok := checksums[archiveFilename]
494	if !ok {
495		return "", fmt.Errorf("no checksum found for %s", archiveFilename)
496	}
497
498	return expectedHash, nil
499}
500
501// Download fetches the update archive from the given URL.
502func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) {
503	log := logging.FromContext(ctx)
504
505	// Validate the download URL is from GitHub releases.
506	if err := validateDownloadURL(downloadURL); err != nil {
507		return "", fmt.Errorf("invalid download URL: %w", err)
508	}
509
510	// Ensure destination directory exists.
511	if err := os.MkdirAll(destDir, execPerm); err != nil {
512		return "", fmt.Errorf("failed to create download directory: %w", err)
513	}
514
515	// Fetch and look up expected checksum for this archive.
516	expectedHash, err := g.getExpectedChecksum(ctx, downloadURL)
517	if err != nil {
518		return "", err
519	}
520
521	// Create the request.
522	req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody)
523	if err != nil {
524		return "", fmt.Errorf("failed to create download request: %w", err)
525	}
526	req.Header.Set("User-Agent", "dumber-browser")
527
528	log.Debug().Str("url", downloadURL).Msg("downloading update")
529
530	resp, err := g.doRequestWithRetry(ctx, req)
531	if err != nil {
532		return "", fmt.Errorf("failed to download: %w", err)
533	}
534	defer func() { _ = resp.Body.Close() }()
535
536	if resp.StatusCode != http.StatusOK {
537		return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
538	}
539
540	// Check Content-Length if available to enforce size limit.
541	if resp.ContentLength > maxArchiveSize {
542		return "", fmt.Errorf("archive too large: %d bytes (max %d)", resp.ContentLength, maxArchiveSize)
543	}
544
545	archivePath := filepath.Join(destDir, "dumber_update.tar.gz")
546
547	// Create the file.
548	file, err := os.Create(archivePath)
549	if err != nil {
550		return "", fmt.Errorf("failed to create archive file: %w", err)
551	}
552
553	// Use LimitReader to prevent unbounded downloads even if Content-Length is missing/spoofed.
554	limitedReader := io.LimitReader(resp.Body, maxArchiveSize+1)
555	written, err := io.Copy(file, limitedReader)
556	if closeErr := file.Close(); closeErr != nil && err == nil {
557		err = closeErr
558	}
559	if err != nil {
560		_ = os.Remove(archivePath)
561		return "", fmt.Errorf("failed to write archive: %w", err)
562	}
563
564	// Check if we hit the limit (means file was too large).
565	if written > maxArchiveSize {
566		_ = os.Remove(archivePath)
567		return "", fmt.Errorf("archive exceeds maximum size of %d bytes", maxArchiveSize)
568	}
569
570	// Verify checksum before returning.
571	if err := verifyChecksum(archivePath, expectedHash); err != nil {
572		_ = os.Remove(archivePath)
573		return "", fmt.Errorf("checksum verification failed: %w", err)
574	}
575
576	// Safely truncate hash for logging (SHA256 is 64 chars, but be defensive).
577	displayHash := expectedHash
578	if len(displayHash) > 16 {
579		displayHash = displayHash[:16] + "..."
580	}
581
582	log.Debug().
583		Int64("bytes", written).
584		Str("path", archivePath).
585		Str("checksum", displayHash).
586		Msg("download completed and verified")
587
588	return archivePath, nil
589}
590
591func (g *GitHubDownloader) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
592	return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
593}
594
595// sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal.
596func sanitizeTarPath(name, destDir string) (string, error) {
597	// Clean the path to remove any . or .. components.
598	cleaned := filepath.Clean(name)
599
600	// Reject absolute paths.
601	if filepath.IsAbs(cleaned) {
602		return "", fmt.Errorf("absolute path not allowed: %s", name)
603	}
604
605	// Reject paths that contain ".." as a path component (not as part of filename).
606	// Split by separator and check each component.
607	for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
608		if part == ".." {
609			return "", fmt.Errorf("path traversal detected: %s", name)
610		}
611	}
612
613	// Join with destination and verify result is within destDir.
614	fullPath := filepath.Join(destDir, cleaned)
615	absDestDir, err := filepath.Abs(destDir)
616	if err != nil {
617		return "", fmt.Errorf("failed to get absolute dest path: %w", err)
618	}
619	absFullPath, err := filepath.Abs(fullPath)
620	if err != nil {
621		return "", fmt.Errorf("failed to get absolute full path: %w", err)
622	}
623
624	// Ensure the resolved path is within destDir.
625	if !strings.HasPrefix(absFullPath, absDestDir+string(filepath.Separator)) && absFullPath != absDestDir {
626		return "", fmt.Errorf("path escapes destination directory: %s", name)
627	}
628
629	return fullPath, nil
630}
631
632// Extract extracts the dumber binary from the tar.gz archive.
633func (*GitHubDownloader) Extract(ctx context.Context, archivePath, destDir string) (string, error) {
634	log := logging.FromContext(ctx)
635
636	file, err := os.Open(archivePath)
637	if err != nil {
638		return "", fmt.Errorf("failed to open archive: %w", err)
639	}
640	defer func() { _ = file.Close() }()
641
642	gzr, err := gzip.NewReader(file)
643	if err != nil {
644		return "", fmt.Errorf("failed to create gzip reader: %w", err)
645	}
646	defer func() { _ = gzr.Close() }()
647
648	tr := tar.NewReader(gzr)
649
650	var binaryPath string
651
652	for {
653		header, err := tr.Next()
654		if err == io.EOF {
655			break
656		}
657		if err != nil {
658			return "", fmt.Errorf("failed to read tar: %w", err)
659		}
660
661		// Look for the dumber binary (inside dumber_VERSION/ directory).
662		if header.Typeflag == tar.TypeReg && strings.HasSuffix(header.Name, "/dumber") {
663			// Sanitize the path to prevent path traversal attacks.
664			_, err := sanitizeTarPath(header.Name, destDir)
665			if err != nil {
666				return "", fmt.Errorf("invalid tar entry: %w", err)
667			}
668
669			// We always extract to a fixed name regardless of archive path.
670			binaryPath = filepath.Join(destDir, "dumber")
671
672			outFile, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, execPerm)
673			if err != nil {
674				return "", fmt.Errorf("failed to create binary file: %w", err)
675			}
676
677			// Limit copy to prevent decompression bombs (G110).
678			written, err := io.CopyN(outFile, tr, maxBinarySize)
679			if closeErr := outFile.Close(); closeErr != nil && err == nil {
680				err = closeErr
681			}
682			if err != nil && !errors.Is(err, io.EOF) {
683				_ = os.Remove(binaryPath)
684				return "", fmt.Errorf("failed to extract binary: %w", err)
685			}
686
687			// Verify minimum binary size to detect truncated/corrupt files.
688			if written < minBinarySize {
689				_ = os.Remove(binaryPath)
690				return "", fmt.Errorf("binary too small (%d bytes), expected at least %d bytes", written, minBinarySize)
691			}
692
693			log.Debug().Int64("bytes", written).Str("path", binaryPath).Msg("extracted binary")
694			break
695		}
696	}
697
698	if binaryPath == "" {
699		return "", fmt.Errorf("dumber binary not found in archive")
700	}
701
702	return binaryPath, nil
703}

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