diff --git a/README.md b/README.md index ca9370f7..d22f0eb3 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,11 @@ tests/fixtures/large-repo ## Local directories -To render a local git repo, mount it into the container at the same absolute path: +Local-repo support is **disabled by default**. To enable it, set `CODECITY_ALLOW_LOCAL_REPOS=1` *and* mount the directory read-only into the container at the same absolute path: ```sh docker run --rm --init --pull=always \ + -e CODECITY_ALLOW_LOCAL_REPOS=1 \ -v "$HOME/Documents/Repos:$HOME/Documents/Repos:ro" \ -v codecity-cache:/cache \ -p 8080:8080 \ @@ -107,6 +108,8 @@ docker run --rm --init --pull=always \ Use multiple `-v` flags for multiple directories. codecity only renders git working trees — `git init` first if you want to render a non-git directory. +`just dev ` and `just run ` set the env var for you when a mount path is passed, so you don't have to type both flags. + ## Controls Click the gear in the left sidebar to open the Controls pane. Tweaks stage as drafts; click Save to apply. diff --git a/TODO.md b/TODO.md index 8b2799a9..cb6afbe9 100644 --- a/TODO.md +++ b/TODO.md @@ -43,8 +43,14 @@ - [x] Update Readme - [x] on focus / select look straight down at the building - [ ] add meta data about the world -- [ ] add enable local env variable +- [x] add enable local env variable - [x] fix start position to include tallest building +- [x] support empty git repos (unborn HEAD → empty world, not a cryptic git error) +- [x] no-project-loaded UX: lazy-render header chip, collapse sidebar, explorer + info empty-state cards +- [x] camera framing accounts for the floating repo-label panel (especially on empty worlds) +- [x] forge-aware repo link: external-link button deep-links to the active branch on github / gitlab / bitbucket / codeberg / forgejo / gitea / sr.ht +- [x] drop the history-window picker option + the whole `git_window` / `CODECITY_GIT_WINDOW` plumbing +- [ ] mount-path detection / autocomplete — let the server expose which `-v` paths are mounted so the Local pane can autocomplete + validate. `CODECITY_LOCAL_PATHS` env (set by `just dev/run` alongside `-v`) with `/proc/self/mounts` fallback for raw `docker run` users. Sketched in the conditional-local-repos spec's Follow-up section. ## Agent Prompts ToDos diff --git a/api/cache.py b/api/cache.py index c1633864..cbcc97f1 100644 --- a/api/cache.py +++ b/api/cache.py @@ -52,7 +52,9 @@ # report their actual file count. Pre-v8 entries undercount merges. # Bumped to 9: CommitEntry gained author + subject fields. Pre-v9 # entries are missing those fields and would break manifest consumers. -_GIT_HISTORY_CACHE_VERSION = 9 +# Bumped to 10: dropped the per-entry `git_window` field — the scanner +# no longer accepts a --since window and always walks full history. +_GIT_HISTORY_CACHE_VERSION = 10 # Bumped only when the manifest shape changes for reasons UNRELATED # to git-history output (e.g. a new field on FileNode). Git-history # shape changes don't need a bump here — they auto-invalidate through @@ -192,9 +194,9 @@ def _git_history_cache_path(abs_root: Path) -> Path: def cache_load_git_history( - abs_root: Path, head_sha: str, git_window: str, + abs_root: Path, head_sha: str, ) -> tuple[dict[str, str], dict[str, str], list["CommitEntry"]] | None: - """Load git-history maps + commits if cached for this root, HEAD, AND window. + """Load git-history maps + commits if cached for this root + HEAD. Returns None on miss or any error.""" path = _git_history_cache_path(abs_root) @@ -208,8 +210,6 @@ def cache_load_git_history( return None if raw.get("head_sha") != head_sha: return None - if raw.get("git_window") != git_window: - return None created_raw = raw.get("created") modified_raw = raw.get("modified") commits_raw = raw.get("commits") @@ -243,17 +243,15 @@ def cache_load_git_history( def cache_save_git_history( abs_root: Path, head_sha: str, - git_window: str, created: dict[str, str], modified: dict[str, str], commits: list["CommitEntry"], ) -> None: - """Atomically write the git-history cache for this root + HEAD + window.""" + """Atomically write the git-history cache for this root + HEAD.""" payload = { "version": _GIT_HISTORY_CACHE_VERSION, "root": str(abs_root), "head_sha": head_sha, - "git_window": git_window, "created": created, "modified": modified, "commits": commits, diff --git a/api/clone.py b/api/clone.py index c1f7c712..b099bdf5 100644 --- a/api/clone.py +++ b/api/clone.py @@ -27,6 +27,7 @@ from pathlib import Path from typing import Callable +from api.env import env_bool from api.types import ( BranchNotFoundError, CloneError, @@ -45,7 +46,7 @@ def _log(msg: str) -> None: - if os.environ.get("CODECITY_QUIET") != "1": + if not env_bool("CODECITY_QUIET"): print(f"[clone] {msg}", file=sys.stderr, flush=True) @@ -371,11 +372,25 @@ def clone_dir_for(url: str, branch: str | None) -> Path: return CACHE_ROOT / digest -def _resolve_default_branch(repo: Path) -> str: - """Return the default branch name on origin (e.g. 'main').""" - out = _run_git("symbolic-ref", "refs/remotes/origin/HEAD", cwd=repo).strip() +def _resolve_default_branch(repo: Path) -> str | None: + """Return the default branch name on origin (e.g. 'main'), or None + when the remote has no commits yet (an unborn HEAD). + + A brand-new empty github.com// repo clones successfully + but has no symbolic refs/remotes/origin/HEAD — `git symbolic-ref` + exits non-zero. Treat that as "nothing to check out" rather than an + error: the caller skips the post-clone reset and the working tree + stays empty, which the scanner happily walks into an empty manifest + so the frontend renders an empty world. + """ + try: + out = _run_git("symbolic-ref", "refs/remotes/origin/HEAD", cwd=repo).strip() + except CloneError as e: + if "is not a symbolic ref" in str(e): + return None + raise # e.g. "refs/remotes/origin/main" → "main" - return out.rsplit("/", 1)[-1] if out else "HEAD" + return out.rsplit("/", 1)[-1] if out else None def ensure_clone( @@ -407,9 +422,16 @@ def ensure_clone( cwd=target, progress_dir=pack_dir, on_progress=on_progress, ) - ref = f"origin/{branch}" if branch else f"origin/{_resolve_default_branch(target)}" - _log(f"resetting to {ref}") - _run_git("reset", "--hard", ref, cwd=target) + default = None if branch else _resolve_default_branch(target) + if branch or default: + ref = f"origin/{branch or default}" + _log(f"resetting to {ref}") + _run_git("reset", "--hard", ref, cwd=target) + else: + # Remote has no commits yet — nothing to reset to. Leave + # the working tree empty; the scanner will produce an + # empty manifest and the frontend renders an empty world. + _log("remote has no commits; skipping reset") _log("update complete") except CloneError as e: # On update-path failure: try clean-error translation, then re-raise. diff --git a/api/env.py b/api/env.py new file mode 100644 index 00000000..d938aff2 --- /dev/null +++ b/api/env.py @@ -0,0 +1,30 @@ +"""Permissive boolean env-var parsing. + +Single helper shared by every codecity env-driven bool. Truthy values +(case-insensitive, whitespace-trimmed): "1", "true", "yes", "on". +Anything else — including unset, "", "0", "false", "no", "off" — is +False (or the supplied default for unset). + +Matches the convention used by Docker / Django / typical CLI tooling +so users setting ``-e CODECITY_FOO=true`` aren't surprised by silent +failure. +""" + +from __future__ import annotations + +import os + +_TRUTHY = frozenset({"1", "true", "yes", "on"}) + + +def env_bool(name: str, default: bool = False) -> bool: + """Read env var ``name`` as a permissive boolean. + + Returns ``default`` if the variable is unset. For any set value, + returns True only when the trimmed lower-case value is in the + truthy set above. + """ + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in _TRUTHY diff --git a/api/scan.py b/api/scan.py index 238b17ba..b31b221a 100755 --- a/api/scan.py +++ b/api/scan.py @@ -24,6 +24,7 @@ from pathlib import Path from typing import Any, Callable, Iterator +from .env import env_bool from .cache import ( cache_load_files, cache_load_git_history, @@ -69,7 +70,7 @@ def _check_cancel(event: "threading.Event | None") -> None: def _log(msg: str) -> None: - if os.environ.get("CODECITY_QUIET") != "1": + if not env_bool("CODECITY_QUIET"): print(f"[scan] {msg}", file=sys.stderr, flush=True) @@ -193,43 +194,17 @@ def _is_git_repo(root: Path) -> bool: return _run_git(root, "rev-parse", "--git-dir").strip() != "" -def _git_history_window(override: str | None = None) -> str: - """Resolve the git-log --since window. - - Precedence: explicit ``override`` argument (typically from a per- - request UI/API parameter) > ``CODECITY_GIT_WINDOW`` env var > - empty (= no --since, walk all history). Any value `git log --since=…` - accepts is valid: "3.years.ago", "2022-01-01", "6.months", etc. - - Returns "" to mean "no window"; callers must omit the --since flag. - """ - if override: - return override - return os.environ.get("CODECITY_GIT_WINDOW", "") - - -def _collect_git_dates_windowed( - root: Path, *, git_window: str | None = None, +def _collect_git_dates( + root: Path, ) -> tuple[dict[str, str], dict[str, str], list[CommitEntry]]: """One newest→oldest `git log --name-status` walk that populates both created_map and modified_map in a single pass, and also accumulates a per-commit (date, files, sha) summary list. Replaces two parallel walks (`--diff-filter=A --reverse` for creates - + bare walk for modifies). Two wins: - - 1. Halves the subprocess count — we now read the same history - from one process, parsing both A-events and other-status - events as we go. - 2. Optionally bounds the walk to ``CODECITY_GIT_WINDOW`` (default: - no bound — full history). When a window is set, files not - touched within it get no entry; the renderer falls back to - filesystem dates or the oldest-age color. Useful on huge repos - (e.g. torvalds/linux) where a window cuts walks from ~1.4M - commits to ~250K. Acceptable because the age-signal renders - colors relative to the visible date range — a file modified - 10 years ago and one modified 4 years ago both clamp to the - oldest-color bucket anyway. + + bare walk for modifies). Halves the subprocess count — we read + the same history from one process, parsing both A-events and + other-status events as we go. --no-renames means a rename is recorded as delete+add, so the `created` date reflects when the *current path* first appeared. @@ -239,19 +214,16 @@ def _collect_git_dates_windowed( Walk direction is newest→oldest with first-sighting-wins, so: - modified[path] = date of the most recent commit touching path - created[path] = date of the most recent `A`-status event for - path within the window. For files added once and never - re-added, this is the true creation date. + path. For files added once and never re-added, this is the + true creation date. """ - window = _git_history_window(git_window) - _log(f" starting git log walk (--since={window or 'ALL'})…") + _log(" starting git log walk (full history)…") # See _run_git docstring for why -c safe.directory=* is needed. log_argv = ["git", "-c", "safe.directory=*", "-C", str(root), "log", "--format=COMMIT:%aI%x09%H%x09%an%x09%s", "--name-status", "--no-renames", "--diff-merges=first-parent"] - if window: - log_argv.append(f"--since={window}") try: proc = subprocess.Popen( log_argv, @@ -356,41 +328,35 @@ def _collect_git_dates_windowed( def _collect_git_metadata( - root: Path, *, use_cache: bool = True, git_window: str | None = None, + root: Path, *, use_cache: bool = True, ) -> tuple[dict[str, str], dict[str, str], set[str], list[CommitEntry]]: """Return (created_map, modified_map, tracked_set, commits). - created_map[path] = ISO date of most recent ``A``-event for path - within ``CODECITY_GIT_WINDOW`` (default: all - history). Files added before the window - are absent. + across the full git history. Files never + added are absent. - modified_map[path] = ISO date of most recent commit touching the - path within the window. Files untouched - since the window started are absent. + path. Untouched files are absent. - tracked_set = all tracked paths + parent dirs (for the gitignore filter — independent of history). - commits = oldest-first list of CommitEntry (date, files, sha) - for each commit within the history window. + for each commit in the history. - Single `git log --name-status --no-renames --since=$WINDOW` walk - populates both maps in one pass. With ``use_cache=True`` (default), - the HEAD-keyed git-history cache short-circuits the walk when HEAD - hasn't moved. + Single `git log --name-status --no-renames` walk populates both + maps in one pass. With ``use_cache=True`` (default), the HEAD-keyed + git-history cache short-circuits the walk when HEAD hasn't moved. """ - # Cache is keyed on (root, head_sha, window) — changing the window - # invalidates because the maps' contents depend on it. - window = _git_history_window(git_window) head_sha = _run_git(root, "rev-parse", "HEAD").strip() if use_cache and head_sha: - cached = cache_load_git_history(root, head_sha, window) + cached = cache_load_git_history(root, head_sha) if cached is not None: created, modified, commits = cached tracked = _collect_tracked_set(root) return created, modified, tracked, commits _log(" collecting creation + modified dates…") - created, modified, commits = _collect_git_dates_windowed(root, git_window=window) - _log(f" {len(created)} created, {len(modified)} modified, {len(commits)} commits within window") + created, modified, commits = _collect_git_dates(root) + _log(f" {len(created)} created, {len(modified)} modified, {len(commits)} commits") _log(" listing tracked files…") tracked = _collect_tracked_set(root) @@ -398,7 +364,7 @@ def _collect_git_metadata( if use_cache and head_sha: try: - cache_save_git_history(root, head_sha, window, created, modified, commits) + cache_save_git_history(root, head_sha, created, modified, commits) except OSError: # Cache failures (disk full, permission denied, read-only fs) # must never block a scan. The next run will retry the write. @@ -455,6 +421,15 @@ def _collect_repo_info(root: Path) -> RepoInfo: # Detached: surface the short SHA so the footer isn't blank. short = _run_git(root, "rev-parse", "--short", "HEAD").strip() info["branch"] = f"detached @ {short}" if short else "detached HEAD" + else: + # Unborn HEAD (rev-parse exited non-zero → empty stdout): the + # repo has no commits yet, but HEAD still resolves as a symbolic + # ref to the configured default branch (e.g. "main"). Surface + # that name so the frontend can show "this is an empty " + # instead of falling back to "detached HEAD". + symref = _run_git(root, "symbolic-ref", "--short", "HEAD").strip() + if symref: + info["branch"] = symref remote = _run_git(root, "config", "--get", "remote.origin.url").strip() info["remote_url"] = _normalize_remote_to_web_url(remote) or None @@ -635,15 +610,6 @@ def _hash_repo_info(sig: Any, repo_info: RepoInfo) -> None: ) -def _hash_git_window(sig: Any, git_window: str | None) -> None: - # Different --since windows produce different per-file git dates; - # without this, two scans with different windows hash to the same - # signature and the cache returns a stale manifest. None and "" both - # mean "no window" (= all history) and must hash the same. - sig.update(b"|gw=") - sig.update((git_window or "").encode("utf-8")) - - def _file_node( entry: os.DirEntry[str], rel_path: str, @@ -660,7 +626,7 @@ def _file_node( # Every scanned file is git-tracked (scan_tree rejects non-git roots), # but a tracked file may still have no entry in git_created/git_modified - # if no commit touching it fell inside the git_window. + # if no commit ever touched it (e.g. just added, not yet committed). git_block: GitMeta = { "created": git_created.get(rel_path) or None, "modified": git_modified.get(rel_path) or None, @@ -1053,7 +1019,6 @@ def scan_tree( *, use_cache: bool = True, cancel_event: "threading.Event | None" = None, - git_window: str | None = None, on_scan_progress: Callable[[int], None] | None = None, ) -> Iterator["ScanStreamEvent"]: """Scan a git working tree and yield manifest events. @@ -1082,7 +1047,7 @@ def scan_tree( _log("collecting git metadata…") git_created, git_modified, tracked_files, commits_list = _collect_git_metadata( - Path(root_abs), use_cache=use_cache, git_window=git_window, + Path(root_abs), use_cache=use_cache, ) repo_info = _collect_repo_info(Path(root_abs)) @@ -1095,7 +1060,6 @@ def scan_tree( heartbeat = _Heartbeat(on_progress=on_scan_progress) _log("walking tree…") sig = hashlib.blake2b(digest_size=16) - _hash_git_window(sig, git_window) tree = _build_tree( root_abs, ".", git_created=git_created, git_modified=git_modified, @@ -1219,24 +1183,19 @@ def signature_tree( root: str, *, use_cache: bool = True, - git_window: str | None = None, ) -> SignatureResponse: """Cheap fingerprint of the tree — equivalent to scan_tree(root)['signature'] but without building the full manifest. Walks the tree once with os.scandir, hashing (rel_path, size, mtime) plus repo-level git fields (branch / remote / head / dirty). Skips - file content reads and the two `git log` walks scan_tree uses for + file content reads and the `git log` walk scan_tree uses for per-file created/modified history; both are cost-dominant on a big repo and don't feed the signature anyway. Honors the same skip list and ``/.codecityignore`` file as scan_tree, so signatures stay in lockstep. - ``git_window`` is folded into the signature so two scans with - different --since windows get distinct keys (manifests with - different windows have different per-file dates). - ``use_cache`` is accepted for API symmetry with scan_tree (so both /api/manifest and /api/manifest/signature take the same query params) but is a no-op here — signature_tree doesn't compute @@ -1256,7 +1215,6 @@ def signature_tree( ) sig = hashlib.blake2b(digest_size=16) - _hash_git_window(sig, git_window) _walk_for_signature( root_abs, ".", tracked_files=tracked_files, diff --git a/api/server.py b/api/server.py index 24a319dc..f366c598 100644 --- a/api/server.py +++ b/api/server.py @@ -42,6 +42,7 @@ from typing import Any, Callable, Iterable, Literal from urllib.parse import parse_qs, urlparse +from api.env import env_bool from api.clone import ( CloneError, BranchNotFoundError, @@ -63,6 +64,7 @@ from api.types import ( CacheClearResponse, CommitDetailResponse, + ConfigResponse, ErrorResponse, FileTooLargeResponse, HealthResponse, @@ -135,6 +137,12 @@ def _is_git_working_tree(path: Path) -> bool: "URL instead." ) +_LOCAL_DISABLED_ERROR = ( + "local repositories are disabled — restart codecity with " + "CODECITY_ALLOW_LOCAL_REPOS=1. " + "See https://github.com/thalida/codecity#local-directories" +) + # Bodies under this threshold skip compression — gzip's framing # overhead (~20 bytes header + trailer) exceeds the savings on small @@ -193,6 +201,7 @@ class _State: | HealthResponse | CacheClearResponse | CommitDetailResponse + | ConfigResponse ) @@ -343,21 +352,6 @@ def _parse_no_cache(query: str) -> bool: return raw in ("true", "1") -def _parse_git_window(query: str) -> str | None: - """Parse ?git_window=… as a `git log --since=…` expression. - - Returns ``None`` (meaning "use server default / env var") when the - param is absent or empty. The string is forwarded verbatim to git; - git itself rejects malformed expressions, in which case the scan - surfaces a clean error via the existing manifest-stream error path. - Length-capped at 64 chars to keep an invalid input from blowing up - the subprocess argv.""" - raw = parse_qs(query).get("git_window", [""])[0].strip() - if not raw: - return None - return raw[:64] - - def _resolve_scan_target( handler: BaseHTTPRequestHandler, query: str ) -> tuple[Path, str, str | None, Literal["local", "git"]] | None: @@ -400,6 +394,11 @@ def _resolve_scan_target( return None # kind == "local" — ignore any &branch=, scan the working tree in place + if not _local_repos_allowed(): + _send_json( + handler, HTTPStatus.FORBIDDEN, {"error": _LOCAL_DISABLED_ERROR} + ) + return None try: scan_target = Path(raw_src).resolve(strict=True) except (OSError, RuntimeError): @@ -418,6 +417,19 @@ def _resolve_scan_target( return scan_target, raw_src, None, "local" +def _local_repos_allowed() -> bool: + """Return True if CODECITY_ALLOW_LOCAL_REPOS is set to a truthy + value. Read fresh on each call so tests can monkeypatch the env + var without restarting the server.""" + return env_bool("CODECITY_ALLOW_LOCAL_REPOS") + + +def _serve_config(handler: BaseHTTPRequestHandler) -> None: + """GET /api/config — server-side feature flags for the frontend.""" + body: ConfigResponse = {"allowLocalRepos": _local_repos_allowed()} + _send_json(handler, HTTPStatus.OK, body) + + _COMMIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{7,40}$") @@ -528,6 +540,13 @@ def _serve_manifest(handler: BaseHTTPRequestHandler, query: str) -> None: local_target: Path | None = None if kind == "local": + if not _local_repos_allowed(): + _send_json( + handler, + HTTPStatus.FORBIDDEN, + {"error": _LOCAL_DISABLED_ERROR}, + ) + return try: local_target = Path(raw_src).resolve(strict=True) except (OSError, RuntimeError): @@ -545,7 +564,6 @@ def _serve_manifest(handler: BaseHTTPRequestHandler, query: str) -> None: return use_cache = not _parse_no_cache(query) - git_window = _parse_git_window(query) cancel_event = threading.Event() watchdog = _start_disconnect_watchdog(handler, cancel_event) @@ -644,14 +662,10 @@ def _run_clone() -> None: yield {"phase": "scanning", "display_root": display_root} # Cheap signature probe — same call the live-poll endpoint uses. - # git_window MUST flow in here too: it feeds the signature, and - # without it a window change would collide with a previously - # cached manifest under a different window. try: sig_response = signature_tree( str(scan_target), use_cache=use_cache, - git_window=git_window, ) except Exception as e: # pylint: disable=broad-except yield {"phase": "error", "error": f"scan failed: {e}"} @@ -692,7 +706,6 @@ def _run_scan() -> None: str(scan_target), use_cache=use_cache, cancel_event=cancel_event, - git_window=git_window, on_scan_progress=_on_scan_progress, ): scan_q.put(ev) # skeleton + final flow through the same queue @@ -765,7 +778,13 @@ def _delete_manifest_cache(handler: BaseHTTPRequestHandler, query: str) -> None: list — they're done with this source, so its disk cache should go too. Resolves git URLs to their clone-dir without actually cloning; resolves local paths non-strictly so cleanup still works for paths - that no longer exist on disk.""" + that no longer exist on disk. + + Note: this route is intentionally NOT gated by + `CODECITY_ALLOW_LOCAL_REPOS`. The gate exists to prevent fresh + scans of arbitrary host paths; cache cleanup only manipulates + files under ``CODECITY_CACHE_ROOT`` (a path derived from the + source, not the source itself) and is safe to leave open.""" params = parse_qs(query) raw_src = params.get("src", [""])[0] raw_branch = params.get("branch", [""])[0] or None @@ -799,7 +818,7 @@ def _delete_manifest_cache(handler: BaseHTTPRequestHandler, query: str) -> None: def _log_quiet(msg: str) -> None: """Same env-gated logger as scan._log, duplicated here so server doesn't import a private from scan. CODECITY_QUIET=1 silences.""" - if os.environ.get("CODECITY_QUIET") != "1": + if not env_bool("CODECITY_QUIET"): print(msg, file=sys.stderr, flush=True) @@ -816,15 +835,11 @@ def _serve_manifest_signature(handler: BaseHTTPRequestHandler, query: str) -> No return scan_target, _raw_src, _raw_branch, _kind = resolved use_cache = not _parse_no_cache(query) - # Pass git_window through so the live-update poll's signature matches - # the cached manifest's (which is now window-keyed). - git_window = _parse_git_window(query) try: sig = signature_tree( str(scan_target), use_cache=use_cache, - git_window=git_window, ) except Exception as e: # pylint: disable=broad-except _send_json( @@ -962,6 +977,10 @@ def do_GET(self) -> None: # noqa: N802 (stdlib API) _send_json(self, HTTPStatus.OK, {"ok": True}) return + if path == "/api/config": + _serve_config(self) + return + if path == "/api/manifest": _serve_manifest(self, parsed.query) return diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 50f0280d..df3f7b13 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -75,6 +75,27 @@ def quiet_logs() -> Iterator[None]: os.environ["CODECITY_QUIET"] = prev +@pytest.fixture(autouse=True, scope="session") +def allow_local_repos() -> Iterator[None]: + """Enable local-repo scanning for the full test suite. + + The gate (CODECITY_ALLOW_LOCAL_REPOS) defaults to *off* in production + but nearly every test that exercises a local scan path needs it on. + Set it session-wide; tests that specifically test the *disabled* state + use ``monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False)`` + to override for their scope. + """ + prev = os.environ.get("CODECITY_ALLOW_LOCAL_REPOS") + os.environ["CODECITY_ALLOW_LOCAL_REPOS"] = "1" + try: + yield + finally: + if prev is None: + os.environ.pop("CODECITY_ALLOW_LOCAL_REPOS", None) + else: + os.environ["CODECITY_ALLOW_LOCAL_REPOS"] = prev + + # ── Cache redirection ──────────────────────────────────────────────── diff --git a/api/tests/test_cache.py b/api/tests/test_cache.py index daa02b66..bc88baf6 100644 --- a/api/tests/test_cache.py +++ b/api/tests/test_cache.py @@ -111,71 +111,51 @@ def test_load_drops_malformed_entries(self) -> None: class GitHistoryCacheTests(CacheTestBase): - WINDOW = "3.years.ago" - def test_hit_on_matching_head(self) -> None: root = Path("/some/repo") created = {"src/a.py": "2024-01-01T00:00:00Z"} modified = {"src/a.py": "2024-06-01T00:00:00Z"} - cache_mod.cache_save_git_history(root, "abc123", self.WINDOW, created, modified, []) - result = cache_mod.cache_load_git_history(root, "abc123", self.WINDOW) + cache_mod.cache_save_git_history(root, "abc123", created, modified, []) + result = cache_mod.cache_load_git_history(root, "abc123") self.assertEqual(result, (created, modified, [])) def test_miss_on_different_head(self) -> None: root = Path("/some/repo") - cache_mod.cache_save_git_history(root, "abc123", self.WINDOW, {}, {}, []) - self.assertIsNone( - cache_mod.cache_load_git_history(root, "def456", self.WINDOW) - ) - - def test_miss_on_different_window(self) -> None: - # Cached with one window, looked up with another → cache miss. - # The maps' contents depend on the window so we can't safely - # serve the cached version when the caller asked for a - # different time range. - root = Path("/some/repo") - cache_mod.cache_save_git_history(root, "abc123", "3.years.ago", {}, {}, []) + cache_mod.cache_save_git_history(root, "abc123", {}, {}, []) self.assertIsNone( - cache_mod.cache_load_git_history(root, "abc123", "10.years.ago") + cache_mod.cache_load_git_history(root, "def456") ) def test_load_missing_returns_none(self) -> None: self.assertIsNone( - cache_mod.cache_load_git_history( - Path("/never/scanned"), "abc123", self.WINDOW, - ) + cache_mod.cache_load_git_history(Path("/never/scanned"), "abc123") ) def test_load_corrupted_returns_none(self) -> None: root = Path("/some/repo") - cache_mod.cache_save_git_history(root, "abc", self.WINDOW, {}, {}, []) + cache_mod.cache_save_git_history(root, "abc", {}, {}, []) path = cache_mod.CACHE_ROOT / "git-history" / f"{cache_mod.repo_key(root)}.json" path.write_text("{garbage") - self.assertIsNone( - cache_mod.cache_load_git_history(root, "abc", self.WINDOW) - ) + self.assertIsNone(cache_mod.cache_load_git_history(root, "abc")) def test_load_version_mismatch_returns_none(self) -> None: root = Path("/some/repo") - cache_mod.cache_save_git_history(root, "abc", self.WINDOW, {}, {}, []) + cache_mod.cache_save_git_history(root, "abc", {}, {}, []) path = cache_mod.CACHE_ROOT / "git-history" / f"{cache_mod.repo_key(root)}.json" bad = {"version": 999, "root": str(root), "head_sha": "abc", - "git_window": self.WINDOW, "created": {}, "modified": {}} + "created": {}, "modified": {}} path.write_text(json.dumps(bad)) - self.assertIsNone( - cache_mod.cache_load_git_history(root, "abc", self.WINDOW) - ) + self.assertIsNone(cache_mod.cache_load_git_history(root, "abc")) def test_load_drops_non_string_entries(self) -> None: # Mixed string + non-string values in created/modified maps; # only string-keyed string-valued entries survive. root = Path("/some/repo") - cache_mod.cache_save_git_history(root, "abc", self.WINDOW, {}, {}, []) + cache_mod.cache_save_git_history(root, "abc", {}, {}, []) path = cache_mod.CACHE_ROOT / "git-history" / f"{cache_mod.repo_key(root)}.json" payload = { "version": cache_mod._GIT_HISTORY_CACHE_VERSION, "root": str(root), "head_sha": "abc", - "git_window": self.WINDOW, "created": { "good.py": "2024-01-01T00:00:00Z", "bad.py": 12345, # not a string @@ -186,7 +166,7 @@ def test_load_drops_non_string_entries(self) -> None: "commits": [], } path.write_text(json.dumps(payload)) - result = cache_mod.cache_load_git_history(root, "abc", self.WINDOW) + result = cache_mod.cache_load_git_history(root, "abc") self.assertIsNotNone(result) assert result is not None # narrow for type checker created, modified, commits = result @@ -202,12 +182,12 @@ def test_git_history_cache_round_trips_commits(self): {"date": "2024-02-15", "files": 7, "sha": "b" * 40}, ] cache_mod.cache_save_git_history( - root, head_sha="abc", git_window="3.years.ago", + root, head_sha="abc", created={"a.py": "2024-01-01"}, modified={"a.py": "2024-02-15"}, commits=commits, ) - loaded = cache_mod.cache_load_git_history(root, "abc", "3.years.ago") + loaded = cache_mod.cache_load_git_history(root, "abc") self.assertIsNotNone(loaded) assert loaded is not None # narrow for type checker loaded_created, loaded_modified, loaded_commits = loaded @@ -225,7 +205,6 @@ def test_git_history_cache_drops_malformed_commits(self): "version": cache_mod._GIT_HISTORY_CACHE_VERSION, "root": str(root), "head_sha": "abc", - "git_window": "3.years.ago", "created": {}, "modified": {}, "commits": [ @@ -241,7 +220,7 @@ def test_git_history_cache_drops_malformed_commits(self): {"date": "2024-06-01", "files": 1, "sha": sha_b}, # valid ], }), encoding="utf-8") - loaded = cache_mod.cache_load_git_history(root, "abc", "3.years.ago") + loaded = cache_mod.cache_load_git_history(root, "abc") self.assertIsNotNone(loaded) assert loaded is not None _created, _modified, commits = loaded @@ -260,17 +239,16 @@ def test_git_history_rejects_old_version(self): root = Path("/fake/root2") path = _git_history_cache_path(root) path.parent.mkdir(parents=True, exist_ok=True) - # Simulate a pre-sha cache file (version one below current). + # Simulate a cache file with the previous version number. old = { "version": cache_mod._GIT_HISTORY_CACHE_VERSION - 1, "head_sha": "HEADSHA", - "git_window": "30.years.ago", "created": {}, "modified": {}, "commits": [{"date": "2026-03-12", "files": 1}], } path.write_text(json.dumps(old)) - self.assertIsNone(cache_load_git_history(root, "HEADSHA", "30.years.ago")) + self.assertIsNone(cache_load_git_history(root, "HEADSHA")) def test_git_history_cache_v2_returns_none(self): """A v2 cache file (no commits field) must be treated as a miss @@ -282,13 +260,10 @@ def test_git_history_cache_v2_returns_none(self): "version": 2, "root": str(root), "head_sha": "abc", - "git_window": "3.years.ago", "created": {}, "modified": {}, }), encoding="utf-8") - self.assertIsNone( - cache_mod.cache_load_git_history(root, "abc", "3.years.ago"), - ) + self.assertIsNone(cache_mod.cache_load_git_history(root, "abc")) class ManifestCacheTests(CacheTestBase): diff --git a/api/tests/test_clone.py b/api/tests/test_clone.py index 9f7e9030..4277d047 100644 --- a/api/tests/test_clone.py +++ b/api/tests/test_clone.py @@ -119,6 +119,34 @@ def test_missing_remote_raises_clone_error(self) -> None: with self.assertRaises(CloneError): ensure_clone(str(self.tmp_path / "does-not-exist.git")) + def test_empty_remote_no_commits_clones_without_error(self) -> None: + """A brand-new remote with no commits has an unborn HEAD: the + bare repo has no refs/heads/* and no resolvable origin/HEAD on + clones. ensure_clone must produce an empty working tree rather + than surfacing `git symbolic-ref … is not a symbolic ref` from + the update-path reset. The frontend then renders an empty world. + """ + empty_bare = self.tmp_path / "empty.git" + _run("git", "init", "--bare", "-q", str(empty_bare), cwd=self.tmp_path) + url = str(empty_bare) + + # First call (fresh clone path): git clone of an empty bare + # succeeds with a working tree containing nothing. + local = ensure_clone(url) + self.assertTrue(local.is_dir()) + self.assertTrue((local / ".git").is_dir()) + # No files in the working tree (just .git/). + files = [p.name for p in local.iterdir() if p.name != ".git"] + self.assertEqual(files, []) + + # Second call (update path) — this is where the bug was: the + # old code unconditionally called `git symbolic-ref + # refs/remotes/origin/HEAD` which exits non-zero on an unborn + # HEAD and bubbled the cryptic git stderr to the picker modal. + ensure_clone(url) + files_after = [p.name for p in local.iterdir() if p.name != ".git"] + self.assertEqual(files_after, []) + class CleanCloneErrorDispatcherTests(unittest.TestCase): def test_branch_not_found_first_clone_stderr(self) -> None: diff --git a/api/tests/test_env.py b/api/tests/test_env.py new file mode 100644 index 00000000..4765d102 --- /dev/null +++ b/api/tests/test_env.py @@ -0,0 +1,91 @@ +"""Tests for the permissive boolean env-var parser in api.env.""" + +from __future__ import annotations + +import unittest + +import pytest + +from api.env import env_bool + + +class EnvBoolTests(unittest.TestCase): + """Truthy values: '1', 'true', 'yes', 'on' (case-insensitive, + whitespace-trimmed). Anything else (including unset) is False.""" + + NAME = "CODECITY_TEST_ENV_BOOL" + + @pytest.fixture(autouse=True) + def _setup(self, monkeypatch: pytest.MonkeyPatch) -> None: + self.monkeypatch = monkeypatch + # Start from a clean slate every test. + monkeypatch.delenv(self.NAME, raising=False) + + def _set(self, value: str) -> None: + self.monkeypatch.setenv(self.NAME, value) + + # Truthy values + def test_one_enables(self) -> None: + self._set("1") + self.assertTrue(env_bool(self.NAME)) + + def test_true_enables(self) -> None: + self._set("true") + self.assertTrue(env_bool(self.NAME)) + + def test_uppercase_true_enables(self) -> None: + self._set("TRUE") + self.assertTrue(env_bool(self.NAME)) + + def test_mixed_case_true_enables(self) -> None: + self._set("True") + self.assertTrue(env_bool(self.NAME)) + + def test_yes_enables(self) -> None: + self._set("yes") + self.assertTrue(env_bool(self.NAME)) + + def test_on_enables(self) -> None: + self._set("on") + self.assertTrue(env_bool(self.NAME)) + + def test_whitespace_is_trimmed(self) -> None: + self._set(" true ") + self.assertTrue(env_bool(self.NAME)) + + # Falsy values + def test_zero_disables(self) -> None: + self._set("0") + self.assertFalse(env_bool(self.NAME)) + + def test_false_disables(self) -> None: + self._set("false") + self.assertFalse(env_bool(self.NAME)) + + def test_no_disables(self) -> None: + self._set("no") + self.assertFalse(env_bool(self.NAME)) + + def test_off_disables(self) -> None: + self._set("off") + self.assertFalse(env_bool(self.NAME)) + + def test_empty_string_disables(self) -> None: + self._set("") + self.assertFalse(env_bool(self.NAME)) + + def test_arbitrary_string_disables(self) -> None: + self._set("maybe") + self.assertFalse(env_bool(self.NAME)) + + # Unset + def test_unset_returns_default_false(self) -> None: + # No setenv — env var is absent. + self.assertFalse(env_bool(self.NAME)) + + def test_unset_returns_supplied_default(self) -> None: + self.assertTrue(env_bool(self.NAME, default=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_scan.py b/api/tests/test_scan.py index 3f834ea3..fe3d51be 100644 --- a/api/tests/test_scan.py +++ b/api/tests/test_scan.py @@ -388,7 +388,7 @@ def test_codecityignore_negation_does_not_unignore_git_dir(self): self.assertNotIn(".git", names) def test_scan_tree_emits_commits_list(self): - m = _final_manifest(str(FIXTURE), use_cache=False, git_window="30.years.ago") + m = _final_manifest(str(FIXTURE), use_cache=False) self.assertIn("commits", m) self.assertIsInstance(m["commits"], list) self.assertGreater(len(m["commits"]), 0) @@ -529,7 +529,7 @@ def setUpClass(cls): def test_single_walk_invocation(self): # The combined --name-status walk replaces the previous two # parallel walks — _collect_git_metadata should now fire `git - # log` exactly once. _collect_git_dates_windowed streams output + # log` exactly once. _collect_git_dates streams output # via Popen; the short auxiliary commands (rev-parse, ls-files) # go through subprocess.run. Wrap both so we catch git log # regardless of which API the implementation chose. @@ -571,7 +571,7 @@ def counting_popen(args, **kwargs): def test_collect_git_metadata_returns_commits_list(self): from api.scan import _collect_git_metadata _created, _modified, _tracked, commits = _collect_git_metadata( - FIXTURE, use_cache=False, git_window="30.years.ago", + FIXTURE, use_cache=False, ) self.assertIsInstance(commits, list) self.assertGreater(len(commits), 0) @@ -599,7 +599,7 @@ def test_collect_git_metadata_captures_second_author_and_subject_only(self): must be the second author's name (not the bot).""" from api.scan import _collect_git_metadata _c, _m, _t, commits = _collect_git_metadata( - FIXTURE, use_cache=False, git_window="30.years.ago", + FIXTURE, use_cache=False, ) last = commits[-1] self.assertEqual(last["author"], "Other Fixture Person") @@ -637,7 +637,7 @@ def test_collect_git_metadata_counts_merge_files(self): subprocess.run(["git", "-C", td, "add", "."], check=True) subprocess.run(["git", "-C", td, "commit", "-aq", "--no-edit"], check=True) _c, _m, _t, commits = _collect_git_metadata( - Path(td), use_cache=False, git_window="30.years.ago", + Path(td), use_cache=False, ) # The merge commit (latest) MUST have files >= 1. # commits are oldest-first, so the merge is last. @@ -671,7 +671,7 @@ def test_collect_git_metadata_counts_clean_merge_files(self): check=True, ) _c, _m, _t, commits = _collect_git_metadata( - Path(td), use_cache=False, git_window="30.years.ago", + Path(td), use_cache=False, ) # Merge is the most recent commit (oldest-first list, so [-1]). # Side branch added b.txt; the merge against the first parent @@ -763,7 +763,7 @@ def test_parse_loop_exception_kills_subprocess_before_waiting(self): ``proc.kill()`` before ``proc.wait()``. Otherwise wait() blocks forever on any git child that still has buffered output.""" from unittest.mock import patch - from api.scan import _collect_git_dates_windowed + from api.scan import _collect_git_dates class _FakeStdout: """Yields one valid line, then raises — mimics the moment @@ -809,7 +809,7 @@ def wait(self, timeout: float | None = None) -> int: fake = _FakeProc() with patch("api.scan.subprocess.Popen", return_value=fake): with self.assertRaises(UnicodeDecodeError): - _collect_git_dates_windowed(Path("/tmp/does-not-matter")) + _collect_git_dates(Path("/tmp/does-not-matter")) self.assertTrue(fake.killed, "cleanup must call proc.kill()") self.assertTrue(fake.waited, "cleanup must call proc.wait() after kill") diff --git a/api/tests/test_server_commit.py b/api/tests/test_server_commit.py index 3f6ead14..f0419364 100644 --- a/api/tests/test_server_commit.py +++ b/api/tests/test_server_commit.py @@ -19,9 +19,10 @@ class TestServeCommitDetail(unittest.TestCase): """Coverage for /api/commit — full commit message detail endpoint.""" @pytest.fixture(autouse=True) - def _setup_fixtures(self, redirect_cache_root, http_helpers) -> None: + def _setup_fixtures(self, redirect_cache_root, http_helpers, monkeypatch) -> None: self.cache_root = redirect_cache_root self._http = http_helpers + self.monkeypatch = monkeypatch def setUp(self) -> None: super().setUp() @@ -75,6 +76,21 @@ def test_short_sha_resolves(self) -> None: self.assertEqual(status, 200) self.assertEqual(body["sha"], full_sha) + def test_commit_unaffected_by_local_gate_when_no_roots_registered(self) -> None: + """/api/commit looks up shas across already-registered scan roots. + The local-repo gate doesn't change /api/commit's behavior on its + own — this test guards against accidentally adding a stray + local-gate check on the commit path.""" + from http import HTTPStatus + + self.monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) + # No roots registered yet → expect 404 (no scan root) not 403. + # The local-repo gate is orthogonal to commit-detail lookup. + status, body, _ = self._http.get( + self.base + "/api/commit?sha=abcdef0123456789", + ) + self.assertEqual(status, HTTPStatus.NOT_FOUND) + if __name__ == "__main__": unittest.main() diff --git a/api/tests/test_server_config.py b/api/tests/test_server_config.py new file mode 100644 index 00000000..337ffcb9 --- /dev/null +++ b/api/tests/test_server_config.py @@ -0,0 +1,66 @@ +"""Tests for /api/config — the small endpoint the frontend reads at +boot to learn server-side feature flags (currently: whether local-repo +sources are permitted).""" + +from __future__ import annotations + +import json +import unittest +from http import HTTPStatus +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from api.server import start_server + + +class ServerConfigTests(unittest.TestCase): + """Covers /api/config response shape across CODECITY_ALLOW_LOCAL_REPOS + env-var states.""" + + @pytest.fixture(autouse=True) + def _setup_fixtures(self, http_helpers, monkeypatch) -> None: + self._http = http_helpers + self.monkeypatch = monkeypatch + + def setUp(self) -> None: + super().setUp() + self.tmp = TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + static = Path(self.tmp.name) / "static" + static.mkdir() + (static / "index.html").write_text("hi") + + self.server, self.port, self.shutdown = start_server( + port=0, static_dir=static + ) + self.addCleanup(self.shutdown) + self.base = f"http://127.0.0.1:{self.port}" + + def test_config_env_unset_returns_false(self) -> None: + self.monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) + status, body, ctype = self._http.get(self.base + "/api/config") + self.assertEqual(status, HTTPStatus.OK) + self.assertIn("application/json", ctype) + self.assertEqual(json.loads(body), {"allowLocalRepos": False}) + + def test_config_env_one_returns_true(self) -> None: + self.monkeypatch.setenv("CODECITY_ALLOW_LOCAL_REPOS", "1") + status, body, _ = self._http.get(self.base + "/api/config") + self.assertEqual(status, HTTPStatus.OK) + self.assertEqual(json.loads(body), {"allowLocalRepos": True}) + + def test_config_env_true_returns_true(self) -> None: + self.monkeypatch.setenv("CODECITY_ALLOW_LOCAL_REPOS", "true") + _, body, _ = self._http.get(self.base + "/api/config") + self.assertEqual(json.loads(body), {"allowLocalRepos": True}) + + def test_config_env_zero_returns_false(self) -> None: + self.monkeypatch.setenv("CODECITY_ALLOW_LOCAL_REPOS", "0") + _, body, _ = self._http.get(self.base + "/api/config") + self.assertEqual(json.loads(body), {"allowLocalRepos": False}) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_server_file.py b/api/tests/test_server_file.py index 6507ba75..b213188a 100644 --- a/api/tests/test_server_file.py +++ b/api/tests/test_server_file.py @@ -19,9 +19,10 @@ class FileApiTests(unittest.TestCase): """Coverage for /api/file — the root-bounded file reader.""" @pytest.fixture(autouse=True) - def _setup_fixtures(self, redirect_cache_root, http_helpers) -> None: + def _setup_fixtures(self, redirect_cache_root, http_helpers, monkeypatch) -> None: self.cache_root = redirect_cache_root self._http = http_helpers + self.monkeypatch = monkeypatch def setUp(self) -> None: super().setUp() @@ -153,6 +154,21 @@ def test_file_api_image_not_gzipped(self) -> None: # Body is the raw "PNG" bytes — not gzipped. self.assertTrue(body.startswith(b"\x89PNG")) + def test_local_src_blocked_via_manifest_keeps_file_endpoint_clean(self) -> None: + """/api/file trusts `_State.allowed_roots`, which is populated by + successful manifest scans. The local-repo gate sits upstream in + _resolve_scan_target / _serve_manifest, so blocked-local scans + never register a root. Validate the chain by attempting a local + manifest with the gate off — expect 403 and no trust-set growth.""" + import urllib.parse + + self.monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) + q = urllib.parse.urlencode({"src": "/tmp/some-local-path"}) + status, body, _ = self._http.get(self.base + f"/api/manifest?{q}") + self.assertEqual(status, HTTPStatus.FORBIDDEN) + err = json.loads(body)["error"] + self.assertIn("local repositories are disabled", err) + if __name__ == "__main__": unittest.main() diff --git a/api/tests/test_server_manifest.py b/api/tests/test_server_manifest.py index e018edc0..6a6c6629 100644 --- a/api/tests/test_server_manifest.py +++ b/api/tests/test_server_manifest.py @@ -39,11 +39,12 @@ class ManifestRouteTests(unittest.TestCase): @pytest.fixture(autouse=True) def _setup_fixtures( - self, redirect_cache_root, init_git_repo, http_helpers, + self, redirect_cache_root, init_git_repo, http_helpers, monkeypatch, ) -> None: self.cache_root = redirect_cache_root self._init_git_repo = init_git_repo self._http = http_helpers + self.monkeypatch = monkeypatch def setUp(self) -> None: super().setUp() @@ -139,6 +140,26 @@ def test_manifest_response_uncompressed_when_gzip_not_in_accept(self) -> None: events = [json.loads(line) for line in body.splitlines() if line] self.assertGreaterEqual(len(events), 1) + def test_manifest_local_src_403_when_disabled(self) -> None: + self.monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) + q = urllib.parse.urlencode({"src": str(self.project)}) + status, body, _ = self._http.get(self.base + f"/api/manifest?{q}") + self.assertEqual(status, HTTPStatus.FORBIDDEN) + err = json.loads(body)["error"] + self.assertIn("local repositories are disabled", err) + self.assertIn("CODECITY_ALLOW_LOCAL_REPOS=1", err) + + def test_manifest_local_src_succeeds_when_enabled(self) -> None: + self.monkeypatch.setenv("CODECITY_ALLOW_LOCAL_REPOS", "1") + q = urllib.parse.urlencode({"src": str(self.project)}) + status, events = self._http.request_stream( + self.port, f"/api/manifest?{q}", + ) + self.assertEqual(status, HTTPStatus.OK) + # 'final' phase event is the manifest body. + final = next(e for e in events if e["phase"] == "final") + self.assertEqual(final["manifest"]["tree"]["name"], "project") + class ClassifySourceTests(unittest.TestCase): def test_absolute_path(self) -> None: diff --git a/api/types.py b/api/types.py index 61da1821..2d9de374 100644 --- a/api/types.py +++ b/api/types.py @@ -26,9 +26,8 @@ class with string attrs (not enum.Enum) so the Literal discriminators class GitMeta(TypedDict): - """Git history dates for a file. ISO 8601 strings; null when no - commit touching the file fell inside the active git_window (so - create/modify timestamps were never observed).""" + """Git history dates for a file. ISO 8601 strings; null when the + scanner never observed a create/modify date (e.g. uncommitted).""" created: str | None modified: str | None @@ -165,6 +164,13 @@ class HealthResponse(TypedDict): ok: bool +class ConfigResponse(TypedDict): + """/api/config body. Read by the frontend at boot to learn + server-side feature flags.""" + + allowLocalRepos: bool + + class CommitDetailResponse(TypedDict): """Body of GET /api/commit?sha=. Returns the commit's author name (no email), day-precision date, single-line subject, and full diff --git a/app/src/main.ts b/app/src/main.ts index 99250c24..2326d756 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -28,6 +28,7 @@ import { streamManifest } from './utils/manifestStream.js'; import { pushRecent } from './views/source/sourceRecents.js'; import { startRenderLoop, _applyDisplayLabel } from './scene/renderLoop.js'; import { labelFromUrl } from './views/widgets/displayLabel.js'; +import { getServerConfig } from './utils/serverConfig.js'; /** * Set document.title to "{label} (pending) — codecity" from a server-emitted @@ -267,7 +268,6 @@ if (_canvas) { const url = new URL('/api/manifest', window.location.origin); url.searchParams.set('src', payload.src); if (payload.branch) url.searchParams.set('branch', payload.branch); - if (payload.gitWindow) url.searchParams.set('git_window', payload.gitWindow); // Consume the one-shot skip-cache flag set by the source picker. // Only this first fetch uses it; the poll loop is unaffected. if (_pendingSkipCache) { @@ -340,8 +340,8 @@ if (_canvas) { pageUrl.searchParams.set('src', payload.src); if (payload.branch) pageUrl.searchParams.set('branch', payload.branch); else pageUrl.searchParams.delete('branch'); - if (payload.gitWindow) pageUrl.searchParams.set('git_window', payload.gitWindow); - else pageUrl.searchParams.delete('git_window'); + // Strip any stale git_window left over from older bookmarks. + pageUrl.searchParams.delete('git_window'); history.replaceState(null, '', pageUrl.toString()); CURRENT_SOURCE_KEY.set(sourceKey(payload.src, payload.branch)); @@ -357,19 +357,40 @@ if (_canvas) { _applyDisplayLabel(manifest); await handle.world.applyManifest(manifest); + // If the user didn't explicitly request a branch, fall back to + // the manifest's resolved HEAD (the repo's default branch) so + // both the header pill and the recents row reflect what was + // actually loaded instead of leaving the branch blank. + // + // Defensive guard: the scanner labels a detached HEAD with + // strings like "detached HEAD" or "detached @ a1b2c3d". Those + // are display labels, NOT real branch names — passing them to + // a later `git clone --branch …` would fail. Only treat the + // manifest branch as a usable default when it looks like a + // normal ref (no spaces, no leading parens/"detached" prefix). + const manifestBranch = manifest.repo.branch; + const looksLikeRealBranch = + !!manifestBranch && + !/\s/.test(manifestBranch) && + !manifestBranch.startsWith('(') && + !manifestBranch.startsWith('detached'); + const resolvedBranch = + payload.branch ?? (looksLikeRealBranch ? manifestBranch! : undefined); + const branchIsDefault = !payload.branch && looksLikeRealBranch; + // Update the header (project label, branch pill) + footer (repo link) // AFTER applyManifest so world.getManifest() inside the coordinator // resolves to the just-applied manifest — otherwise the label is stale. handle.coordinator.setSourceInfo( - payload.branch, + resolvedBranch, _srcKind(payload.src) === 'git' ? payload.src : undefined ); _liveUpdates?.setSignature(manifest.signature); pushRecent({ src: payload.src, - branch: payload.branch, - gitWindow: payload.gitWindow, + branch: resolvedBranch, + branchIsDefault, label: _deriveLabel(payload.src), }); @@ -389,7 +410,9 @@ if (_canvas) { } } + const serverConfig = await getServerConfig(); const picker = createSourcePicker({ + allowLocalRepos: serverConfig.allowLocalRepos, onSubmit: (payload) => { _pendingSkipCache = !!payload.skipCache; picker.close(); @@ -406,7 +429,6 @@ if (_canvas) { prefill: { src: qp.get('src')!, branch: qp.get('branch') ?? undefined, - gitWindow: qp.get('git_window') ?? undefined, }, error: initialError, }); @@ -429,7 +451,6 @@ if (_canvas) { ? { src: cur.get('src')!, branch: cur.get('branch') ?? undefined, - gitWindow: cur.get('git_window') ?? undefined, } : undefined, }); diff --git a/app/src/scene/components/repoLabel/repoLabel.ts b/app/src/scene/components/repoLabel/repoLabel.ts index 0b29e58d..8ab3c639 100644 --- a/app/src/scene/components/repoLabel/repoLabel.ts +++ b/app/src/scene/components/repoLabel/repoLabel.ts @@ -78,6 +78,23 @@ export interface RepoLabel { tick(dtSeconds: number, camera: THREE.Camera): void; refresh(): void; dispose(): void; + /** + * World position + size of the floating label panel. Returned in + * world units so the camera framing code can include the label as + * a "virtual roof corner" when sizing the start view — essential + * for empty worlds where there are no buildings to frame against, + * so the label would otherwise float off-screen. + * + * Returns null when the label is disabled or hasn't been positioned + * yet (no anchor set). + */ + getPanelBounds(): { + centerX: number; + centerY: number; + centerZ: number; + halfWidth: number; + halfHeight: number; + } | null; } // _faceCamera rotates `obj` so its +Z front face points at the camera, @@ -88,6 +105,7 @@ const _LABEL_WORLD_UP = new THREE.Vector3(0, 1, 0); const _scratchObjPos = new THREE.Vector3(); const _scratchCamPos = new THREE.Vector3(); const _scratchMat = new THREE.Matrix4(); +const _scratchPolarDir = new THREE.Vector3(); function _faceCamera(obj: THREE.Object3D, camera: THREE.Camera): void { obj.updateMatrixWorld(true); obj.getWorldPosition(_scratchObjPos); @@ -100,6 +118,26 @@ function _faceCamera(obj: THREE.Object3D, camera: THREE.Camera): void { obj.quaternion.setFromRotationMatrix(_scratchMat); } +// Smoothstep [0,1] fade that goes to 0 as the camera approaches the +// panel's vertical axis (i.e. looking straight down or up at it). +// _faceCamera's lookAt(eye, target, [0,1,0]) basis is degenerate when +// camera→panel is parallel to world-up — the panel texture smears +// across the screen as a bright artifact, and the beam (a vertical +// cylinder seen on-axis from above) has no meaningful silhouette either. +// Hide both as we approach that pole. +function _polarFade(camera: THREE.Camera, panelWorldPos: THREE.Vector3): number { + _scratchPolarDir.subVectors(camera.position, panelWorldPos); + const lenSq = _scratchPolarDir.lengthSq(); + if (lenSq < 1e-8) return 0; + _scratchPolarDir.normalize(); + // |dir.y| → 1 means viewing parallel to world-up. + const cosToVertical = Math.abs(_scratchPolarDir.y); + // smoothstep from 0.93 (~21° off vertical, still readable) to + // 0.995 (~5.7° off vertical, can't read it anyway). + const t = Math.min(1, Math.max(0, (cosToVertical - 0.93) / (0.995 - 0.93))); + return 1 - t * t * (3 - 2 * t); +} + export function createRepoLabel(): RepoLabel { const group = new THREE.Group(); group.name = 'repoLabel'; @@ -336,5 +374,41 @@ export function createRepoLabel(): RepoLabel { if (group.parent) group.parent.remove(group); } - return { group, setRepoName, setAnchor, setGem, tick, refresh, dispose }; + function getPanelBounds(): { + centerX: number; + centerY: number; + centerZ: number; + halfWidth: number; + halfHeight: number; + } | null { + if (!panelMesh || !textTex) return null; + const cfg = REPO_LABEL.get(); + if (!cfg.ENABLED) return null; + const halfFont = cfg.FONT_SIZE / 2; + const dims = BUILDING_DIMENSIONS.get(); + const maxBldgH = dims.MAX_FLOORS * dims.FLOOR_HEIGHT; + const heightWorld = maxBldgH * (cfg.HEIGHT_PCT / 100); + // Mirror _applyTransform: panel center sits at anchor.y + heightWorld + // + halfFont; the group's x/z is the anchor's x/z. + const centerY = anchorY + heightWorld + halfFont; + const halfWidth = (cfg.FONT_SIZE * (textTex.aspect ?? 1)) / 2; + return { + centerX: anchorX, + centerY, + centerZ: anchorZ, + halfWidth, + halfHeight: halfFont, + }; + } + + return { + group, + setRepoName, + setAnchor, + setGem, + tick, + refresh, + dispose, + getPanelBounds, + }; } diff --git a/app/src/scene/system/cameraRig.ts b/app/src/scene/system/cameraRig.ts index 0bc5f3c0..147b8079 100644 --- a/app/src/scene/system/cameraRig.ts +++ b/app/src/scene/system/cameraRig.ts @@ -233,7 +233,8 @@ export function createCameraRig({ // breathing room above the roof (1.0 = spire flush against top edge). let heightDist = 0; const tallest = gemPos && rootStreet ? world.getTallestBuilding() : null; - if (tallest) { + const labelBounds = gemPos && rootStreet ? world.getRepoLabelBounds() : null; + if (tallest || labelBounds) { const sinElev = dir.y; const camUpScale = Math.sqrt(Math.max(0, 1 - sinElev * sinElev)); const camUpX = camUpScale > 1e-6 ? (-dir.y * dir.x) / camUpScale : 0; @@ -242,16 +243,38 @@ export function createCameraRig({ const tanHalfFov = Math.tan(halfFov); const gemX = framingCenter.x; const gemZ = framingCenter.z; + const _fitPoint = (wx: number, wy: number, wz: number) => { + const px = wx - gemX; + const py = wy; + const pz = wz - gemZ; + const pDotDir = px * dir.x + py * dir.y + pz * dir.z; + const pDotUp = px * camUpX + py * camUpY + pz * camUpZ; + const dNeeded = Math.abs(pDotUp) / tanHalfFov + pDotDir; + if (dNeeded > heightDist) heightDist = dNeeded; + }; // 4 roof corners in world: (b.x ± b.w/2, b.h, b.y ± b.d/2). - for (const sx of [-0.5, 0.5]) { - for (const sz of [-0.5, 0.5]) { - const px = tallest.x + sx * tallest.w - gemX; - const py = tallest.h; - const pz = tallest.y + sz * tallest.d - gemZ; - const pDotDir = px * dir.x + py * dir.y + pz * dir.z; - const pDotUp = px * camUpX + py * camUpY + pz * camUpZ; - const dNeeded = Math.abs(pDotUp) / tanHalfFov + pDotDir; - if (dNeeded > heightDist) heightDist = dNeeded; + if (tallest) { + for (const sx of [-0.5, 0.5]) { + for (const sz of [-0.5, 0.5]) { + _fitPoint(tallest.x + sx * tallest.w, tallest.h, tallest.y + sz * tallest.d); + } + } + } + // Include the floating repo-label panel so empty worlds (no + // buildings) still frame to show the label, and crowded worlds + // never crop it off the top edge. The panel billboards to face + // the camera, so its horizontal extent could rotate either way — + // sample the top corners along BOTH world axes to bound it. + if (labelBounds) { + const topY = labelBounds.centerY + labelBounds.halfHeight; + const r = labelBounds.halfWidth; + for (const [dx, dz] of [ + [-r, 0], + [r, 0], + [0, -r], + [0, r], + ]) { + _fitPoint(labelBounds.centerX + dx, topY, labelBounds.centerZ + dz); } } heightDist *= TALLEST_BUILDING_HEADROOM_MULT; diff --git a/app/src/scene/world.ts b/app/src/scene/world.ts index 4f68b6fc..77a1c2bb 100644 --- a/app/src/scene/world.ts +++ b/app/src/scene/world.ts @@ -1400,6 +1400,9 @@ export function createWorld(_canvas: HTMLCanvasElement) { getRootGem() { return rootGem; }, + getRepoLabelBounds() { + return _repoLabel.getPanelBounds(); + }, getRootGemBody() { return rootGemBody; }, diff --git a/app/src/styles.css b/app/src/styles.css index 7723ea96..cb51b338 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -144,6 +144,7 @@ h6 { --cc-success-dark: oklch(0.681 0.123 157.3); --cc-success-bg: color-mix(in oklch, var(--cc-success) 10%, transparent); --cc-warning: oklch(0.837 0.164 84.4); + --cc-warning-bg: color-mix(in oklch, var(--cc-warning) 15%, transparent); --cc-error: oklch(0.637 0.208 25.3); --cc-error-light: oklch(0.843 0.088 10.3); --cc-error-bg: color-mix(in oklch, var(--cc-error) 18%, transparent); @@ -988,11 +989,11 @@ canvas { } /* The project switcher button is now .btn-chip (defined with the button - components near the top of the file). This rule only styles its - ellipsis-truncated label child. */ + components near the top of the file). The label is allowed to grow + to the full project name — no ellipsis — so users always see what + they're switched to even for long owner/repo strings. */ .btn-chip-label { - overflow: hidden; - text-overflow: ellipsis; + white-space: nowrap; } .btn-chip .lucide-icon { font-size: var(--cc-font-xl); @@ -2693,6 +2694,13 @@ canvas { font-size: var(--cc-font-lg); color: var(--cc-text-muted); } +/* Checkbox labels wrap a checkbox + text on one line — flex-center + so the box sits on the text baseline instead of riding above it. */ +.modal-field label:has(> input[type='checkbox']) { + display: flex; + align-items: center; + gap: var(--cc-space-2); +} /* Visual style consolidated in the .modal-field input rule near the top of the file (next to .form-input). */ @@ -2706,6 +2714,40 @@ canvas { font-size: var(--cc-font-lg); } +/* Inline notice for a non-blocking advisory state (e.g. "local repos + are disabled"). Quieter than .modal-error: subtle amber background + tint, no outline border, body text uses the normal high-contrast + color so it's readable. The amber accent lives in the title + link. */ +.modal-warning { + background: var(--cc-warning-bg); + color: var(--cc-text-secondary); + padding: var(--cc-space-4) var(--cc-space-5); + border-radius: var(--cc-radius-md); + margin: var(--cc-space-3) 0 var(--cc-space-6); + font-size: var(--cc-font-lg); + line-height: 1.5; +} +.modal-warning strong { + display: block; + margin-bottom: var(--cc-space-2); + font-weight: var(--cc-fw-semibold); + color: var(--cc-warning); +} +.modal-warning p { + margin: var(--cc-space-2) 0; +} +.modal-warning code { + background: rgba(255, 255, 255, 0.08); + padding: 0 var(--cc-space-2); + border-radius: var(--cc-radius-sm); + font-size: var(--cc-font-md); + color: var(--cc-text-primary); +} +.modal-warning a { + color: var(--cc-warning); + text-decoration: underline; +} + .modal-actions { display: flex; margin-top: var(--cc-space-3); @@ -2773,6 +2815,15 @@ canvas { background: var(--cc-accent-bg); } +.recent-row--disabled { + opacity: 0.55; + cursor: not-allowed; +} +.recent-row--disabled:hover { + /* Suppress the hover affordance — the row is inert. */ + background: transparent; +} + /* Destructive trash button next to each recent — red icon at rest and on hover, with a red-tinted hover background. Vertical padding gives the icon breathing room and a generous vertical click target. */ diff --git a/app/src/types/manifest.ts b/app/src/types/manifest.ts index 046b8061..51685b2d 100644 --- a/app/src/types/manifest.ts +++ b/app/src/types/manifest.ts @@ -17,9 +17,8 @@ export enum NodeKind { } /** - * Git metadata for a file. ISO 8601 timestamps; null when no commit - * touching the file fell inside the active git_window (so the scanner - * never observed a create/modify date). + * Git metadata for a file. ISO 8601 timestamps; null when the scanner + * never observed a create/modify date for the file (e.g. uncommitted). */ export interface GitMeta { created: string | null; @@ -121,7 +120,7 @@ export interface Manifest { tree: DirNode; repo: RepoInfo; /** Per-commit metadata, oldest-first. `[]` when the repo has zero - * commits in the active git_window. */ + * commits. */ commits: CommitEntry[]; } diff --git a/app/src/utils/serverConfig.ts b/app/src/utils/serverConfig.ts new file mode 100644 index 00000000..251a2c14 --- /dev/null +++ b/app/src/utils/serverConfig.ts @@ -0,0 +1,43 @@ +// utils/serverConfig.ts — One-shot fetch of /api/config, memoized. +// +// Read once at boot in main.ts 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 +// path input that the server will reject anyway. + +export interface ServerConfig { + allowLocalRepos: boolean; +} + +const DISABLED: ServerConfig = { allowLocalRepos: false }; + +let _cached: Promise | null = null; + +export async function fetchServerConfig(): Promise { + try { + const resp = await fetch('/api/config'); + if (!resp.ok) return DISABLED; + const body = (await resp.json()) as Partial; + return { allowLocalRepos: !!body.allowLocalRepos }; + } catch (_) { + return DISABLED; + } +} + +/** + * Memoized variant. First call hits the network; subsequent calls + * return the cached promise. Use this from the picker and anywhere + * else that reads config — `fetchServerConfig` is exposed only for + * tests that want a fresh roundtrip. + */ +export function getServerConfig(): Promise { + if (_cached === null) _cached = fetchServerConfig(); + return _cached; +} + +/** Test-only: clear the memoized promise so successive tests can + * return different responses without leaking state. */ +export function _resetServerConfigForTests(): void { + _cached = null; +} diff --git a/app/src/utils/url.ts b/app/src/utils/url.ts index 419ca787..d578f802 100644 --- a/app/src/utils/url.ts +++ b/app/src/utils/url.ts @@ -7,11 +7,11 @@ export interface BuildApiUrlOpts { } /** - * Build the URL for a server endpoint, forwarding the page's `src` (and - * optional `branch` / `git_window`) 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". + * 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". */ export function buildApiUrl( endpoint: string, @@ -24,9 +24,6 @@ export function buildApiUrl( if (qp.has('src')) { u.searchParams.set('src', qp.get('src')!); if (qp.has('branch')) u.searchParams.set('branch', qp.get('branch')!); - // git_window only travels through with a source; without ?src= the - // endpoint is a no-op anyway, so don't bother forwarding it. - if (qp.has('git_window')) u.searchParams.set('git_window', qp.get('git_window')!); } if (opts.noCache) { u.searchParams.set('no_cache', 'true'); diff --git a/app/src/views/panes/infoPane.ts b/app/src/views/panes/infoPane.ts index 76a3896f..36b16f41 100644 --- a/app/src/views/panes/infoPane.ts +++ b/app/src/views/panes/infoPane.ts @@ -6,7 +6,7 @@ import { marked } from 'marked'; import { NodeKind } from '@/types'; -import type { DirNode, FileNode, Manifest } from '@/types'; +import type { DirNode, FileNode, Manifest, TreeNode } from '@/types'; import { makeLucideIcon } from '@/views/widgets/icon.js'; import { buildPaneHeader } from '@/views/shell/paneHeader.js'; @@ -14,6 +14,22 @@ import { buildPaneHeader } from '@/views/shell/paneHeader.js'; // stem (case-insensitive) is "readme". GitHub/VSCode use the same rule. const README_BASE_NAME = 'readme'; +// True when the manifest represents the cold-boot EMPTY_MANIFEST shape +// (root tree has no name and no children). Used to distinguish "no +// project loaded yet" from "project loaded but has no README" so the +// pane shows the right empty-state copy. +function _isEmptyManifest( + m: Manifest | DirNode | { tree?: unknown; [k: string]: unknown } | null +): boolean { + if (!m) return true; + const tree = (('tree' in m && (m as Manifest).tree) || m) as TreeNode | DirNode; + if (!tree.name) { + if (!('children' in tree)) return true; + return ((tree as DirNode).children?.length ?? 0) === 0; + } + return false; +} + function _findRootReadme(manifest: Manifest | DirNode | null): FileNode | null { if (!manifest) return null; const tree = @@ -79,6 +95,22 @@ export function buildInfoPane( body.appendChild(box); } + function _renderNoProjectState(): void { + body.replaceChildren(); + const box = document.createElement('div'); + box.className = 'empty-state empty-state--lg'; + box.appendChild(makeLucideIcon('folder-open')); + const h = document.createElement('p'); + h.className = 'text-card-title'; + h.textContent = 'No project loaded'; + box.appendChild(h); + const sub = document.createElement('p'); + sub.className = 'text-card-sub'; + sub.textContent = 'Open one to read its README.'; + box.appendChild(sub); + body.appendChild(box); + } + function _renderError(message: string): void { body.replaceChildren(); const box = document.createElement('div'); @@ -115,6 +147,10 @@ export function buildInfoPane( function render( currentManifest: Manifest | DirNode | { tree?: unknown; [k: string]: unknown } | null ): void { + if (_isEmptyManifest(currentManifest)) { + _renderNoProjectState(); + return; + } const readme = _findRootReadme(currentManifest as Manifest | DirNode | null); if (!readme || !readme.fullPath) { _renderEmptyState(); diff --git a/app/src/views/panes/treePane.ts b/app/src/views/panes/treePane.ts index c17ba160..2cff50b3 100644 --- a/app/src/views/panes/treePane.ts +++ b/app/src/views/panes/treePane.ts @@ -289,14 +289,49 @@ export function buildTreePane( const listEl = document.createElement('ul'); listEl.className = 'tree-list tree-root'; ctx.rootList = listEl; - const rootItem = _buildItem(tree, ctx, true); - ctx.rootDirLi = rootItem; - listEl.appendChild(rootItem); + + // Empty-state card shown when the tree has no children — either no + // project loaded (cold-boot EMPTY_MANIFEST) or a loaded project with + // no files (e.g. a brand-new repo). Lives in the pane alongside the + // tree list; visibility + copy toggle on every render so live-update + // and source-switch both stay coherent. Without this, both cases + // rendered a lone unlabeled gem in the sidebar. + const empty = _buildEmptyState(); + pane.appendChild(empty.el); pane.appendChild(listEl); + _renderTree(tree); + let currentSelectedLi: HTMLLIElement | null = null; let currentHoveredLi: HTMLLIElement | null = null; + // Render `tree` into listEl OR show the empty state — toggle in lockstep + // so the pane shows exactly one of them at a time. "Empty" means no + // children, regardless of whether the project itself has a name — + // both the no-project state and the loaded-but-empty-repo state get + // an empty-state card (with different copy). + function _renderTree(tree: TreeNode): void { + const noChildren = !('children' in tree) || ((tree as DirNode).children?.length ?? 0) === 0; + if (noChildren) { + if (tree.name) { + empty.titleEl.textContent = 'Empty repository'; + empty.subEl.textContent = 'This project has no files yet.'; + } else { + empty.titleEl.textContent = 'No project loaded'; + empty.subEl.textContent = 'Open one to explore its file tree.'; + } + empty.el.style.display = ''; + listEl.style.display = 'none'; + ctx.rootDirLi = null; + return; + } + empty.el.style.display = 'none'; + listEl.style.display = ''; + const rootItem = _buildItem(tree, ctx, true); + ctx.rootDirLi = rootItem; + listEl.appendChild(rootItem); + } + // Rebuild the tree DOM from a fresh manifest. Used after applyManifest // swaps in a new tree (e.g. live-update poll picking up new files). // Without this the sidebar shows the snapshot captured at init and @@ -307,9 +342,7 @@ export function buildTreePane( currentSelectedLi = null; currentHoveredLi = null; const next = ((m as { tree?: unknown }).tree || m) as TreeNode; - const nextRoot = _buildItem(next, ctx, true); - ctx.rootDirLi = nextRoot; - listEl.appendChild(nextRoot); + _renderTree(next); } function setSelectedPath(path: string | null): void { @@ -356,3 +389,27 @@ export function buildTreePane( }, }; } + +/** + * Empty-state card for the Explorer pane — centered icon + title + + * subtitle. Title + subtitle nodes are exposed so the caller can swap + * copy between the no-project and empty-repo cases without rebuilding + * the element. Follows the existing `.empty-state` design pattern used + * by the file-preview and info panes. + */ +function _buildEmptyState(): { + el: HTMLElement; + titleEl: HTMLElement; + subEl: HTMLElement; +} { + const box = document.createElement('div'); + box.className = 'empty-state empty-state--lg'; + box.appendChild(makeLucideIcon('folder-open')); + const titleEl = document.createElement('p'); + titleEl.className = 'text-card-title'; + box.appendChild(titleEl); + const subEl = document.createElement('p'); + subEl.className = 'text-card-sub'; + box.appendChild(subEl); + return { el: box, titleEl, subEl }; +} diff --git a/app/src/views/shell/appHeader.ts b/app/src/views/shell/appHeader.ts index 7750eed9..7a877078 100644 --- a/app/src/views/shell/appHeader.ts +++ b/app/src/views/shell/appHeader.ts @@ -238,8 +238,10 @@ export function initAppHeader(opts: InitAppHeaderOpts = {}) { _repoLinkEl = a; _projectBtn.parentElement.insertBefore(_repoLinkEl, _projectBtn.nextSibling); } - _repoLinkEl.href = toHttpsRepoUrl(_sourceUrl); - _repoLinkEl.title = `Open repo: ${_sourceUrl}`; + const baseUrl = toHttpsRepoUrl(_sourceUrl); + const url = _branch ? _withBranchPath(baseUrl, _branch) : baseUrl; + _repoLinkEl.href = url; + _repoLinkEl.title = _branch ? `Open repo at @${_branch}` : `Open repo: ${_sourceUrl}`; } else if (_repoLinkEl) { _repoLinkEl.remove(); _repoLinkEl = null; @@ -252,6 +254,14 @@ export function initAppHeader(opts: InitAppHeaderOpts = {}) { if (_projectLabelEl) _projectLabelEl.textContent = _rootLabel; } + // First-time project load: create the button now that we have + // something to show. The button stays hidden on cold boot (when + // _rootLabel is empty) so the header doesn't have an empty chip + // dangling next to the gem. + if (!_projectBtn && _rootLabel) { + _createProjectButton(); + } + _branch = branch; if (_projectBtn) { if (_branchPillEl) { @@ -298,8 +308,10 @@ export function initAppHeader(opts: InitAppHeaderOpts = {}) { } // Project button — sits at the far left of the header row, prepended - // before the title/breadcrumb slot. Contains: icon + project label + branch pill. - { + // before the title/breadcrumb slot. Contains: icon + project label + + // branch pill. Created lazily so the header stays clean on cold boot + // (no project loaded → no empty chip dangling next to the gem). + function _createProjectButton(): void { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn-chip'; @@ -330,15 +342,24 @@ export function initAppHeader(opts: InitAppHeaderOpts = {}) { _projectBtn = btn; btn.dataset.appHeaderInjected = '1'; - titleEl.parentElement?.prepend(btn); + // Anchor right before the title slot so the order is [gem][project] + // [(link)][title] regardless of how many far-left buttons (currently + // just the reset-view gem) live before the project chip. + titleEl!.parentElement?.insertBefore(btn, titleEl!); // Render the open-repo link IMMEDIATELY AFTER the project button if a - // git source URL was passed at init. _syncRepoLink relies on _projectBtn - // being inserted into the DOM (it positions the link via .nextSibling), - // so this call must happen after the prepend above. + // git source URL was set. _syncRepoLink relies on _projectBtn being + // inserted into the DOM (it positions the link via .nextSibling), so + // this call must happen after the insert above. _syncRepoLink(); } + // Only render the button at init if there's already a project loaded — + // otherwise wait for setSourceInfo() to fire on first manifest. + if (_rootLabel) { + _createProjectButton(); + } + // Reset-view button — sits at the FAR LEFT of the header row, prepended // before the project button. Same action as the R keyboard shortcut // and as clicking the gem in the city: reset the camera view. Does NOT @@ -392,6 +413,38 @@ function _copy(text: string, btn: HTMLButtonElement): void { } } +/** + * Append a branch-tree path to a forge HTTPS URL so the external-link + * icon opens the branch instead of the repo root. Path conventions + * vary by forge: + * github.com / sr.ht → /tree/ + * gitlab.com → /-/tree/ + * bitbucket.org → /src/ + * codeberg.org + Forgejo + Gitea hosts → /src/branch/ + * + * Self-hosted Forgejo / Gitea instances live on arbitrary hostnames; + * we match by the host containing "forgejo" or "gitea" as a best-effort + * shorthand (works for e.g. forgejo.example.com or git.gitea.io, but + * not for fully-renamed instances). When nothing matches, return the + * base URL — better to land on the repo than to ship a broken 404. + */ +function _withBranchPath(repoHttpsUrl: string, branch: string): string { + const ref = encodeURIComponent(branch); + if (/codeberg\.org|forgejo|gitea/i.test(repoHttpsUrl)) { + return `${repoHttpsUrl}/src/branch/${ref}`; + } + if (/github\.com|sr\.ht/i.test(repoHttpsUrl)) { + return `${repoHttpsUrl}/tree/${ref}`; + } + if (/gitlab\.com/i.test(repoHttpsUrl)) { + return `${repoHttpsUrl}/-/tree/${ref}`; + } + if (/bitbucket\.org/i.test(repoHttpsUrl)) { + return `${repoHttpsUrl}/src/${ref}`; + } + return repoHttpsUrl; +} + function _legacyCopy(text: string): void { const ta = document.createElement('textarea'); ta.value = text; diff --git a/app/src/views/shell/leftSidebar.ts b/app/src/views/shell/leftSidebar.ts index d0139f16..67a8c6a3 100644 --- a/app/src/views/shell/leftSidebar.ts +++ b/app/src/views/shell/leftSidebar.ts @@ -11,7 +11,7 @@ import { buildControlsPane } from '@/views/panes/controlsPane.js'; import { buildSearchPane } from '@/views/panes/searchPane.js'; import { ACTIVITY_BAR_TABS, DOM_IDS, LUCIDE_ICON_BASE_URL, STORAGE_KEYS } from '@/constants'; import { SidebarTab } from '@/types'; -import type { Manifest, TreeNode } from '@/types'; +import type { DirNode, Manifest, TreeNode } from '@/types'; import { loadFlag, saveFlag } from '../source/localFlag.js'; const SIDEBAR_MIN_WIDTH = 280; @@ -129,7 +129,16 @@ export function showLeftSidebar( let activeTab: SidebarTab = opts.initialTab === SidebarTab.Controls ? SidebarTab.Controls : SidebarTab.Tree; - let collapsed = loadFlag(STORAGE_KEYS.SIDEBAR_COLLAPSED, false); + // Empty-manifest cold boot: force the sidebar collapsed so the user + // isn't staring at an empty Explorer pane behind the picker modal. + // The persisted preference is restored automatically on the next + // mount once a project is loaded (showLeftSidebar is re-run by the + // boot path when applyManifest swaps in a real manifest). + const _treeRoot = ((manifest as { tree?: unknown }).tree || manifest) as TreeNode | DirNode; + const _manifestIsEmpty = + !('children' in _treeRoot) || + (((_treeRoot as DirNode).children?.length ?? 0) === 0 && !_treeRoot.name); + let collapsed = _manifestIsEmpty ? true : loadFlag(STORAGE_KEYS.SIDEBAR_COLLAPSED, false); const iconBtns: Record = {}; // The activity bar splits into a top group (tabs that stack from the diff --git a/app/src/views/source/sourcePicker.ts b/app/src/views/source/sourcePicker.ts index 43a415cc..4acd709e 100644 --- a/app/src/views/source/sourcePicker.ts +++ b/app/src/views/source/sourcePicker.ts @@ -38,44 +38,11 @@ function _hostingIconSvg(src: string): string { export interface SourcePayload { src: string; branch?: string; - // Per-source git-log history window. Forwarded to the server as the - // ?git_window= query param. Undefined or "" = walk all history - // (server default). Accepts any `git log --since=…` expression; the - // dropdown below exposes a handful of presets but the field is - // otherwise free-form if a caller wants to set it programmatically. - gitWindow?: string; /** When true, this open forces a fresh scan (server-side ?no_cache=1). * Not persisted — re-opening from a recent uses cached scan by default. */ skipCache?: boolean; } -// Presets shown in the git tab's "History window" dropdown. The label -// is what the user sees; the value is what we send to `git log --since`. -// "All history" sends an empty string → server omits --since entirely. -interface GitWindowOption { - label: string; - value: string; -} -const GIT_WINDOW_OPTIONS: GitWindowOption[] = [ - { label: '1 year ago', value: '1.years.ago' }, - { label: '3 years ago', value: '3.years.ago' }, - { label: '5 years ago', value: '5.years.ago' }, - { label: '10 years ago', value: '10.years.ago' }, - { label: 'All history (default)', value: '' }, -]; -const DEFAULT_GIT_WINDOW = ''; - -/** Map a raw `git_window` value back to its human-readable label, or - * return the raw value when nothing matches (the field accepts any - * free-form `git log --since=…` expression). */ -function gitWindowLabel(value: string | undefined | null): string { - if (!value) return ''; - const preset = GIT_WINDOW_OPTIONS.find((o) => o.value === value); - if (!preset) return value; - // Strip the "(default)" suffix so it doesn't leak into recent rows. - return preset.label.replace(/\s*\(default\)\s*$/i, ''); -} - export interface OpenOpts { prefill?: SourcePayload; dismissible?: boolean; // default: false @@ -87,7 +54,10 @@ export interface SourcePicker { close(): void; } -export function createSourcePicker(opts: { onSubmit: (s: SourcePayload) => void }): SourcePicker { +export function createSourcePicker(opts: { + onSubmit: (s: SourcePayload) => void; + allowLocalRepos: boolean; +}): SourcePicker { const root = document.getElementById('source-picker-root'); if (!root) { return { open: () => {}, close: () => {} }; @@ -95,6 +65,7 @@ export function createSourcePicker(opts: { onSubmit: (s: SourcePayload) => void let dismissible = false; let activeTab: 'local' | 'git' = 'git'; + const allowLocalRepos = opts.allowLocalRepos; function isGitLike(s: string): boolean { return /:\/\//.test(s) || /^[^@]+@[^:]+:/.test(s); @@ -102,6 +73,7 @@ export function createSourcePicker(opts: { onSubmit: (s: SourcePayload) => void function deriveTabFromPrefill(p?: SourcePayload): 'local' | 'git' { if (!p) return 'git'; + if (!allowLocalRepos) return 'git'; return isGitLike(p.src) ? 'git' : 'local'; } @@ -110,7 +82,6 @@ export function createSourcePicker(opts: { onSubmit: (s: SourcePayload) => void activeTab = deriveTabFromPrefill(o.prefill); const prefillSrc = o.prefill?.src ?? ''; const prefillBranch = o.prefill?.branch ?? ''; - const prefillWindow = o.prefill?.gitWindow ?? DEFAULT_GIT_WINDOW; // Read current source from URL so we can mark the matching recent as active const urlParams = new URLSearchParams(window.location.search); @@ -152,46 +123,45 @@ export function createSourcePicker(opts: { onSubmit: (s: SourcePayload) => void
+ ${ + allowLocalRepos + ? ` +
` + : ` + ` + } - -