diff --git a/api/cache.py b/api/cache.py index f31e836c..7dd90f3d 100644 --- a/api/cache.py +++ b/api/cache.py @@ -67,7 +67,20 @@ # ALWAYS_SKIP. Cached manifests that observed lockfiles (or that came # from include_all=true scans) would no longer match a fresh scan; # bumping forces every repo to re-cache and drops the orphans. -_MANIFEST_SCHEMA_VERSION = 2 +# v3: DirNode gained descendants_ext_breakdown and the manifest gained +# top-level busyness thresholds. Pre-v3 cached manifests lack both, so +# consumers (street view, commit-pane busyness label, scene tree color) +# would mis-render until a fresh scan; bumping forces the re-cache. +# v4: a latent bug in cache_load_git_history dropped each commit's authors +# + subject when reconstructing from the git-history cache. It never bit +# until the v3 bump invalidated the manifest blobs and forced a re-scan that +# rebuilds commits FROM that cache — which then cached author-less manifests +# (fireflies/header crash on commit.authors). The loader is fixed; this bump +# discards the polluted v3 blobs so a normal load re-scans correctly. +# v5: each CommitEntry gained same_day_total (commits sharing its date), +# baked at wrap time so the commit pane + scene tree-color read one field +# instead of recomputing the per-day grouping. Pre-v5 blobs lack it. +_MANIFEST_SCHEMA_VERSION = 5 # Composite cache version: invalidates when EITHER the manifest # schema OR the git-history cache shape changes. Stored as a string # in the cache file's `version` field; the loader's equality check @@ -235,11 +248,26 @@ def cache_load_git_history( date = c.get("date") files = c.get("files") sha = c.get("sha") + authors = c.get("authors") + subject = c.get("subject") + # Reconstruct the FULL CommitEntry — authors + subject are part of the + # shape (v9/v11) and manifest consumers (fireflies iterate authors, + # the commit pane shows subject) break without them. Drop any commit + # missing/malformed on any field rather than emit a partial entry. if (isinstance(date, str) and isinstance(files, int) and not isinstance(files, bool) and isinstance(sha, str) - and _SHA_HEX_RE.fullmatch(sha) is not None): - commits.append({"date": date, "files": files, "sha": sha}) + and _SHA_HEX_RE.fullmatch(sha) is not None + and isinstance(authors, list) + and all(isinstance(a, str) for a in authors) + and isinstance(subject, str)): + commits.append({ + "date": date, + "files": files, + "sha": sha, + "authors": authors, + "subject": subject, + }) return created, modified, commits diff --git a/api/scan.py b/api/scan.py index 52cbbab6..6c4adc16 100755 --- a/api/scan.py +++ b/api/scan.py @@ -34,8 +34,10 @@ ) from .media import probe_media_dims from .types import ( + BusynessThresholds, CommitEntry, DirNode, + ExtBreakdownEntry, FileEntry, FileNode, GitMeta, @@ -409,7 +411,10 @@ def _collect_git_metadata( tracked = _collect_tracked_set(root) _log(f" {len(tracked)} tracked entries (files + dirs)") - if use_cache and head_sha: + # Always write the cache (only `head_sha` is required to key it) — + # `use_cache` gates the READ above, not the write. A skip-cache scan + # still refreshes the cache so the next normal run is up to date. + if head_sha: try: cache_save_git_history(root, head_sha, created, modified, commits) except OSError: @@ -804,26 +809,27 @@ def _populate_file_metadata( raise _log(f" read {len(miss_paths)}/{len(miss_paths)} files") - if use_cache: - # Union-merge: start from the loaded cache (preserves entries - # for files not visited this scan, e.g. when .codecityignore flips) - # and overwrite with current values for everything we did visit. - for node in nodes: - entry: FileEntry = { - "size": node["size"], - "mtime": _node_mtime(node), - "lines": node["lines"], - "binary": node["binary"], - "ext": node["extension"], - } - if "media_width" in node and "media_height" in node: - entry["media_width"] = node["media_width"] - entry["media_height"] = node["media_height"] - cache_entries[node["path"]] = entry - try: - cache_save_files(abs_root, cache_entries) - except OSError: - pass + # Always write the file-stat cache — `use_cache` gates only the READ. + # On a warm scan cache_entries holds the loaded cache, so this is a + # union-merge (preserves entries for files not visited this scan, e.g. + # when .codecityignore flips); on a skip-cache scan it starts empty and + # records exactly the files this fresh scan visited. + for node in nodes: + entry: FileEntry = { + "size": node["size"], + "mtime": _node_mtime(node), + "lines": node["lines"], + "binary": node["binary"], + "ext": node["extension"], + } + if "media_width" in node and "media_height" in node: + entry["media_width"] = node["media_width"] + entry["media_height"] = node["media_height"] + cache_entries[node["path"]] = entry + try: + cache_save_files(abs_root, cache_entries) + except OSError: + pass def _iter_file_nodes(tree: DirNode) -> Iterator[FileNode]: @@ -901,6 +907,7 @@ class _DirFrame: "pending_entries", "files", "subdirs", "descendants_count", "descendants_file_count", "descendants_dir_count", "descendants_size", + "ext_breakdown", ) def __init__(self, abs_dir: str, rel_dir: str) -> None: @@ -921,6 +928,9 @@ def __init__(self, abs_dir: str, rel_dir: str) -> None: self.descendants_file_count = 0 self.descendants_dir_count = 0 self.descendants_size = 0 + # ext (lowercase, "(none)" if absent) -> [count, total_size], over + # all descendant files. Merged up from child frames as they pop. + self.ext_breakdown: dict[str, list[int]] = {} def _build_tree( @@ -971,6 +981,13 @@ def _build_tree( top.descendants_count += 1 top.descendants_file_count += 1 top.descendants_size += node["size"] + ext = (node["extension"] or "(none)").lower() + bucket = top.ext_breakdown.get(ext) + if bucket is None: + top.ext_breakdown[ext] = [1, node["size"]] + else: + bucket[0] += 1 + bucket[1] += node["size"] heartbeat.tick() elif entry.is_dir(follow_symlinks=False): # Descend by pushing a new frame; rollup happens when it @@ -982,6 +999,13 @@ def _build_tree( # (root) or attach to the parent. finished = stack.pop() children: list[FileNode | DirNode] = [*finished.files, *finished.subdirs] + # Sort by count desc, then ext asc for deterministic output. + ext_breakdown_out: list[ExtBreakdownEntry] = [ + {"ext": ext, "count": cnt, "size": size} + for ext, (cnt, size) in sorted( + finished.ext_breakdown.items(), key=lambda kv: (-kv[1][0], kv[0]) + ) + ] node_out: DirNode = { "name": finished.name, "type": NodeKind.DIRECTORY, @@ -994,6 +1018,7 @@ def _build_tree( "descendants_file_count": finished.descendants_file_count, "descendants_dir_count": finished.descendants_dir_count, "descendants_size": finished.descendants_size, + "descendants_ext_breakdown": ext_breakdown_out, "children": children, } if not stack: @@ -1004,6 +1029,14 @@ def _build_tree( parent.descendants_file_count += node_out["descendants_file_count"] parent.descendants_dir_count += 1 + node_out["descendants_dir_count"] parent.descendants_size += node_out["descendants_size"] + # Merge the child's per-extension breakdown up into the parent. + for ext, (cnt, size) in finished.ext_breakdown.items(): + bucket = parent.ext_breakdown.get(ext) + if bucket is None: + parent.ext_breakdown[ext] = [cnt, size] + else: + bucket[0] += cnt + bucket[1] += size # ── Public entry ───────────────────────────────────────────────────────────── @@ -1038,6 +1071,27 @@ def _walk(node: dict) -> None: return h.hexdigest() +def _compute_busyness(commits: list[CommitEntry]) -> BusynessThresholds: + """Repo-relative per-day commit-count thresholds. avg = median commits/day + (over days with >= 1 commit); busy = 75th percentile, clamped to avg+1 so + the three bands stay distinct. Both the scene tree-color gradient and the + commit pane's label read these, so a busy day looks consistent in both. + Returns {avg:1, busy:1} for an empty history so consumers needn't guard.""" + if not commits: + return {"avg": 1, "busy": 1} + per_day: dict[str, int] = {} + for c in commits: + per_day[c["date"]] = per_day.get(c["date"], 0) + 1 + counts = sorted(per_day.values()) + + def _quantile(p: float) -> int: + return counts[min(len(counts) - 1, int(len(counts) * p))] + + avg = _quantile(0.5) + busy = max(_quantile(0.75), avg + 1) + return {"avg": avg, "busy": busy} + + def _wrap_manifest( root_abs: str, tree: DirNode, sig: Any, tree_signature: str, repo_info: RepoInfo, commits: list[CommitEntry], @@ -1048,6 +1102,7 @@ def _wrap_manifest( has placeholder lines/binary set by _force_skeleton_placeholders; final has the real per-file metadata). The envelope shape is the same either way.""" + _annotate_same_day_totals(commits) return { "root": root_abs, "scanned_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), @@ -1056,9 +1111,22 @@ def _wrap_manifest( "tree": tree, "repo": repo_info, "commits": commits, + "busyness": _compute_busyness(commits), } +def _annotate_same_day_totals(commits: list[CommitEntry]) -> None: + """In-place: set each commit's same_day_total to the number of commits + sharing its calendar date. A derived aggregate (like busyness), so it's + baked at wrap time rather than during git collection — both the commit + pane's badge and the scene tree-color read this one field (#35).""" + per_day: dict[str, int] = {} + for c in commits: + per_day[c["date"]] = per_day.get(c["date"], 0) + 1 + for c in commits: + c["same_day_total"] = per_day[c["date"]] + + def _force_skeleton_placeholders(node: DirNode | FileNode) -> None: """In-place: set every FileNode under `node` to lines=1, binary=False so the skeleton renders with uniform-height buildings.""" diff --git a/api/server.py b/api/server.py index f01432c2..5c06343d 100644 --- a/api/server.py +++ b/api/server.py @@ -748,13 +748,15 @@ def _run_scan() -> None: try: _stream_events(handler, _events(), cancel_event) - # Only cache on successful completion AND only when use_cache. + # Always write the cache on a successful scan — `use_cache` only + # controls whether we READ from it. A skip-cache (no_cache) scan still + # persists its fresh result, so the next normal load is served the + # up-to-date manifest instead of a stale one. final_manifest = state["final_manifest"] scan_target = state["scan_target"] sig = state["sig"] if ( - use_cache - and final_manifest is not None + final_manifest is not None and scan_target is not None and sig is not None ): diff --git a/api/tests/test_cache.py b/api/tests/test_cache.py index bc88baf6..61a866ce 100644 --- a/api/tests/test_cache.py +++ b/api/tests/test_cache.py @@ -119,6 +119,37 @@ def test_hit_on_matching_head(self) -> None: result = cache_mod.cache_load_git_history(root, "abc123") self.assertEqual(result, (created, modified, [])) + def test_round_trips_full_commit_entries(self) -> None: + # The loader must reconstruct the WHOLE CommitEntry — authors + + # subject included. A previous bug dropped them, so commits loaded + # from a warm cache had no `authors`, crashing the fireflies + # consumer (`for author of c.authors`). Round-trip a populated commit. + root = Path("/some/repo") + commits = [ + { + "date": "2024-01-01", + "files": 3, + "sha": "a" * 40, + "authors": ["Alice", "Bob"], + "subject": "Initial commit", + }, + { + "date": "2024-01-02", + "files": 1, + "sha": "b" * 40, + "authors": [], + "subject": "Empty-authors edge case", + }, + ] + cache_mod.cache_save_git_history(root, "head1", {}, {}, commits) + result = cache_mod.cache_load_git_history(root, "head1") + assert result is not None + _, _, loaded = result + self.assertEqual(loaded, commits) + # Every loaded commit carries an iterable authors list. + for c in loaded: + self.assertIsInstance(c["authors"], list) + def test_miss_on_different_head(self) -> None: root = Path("/some/repo") cache_mod.cache_save_git_history(root, "abc123", {}, {}, []) @@ -178,8 +209,10 @@ def test_git_history_cache_round_trips_commits(self): """Round-trip a small commits list through the cache.""" root = Path("/some/repo") commits: list[CommitEntry] = [ - {"date": "2024-01-01", "files": 3, "sha": "a" * 40}, - {"date": "2024-02-15", "files": 7, "sha": "b" * 40}, + {"date": "2024-01-01", "files": 3, "sha": "a" * 40, + "authors": ["Alice"], "subject": "first"}, + {"date": "2024-02-15", "files": 7, "sha": "b" * 40, + "authors": ["Bob", "Carol"], "subject": "second"}, ] cache_mod.cache_save_git_history( root, head_sha="abc", @@ -208,26 +241,41 @@ def test_git_history_cache_drops_malformed_commits(self): "created": {}, "modified": {}, "commits": [ - {"date": "2024-01-01", "files": 3, "sha": sha_a}, # valid + {"date": "2024-01-01", "files": 3, "sha": sha_a, + "authors": ["Alice"], "subject": "ok"}, # valid "not a dict", # dropped: not a dict - {"date": 12345, "files": 5, "sha": sha_a}, # dropped: date not str - {"date": "2024-02-01", "sha": sha_a}, # dropped: missing files - {"date": "2024-03-01", "files": True, "sha": sha_a}, # dropped: files is bool - {"files": 4, "sha": sha_a}, # dropped: missing date - {"date": "2024-04-01", "files": 7}, # dropped: missing sha - {"date": "2024-05-01", "files": 2, "sha": "short"}, # dropped: sha too short - {"date": "2024-05-15", "files": 4, "sha": "Z" * 40}, # sha contains non-hex characters - {"date": "2024-06-01", "files": 1, "sha": sha_b}, # valid + {"date": 12345, "files": 5, "sha": sha_a, + "authors": [], "subject": "x"}, # dropped: date not str + {"date": "2024-02-01", "sha": sha_a, + "authors": [], "subject": "x"}, # dropped: missing files + {"date": "2024-03-01", "files": True, "sha": sha_a, + "authors": [], "subject": "x"}, # dropped: files is bool + {"files": 4, "sha": sha_a, + "authors": [], "subject": "x"}, # dropped: missing date + {"date": "2024-04-01", "files": 7, + "authors": [], "subject": "x"}, # dropped: missing sha + {"date": "2024-05-01", "files": 2, "sha": "short", + "authors": [], "subject": "x"}, # dropped: sha too short + {"date": "2024-05-15", "files": 4, "sha": "Z" * 40, + "authors": [], "subject": "x"}, # dropped: non-hex sha + {"date": "2024-07-01", "files": 1, "sha": sha_a, + "authors": "Alice", "subject": "x"}, # dropped: authors not a list + {"date": "2024-08-01", "files": 1, "sha": sha_a, + "authors": ["Alice"]}, # dropped: missing subject + {"date": "2024-06-01", "files": 1, "sha": sha_b, + "authors": ["Bob"], "subject": "ok2"}, # valid ], }), encoding="utf-8") loaded = cache_mod.cache_load_git_history(root, "abc") self.assertIsNotNone(loaded) assert loaded is not None _created, _modified, commits = loaded - # Only the two well-formed entries survive. + # Only the two well-formed entries survive (with authors + subject). self.assertEqual(commits, [ - {"date": "2024-01-01", "files": 3, "sha": sha_a}, - {"date": "2024-06-01", "files": 1, "sha": sha_b}, + {"date": "2024-01-01", "files": 3, "sha": sha_a, + "authors": ["Alice"], "subject": "ok"}, + {"date": "2024-06-01", "files": 1, "sha": sha_b, + "authors": ["Bob"], "subject": "ok2"}, ]) def test_git_history_rejects_old_version(self): diff --git a/api/tests/test_scan.py b/api/tests/test_scan.py index 4e131973..50bb81d5 100644 --- a/api/tests/test_scan.py +++ b/api/tests/test_scan.py @@ -15,6 +15,8 @@ import pytest from api.scan import ( + _annotate_same_day_totals, + _compute_busyness, _extension, _is_binary, compute_tree_signature, @@ -198,6 +200,51 @@ def test_mostly_control_chars_is_binary(self): self.assertTrue(_is_binary(p)) +class ComputeBusynessTests(unittest.TestCase): + @staticmethod + def _commits(*per_day: int) -> list: + # Build commits across distinct dates with the given per-day counts. + out = [] + for day, n in enumerate(per_day, start=1): + for _ in range(n): + out.append({"date": f"2026-01-{day:02d}", "files": 1, + "sha": "0" * 40, "authors": [], "subject": ""}) + return out + + def test_empty_history(self): + self.assertEqual(_compute_busyness([]), {"avg": 1, "busy": 1}) + + def test_percentile_bands(self): + # Per-day counts sorted: [1, 1, 2, 5] + # avg = q(0.50) = counts[floor(4*0.5)=2] = 2 + # busy = max(q(0.75)=counts[floor(4*0.75)=3]=5, avg+1=3) = 5 + self.assertEqual(_compute_busyness(self._commits(1, 1, 2, 5)), + {"avg": 2, "busy": 5}) + + def test_busy_clamped_to_avg_plus_one(self): + # Uniform 2-per-day → median 2, 75th pct 2 → busy clamps to 3. + self.assertEqual(_compute_busyness(self._commits(2, 2, 2, 2)), + {"avg": 2, "busy": 3}) + + +class AnnotateSameDayTotalsTests(unittest.TestCase): + def test_sets_per_day_group_size_on_each_commit(self): + # Three commits on 01-01, one on 01-02. + commits = [ + {"date": "2026-01-01", "files": 1, "sha": "0" * 40, "authors": [], "subject": ""}, + {"date": "2026-01-01", "files": 1, "sha": "1" * 40, "authors": [], "subject": ""}, + {"date": "2026-01-01", "files": 1, "sha": "2" * 40, "authors": [], "subject": ""}, + {"date": "2026-01-02", "files": 1, "sha": "3" * 40, "authors": [], "subject": ""}, + ] + _annotate_same_day_totals(commits) + self.assertEqual([c["same_day_total"] for c in commits], [3, 3, 3, 1]) + + def test_empty_history_is_a_noop(self): + commits: list = [] + _annotate_same_day_totals(commits) + self.assertEqual(commits, []) + + class ScanTreeIntegrationTests(_CacheRedirectMixin, unittest.TestCase): @classmethod def setUpClass(cls): @@ -223,6 +270,72 @@ def test_counts_roll_up_correctly(self): self.assertEqual(tree["descendants_count"], 15) self.assertGreater(tree["descendants_size"], 0) + def test_ext_breakdown_rolls_up(self): + m = _final_manifest(str(FIXTURE)) + tree = m["tree"] + breakdown = tree["descendants_ext_breakdown"] + self.assertIsInstance(breakdown, list) + for entry in breakdown: + self.assertEqual(set(entry.keys()), {"ext", "count", "size"}) + # Per-ext counts/sizes partition the descendant files exactly. + self.assertEqual( + sum(e["count"] for e in breakdown), tree["descendants_file_count"] + ) + self.assertEqual( + sum(e["size"] for e in breakdown), tree["descendants_size"] + ) + # Sorted by count descending. + counts = [e["count"] for e in breakdown] + self.assertEqual(counts, sorted(counts, reverse=True)) + # Extension keys are lowercase, dot-prefixed; the fixture has .md files. + self.assertIn(".md", {e["ext"] for e in breakdown}) + + def test_ext_breakdown_leaf_dir_only_counts_own_files(self): + # A nested directory's breakdown must cover only its own subtree, + # not the whole repo. + m = _final_manifest(str(FIXTURE)) + + def _find_dir_with_files(node): + if node["type"] != "directory": + return None + if node["children_file_count"] > 0 and node["path"] != ".": + return node + for child in node["children"]: + found = _find_dir_with_files(child) + if found: + return found + return None + + sub = _find_dir_with_files(m["tree"]) + if sub is not None: + self.assertEqual( + sum(e["count"] for e in sub["descendants_ext_breakdown"]), + sub["descendants_file_count"], + ) + + def test_busyness_present_in_manifest(self): + m = _final_manifest(str(FIXTURE)) + b = m["busyness"] + self.assertEqual(set(b.keys()), {"avg", "busy"}) + self.assertIsInstance(b["avg"], int) + self.assertIsInstance(b["busy"], int) + # Bands stay distinct: busy is always at least avg + 1. + self.assertGreaterEqual(b["busy"], b["avg"] + 1) + + def test_same_day_total_baked_on_every_commit(self): + m = _final_manifest(str(FIXTURE)) + commits = m["commits"] + self.assertGreater(len(commits), 0) + # Recompute the per-day grouping independently and assert each + # commit's baked same_day_total matches (>= 1, includes self). + per_day: dict[str, int] = {} + for c in commits: + per_day[c["date"]] = per_day.get(c["date"], 0) + 1 + for c in commits: + self.assertIn("same_day_total", c) + self.assertEqual(c["same_day_total"], per_day[c["date"]]) + self.assertGreaterEqual(c["same_day_total"], 1) + def test_signature_present_and_stable(self): m1 = _final_manifest(str(FIXTURE)) m2 = _final_manifest(str(FIXTURE)) diff --git a/api/tests/test_server_manifest.py b/api/tests/test_server_manifest.py index 6a6c6629..8e3fd283 100644 --- a/api/tests/test_server_manifest.py +++ b/api/tests/test_server_manifest.py @@ -612,12 +612,12 @@ def test_warm_cache_emits_one_final(self) -> None: self.assertEqual(len(manifest_events), 1) self.assertEqual(manifest_events[0]["phase"], "final") - def test_no_cache_skips_lookup_and_save(self) -> None: + def test_no_cache_skips_lookup_but_still_writes(self) -> None: with TemporaryDirectory() as td: self._make_tiny_repo(td) # Warm the cache first. self._http.request_stream(self.server_port, f"/api/manifest?src={td}") - # no_cache=true should NOT serve from cache. + # no_cache=true should NOT serve from cache (forces a fresh scan). _, events = self._http.request_stream( self.server_port, f"/api/manifest?src={td}&no_cache=true", ) @@ -625,9 +625,10 @@ def test_no_cache_skips_lookup_and_save(self) -> None: self.assertEqual( len(manifest_events), 2, "no_cache should force a fresh scan", ) - # Delete the cache file and verify no_cache also skipped - # the save — the cache should remain absent after this - # request. + # Clear the cache, then run another no_cache scan. `use_cache` + # gates only the READ — a fresh scan must STILL write its result, + # so the next normal load is served up-to-date data instead of a + # stale (or absent) manifest. import shutil shutil.rmtree(self.cache_root, ignore_errors=True) self.cache_root.mkdir(parents=True, exist_ok=True) @@ -635,11 +636,10 @@ def test_no_cache_skips_lookup_and_save(self) -> None: self.server_port, f"/api/manifest?src={td}&no_cache=true", ) manifests_dir = self.cache_root / "manifests" - if manifests_dir.exists(): - self.assertEqual( - list(manifests_dir.iterdir()), [], - "no_cache=true must not write to the manifest cache", - ) + self.assertTrue( + manifests_dir.exists() and list(manifests_dir.iterdir()), + "no_cache=true must still WRITE the manifest cache (read-only flag)", + ) def test_skeleton_has_placeholder_lines(self) -> None: import subprocess diff --git a/api/types.py b/api/types.py index 86e08acb..76f6afef 100644 --- a/api/types.py +++ b/api/types.py @@ -57,6 +57,17 @@ class FileNode(TypedDict): media_height: NotRequired[int] +class ExtBreakdownEntry(TypedDict): + """One file-extension bucket within a directory's descendant breakdown. + `ext` is the lowercase extension (e.g. ".ts") or "(none)" for files with + no extension. Computed once during the tree walk so the UI's street view + reads it instead of re-walking the subtree on every selection.""" + + ext: str + count: int + size: int + + class DirNode(TypedDict): """One directory in the manifest tree. children may be empty.""" @@ -72,6 +83,10 @@ class DirNode(TypedDict): descendants_file_count: int descendants_dir_count: int descendants_size: int + # Per-extension counts/sizes aggregated over ALL descendant files, + # sorted by count desc (ext asc tiebreak). Baked here so the street + # view reads it directly. Empty list for directories with no files. + descendants_ext_breakdown: list[ExtBreakdownEntry] TreeNode = FileNode | DirNode @@ -100,13 +115,30 @@ class CommitEntry(TypedDict): distinct authors for this commit — primary (git's %an) at index 0, Co-authored-by trailer names following. Emails stripped (privacy). subject is git %s — the first line of the commit message only; - body is fetched lazily via /api/commit.""" + body is fetched lazily via /api/commit. same_day_total is the number + of commits sharing this commit's calendar date (>= 1, includes self), + baked once at manifest-wrap so the commit pane's busyness badge and the + scene tree-color both read one consistent value instead of each + recomputing the per-day grouping.""" date: str # "YYYY-MM-DD" files: int sha: str authors: list[str] subject: str + same_day_total: int + + +class BusynessThresholds(TypedDict): + """Repo-relative per-day commit-count thresholds (commits/day), computed + once from the commit history. A day with >= busy commits reads as "Busy", + >= avg as "Average", else "Quiet". The scene tree-color gradient and the + commit pane's label both read these, so a busy day looks consistent in + both. `avg` is the median commits/day (over days with >= 1 commit); `busy` + is the 75th percentile, clamped to avg+1 so the bands stay distinct.""" + + avg: int + busy: int class Manifest(TypedDict): @@ -120,6 +152,7 @@ class Manifest(TypedDict): tree: DirNode repo: RepoInfo commits: list[CommitEntry] + busyness: BusynessThresholds display_root: NotRequired[str] diff --git a/app/index.html b/app/index.html index c9781b22..1071b648 100644 --- a/app/index.html +++ b/app/index.html @@ -10,26 +10,7 @@ codecity - -
-
-
-
-
-
-
-
- -
- -
- - - - - - +
+ diff --git a/app/package-lock.json b/app/package-lock.json index 298958c1..28dfaf00 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,14 +9,17 @@ "version": "1.0.0", "license": "AGPL-3.0", "dependencies": { + "@preact/signals": "^2.9.1", "highlight.js": "^11.11.1", + "lucide-preact": "^1.17.0", "marked": "^18.0.3", - "nanostores": "^1.3.0", + "preact": "^10.29.2", "rbush": "^4.0.1", "three": "^0.184.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@preact/preset-vite": "^2.10.5", "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", @@ -25,6 +28,7 @@ "eslint-config-prettier": "^10.1.8", "globals": "^17.6.0", "jsdom": "^29.0.2", + "material-icon-theme": "^5.30.0", "prettier": "^3.8.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", @@ -83,6 +87,199 @@ "dev": true, "license": "MIT" }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -103,6 +300,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", @@ -119,6 +340,92 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", @@ -528,6 +835,28 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -585,6 +914,131 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@preact/preset-vite": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz", + "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/@preact/signals": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.9.1.tgz", + "integrity": "sha512-xVqN8mJjbSN5IB/8Ubmd9NN+Ew6zJswoRxrjZbH3YsgkMshFeO6d8zxEFpHRTq9GJZx7cnPs2CnCpFqtGXGNsw==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact": ">= 10.25.0 || >=11.0.0-0" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.2.tgz", + "integrity": "sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", + "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.10.tgz", + "integrity": "sha512-7yPTFbG56sutaFu8krp3B4a200KOFUvrtlllKWRuLjsYXo9UUucHOZRcer+gtgMkFTpv6ob8TGcTwA32bSwa1w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", @@ -872,6 +1326,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1415,6 +1899,16 @@ "js-tokens": "^10.0.0" } }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1446,6 +1940,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1468,6 +1975,13 @@ "readable-stream": "^3.4.0" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1481,6 +1995,41 @@ "node": "18 || 20 || >=22" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1506,6 +2055,27 @@ "ieee754": "^1.1.13" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/canvas": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", @@ -1537,7 +2107,14 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "dev": true, - "license": "ISC" + "license": "ISC" + }, + "node_modules/chroma-js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", + "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==", + "dev": true, + "license": "(BSD-3-Clause AND Apache-2.0)" }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -1561,6 +2138,23 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -1575,6 +2169,19 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -1647,6 +2254,20 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-rename-keys": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/deep-rename-keys/-/deep-rename-keys-0.2.1.tgz", + "integrity": "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2", + "rename-keys": "^1.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1657,6 +2278,85 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1687,6 +2387,16 @@ "dev": true, "license": "MIT" }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1879,6 +2589,23 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2018,6 +2745,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -2061,6 +2798,16 @@ "node": ">=8" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -2145,6 +2892,13 @@ "dev": true, "license": "ISC" }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2270,6 +3024,19 @@ } } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2291,6 +3058,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2301,6 +3081,26 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2602,6 +3402,15 @@ "node": "20 || >=22" } }, + "node_modules/lucide-preact": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-preact/-/lucide-preact-1.17.0.tgz", + "integrity": "sha512-QoHqgMY9WJanT+zd8x5Hq9quOqY/MHt7oTpuPAZzCkfUhfxsTJ+L0CFnUaBBh+H54OySAElm2+UQ1JwQ2CDPmg==", + "license": "ISC", + "peerDependencies": { + "preact": "^10.27.2" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2652,6 +3461,25 @@ "node": ">= 20" } }, + "node_modules/material-icon-theme": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/material-icon-theme/-/material-icon-theme-5.30.0.tgz", + "integrity": "sha512-GAB9vZGEVBQWD7NfZpx6mzkV47IJ6MaSFRLTXIhk9eCKrHbT5BlCwkeieu1Db/hDJHrQ/n+RdMvGtZa70FCGCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chroma-js": "^3.1.2", + "events": "^3.3.0", + "fast-deep-equal": "^3.1.3", + "svgson": "^5.3.1" + }, + "engines": { + "vscode": "^1.55.0" + }, + "funding": { + "url": "https://github.com/sponsors/material-extensions" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -2738,21 +3566,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/nanostores": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.3.0.tgz", - "integrity": "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": "^20.0.0 || >=22.0.0" - } - }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -2787,6 +3600,40 @@ "dev": true, "license": "MIT" }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2911,7 +3758,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2948,6 +3794,17 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -3099,6 +3956,16 @@ "node": ">= 6" } }, + "node_modules/rename-keys": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz", + "integrity": "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3220,6 +4087,16 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -3267,6 +4144,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3277,6 +4164,16 @@ "node": ">=0.10.0" } }, + "node_modules/stack-trace": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0.tgz", + "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3324,6 +4221,17 @@ "node": ">=8" } }, + "node_modules/svgson": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/svgson/-/svgson-5.3.1.tgz", + "integrity": "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-rename-keys": "^0.2.1", + "xml-reader": "2.4.3" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3530,6 +4438,37 @@ "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3626,6 +4565,24 @@ } } }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", + "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, "node_modules/vitest": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", @@ -3815,6 +4772,16 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-lexer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/xml-lexer/-/xml-lexer-0.2.2.tgz", + "integrity": "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^2.0.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -3825,6 +4792,17 @@ "node": ">=18" } }, + "node_modules/xml-reader": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz", + "integrity": "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^2.0.0", + "xml-lexer": "^0.2.2" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -3832,6 +4810,13 @@ "dev": true, "license": "MIT" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3844,6 +4829,13 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" } } } diff --git a/app/package.json b/app/package.json index edf8a6d6..6d820ca9 100644 --- a/app/package.json +++ b/app/package.json @@ -28,6 +28,7 @@ "homepage": "https://github.com/thalida/codecity#readme", "devDependencies": { "@eslint/js": "^10.0.1", + "@preact/preset-vite": "^2.10.5", "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", @@ -36,6 +37,7 @@ "eslint-config-prettier": "^10.1.8", "globals": "^17.6.0", "jsdom": "^29.0.2", + "material-icon-theme": "^5.30.0", "prettier": "^3.8.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", @@ -43,9 +45,11 @@ "vitest": "^4.1.4" }, "dependencies": { + "@preact/signals": "^2.9.1", "highlight.js": "^11.11.1", + "lucide-preact": "^1.17.0", "marked": "^18.0.3", - "nanostores": "^1.3.0", + "preact": "^10.29.2", "rbush": "^4.0.1", "three": "^0.184.0" } diff --git a/app/public/gem-simple.svg b/app/public/gem-simple.svg deleted file mode 100644 index 7854a8ea..00000000 --- a/app/public/gem-simple.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/app/scripts/gen-material-icons.mjs b/app/scripts/gen-material-icons.mjs new file mode 100644 index 00000000..fdd395f3 --- /dev/null +++ b/app/scripts/gen-material-icons.mjs @@ -0,0 +1,52 @@ +// Regenerate src/constants/materialIcons.ts — the name→URL map of the Material +// Icon Theme glyphs that utils/fileIcons.ts can resolve to, imported as ?url +// assets from the pinned material-icon-theme package (Vite bundles each into +// the build, self-hosted, no runtime CDN). Run from app/ after editing the +// lookup maps in src/constants/fileIcons.ts: +// +// node scripts/gen-material-icons.mjs +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; + +const src = readFileSync('src/constants/fileIcons.ts', 'utf8'); +// Icon basenames are the quoted RHS of every map entry (`: 'typescript'`) AND +// every generic-fallback const (`GENERIC_FOLDER = 'folder'`). Match both `:` +// and `=` — missing the generics ships folders that fall back to the generic +// icon with no bundled URL (blank glyph). +const names = [...new Set([...src.matchAll(/[:=] ?'([a-z0-9_-]+)'/g)].map((m) => m[1]))].sort(); + +const pkgDir = 'node_modules/material-icon-theme/icons'; +const missing = names.filter((n) => !existsSync(`${pkgDir}/${n}.svg`)); +if (missing.length) { + console.error('Icons not present in material-icon-theme:', missing); + process.exit(1); +} + +const camel = (s) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); +const imports = names + .map((n) => `import ${camel(n)} from 'material-icon-theme/icons/${n}.svg?url';`) + .join('\n'); +// Property shorthand when the basename is already a valid identifier equal to +// the import var (e.g. `audio`); quoted "key: var" for hyphenated names +// (e.g. `'folder-src': folderSrc`). Keeps the output eslint-clean +// (object-shorthand) so re-running the generator is idempotent. +const entries = names + .map((n) => (/^[a-z][a-zA-Z0-9_]*$/.test(n) ? ` ${n},` : ` '${n}': ${camel(n)},`)) + .join('\n'); + +const out = `// constants/materialIcons.ts — GENERATED by scripts/gen-material-icons.mjs. +// Material Icon Theme file/folder glyphs the resolvers (utils/fileIcons.ts) +// can produce, imported as URL assets from the pinned material-icon-theme +// package. Vite bundles each into the build and resolves the import to its +// served URL — self-hosted (no runtime CDN), and a name with no file in the +// package fails at BUILD. The icons-exist test keeps this in sync with the +// lookup maps. Regenerate after editing constants/fileIcons.ts. + +${imports} + +/** Material icon basename -> served asset URL (resolved by Vite at build). */ +export const MATERIAL_ICON_URLS: Record = { +${entries} +}; +`; +writeFileSync('src/constants/materialIcons.ts', out); +console.log(`Wrote src/constants/materialIcons.ts (${names.length} icons).`); diff --git a/app/src/api/config.ts b/app/src/api/config.ts index 9e808e5d..6c1fdca7 100644 --- a/app/src/api/config.ts +++ b/app/src/api/config.ts @@ -1,6 +1,6 @@ // api/config.ts — One-shot fetch of /api/config, memoized. // -// Read once at boot in main.ts and passed into UI components that need +// Read once at boot in useManifestSource and passed into UI components that need // to know server-side feature flags (currently: whether local-repo // sources are permitted). Any fetch / parse failure fails closed — // we prefer to render the "local is disabled" UI than to expose a diff --git a/app/src/api/file.ts b/app/src/api/file.ts new file mode 100644 index 00000000..fefda67a --- /dev/null +++ b/app/src/api/file.ts @@ -0,0 +1,16 @@ +// api/file.ts — endpoint helpers for /api/file (raw file content reads). + +/** URL for the file-content endpoint, scoped to a single absolute path. */ +export function fileUrl(path: string): string { + return `/api/file?path=${encodeURIComponent(path)}`; +} + +/** + * Fetch the raw text body for a file. Throws on non-2xx. Used by infoPane + * (README rendering) and filePreviewPane (syntax-highlighted preview). + */ +export async function fetchFileText(path: string): Promise { + const resp = await fetch(fileUrl(path)); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.text(); +} diff --git a/app/src/api/index.ts b/app/src/api/index.ts deleted file mode 100644 index a9209055..00000000 --- a/app/src/api/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -// api/index.ts — Shared API helpers. Endpoint-specific code (URL builders, -// fetchers, streaming) lives in sibling files (api/manifest.ts, -// api/commit.ts, api/config.ts). - -export interface BuildApiUrlOpts { - noCache?: boolean; -} - -/** - * Build the URL for a server endpoint, forwarding the page's `src` - * (and optional `branch`) params. When `opts.noCache` is true, - * appends `no_cache=true` to force a fresh scan on this request. - * When no `src` is present, returns the endpoint URL without any - * source params — boot uses this to detect "no source picked yet". - * - * Pure function (no `window` access) so endpoint-specific wrappers - * in sibling modules can bind it to live `window.location.*` values - * while this helper stays directly unit-testable. - */ -export function buildApiUrl( - endpoint: string, - pageSearch: string, - origin: string, - opts: BuildApiUrlOpts = {} -): string { - const qp = new URLSearchParams(pageSearch); - const u = new URL(endpoint, origin); - if (qp.has('src')) { - u.searchParams.set('src', qp.get('src')!); - if (qp.has('branch')) u.searchParams.set('branch', qp.get('branch')!); - } - if (opts.noCache) { - u.searchParams.set('no_cache', 'true'); - } - return u.toString(); -} diff --git a/app/src/api/manifest.ts b/app/src/api/manifest.ts index 3299442e..d48d2cd2 100644 --- a/app/src/api/manifest.ts +++ b/app/src/api/manifest.ts @@ -17,7 +17,42 @@ // error — fatal mid-stream failure; client should surface and stop. import type { Manifest } from '@/types/manifest'; -import { buildApiUrl, type BuildApiUrlOpts } from './'; +import { URL_PARAMS } from '@/constants/urlParams'; +// ── URL helper ─────────────────────────────────────────────────────────── + +export interface BuildApiUrlOpts { + noCache?: boolean; +} + +/** + * Build the URL for a server endpoint, forwarding the page's `src` + * (and optional `branch`) params. When `opts.noCache` is true, + * appends `no_cache=true` to force a fresh scan on this request. + * When no `src` is present, returns the endpoint URL without any + * source params — boot uses this to detect "no source picked yet". + * + * Pure function (no `window` access) so the endpoint wrappers below can + * bind it to live `window.location.*` values while this helper stays + * directly unit-testable. + */ +export function buildApiUrl( + endpoint: string, + pageSearch: string, + origin: string, + opts: BuildApiUrlOpts = {} +): string { + const qp = new URLSearchParams(pageSearch); + const u = new URL(endpoint, origin); + if (qp.has(URL_PARAMS.SRC)) { + u.searchParams.set(URL_PARAMS.SRC, qp.get(URL_PARAMS.SRC)!); + if (qp.has(URL_PARAMS.BRANCH)) + u.searchParams.set(URL_PARAMS.BRANCH, qp.get(URL_PARAMS.BRANCH)!); + } + if (opts.noCache) { + u.searchParams.set(URL_PARAMS.NO_CACHE, 'true'); + } + return u.toString(); +} // ── Endpoint URL builders ──────────────────────────────────────────────── @@ -32,21 +67,55 @@ export function signatureUrl(): string { return buildApiUrl('/api/manifest/signature', window.location.search, window.location.origin); } +/** URL for the manifest stream of an EXPLICIT source — used when loading or + * switching to a source whose params aren't on the page URL yet (the picker + * submit path). `manifestUrl()` reads the page URL; this takes the source + * directly. */ +export function manifestUrlFor(opts: { src: string; branch?: string; noCache?: boolean }): string { + // Delegate param assembly to buildApiUrl (single source of truth for the + // src/branch/no_cache query contract); just feed it an explicit search + // string instead of the page's location.search. + const search = new URLSearchParams({ [URL_PARAMS.SRC]: opts.src }); + if (opts.branch) search.set(URL_PARAMS.BRANCH, opts.branch); + return buildApiUrl('/api/manifest', search.toString(), window.location.origin, { + noCache: opts.noCache, + }); +} + // ── NDJSON streaming reader ────────────────────────────────────────────── +// The phases the NDJSON scan stream advances through. String values are the +// wire form the server emits. (Distinct from constants/loadingSteps' LoadingStep, +// which is the UI step vocabulary — there's no 'building' phase: the Final event +// drives the "Building city" step.) +export enum ScanPhase { + Cloning = 'cloning', + Scanning = 'scanning', + Skeleton = 'skeleton', + Final = 'final', + Error = 'error', +} + // One variant per discriminant value so TS narrows cleanly through -// `if (event.phase === 'cloning' || event.phase === 'scanning')` etc. +// `if (event.phase === ScanPhase.Cloning || event.phase === ScanPhase.Scanning)` etc. export type ScanStreamEvent = | { - phase: 'cloning'; + phase: ScanPhase.Cloning; display_root?: string; stage?: 'receiving' | 'resolving' | 'counting'; percent?: number; } - | { phase: 'scanning'; display_root?: string; files_scanned?: number } - | { phase: 'skeleton'; manifest: Manifest } - | { phase: 'final'; manifest: Manifest } - | { phase: 'error'; error: string }; + | { phase: ScanPhase.Scanning; display_root?: string; files_scanned?: number } + | { phase: ScanPhase.Skeleton; manifest: Manifest } + | { phase: ScanPhase.Final; manifest: Manifest } + | { phase: ScanPhase.Error; error: string }; + +/** The non-terminal progress events that carry loading-step detail (clone + * percent, files scanned). The shared progress helper consumes these. */ +export type ScanProgressEvent = Extract< + ScanStreamEvent, + { phase: ScanPhase.Cloning | ScanPhase.Scanning } +>; export async function* streamManifest( url: string, @@ -80,3 +149,14 @@ export async function* streamManifest( } if (buf.trim()) yield JSON.parse(buf) as ScanStreamEvent; } + +/** + * Clear the server-side scan cache for one (src, branch) pair. Best-effort — + * failures are swallowed (cache-clear is a UX nicety, not a correctness path). + */ +export function clearManifestCache(src: string, branch?: string): void { + const url = new URL('/api/manifest/cache', window.location.origin); + url.searchParams.set(URL_PARAMS.SRC, src); + if (branch) url.searchParams.set(URL_PARAMS.BRANCH, branch); + fetch(url.toString(), { method: 'DELETE' }).catch(() => {}); +} diff --git a/app/src/components/Badge.tsx b/app/src/components/Badge.tsx new file mode 100644 index 00000000..856ca768 --- /dev/null +++ b/app/src/components/Badge.tsx @@ -0,0 +1,81 @@ +// components/Badge.tsx — Shared builder for the small pill that shows +// "what kind of thing am I looking at": a file extension (color-coded +// from the same hue palette the city uses) or a generic "dir" badge +// (painted with the asphalt color). Used by both the floating header +// and the status-bar footer so the two stay visually in sync with +// the city's current theme — when a user changes the asphalt color +// or a hue in Controls, badges repaint to match. +// +// Text color is auto-contrasted against the badge background using the +// WCAG relative-luminance formula: dark text on bright backgrounds, +// light text on dark backgrounds. That keeps the label readable no +// matter what colors the user picks in Controls. + +import { getHue } from '@/scene/components/buildings/buildingColor'; +import { parseHex, hslToRgb, pickContrastingText } from '@/utils/colors'; + +// Badge color palette defaults. The file badge's CSS rule paints the background +// with `hsl(var(--badge-hue), 60%, 35%)` — the saturation/lightness defaults +// mirror those values so the JS-side luminance check matches what the user sees. +const DEFAULT_TEXT_DARK = '#0a0b10'; +const DEFAULT_TEXT_LIGHT = '#f4f6ff'; +const DEFAULT_FILE_BADGE_SATURATION = 0.6; +const DEFAULT_FILE_BADGE_LIGHTNESS = 0.35; + +// ── Props interface ───────────────────────────────────────────────────────── + +export interface ExtensionBadgeProps { + extension: string | null | undefined; + isDir: boolean; + huePalette: Record; + asphaltColor: string; + /** Label color used on bright backgrounds. */ + textDark?: string; + /** Label color used on dark backgrounds. */ + textLight?: string; + /** Saturation (0–1) for the file badge's hue → RGB luminance check. */ + fileBadgeSaturation?: number; + /** Lightness (0–1) for the file badge's hue → RGB luminance check. */ + fileBadgeLightness?: number; +} + +// ── Preact component ──────────────────────────────────────────────────────── + +export function ExtensionBadge({ + extension, + isDir, + huePalette, + asphaltColor, + textDark = DEFAULT_TEXT_DARK, + textLight = DEFAULT_TEXT_LIGHT, + fileBadgeSaturation = DEFAULT_FILE_BADGE_SATURATION, + fileBadgeLightness = DEFAULT_FILE_BADGE_LIGHTNESS, +}: ExtensionBadgeProps) { + const contrastingText = (rgb: [number, number, number] | null): string => + pickContrastingText(rgb, textDark, textLight); + + if (isDir) { + return ( + + dir + + ); + } + const label = (extension || '').replace(/^\./, '').slice(0, 4) || 'file'; + const hue = getHue(extension ?? '', huePalette); + const color = contrastingText(hslToRgb(hue, fileBadgeSaturation, fileBadgeLightness)); + return ( + } + > + {label} + + ); +} diff --git a/app/src/components/ColorInput.tsx b/app/src/components/ColorInput.tsx new file mode 100644 index 00000000..6a61898f --- /dev/null +++ b/app/src/components/ColorInput.tsx @@ -0,0 +1,21 @@ +// components/ColorInput.tsx — Thin wrapper. +// HTML's color input only accepts `#rrggbb`; the app's colors are always +// hex, so we normalize through a pure helper (no DOM probe). + +import { normalizeHex } from '@/utils/colors'; + +export interface ColorInputProps { + value: string; + onCommit: (hex: string) => void; +} + +export function ColorInput({ value, onCommit }: ColorInputProps) { + return ( + onCommit((e.currentTarget as HTMLInputElement).value)} + /> + ); +} diff --git a/app/src/components/CommitChip.tsx b/app/src/components/CommitChip.tsx new file mode 100644 index 00000000..6e8df9fe --- /dev/null +++ b/app/src/components/CommitChip.tsx @@ -0,0 +1,42 @@ +// components/CommitChip.tsx — Header title slot for a selected commit: +// a focus button + "Commit · (+N)" + copy-SHA button. + +import { Focus } from 'lucide-preact'; +import { CopyButton } from '@/components/CopyButton'; +import { KEY_BINDINGS } from '@/constants/keyboard'; + +export interface CommitChipProps { + sha: string; + authors: string[]; + onFocus?: () => void; +} + +export function CommitChip({ sha, authors, onFocus }: CommitChipProps) { + // Guard against commits with missing authors (e.g. a manifest from a cache + // that dropped the field) so the header degrades instead of crashing. + const list = authors ?? []; + const primary = list[0] || '(unknown)'; + const coAuthorCount = Math.max(0, list.length - 1); + const authorText = coAuthorCount > 0 ? ` · ${primary} (+${coAuthorCount})` : ` · ${primary}`; + const focusTitle = `Focus camera on commit (${KEY_BINDINGS.FOCUS_SELECTION.label})`; + + return ( + <> + {onFocus && ( + + )} + {'Commit '} + {sha.slice(0, 7)} + {authorText} + + + ); +} diff --git a/app/src/components/CopyButton.tsx b/app/src/components/CopyButton.tsx new file mode 100644 index 00000000..fb3d0ede --- /dev/null +++ b/app/src/components/CopyButton.tsx @@ -0,0 +1,61 @@ +// components/CopyButton.tsx — Small icon button that copies `text` to +// the clipboard and flashes a brief "Copied!" state on itself (a CSS-side +// .is-copied modifier toggled from component state). Uses the async +// Clipboard API; secure contexts always provide it, so there's no legacy +// execCommand fallback. + +import { useState, useRef, useEffect } from 'preact/hooks'; +import { Copy } from 'lucide-preact'; + +// How long the "Copied!" badge lingers after the copy button is clicked. +const DEFAULT_COPY_FEEDBACK_DURATION_MS = 1500; + +export interface CopyButtonProps { + text: string; + label?: string; + /** How long the "Copied!" state lingers after a click, in ms. */ + feedbackDurationMs?: number; +} + +export function CopyButton({ + text, + label = 'Copy path', + feedbackDurationMs = DEFAULT_COPY_FEEDBACK_DURATION_MS, +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + const timer = useRef | null>(null); + + // Clear a pending reset timer on unmount so it can't fire after teardown. + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [] + ); + + const flash = () => { + setCopied(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), feedbackDurationMs); + }; + + const onClick = () => { + // Optional-chain so the call is a no-op if the API is somehow absent + // (non-secure context) rather than throwing. + void navigator.clipboard?.writeText(text).then(flash, () => { + /* clipboard write denied — leave the button un-flashed */ + }); + }; + + return ( + + ); +} diff --git a/app/src/components/Field.tsx b/app/src/components/Field.tsx new file mode 100644 index 00000000..d39f6eb4 --- /dev/null +++ b/app/src/components/Field.tsx @@ -0,0 +1,114 @@ +// components/Field.tsx — One generic settings row, driven entirely +// by the field's schema definition (looked up by store + key). Dispatches on +// `kind` to the right primitive and wraps it in a Row (label + tip + reset). +// +// Replaces the per-kind Fields.tsx wrappers (ColorField/NumberField/ +// SliderField/ToggleField/SelectField/RangePairField) — the metadata they +// duplicated in JSX now lives once in the store schema. + +import { FieldKind, getFieldDef } from '@/state/settingsSchema'; +import { useField } from '@/hooks/useSettings'; +import { Row } from './Row'; +import { TierWidthsField } from './TierWidthsField'; +import { HueMapField } from './HueMapField'; +import { ColorInput } from '@/components/ColorInput'; +import { NumberInput } from '@/components/NumberInput'; +import { Slider } from '@/components/Slider'; +import { Toggle } from '@/components/Toggle'; +import { SegmentedSelect } from '@/components/SegmentedSelect'; +import { RangePair } from '@/components/RangePair'; + +interface SignalLike { + get value(): unknown; + set value(v: unknown); +} + +export interface FieldProps { + store: SignalLike; + fieldKey: string; +} + +export function Field({ store, fieldKey }: FieldProps) { + const def = getFieldDef(store as object, fieldKey); + // useField must run unconditionally (hook rules); the early-return guard + // below only fires for a misconfigured schema, which a completeness test + // catches — so in practice the hook always runs with a real field. + const binding = useField(store, fieldKey); + if (!def) { + // Misconfigured schema (a (store, key) with no field def). The + // completeness test catches this; log + render nothing as a safety net. + console.error(`[controls] has no schema for key "${fieldKey}"`); + return null; + } + + // TierWidths / HueMap are array/object-valued fields that expand to one row + // per entry, so they render their own rows rather than a single Row-wrapped + // control. + if (def.kind === FieldKind.TierWidths) { + return ; + } + if (def.kind === FieldKind.HueMap) { + return ; + } + + let control; + switch (def.kind) { + case FieldKind.Color: + control = ; + break; + case FieldKind.Number: + control = ( + + ); + break; + case FieldKind.Slider: + control = ( + + ); + break; + case FieldKind.Toggle: + control = ; + break; + case FieldKind.Select: + control = ( + + ); + break; + case FieldKind.RangePair: { + const [lo, hi] = binding.value as [number, number]; + control = ( + binding.onCommit([l, h] as never)} + /> + ); + break; + } + } + + return ( + + {control} + + ); +} diff --git a/app/src/components/GemIcon.tsx b/app/src/components/GemIcon.tsx new file mode 100644 index 00000000..b5f7c399 --- /dev/null +++ b/app/src/components/GemIcon.tsx @@ -0,0 +1,40 @@ +// components/GemIcon.tsx — The codecity gem icon, rendered inline as JSX +// (same pattern as HostingIcon / lucide icons) so the per-face palette fills +// render directly. Default is full multicolor (same geometry as /gem.svg, the +// favicon). The `simple` prop swaps to a grayscale-filled variant for quieter +// contexts (tree rows) where the multicolor palette would compete with row text. + +export interface GemIconProps { + /** Extra class added alongside `gem-icon`. */ + class?: string; + /** Tooltip shown on hover (sets the title attr). */ + title?: string; + /** Render the grayscale-filled variant for quieter contexts (e.g. tree + * rows). Same octahedron geometry, just neutral fills instead of + * palette colors. */ + simple?: boolean; +} + +export function GemIcon({ class: cls, title, simple }: GemIconProps) { + const className = `gem-icon${cls ? ` ${cls}` : ''}`; + if (simple) { + return ( + + ); + } + return ( + + ); +} diff --git a/app/src/components/HljsThemeLink.tsx b/app/src/components/HljsThemeLink.tsx new file mode 100644 index 00000000..f9c4516f --- /dev/null +++ b/app/src/components/HljsThemeLink.tsx @@ -0,0 +1,15 @@ +// components/HljsThemeLink.tsx — Signals-native replacement for the +// old imperative applyHljsTheme(). Renders a into +// document.head via Preact's createPortal so the syntax-highlighting CSS +// follows the SYNTAX_THEME signal automatically — no module-load effect, +// no manual element management. + +import { createPortal } from 'preact/compat'; +import { SYNTAX_THEME } from '@/state/stores/settings/syntaxTheme'; + +const HLJS_VERSION = '11.11.1'; + +export function HljsThemeLink() { + const href = `https://cdn.jsdelivr.net/npm/highlight.js@${HLJS_VERSION}/styles/${SYNTAX_THEME.value}.min.css`; + return createPortal(, document.head); +} diff --git a/app/src/components/HostingIcon.tsx b/app/src/components/HostingIcon.tsx new file mode 100644 index 00000000..3434676e --- /dev/null +++ b/app/src/components/HostingIcon.tsx @@ -0,0 +1,44 @@ +// components/HostingIcon.tsx — Brand glyph for a git-hosting provider +// (GitHub, GitLab, Bitbucket) with a generic globe fallback, picked from a +// source URL and rendered inline as JSX (no innerHTML). The brand glyphs are +// inlined because lucide dropped brand icons; the generic fallback is lucide's +// . + +import { Globe } from 'lucide-preact'; + +export interface HostingIconProps { + /** Source URL — provider is sniffed from the host. */ + src: string; +} + +function GithubGlyph() { + return ( + + ); +} + +function GitlabGlyph() { + return ( + + ); +} + +function BitbucketGlyph() { + return ( + + ); +} + +export function HostingIcon({ src }: HostingIconProps) { + const lower = src.toLowerCase(); + if (/github\.com/.test(lower)) return ; + if (/gitlab\.com/.test(lower) || /\.gitlab\.io/.test(lower)) return ; + if (/bitbucket\.org/.test(lower)) return ; + return