detached b14a8e4

build(wasm): add systemviews runtime foundation

commit
b14a8e4b58332ae48749b6ecae025c5b667e2325
author
brice · · 2026-04-11 18:32 UTC
committer
brice · · 2026-04-12 04:52 UTC
parent
0355bc1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +383 -35
M Makefile +9 -1
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
3+.PHONY: build build-frontend build-systemviews test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -50,6 +50,13 @@ build-frontend: ## Build homepage and error pages
5050 @cd webui && npm install --silent && npm run build
5151 @echo "Frontend build complete"
5252
53+build-systemviews: ## Build the WASM systemviews runtime
54+ @echo "Building systemviews wasm assets..."
55+ @mkdir -p assets/systemviews
56+ @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
57+ GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews
58+ @echo "Systemviews build complete"
59+
5360 build-quick: ## Build without frontend (faster for backend development)
5461 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
5562 @mkdir -p $(DIST_DIR)
@@ -125,6 +132,7 @@ clean: ## Clean build artifacts
125132 rm -f $(BINARY_NAME)
126133 rm -rf webui/dist webui/node_modules
127134 rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css
135+ rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm
128136 rm -f coverage.out coverage.html
129137 go clean -cache
130138 go clean -testcache
@@ -1,6 +1,6 @@
1 # Makefile for dumber (Clean Architecture - puregotk)1 # Makefile for dumber (Clean Architecture - puregotk)
2 2
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check3+.PHONY: build build-frontend build-systemviews test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
4 4
5 # Load local overrides from .env.local if present (Makefile syntax)5 # Load local overrides from .env.local if present (Makefile syntax)
6 ifneq (,$(wildcard .env.local))6 ifneq (,$(wildcard .env.local))
@@ -50,6 +50,13 @@ build-frontend: ## Build homepage and error pages
50 @cd webui && npm install --silent && npm run build50 @cd webui && npm install --silent && npm run build
51 @echo "Frontend build complete"51 @echo "Frontend build complete"
52 52
53+build-systemviews: ## Build the WASM systemviews runtime
54+ @echo "Building systemviews wasm assets..."
55+ @mkdir -p assets/systemviews
56+ @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
57+ GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews
58+ @echo "Systemviews build complete"
59+
53 build-quick: ## Build without frontend (faster for backend development)60 build-quick: ## Build without frontend (faster for backend development)
54 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."61 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
55 @mkdir -p $(DIST_DIR)62 @mkdir -p $(DIST_DIR)
@@ -125,6 +132,7 @@ clean: ## Clean build artifacts
125 rm -f $(BINARY_NAME)132 rm -f $(BINARY_NAME)
126 rm -rf webui/dist webui/node_modules133 rm -rf webui/dist webui/node_modules
127 rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css134 rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css
135+ rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm
128 rm -f coverage.out coverage.html136 rm -f coverage.out coverage.html
129 go clean -cache137 go clean -cache
130 go clean -testcache138 go clean -testcache
A assets/systemviews/index.html +17 -0
@@ -0,0 +1,17 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>systemviews</title>
7+ <script src="./wasm_exec.js"></script>
8+ <script>
9+ const go = new Go();
10+ WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject)
11+ .then((result) => go.run(result.instance));
12+ </script>
13+</head>
14+<body>
15+ <main id="app">Loading systemviews…</main>
16+</body>
17+</html>
@@ -0,0 +1,17 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <title>systemviews</title>
7+ <script src="./wasm_exec.js"></script>
8+ <script>
9+ const go = new Go();
10+ WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject)
11+ .then((result) => go.run(result.instance));
12+ </script>
13+</head>
14+<body>
15+ <main id="app">Loading systemviews…</main>
16+</body>
17+</html>
M assets/webui_embed.go +4 -3
@@ -4,10 +4,11 @@ import (
44 "embed"
55 )
66
7-// WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages).
8-// Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc.
7+// WebUIAssets contains the embedded webui and systemviews build output.
8+// Used by the scheme handlers during the transition to serve dumb://home,
9+// dumb://error, dumb://config, dumb://webrtc, dumb://history, and dumb://favorites.
910 //
10-//go:embed webui/*
11+//go:embed webui/* systemviews/*
1112 var WebUIAssets embed.FS
1213
1314 // LogoSVG contains the dumber logo as SVG for desktop icon installation.
@@ -4,10 +4,11 @@ import (
4 "embed"4 "embed"
5 )5 )
6 6
7-// WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages).7+// WebUIAssets contains the embedded webui and systemviews build output.
8-// Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc.8+// Used by the scheme handlers during the transition to serve dumb://home,
9+// dumb://error, dumb://config, dumb://webrtc, dumb://history, and dumb://favorites.
9 //10 //
10-//go:embed webui/*11+//go:embed webui/* systemviews/*
11 var WebUIAssets embed.FS12 var WebUIAssets embed.FS
12 13
13 // LogoSVG contains the dumber logo as SVG for desktop icon installation.14 // LogoSVG contains the dumber logo as SVG for desktop icon installation.
A cmd/systemviews/main.go +23 -0
@@ -0,0 +1,23 @@
1+//go:build js && wasm
2+
3+package main
4+
5+import (
6+ "syscall/js"
7+
8+ "github.com/bnema/dumber/internal/ui/systemviews"
9+)
10+
11+func main() {
12+ app := systemviews.NewApp(systemviews.Dependencies{
13+ DOM: systemviews.NewDOM(),
14+ LocationURI: js.Global().Get("location").Get("href").String(),
15+ })
16+
17+ if err := app.Run(); err != nil {
18+ js.Global().Get("console").Call("error", err.Error())
19+ panic(err)
20+ }
21+
22+ select {}
23+}
@@ -0,0 +1,23 @@
1+//go:build js && wasm
2+
3+package main
4+
5+import (
6+ "syscall/js"
7+
8+ "github.com/bnema/dumber/internal/ui/systemviews"
9+)
10+
11+func main() {
12+ app := systemviews.NewApp(systemviews.Dependencies{
13+ DOM: systemviews.NewDOM(),
14+ LocationURI: js.Global().Get("location").Get("href").String(),
15+ })
16+
17+ if err := app.Run(); err != nil {
18+ js.Global().Get("console").Call("error", err.Error())
19+ panic(err)
20+ }
21+
22+ select {}
23+}
A cmd/systemviews/main_stub.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !js || !wasm
2+
3+package main
4+
5+func main() {}
@@ -0,0 +1,5 @@
1+//go:build !js || !wasm
2+
3+package main
4+
5+func main() {}
M internal/infrastructure/cef/internal_urls.go +1 -1
@@ -102,7 +102,7 @@ func toConceptualInternalURL(raw string) string {
102102
103103 func isInternalPageHost(host string) bool {
104104 switch host {
105- case homePath, configPath, webrtcPath, errorPath:
105+ case homePath, historyPath, favoritesPath, configPath, webrtcPath, errorPath:
106106 return true
107107 default:
108108 return false
@@ -102,7 +102,7 @@ func toConceptualInternalURL(raw string) string {
102 102
103 func isInternalPageHost(host string) bool {103 func isInternalPageHost(host string) bool {
104 switch host {104 switch host {
105- case homePath, configPath, webrtcPath, errorPath:105+ case homePath, historyPath, favoritesPath, configPath, webrtcPath, errorPath:
106 return true106 return true
107 default:107 default:
108 return false108 return false
M internal/infrastructure/cef/internal_urls_test.go +30 -0
@@ -15,6 +15,21 @@ func TestToActualInternalURL(t *testing.T) {
1515 in: "dumb://home",
1616 want: "https://dumber.invalid/home",
1717 },
18+ {
19+ name: "history page root",
20+ in: "dumb://history",
21+ want: "https://dumber.invalid/history",
22+ },
23+ {
24+ name: "favorites page root",
25+ in: "dumb://favorites",
26+ want: "https://dumber.invalid/favorites",
27+ },
28+ {
29+ name: "config page root",
30+ in: "dumb://config",
31+ want: "https://dumber.invalid/config",
32+ },
1833 {
1934 name: "api path stays at origin root",
2035 in: "dumb://home/api/message",
@@ -60,6 +75,21 @@ func TestToConceptualInternalURL(t *testing.T) {
6075 in: "https://dumber.invalid/home",
6176 want: "dumb://home",
6277 },
78+ {
79+ name: "history page root",
80+ in: "https://dumber.invalid/history",
81+ want: "dumb://history",
82+ },
83+ {
84+ name: "favorites page root",
85+ in: "https://dumber.invalid/favorites",
86+ want: "dumb://favorites",
87+ },
88+ {
89+ name: "config page root",
90+ in: "https://dumber.invalid/config",
91+ want: "dumb://config",
92+ },
6393 {
6494 name: "page subroute",
6595 in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
@@ -15,6 +15,21 @@ func TestToActualInternalURL(t *testing.T) {
15 in: "dumb://home",15 in: "dumb://home",
16 want: "https://dumber.invalid/home",16 want: "https://dumber.invalid/home",
17 },17 },
18+ {
19+ name: "history page root",
20+ in: "dumb://history",
21+ want: "https://dumber.invalid/history",
22+ },
23+ {
24+ name: "favorites page root",
25+ in: "dumb://favorites",
26+ want: "https://dumber.invalid/favorites",
27+ },
28+ {
29+ name: "config page root",
30+ in: "dumb://config",
31+ want: "https://dumber.invalid/config",
32+ },
18 {33 {
19 name: "api path stays at origin root",34 name: "api path stays at origin root",
20 in: "dumb://home/api/message",35 in: "dumb://home/api/message",
@@ -60,6 +75,21 @@ func TestToConceptualInternalURL(t *testing.T) {
60 in: "https://dumber.invalid/home",75 in: "https://dumber.invalid/home",
61 want: "dumb://home",76 want: "dumb://home",
62 },77 },
78+ {
79+ name: "history page root",
80+ in: "https://dumber.invalid/history",
81+ want: "dumb://history",
82+ },
83+ {
84+ name: "favorites page root",
85+ in: "https://dumber.invalid/favorites",
86+ want: "dumb://favorites",
87+ },
88+ {
89+ name: "config page root",
90+ in: "https://dumber.invalid/config",
91+ want: "dumb://config",
92+ },
63 {93 {
64 name: "page subroute",94 name: "page subroute",
65 in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",95 in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
M internal/infrastructure/cef/scheme_handler.go +9 -4
@@ -27,19 +27,24 @@ import (
2727 // Scheme path constants matching WebKit's naming.
2828 const (
2929 homePath = "home"
30+ historyPath = "history"
31+ favoritesPath = "favorites"
3032 configPath = "config"
3133 webrtcPath = "webrtc"
3234 errorPath = "error"
3335 indexHTML = "index.html"
36+ systemviewsIndexHTML = "systemviews/index.html"
3437 maxSchemeTruncatedURLLength = 240
3538 )
3639
3740 // pageRootFiles maps internal page hosts/paths to their HTML entry points.
3841 var pageRootFiles = map[string]string{
39- homePath: indexHTML,
40- configPath: "config.html",
41- webrtcPath: "webrtc.html",
42- errorPath: "error.html",
42+ homePath: indexHTML,
43+ historyPath: systemviewsIndexHTML,
44+ favoritesPath: systemviewsIndexHTML,
45+ configPath: systemviewsIndexHTML,
46+ webrtcPath: "webrtc.html",
47+ errorPath: "error.html",
4348 }
4449
4550 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
@@ -27,19 +27,24 @@ import (
27 // Scheme path constants matching WebKit's naming.27 // Scheme path constants matching WebKit's naming.
28 const (28 const (
29 homePath = "home"29 homePath = "home"
30+ historyPath = "history"
31+ favoritesPath = "favorites"
30 configPath = "config"32 configPath = "config"
31 webrtcPath = "webrtc"33 webrtcPath = "webrtc"
32 errorPath = "error"34 errorPath = "error"
33 indexHTML = "index.html"35 indexHTML = "index.html"
36+ systemviewsIndexHTML = "systemviews/index.html"
34 maxSchemeTruncatedURLLength = 24037 maxSchemeTruncatedURLLength = 240
35 )38 )
36 39
37 // pageRootFiles maps internal page hosts/paths to their HTML entry points.40 // pageRootFiles maps internal page hosts/paths to their HTML entry points.
38 var pageRootFiles = map[string]string{41 var pageRootFiles = map[string]string{
39- homePath: indexHTML,42+ homePath: indexHTML,
40- configPath: "config.html",43+ historyPath: systemviewsIndexHTML,
41- webrtcPath: "webrtc.html",44+ favoritesPath: systemviewsIndexHTML,
42- errorPath: "error.html",45+ configPath: systemviewsIndexHTML,
46+ webrtcPath: "webrtc.html",
47+ errorPath: "error.html",
43 }48 }
44 49
45 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual50 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
M internal/infrastructure/webkit/scheme_handler.go +35 -24
@@ -21,13 +21,16 @@ import (
2121
2222 // Scheme path constants
2323 const (
24- HomePath = "home"
25- ConfigPath = "config"
26- WebRTCPath = "webrtc"
27- ErrorPath = "error"
28- CrashPath = "crash"
29- IndexHTML = "index.html"
30- httpGET = "GET"
24+ HomePath = "home"
25+ HistoryPath = "history"
26+ FavoritesPath = "favorites"
27+ ConfigPath = "config"
28+ WebRTCPath = "webrtc"
29+ ErrorPath = "error"
30+ CrashPath = "crash"
31+ IndexHTML = "index.html"
32+ SystemViewsIndexHTML = "systemviews/index.html"
33+ httpGET = "GET"
3134 )
3235
3336 // SchemeRequest represents a request to a custom URI scheme.
@@ -62,7 +65,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
6265 type DumbSchemeHandler struct {
6366 handlers map[string]PageHandler
6467 assets embed.FS
65- assetDir string // subdirectory within embed.FS (e.g., "assets/webui")
68+ assetDir string // default subdirectory within embed.FS (e.g., "webui")
6669 logger zerolog.Logger
6770 mu sync.RWMutex
6871 currentConfigPayload func() ([]byte, error)
@@ -292,10 +295,13 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
292295 return nil
293296 }
294297
295- relPath, ok := resolveAssetPath(u)
298+ assetDir, relPath, ok := resolveAssetPath(u)
296299 if !ok {
297300 return nil
298301 }
302+ if assetDir == "" {
303+ assetDir = h.assetDir
304+ }
299305
300306 // Read the asset from embedded FS
301307 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
@@ -319,37 +325,42 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
319325 }
320326 }
321327
322-func resolveAssetPath(u *url.URL) (string, bool) {
328+func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
323329 if u == nil {
324- return "", false
330+ return "", "", false
325331 }
326332
327- rootByHost := map[string]string{
328- HomePath: IndexHTML,
329- ConfigPath: "config.html",
330- WebRTCPath: "webrtc.html",
331- ErrorPath: "error.html",
333+ rootByHost := map[string]struct {
334+ assetDir string
335+ file string
336+ }{
337+ HomePath: {assetDir: "webui", file: IndexHTML},
338+ HistoryPath: {assetDir: "systemviews", file: IndexHTML},
339+ FavoritesPath: {assetDir: "systemviews", file: IndexHTML},
340+ ConfigPath: {assetDir: "systemviews", file: IndexHTML},
341+ WebRTCPath: {assetDir: "webui", file: "webrtc.html"},
342+ ErrorPath: {assetDir: "webui", file: "error.html"},
332343 }
333344
334345 if root, ok := rootByHost[u.Host]; ok {
335346 path := strings.TrimPrefix(u.Path, "/")
336347 if path == "" {
337- return root, true
348+ return root.assetDir, root.file, true
338349 }
339- return path, true
350+ return root.assetDir, path, true
340351 }
341352
342353 switch u.Opaque {
343354 case HomePath:
344- return IndexHTML, true
345- case ConfigPath:
346- return "config.html", true
355+ return "webui", IndexHTML, true
356+ case HistoryPath, FavoritesPath, ConfigPath:
357+ return "systemviews", IndexHTML, true
347358 case ErrorPath:
348- return "error.html", true
359+ return "webui", "error.html", true
349360 case WebRTCPath:
350- return "webrtc.html", true
361+ return "webui", "webrtc.html", true
351362 default:
352- return "", false
363+ return "", "", false
353364 }
354365 }
355366
@@ -21,13 +21,16 @@ import (
21 21
22 // Scheme path constants22 // Scheme path constants
23 const (23 const (
24- HomePath = "home"24+ HomePath = "home"
25- ConfigPath = "config"25+ HistoryPath = "history"
26- WebRTCPath = "webrtc"26+ FavoritesPath = "favorites"
27- ErrorPath = "error"27+ ConfigPath = "config"
28- CrashPath = "crash"28+ WebRTCPath = "webrtc"
29- IndexHTML = "index.html"29+ ErrorPath = "error"
30- httpGET = "GET"30+ CrashPath = "crash"
31+ IndexHTML = "index.html"
32+ SystemViewsIndexHTML = "systemviews/index.html"
33+ httpGET = "GET"
31 )34 )
32 35
33 // SchemeRequest represents a request to a custom URI scheme.36 // SchemeRequest represents a request to a custom URI scheme.
@@ -62,7 +65,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
62 type DumbSchemeHandler struct {65 type DumbSchemeHandler struct {
63 handlers map[string]PageHandler66 handlers map[string]PageHandler
64 assets embed.FS67 assets embed.FS
65- assetDir string // subdirectory within embed.FS (e.g., "assets/webui")68+ assetDir string // default subdirectory within embed.FS (e.g., "webui")
66 logger zerolog.Logger69 logger zerolog.Logger
67 mu sync.RWMutex70 mu sync.RWMutex
68 currentConfigPayload func() ([]byte, error)71 currentConfigPayload func() ([]byte, error)
@@ -292,10 +295,13 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
292 return nil295 return nil
293 }296 }
294 297
295- relPath, ok := resolveAssetPath(u)298+ assetDir, relPath, ok := resolveAssetPath(u)
296 if !ok {299 if !ok {
297 return nil300 return nil
298 }301 }
302+ if assetDir == "" {
303+ assetDir = h.assetDir
304+ }
299 305
300 // Read the asset from embedded FS306 // Read the asset from embedded FS
301 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))307 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
@@ -319,37 +325,42 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
319 }325 }
320 }326 }
321 327
322-func resolveAssetPath(u *url.URL) (string, bool) {328+func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
323 if u == nil {329 if u == nil {
324- return "", false330+ return "", "", false
325 }331 }
326 332
327- rootByHost := map[string]string{333+ rootByHost := map[string]struct {
328- HomePath: IndexHTML,334+ assetDir string
329- ConfigPath: "config.html",335+ file string
330- WebRTCPath: "webrtc.html",336+ }{
331- ErrorPath: "error.html",337+ HomePath: {assetDir: "webui", file: IndexHTML},
338+ HistoryPath: {assetDir: "systemviews", file: IndexHTML},
339+ FavoritesPath: {assetDir: "systemviews", file: IndexHTML},
340+ ConfigPath: {assetDir: "systemviews", file: IndexHTML},
341+ WebRTCPath: {assetDir: "webui", file: "webrtc.html"},
342+ ErrorPath: {assetDir: "webui", file: "error.html"},
332 }343 }
333 344
334 if root, ok := rootByHost[u.Host]; ok {345 if root, ok := rootByHost[u.Host]; ok {
335 path := strings.TrimPrefix(u.Path, "/")346 path := strings.TrimPrefix(u.Path, "/")
336 if path == "" {347 if path == "" {
337- return root, true348+ return root.assetDir, root.file, true
338 }349 }
339- return path, true350+ return root.assetDir, path, true
340 }351 }
341 352
342 switch u.Opaque {353 switch u.Opaque {
343 case HomePath:354 case HomePath:
344- return IndexHTML, true355+ return "webui", IndexHTML, true
345- case ConfigPath:356+ case HistoryPath, FavoritesPath, ConfigPath:
346- return "config.html", true357+ return "systemviews", IndexHTML, true
347 case ErrorPath:358 case ErrorPath:
348- return "error.html", true359+ return "webui", "error.html", true
349 case WebRTCPath:360 case WebRTCPath:
350- return "webrtc.html", true361+ return "webui", "webrtc.html", true
351 default:362 default:
352- return "", false363+ return "", "", false
353 }364 }
354 }365 }
355 366
M internal/infrastructure/webkit/scheme_handler_test.go +28 -2
@@ -117,7 +117,7 @@ func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) {
117117 assert.Contains(t, string(resp.Data), "webrtc.min.js")
118118 }
119119
120-func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) {
120+func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) {
121121 h := NewDumbSchemeHandler(context.Background())
122122 h.SetAssets(assets.WebUIAssets)
123123
@@ -127,7 +127,33 @@ func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) {
127127 resp := h.handleAsset(u)
128128 require.NotNil(t, resp)
129129 assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
130- assert.Contains(t, string(resp.Data), "config")
130+ assert.Contains(t, string(resp.Data), "systemviews.wasm")
131+}
132+
133+func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {
134+ h := NewDumbSchemeHandler(context.Background())
135+ h.SetAssets(assets.WebUIAssets)
136+
137+ tests := []string{
138+ "dumb://history",
139+ "dumb:history",
140+ "dumb://favorites",
141+ "dumb:favorites",
142+ "dumb://config",
143+ "dumb:config",
144+ }
145+
146+ for _, raw := range tests {
147+ t.Run(raw, func(t *testing.T) {
148+ u, err := url.Parse(raw)
149+ require.NoError(t, err)
150+
151+ resp := h.handleAsset(u)
152+ require.NotNil(t, resp)
153+ assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
154+ assert.Contains(t, string(resp.Data), "systemviews.wasm")
155+ })
156+ }
131157 }
132158
133159 func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
@@ -117,7 +117,7 @@ func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) {
117 assert.Contains(t, string(resp.Data), "webrtc.min.js")117 assert.Contains(t, string(resp.Data), "webrtc.min.js")
118 }118 }
119 119
120-func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) {120+func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) {
121 h := NewDumbSchemeHandler(context.Background())121 h := NewDumbSchemeHandler(context.Background())
122 h.SetAssets(assets.WebUIAssets)122 h.SetAssets(assets.WebUIAssets)
123 123
@@ -127,7 +127,33 @@ func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) {
127 resp := h.handleAsset(u)127 resp := h.handleAsset(u)
128 require.NotNil(t, resp)128 require.NotNil(t, resp)
129 assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)129 assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
130- assert.Contains(t, string(resp.Data), "config")130+ assert.Contains(t, string(resp.Data), "systemviews.wasm")
131+}
132+
133+func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {
134+ h := NewDumbSchemeHandler(context.Background())
135+ h.SetAssets(assets.WebUIAssets)
136+
137+ tests := []string{
138+ "dumb://history",
139+ "dumb:history",
140+ "dumb://favorites",
141+ "dumb:favorites",
142+ "dumb://config",
143+ "dumb:config",
144+ }
145+
146+ for _, raw := range tests {
147+ t.Run(raw, func(t *testing.T) {
148+ u, err := url.Parse(raw)
149+ require.NoError(t, err)
150+
151+ resp := h.handleAsset(u)
152+ require.NotNil(t, resp)
153+ assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
154+ assert.Contains(t, string(resp.Data), "systemviews.wasm")
155+ })
156+ }
131 }157 }
132 158
133 func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {159 func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
A internal/ui/systemviews/app.go +37 -0
@@ -0,0 +1,37 @@
1+package systemviews
2+
3+import "errors"
4+
5+type Dependencies struct {
6+ DOM DOM
7+ LocationURI string
8+}
9+
10+type App struct {
11+ deps Dependencies
12+ currentRoute Route
13+}
14+
15+func NewApp(deps Dependencies) *App {
16+ return &App{deps: deps}
17+}
18+
19+func (a *App) Run() error {
20+ if a == nil {
21+ return errors.New("app is nil")
22+ }
23+
24+ a.currentRoute = ParseRoute(a.deps.LocationURI)
25+ if a.deps.DOM == nil {
26+ return errors.New("DOM not configured")
27+ }
28+
29+ return a.deps.DOM.Mount(placeholderHTML(a.currentRoute))
30+}
31+
32+func (a *App) CurrentRoute() Route {
33+ if a == nil {
34+ return RouteUnknown
35+ }
36+ return a.currentRoute
37+}
@@ -0,0 +1,37 @@
1+package systemviews
2+
3+import "errors"
4+
5+type Dependencies struct {
6+ DOM DOM
7+ LocationURI string
8+}
9+
10+type App struct {
11+ deps Dependencies
12+ currentRoute Route
13+}
14+
15+func NewApp(deps Dependencies) *App {
16+ return &App{deps: deps}
17+}
18+
19+func (a *App) Run() error {
20+ if a == nil {
21+ return errors.New("app is nil")
22+ }
23+
24+ a.currentRoute = ParseRoute(a.deps.LocationURI)
25+ if a.deps.DOM == nil {
26+ return errors.New("DOM not configured")
27+ }
28+
29+ return a.deps.DOM.Mount(placeholderHTML(a.currentRoute))
30+}
31+
32+func (a *App) CurrentRoute() Route {
33+ if a == nil {
34+ return RouteUnknown
35+ }
36+ return a.currentRoute
37+}
A internal/ui/systemviews/app_test.go +61 -0
@@ -0,0 +1,61 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestParseRoute(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ uri string
16+ want Route
17+ }{
18+ {name: "history host", uri: "dumb://history", want: RouteHistory},
19+ {name: "history opaque", uri: "dumb:history", want: RouteHistory},
20+ {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites},
21+ {name: "favorites opaque", uri: "dumb:favorites", want: RouteFavorites},
22+ {name: "config host", uri: "dumb://config", want: RouteConfig},
23+ {name: "config opaque", uri: "dumb:config", want: RouteConfig},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ t.Parallel()
29+ if got := ParseRoute(tt.uri); got != tt.want {
30+ t.Fatalf("ParseRoute(%q) = %v, want %v", tt.uri, got, tt.want)
31+ }
32+ })
33+ }
34+}
35+
36+func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
37+ t.Parallel()
38+
39+ dom := &fakeDOM{}
40+ app := NewApp(Dependencies{
41+ DOM: dom,
42+ LocationURI: "dumb://history",
43+ })
44+
45+ require.NoError(t, app.Run())
46+ assert.Equal(t, RouteHistory, app.CurrentRoute())
47+ assert.True(t, dom.mounted)
48+ assert.Contains(t, dom.html, "history")
49+ assert.Contains(t, dom.html, "systemviews")
50+}
51+
52+type fakeDOM struct {
53+ mounted bool
54+ html string
55+}
56+
57+func (d *fakeDOM) Mount(html string) error {
58+ d.mounted = true
59+ d.html = html
60+ return nil
61+}
@@ -0,0 +1,61 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestParseRoute(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ uri string
16+ want Route
17+ }{
18+ {name: "history host", uri: "dumb://history", want: RouteHistory},
19+ {name: "history opaque", uri: "dumb:history", want: RouteHistory},
20+ {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites},
21+ {name: "favorites opaque", uri: "dumb:favorites", want: RouteFavorites},
22+ {name: "config host", uri: "dumb://config", want: RouteConfig},
23+ {name: "config opaque", uri: "dumb:config", want: RouteConfig},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ t.Parallel()
29+ if got := ParseRoute(tt.uri); got != tt.want {
30+ t.Fatalf("ParseRoute(%q) = %v, want %v", tt.uri, got, tt.want)
31+ }
32+ })
33+ }
34+}
35+
36+func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
37+ t.Parallel()
38+
39+ dom := &fakeDOM{}
40+ app := NewApp(Dependencies{
41+ DOM: dom,
42+ LocationURI: "dumb://history",
43+ })
44+
45+ require.NoError(t, app.Run())
46+ assert.Equal(t, RouteHistory, app.CurrentRoute())
47+ assert.True(t, dom.mounted)
48+ assert.Contains(t, dom.html, "history")
49+ assert.Contains(t, dom.html, "systemviews")
50+}
51+
52+type fakeDOM struct {
53+ mounted bool
54+ html string
55+}
56+
57+func (d *fakeDOM) Mount(html string) error {
58+ d.mounted = true
59+ d.html = html
60+ return nil
61+}
A internal/ui/systemviews/dom.go +29 -0
@@ -0,0 +1,29 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+)
7+
8+type DOM interface {
9+ Mount(html string) error
10+}
11+
12+func placeholderHTML(route Route) string {
13+ name := html.EscapeString(string(route))
14+ if name == "" || route == RouteUnknown {
15+ name = "unknown"
16+ }
17+
18+ return fmt.Sprintf(`<!doctype html>
19+<html lang="en">
20+<head>
21+ <meta charset="utf-8">
22+ <meta name="viewport" content="width=device-width, initial-scale=1">
23+ <title>systemviews</title>
24+</head>
25+<body>
26+ <main id="app" data-route="%s">systemviews %s</main>
27+</body>
28+</html>`, name, name)
29+}
@@ -0,0 +1,29 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+)
7+
8+type DOM interface {
9+ Mount(html string) error
10+}
11+
12+func placeholderHTML(route Route) string {
13+ name := html.EscapeString(string(route))
14+ if name == "" || route == RouteUnknown {
15+ name = "unknown"
16+ }
17+
18+ return fmt.Sprintf(`<!doctype html>
19+<html lang="en">
20+<head>
21+ <meta charset="utf-8">
22+ <meta name="viewport" content="width=device-width, initial-scale=1">
23+ <title>systemviews</title>
24+</head>
25+<body>
26+ <main id="app" data-route="%s">systemviews %s</main>
27+</body>
28+</html>`, name, name)
29+}
A internal/ui/systemviews/dom_js.go +35 -0
@@ -0,0 +1,35 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import (
6+ "fmt"
7+ "syscall/js"
8+)
9+
10+type browserDOM struct {
11+ document js.Value
12+ target js.Value
13+}
14+
15+func NewDOM() DOM {
16+ doc := js.Global().Get("document")
17+ if !doc.Truthy() {
18+ return &browserDOM{}
19+ }
20+
21+ target := doc.Call("getElementById", "app")
22+ if !target.Truthy() {
23+ target = doc.Get("body")
24+ }
25+
26+ return &browserDOM{document: doc, target: target}
27+}
28+
29+func (d *browserDOM) Mount(html string) error {
30+ if d == nil || !d.target.Truthy() {
31+ return fmt.Errorf("DOM target not available")
32+ }
33+ d.target.Set("innerHTML", html)
34+ return nil
35+}
@@ -0,0 +1,35 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import (
6+ "fmt"
7+ "syscall/js"
8+)
9+
10+type browserDOM struct {
11+ document js.Value
12+ target js.Value
13+}
14+
15+func NewDOM() DOM {
16+ doc := js.Global().Get("document")
17+ if !doc.Truthy() {
18+ return &browserDOM{}
19+ }
20+
21+ target := doc.Call("getElementById", "app")
22+ if !target.Truthy() {
23+ target = doc.Get("body")
24+ }
25+
26+ return &browserDOM{document: doc, target: target}
27+}
28+
29+func (d *browserDOM) Mount(html string) error {
30+ if d == nil || !d.target.Truthy() {
31+ return fmt.Errorf("DOM target not available")
32+ }
33+ d.target.Set("innerHTML", html)
34+ return nil
35+}
A internal/ui/systemviews/router.go +60 -0
@@ -0,0 +1,60 @@
1+package systemviews
2+
3+import (
4+ "net/url"
5+ "strings"
6+)
7+
8+type Route string
9+
10+const (
11+ RouteUnknown Route = "unknown"
12+ RouteHistory Route = "history"
13+ RouteFavorites Route = "favorites"
14+ RouteConfig Route = "config"
15+)
16+
17+func ParseRoute(uri string) Route {
18+ if uri == "" {
19+ return RouteUnknown
20+ }
21+
22+ u, err := url.Parse(uri)
23+ if err != nil {
24+ return RouteUnknown
25+ }
26+
27+ switch {
28+ case strings.EqualFold(u.Scheme, "dumb"):
29+ return routeFromPageHost(u.Host, u.Opaque)
30+ case strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "dumber.invalid"):
31+ path := strings.Trim(u.Path, "/")
32+ if path == "" {
33+ return RouteUnknown
34+ }
35+ if head, _, ok := strings.Cut(path, "/"); ok {
36+ path = head
37+ }
38+ return routeFromPageHost(path, "")
39+ default:
40+ return RouteUnknown
41+ }
42+}
43+
44+func routeFromPageHost(host, opaque string) Route {
45+ page := strings.TrimSpace(host)
46+ if page == "" {
47+ page = strings.TrimSpace(opaque)
48+ }
49+
50+ switch strings.ToLower(page) {
51+ case string(RouteHistory):
52+ return RouteHistory
53+ case string(RouteFavorites):
54+ return RouteFavorites
55+ case string(RouteConfig):
56+ return RouteConfig
57+ default:
58+ return RouteUnknown
59+ }
60+}
@@ -0,0 +1,60 @@
1+package systemviews
2+
3+import (
4+ "net/url"
5+ "strings"
6+)
7+
8+type Route string
9+
10+const (
11+ RouteUnknown Route = "unknown"
12+ RouteHistory Route = "history"
13+ RouteFavorites Route = "favorites"
14+ RouteConfig Route = "config"
15+)
16+
17+func ParseRoute(uri string) Route {
18+ if uri == "" {
19+ return RouteUnknown
20+ }
21+
22+ u, err := url.Parse(uri)
23+ if err != nil {
24+ return RouteUnknown
25+ }
26+
27+ switch {
28+ case strings.EqualFold(u.Scheme, "dumb"):
29+ return routeFromPageHost(u.Host, u.Opaque)
30+ case strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "dumber.invalid"):
31+ path := strings.Trim(u.Path, "/")
32+ if path == "" {
33+ return RouteUnknown
34+ }
35+ if head, _, ok := strings.Cut(path, "/"); ok {
36+ path = head
37+ }
38+ return routeFromPageHost(path, "")
39+ default:
40+ return RouteUnknown
41+ }
42+}
43+
44+func routeFromPageHost(host, opaque string) Route {
45+ page := strings.TrimSpace(host)
46+ if page == "" {
47+ page = strings.TrimSpace(opaque)
48+ }
49+
50+ switch strings.ToLower(page) {
51+ case string(RouteHistory):
52+ return RouteHistory
53+ case string(RouteFavorites):
54+ return RouteFavorites
55+ case string(RouteConfig):
56+ return RouteConfig
57+ default:
58+ return RouteUnknown
59+ }
60+}

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