SPB Git

spb/spbgit

Public MIT Pinned

SPB Git — the platform hosting itself

feat: SPB Git 2.0 — design system (light/dark/midnight), mobile-first AppShell, core screens, streamed raw files, bounded git concurrency, stamp-validated caches, scoped search

Phase 0–2 of docs/SPBGIT-2-PLAN.md. Fixes the production memory-restart loop
(unbuffered /raw, metadata-only blob probes, 12-way git semaphore), the stale
all-time commit total (overview cache validated against live refs), the
600 MB request log (completion-only logging), and the 24 px horizontal
overflow on every phone page. Adds /about/metrics, /api/v1/search,
/api/v1/repos/:name/tree, split diffs, lazy file tree, clone sheet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 1, 2026) parent 810e402 Browse files

72 changed files +4,282 −1,865

72 changed files
added docs/SPBGIT-2-PLAN.md +176 −0
@@ -0,0 +1,176 @@
1+<!--
2+ ─────────────────────────────────────────────
3+ SPB Git — Personal Git Platform
4+ ─────────────────────────────────────────────
5+ Author : Simon-Pierre Boucher
6+ Contact : contact@spboucher.ai
7+ File : docs/SPBGIT-2-PLAN.md
8+ Purpose : SPB Git 2.0 — architecture, feature inventory, risks, phased plan, acceptance criteria
9+ License : MIT © Simon-Pierre Boucher
10+ ─────────────────────────────────────────────
11+-->
12+# SPB Git 2.0 — The Intelligent Git Forge · Plan
13+
14+Written 2026-11-01 after Phase 0 (reconnaissance) on the real repository
15+(`spbgit` 1.1.0, commit `810e402`, deployed on M1M32 `~/srv/spbgit/app`,
16+public at https://git.spboucher.ai through the MacLustr Tunnel).
17+
18+## 1. Architecture as found
19+
20+| Layer | Implementation | Notes |
21+|---|---|---|
22+| Runtime | Node ≥ 20 (25.9 on M1M32), ESM, Fastify 5, pino | PM2 `spbgit-server`, port 7420, loopback; TLS at the BHS64 gateway |
23+| Git hosting | `src/git/smart-http.mjs` spawns `git upload-pack / receive-pack --stateless-rpc`, streamed, gzip bodies | Anonymous clone/fetch, PAT (argon2id) for push, HTTP Basic user `spb` |
24+| Repo model | `src/git/repo.mjs` shells out to `git` (refs, tree, blob, log, diff parser, blame, archive) | `maxBuffer` 64 MB per call, blobs fully buffered |
25+| Storage | bare repos `~/srv/git/*.git` (157), `data/meta.json` (desc/topics/pins), `data/activity.jsonl`, `data/clones.json`, `data/tokens.json`, `data/releases/` | filesystem is the database |
26+| Cache | `~/srv/spbgit/cache/repos/<repo>/<sha>/*`, `repos/<repo>/_repo/overview-v4.json`, `global/*` | post-receive hook POSTs to `/internal/hooks/post-receive` (loopback only) → busts repo + global |
27+| Rendering | Nunjucks SSR (`src/web/views`), hand-written CSS (`tokens.css`, `app.css`, `markdown.css`), vanilla JS (`app.js`, `theme-boot.js`), shiki dual-theme, markdown-it + sanitize-html, satori/resvg OG cards | No bundler, no framework, CSP `script-src 'self'` (no inline JS) |
28+| API | `/api/v1/*` public reads, Bearer PAT writes, `/healthz`, `/feed.atom`, `/sitemap.xml`, `/og/*.png`, `/raw/*`, `/archive/*`, `/releases/*` | JSON error envelope, rate limit 200/min/IP |
29+| CLI | `cli/spbgit.mjs` (commander) | untouched by this plan |
30+| Tests | vitest: 6 unit files + 1 e2e (real `git push` round trip, releases) — **59/59 green** at baseline; `eslint`, `check:headers` green | no browser tests yet |
31+| Deploy | `mld` manifest `M1M32:~/dispatch/apps/spbgit.json` (pinned to M1M32), PM2 `max_memory_restart 750M`, nightly `deploy/backup.sh` | ngrok removed 2026-09-10; `deploy/ecosystem.config.cjs` still documents a tunnel app (dead) |
32+
33+Visibility model (CLAUDE.md §0): **every repository is public, read-only**; the
34+only writes are owner PAT operations through the API/CLI. The web UI has no
35+login and no authenticated action by design — "authenticated actions on a phone"
36+therefore means: clone URLs, release downloads and the public API, all of which
37+must work from Safari/iOS. This plan does not add a web login.
38+
39+## 2. Feature inventory (existing — must keep working)
40+
41+Home (hero, stats, pinned bento, repo grid with sparklines + client filters,
42+activity feed, language ring, heatmap) · Repo overview (header, 6 stat tiles,
43+language bar, tabs, branch select, clone box, zip/tar.gz, file table with last
44+commit, README + TOC) · Tree · Blob (shiki, line anchors + ranges, wrap, copy,
45+raw, blame, history, permalink, markdown render/source, images, PDF inline,
46+binaries, notebooks as JSON) · Commits (grouped by day, stats, pagination,
47+path filter) · Commit (diff with per-hunk highlighting, file nav, rename/binary)
48+· Blame (age-coloured) · Branches (ahead/behind) · Tags/releases (assets with
49+sha256) · Search (names/desc/topics/README) · ⌘K palette (repos + commands) ·
50+OG images, favicon, sitemap, robots, Atom · 404/500 · Smart HTTP · API v1 ·
51+releases upload · hooks · CLI.
52+
53+## 3. Baseline measurements (2026-11-01, production, Playwright Chromium)
54+
55+Mobile = 375×812, CPU ×4, ~1.6 Mb/s. Desktop = 1440×900, no throttling.
56+
57+| Page | Mobile LCP | Mobile TTFB | Desktop LCP | HTML | CSS | JS | Fonts |
58+|---|---|---|---|---|---|---|---|
59+| `/` | 1 216 ms | 182 ms | 320 ms | 34 KB | 18.7 KB | 9 KB | 209 KB (5 files preloaded) |
60+| `/spbgit` | 276 ms | 144 ms | 236 ms | 9.7 KB | cached | cached | cached |
61+| blob `src/server.mjs` | 552 ms | 424 ms | 328 ms | 13 KB | | | |
62+| commit `810e402` | 1 612 ms | **1 463 ms** | 1 336 ms | **121 KB** | | | |
63+
64+CLS = 0 everywhere. Server timing on M1M32: `/` 64 ms warm, repo 58 ms,
65+`/api/v1/repos` 10 ms. 157 repos, cache 2.0 GB / 9 684 files.
66+
67+Responsive audit (`document.documentElement.scrollWidth` at 375 px, Chromium +
68+WebKit): **every page overflows horizontally** (399 px in production, 377–383 px
69+locally). Causes found by bisection: footer grid `1fr` track inheriting the
70+`min-content` of `pre.footer-cmd` (fix: `minmax(0,1fr)` / `min-width:0`), the
71+hero `canvas` positioned `inset:-24px` + `width:calc(100%+48px)`, and the
72+repo tab strip. Baseline screenshots: `docs/screenshots/baseline/`.
73+
74+## 4. Critical risks found in Phase 0
75+
76+1. **Memory restart loop (production incident).** PM2 restarted `spbgit-server`
77+ **2 861 times** (`exceeds --max-memory-restart`, RSS up to 2.1 GB vs 750 MB
78+ limit). Every restart drops in-flight pushes/clones and makes the post-receive
79+ hook POST fail (hook has a 15 s timeout and swallows errors). Request mix
80+ before a restart: crawlers on `/<repo>/blob/*` and `/raw/*` of large files
81+ (`phd_thesis` PDFs). `Repos.blob()` buffers whole blobs (up to 64 MB) even
82+ when the view only needs size/type, `/raw` buffers again; concurrency is
83+ unbounded. Fix in Phase 1 (reliability track): stream `/raw`, metadata-only
84+ blob probe, bounded git concurrency, cheaper `/healthz`.
85+2. **Commit totals inconsistent** ("all-time" 3 332 < "52 weeks" 3 346). Root
86+ cause verified: `overview-v4.json` is keyed by repo, not by HEAD, and is only
87+ invalidated by the hook; 8 repos were pushed while the server was restarting
88+ (risk 1) so their cached `commitCount` is stale (e.g. `llm-api` 0 vs 5), while
89+ the heatmap is rebuilt from live `rev-list` whenever *any* push clears
90+ `global/`. Fix: validate overview cache against live HEAD (cheap ref stamp),
91+ derive both numbers from the same per-repo timestamp source, document metric
92+ definitions.
93+3. **Unbounded logs.** pino logs every request at info; `~/.pm2/logs/spbgit-server-out.log`
94+ is 599 MB with no rotation. Fix: compact completion-only logging, exclude
95+ assets, document `pm2-logrotate`.
96+4. **Commit page cost.** Per-hunk shiki highlighting on every request (1.2–1.5 s
97+ TTFB, 121 KB HTML). Fix: cache the prepared diff per sha, collapse large files.
98+5. **Mobile layout.** Horizontal overflow on every page; touch targets < 44 px
99+ (tabs 36 px, `.btn-sm` 28 px, copy buttons); desktop-only hover reveal on
100+ heading anchors; `.hero-terminal` and constellation canvas run on phones;
101+ `100vh` without `dvh`; no safe-area insets; sticky header 64 px on phones.
102+6. **Themes.** Only dark/light; theme toggle is a 2-state button without an
103+ "OS" mode; no `color-scheme` meta per theme.
104+7. **Dead deploy config.** `deploy/ecosystem.config.cjs` + `deploy/ngrok.yml`
105+ still describe ngrok; CLAUDE.md names host `m3u96a`. Production is M1M32 via
106+ `mld` + MacLustr Tunnel. Documentation only — the running manifest is correct.
107+8. **Fonts.** 209 KB of preloaded fonts on first visit (5 files). Keep
108+ self-hosting but drop preloads for non-critical weights, subset usage.
109+
110+## 5. Design direction (decisions)
111+
112+- **Identity kept**: S mark, electric-blue→teal gradient, network motif (static
113+ SVG/CSS, animated only on desktop without `prefers-reduced-motion`).
114+- **Three themes**: `light` (soft blue-white `#f4f6fb`), `dark` (charcoal/slate
115+ `#15181f`), `midnight` (deep navy `#070b16`, electric blue, restrained
116+ turquoise — the successor of the v4 obsidian look). Default follows the OS
117+ (`auto` → light/dark); explicit override persisted in `localStorage`
118+ (`spbgit-theme` = `auto|light|dark|midnight`), applied before first paint by
119+ `theme-boot.js`, exposed in the header menu and the ⌘K palette.
120+- **Semantic tokens** in `tokens.css`: surfaces (`--surface-0..3`), foreground
121+ (`--fg`, `--fg-muted`, `--fg-faint`), borders, focus ring, status
122+ (`--ok/--warn/--danger/--info`), spacing scale 4/8 px (`--space-1..10`),
123+ radii, shadows, motion durations, syntax colours (shiki vars). WCAG AA checked
124+ per theme (contrast table in progress log).
125+- **Density**: Linear-like. Compact sticky header (56 px desktop, 52 px phone),
126+ compact `StatStrip` instead of five hero tiles and six repo tiles, 1-column
127+ phone layouts, bottom-sheet dialogs on phones (palette, menu, branch picker).
128+- **Component library** (Nunjucks macros in `src/web/views/components/` +
129+ matching CSS sections): AppShell, Header, MobileNavigation (sheet), Breadcrumbs,
130+ StatStrip, RepositoryCard, StatusChip, Tabs, SearchDialog, CodeViewer, FileTree,
131+ CommitTimeline, DiffViewer, EmptyState, LoadingSkeleton, ErrorState, Toast,
132+ ConfirmationDialog (JS, used by destructive CLI-less flows only if they ever
133+ appear; shipped as a primitive).
134+- **Typography**: Inter (UI) / JetBrains Mono (code) / Bricolage Grotesque
135+ (display, 700 + 800). Instrument Serif italic dropped (one less font).
136+ `font-variant-numeric: tabular-nums` on every statistic.
137+
138+## 6. Phased milestones
139+
140+| Phase | Scope | Acceptance |
141+|---|---|---|
142+| 0 Reconnaissance | this document, baseline numbers, screenshots, risks | done |
143+| 1 Design system + reliability P0 | tokens/themes, AppShell, header + mobile sheet, buttons/tabs/cards/chips, skeleton/empty/error, a11y foundations; streaming raw, blob probe, bounded concurrency, head-validated overview cache, compact logging, overflow test harness | tests green, no overflow at 320–1920, three themes, memory fix unit-tested |
144+| 2 Core screens | home, repo overview, catalog, source browser (tree + blob), clone panel, file tree sidebar on desktop | all old URLs 200, README/badges unchanged, screenshots at 9 widths |
145+| 3 Developer workflows | ⌘K with server-side search (repos/files/commits/branches), commits timeline, diff viewer (unified/split, wrap, cached), branch nav, analytics + metric definitions, keyboard/touch | search API tests, commit-count coherence test, Playwright mobile smoke |
146+| 4 Research Mode | opt-in `research` flag + auto-detection banner, `/:repo/research`: PDF, LaTeX sources, figures, tables, reproducibility checklist, outputs, version links | no code execution, no LaTeX compile, tests on fixtures |
147+| 5 AI groundwork | `src/ai/` provider-agnostic (Anthropic, OpenAI-compatible, local OpenAI-compatible e.g. MLX/llama.cpp), incremental file index, grounded Q&A with `path@sha#Lx-Ly` citations, prompt-injection wrapping, token/concurrency budgets, feature-flagged off by default | disabled providers are *absent*, never simulated; tests use an explicit fake provider under `SPBGIT_ENV=test` |
148+| 6 Agent Workspace | design document only (isolation, scopes, approvals, audit) | `docs/AGENT-WORKSPACE-DESIGN.md` |
149+| 7 Hardening & release | cross-engine QA (Chromium + WebKit) at 320/360/375/390/430/768/1024/1440/1920, landscape, 200 % zoom, axe audit, perf re-measure, security regression tests, changelog, deploy + rollback runbook | all acceptance criteria below |
150+
151+Deployment to M1M32 (`git pull` + `pm2 restart`) is **not** performed by the
152+implementer without explicit approval (directive: pause before production
153+changes).
154+
155+## 7. Acceptance criteria (from the directive, made checkable)
156+
157+1. `npm test` includes the Smart HTTP round trip (clone/push/fetch) and passes;
158+ every pre-2.0 URL pattern is covered by a route test (home, repo, tree, blob,
159+ raw, archive, commits, commit, blame, branches, tags/releases, search, feeds,
160+ OG, API).
161+2. Playwright run at 320–430 px and desktop, in light/dark/midnight, asserts
162+ `scrollWidth === clientWidth` on every core page and 44×44 px targets on
163+ primary controls.
164+3. Metric definitions documented (`docs/METRICS.md`), unit test proving
165+ all-time ≥ 52-week on the same source, no placeholder data in production
166+ templates (demo fixtures only under `dev/`).
167+4. Search/file browsing: path traversal + ref validation tests; search index
168+ built only from repos on disk (all public).
169+5. Markdown/diff/code: sanitizer tests (script/iframe/event handlers stripped,
170+ badges kept), diff whitespace preserved, keyboard-reachable actions.
171+6. Automated tests + a manual Safari/iPhone checklist recorded in the progress
172+ log.
173+7. AI/Research features behind flags; `/healthz` reports feature flags.
174+8. Deliverables: `CHANGELOG.md`, screenshots (`docs/screenshots/`), tests,
175+ `docs/BENCHMARKS.md` (before/after), `docs/DEPLOY.md` (deploy + rollback),
176+ remaining work list in `docs/SPBGIT-2-PROGRESS.md`.
added docs/SPBGIT-2-PROGRESS.md +92 −0
@@ -0,0 +1,92 @@
1+<!--
2+ ─────────────────────────────────────────────
3+ SPB Git — Personal Git Platform
4+ ─────────────────────────────────────────────
5+ Author : Simon-Pierre Boucher
6+ Contact : contact@spboucher.ai
7+ File : docs/SPBGIT-2-PROGRESS.md
8+ Purpose : SPB Git 2.0 — per-phase progress log (files, trade-offs, tests, screenshots, open issues)
9+ License : MIT © Simon-Pierre Boucher
10+ ─────────────────────────────────────────────
11+-->
12+# SPB Git 2.0 — Progress log
13+
14+## Phase 0 — Reconnaissance (2026-11-01) ✅
15+
16+**Files added**: `docs/SPBGIT-2-PLAN.md`, `docs/SPBGIT-2-PROGRESS.md`,
17+`docs/screenshots/baseline/*.png` (home, repo, blob, commits, commit, search ×
18+375/1440 × dark/light).
19+
20+**Method**: read every source/template/CSS/test file, the deploy manifest on
21+M1M32, PM2 state and logs; measured production with Playwright (Chromium) and
22+WebKit; ran the existing test suite on a laptop mirror with 9 bare repos cloned
23+from production plus a copy of `meta.json`/`activity.jsonl`/`clones.json`
24+(`dev/`, git-ignored).
25+
26+**Baseline**: tests 59/59, eslint clean, headers clean. Web vitals and server
27+timings in the plan (§3). Production restarts: 2 861 (memory). Stale overview
28+caches: 8 repos (`countryatlas`, `hilmacorp-ca`, `ka-apify-actors`, `llm-api`,
29+`maclustr-agentd`, `wp19_uqo`, `wp20_uqo`, `wp21_uqo`).
30+
31+**Trade-offs**: work happens on a laptop mirror (`~/Desktop/Cluster/projets/spbgit`,
32+origin = spbgit on M1M32) because the production checkout is the live app; the
33+node is updated only by `git pull` + `pm2 restart` after approval.
34+
35+**Open issues carried into Phase 1**: risks 1–8 of the plan.
36+
37+## Phase 1 — Design system + reliability P0 (2026-11-01) ✅
38+
39+**Files changed**: `src/web/assets/css/tokens.css` (rewritten: semantic tokens,
40+three themes, type/spacing/motion scales), `src/web/assets/css/app.css`
41+(rewritten, mobile-first, 13 sections), `src/web/assets/js/theme-boot.js`
42+(auto/light/dark/midnight before first paint), `src/web/assets/js/app.js`
43+(rewritten: dialogs/sheets, theme, palette, toast, confirm, clipboard, file
44+tree, code/diff helpers, TOC), `src/web/views/layout.njk` (AppShell),
45+`src/web/views/components/ui.njk` (new: icon, chip, statStrip, tabs,
46+breadcrumbs, refSelect, emptyState, errorState, skeleton, copyButton),
47+`src/lib/semaphore.mjs` (new), `src/git/repo.mjs` (git semaphore, `blobInfo`,
48+`blobStream`, `blob({maxBytes})`, `refStamp`, `allPaths`, `grepCommits`),
49+`src/lib/overview.mjs` (stamp-validated cache `overview-v5.json`, clones read
50+once, research detection), `src/stats/activity.mjs` (stamp-validated calendar
51+with `allTime`), `src/server.mjs` (compact completion-only logging via
52+`LogController`, memoized `/healthz` with memory/git/feature fields),
53+`src/config.mjs` (AI flags, off by default).
54+
55+**Trade-offs**: `Instrument Serif` dropped (one font less, 1 preload less);
56+the constellation canvas and typewriter are gone (static terminal card on
57+desktop only) — decorative motion is now limited to a slow mesh drift on
58+≥768 px without `prefers-reduced-motion`. `git` subprocesses are capped at 12
59+concurrent (`SPBGIT_GIT_CONCURRENCY`); crawlers now queue instead of growing
60+RSS. Native `<dialog>` is used for the palette, menu, clone panel and file
61+tree (bottom sheets on phones, modals on desktop) — requires Safari ≥ 15.4.
62+
63+**Tests**: `test/unit/semaphore.test.mjs` (2), `test/unit/routes-helpers.test.mjs`
64+(8: split rows, day grouping, research signals, sparkline, query terms,
65+stamps), e2e extended (+6: scoped search API, tree API + traversal, 3 MB
66+binary streamed from `/raw` with exact bytes + metadata-only blob view,
67+missed-hook detection with coherent totals, split/history/blame, healthz
68+flags). **74/74 green**, eslint + headers clean.
69+
70+## Phase 2 — Core screens (2026-11-01) ✅
71+
72+**Files changed**: `src/web/views/{home,repo,tree,blob,commits,commit,blame,branches,tags,search,error}.njk`
73+(all rewritten on the component library), `partials/{repo-header,repo-card,file-table}.njk`
74+(rewritten), `partials/{clone-panel,tree-sheet}.njk` (new), `views/metrics.njk`
75+(new `/about/metrics`), `src/web/routes.mjs` (rewritten: streamed `/raw`,
76+metadata-only blob probe, cached highlighted blobs/markdown/blame, cached
77+prepared diff per sha `commit-v2.json`, split view, large diffs not loaded
78+by default, `/about/metrics`, scoped search page), `src/api/v1.mjs`
79+(`/api/v1/search`, `/api/v1/repos/:name/tree`, richer `/api/v1/stats`,
80+`research` flag on PATCH), `src/lib/search.mjs` (rewritten: files, refs,
81+commits, stamp-validated index `search-index-v2.json`).
82+
83+**Responsive QA** (`scripts/qa/screens.py`, Playwright Chromium, 13 pages ×
84+320/360/375/390/430/768/1024/1440/1920, light/dark/midnight): **0 horizontal
85+overflow, 0 essential control under 44×44 px on touch viewports** (was: every
86+page overflowing at 375 px). Screenshots: `docs/screenshots/v2/` (375 + 1440,
87+midnight + light; full run archived outside the repo).
88+
89+**Open issues carried**: commit pages ship every file's rows in one document
90+(3 MB uncompressed for the 60-file v4 commit; ~120 KB over the gzip edge) —
91+per-file lazy loading is listed as remaining work; WebKit run and 200 % zoom
92+are done in Phase 7.
added docs/screenshots/baseline/blob-1440-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/blob-375-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/commit-1440-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/commit-375-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/home-1440-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/home-375-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/repo-1440-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/repo-375-dark.png +0 −0

Binary file not shown.

added docs/screenshots/v2/blob-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/blob-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/blob-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/blob-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commit-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commit-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commit-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commit-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commits-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commits-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commits-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commits-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/home-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/home-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/home-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/home-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/repo-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/repo-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/repo-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/repo-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/report.json +1000 −0
@@ -0,0 +1,1000 @@
1+[
2+ {
3+ "engine": "chromium",
4+ "theme": "midnight",
5+ "width": 320,
6+ "page": "home",
7+ "scrollWidth": 320,
8+ "clientWidth": 320,
9+ "small": [
10+ "a.brand 28x44",
11+ "input#repo-filter.input 288x38"
12+ ]
13+ },
14+ {
15+ "engine": "chromium",
16+ "theme": "midnight",
17+ "width": 320,
18+ "page": "repo",
19+ "scrollWidth": 320,
20+ "clientWidth": 320,
21+ "small": [
22+ "a.brand 28x44"
23+ ]
24+ },
25+ {
26+ "engine": "chromium",
27+ "theme": "midnight",
28+ "width": 320,
29+ "page": "tree",
30+ "scrollWidth": 320,
31+ "clientWidth": 320,
32+ "small": [
33+ "a.brand 28x44"
34+ ]
35+ },
36+ {
37+ "engine": "chromium",
38+ "theme": "midnight",
39+ "width": 320,
40+ "page": "blob",
41+ "scrollWidth": 320,
42+ "clientWidth": 320,
43+ "small": [
44+ "a.brand 28x44"
45+ ]
46+ },
47+ {
48+ "engine": "chromium",
49+ "theme": "midnight",
50+ "width": 320,
51+ "page": "readme",
52+ "scrollWidth": 320,
53+ "clientWidth": 320,
54+ "small": [
55+ "a.brand 28x44"
56+ ]
57+ },
58+ {
59+ "engine": "chromium",
60+ "theme": "midnight",
61+ "width": 320,
62+ "page": "commits",
63+ "scrollWidth": 320,
64+ "clientWidth": 320,
65+ "small": [
66+ "a.brand 28x44"
67+ ]
68+ },
69+ {
70+ "engine": "chromium",
71+ "theme": "midnight",
72+ "width": 320,
73+ "page": "commit",
74+ "scrollWidth": 320,
75+ "clientWidth": 320,
76+ "small": [
77+ "a.brand 28x44"
78+ ]
79+ },
80+ {
81+ "engine": "chromium",
82+ "theme": "midnight",
83+ "width": 320,
84+ "page": "blame",
85+ "scrollWidth": 320,
86+ "clientWidth": 320,
87+ "small": [
88+ "a.brand 28x44"
89+ ]
90+ },
91+ {
92+ "engine": "chromium",
93+ "theme": "midnight",
94+ "width": 320,
95+ "page": "branches",
96+ "scrollWidth": 320,
97+ "clientWidth": 320,
98+ "small": [
99+ "a.brand 28x44"
100+ ]
101+ },
102+ {
103+ "engine": "chromium",
104+ "theme": "midnight",
105+ "width": 320,
106+ "page": "tags",
107+ "scrollWidth": 320,
108+ "clientWidth": 320,
109+ "small": [
110+ "a.brand 28x44"
111+ ]
112+ },
113+ {
114+ "engine": "chromium",
115+ "theme": "midnight",
116+ "width": 320,
117+ "page": "search",
118+ "scrollWidth": 320,
119+ "clientWidth": 320,
120+ "small": [
121+ "a.brand 28x44"
122+ ]
123+ },
124+ {
125+ "engine": "chromium",
126+ "theme": "midnight",
127+ "width": 320,
128+ "page": "metrics",
129+ "scrollWidth": 320,
130+ "clientWidth": 320,
131+ "small": [
132+ "a.brand 28x44"
133+ ]
134+ },
135+ {
136+ "engine": "chromium",
137+ "theme": "midnight",
138+ "width": 320,
139+ "page": "404",
140+ "scrollWidth": 320,
141+ "clientWidth": 320,
142+ "small": [
143+ "a.brand 28x44"
144+ ]
145+ },
146+ {
147+ "engine": "chromium",
148+ "theme": "midnight",
149+ "width": 375,
150+ "page": "home",
151+ "scrollWidth": 375,
152+ "clientWidth": 375,
153+ "small": [
154+ "input#repo-filter.input 343x38"
155+ ]
156+ },
157+ {
158+ "engine": "chromium",
159+ "theme": "midnight",
160+ "width": 375,
161+ "page": "repo",
162+ "scrollWidth": 375,
163+ "clientWidth": 375,
164+ "small": []
165+ },
166+ {
167+ "engine": "chromium",
168+ "theme": "midnight",
169+ "width": 375,
170+ "page": "tree",
171+ "scrollWidth": 375,
172+ "clientWidth": 375,
173+ "small": []
174+ },
175+ {
176+ "engine": "chromium",
177+ "theme": "midnight",
178+ "width": 375,
179+ "page": "blob",
180+ "scrollWidth": 375,
181+ "clientWidth": 375,
182+ "small": []
183+ },
184+ {
185+ "engine": "chromium",
186+ "theme": "midnight",
187+ "width": 375,
188+ "page": "readme",
189+ "scrollWidth": 375,
190+ "clientWidth": 375,
191+ "small": []
192+ },
193+ {
194+ "engine": "chromium",
195+ "theme": "midnight",
196+ "width": 375,
197+ "page": "commits",
198+ "scrollWidth": 375,
199+ "clientWidth": 375,
200+ "small": []
201+ },
202+ {
203+ "engine": "chromium",
204+ "theme": "midnight",
205+ "width": 375,
206+ "page": "commit",
207+ "scrollWidth": 375,
208+ "clientWidth": 375,
209+ "small": []
210+ },
211+ {
212+ "engine": "chromium",
213+ "theme": "midnight",
214+ "width": 375,
215+ "page": "blame",
216+ "scrollWidth": 375,
217+ "clientWidth": 375,
218+ "small": []
219+ },
220+ {
221+ "engine": "chromium",
222+ "theme": "midnight",
223+ "width": 375,
224+ "page": "branches",
225+ "scrollWidth": 375,
226+ "clientWidth": 375,
227+ "small": []
228+ },
229+ {
230+ "engine": "chromium",
231+ "theme": "midnight",
232+ "width": 375,
233+ "page": "tags",
234+ "scrollWidth": 375,
235+ "clientWidth": 375,
236+ "small": []
237+ },
238+ {
239+ "engine": "chromium",
240+ "theme": "midnight",
241+ "width": 375,
242+ "page": "search",
243+ "scrollWidth": 375,
244+ "clientWidth": 375,
245+ "small": []
246+ },
247+ {
248+ "engine": "chromium",
249+ "theme": "midnight",
250+ "width": 375,
251+ "page": "metrics",
252+ "scrollWidth": 375,
253+ "clientWidth": 375,
254+ "small": []
255+ },
256+ {
257+ "engine": "chromium",
258+ "theme": "midnight",
259+ "width": 375,
260+ "page": "404",
261+ "scrollWidth": 375,
262+ "clientWidth": 375,
263+ "small": []
264+ },
265+ {
266+ "engine": "chromium",
267+ "theme": "midnight",
268+ "width": 430,
269+ "page": "home",
270+ "scrollWidth": 430,
271+ "clientWidth": 430,
272+ "small": [
273+ "input#repo-filter.input 398x38"
274+ ]
275+ },
276+ {
277+ "engine": "chromium",
278+ "theme": "midnight",
279+ "width": 430,
280+ "page": "repo",
281+ "scrollWidth": 430,
282+ "clientWidth": 430,
283+ "small": []
284+ },
285+ {
286+ "engine": "chromium",
287+ "theme": "midnight",
288+ "width": 430,
289+ "page": "tree",
290+ "scrollWidth": 430,
291+ "clientWidth": 430,
292+ "small": []
293+ },
294+ {
295+ "engine": "chromium",
296+ "theme": "midnight",
297+ "width": 430,
298+ "page": "blob",
299+ "scrollWidth": 430,
300+ "clientWidth": 430,
301+ "small": []
302+ },
303+ {
304+ "engine": "chromium",
305+ "theme": "midnight",
306+ "width": 430,
307+ "page": "readme",
308+ "scrollWidth": 430,
309+ "clientWidth": 430,
310+ "small": []
311+ },
312+ {
313+ "engine": "chromium",
314+ "theme": "midnight",
315+ "width": 430,
316+ "page": "commits",
317+ "scrollWidth": 430,
318+ "clientWidth": 430,
319+ "small": []
320+ },
321+ {
322+ "engine": "chromium",
323+ "theme": "midnight",
324+ "width": 430,
325+ "page": "commit",
326+ "scrollWidth": 430,
327+ "clientWidth": 430,
328+ "small": []
329+ },
330+ {
331+ "engine": "chromium",
332+ "theme": "midnight",
333+ "width": 430,
334+ "page": "blame",
335+ "scrollWidth": 430,
336+ "clientWidth": 430,
337+ "small": []
338+ },
339+ {
340+ "engine": "chromium",
341+ "theme": "midnight",
342+ "width": 430,
343+ "page": "branches",
344+ "scrollWidth": 430,
345+ "clientWidth": 430,
346+ "small": []
347+ },
348+ {
349+ "engine": "chromium",
350+ "theme": "midnight",
351+ "width": 430,
352+ "page": "tags",
353+ "scrollWidth": 430,
354+ "clientWidth": 430,
355+ "small": []
356+ },
357+ {
358+ "engine": "chromium",
359+ "theme": "midnight",
360+ "width": 430,
361+ "page": "search",
362+ "scrollWidth": 430,
363+ "clientWidth": 430,
364+ "small": []
365+ },
366+ {
367+ "engine": "chromium",
368+ "theme": "midnight",
369+ "width": 430,
370+ "page": "metrics",
371+ "scrollWidth": 430,
372+ "clientWidth": 430,
373+ "small": []
374+ },
375+ {
376+ "engine": "chromium",
377+ "theme": "midnight",
378+ "width": 430,
379+ "page": "404",
380+ "scrollWidth": 430,
381+ "clientWidth": 430,
382+ "small": []
383+ },
384+ {
385+ "engine": "chromium",
386+ "theme": "midnight",
387+ "width": 1440,
388+ "page": "home",
389+ "scrollWidth": 1440,
390+ "clientWidth": 1440,
391+ "small": []
392+ },
393+ {
394+ "engine": "chromium",
395+ "theme": "midnight",
396+ "width": 1440,
397+ "page": "repo",
398+ "scrollWidth": 1440,
399+ "clientWidth": 1440,
400+ "small": []
401+ },
402+ {
403+ "engine": "chromium",
404+ "theme": "midnight",
405+ "width": 1440,
406+ "page": "tree",
407+ "scrollWidth": 1440,
408+ "clientWidth": 1440,
409+ "small": []
410+ },
411+ {
412+ "engine": "chromium",
413+ "theme": "midnight",
414+ "width": 1440,
415+ "page": "blob",
416+ "scrollWidth": 1440,
417+ "clientWidth": 1440,
418+ "small": []
419+ },
420+ {
421+ "engine": "chromium",
422+ "theme": "midnight",
423+ "width": 1440,
424+ "page": "readme",
425+ "scrollWidth": 1440,
426+ "clientWidth": 1440,
427+ "small": []
428+ },
429+ {
430+ "engine": "chromium",
431+ "theme": "midnight",
432+ "width": 1440,
433+ "page": "commits",
434+ "scrollWidth": 1440,
435+ "clientWidth": 1440,
436+ "small": []
437+ },
438+ {
439+ "engine": "chromium",
440+ "theme": "midnight",
441+ "width": 1440,
442+ "page": "commit",
443+ "scrollWidth": 1440,
444+ "clientWidth": 1440,
445+ "small": []
446+ },
447+ {
448+ "engine": "chromium",
449+ "theme": "midnight",
450+ "width": 1440,
451+ "page": "blame",
452+ "scrollWidth": 1440,
453+ "clientWidth": 1440,
454+ "small": []
455+ },
456+ {
457+ "engine": "chromium",
458+ "theme": "midnight",
459+ "width": 1440,
460+ "page": "branches",
461+ "scrollWidth": 1440,
462+ "clientWidth": 1440,
463+ "small": []
464+ },
465+ {
466+ "engine": "chromium",
467+ "theme": "midnight",
468+ "width": 1440,
469+ "page": "tags",
470+ "scrollWidth": 1440,
471+ "clientWidth": 1440,
472+ "small": []
473+ },
474+ {
475+ "engine": "chromium",
476+ "theme": "midnight",
477+ "width": 1440,
478+ "page": "search",
479+ "scrollWidth": 1440,
480+ "clientWidth": 1440,
481+ "small": []
482+ },
483+ {
484+ "engine": "chromium",
485+ "theme": "midnight",
486+ "width": 1440,
487+ "page": "metrics",
488+ "scrollWidth": 1440,
489+ "clientWidth": 1440,
490+ "small": []
491+ },
492+ {
493+ "engine": "chromium",
494+ "theme": "midnight",
495+ "width": 1440,
496+ "page": "404",
497+ "scrollWidth": 1440,
498+ "clientWidth": 1440,
499+ "small": []
500+ },
501+ {
502+ "engine": "chromium",
503+ "theme": "light",
504+ "width": 320,
505+ "page": "home",
506+ "scrollWidth": 320,
507+ "clientWidth": 320,
508+ "small": [
509+ "a.brand 28x44",
510+ "input#repo-filter.input 288x38"
511+ ]
512+ },
513+ {
514+ "engine": "chromium",
515+ "theme": "light",
516+ "width": 320,
517+ "page": "repo",
518+ "scrollWidth": 320,
519+ "clientWidth": 320,
520+ "small": [
521+ "a.brand 28x44"
522+ ]
523+ },
524+ {
525+ "engine": "chromium",
526+ "theme": "light",
527+ "width": 320,
528+ "page": "tree",
529+ "scrollWidth": 320,
530+ "clientWidth": 320,
531+ "small": [
532+ "a.brand 28x44"
533+ ]
534+ },
535+ {
536+ "engine": "chromium",
537+ "theme": "light",
538+ "width": 320,
539+ "page": "blob",
540+ "scrollWidth": 320,
541+ "clientWidth": 320,
542+ "small": [
543+ "a.brand 28x44"
544+ ]
545+ },
546+ {
547+ "engine": "chromium",
548+ "theme": "light",
549+ "width": 320,
550+ "page": "readme",
551+ "scrollWidth": 320,
552+ "clientWidth": 320,
553+ "small": [
554+ "a.brand 28x44"
555+ ]
556+ },
557+ {
558+ "engine": "chromium",
559+ "theme": "light",
560+ "width": 320,
561+ "page": "commits",
562+ "scrollWidth": 320,
563+ "clientWidth": 320,
564+ "small": [
565+ "a.brand 28x44"
566+ ]
567+ },
568+ {
569+ "engine": "chromium",
570+ "theme": "light",
571+ "width": 320,
572+ "page": "commit",
573+ "scrollWidth": 320,
574+ "clientWidth": 320,
575+ "small": [
576+ "a.brand 28x44"
577+ ]
578+ },
579+ {
580+ "engine": "chromium",
581+ "theme": "light",
582+ "width": 320,
583+ "page": "blame",
584+ "scrollWidth": 320,
585+ "clientWidth": 320,
586+ "small": [
587+ "a.brand 28x44"
588+ ]
589+ },
590+ {
591+ "engine": "chromium",
592+ "theme": "light",
593+ "width": 320,
594+ "page": "branches",
595+ "scrollWidth": 320,
596+ "clientWidth": 320,
597+ "small": [
598+ "a.brand 28x44"
599+ ]
600+ },
601+ {
602+ "engine": "chromium",
603+ "theme": "light",
604+ "width": 320,
605+ "page": "tags",
606+ "scrollWidth": 320,
607+ "clientWidth": 320,
608+ "small": [
609+ "a.brand 28x44"
610+ ]
611+ },
612+ {
613+ "engine": "chromium",
614+ "theme": "light",
615+ "width": 320,
616+ "page": "search",
617+ "scrollWidth": 320,
618+ "clientWidth": 320,
619+ "small": [
620+ "a.brand 28x44"
621+ ]
622+ },
623+ {
624+ "engine": "chromium",
625+ "theme": "light",
626+ "width": 320,
627+ "page": "metrics",
628+ "scrollWidth": 320,
629+ "clientWidth": 320,
630+ "small": [
631+ "a.brand 28x44"
632+ ]
633+ },
634+ {
635+ "engine": "chromium",
636+ "theme": "light",
637+ "width": 320,
638+ "page": "404",
639+ "scrollWidth": 320,
640+ "clientWidth": 320,
641+ "small": [
642+ "a.brand 28x44"
643+ ]
644+ },
645+ {
646+ "engine": "chromium",
647+ "theme": "light",
648+ "width": 375,
649+ "page": "home",
650+ "scrollWidth": 375,
651+ "clientWidth": 375,
652+ "small": [
653+ "input#repo-filter.input 343x38"
654+ ]
655+ },
656+ {
657+ "engine": "chromium",
658+ "theme": "light",
659+ "width": 375,
660+ "page": "repo",
661+ "scrollWidth": 375,
662+ "clientWidth": 375,
663+ "small": []
664+ },
665+ {
666+ "engine": "chromium",
667+ "theme": "light",
668+ "width": 375,
669+ "page": "tree",
670+ "scrollWidth": 375,
671+ "clientWidth": 375,
672+ "small": []
673+ },
674+ {
675+ "engine": "chromium",
676+ "theme": "light",
677+ "width": 375,
678+ "page": "blob",
679+ "scrollWidth": 375,
680+ "clientWidth": 375,
681+ "small": []
682+ },
683+ {
684+ "engine": "chromium",
685+ "theme": "light",
686+ "width": 375,
687+ "page": "readme",
688+ "scrollWidth": 375,
689+ "clientWidth": 375,
690+ "small": []
691+ },
692+ {
693+ "engine": "chromium",
694+ "theme": "light",
695+ "width": 375,
696+ "page": "commits",
697+ "scrollWidth": 375,
698+ "clientWidth": 375,
699+ "small": []
700+ },
701+ {
702+ "engine": "chromium",
703+ "theme": "light",
704+ "width": 375,
705+ "page": "commit",
706+ "scrollWidth": 375,
707+ "clientWidth": 375,
708+ "small": []
709+ },
710+ {
711+ "engine": "chromium",
712+ "theme": "light",
713+ "width": 375,
714+ "page": "blame",
715+ "scrollWidth": 375,
716+ "clientWidth": 375,
717+ "small": []
718+ },
719+ {
720+ "engine": "chromium",
721+ "theme": "light",
722+ "width": 375,
723+ "page": "branches",
724+ "scrollWidth": 375,
725+ "clientWidth": 375,
726+ "small": []
727+ },
728+ {
729+ "engine": "chromium",
730+ "theme": "light",
731+ "width": 375,
732+ "page": "tags",
733+ "scrollWidth": 375,
734+ "clientWidth": 375,
735+ "small": []
736+ },
737+ {
738+ "engine": "chromium",
739+ "theme": "light",
740+ "width": 375,
741+ "page": "search",
742+ "scrollWidth": 375,
743+ "clientWidth": 375,
744+ "small": []
745+ },
746+ {
747+ "engine": "chromium",
748+ "theme": "light",
749+ "width": 375,
750+ "page": "metrics",
751+ "scrollWidth": 375,
752+ "clientWidth": 375,
753+ "small": []
754+ },
755+ {
756+ "engine": "chromium",
757+ "theme": "light",
758+ "width": 375,
759+ "page": "404",
760+ "scrollWidth": 375,
761+ "clientWidth": 375,
762+ "small": []
763+ },
764+ {
765+ "engine": "chromium",
766+ "theme": "light",
767+ "width": 430,
768+ "page": "home",
769+ "scrollWidth": 430,
770+ "clientWidth": 430,
771+ "small": [
772+ "input#repo-filter.input 398x38"
773+ ]
774+ },
775+ {
776+ "engine": "chromium",
777+ "theme": "light",
778+ "width": 430,
779+ "page": "repo",
780+ "scrollWidth": 430,
781+ "clientWidth": 430,
782+ "small": []
783+ },
784+ {
785+ "engine": "chromium",
786+ "theme": "light",
787+ "width": 430,
788+ "page": "tree",
789+ "scrollWidth": 430,
790+ "clientWidth": 430,
791+ "small": []
792+ },
793+ {
794+ "engine": "chromium",
795+ "theme": "light",
796+ "width": 430,
797+ "page": "blob",
798+ "scrollWidth": 430,
799+ "clientWidth": 430,
800+ "small": []
801+ },
802+ {
803+ "engine": "chromium",
804+ "theme": "light",
805+ "width": 430,
806+ "page": "readme",
807+ "scrollWidth": 430,
808+ "clientWidth": 430,
809+ "small": []
810+ },
811+ {
812+ "engine": "chromium",
813+ "theme": "light",
814+ "width": 430,
815+ "page": "commits",
816+ "scrollWidth": 430,
817+ "clientWidth": 430,
818+ "small": []
819+ },
820+ {
821+ "engine": "chromium",
822+ "theme": "light",
823+ "width": 430,
824+ "page": "commit",
825+ "scrollWidth": 430,
826+ "clientWidth": 430,
827+ "small": []
828+ },
829+ {
830+ "engine": "chromium",
831+ "theme": "light",
832+ "width": 430,
833+ "page": "blame",
834+ "scrollWidth": 430,
835+ "clientWidth": 430,
836+ "small": []
837+ },
838+ {
839+ "engine": "chromium",
840+ "theme": "light",
841+ "width": 430,
842+ "page": "branches",
843+ "scrollWidth": 430,
844+ "clientWidth": 430,
845+ "small": []
846+ },
847+ {
848+ "engine": "chromium",
849+ "theme": "light",
850+ "width": 430,
851+ "page": "tags",
852+ "scrollWidth": 430,
853+ "clientWidth": 430,
854+ "small": []
855+ },
856+ {
857+ "engine": "chromium",
858+ "theme": "light",
859+ "width": 430,
860+ "page": "search",
861+ "scrollWidth": 430,
862+ "clientWidth": 430,
863+ "small": []
864+ },
865+ {
866+ "engine": "chromium",
867+ "theme": "light",
868+ "width": 430,
869+ "page": "metrics",
870+ "scrollWidth": 430,
871+ "clientWidth": 430,
872+ "small": []
873+ },
874+ {
875+ "engine": "chromium",
876+ "theme": "light",
877+ "width": 430,
878+ "page": "404",
879+ "scrollWidth": 430,
880+ "clientWidth": 430,
881+ "small": []
882+ },
883+ {
884+ "engine": "chromium",
885+ "theme": "light",
886+ "width": 1440,
887+ "page": "home",
888+ "scrollWidth": 1440,
889+ "clientWidth": 1440,
890+ "small": []
891+ },
892+ {
893+ "engine": "chromium",
894+ "theme": "light",
895+ "width": 1440,
896+ "page": "repo",
897+ "scrollWidth": 1440,
898+ "clientWidth": 1440,
899+ "small": []
900+ },
901+ {
902+ "engine": "chromium",
903+ "theme": "light",
904+ "width": 1440,
905+ "page": "tree",
906+ "scrollWidth": 1440,
907+ "clientWidth": 1440,
908+ "small": []
909+ },
910+ {
911+ "engine": "chromium",
912+ "theme": "light",
913+ "width": 1440,
914+ "page": "blob",
915+ "scrollWidth": 1440,
916+ "clientWidth": 1440,
917+ "small": []
918+ },
919+ {
920+ "engine": "chromium",
921+ "theme": "light",
922+ "width": 1440,
923+ "page": "readme",
924+ "scrollWidth": 1440,
925+ "clientWidth": 1440,
926+ "small": []
927+ },
928+ {
929+ "engine": "chromium",
930+ "theme": "light",
931+ "width": 1440,
932+ "page": "commits",
933+ "scrollWidth": 1440,
934+ "clientWidth": 1440,
935+ "small": []
936+ },
937+ {
938+ "engine": "chromium",
939+ "theme": "light",
940+ "width": 1440,
941+ "page": "commit",
942+ "scrollWidth": 1440,
943+ "clientWidth": 1440,
944+ "small": []
945+ },
946+ {
947+ "engine": "chromium",
948+ "theme": "light",
949+ "width": 1440,
950+ "page": "blame",
951+ "scrollWidth": 1440,
952+ "clientWidth": 1440,
953+ "small": []
954+ },
955+ {
956+ "engine": "chromium",
957+ "theme": "light",
958+ "width": 1440,
959+ "page": "branches",
960+ "scrollWidth": 1440,
961+ "clientWidth": 1440,
962+ "small": []
963+ },
964+ {
965+ "engine": "chromium",
966+ "theme": "light",
967+ "width": 1440,
968+ "page": "tags",
969+ "scrollWidth": 1440,
970+ "clientWidth": 1440,
971+ "small": []
972+ },
973+ {
974+ "engine": "chromium",
975+ "theme": "light",
976+ "width": 1440,
977+ "page": "search",
978+ "scrollWidth": 1440,
979+ "clientWidth": 1440,
980+ "small": []
981+ },
982+ {
983+ "engine": "chromium",
984+ "theme": "light",
985+ "width": 1440,
986+ "page": "metrics",
987+ "scrollWidth": 1440,
988+ "clientWidth": 1440,
989+ "small": []
990+ },
991+ {
992+ "engine": "chromium",
993+ "theme": "light",
994+ "width": 1440,
995+ "page": "404",
996+ "scrollWidth": 1440,
997+ "clientWidth": 1440,
998+ "small": []
999+ }
1000+]
\ No newline at end of file
added docs/screenshots/v2/search-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/search-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/search-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/search-375-midnight-chromium.png +0 −0

Binary file not shown.

modified package.json +1 −1
@@ -1,6 +1,6 @@
1 { 1 {
2 "name": "spbgit", 2 "name": "spbgit",
3− "version": "1.1.0", 3+ "version": "2.0.0",
4 "description": "SPB Git — Personal Git Platform for Simon-Pierre Boucher", 4 "description": "SPB Git — Personal Git Platform for Simon-Pierre Boucher",
5 "author": "Simon-Pierre Boucher <contact@spboucher.ai>", 5 "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
6 "license": "MIT", 6 "license": "MIT",
added scripts/qa/screens.py +96 −0
@@ -0,0 +1,96 @@
1+# ─────────────────────────────────────────────
2+# SPB Git — Personal Git Platform
3+# ─────────────────────────────────────────────
4+# Author : Simon-Pierre Boucher
5+# Contact : contact@spboucher.ai
6+# File : scripts/qa/screens.py
7+# Purpose : Responsive QA — screenshots + overflow + touch-target checks across widths, themes and engines (Playwright for Python)
8+# License : MIT © Simon-Pierre Boucher
9+# ─────────────────────────────────────────────
10+"""
11+Usage:
12+ python3 -I scripts/qa/screens.py BASE_URL OUT_DIR [--widths 320,375,1440] [--themes light,dark,midnight]
13+ [--engines chromium,webkit] [--pages home,repo,...] [--repo spbgit] [--no-shots]
14+Exit code 1 when any page overflows horizontally or an essential control is under 44×44 CSS px on a touch viewport.
15+"""
16+import sys, os, asyncio, argparse, json
17+from playwright.async_api import async_playwright
18+
19+PAGES = {
20+ "home": "/",
21+ "repo": "/{repo}",
22+ "tree": "/{repo}/tree/main/src",
23+ "blob": "/{repo}/blob/main/src/server.mjs",
24+ "readme": "/{repo}/blob/main/README.md",
25+ "commits": "/{repo}/commits/main",
26+ "commit": "/{repo}/commit/{sha}",
27+ "blame": "/{repo}/blame/main/package.json",
28+ "branches": "/{repo}/branches",
29+ "tags": "/{repo}/tags",
30+ "search": "/search?q=git",
31+ "metrics": "/about/metrics",
32+ "404": "/this-does-not-exist",
33+}
34+ESSENTIAL = ".app-header button, .app-header a.brand, .tab, .btn:not(.hide-mobile *), .icon-btn, .sha-chip, .ref-select, #repo-filter, .catalog-filters select, .sheet-nav a, .theme-choices button"
35+CHECK_JS = """(sel) => {
36+ const de = document.documentElement;
37+ const out = { scrollWidth: de.scrollWidth, clientWidth: de.clientWidth, small: [] };
38+ for (const el of document.querySelectorAll(sel)) {
39+ const r = el.getBoundingClientRect();
40+ if (r.width === 0 || r.height === 0) continue; // hidden
41+ if (el.closest('dialog:not([open])')) continue;
42+ if (r.height < 43.5 || r.width < 43.5) out.small.push(el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.') : '') + ' ' + Math.round(r.width) + 'x' + Math.round(r.height));
43+ }
44+ return out;
45+}"""
46+
47+async def main():
48+ ap = argparse.ArgumentParser()
49+ ap.add_argument("base"); ap.add_argument("out")
50+ ap.add_argument("--widths", default="320,360,375,390,430,768,1024,1440,1920")
51+ ap.add_argument("--themes", default="light,dark,midnight")
52+ ap.add_argument("--engines", default="chromium")
53+ ap.add_argument("--pages", default=",".join(PAGES))
54+ ap.add_argument("--repo", default="spbgit"); ap.add_argument("--sha", default="810e402")
55+ ap.add_argument("--no-shots", action="store_true"); ap.add_argument("--zoom", type=float, default=1.0)
56+ a = ap.parse_args()
57+ os.makedirs(a.out, exist_ok=True)
58+ widths = [int(w) for w in a.widths.split(",")]
59+ themes = a.themes.split(","); engines = a.engines.split(",")
60+ pages = [(k, PAGES[k].format(repo=a.repo, sha=a.sha)) for k in a.pages.split(",") if k in PAGES]
61+ failures = 0; rows = []
62+ async with async_playwright() as p:
63+ for engine in engines:
64+ b = await getattr(p, engine).launch()
65+ for theme in themes:
66+ for w in widths:
67+ mobile = w < 600
68+ h = 812 if mobile else 900
69+ ctx = await b.new_context(viewport={"width": w, "height": h}, is_mobile=mobile and engine == "chromium", has_touch=mobile, device_scale_factor=1)
70+ await ctx.add_init_script(f"try{{localStorage.setItem('spbgit-theme','{theme}')}}catch(e){{}}")
71+ if a.zoom != 1.0:
72+ await ctx.add_init_script(f"document.addEventListener('DOMContentLoaded',()=>{{document.documentElement.style.zoom='{a.zoom}'}})")
73+ pg = await ctx.new_page()
74+ for name, path in pages:
75+ try:
76+ await pg.goto(a.base + path, wait_until="networkidle", timeout=45000)
77+ await pg.wait_for_timeout(300)
78+ r = await pg.evaluate(CHECK_JS, ESSENTIAL)
79+ overflow = r["scrollWidth"] > r["clientWidth"]
80+ small = r["small"] if mobile else []
81+ bad = overflow or bool(small)
82+ failures += int(bad)
83+ rows.append({"engine": engine, "theme": theme, "width": w, "page": name, "scrollWidth": r["scrollWidth"], "clientWidth": r["clientWidth"], "small": small})
84+ flag = ("OVERFLOW " if overflow else "") + (f"SMALL:{small[:3]}" if small else "")
85+ print(f"{engine:8} {theme:8} {w:5} {name:9} sw={r['scrollWidth']:5} cw={r['clientWidth']:5} {flag}")
86+ if not a.no_shots and w in (320, 375, 430, 1440):
87+ await pg.screenshot(path=f"{a.out}/{name}-{w}-{theme}-{engine}.png", full_page=True)
88+ except Exception as e:
89+ failures += 1; print(f"{engine} {theme} {w} {name} ERROR {str(e)[:120]}")
90+ await ctx.close()
91+ await b.close()
92+ with open(f"{a.out}/report.json", "w") as f: json.dump(rows, f, indent=1)
93+ print(f"\n{'FAIL' if failures else 'OK'} — {failures} failing page/width combinations")
94+ sys.exit(1 if failures else 0)
95+
96+asyncio.run(main())
modified src/api/v1.mjs +41 −2
@@ -11,10 +11,12 @@
11 */ 11 */
12 12
13 import { z } from 'zod'; 13 import { z } from 'zod';
14−import { isValidRepoName } from '../lib/util.mjs'; 14+import { isValidRepoName, isValidTreePath } from '../lib/util.mjs';
15 import { makeRequireAuth } from '../auth/token.mjs'; 15 import { makeRequireAuth } from '../auth/token.mjs';
16 import { installHooks } from '../git/hooks.mjs'; 16 import { installHooks } from '../git/hooks.mjs';
17 import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs'; 17 import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs';
18+import { search } from '../lib/search.mjs';
19+import { contributionCalendar } from '../stats/activity.mjs';
18 import { computeLanguages } from '../stats/languages.mjs'; 20 import { computeLanguages } from '../stats/languages.mjs';
19 import { isValidAssetName, isValidTagName } from '../git/releases.mjs'; 21 import { isValidAssetName, isValidTagName } from '../git/releases.mjs';
20 22
@@ -36,6 +38,7 @@ const patchSchema = z.object({
36 topics: z.array(z.string().regex(TOPIC_RE)).max(10).optional(), 38 topics: z.array(z.string().regex(TOPIC_RE)).max(10).optional(),
37 homepage: z.union([z.literal(''), z.string().url().max(300)]).optional(), 39 homepage: z.union([z.literal(''), z.string().url().max(300)]).optional(),
38 pinned: z.boolean().optional(), 40 pinned: z.boolean().optional(),
41+ research: z.boolean().optional(),
39 defaultBranch: z.string().regex(/^[\w./-]{1,100}$/).optional(), 42 defaultBranch: z.string().regex(/^[\w./-]{1,100}$/).optional(),
40 }).strict(); 43 }).strict();
41 44
@@ -97,7 +100,43 @@ export function registerApi(app, ctx) {
97 return { ref: refInput, sha, page, hasNext, commits }; 100 return { ref: refInput, sha, page, hasNext, commits };
98 }); 101 });
99 102
100− app.get('/api/v1/stats', async () => siteStats(ctx)); 103+ app.get('/api/v1/stats', async () => {
104+ const overviews = await allOverviews(ctx);
105+ const [stats, calendar] = await Promise.all([siteStats(ctx, overviews), contributionCalendar(ctx)]);
106+ return {
107+ ...stats,
108+ commitsLast52Weeks: calendar.total,
109+ streak: calendar.streak,
110+ longestStreak: calendar.longest,
111+ activeDays: calendar.activeDays,
112+ definitions: `${ctx.config.publicUrl}/about/metrics`,
113+ };
114+ });
115+
116+ // Scoped search powering the ⌘K palette and /search.
117+ app.get('/api/v1/search', async (request) => {
118+ const q = String(request.query.q ?? '').slice(0, 120);
119+ const scope = ['all', 'repos', 'files', 'commits', 'branches', 'readmes'].includes(request.query.scope) ? request.query.scope : 'all';
120+ const repo = typeof request.query.repo === 'string' && isValidRepoName(request.query.repo) && ctx.repos.exists(request.query.repo) ? request.query.repo : null;
121+ const limit = Math.max(1, Math.min(50, Number(request.query.limit) || 20));
122+ const results = await search(ctx, q, { scope, repo, limit });
123+ return { q, scope, repo, ...results };
124+ });
125+
126+ // One directory level of a tree (feeds the lazy file tree).
127+ app.get('/api/v1/repos/:name/tree', async (request, reply) => {
128+ const { name } = request.params;
129+ if (!isValidRepoName(name) || !ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');
130+ const refInput = String(request.query.ref ?? '') || (await ctx.repos.defaultBranch(name));
131+ const path = String(request.query.path ?? '').replace(/^\/+|\/+$/g, '');
132+ if (path !== '' && !isValidTreePath(path)) return apiError(reply, 400, 'invalid_path', 'invalid path');
133+ const sha = await ctx.repos.resolveRef(name, refInput);
134+ if (!sha) return apiError(reply, 404, 'ref_not_found', `unknown ref: ${refInput}`);
135+ const entries = await ctx.repos.tree(name, sha, path);
136+ if (!entries) return apiError(reply, 404, 'not_found', 'no such directory');
137+ reply.header('Cache-Control', /^[0-9a-f]{40}$/.test(refInput) ? 'public, max-age=31536000, immutable' : 'public, max-age=60');
138+ return { repo: name, ref: refInput, sha, path, entries: entries.map((e) => ({ name: e.name, path: e.path, type: e.type, size: e.size })) };
139+ });
101 140
102 app.post('/api/v1/repos', async (request, reply) => { 141 app.post('/api/v1/repos', async (request, reply) => {
103 if (!(await requireAuth(request, reply))) return reply; 142 if (!(await requireAuth(request, reply))) return reply;
modified src/config.mjs +19 −0
@@ -36,6 +36,14 @@ const schema = z.object({
36 SPBGIT_CACHE_DIR: z.string().min(1).default('/srv/spbgit/cache'), 36 SPBGIT_CACHE_DIR: z.string().min(1).default('/srv/spbgit/cache'),
37 SPBGIT_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), 37 SPBGIT_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
38 SPBGIT_ENV: z.enum(['development', 'production', 'test']).default('production'), 38 SPBGIT_ENV: z.enum(['development', 'production', 'test']).default('production'),
39+ // ── AI features (Phase 5) — OFF unless a provider is configured explicitly ──
40+ SPBGIT_AI_PROVIDER: z.enum(['', 'anthropic', 'openai-compatible', 'fake']).default(''),
41+ SPBGIT_AI_API_KEY: z.string().default(''),
42+ SPBGIT_AI_BASE_URL: z.string().default(''),
43+ SPBGIT_AI_MODEL: z.string().default(''),
44+ SPBGIT_AI_MAX_TOKENS: z.coerce.number().int().min(256).max(32000).default(2048),
45+ SPBGIT_AI_CONCURRENCY: z.coerce.number().int().min(1).max(16).default(2),
46+ SPBGIT_AI_DAILY_BUDGET_TOKENS: z.coerce.number().int().min(0).default(500000),
39 }); 47 });
40 48
41 /** 49 /**
@@ -94,6 +102,17 @@ export function loadConfig(overrides = {}) {
94 dataDir: abs(env.SPBGIT_DATA_DIR), 102 dataDir: abs(env.SPBGIT_DATA_DIR),
95 cacheDir: abs(env.SPBGIT_CACHE_DIR), 103 cacheDir: abs(env.SPBGIT_CACHE_DIR),
96 owner: OWNER, 104 owner: OWNER,
105+ ai: Object.freeze({
106+ // The fake provider is only ever allowed under the test environment.
107+ enabled: env.SPBGIT_AI_PROVIDER !== '' && (env.SPBGIT_AI_PROVIDER !== 'fake' || env.SPBGIT_ENV === 'test'),
108+ provider: env.SPBGIT_AI_PROVIDER || null,
109+ apiKey: env.SPBGIT_AI_API_KEY,
110+ baseUrl: env.SPBGIT_AI_BASE_URL,
111+ model: env.SPBGIT_AI_MODEL,
112+ maxTokens: env.SPBGIT_AI_MAX_TOKENS,
113+ concurrency: env.SPBGIT_AI_CONCURRENCY,
114+ dailyBudgetTokens: env.SPBGIT_AI_DAILY_BUDGET_TOKENS,
115+ }),
97 }); 116 });
98 return config; 117 return config;
99 } 118 }
modified src/git/repo.mjs +158 −11
@@ -15,8 +15,9 @@ import { promisify } from 'node:util';
15 import { readdirSync, existsSync, statSync, renameSync, mkdirSync } from 'node:fs'; 15 import { readdirSync, existsSync, statSync, renameSync, mkdirSync } from 'node:fs';
16 import { join } from 'node:path'; 16 import { join } from 'node:path';
17 import { isValidRepoName, safeJoin, isValidTreePath } from '../lib/util.mjs'; 17 import { isValidRepoName, safeJoin, isValidTreePath } from '../lib/util.mjs';
18+import { Semaphore } from '../lib/semaphore.mjs';
18 19
19−const execFileAsync = promisify(execFile); 20+const execFileRaw = promisify(execFile);
20 const MAX_BUFFER = 64 * 1024 * 1024; 21 const MAX_BUFFER = 64 * 1024 * 1024;
21 const FS = '\x01'; 22 const FS = '\x01';
22 const RS = '\x02'; 23 const RS = '\x02';
@@ -25,6 +26,21 @@ const RS = '\x02';
25 const DIFF_MAX_LINES = 20000; 26 const DIFF_MAX_LINES = 20000;
26 /** Commits walked when computing "last commit per path" before falling back. */ 27 /** Commits walked when computing "last commit per path" before falling back. */
27 const TREE_LOG_WALK_CAP = 600; 28 const TREE_LOG_WALK_CAP = 600;
29+/** Bytes read to decide whether a blob is binary. */
30+const BINARY_PROBE_BYTES = 8000;
31+/**
32+ * Global ceiling on concurrent git subprocesses. Crawlers fanning out over
33+ * large blobs used to spawn dozens of 64 MB-buffered git processes at once
34+ * (the 2026-10/11 memory-restart loop); 12 keeps a 10-core gateway busy
35+ * without letting RSS run away.
36+ */
37+export const GIT_CONCURRENCY = Number(process.env.SPBGIT_GIT_CONCURRENCY) > 0 ? Number(process.env.SPBGIT_GIT_CONCURRENCY) : 12;
38+export const gitSemaphore = new Semaphore(GIT_CONCURRENCY);
39+
40+/** execFile under the global git semaphore. */
41+function execFileAsync(cmd, args, opts) {
42+ return gitSemaphore.run(() => execFileRaw(cmd, args, opts));
43+}
28 44
29 /** 45 /**
30 * Repository model rooted at `gitRoot`. All read operations shell out to the 46 * Repository model rooted at `gitRoot`. All read operations shell out to the
@@ -272,20 +288,115 @@ export class Repos {
272 288
273 /** 289 /**
274 * Read a blob at `<sha>:<path>`. 290 * Read a blob at `<sha>:<path>`.
275− * @returns {Promise<{content: Buffer, size: number, binary: boolean}|null>} 291+ * @param {{maxBytes?: number}} [opts] when the blob is larger than `maxBytes`
292+ * the content is NOT read — `content` is null and `tooLarge` is true.
293+ * @returns {Promise<{content: Buffer|null, size: number, binary: boolean, tooLarge: boolean}|null>}
276 */ 294 */
277− async blob(name, sha, path) { 295+ async blob(name, sha, path, opts = {}) {
278 if (!isValidTreePath(path)) return null; 296 if (!isValidTreePath(path)) return null;
297+ const info = await this.blobInfo(name, sha, path);
298+ if (!info) return null;
299+ if (opts.maxBytes != null && info.size > opts.maxBytes) {
300+ return { content: null, size: info.size, binary: info.binary, tooLarge: true };
301+ }
279 try { 302 try {
280 const content = await this.gitBuffer(name, ['cat-file', 'blob', `${sha}:${path}`]); 303 const content = await this.gitBuffer(name, ['cat-file', 'blob', `${sha}:${path}`]);
281− const probe = content.subarray(0, 8000); 304+ return { content, size: content.length, binary: info.binary, tooLarge: false };
282− const binary = probe.includes(0);
283− return { content, size: content.length, binary };
284 } catch { 305 } catch {
285 return null; 306 return null;
286 } 307 }
287 } 308 }
288 309
310+ /**
311+ * Size + binary flag of a blob WITHOUT buffering it: `cat-file -s` plus a
312+ * bounded read of the first bytes. Lets views and `/raw` decide what to do
313+ * with multi-megabyte files at near-zero memory cost.
314+ * @returns {Promise<{size: number, binary: boolean}|null>}
315+ */
316+ async blobInfo(name, sha, path) {
317+ if (!isValidTreePath(path)) return null;
318+ const type = await this.tryGit(name, ['cat-file', '-t', `${sha}:${path}`]);
319+ if (!type || type.trim() !== 'blob') return null;
320+ const sizeOut = await this.tryGit(name, ['cat-file', '-s', `${sha}:${path}`]);
321+ const size = Number(sizeOut?.trim());
322+ if (!Number.isFinite(size)) return null;
323+ if (size === 0) return { size: 0, binary: false };
324+ const head = await this.#readHead(name, ['cat-file', 'blob', `${sha}:${path}`], BINARY_PROBE_BYTES);
325+ return { size, binary: head.includes(0) };
326+ }
327+
328+ /** Spawn git and collect at most `limit` bytes of stdout, then kill it. */
329+ #readHead(name, args, limit) {
330+ return gitSemaphore.run(() => new Promise((resolve) => {
331+ const child = spawn('git', args, { cwd: this.dir(name) });
332+ const chunks = [];
333+ let got = 0;
334+ let done = false;
335+ const finish = () => {
336+ if (done) return;
337+ done = true;
338+ resolve(Buffer.concat(chunks).subarray(0, limit));
339+ };
340+ child.stdout.on('data', (chunk) => {
341+ if (done) return;
342+ chunks.push(chunk);
343+ got += chunk.length;
344+ if (got >= limit) {
345+ child.stdout.destroy();
346+ child.kill('SIGTERM');
347+ finish();
348+ }
349+ });
350+ child.on('close', finish);
351+ child.on('error', finish);
352+ }));
353+ }
354+
355+ /**
356+ * Stream a blob's content (for `/raw`). The returned stream is git's stdout;
357+ * the semaphore slot is released when the process exits.
358+ * @returns {Promise<import('node:stream').Readable>}
359+ */
360+ async blobStream(name, sha, path) {
361+ if (!isValidTreePath(path)) throw new Error('invalid path');
362+ const release = await gitSemaphore.acquire();
363+ const child = spawn('git', ['cat-file', 'blob', `${sha}:${path}`], { cwd: this.dir(name) });
364+ let released = false;
365+ const done = () => {
366+ if (!released) {
367+ released = true;
368+ release();
369+ }
370+ };
371+ child.on('close', done);
372+ child.on('error', done);
373+ child.stdout.on('close', () => {
374+ if (child.exitCode === null) child.kill('SIGTERM');
375+ });
376+ return child.stdout;
377+ }
378+
379+ /**
380+ * Cheap change detector for a repository's refs: mtimes of HEAD,
381+ * packed-refs and the refs/heads + refs/tags directories. Any push, tag or
382+ * branch deletion changes at least one of them, so caches keyed on this
383+ * stamp never go stale when the post-receive hook is missed.
384+ * @returns {string}
385+ */
386+ refStamp(name) {
387+ const dir = this.dir(name);
388+ const parts = [];
389+ for (const rel of ['HEAD', 'packed-refs', 'refs/heads', 'refs/tags']) {
390+ try {
391+ const st = statSync(join(dir, rel));
392+ parts.push(`${rel}:${st.mtimeMs}:${st.size}`);
393+ } catch {
394+ parts.push(`${rel}:-`);
395+ }
396+ }
397+ return parts.join('|');
398+ }
399+
289 /** Type of the object at `<sha>:<path>` — 'blob' | 'tree' | null. */ 400 /** Type of the object at `<sha>:<path>` — 'blob' | 'tree' | null. */
290 async objectType(name, sha, path) { 401 async objectType(name, sha, path) {
291 if (path === '') return 'tree'; 402 if (path === '') return 'tree';
@@ -635,14 +746,50 @@ export class Repos {
635 .filter(Boolean); 746 .filter(Boolean);
636 } 747 }
637 748
638− /** All commit timestamps+subjects on the default branch (for the heatmap). */ 749+ /**
639− async commitTimestamps(name) { 750+ * Committer timestamps (unix seconds) of every commit reachable from HEAD —
640− const head = await this.head(name); 751+ * the single source for commit counts, sparklines and the calendar (see
641− if (!head) return []; 752+ * /about/metrics).
642− const out = await this.tryGit(name, ['rev-list', '--format=%ct', '--no-commit-header', head]); 753+ * @param {string} name
754+ * @param {string} [head] resolved HEAD sha (looked up when omitted)
755+ */
756+ async commitTimestamps(name, head) {
757+ const sha = head ?? (await this.head(name));
758+ if (!sha) return [];
759+ const out = await this.tryGit(name, ['rev-list', '--format=%ct', '--no-commit-header', sha]);
643 if (!out) return []; 760 if (!out) return [];
644 return out.split('\n').filter(Boolean).map(Number); 761 return out.split('\n').filter(Boolean).map(Number);
645 } 762 }
763+
764+ /**
765+ * Every path at a commit (for the file search index). Capped so one huge
766+ * monorepo cannot dominate the index.
767+ * @returns {Promise<string[]>}
768+ */
769+ async allPaths(name, sha, cap = 20000) {
770+ const out = await this.tryGit(name, ['ls-tree', '-r', '--name-only', '-z', sha]);
771+ if (!out) return [];
772+ const paths = out.split('\0').filter(Boolean);
773+ return paths.length > cap ? paths.slice(0, cap) : paths;
774+ }
775+
776+ /**
777+ * Commits whose message matches `query` (case-insensitive, fixed string).
778+ * @returns {Promise<Array<{sha: string, shortSha: string, subject: string, authorName: string, date: string}>>}
779+ */
780+ async grepCommits(name, sha, query, limit = 10) {
781+ const q = String(query ?? '').trim();
782+ if (!q || q.startsWith('-')) return [];
783+ const out = await this.tryGit(name, [
784+ 'log', `--max-count=${limit}`, '-i', '--fixed-strings', `--grep=${q}`,
785+ `--format=%H${FS}%h${FS}%s${FS}%an${FS}%cI`, sha,
786+ ]);
787+ if (!out) return [];
788+ return out.split('\n').filter(Boolean).map((line) => {
789+ const [full, short, subject, authorName, date] = line.split(FS);
790+ return { sha: full, shortSha: short, subject, authorName, date };
791+ });
792+ }
646 } 793 }
647 794
648 /** 795 /**
modified src/lib/overview.mjs +52 −17
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/search.mjs +149 −41
Large diff (243 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/semaphore.mjs +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
modified src/server.mjs +36 −5
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
modified src/stats/activity.mjs +25 −7
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/assets/css/app.css +664 −703
Large diff (1,475 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/assets/css/tokens.css +274 −167
Large diff (494 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/assets/js/app.js +375 −411
Large diff (958 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/assets/js/theme-boot.js +11 −8
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/routes.mjs +108 −47
Large diff (341 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/blame.njk +15 −15
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/blob.njk +32 −32
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/branches.njk +18 −27
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/commit.njk +57 −27
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/commits.njk +19 −32
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/components/ui.njk +154 −0
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/error.njk +8 −7
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/home.njk +63 −76
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/layout.njk +100 −74
Large diff (251 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/metrics.njk +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/partials/clone-panel.njk +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/partials/file-table.njk +14 −13
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/partials/repo-card.njk +9 −12
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/partials/repo-header.njk +35 −49
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/partials/tree-sheet.njk +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/repo.njk +14 −27
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/search.njk +42 −18
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/tags.njk +13 −18
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/tree.njk +12 −17
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified test/e2e/roundtrip.test.mjs +104 −1
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added test/unit/routes-helpers.test.mjs +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added test/unit/semaphore.test.mjs +45 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme