v0.31.0 bf0756b

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