diff --git a/INSTALL.md b/INSTALL.md index c8a5d50..84493ab 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -139,21 +139,32 @@ docker run -d \ -v ./data:/app/data \ -v /Users/you/Development:/Users/you/Development:ro \ -e ANTHROPIC_API_KEY=your_key_here \ + -e PROJECTS_DIR=/Users/you/Development \ ericblue/vibefocus:latest ``` Replace `/Users/you/Development` with the parent directory where your git repos live. The path inside the container must match the host path so that `local_path` values on your projects resolve correctly. The `:ro` flag makes it read-only. -If using `docker-compose.yml`, uncomment the volume mount line and set your path: +If using `docker-compose.yml`, set `PROJECTS_DIR` before starting the container: -```yaml -volumes: - - ./data:/app/data - - /Users/you/Development:/Users/you/Development:ro +```bash +PROJECTS_DIR=/Users/you/Development docker compose up -d --build ``` Without this mount, VibeFocus still works but analytics (heatmaps, velocity, streaks) will be empty and code analysis won't be available. +### Importing Existing Local Repos + +After startup, open Settings and use **Import Local Git Repositories**. Enter the parent folder that contains your repos, for example `/Users/you/Development`, then click **Scan Repositories**. VibeFocus creates missing projects, updates matching projects by local path or GitHub URL, infers GitHub URLs from `origin`, and refreshes lightweight git stats like branch, dirty status, and latest commit. + +If the Settings page says the directory is missing, the backend cannot see that path. For Docker, restart with the folder mounted: + +```bash +PROJECTS_DIR=/Users/you/Development docker compose up -d --build +``` + +From source, set `PROJECTS_DIR` in `backend/.env` or enter the absolute path in Settings. Re-running a scan is safe; it updates existing projects without importing commit logs. Use Analytics sync later when you want heatmaps, velocity, streaks, and health history. + ### From Source ```bash diff --git a/Makefile b/Makefile index 2bf36c3..53127a1 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ MCP_VENV := $(MCP_DIR)/venv BE_PORT ?= 8000 FE_PORT ?= 5173 PYTHON ?= /opt/homebrew/bin/python3.12 +PROJECTS_DIR ?= # ── Version (single source of truth: VERSION file) ────────────────────────── CURRENT_VERSION := $(shell cat VERSION 2>/dev/null || echo "0.0.0") @@ -20,7 +21,7 @@ DOCKER_TAG ?= $(CURRENT_VERSION) # ── Install / Setup ────────────────────────────────────────────────────────── -.PHONY: install install-fe install-be install-mcp +.PHONY: install install-fe install-be install-mcp import-projects install: install-fe install-be install-mcp ## Install all dependencies @@ -33,6 +34,13 @@ install-be: ## Create venv and install backend dependencies install-mcp: ## Create venv and install MCP server dependencies cd $(MCP_DIR) && $(PYTHON) -m venv venv && . venv/bin/activate && pip install -r requirements.txt +import-projects: ## Import local git repos into VibeFocus (PROJECTS_DIR=/path/to/repos) + @if [ -z "$(PROJECTS_DIR)" ]; then \ + echo "PROJECTS_DIR is required. Example: make import-projects PROJECTS_DIR=/path/to/repos"; \ + exit 1; \ + fi + cd $(BE_DIR) && . venv/bin/activate && python import_local_projects.py --root "$(PROJECTS_DIR)" + # ── Frontend ───────────────────────────────────────────────────────────────── .PHONY: fe fe-build fe-stop diff --git a/README.md b/README.md index 663925f..ac88a29 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,9 @@ docker run -d -p 8000:8000 \ ericblue/vibefocus:latest ``` -Open http://localhost:8000. The volume mount to your projects directory enables git sync, code analysis, and AI code exploration. +Open http://localhost:8000. The volume mount to your projects directory enables local repo import, git sync, code analysis, and AI code exploration. + +On a fresh install, open Settings and use **Import Local Git Repositories** to scan the folder you mounted or configured. The scan creates projects and refreshes lightweight git stats only; use Analytics sync later when you want commit history. ### From Source diff --git a/backend/.env.example b/backend/.env.example index 98e6452..e2fbce2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,3 +2,6 @@ ANTHROPIC_API_KEY=your_api_key_here DATABASE_URL=sqlite:///./vibefocus.db CORS_ORIGINS=http://localhost:5173 PORT=8000 + +# Optional: parent folder containing local git repositories. +# PROJECTS_DIR=~/Development diff --git a/backend/database.py b/backend/database.py index 5736dae..2013e7a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -4,10 +4,11 @@ class Settings(BaseSettings): - anthropic_api_key: str + anthropic_api_key: str | None = None database_url: str = "sqlite:///./vibefocus.db" cors_origins: str = "http://localhost:5173" port: int = 8000 + projects_dir: str | None = None model_config = SettingsConfigDict(env_file=".env", extra="ignore") diff --git a/backend/import_local_projects.py b/backend/import_local_projects.py new file mode 100644 index 0000000..fe84a42 --- /dev/null +++ b/backend/import_local_projects.py @@ -0,0 +1,311 @@ +""" +Import local git repositories into VibeFocus. + +Usage: + cd backend + python import_local_projects.py --root /Users/you/Development +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable + +from database import Base, SessionLocal, engine +from models import Bucket, Project, State +from services.git_service import get_local_git_stats + + +DEFAULT_STATES = [ + {"name": "Idea", "color": "#8b5cf6", "position": 0}, + {"name": "Exploring", "color": "#0ea5e9", "position": 1}, + {"name": "Building", "color": "#f59e0b", "position": 2}, + {"name": "MVP", "color": "#f97316", "position": 3}, + {"name": "Launched", "color": "#22c55e", "position": 4}, + {"name": "Stalled", "color": "#ef4444", "position": 5}, + {"name": "Archived", "color": "#64748b", "position": 6}, +] + +DEFAULT_BUCKETS = [ + {"name": "Uncategorized", "color": "#94a3b8", "position": 0}, + {"name": "Open Source", "color": "#0ea5e9", "position": 1}, + {"name": "Commercial", "color": "#f59e0b", "position": 2}, + {"name": "Personal", "color": "#ec4899", "position": 3}, + {"name": "Side Project", "color": "#8b5cf6", "position": 4}, + {"name": "Client Work", "color": "#10b981", "position": 5}, + {"name": "Experiment", "color": "#f97316", "position": 6}, +] + + +def run(repo: Path, args: list[str]) -> str: + result = subprocess.run(args, cwd=repo, capture_output=True, text=True, timeout=10) + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def utcnow_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def find_git_repos(root: Path, recursive: bool) -> Iterable[Path]: + if (root / ".git").is_dir(): + yield root + if not recursive: + return + + if recursive: + seen = {root.resolve()} if (root / ".git").is_dir() else set() + for git_dir in root.rglob(".git"): + if git_dir.is_dir(): + repo = git_dir.parent.resolve() + if repo not in seen: + seen.add(repo) + yield repo + return + + for child in sorted(root.iterdir()): + if child.is_dir() and (child / ".git").is_dir(): + yield child + + +def normalize_github_url(remote: str) -> str | None: + if not remote: + return None + remote = remote.removesuffix(".git") + ssh_match = re.match(r"git@github\.com:([^/]+)/(.+)$", remote) + if ssh_match: + return f"https://github.com/{ssh_match.group(1)}/{ssh_match.group(2)}" + https_match = re.match(r"https://github\.com/([^/]+)/(.+)$", remote) + if https_match: + return remote + return None + + +def title_from_name(name: str) -> str: + special = {"pq": "PQ", "api": "API", "mcp": "MCP", "sdk": "SDK"} + parts = re.split(r"[-_\s]+", name) + return " ".join(special.get(part.lower(), part.capitalize()) for part in parts if part) + + +def read_description(repo: Path) -> str: + package_json = repo / "package.json" + if package_json.exists(): + try: + description = json.loads(package_json.read_text()).get("description") + if description: + return description.strip() + except (OSError, json.JSONDecodeError): + pass + + for name in ("README.md", "readme.md", "README"): + readme = repo / name + if not readme.exists(): + continue + try: + lines = [line.strip() for line in readme.read_text(errors="replace").splitlines()] + except OSError: + continue + for line in lines: + if not line or line.startswith("#") or line.startswith("[!"): + continue + return line[:500] + return "" + + +def detect_stack(repo: Path) -> list[str]: + stack: list[str] = [] + + def add(name: str): + if name not in stack: + stack.append(name) + + if (repo / "package.json").exists(): + add("JavaScript/TypeScript") + try: + package = json.loads((repo / "package.json").read_text()) + deps = {**package.get("dependencies", {}), **package.get("devDependencies", {})} + if "next" in deps: + add("Next.js") + if "react" in deps: + add("React") + if "vite" in deps: + add("Vite") + if "tailwindcss" in deps: + add("Tailwind CSS") + if "prisma" in deps: + add("Prisma") + except (OSError, json.JSONDecodeError): + pass + if (repo / "pyproject.toml").exists() or (repo / "requirements.txt").exists(): + add("Python") + if (repo / "composer.json").exists(): + add("PHP") + if (repo / "Gemfile").exists(): + add("Ruby") + if (repo / "go.mod").exists(): + add("Go") + if (repo / "Cargo.toml").exists(): + add("Rust") + if (repo / "Dockerfile").exists() or (repo / "docker-compose.yml").exists(): + add("Docker") + + return stack + + +def parse_last_commit_at(repo: Path) -> datetime | None: + raw = run(repo, ["git", "log", "-1", "--format=%cI"]) + if not raw: + return None + try: + return datetime.fromisoformat(raw) + except ValueError: + return None + + +def state_for(last_commit_at: datetime | None, states: dict[str, str]) -> str: + if not last_commit_at: + return states["Building"] + now = datetime.now(last_commit_at.tzinfo or timezone.utc) + age_days = (now - last_commit_at).days + if age_days <= 45: + return states["Building"] + if age_days <= 120: + return states["MVP"] + return states["Stalled"] + + +def priority_for(last_commit_at: datetime | None) -> str: + if not last_commit_at: + return "medium" + now = datetime.now(last_commit_at.tzinfo or timezone.utc) + age_days = (now - last_commit_at).days + if age_days <= 14: + return "high" + if age_days <= 90: + return "medium" + return "low" + + +def bucket_for(buckets: dict[str, str]) -> str: + return buckets["Uncategorized"] + + +def ensure_defaults(db) -> tuple[dict[str, str], dict[str, str]]: + Base.metadata.create_all(bind=engine) + for bucket in DEFAULT_BUCKETS: + if not db.query(Bucket).filter(Bucket.name == bucket["name"]).first(): + db.add(Bucket(**bucket)) + for state in DEFAULT_STATES: + if not db.query(State).filter(State.name == state["name"]).first(): + db.add(State(**state)) + db.commit() + buckets = {b.name: b.id for b in db.query(Bucket).all()} + states = {s.name: s.id for s in db.query(State).all()} + return buckets, states + + +def import_repo(db, repo: Path, buckets: dict[str, str], states: dict[str, str]) -> tuple[str, str]: + remote = run(repo, ["git", "remote", "get-url", "origin"]) + github_url = normalize_github_url(remote) + local_path = str(repo.resolve()) + last_commit_at = parse_last_commit_at(repo) + + project = ( + db.query(Project).filter(Project.local_path == local_path).first() + or (db.query(Project).filter(Project.github_url == github_url).first() if github_url else None) + or db.query(Project).filter(Project.name == title_from_name(repo.name)).first() + ) + created = project is None + if created: + project = Project( + name=title_from_name(repo.name), + bucket_id=bucket_for(buckets), + state_id=state_for(last_commit_at, states), + priority=priority_for(last_commit_at), + kanban_position=db.query(Project).count(), + ) + db.add(project) + + project.description = project.description or read_description(repo) + project.github_url = project.github_url or github_url + project.local_path = local_path + project.code_tech_stack = project.code_tech_stack or detect_stack(repo) + project.code_summary = project.code_summary or f"Imported from local git repository at {local_path}." + + for field, value in get_local_git_stats(local_path).items(): + setattr(project, field, value) + project.stats_updated_at = utcnow_naive() + project.updated_at = utcnow_naive() + + db.commit() + return ("created" if created else "updated", project.name) + + +def scan_repos(db, root: Path, recursive: bool = False) -> dict: + """Scan a directory for git repos and import/update them. Returns a summary dict.""" + buckets, states = ensure_defaults(db) + repos = list(find_git_repos(root, recursive=recursive)) + + results = [] + total_created = total_updated = total_skipped = 0 + for repo in repos: + try: + action, name = import_repo(db, repo, buckets, states) + except Exception as exc: + db.rollback() + total_skipped += 1 + results.append({ + "action": "skipped", + "name": repo.name, + "path": str(repo), + "error": str(exc), + }) + continue + + results.append({"action": action, "name": name, "path": str(repo)}) + if action == "created": + total_created += 1 + else: + total_updated += 1 + + return { + "root": str(root), + "total": len(repos), + "created": total_created, + "updated": total_updated, + "skipped": total_skipped, + "projects": results, + } + + +def main(): + parser = argparse.ArgumentParser(description="Import local git repositories into VibeFocus.") + parser.add_argument("--root", required=True, help="Directory containing local git repositories.") + parser.add_argument("--recursive", action="store_true", help="Search recursively for .git directories.") + args = parser.parse_args() + + root = Path(args.root).expanduser().resolve() + if not root.is_dir(): + raise SystemExit(f"Root directory does not exist: {root}") + + with SessionLocal() as db: + result = scan_repos(db, root, recursive=args.recursive) + + if result["total"] == 0: + raise SystemExit(f"No git repositories found under {root}") + + for project in result["projects"]: + suffix = f" ({project['error']})" if project.get("error") else "" + print(f"{project['action']:7} {project['name']}{suffix}") + print("") + print(f"Imported {result['total']} repos: {result['created']} created, {result['updated']} updated, {result['skipped']} skipped.") + + +if __name__ == "__main__": + main() diff --git a/backend/routers/data.py b/backend/routers/data.py index 6457735..6eee183 100644 --- a/backend/routers/data.py +++ b/backend/routers/data.py @@ -1,6 +1,6 @@ """ Data import/export endpoints for VibeFocus. -Supports JSON (full/per-project), CSV, SQLite backup, and JSON import. +Supports JSON (full/per-project), CSV, SQLite backup, JSON import, and local repo scanning. """ import csv @@ -440,3 +440,54 @@ async def import_preview(file: UploadFile = File(...)): }, "projects": [{"name": p["name"], "completion_pct": p.get("completion_pct", 0)} for p in projects], } + + +# ── Local repo scan ─────────────────────────────────────────────────────────── + +@router.get("/scan-config") +def scan_config(): + """Return the configured PROJECTS_DIR and whether it is ready to scan.""" + raw = settings.projects_dir + resolved = str(Path(raw).expanduser().resolve()) if raw else None + exists = Path(resolved).is_dir() if resolved else False + return { + "projects_dir": resolved, + "projects_dir_raw": raw, + "exists": exists, + "configured": bool(raw), + "ready": bool(raw and exists), + } + + +@router.post("/scan") +def scan_local_projects( + root: str | None = Query(default=None, description="Directory to scan. Falls back to PROJECTS_DIR setting."), + recursive: bool = Query(default=False), + db: Session = Depends(get_db), +): + """Scan a local directory for git repositories and import/update projects.""" + from import_local_projects import scan_repos + + scan_root = root or settings.projects_dir + if not scan_root: + raise HTTPException( + 400, + { + "code": "scan_root_required", + "message": "Enter a directory path to scan or set PROJECTS_DIR in backend/.env.", + }, + ) + + root_path = Path(scan_root).expanduser().resolve() + if not root_path.is_dir(): + raise HTTPException( + 400, + { + "code": "scan_root_missing", + "message": f"Directory not found: {scan_root}", + "root": scan_root, + "resolved_root": str(root_path), + }, + ) + + return scan_repos(db, root_path, recursive=recursive) diff --git a/docker-compose.yml b/docker-compose.yml index a40f480..81e5bd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - ./backend/.env environment: - DATABASE_URL=sqlite:///./data/vibefocus.db + - PROJECTS_DIR=${PROJECTS_DIR:-} volumes: - ./data:/app/data # Mount local repos (read-only) for git sync, code analysis, and AI chat. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8053101..802621f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { useProjects, useBuckets } from './hooks/useProjects' +import { useProjects, useBuckets, useProject } from './hooks/useProjects' import { useAppStore } from './store/appStore' import { Header } from './components/Header' import { Dashboard } from './components/Dashboard' @@ -26,9 +26,12 @@ export default function App() { return () => window.removeEventListener('hashchange', onHashChange) }, []) const { data: projects = [], isLoading, isError } = useProjects() + const { data: drawerProjectById, isError: drawerProjectMissing } = useProject(drawerProjectId) const { data: buckets = [] } = useBuckets() - const drawerProject = drawerProjectId ? projects.find(p => p.id === drawerProjectId) : null + const drawerProject = drawerProjectId + ? projects.find(p => p.id === drawerProjectId) ?? drawerProjectById ?? null + : null if (isError) { return ( @@ -68,8 +71,8 @@ export default function App() { onClick={closeDrawer} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', - zIndex: 100, opacity: drawerProject ? 1 : 0, - transition: 'opacity 0.2s', pointerEvents: drawerProject ? 'auto' : 'none', + zIndex: 100, opacity: 1, + transition: 'opacity 0.2s', pointerEvents: 'auto', }} />
{drawerProject ? - :
Loading...
+ : ( +
+ {drawerProjectMissing ? 'Project not found.' : 'Loading project...'} +
+ ) }
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d1ab57a..abfd07f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,6 +8,7 @@ import type { HeatmapDay, VelocityWeek, ProjectVelocityWeek, FocusItem, HealthItem, TechStackItem, ContributionPatterns, StreakData, LifecycleItem, + ScanConfig, ScanResult, } from '../types' const BASE = '/api' @@ -19,7 +20,16 @@ async function req(path: string, init?: RequestInit): Promise { }) if (!res.ok) { const detail = await res.text() - throw new Error(`API ${res.status}: ${detail}`) + let message = detail + try { + const parsed = JSON.parse(detail) + message = typeof parsed.detail === 'string' + ? parsed.detail + : parsed.detail?.message ?? detail + } catch { + // Keep the raw response text when it is not JSON. + } + throw new Error(`API ${res.status}: ${message}`) } if (res.status === 204) return undefined as T return res.json() @@ -165,6 +175,17 @@ export const api = { ), }, + data: { + scanConfig: () => req('/data/scan-config'), + scan: (body: { root?: string; recursive?: boolean }) => { + const params = new URLSearchParams() + if (body.root?.trim()) params.set('root', body.root.trim()) + if (body.recursive) params.set('recursive', 'true') + const qs = params.toString() + return req(`/data/scan${qs ? '?' + qs : ''}`, { method: 'POST' }) + }, + }, + chatSessions: { get: (scopeType: string, projectId?: string | null) => { const params = new URLSearchParams({ scope_type: scopeType }) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index c657b4c..d36e539 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -324,7 +324,7 @@ function TableView({ projects, bucketMap, stateMap, openDrawer, sortField, sortD // ── Onboarding ────────────────────────────────────────────────────────────── function Onboarding({ buckets, states }: { buckets: Bucket[]; states: State[] }) { - const { openDrawer } = useAppStore() + const { openDrawer, setView } = useAppStore() const qc = useQueryClient() const [step, setStep] = useState<'welcome' | 'create'>('welcome') const [name, setName] = useState('') @@ -344,6 +344,8 @@ function Onboarding({ buckets, states }: { buckets: Bucket[]; states: State[] }) state_id: stateId || null, local_path: localPath || null, }) + qc.setQueryData(['projects'], (old: any) => Array.isArray(old) ? [p, ...old] : [p]) + qc.setQueryData(['projects', p.id], p) qc.invalidateQueries({ queryKey: ['projects'] }) openDrawer(p.id, 'overview') setCreating(false) @@ -402,15 +404,25 @@ function Onboarding({ buckets, states }: { buckets: Bucket[]; states: State[] }) ))} -
+
+
+

+ Scanning imports local paths, GitHub remotes, and git stats without requiring an AI key. +

) } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index c646f1c..dc94349 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -24,6 +24,8 @@ export function Header() { bucket_id: defaultBucket.id, state_id: defaultState?.id ?? null, }).then(p => { + qc.setQueryData(['projects'], (old: any) => Array.isArray(old) ? [p, ...old] : [p]) + qc.setQueryData(['projects', p.id], p) qc.invalidateQueries({ queryKey: ['projects'] }) openDrawer(p.id, 'overview') }) @@ -61,8 +63,8 @@ export function Header() { - + + {result && ( +
+
+ Scan complete - {result.total} repos found in {result.root} +
+
+ {result.created} new  ·  + {result.updated} updated  ·  + {result.skipped} skipped +
+
+ Commit history was not imported. Use Analytics sync when you want heatmaps, velocity, and health history. +
+ {result.projects.length > 0 && ( +
+ {result.projects.map((p, i) => ( +
+ {p.action} + {p.name} + {p.error && - {p.error}} +
+ ))} +
+ )} +
+ )} + + + ) +} + +function InlineCode({ children }: { children: React.ReactNode }) { + return {children} +} + + // ── Export ─────────────────────────────────────────────────────────────────── function ExportSection() { diff --git a/frontend/src/index.css b/frontend/src/index.css index 429dee4..43dacad 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -118,14 +118,29 @@ body { transition: all 0.12s; white-space: nowrap; } -.btn:hover { background: var(--surface2); } -.btn:disabled { opacity: 0.45; cursor: not-allowed; } +.btn:hover:not(:disabled) { background: var(--surface2); } +.btn:disabled { + cursor: not-allowed; + background: var(--surface2); + color: var(--muted); + border-color: var(--border2); +} .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } .btn-primary:hover:not(:disabled) { background: #1d4ed8; border-color: #1d4ed8; } +.btn-primary:disabled { + background: rgba(37,99,235,0.35); + border-color: rgba(37,99,235,0.25); + color: rgba(255,255,255,0.82); +} +[data-theme="light"] .btn-primary:disabled { + background: rgba(37,99,235,0.18); + border-color: rgba(37,99,235,0.22); + color: #3b4a68; +} .btn-ghost { border-color: transparent; color: var(--muted); } -.btn-ghost:hover { background: var(--surface2); color: var(--text); border-color: transparent; } +.btn-ghost:hover:not(:disabled) { background: var(--surface2); color: var(--text); border-color: transparent; } .btn-danger { color: #f87171; border-color: rgba(248,113,113,0.25); } -.btn-danger:hover { background: rgba(248,113,113,0.1); } +.btn-danger:hover:not(:disabled) { background: rgba(248,113,113,0.1); } .btn-sm { padding: 4px 10px; font-size: 11px; } .btn-icon { display: inline-flex; @@ -144,6 +159,17 @@ body { } .btn-icon:hover { background: var(--surface2); color: var(--text); } +.spinner { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--border3); + border-top-color: var(--accent); + display: inline-block; + flex-shrink: 0; + animation: spin 0.8s linear infinite; +} + /* ── Item delete button ── */ .item-del { background: none; @@ -194,6 +220,9 @@ body { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } +@keyframes spin { + to { transform: rotate(360deg); } +} @keyframes bounce { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-5px); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 297d618..b2480fc 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -292,6 +292,32 @@ export interface LifecycleItem { monthly_activity: { month: string; commits: number }[] } +// ── Local repo scan ───────────────────────────────────────────────────────── + +export interface ScanConfig { + projects_dir: string | null + projects_dir_raw: string | null + exists: boolean + configured: boolean + ready: boolean +} + +export interface ScanProjectResult { + action: 'created' | 'updated' | 'skipped' + name: string + path: string + error?: string +} + +export interface ScanResult { + root: string + total: number + created: number + updated: number + skipped: number + projects: ScanProjectResult[] +} + // ── UI helpers ────────────────────────────────────────────────────────────────