detached 83e495f

web.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package web is the HTML front of ermite: the pages of the read-only
4// showcase, rendered with templ from the data of the use cases.
5//
6// Like every other inbound adapter, the package reads and writes through the
7// use case interfaces of package ports (ADR 0024), never through the API. The
8// caller of every use case call is passed explicitly: the principal of the
9// request, which is anonymous unless the request carries a valid owner session
10// (ServeHTTP sets it in the context, principal reads it). The use cases then
11// show public repositories only to an anonymous caller; a private repository
12// is indistinguishable from a missing one (domain.ErrNotFound), here as
13// everywhere.
14//
15// Adding a page: a handler on Server, a route in routes, a templ page in
16// pages.templ and its view model in view.go.
17package web
18
19import (
20	"bytes"
21	"context"
22	"crypto/rand"
23	"encoding/hex"
24	"errors"
25	"fmt"
26	"net/http"
27	"net/netip"
28	"net/url"
29	"strings"
30	"time"
31
32	"github.com/a-h/templ"
33	"github.com/bnema/zerowrap"
34
35	"bnema.dev/ermite/internal/domain"
36	"bnema.dev/ermite/internal/ports"
37)
38
39// Patterns are the ServeMux patterns the handler serves: everything. The
40// router of the package answers the paths it does not know with its 404
41// page, and the more specific patterns of the server (health, API, Git) win
42// over this one.
43var Patterns = []string{"/"}
44
45// requestTimeout bounds the use case calls that build one page.
46const requestTimeout = 30 * time.Second
47
48// Config configures a Server.
49type Config struct {
50	// Mirrors answers the push mirror badge shown to repository admins. Without it there is
51	// no badge. Leave it unset rather than assigning a typed nil pointer.
52	Mirrors ports.MirrorWarner
53	// Repos and Browse are the use cases the pages are built from.
54	Repos  ports.RepoService
55	Browse ports.BrowseService
56	// MergeRequests serves the merge request pages (ADR 0017). Without it
57	// there are no such pages, nor section for them. Leave it unset rather
58	// than assigning a typed nil pointer.
59	MergeRequests ports.MergeRequestService
60	// Issues serves the issue pages (ADR 0031). Without it there are no such
61	// pages, nor tab for them. Leave it unset rather than assigning a typed
62	// nil pointer.
63	Issues ports.IssueService
64	// IssueNumbers tells whether a number names an issue or a merge request,
65	// so that the page of the wrong kind redirects to the right one. Without
66	// it there is no such redirect. Leave it unset rather than assigning a
67	// typed nil pointer.
68	IssueNumbers ports.IssueNumberResolver
69	// IssueForm serves the public issue form of repositories whose issues
70	// mode is public (GET and POST /<repo>/issues/new for a visitor). Without
71	// it there is no such form. Leave it unset rather than assigning a typed
72	// nil pointer.
73	IssueForm ports.IssueSubmissionService
74	// Moderation serves the Approve, Reject and Reject-and-ban buttons of the
75	// pending issues in the inbox. It needs Inbox. Without it there are no
76	// such routes. Leave it unset rather than assigning a typed nil pointer.
77	Moderation ports.IssueModerationService
78	// Inbox serves the owner's inbox page (/-/inbox) and the button that
79	// leads to it. Without it there is neither. Leave it unset rather than
80	// assigning a typed nil pointer.
81	Inbox ports.InboxService
82	// SigningKey tells the key that signs the squash and merge commits, shown
83	// in the merge box. Without it the box does not name the key. Leave it
84	// unset rather than assigning a typed nil pointer.
85	SigningKey ports.SigningKeyService
86	// Runs serves the CI pages (/<repo>/actions) and the checks shown next to
87	// commits and merge requests. Without it there are no such pages. Leave it
88	// unset rather than assigning a typed nil pointer.
89	Runs ports.RunService
90	// Artifacts serves the artifacts of the runs: a section of the run page,
91	// their download and their deletion. It needs Runs. Without it there is
92	// neither. Leave it unset rather than assigning a typed nil pointer.
93	Artifacts ports.ArtifactService
94	// Events is the source of the event stream behind /-/events. Without
95	// it the pages are not live and the route answers 404. Leave it unset
96	// rather than assigning a typed nil pointer.
97	Events ports.EventService
98	// Heartbeat is how often an idle event stream gets a comment line;
99	// DefaultHeartbeat when zero.
100	Heartbeat time.Duration
101	// Owner is the owner login the GitHub-shaped URLs of CI show
102	// (<server>/<owner>/<repo>/...). A GET or HEAD of /<owner>/<rest> is
103	// redirected to /<rest> unless a repository named like the owner is
104	// visible to the caller. Empty: no redirect.
105	Owner string
106	// PublicURL is the base URL of the instance, without a trailing slash:
107	// the clone URLs are built on it, and its host is the instance name.
108	PublicURL string
109	// SourceURL is the link to the source of the running version (AGPL §13).
110	SourceURL string
111	// Version is the version of the running server.
112	Version string
113	// Now is the clock of relative dates; time.Now when nil.
114	Now func() time.Time
115	// Sessions signs the owner in (ADR 0018). Without it there are no login
116	// routes and every visitor is anonymous.
117	Sessions ports.WebSessionService
118	// Passkeys signs the owner in with a passkey and manages the passkeys
119	// (ADR 0026). Without it, or without Sessions, there are no passkey
120	// routes and no link to them. Leave it unset rather than assigning a typed
121	// nil pointer.
122	Passkeys ports.WebPasskeyService
123	// Limiter counts the failed logins; required with Sessions. Share it with
124	// the API and Git, so that a client has one budget of failures.
125	Limiter ports.FailureLimiter
126	// TrustedProxies are the networks allowed to set X-Forwarded-For, to find
127	// the address of the client that fails a login.
128	TrustedProxies []netip.Prefix
129}
130
131// Server serves the pages. It is an http.Handler.
132type Server struct {
133	cfg      Config
134	instance string
135	origin   string // scheme://host of the public URL, what a POST must come from
136	assets   *assetSet
137	md       *markdown
138	// pathCache holds the path lists the file finder fetches.
139	pathCache *pathCache
140	// hl holds the colours of highlighted diffs.
141	hl  *hlCache
142	mux *http.ServeMux
143	// ownerRepo is the owner login as a repository name, zero when it is not
144	// a valid one (then no repository can be named like the owner).
145	ownerRepo domain.RepoName
146	// refsFan shares the fragments of the refs page between the watchers of
147	// a push; entries are few and short-lived.
148	refsFan fanout
149	// pulses holds the activity curves of the home rows.
150	pulses pulseCache
151}
152
153// New builds the web handler.
154func New(cfg Config) (*Server, error) {
155	if cfg.Repos == nil || cfg.Browse == nil {
156		return nil, errors.New("web: the repository and browse use cases are required")
157	}
158	u, err := url.Parse(cfg.PublicURL)
159	if err != nil || u.Host == "" {
160		return nil, fmt.Errorf("web: public URL %q is not a valid base URL", cfg.PublicURL)
161	}
162	if cfg.Now == nil {
163		cfg.Now = time.Now
164	}
165	assets, err := loadAssets()
166	if err != nil {
167		return nil, fmt.Errorf("web: load assets: %w", err)
168	}
169	if cfg.Sessions != nil && cfg.Limiter == nil {
170		return nil, errors.New("web: sessions need a failure limiter")
171	}
172	s := &Server{
173		cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
174		pathCache: newPathCache(pathCacheBytes, pathBuilds),
175		hl:        newHLCache(hlCacheBytes),
176		origin:    originOf(u),
177	}
178	s.ownerRepo, _ = domain.ParseRepoName(cfg.Owner)
179	s.mux = s.routes()
180	return s, nil
181}
182
183// routes is the router of the web. Any path that is not a page is the 404
184// page.
185func (s *Server) routes() *http.ServeMux {
186	mux := http.NewServeMux()
187	mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
188	mux.HandleFunc("GET /-/events", s.events)
189	mux.HandleFunc("GET "+ServiceWorkerPath, s.assets.sw.serve)
190	if s.cfg.Sessions != nil {
191		mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm)
192		mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
193		mux.HandleFunc("POST /-/logout", s.logout)
194	}
195	if s.passkeysOn() {
196		s.passkeyRoutes(mux)
197	}
198	if s.cfg.Inbox != nil {
199		mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
200		if s.cfg.Moderation != nil {
201			mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
202			mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
203			mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
204		}
205	}
206	mux.HandleFunc("GET /{$}", s.page(s.home))
207	mux.HandleFunc("GET /{repo}", s.page(s.summary))
208	mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
209	mux.HandleFunc("GET /{repo}/refs", s.page(s.refs))
210	mux.HandleFunc("GET /{repo}/commits", s.page(s.commits))
211	mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits))
212	mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit))
213	mux.HandleFunc("GET /{repo}/commit/{sha}/files", s.page(s.commitFiles))
214	mux.HandleFunc("GET /{repo}/tree", s.page(s.tree))
215	mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
216	mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
217	mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
218	mux.HandleFunc("GET /{repo}/paths/{sha}", s.paths)
219	mux.HandleFunc("GET /{repo}/context/{sha}", s.page(s.diffContext))
220	if s.cfg.MergeRequests != nil {
221		mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
222		mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
223		mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
224		mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
225		mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
226		mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
227		// The writes: POST only, behind the gate (ServeHTTP).
228		mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
229		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
230		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
231		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/edit", s.mrAction(s.mrEditComment))
232		mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/delete", s.mrAction(s.mrDeleteComment))
233		mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
234		mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
235		mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
236		mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
237	}
238	if s.cfg.Issues != nil {
239		mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
240		mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
241		mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
242		mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
243		// The writes: POST only, behind the gate (ServeHTTP).
244		mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
245		mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
246		mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
247		mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
248	}
249	if s.cfg.IssueForm != nil {
250		mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
251		mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
252	}
253	if s.cfg.Runs != nil {
254		mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
255		mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
256		mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run))
257		mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job))
258		mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog)
259		mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream)
260		mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel))
261		mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
262		mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
263		if s.cfg.Artifacts != nil {
264			mux.HandleFunc("GET /{repo}/actions/runs/{id}/artifacts/{artifact}", s.artifactDownload)
265			mux.HandleFunc("POST /{repo}/actions/runs/{id}/artifacts/{artifact}/delete", s.ciAction(s.artifactDelete))
266		}
267	}
268	mux.HandleFunc(catchAll, s.page(s.notFound))
269	return mux
270}
271
272// ServeHTTP implements http.Handler.
273func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
274	// Whatever the context carried, the caller is anonymous until a valid
275	// session says otherwise (withSession).
276	r = r.WithContext(domain.ContextWithPrincipal(r.Context(), domain.Anonymous()))
277	r = s.withSession(w, r)
278	if !s.gate(w, r) {
279		return
280	}
281	if to, ok := s.ownerRedirect(r); ok {
282		http.Redirect(w, r, to, http.StatusPermanentRedirect)
283		return
284	}
285	s.mux.ServeHTTP(w, r)
286}
287
288// catchAll is the pattern of the paths that are no page.
289const catchAll = "GET /{repo}/{rest...}"
290
291// ownerRedirect maps /<owner>/<repo>[.git][/rest] to /<repo>[.git][/rest]: the
292// URLs that the github context of CI shows (html_url, commit and compare
293// URLs, clone_url) have the owner of the instance in front, like GitHub's.
294//
295// The rule is by repository, not by route: when no repository named like the
296// owner is visible to the caller, every GET or HEAD of /<owner>/<rest> is
297// redirected, so /<owner>/actions reaches the repository named "actions". A
298// repository named like the owner keeps its own pages. A private one is seen
299// as missing by an anonymous caller (the use case answers ErrNotFound), so the
300// redirect is the same as for a name nobody has and tells nothing.
301//
302// The target is built from a segment that parses as a repository name, which
303// has no slash, backslash, percent sign or dot-dot: it can only be a path on
304// this origin, never a "//host" one.
305func (s *Server) ownerRedirect(r *http.Request) (string, bool) {
306	if s.cfg.Owner == "" || (r.Method != http.MethodGet && r.Method != http.MethodHead) {
307		return "", false
308	}
309	first, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.EscapedPath(), "/"), "/")
310	if !ok || !domain.OwnerMatches(s.cfg.Owner, first) || rest == "" {
311		return "", false
312	}
313	repo, _, _ := strings.Cut(rest, "/")
314	if _, err := domain.ParseRepoPath(repo); err != nil {
315		return "", false
316	}
317	if s.ownerNamesARepo(r) {
318		return "", false
319	}
320	to := "/" + rest
321	if r.URL.RawQuery != "" {
322		to += "?" + r.URL.RawQuery
323	}
324	return to, true
325}
326
327// ownerNamesARepo tells whether the caller can see a repository named like
328// the owner. A lookup that fails otherwise counts as yes: the request is then
329// served as it was before, without a redirect.
330func (s *Server) ownerNamesARepo(r *http.Request) bool {
331	if s.ownerRepo.IsZero() {
332		return false // the owner login is not a repository name
333	}
334	ctx := r.Context()
335	_, err := s.cfg.Repos.Get(ctx, principal(ctx), s.ownerRepo)
336	switch {
337	case err == nil:
338		return true
339	case errors.Is(err, domain.ErrNotFound):
340		return false
341	}
342	log := zerowrap.FromCtx(ctx)
343	log.Debug().Err(err).Msg("owner redirect: the lookup of the repository named like the owner failed")
344	return true
345}
346
347// handler builds a page. It returns the component and its status, or an
348// error, which becomes the 404 or 500 page.
349type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error)
350
351// page adapts a handler: it bounds the use case calls, renders the component into
352// a buffer (so that a failure never leaves half a page), and maps errors to
353// the state pages.
354func (s *Server) page(h handler) http.HandlerFunc {
355	return func(w http.ResponseWriter, r *http.Request) {
356		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
357		defer cancel()
358		c, status, err := h(ctx, r)
359		if err != nil {
360			s.fail(ctx, w, r, err)
361			return
362		}
363		s.write(ctx, w, r, status, c)
364	}
365}
366
367// write renders c with the given status.
368func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
369	var buf bytes.Buffer
370	if err := c.Render(ctx, &buf); err != nil {
371		// The error page itself failed: a bare answer is all that is left.
372		log := zerowrap.FromCtx(ctx)
373		log.Error().Err(err).Str("path", logPath(r.URL.Path)).Msg("render page")
374		http.Error(w, "internal error", http.StatusInternalServerError)
375		return
376	}
377	h := w.Header()
378	h.Set("Content-Type", "text/html; charset=utf-8")
379	// Links on this origin are prefetched on hover, so a click shows the page
380	// at once. Every GET page is safe to load (nothing is marked read on GET).
381	h.Set("Speculation-Rules", `"`+s.assets.urls.Rules+`"`)
382	// Public pages: the browser and any proxy revalidate each time. A page
383	// rendered for the owner is private and never stored; a handler that set
384	// its own Cache-Control keeps it.
385	if h.Get("Cache-Control") == "" {
386		cacheHeaders(ctx, h, "no-cache", sessionCache)
387	}
388	body := buf.Bytes()
389	// Whether the body is sent gzipped follows from its size and the request
390	// alone, so that the tag is known before the compression is spent.
391	gz := len(body) >= minCompress && acceptsGzip(r.Header) &&
392		!noTransform(h) && h.Get("Content-Encoding") == ""
393	addVary(h, "Accept-Encoding")
394	readOnly := r.Method == http.MethodGet || r.Method == http.MethodHead
395	// A page that may be stored gets a validator: the browser then
396	// revalidates with If-None-Match and a page that did not change costs a
397	// 304. A no-store page (the owner's) is never revalidated.
398	if readOnly && status == http.StatusOK && h.Get("ETag") == "" && !hasCacheDirective(h, noStore) {
399		tag := pageETag(body, SignedIn(ctx), gz)
400		h.Set("ETag", tag)
401		if etagMatches(strings.Join(r.Header.Values("If-None-Match"), ","), tag) {
402			h.Del("Content-Type")
403			w.WriteHeader(http.StatusNotModified)
404			return
405		}
406	}
407	if gz {
408		if z, err := gzipPage(body); err == nil && len(z) < len(body) {
409			body = z
410			h.Set("Content-Encoding", "gzip")
411		}
412	}
413	h.Set("Content-Length", itoa(len(body)))
414	w.WriteHeader(status)
415	if r.Method != http.MethodHead {
416		_, _ = w.Write(body)
417	}
418}
419
420// fail answers an error of a use case: not found (and a name or revision the
421// use case refuses, which no page can show either) gives the 404 page, a
422// visitor that left gives nothing, anything else gives the 500 page with an id
423// to find the cause in the log.
424func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
425	switch {
426	case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err):
427		s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
428	case errors.Is(err, context.Canceled):
429		// The client is gone; there is nobody to answer.
430	default:
431		id := requestID()
432		log := zerowrap.FromCtx(ctx)
433		log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web request failed")
434		s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
435	}
436}
437
438// errNotFound is returned by handlers for a resource that does not exist
439// although the use case did not say so (a ref or a path that is not there).
440var errNotFound = domain.ErrNotFound
441
442// requestID is a short random id, shown on the 500 page and logged.
443func requestID() string {
444	var b [4]byte
445	_, _ = rand.Read(b[:])
446	return hex.EncodeToString(b[:])
447}
448
449// notFound is the handler of the paths without a page.
450func (s *Server) notFound(context.Context, *http.Request) (templ.Component, int, error) {
451	return nil, 0, errNotFound
452}
453
454// base is the Layout every page starts from.
455func (s *Server) base() Layout {
456	return Layout{
457		Owner:     s.cfg.Owner,
458		Version:   s.cfg.Version,
459		SourceURL: s.cfg.SourceURL,
460		Assets:    s.assets.urls,
461		Passkeys:  s.passkeysOn(),
462	}
463}
464
465// stateLayout is the layout of a state page: only the way home.
466func (s *Server) stateLayout(title string, crumbs []Crumb) Layout {
467	l := s.base()
468	l.Title = title + " · " + s.instance
469	l.Crumbs = crumbs
470	l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
471	return l
472}
473
474func (s *Server) notFoundPage() templ.Component {
475	l := s.stateLayout("404", []Crumb{{Label: "not found", Current: true}})
476	return State(l, StatePage{
477		Code: "404", Title: "not found",
478		Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},
479		Back:  []Link{{Label: "home", Href: "/"}},
480	})
481}
482
483func (s *Server) errorPage(id string) templ.Component {
484	l := s.stateLayout("error", []Crumb{{Label: "error", Current: true}})
485	return State(l, StatePage{
486		Code: "500", Title: "internal error",
487		Lines: []string{
488			"the server failed to answer this request.",
489			"request id: " + id,
490			"retry in a moment; if it persists, the owner has the log.",
491		},
492		Back: []Link{{Label: "home", Href: "/"}},
493	})
494}
495
496// originOf is the origin of a URL as a browser writes it in the Origin
497// header: scheme and host in lower case, the port only when it is not the
498// default of the scheme.
499func originOf(u *url.URL) string {
500	scheme, host, port := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()), u.Port()
501	if strings.Contains(host, ":") {
502		host = "[" + host + "]"
503	}
504	defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
505	if port != "" && !defaultPort {
506		host += ":" + port
507	}
508	return scheme + "://" + host
509}

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 4

Tags 45

↑↓ moveenter openesc close