diff --git a/api/models/responses.py b/api/models/responses.py index 0fda90c13..e3998308a 100644 --- a/api/models/responses.py +++ b/api/models/responses.py @@ -39,6 +39,7 @@ class HealthResponse(BaseModel): class ConfigResponse(BaseModel): allowLocalRepos: bool maxBatchPaths: int + version: str class CommitDetailResponse(BaseModel): diff --git a/api/routers/meta.py b/api/routers/meta.py index 0c97813ef..5308f7bdf 100644 --- a/api/routers/meta.py +++ b/api/routers/meta.py @@ -6,6 +6,7 @@ from fastapi import APIRouter +from api import __version__ from api.config import MAX_BATCH_PATHS, local_repos_allowed from api.models.responses import ConfigResponse, HealthResponse @@ -22,4 +23,5 @@ def config() -> ConfigResponse: return ConfigResponse( allowLocalRepos=local_repos_allowed(), maxBatchPaths=MAX_BATCH_PATHS, + version=__version__, ) diff --git a/api/tests/test_models.py b/api/tests/test_models.py index 5999c1246..4419a9db4 100644 --- a/api/tests/test_models.py +++ b/api/tests/test_models.py @@ -169,8 +169,10 @@ def test_health_and_config(self) -> None: self.assertEqual(HealthResponse(ok=True).model_dump(), {"ok": True}) self.assertEqual( - ConfigResponse(allowLocalRepos=False, maxBatchPaths=64).model_dump(), - {"allowLocalRepos": False, "maxBatchPaths": 64}, + ConfigResponse( + allowLocalRepos=False, maxBatchPaths=64, version="1.2.3" + ).model_dump(), + {"allowLocalRepos": False, "maxBatchPaths": 64, "version": "1.2.3"}, ) def test_sse_event_serialization(self) -> None: diff --git a/api/tests/test_server_config.py b/api/tests/test_server_config.py index 4f80ea4ea..5a118d38c 100644 --- a/api/tests/test_server_config.py +++ b/api/tests/test_server_config.py @@ -20,18 +20,24 @@ def client(tmp_path: Path) -> TestClient: def test_config_enabled(client: TestClient, monkeypatch) -> None: + from api import __version__ + monkeypatch.setenv("CODECITY_ALLOW_LOCAL_REPOS", "1") assert client.get("/api/config").json() == { "allowLocalRepos": True, "maxBatchPaths": MAX_BATCH_PATHS, + "version": __version__, } def test_config_disabled(client: TestClient, monkeypatch) -> None: + from api import __version__ + monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) assert client.get("/api/config").json() == { "allowLocalRepos": False, "maxBatchPaths": MAX_BATCH_PATHS, + "version": __version__, } @@ -41,3 +47,13 @@ def test_config_publishes_the_cap_the_batch_routes_enforce() -> None: from api.routers import file as file_router assert file_router.MAX_BATCH_PATHS == MAX_BATCH_PATHS + + +def test_config_reports_the_running_package_version(client: TestClient) -> None: + """The footer shows which build is running, so /api/config carries the + package version rather than the client guessing from a bundled constant.""" + from api import __version__ + + body = client.get("/api/config").json() + assert body["version"] == __version__ + assert body["version"] diff --git a/api/tests/test_server_health.py b/api/tests/test_server_health.py index 3534cf6e5..1ce828a16 100644 --- a/api/tests/test_server_health.py +++ b/api/tests/test_server_health.py @@ -34,6 +34,8 @@ def test_config_default_disabled( # The conftest sets CODECITY_ALLOW_LOCAL_REPOS=1 session-wide for # tests that exercise local scan paths. Override it here to verify the # default-disabled state that the real endpoint exposes. + from api import __version__ + monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False) static = tmp_path / "static" static.mkdir() @@ -41,7 +43,11 @@ def test_config_default_disabled( app = create_app(static_dir=static) r = TestClient(app).get("/api/config") assert r.status_code == 200 - assert r.json() == {"allowLocalRepos": False, "maxBatchPaths": MAX_BATCH_PATHS} + assert r.json() == { + "allowLocalRepos": False, + "maxBatchPaths": MAX_BATCH_PATHS, + "version": __version__, + } def test_root_serves_index(client: TestClient) -> None: diff --git a/app/src/api/config.ts b/app/src/api/config.ts index c17585b19..5b2081e80 100644 --- a/app/src/api/config.ts +++ b/app/src/api/config.ts @@ -9,10 +9,12 @@ import type { components } from '@/types/manifest.generated'; export type ServerConfig = components['schemas']['ConfigResponse']; // Pre-boot defaults. maxBatchPaths guesses low: too high silently truncates a -// batch's tail, too low only costs an extra request. +// batch's tail, too low only costs an extra request. `version` matches the +// backend's own metadata-lookup fallback. export const DEFAULT_SERVER_CONFIG: ServerConfig = { allowLocalRepos: false, maxBatchPaths: 16, + version: '0.0.0+unknown', }; let _cached: Promise | null = null; @@ -38,6 +40,7 @@ export async function fetchServerConfig(): Promise { ...(typeof body.maxBatchPaths === 'number' && body.maxBatchPaths > 0 ? { maxBatchPaths: body.maxBatchPaths } : {}), + ...(typeof body.version === 'string' && body.version ? { version: body.version } : {}), }; } catch (_) { return DEFAULT_SERVER_CONFIG; diff --git a/app/src/city/interaction/inputHandlers.ts b/app/src/city/interaction/inputHandlers.ts index 674f2326e..be99e2ea0 100644 --- a/app/src/city/interaction/inputHandlers.ts +++ b/app/src/city/interaction/inputHandlers.ts @@ -20,7 +20,7 @@ import { MODAL_OPEN } from '@/state/stores/ui'; import { NodeKind } from '@/types'; import { scrubbedStatsFor } from '@/state/stores/presentPaths'; import type { PickTarget } from '@/types'; -import { formatHoverTooltip, isDeletedTarget } from './tooltipText'; +import { hoverTooltipContent, type TooltipContent } from './tooltipText'; import type { createPicker } from './picker'; import type { createCameraRig } from '../render/cameraRig'; import type { CityState } from '../state'; @@ -41,7 +41,7 @@ export function createInputHandlers({ rig: ReturnType; renderer: THREE.WebGLRenderer; cityState: CityState; - showTooltip: (text: string, x: number, y: number, deleted?: boolean) => void; + showTooltip: (content: TooltipContent, x: number, y: number) => void; hideTooltip: () => void; onResize: () => void; /** Reset-view action triggered by R / gem-click. Does NOT rebuild the @@ -112,9 +112,9 @@ export function createInputHandlers({ newHover?.kind === NodeKind.File && newHover.file?.path != null ? (scrubbedStatsFor(newHover.file.path)?.lines ?? null) : null; - const tooltipText = formatHoverTooltip(newHover, rootName, scrubLines); + const tooltipText = hoverTooltipContent(newHover, rootName, scrubLines); if (tooltipText) { - showTooltip(tooltipText, e.clientX, e.clientY, isDeletedTarget(newHover)); + showTooltip(tooltipText, e.clientX, e.clientY); canvas.style.cursor = 'pointer'; } else { hideTooltip(); @@ -148,7 +148,7 @@ export function createInputHandlers({ } if (hit.object.userData.type === NodeKind.Gem) { picker.setSelection(null); - // Gem click = reset view, same as the R key and the header gem button. + // Gem click = reset view, same as the R key. onResetView(); return; } diff --git a/app/src/city/interaction/tooltip.ts b/app/src/city/interaction/tooltip.ts index 23a05797b..f53557cd5 100644 --- a/app/src/city/interaction/tooltip.ts +++ b/app/src/city/interaction/tooltip.ts @@ -1,8 +1,7 @@ -// city/interaction/tooltip.ts — Tiny floating label that follows the cursor -// on hover. Shown when the user is hovering a building/street; hidden -// otherwise. Inspired by Cities: Skylines / SimCity — every interactive -// object has a brief name label so the city feels alive without forcing -// a sidebar open. +// city/interaction/tooltip.ts — Tiny floating card that follows the cursor on +// hover. Shown when the user is hovering a building/street/commit; hidden +// otherwise. Inspired by Cities: Skylines / SimCity — every interactive object +// has a brief label so the city feels alive without forcing a sidebar open. // // The imperative showTooltip/moveTooltip/hideTooltip API is kept for the // still-vanilla scene code (picker, inputHandlers) that calls it directly. @@ -10,6 +9,8 @@ // driven by Three.js pointer events rather than React-tree state — the // imperative API IS the right interface for this component. +import type { TooltipContent } from './tooltipText'; + // Tooltip placement — fixed, not user-tunable. const TOOLTIP_OFFSET_PX = 14; const TOOLTIP_VIEWPORT_MARGIN_PX = 4; @@ -21,31 +22,46 @@ function _ensure(): HTMLElement { if (_el && _el.isConnected) return _el; _el = document.createElement('div'); _el.id = 'hover-tooltip'; - _el.className = 'card-tooltip'; + _el.className = 'card-tooltip surface-glass'; _el.style.display = 'none'; document.body.appendChild(_el); return _el; } -// showTooltip(text, x, y, deleted?) — show with the given text, positioned near -// cursor (with auto-clamp to viewport so it doesn't bleed off-screen). When -// `deleted`, lead with a red "deleted" badge so a ruin reads at a glance. -export function showTooltip(text: string, x: number, y: number, deleted = false): void { +function _line(cls: string, text: string): HTMLElement { + const line = document.createElement('div'); + line.className = cls; + line.textContent = text; + return line; +} + +/** + * Show the tooltip near the cursor, clamped so it can't leave the viewport. + * Three stacked lines: identity, location, stats. + */ +export function showTooltip(content: TooltipContent, x: number, y: number): void { const el = _ensure(); - if (deleted) { - el.textContent = ''; + el.textContent = ''; + + const title = _line('tooltip-title', content.title); + if (content.deleted) { const badge = document.createElement('span'); badge.className = 'tooltip-deleted'; badge.textContent = 'deleted'; - el.append(badge, document.createTextNode(` · ${text}`)); - } else { - el.textContent = text; + title.prepend(badge, document.createTextNode(' ')); } + el.append(title); + + if (content.path) el.append(_line('tooltip-path', content.path)); + if (content.stats.length > 0) { + el.append(_line('tooltip-stats', content.stats.join(' · '))); + } + el.style.display = 'block'; moveTooltip(x, y); } -// moveTooltip(x, y) — reposition without changing text. Cheap; safe to call +// moveTooltip(x, y) — reposition without changing content. Cheap; safe to call // on every pointermove. export function moveTooltip(x: number, y: number): void { if (!_el) return; @@ -55,10 +71,18 @@ export function moveTooltip(x: number, y: number): void { const h = _el.offsetHeight; const vw = window.innerWidth; const vh = window.innerHeight; + + // Prefer below-right of the cursor, flipping to the other side when that + // would overflow. The final clamp matters independently: a card wider than + // the space on either side overflows whichever way it is flipped, and + // flipping alone would push it off the opposite edge. let px = x + OFFSET; let py = y + OFFSET; if (px + w + MARGIN > vw) px = x - OFFSET - w; if (py + h + MARGIN > vh) py = y - OFFSET - h; + px = Math.min(Math.max(px, MARGIN), Math.max(MARGIN, vw - w - MARGIN)); + py = Math.min(Math.max(py, MARGIN), Math.max(MARGIN, vh - h - MARGIN)); + _el.style.left = `${px}px`; _el.style.top = `${py}px`; } diff --git a/app/src/city/interaction/tooltipText.ts b/app/src/city/interaction/tooltipText.ts index a00d78b2d..dd28ddad6 100644 --- a/app/src/city/interaction/tooltipText.ts +++ b/app/src/city/interaction/tooltipText.ts @@ -1,75 +1,123 @@ -// city/interaction/tooltipText.ts — Pure formatting for the hover tooltip -// label. Given a pick target and the project's root directory name, returns -// the one-line string shown next to the cursor (or null when nothing should -// show). Kept side-effect-free so it's unit-testable in isolation; the live -// wiring in inputHandlers.ts reads the root name off the manifest signal and -// hands it in. +// city/interaction/tooltipText.ts — Pure content for the hover tooltip. Given a +// pick target and the project's root directory name, returns the three parts the +// renderer draws (or null when nothing should show). Side-effect-free so it's +// unit-testable in isolation; the live wiring in inputHandlers.ts reads the root +// name off the manifest signal and hands it in. +// +// Stats come from the same builders the selection pane uses, so hovering a +// building and selecting it cannot report different numbers. import { NodeKind } from '@/types'; import type { PickTarget } from '@/types'; import { formatRelativeAge } from '@/utils/dates'; import { ROOT_PATH } from '@/constants/manifest'; +import { fileStatItems, directoryStatItems } from '@/components/PaneStats/statItems'; -// Prepend the root directory name (with a leading slash) to a manifest- -// relative path so the tooltip reads as an absolute-looking path (e.g. -// "/codecity/app/main.ts" rather than "app/main.ts"). The root's own path is -// ROOT_PATH — we render just "/codecity", not "codecity/.". +/** Longest path rendered before the middle segments collapse to an ellipsis. */ +const PATH_BUDGET_CHARS = 44; + +export interface TooltipContent { + /** Identity line: a filename, a folder name, or a commit subject. */ + title: string; + /** Location line, already truncated. Absent where a path means nothing. */ + path?: string; + /** Third line, joined with separators by the renderer. */ + stats: string[]; + /** Ruin at the scrubbed commit: the renderer leads with a red badge. */ + deleted: boolean; +} + +// Prepend the root directory name (with a leading slash) to a manifest-relative +// path so it reads as absolute-looking (e.g. "/codecity/app" rather than "app"). +// The root's own path is ROOT_PATH — render just "/codecity", not "codecity/.". function withRoot(relPath: string, rootName: string | null): string { if (!rootName) return relPath || ''; if (!relPath || relPath === ROOT_PATH) return `/${rootName}`; return `/${rootName}/${relPath}`; } -export function formatHoverTooltip( +/** The path minus its last segment: what the title line already shows. */ +function parentOf(relPath: string): string { + const cut = relPath.lastIndexOf('/'); + return cut === -1 ? '' : relPath.slice(0, cut); +} + +/** + * Drop whole segments from the middle until the path fits the budget, keeping + * the first and last. Truncating the tail instead would hide the segment + * nearest the file, which is the informative end. + */ +export function middleTruncatePath(path: string, budget = PATH_BUDGET_CHARS): string { + if (path.length <= budget) return path; + const lead = path.startsWith('/') ? '/' : ''; + const segments = path.slice(lead.length).split('/'); + if (segments.length <= 2) return path; + + // Walk inward from the middle, dropping one segment at a time. + const kept = segments.slice(); + while (kept.length > 2) { + kept.splice(Math.floor(kept.length / 2), 1); + const candidate = `${lead}${kept[0]}/…/${kept.slice(1).join('/')}`; + if (candidate.length <= budget) return candidate; + } + return `${lead}${kept[0]}/…/${kept[kept.length - 1]}`; +} + +export function hoverTooltipContent( target: PickTarget | null, rootName: string | null, // Timeline: lines at the scrubbed commit, or at deletion for a file already gone. scrubLines?: number | null -): string | null { +): TooltipContent | null { if (!target) return null; + const deleted = isDeletedTarget(target); + if (target.kind === NodeKind.Gem) { - // The gem represents the project root and also acts as the reset - // button — clicking it clears the selection and recenters the - // camera. Show both so the affordance is discoverable. - return `${withRoot('', rootName)} · click to reset view`; + // The gem represents the project root and also acts as the reset button, so + // name the affordance to keep it discoverable. + return { title: rootName ?? 'project', stats: ['click to reset view'], deleted: false }; } + if (target.kind === NodeKind.Commit) { const c = target.commit; - const shortSha = c.sha.slice(0, 7); - const filesLabel = `${c.files} file${c.files === 1 ? '' : 's'}`; - return `commit ${shortSha} · ${formatRelativeAge(c.date)} · ${filesLabel}`; + const authors = c.authors.length > 0 ? c.authors[0] : null; + const stats = [c.sha.slice(0, 7)]; + if (authors) stats.push(authors); + stats.push(formatRelativeAge(c.date)); + stats.push(`${c.files} file${c.files === 1 ? '' : 's'}`); + return { title: c.subject || `commit ${c.sha.slice(0, 7)}`, stats, deleted: false }; } + if (target.kind === NodeKind.File && target.file) { const f = target.file; - const fpath = withRoot(f.path || f.name || 'file', rootName); - // Media files (images/video) are binary, so their line count is a - // meaningless 0 — surface pixel dimensions instead. The backend only - // stamps media_width/height on recognized media, so their presence is a - // reliable "this is dimensioned media" signal. - if (f.media_width != null && f.media_height != null) { - return `${fpath} · ${f.media_width}×${f.media_height}`; - } - const lines = scrubLines ?? f.lines; - return fpath + (lines != null ? ` · ${lines} lines` : ''); + const rel = f.path || f.name || 'file'; + const parent = parentOf(rel); + // scrubLines wins where the replay has a value for this commit. + const node = scrubLines != null ? { ...f, lines: scrubLines } : f; + return { + title: f.name || rel, + path: middleTruncatePath(withRoot(parent, rootName)), + stats: fileStatItems(node, { dates: false }).map((i) => i.text), + deleted, + }; } + if (target.kind === NodeKind.Directory && target.dir) { const d = target.dir; - const dpath = withRoot(d.path || d.name || '', rootName); - // Show immediate-child counts (not descendants) — the tooltip is a - // quick "what's directly inside here", not a subtree summary. - const fileCount = d.children_file_count != null ? d.children_file_count : 0; - const dirCount = d.children_dir_count != null ? d.children_dir_count : 0; - const counts = `${fileCount} file${fileCount === 1 ? '' : 's'}, ${dirCount} dir${ - dirCount === 1 ? '' : 's' - }`; - return `${dpath || 'directory'} · ${counts}`; + const rel = d.path || d.name || ''; + const isRoot = !rel || rel === ROOT_PATH; + return { + title: isRoot ? (rootName ?? 'root') : d.name || rel, + path: isRoot ? undefined : middleTruncatePath(withRoot(parentOf(rel), rootName)), + stats: directoryStatItems(d).map((i) => i.text), + deleted, + }; } + return null; } // Whether a hovered file/dir is a ghost-ruin (deleted at the scrubbed commit). -// The tooltip renderer leads with a red "deleted" badge for these, so the marker -// lives with the render, not in this identity-text formatter. export function isDeletedTarget(target: PickTarget | null): boolean { if (target?.kind === NodeKind.File || target?.kind === NodeKind.Directory) { return Boolean(target.isRuin); diff --git a/app/src/components/LoadingOverlay/LoadingOverlay.css b/app/src/components/LoadingOverlay/LoadingOverlay.css index 9950f7e38..a5055616c 100644 --- a/app/src/components/LoadingOverlay/LoadingOverlay.css +++ b/app/src/components/LoadingOverlay/LoadingOverlay.css @@ -2,7 +2,8 @@ /* The deep-link cold-boot surface: a centered card over a full-viewport backdrop. The inner column comes from (LoadingProgress.css); - this only owns the card shell. Surface from .card-overlay. */ + this only owns the card shell. Shape from .card-overlay, fill from + .surface-glass. */ .loading-card { display: flex; flex-direction: column; diff --git a/app/src/components/LoadingOverlay/LoadingOverlay.tsx b/app/src/components/LoadingOverlay/LoadingOverlay.tsx index df6ae82af..62bbc7073 100644 --- a/app/src/components/LoadingOverlay/LoadingOverlay.tsx +++ b/app/src/components/LoadingOverlay/LoadingOverlay.tsx @@ -25,7 +25,7 @@ export function LoadingOverlay({ onCancel }: LoadingOverlayProps) { return (
-
+
void; } -const LOCAL_DOCS_URL = 'https://github.com/thalida/codecity#local-directories'; +const LOCAL_DOCS_URL = `${REPO_URL}#local-directories`; export function NewProjectForm({ allowLocalRepos, diff --git a/app/src/components/PaneStats/PaneStats.css b/app/src/components/PaneStats/PaneStats.css new file mode 100644 index 000000000..5271149de --- /dev/null +++ b/app/src/components/PaneStats/PaneStats.css @@ -0,0 +1,30 @@ +/* Stat row pinned below a pane's scrolling body. Sits outside .pane-body, so + it stays put while the content scrolls under it. Its top rule mirrors the + header's bottom rule, bracketing the scroll region. */ + +.pane-stats { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--cc-space-3); + padding: var(--cc-space-4) var(--cc-pane-inset); + border-top: 1px solid var(--cc-border-subtle); + font-family: var(--cc-font-mono); + font-size: var(--cc-font-2xs); + color: var(--cc-text-secondary); +} + +.pane-stats-item { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Pointer-events off so a stray click between items can't land on the dot. */ +.pane-stats-sep { + flex: 0 0 auto; + color: var(--cc-text-faint); + pointer-events: none; +} diff --git a/app/src/components/PaneStats/PaneStats.tsx b/app/src/components/PaneStats/PaneStats.tsx new file mode 100644 index 000000000..0f73f03e6 --- /dev/null +++ b/app/src/components/PaneStats/PaneStats.tsx @@ -0,0 +1,35 @@ +// components/PaneStats.tsx — The stat row pinned to the bottom of a selection +// pane, passed through . Mirrors : one definition +// of the row so the file and road panes read identically. +// +// Items are supplied by the calling pane, which owns the node and therefore +// knows which stats apply. A single row that wraps rather than a grid, so a +// file's five items and a road's four occupy the same shell. + +import './PaneStats.css'; + +export interface PaneStatItem { + text: string; + /** Hover tooltip, for a value the short form abbreviates (an exact date). */ + title?: string; +} + +export interface PaneStatsProps { + items: PaneStatItem[]; +} + +export function PaneStats({ items }: PaneStatsProps) { + if (items.length === 0) return null; + return ( +
+ {items.map((item, i) => ( + <> + {i > 0 && ·} + + {item.text} + + + ))} +
+ ); +} diff --git a/app/src/components/PaneStats/statItems.ts b/app/src/components/PaneStats/statItems.ts new file mode 100644 index 000000000..a516c2617 --- /dev/null +++ b/app/src/components/PaneStats/statItems.ts @@ -0,0 +1,86 @@ +// components/PaneStats/statItems.ts — Builds the stat rows the file and road +// panes hand to . Pure: the caller supplies the node, so the same +// formatting rules serve both panes without either reaching for the picker. + +import type { FileNode, DirNode } from '@/types'; +import { formatShortDate, formatRelativeAgeShort } from '@/utils/dates'; +import { formatBytes } from '@/utils/bytes'; +import { humanLanguageFor } from '@/utils/syntaxLanguages'; +import { scrubbedStatsFor } from '@/state/stores/presentPaths'; +import type { PaneStatItem } from './PaneStats'; + +/** + * Direct-children and recursive-descendant counts as one item. They collapse to + * a single number when they match (a leaf-ish folder); otherwise the recursive + * total follows in parentheses, e.g. `12 files (1375 total)`. + */ +function countItem( + direct: number | null | undefined, + total: number | null | undefined, + label: string +): PaneStatItem | null { + if (direct == null && total == null) return null; + if (direct == null) return { text: `${total} ${label}`, title: `${total} total` }; + if (total == null || direct === total) { + return { text: `${direct} ${label}`, title: `${direct} direct children` }; + } + return { + text: `${direct} ${label} (${total} total)`, + title: `${direct} direct · ${total} total in this subtree`, + }; +} + +/** Relative age with the exact date as its tooltip. */ +function ageItem(iso: string, label: string, now: number): PaneStatItem { + return { + text: `${label} ${formatRelativeAgeShort(new Date(iso).getTime(), now)}`, + title: `${label} ${formatShortDate(iso)}`, + }; +} + +export interface FileStatOpts { + /** Include the created/modified ages. The hover tooltip drops them to stay + * to a single line; the pane has the room. Defaults to true. */ + dates?: boolean; + /** Reference point for the relative ages. Defaults to now. */ + now?: number; +} + +export function fileStatItems(file: FileNode, opts: FileStatOpts = {}): PaneStatItem[] { + const { dates = true, now = Date.now() } = opts; + const items: PaneStatItem[] = []; + // In Timeline the static node carries max-over-history values, so the replayed + // ones win where they exist (at deletion for a file already gone). + const scrubbed = file.path != null ? scrubbedStatsFor(file.path) : null; + const lines = scrubbed ? scrubbed.lines : file.lines; + const size = scrubbed ? scrubbed.bytes : file.size; + + const language = humanLanguageFor(file); + if (language) items.push({ text: language }); + // Media is binary, so its line count is a meaningless 0: the pixel dimensions + // are the size that means something. The backend only stamps media_width and + // media_height on recognized media, so their presence is the signal. + if (file.media_width != null && file.media_height != null) { + items.push({ text: `${file.media_width}×${file.media_height}` }); + } else if (lines != null) { + items.push({ text: `${lines} lines` }); + } + if (size != null) items.push({ text: formatBytes(size) }); + if (dates) { + if (file.modified) items.push(ageItem(file.modified, 'modified', now)); + if (file.created) items.push(ageItem(file.created, 'created', now)); + } + return items; +} + +export function directoryStatItems(dir: DirNode): PaneStatItem[] { + // Lead with the kind: a folder named `app` and a file named `app` look alike, + // and the counts that follow only make sense once you know which this is. + const items: PaneStatItem[] = [{ text: 'directory' }]; + const files = countItem(dir.children_file_count, dir.descendants_file_count, 'files'); + if (files) items.push(files); + const dirs = countItem(dir.children_dir_count, dir.descendants_dir_count, 'dirs'); + if (dirs) items.push(dirs); + if (dir.descendants_size != null) items.push({ text: formatBytes(dir.descendants_size) }); + return items; +} diff --git a/app/src/components/ProjectSwitcher/ProjectSwitcher.css b/app/src/components/ProjectSwitcher/ProjectSwitcher.css index 81e9c8947..2c1c4e182 100644 --- a/app/src/components/ProjectSwitcher/ProjectSwitcher.css +++ b/app/src/components/ProjectSwitcher/ProjectSwitcher.css @@ -1,8 +1,9 @@ /* The project switcher button is .btn-chip (defined with the button components - in buttons.css), capped at a max-width there. The label and branch pill - truncate with an ellipsis so a long repo name or branch can't overflow the - chip and overlap the neighbouring repo link. The full name stays reachable - via the switcher itself. */ + in buttons.css), which shrinks within the header's flex row rather than + pushing neighboring buttons out of view. The label and branch pill truncate + with an ellipsis so a long repo name or branch can't overflow the chip and + overlap the neighbouring repo link. The full name stays reachable via the + switcher itself. */ .btn-chip-label { white-space: nowrap; min-width: 0; @@ -14,6 +15,19 @@ flex: 0 0 auto; } +/* The gem is the chip's leading glyph, sized to the chip's other icons and + held at its intrinsic width so a long repo name shrinks the label, not it. + The descendant selector outranks .gem-icon's own 1em sizing on specificity, + so the size holds regardless of CSS import order. */ +.btn-chip .gem-icon { + width: var(--cc-font-md); + height: var(--cc-font-md); + flex: 0 0 auto; + /* Reads as the chip's icon rather than a glyph in the name: only this gap + widens, so the name, branch pill, and affordance stay a tight cluster. */ + margin-right: var(--cc-space-2); +} + .app-header-branch-pill { display: inline-flex; align-items: center; diff --git a/app/src/components/ProjectSwitcher/ProjectSwitcher.tsx b/app/src/components/ProjectSwitcher/ProjectSwitcher.tsx index 66f99f7fe..99e418aa1 100644 --- a/app/src/components/ProjectSwitcher/ProjectSwitcher.tsx +++ b/app/src/components/ProjectSwitcher/ProjectSwitcher.tsx @@ -1,11 +1,13 @@ -// components/ProjectSwitcher.tsx — The project chip: label + optional @branch -// pill + a switch cue. Click opens the project switcher. The trailing glyph is -// ChevronsUpDown (a "switchable value" cue), not a down-caret, since clicking -// opens a full switcher screen rather than a dropdown menu. -// Renders nothing until a project is loaded (no label). +// components/ProjectSwitcher.tsx — The project chip: gem + label + optional +// @branch pill + a switch cue. Click opens the project switcher. The trailing +// glyph is ChevronsUpDown (a "switchable value" cue), not a down-caret, since +// clicking opens a full switcher screen rather than a dropdown menu. +// The gem doubles as the app logo, so the chip renders even before a project +// loads: gem alone, still opening the switcher. import './ProjectSwitcher.css'; import { ChevronsUpDown } from 'lucide-preact'; +import { GemIcon } from '@/components/GemIcon/GemIcon'; export interface ProjectSwitcherProps { rootLabel: string; @@ -14,7 +16,6 @@ export interface ProjectSwitcherProps { } export function ProjectSwitcher({ rootLabel, branch, onSwitchSource }: ProjectSwitcherProps) { - if (!rootLabel) return null; return ( diff --git a/app/src/components/RecentsList/RecentsList.css b/app/src/components/RecentsList/RecentsList.css index ec9c66e60..38cb15bd2 100644 --- a/app/src/components/RecentsList/RecentsList.css +++ b/app/src/components/RecentsList/RecentsList.css @@ -5,9 +5,11 @@ display: flex; flex-direction: column; gap: var(--cc-space-1); - /* Grow into the card's remaining height (the card is height-capped by - .landing-actions), then scroll the rows rather than the page. min-height: 0 - lets this flex child shrink below its content so the overflow is its own. */ + /* Grow into the card's remaining height and scroll the rows rather than the + page, wherever the card is height-capped (the side-by-side landing layout). + Stacked, the card shrink-wraps and the page scroller takes over instead. + min-height: 0 lets this flex child shrink below its content so the overflow + is its own. */ flex: 1 1 auto; min-height: 0; overflow-y: auto; diff --git a/app/src/components/ResetViewButton.tsx b/app/src/components/ResetViewButton.tsx deleted file mode 100644 index 73dddb704..000000000 --- a/app/src/components/ResetViewButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// components/ResetViewButton.tsx — The gem button at the header's left edge. -// Doubles as the app logo: clicking it resets the camera view (R). Renders -// nothing when no reset handler is wired (pre-boot). - -import { GemIcon } from '@/components/GemIcon/GemIcon'; -import { KEY_BINDINGS } from '@/constants/keyboard'; - -export interface ResetViewButtonProps { - onResetView?: () => void; -} - -export function ResetViewButton({ onResetView }: ResetViewButtonProps) { - if (!onResetView) return null; - return ( - - ); -} diff --git a/app/src/components/SceneModeToggle/SceneModeToggle.css b/app/src/components/SceneModeToggle/SceneModeToggle.css index baa5ddfb1..84ad24c56 100644 --- a/app/src/components/SceneModeToggle/SceneModeToggle.css +++ b/app/src/components/SceneModeToggle/SceneModeToggle.css @@ -4,10 +4,7 @@ display: inline-flex; gap: var(--cc-space-1); padding: var(--cc-space-1); - background: color-mix(in oklch, var(--cc-bg-modal) 82%, transparent); border-radius: var(--cc-radius-lg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); } .scene-mode-btn { diff --git a/app/src/components/SceneModeToggle/SceneModeToggle.tsx b/app/src/components/SceneModeToggle/SceneModeToggle.tsx index 69232215d..deb9e4c71 100644 --- a/app/src/components/SceneModeToggle/SceneModeToggle.tsx +++ b/app/src/components/SceneModeToggle/SceneModeToggle.tsx @@ -10,7 +10,7 @@ export function SceneModeToggle() { if (!SOURCE_INFO.value.src) return null; const timeline = TIMELINE_MODE.value; return ( -
+
- )} + + + {isDebugMode() && ( -
+ )} +
+ ); diff --git a/app/src/layout/AppFooter/FooterMeta.tsx b/app/src/layout/AppFooter/FooterMeta.tsx new file mode 100644 index 000000000..2f4999f23 --- /dev/null +++ b/app/src/layout/AppFooter/FooterMeta.tsx @@ -0,0 +1,30 @@ +// layout/FooterMeta.tsx — The footer's two meta bits, at opposite ends. +// +// Version sits bottom-left with the build status: both answer "what is running +// right now", and it comes from the server rather than a bundled constant, so a +// released image reports its own tag. Credit sits bottom-right, the quiet +// corner. The outward link to the repo lives in the app header. + +import { SERVER_CONFIG } from '@/state/stores/serverConfig'; +import { CREATOR_URL } from '@/constants/ui'; + +export function FooterVersion() { + return v{SERVER_CONFIG.value.version}; +} + +export function FooterCredit() { + return ( + + made by 🦄{' '} + + thalida. + + + ); +} diff --git a/app/src/layout/AppFooter/FooterSep.tsx b/app/src/layout/AppFooter/FooterSep.tsx new file mode 100644 index 000000000..98f4ad0e7 --- /dev/null +++ b/app/src/layout/AppFooter/FooterSep.tsx @@ -0,0 +1,6 @@ +// layout/FooterSep.tsx — The footer's muted-dot separator, shared by the +// selection metadata and the credit line. + +export function FooterSep() { + return ·; +} diff --git a/app/src/layout/AppHeader/AppHeader.css b/app/src/layout/AppHeader/AppHeader.css index f6f52a002..0c9724114 100644 --- a/app/src/layout/AppHeader/AppHeader.css +++ b/app/src/layout/AppHeader/AppHeader.css @@ -1,17 +1,14 @@ /* ── Sitewide header ────────────────────────────────────────────────── Full-width strip across the top of the app shell. Sits in the body's - column flex above #app-body; the sidebars start at its lower edge. */ + column flex above #app-body; the sidebars start at its lower edge. + One left-aligned row: project chip, copy-source, open-on-origin. */ #app-header { flex: 0 0 auto; - /* 3-column grid keeps #app-title visually centered in the viewport - regardless of how much content the left controls (gem, project chip, - repo link) occupy. The minmax(0, 1fr) tracks share the leftover space - equally, so the center column stays at true header-center. */ - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + display: flex; align-items: center; - column-gap: var(--cc-space-6); + gap: var(--cc-space-2); + min-width: 0; height: 32px; padding: 0 var(--cc-space-4); border-bottom: 1px solid var(--cc-border-subtle); @@ -22,29 +19,29 @@ user-select: none; } -#app-header-left { +/* Meta cluster pinned to the far right, opposite the project controls. */ +#app-header-meta { display: flex; align-items: center; - gap: var(--cc-space-2); - min-width: 0; + gap: var(--cc-space-4); + margin-left: auto; + flex: 0 0 auto; } -/* Centered cluster: project switcher, copy-source, open-remote (the gem stays - left, in #app-header-left). */ -#app-title { - display: flex; - align-items: center; - justify-content: center; - gap: var(--cc-space-2); - min-width: 0; - color: var(--cc-text-secondary); +/* A text link among icon buttons: matched to the resting icon color so the + cluster reads as one weight, and brightened on hover like they are. */ +.app-header-link { + color: var(--cc-text-muted); + text-decoration: none; +} +.app-header-link:hover, +.app-header-link:focus-visible { + color: var(--cc-text-strong); + text-decoration: underline; } -/* Visual style fully in .btn-icon (with --no-drag modifier where needed). */ - -/* Header buttons (switch-source, refresh, repo-link) now use - .btn-icon + .btn-icon--no-drag (refresh/switch) or - .btn-icon + .btn-icon--link + .btn-icon--no-drag (repo-link). +/* Header buttons use .btn-icon + .btn-icon--no-drag, or + .btn-icon + .btn-icon--link + .btn-icon--no-drag for the repo link. Inner svg rendering tweak applies to any svg child of a header btn-icon. */ #app-header .btn-icon svg { display: block; diff --git a/app/src/layout/AppHeader/AppHeader.tsx b/app/src/layout/AppHeader/AppHeader.tsx index 3e1d2cefd..aa9f95c8b 100644 --- a/app/src/layout/AppHeader/AppHeader.tsx +++ b/app/src/layout/AppHeader/AppHeader.tsx @@ -1,53 +1,68 @@ -// layout/AppHeader.tsx — Sitewide top header. Composition shell only: the reset -// gem stays at the left edge; the project switcher + its actions (copy-source, -// open-on-origin) sit centered. What's selected is shown in the right sidebar -// (open whenever there's a selection), not here. +// layout/AppHeader.tsx — Sitewide top header. Composition shell only. Left: the +// project switcher (gem + name + branch) and its actions (copy-source, +// open-on-origin), all about the project you have open. Right: the meta any +// user might want, about and the keyboard shortcuts. Developer-only tools and +// run-state live in the footer instead; what's selected is shown in the right +// sidebar (open whenever there's a selection). import './AppHeader.css'; -import { ExternalLink } from 'lucide-preact'; +import { ExternalLink, Keyboard } from 'lucide-preact'; import { SOURCE_INFO } from '@/state/stores/source'; import { MANIFEST } from '@/state/stores/manifest'; import type { Manifest } from '@/types'; -import { openProjectsView } from '@/state/stores/ui'; -import { ResetViewButton } from '@/components/ResetViewButton'; +import { openProjectsView, openShortcuts } from '@/state/stores/ui'; +import { REPO_URL } from '@/constants/ui'; import { ProjectSwitcher } from '@/components/ProjectSwitcher/ProjectSwitcher'; import { CopyButton } from '@/components/CopyButton/CopyButton'; export interface AppHeaderProps { /** Fires when the user clicks the project chip to switch source. */ onSwitchSource?: () => void; - /** Fires when the user clicks the reset-view (gem) button. */ - onResetView?: () => void; } -export function AppHeader({ onSwitchSource, onResetView }: AppHeaderProps = {}) { +export function AppHeader({ onSwitchSource }: AppHeaderProps = {}) { const si = SOURCE_INFO.value; const remoteUrl = (MANIFEST.value as Manifest)?.repo?.remote_url ?? null; return (
-
- -
-
- openProjectsView({ dismissible: true }))} - /> - {si.src && } - {remoteUrl && ( - - - - )} + openProjectsView({ dismissible: true }))} + /> + {si.src && } + {remoteUrl && ( + + + + )} +
+ + about + +
); diff --git a/app/src/styles/buttons.css b/app/src/styles/buttons.css index e5e9e24ea..20adc5ef1 100644 --- a/app/src/styles/buttons.css +++ b/app/src/styles/buttons.css @@ -113,14 +113,14 @@ LAYER-2 COMPONENTS — Icon-text chip (.btn-chip) ═══════════════════════════════════════════════════════════════════════════ Used by the header project switcher (icon + label + optional badge). - Different from .btn-icon — has gap, asymmetric padding, max-width, - ellipsis truncation on its label. */ + Different from .btn-icon — has gap, shrinks and truncates its label instead + of a hard-capped width. */ .btn-chip { appearance: none; - /* Grow with its content up to the header's left column (1fr), then shrink and - truncate — no hard cap, so it uses the space it's given before ellipsizing - (min-width: 0 lets the flex item shrink below its content width). */ + /* Grows with its content, no hard cap, then shrinks and truncates within + the header's flex row (min-width: 0 lets it shrink below its content + width) rather than pushing neighboring header buttons out of view. */ flex: 0 1 auto; min-width: 0; display: inline-flex; diff --git a/app/src/styles/cards.css b/app/src/styles/cards.css index d2e1414d9..e87f2dd37 100644 --- a/app/src/styles/cards.css +++ b/app/src/styles/cards.css @@ -1,15 +1,15 @@ /* ═══════════════════════════════════════════════════════════════════════════ LAYER-2 COMPONENTS — Cards ═══════════════════════════════════════════════════════════════════════════ - Reusable boxed surfaces. Each owns its background + border + radius (+ shadow - / padding); the consuming selector adds only its own layout. The element - composes the card class with its layout class, e.g. `modal-error card-error`. + Reusable boxed surfaces owning border + radius (+ shadow / padding), and a + background where the role has one; the consuming selector adds only its own + layout. The element composes the card class with its layout class, e.g. + `modal-error card-error`. ═══════════════════════════════════════════════════════════════════════════ */ -/* Elevated panel floating on a backdrop — the source-picker modal + the - loading overlay. */ +/* Elevated panel floating on a backdrop: the Shortcuts and Debug modals plus + the loading overlay. Pair with .surface-glass, which supplies the fill. */ .card-overlay { - background: var(--cc-bg-modal); color: var(--cc-text-base); border-radius: var(--cc-radius-lg); box-shadow: var(--cc-shadow-lg); @@ -30,17 +30,43 @@ padding: var(--cc-space-3) var(--cc-space-6); } -/* Floating tooltip surface (the hover tooltip). */ +/* Floating tooltip surface (the hover tooltip). Pair with .surface-glass: it + rides directly on the canvas. */ .card-tooltip { - background: var(--cc-bg-tooltip); border: 1px solid var(--cc-border-tooltip); border-radius: var(--cc-radius-sm); - padding: var(--cc-space-2) var(--cc-space-4); + padding: var(--cc-space-3) var(--cc-space-4); box-shadow: var(--cc-shadow-md); + /* A hard cap the path truncation cannot overshoot: the truncation budget is + counted in characters, which only approximates rendered width. */ + max-width: 340px; +} + +/* Three stacked lines: what it is, where it lives, what it measures. Each + clips on its own so one long value can't widen the card. */ +.tooltip-title, +.tooltip-path, +.tooltip-stats { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tooltip-title { + color: var(--cc-text-strong); + font-weight: var(--cc-fw-semibold); +} +.tooltip-path { + color: var(--cc-text-muted); + font-family: var(--cc-font-mono); + font-size: var(--cc-font-2xs); +} +.tooltip-stats { + color: var(--cc-text-secondary); + font-size: var(--cc-font-xs); } /* Leading "deleted" badge on a ruin's hover tooltip — red + bold so it reads - at a glance before the path. */ + at a glance before the name. */ .tooltip-deleted { color: var(--cc-error-light); font-weight: var(--cc-fw-semibold); diff --git a/app/src/styles/modal.css b/app/src/styles/modal.css index ac035fdea..415f82700 100644 --- a/app/src/styles/modal.css +++ b/app/src/styles/modal.css @@ -3,7 +3,7 @@ ═══════════════════════════════════════════════════════════════════════════ Shared backdrop + card layout for DebugModal, ShortcutsModal, and LoadingOverlay (ProjectsView is a full-viewport surface, not a modal, so it - doesn't use these). Surface fill/radius/shadow comes from .card-overlay; + doesn't use these). Radius and shadow come from .card-overlay and the fill from .surface-glass; this file only adds backdrop positioning + the header/body column layout. ═══════════════════════════════════════════════════════════════════════════ */ @@ -29,9 +29,9 @@ flex-direction: column; overflow: hidden; font-family: inherit; - /* A faint full border (header-divider color) lifts the modal off the blurred - backdrop, and an even, slightly darker shadow (no downward offset) rings it - equally rather than casting downward. */ + /* A faint border lifts the modal off the blurred backdrop, and an even + shadow (no downward offset) rings it equally rather than casting + downward. */ border: 1px solid var(--cc-border-subtle); box-shadow: 0 0 40px oklch(0 0 0 / 0.7); } diff --git a/app/src/styles/surfaces.css b/app/src/styles/surfaces.css index 3380aa3c7..4571db167 100644 --- a/app/src/styles/surfaces.css +++ b/app/src/styles/surfaces.css @@ -1,7 +1,7 @@ /* ═══════════════════════════════════════════════════════════════════════════ LAYER-2 COMPONENTS — Surfaces ═══════════════════════════════════════════════════════════════════════════ - Background-only utilities naming the app's three base fills. The consuming + Background-only utilities naming the app's base fills. The consuming element keeps its own borders + layout; this just gives the chrome/backdrop fill a single source so it's set once per role, not per selector. ═══════════════════════════════════════════════════════════════════════════ */ @@ -15,3 +15,13 @@ .surface-sidebar { background: var(--cc-bg-sidebar); } + +/* The fourth role: a panel with the scene behind it. Only for surfaces that + float over the canvas or the landing backdrop, never for chrome laid out in + the app body, where a blur would have nothing to sample and the translucency + would reveal the page background. */ +.surface-glass { + background: var(--cc-glass-fill); + backdrop-filter: blur(var(--cc-glass-blur)); + -webkit-backdrop-filter: blur(var(--cc-glass-blur)); +} diff --git a/app/src/styles/tokens.css b/app/src/styles/tokens.css index 0a36b29b6..c7ffc40f7 100644 --- a/app/src/styles/tokens.css +++ b/app/src/styles/tokens.css @@ -35,10 +35,13 @@ --cc-bg-sidebar: oklch(0.181 0.019 279.4); --cc-bg-modal: var(--cc-bg-sidebar); /* alias — modal uses the same panel surface as sidebars */ --cc-bg-backdrop: color-mix(in oklch, var(--cc-black) 55%, transparent); - --cc-bg-tooltip: color-mix(in oklch, var(--cc-bg-app) 94%, transparent); --cc-bg-base: var(--cc-bg-app); - /* ── COLORS — Borders & tracks ────────────────────────────────────────── */ + /* ── COLORS — Borders & tracks ────────────────────────────────────────── + Absolute fallbacks, matching the default (cool) surface exactly. The + @supports block below rewrites them as offsets from the surface itself so + they follow whichever preset is picked; these values are what engines + without relative color syntax keep. */ --cc-border-subtle: oklch(0.249 0.03 278.4); --cc-border-input: oklch(0.281 0.03 276.1); --cc-border-tooltip: oklch(0.32 0.058 274.6); @@ -105,6 +108,14 @@ --cc-overlay-bg-strong: color-mix(in oklch, var(--cc-white) 10%, transparent); --cc-overlay-border: color-mix(in oklch, var(--cc-white) 18%, transparent); + /* ── COLORS — Glass (surfaces that float over the scene) ────────────── + One fill and one blur for every panel with the canvas behind it, so the + scrubber, the mode toggle, the modals and the landing cards frost the + scene identically. Applied via .surface-glass; a surface that needs its + own fill (the timeline's error toast) still blurs by this radius. */ + --cc-glass-fill: color-mix(in oklch, var(--cc-bg-modal) 90%, transparent); + --cc-glass-blur: 12px; + /* ── COLORS — Pure neutrals ───────────────────────────────────────────── */ --cc-black: oklch(0 0 0); --cc-white: oklch(1 0 0); @@ -195,3 +206,25 @@ --cc-shadow-text: 0 1px 2px oklch(0 0 0 / 0.5); /* canvas overlay text */ --cc-glow-accent: 0 0 6px color-mix(in oklch, var(--cc-accent) 35%, transparent); /* range-pair fill */ } + +/* Borders and tracks, derived from the surface so a preset tints every edge with + it. themes.css swaps only --cc-bg-*, and these resolve against whichever value + wins, so no preset has to restate a border. + + Lightness is an offset and chroma a multiplier, both measured off the default + surface, so the cool theme renders identically to the absolute values above + while a warmer or more neutral surface carries its own hue and saturation + through. Hue passes straight through. + + @supports because relative color syntax has no per-declaration fallback: a + custom property holding an unparseable value fails at substitution, which + would leave every border at currentColor rather than at the value above. */ +@supports (color: oklch(from white l c h)) { + :root { + --cc-border-subtle: oklch(from var(--cc-bg-sidebar) calc(l + 0.068) calc(c * 1.58) h); + --cc-border-input: oklch(from var(--cc-bg-sidebar) calc(l + 0.1) calc(c * 1.58) h); + --cc-border-tooltip: oklch(from var(--cc-bg-sidebar) calc(l + 0.139) calc(c * 3.05) h); + --cc-track: oklch(from var(--cc-bg-sidebar) calc(l + 0.049) calc(c * 1.21) h); + --cc-track-hover: oklch(from var(--cc-bg-sidebar) calc(l + 0.174) calc(c * 2.21) h); + } +} diff --git a/app/src/types/manifest.generated.ts b/app/src/types/manifest.generated.ts index 81e4d21f7..0e36f0ec8 100644 --- a/app/src/types/manifest.generated.ts +++ b/app/src/types/manifest.generated.ts @@ -307,6 +307,8 @@ export interface components { allowLocalRepos: boolean; /** Maxbatchpaths */ maxBatchPaths: number; + /** Version */ + version: string; }; /** DateRangeMs */ DateRangeMs: { diff --git a/app/src/utils/debugMode.ts b/app/src/utils/debugMode.ts index 86e978dfb..93fe5adfc 100644 --- a/app/src/utils/debugMode.ts +++ b/app/src/utils/debugMode.ts @@ -1,4 +1,4 @@ -// utils/debugMode.ts — Gate for developer-only UI (the header bug icon and +// utils/debugMode.ts — Gate for developer-only UI (the footer bug icon and // its DebugModal). On in a dev server, when built with VITE_DEBUG, or with // ?debug in the URL — so a prod deploy can still be flipped on for support. diff --git a/app/src/views/ControlsPane/partials/ExcludesSection.tsx b/app/src/views/ControlsPane/partials/ExcludesSection.tsx index 756864ee9..e66f190cf 100644 --- a/app/src/views/ControlsPane/partials/ExcludesSection.tsx +++ b/app/src/views/ControlsPane/partials/ExcludesSection.tsx @@ -9,6 +9,7 @@ import './ExcludesSection.css'; import { EyeOff, RotateCcw } from 'lucide-preact'; import { ACTIVE_EXCLUDES, removeExclude, clearExcludes } from '@/state/stores/excludes'; import { Section } from '@/components/Section/Section'; +import { REPO_URL } from '@/constants/ui'; export function ExcludesSection() { const paths = ACTIVE_EXCLUDES.value; @@ -20,7 +21,7 @@ export function ExcludesSection() { Paths you hide from the city, saved in this browser. This does not change the repo.{' '} diff --git a/app/src/views/DebugModal/DebugModal.tsx b/app/src/views/DebugModal/DebugModal.tsx index 094e1ed5f..a3fbb030b 100644 --- a/app/src/views/DebugModal/DebugModal.tsx +++ b/app/src/views/DebugModal/DebugModal.tsx @@ -43,8 +43,13 @@ export function DebugModal({ onRunCollisionCheck, onRunStemDiagnostic }: DebugMo if (e.target === e.currentTarget) closeDebug(); }} > -