v0.29.0 21b3150

scheme_handler.go RawHistoryFolder

1package webkit
2
3import (
4	"bytes"
5	"context"
6	"embed"
7	"fmt"
8	"io"
9	"io/fs"
10	"net/http"
11	"net/url"
12	"os"
13	"path"
14	"strconv"
15	"strings"
16	"sync"
17
18	"github.com/andybalholm/brotli"
19	"github.com/bnema/dumber/internal/application/port"
20	domainurl "github.com/bnema/dumber/internal/domain/url"
21	"github.com/bnema/dumber/internal/infrastructure/webutil"
22	"github.com/bnema/dumber/internal/logging"
23	"github.com/bnema/puregotk/v4/gio"
24	"github.com/bnema/puregotk/v4/soup"
25	"github.com/bnema/puregotk/v4/webkit"
26	"github.com/rs/zerolog"
27)
28
29// Scheme path constants
30const (
31	HistoryPath             = "history"
32	FavoritesPath           = "favorites"
33	ConfigPath              = "config"
34	ErrorPath               = "error"
35	CrashPath               = "crash"
36	IndexHTML               = "index.html"
37	httpGET                 = "GET"
38	maxSystemviewsWASMBytes = 64 * 1024 * 1024
39	systemviewsAssetDir     = "systemviews"
40)
41
42// SchemeRequest represents a request to a custom URI scheme.
43type SchemeRequest struct {
44	inner   *webkit.URISchemeRequest
45	URI     string
46	Path    string
47	Method  string
48	Scheme  string
49	Origin  string
50	Referer string
51}
52
53// SchemeResponse represents a response to a scheme request.
54type SchemeResponse struct {
55	Data                   []byte
56	ContentType            string
57	StatusCode             int
58	Headers                map[string]string
59	SuppressDefaultHeaders bool
60}
61
62// PageHandler generates content for a specific page path.
63type PageHandler interface {
64	Handle(req *SchemeRequest) *SchemeResponse
65}
66
67// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
68type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
69
70func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
71	return f(req)
72}
73
74// DumbSchemeHandler handles dumb:// URI scheme requests.
75type DumbSchemeHandler struct {
76	handlers             map[string]PageHandler
77	assets               embed.FS
78	faviconService       port.FaviconService
79	assetDir             string // default subdirectory within embed.FS (e.g., "systemviews")
80	logger               zerolog.Logger
81	mu                   sync.RWMutex
82	currentConfigPayload func() ([]byte, error)
83	defaultConfigPayload func() ([]byte, error)
84}
85
86// NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
87func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
88	log := logging.FromContext(ctx)
89
90	h := &DumbSchemeHandler{
91		handlers: make(map[string]PageHandler),
92		assetDir: systemviewsAssetDir,
93		logger:   log.With().Str("component", "scheme-handler").Logger(),
94	}
95
96	// Register default pages
97	h.registerDefaults()
98
99	return h
100}
101
102// SetAssets sets the embedded filesystem containing systemviews assets.
103func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
104	h.mu.Lock()
105	defer h.mu.Unlock()
106	h.assets = assets
107	h.logger.Debug().Msg("assets filesystem configured")
108}
109
110// SetConfigPayloadBuilders wires the config payload builders used by /api/config.
111func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload func() ([]byte, error)) {
112	h.mu.Lock()
113	defer h.mu.Unlock()
114	h.currentConfigPayload = current
115	h.defaultConfigPayload = defaultPayload
116	h.logger.Debug().Msg("config payload builders configured")
117}
118
119func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) {
120	h.mu.Lock()
121	defer h.mu.Unlock()
122	h.faviconService = service
123}
124
125// registerDefaults sets up default page handlers.
126func (h *DumbSchemeHandler) registerDefaults() {
127	// Error page (static fallback)
128	h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse {
129		return &SchemeResponse{
130			Data:        []byte(errorPageHTML),
131			ContentType: "text/html",
132			StatusCode:  http.StatusOK,
133		}
134	}))
135
136	// Crash page (web process termination fallback)
137	h.RegisterPage("/"+CrashPath, PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
138		if req.Method != "" && req.Method != httpGET {
139			return nil
140		}
141		originalURI := sanitizeCrashPageOriginalURI(crashOriginalURI(req.URI))
142		return &SchemeResponse{
143			Data:        []byte(buildCrashPageHTML(originalURI)),
144			ContentType: "text/html; charset=utf-8",
145			StatusCode:  http.StatusOK,
146		}
147	}))
148
149	// API: Get current config (used by dumb://config)
150	h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
151		if req.Method != "" && req.Method != httpGET {
152			return nil
153		}
154		if !isTrustedSystemviewAPIRequest(req) {
155			return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
156		}
157
158		h.mu.RLock()
159		build := h.currentConfigPayload
160		h.mu.RUnlock()
161		return buildConfigResponse(build)
162	}))
163
164	// API: Get default config (used by Reset Defaults in dumb://config)
165	h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
166		if req.Method != "" && req.Method != httpGET {
167			return nil
168		}
169		if !isTrustedSystemviewAPIRequest(req) {
170			return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
171		}
172
173		h.mu.RLock()
174		build := h.defaultConfigPayload
175		h.mu.RUnlock()
176		return buildConfigResponse(build)
177	}))
178
179	h.RegisterPage("/api/favicon", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
180		if req.Method != "" && req.Method != httpGET {
181			return nil
182		}
183		return h.handleFaviconAPI(req)
184	}))
185}
186
187func crashOriginalURI(requestURI string) string {
188	if requestURI == "" {
189		return ""
190	}
191	parsed, err := url.Parse(requestURI)
192	if err != nil {
193		return ""
194	}
195	return strings.TrimSpace(parsed.Query().Get("url"))
196}
197
198func sanitizeCrashPageOriginalURI(originalURI string) string {
199	return webutil.SanitizeCrashPageOriginalURI(originalURI)
200}
201
202func buildCrashPageHTML(originalURI string) string {
203	return webutil.BuildCrashPageHTML(originalURI)
204}
205
206const systemviewFaviconSize = 32
207
208func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse {
209	if !isTrustedSystemviewFaviconRequest(req) {
210		return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
211	}
212	h.mu.RLock()
213	service := h.faviconService
214	h.mu.RUnlock()
215	if service == nil {
216		return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
217	}
218	if req == nil {
219		return privateJSONErrorResponse(http.StatusBadRequest, "invalid request")
220	}
221	parsed, err := url.Parse(req.URI)
222	if err != nil {
223		return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")
224	}
225	domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))
226	if domain == "" {
227		return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")
228	}
229	size := systemviewFaviconSize
230	if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
231		parsedSize, parseErr := strconv.Atoi(rawSize)
232		if parseErr != nil || parsedSize != systemviewFaviconSize {
233			return privateJSONErrorResponse(http.StatusBadRequest, "unsupported favicon size")
234		}
235		size = parsedSize
236	}
237
238	if !service.HasPNGSizedOnDisk(domain, size) {
239		return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
240	}
241	diskPath := service.DiskPathPNGSized(domain, size)
242	if diskPath == "" {
243		return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
244	}
245	data, err := os.ReadFile(diskPath)
246	if err != nil || len(data) == 0 {
247		return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
248	}
249	return &SchemeResponse{
250		Data:                   data,
251		ContentType:            "image/png",
252		StatusCode:             http.StatusOK,
253		Headers:                map[string]string{"Cache-Control": "no-store"},
254		SuppressDefaultHeaders: true,
255	}
256}
257
258func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool {
259	return isTrustedSystemviewAPIRequest(req)
260}
261
262func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool {
263	if req == nil {
264		return false
265	}
266	origin := strings.TrimSpace(req.Origin)
267	if origin != "" {
268		return isTrustedSystemviewURL(origin)
269	}
270	return isTrustedSystemviewURL(req.Referer)
271}
272
273func isTrustedSystemviewURL(raw string) bool {
274	parsed, err := url.Parse(strings.TrimSpace(raw))
275	if err != nil || parsed.Scheme == "" {
276		return false
277	}
278	if !strings.EqualFold(parsed.Scheme, "dumb") {
279		return false
280	}
281	host := parsed.Host
282	if host == "" {
283		host = parsed.Opaque
284	}
285	if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
286		host = host[:idx]
287	}
288	switch host {
289	case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
290		return true
291	default:
292		return false
293	}
294}
295
296func jsonErrorResponse(status int, message string) *SchemeResponse {
297	return &SchemeResponse{
298		Data:        []byte(fmt.Sprintf(`{"error":%q}`, message)),
299		ContentType: "application/json",
300		StatusCode:  status,
301	}
302}
303
304func privateJSONErrorResponse(status int, message string) *SchemeResponse {
305	resp := jsonErrorResponse(status, message)
306	resp.Headers = map[string]string{"Cache-Control": "no-store"}
307	resp.SuppressDefaultHeaders = true
308	return resp
309}
310
311func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
312	if build == nil {
313		resp := privateJSONErrorResponse(http.StatusInternalServerError, "config payload builder not configured")
314		return resp
315	}
316
317	data, err := build()
318	if err != nil {
319		resp := privateJSONErrorResponse(http.StatusInternalServerError, err.Error())
320		return resp
321	}
322
323	return &SchemeResponse{
324		Data:                   data,
325		ContentType:            "application/json",
326		StatusCode:             http.StatusOK,
327		Headers:                map[string]string{"Cache-Control": "no-store"},
328		SuppressDefaultHeaders: true,
329	}
330}
331
332// RegisterPage registers a handler for a specific path.
333func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) {
334	h.mu.Lock()
335	defer h.mu.Unlock()
336	h.handlers[pagePath] = handler
337	h.logger.Debug().Str("path", pagePath).Msg("registered page handler")
338}
339
340// HandleRequest processes a scheme request and sends the response.
341func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
342	req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
343	if req == nil {
344		return
345	}
346
347	uri := req.GetUri()
348	requestHeaders := req.GetHttpHeaders()
349	schemeReq := &SchemeRequest{
350		inner:  req,
351		URI:    uri,
352		Path:   req.GetPath(),
353		Method: req.GetHttpMethod(),
354		Scheme: req.GetScheme(),
355	}
356	if requestHeaders != nil {
357		schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin"))
358		schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer"))
359	}
360
361	h.logger.Debug().
362		Str("uri", schemeReq.URI).
363		Str("path", schemeReq.Path).
364		Str("method", schemeReq.Method).
365		Msg("handling scheme request")
366
367	// Parse the URI to extract host and path
368	u, err := url.Parse(uri)
369	if err != nil || u.Scheme != "dumb" {
370		h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI")
371		h.sendResponse(req, &SchemeResponse{
372			Data:        []byte("Invalid URI"),
373			ContentType: "text/plain",
374			StatusCode:  http.StatusBadRequest,
375		})
376		return
377	}
378
379	// API endpoints should never be treated as static assets
380	if strings.HasPrefix(schemeReq.Path, "/api/") {
381		h.mu.RLock()
382		handler, ok := h.handlers[schemeReq.Path]
383		if !ok {
384			handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
385		}
386		h.mu.RUnlock()
387		if ok {
388			response := handler.Handle(schemeReq)
389			h.sendResponse(req, response)
390			return
391		}
392	}
393
394	// Try to serve from embedded assets
395	if response := h.handleAsset(u); response != nil {
396		h.sendResponse(req, response)
397		return
398	}
399
400	// Fall back to registered handlers
401	h.mu.RLock()
402	handler, ok := h.handlers[schemeReq.Path]
403	if !ok {
404		handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
405	}
406	h.mu.RUnlock()
407
408	var response *SchemeResponse
409	if ok {
410		response = handler.Handle(schemeReq)
411	} else {
412		response = &SchemeResponse{
413			Data:        []byte(notFoundHTML),
414			ContentType: "text/html",
415			StatusCode:  http.StatusNotFound,
416		}
417	}
418
419	h.sendResponse(req, response)
420}
421
422// handleAsset serves static assets from the embedded filesystem.
423// Returns nil if no asset was found (allowing fallback to registered handlers).
424func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
425	h.mu.RLock()
426	hasAssets := h.assets != (embed.FS{})
427	h.mu.RUnlock()
428
429	if !hasAssets {
430		return nil
431	}
432
433	assetDir, relPath, ok := resolveAssetPath(u)
434	if !ok {
435		return nil
436	}
437	if assetDir == "" {
438		assetDir = h.assetDir
439	}
440
441	fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
442	if !ok {
443		return nil
444	}
445
446	data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
447	if err != nil {
448		h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
449		return nil
450	}
451
452	contentType := webutil.GetMimeType(relPath)
453	h.logger.Debug().
454		Str("path", fullPath).
455		Str("content_type", contentType).
456		Int("size", len(data)).
457		Msg("serving asset")
458
459	return &SchemeResponse{
460		Data:        data,
461		ContentType: contentType,
462		StatusCode:  http.StatusOK,
463		Headers:     nil,
464	}
465}
466
467func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
468	assetDir = strings.Trim(assetDir, "/")
469	if assetDir != systemviewsAssetDir {
470		return "", "", false
471	}
472
473	relPath = strings.TrimLeft(relPath, "/")
474	if relPath == "" || strings.ContainsRune(relPath, '\x00') {
475		return "", "", false
476	}
477
478	cleanRelPath = path.Clean(relPath)
479	if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
480		return "", "", false
481	}
482
483	fullPath = path.Join(assetDir, cleanRelPath)
484	if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
485		return "", "", false
486	}
487	return fullPath, cleanRelPath, true
488}
489
490func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
491	var compressedErr error
492	if strings.HasSuffix(relPath, ".wasm") {
493		if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
494			data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
495			if err != nil {
496				compressedErr = err
497			} else if len(data) > maxSystemviewsWASMBytes {
498				compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
499			} else {
500				return data, nil
501			}
502		}
503	}
504	data, err := fs.ReadFile(assets, fullPath)
505	if err == nil {
506		return data, nil
507	}
508	if compressedErr != nil {
509		return nil, compressedErr
510	}
511	return nil, err
512}
513
514func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
515	if u == nil {
516		return "", "", false
517	}
518
519	rootByHost := map[string]struct {
520		assetDir string
521		file     string
522	}{
523		HistoryPath:   {assetDir: systemviewsAssetDir, file: IndexHTML},
524		FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
525		ConfigPath:    {assetDir: systemviewsAssetDir, file: IndexHTML},
526		ErrorPath:     {assetDir: systemviewsAssetDir, file: IndexHTML},
527		CrashPath:     {assetDir: systemviewsAssetDir, file: IndexHTML},
528	}
529
530	if root, ok := rootByHost[u.Host]; ok {
531		assetPath := strings.TrimPrefix(u.Path, "/")
532		if assetPath == "" {
533			return root.assetDir, root.file, true
534		}
535		return root.assetDir, assetPath, true
536	}
537
538	switch u.Opaque {
539	case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
540		return systemviewsAssetDir, IndexHTML, true
541	default:
542		return "", "", false
543	}
544}
545
546func shouldAddCORSHeaders(requestPath string) bool {
547	requestPath = strings.TrimSpace(requestPath)
548	return strings.HasSuffix(requestPath, ".wasm")
549}
550
551func responseHeadersForPath(requestPath, contentType string) map[string]string {
552	if !shouldAddCORSHeaders(requestPath) {
553		return nil
554	}
555
556	headers := map[string]string{
557		"Access-Control-Allow-Origin":  "*",
558		"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
559		"Access-Control-Allow-Headers": "Content-Type",
560		"Access-Control-Max-Age":       "86400",
561	}
562	if contentType != "" {
563		headers["Content-Type"] = contentType
564	}
565	return headers
566}
567
568// sendResponse sends the response back to WebKit.
569func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
570	if response == nil {
571		response = &SchemeResponse{
572			Data:        []byte("Internal error"),
573			ContentType: "text/plain",
574			StatusCode:  http.StatusInternalServerError,
575		}
576	}
577
578	contentType := response.ContentType
579	if contentType == "" {
580		contentType = "text/html"
581	}
582
583	// Create MemoryInputStream from data directly
584	stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil)
585	if stream == nil {
586		h.logger.Error().Msg("failed to create MemoryInputStream for response")
587		return
588	}
589
590	// Create response object for more control
591	schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data)))
592	if schemeResp == nil {
593		h.logger.Error().Msg("failed to create URISchemeResponse")
594		return
595	}
596	schemeResp.SetContentType(contentType)
597	schemeResp.SetStatus(uint(response.StatusCode), nil)
598
599	// WebKit can treat custom schemes as CORS-relevant for the wasm runtime asset.
600	// Private systemview APIs opt out with SuppressDefaultHeaders and validate callers.
601	var headers map[string]string
602	if !response.SuppressDefaultHeaders {
603		headers = responseHeadersForPath(req.GetPath(), contentType)
604	}
605	for name, value := range response.Headers {
606		if headers == nil {
607			headers = map[string]string{}
608		}
609		headers[name] = value
610	}
611	if len(headers) > 0 {
612		hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
613		for name, value := range headers {
614			hdrs.Append(name, value)
615		}
616		schemeResp.SetHttpHeaders(hdrs)
617	}
618
619	req.FinishWithResponse(schemeResp)
620}
621
622// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
623// The scheme is always registered on the default WebContext to ensure WebViews
624// (which use the default WebContext) can load dumb:// URLs.
625func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
626	if wkCtx == nil || wkCtx.Context() == nil {
627		h.logger.Error().Msg("cannot register scheme: context is nil")
628		return
629	}
630
631	callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
632		h.HandleRequest(reqPtr)
633	})
634
635	// Always register on the default WebContext since WebViews use it
636	defaultCtx := webkit.WebContextGetDefault()
637	if defaultCtx != nil {
638		defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil)
639
640		// Mark scheme as local, secure, and CORS-enabled for proper security policies
641		if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil {
642			secMgr.RegisterUriSchemeAsLocal("dumb")
643			secMgr.RegisterUriSchemeAsSecure("dumb")
644			secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
645		}
646		h.logger.Debug().Msg("dumb:// scheme registered on default WebContext")
647	}
648
649	// Also register on custom WebContext if different from default
650	customCtx := wkCtx.Context()
651	if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) {
652		wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
653
654		if secMgr := customCtx.GetSecurityManager(); secMgr != nil {
655			secMgr.RegisterUriSchemeAsLocal("dumb")
656			secMgr.RegisterUriSchemeAsSecure("dumb")
657			secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
658		}
659		h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext")
660	}
661
662	h.logger.Info().Msg("dumb:// scheme registered")
663}
664
665// Default page templates (fallback when assets not available)
666
667const errorPageHTML = `<!DOCTYPE html>
668<html>
669<head>
670    <meta charset="utf-8">
671    <title>Error</title>
672    <style>
673        body {
674            font-family: system-ui, -apple-system, sans-serif;
675            background: #1a1a2e;
676            color: #eee;
677            display: flex;
678            align-items: center;
679            justify-content: center;
680            height: 100vh;
681            margin: 0;
682        }
683        .container {
684            text-align: center;
685        }
686        h1 { color: #e74c3c; }
687        p { color: #888; }
688    </style>
689</head>
690<body>
691    <div class="container">
692        <h1>Error</h1>
693        <p>The page could not be loaded.</p>
694    </div>
695</body>
696</html>`
697
698const notFoundHTML = `<!DOCTYPE html>
699<html>
700<head>
701    <meta charset="utf-8">
702    <title>Not Found</title>
703    <style>
704        body {
705            font-family: system-ui, -apple-system, sans-serif;
706            background: #1a1a2e;
707            color: #eee;
708            display: flex;
709            align-items: center;
710            justify-content: center;
711            height: 100vh;
712            margin: 0;
713        }
714        .container {
715            text-align: center;
716        }
717        h1 { color: #f39c12; }
718        p { color: #888; }
719    </style>
720</head>
721<body>
722    <div class="container">
723        <h1>404</h1>
724        <p>Page not found.</p>
725    </div>
726</body>
727</html>`

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