diff --git a/backend/app/main.py b/backend/app/main.py index 06f5f97..eff35bc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles from sqlalchemy import text, inspect as sa_inspect from .database import Base, engine, SessionLocal -from .routers import bundles, connectors, diagrams, devices, export, octopart, views, wires +from .routers import bundles, connectors, diagrams, devices, export, git_mgr, octopart, views, wires Base.metadata.create_all(bind=engine) @@ -70,6 +70,7 @@ app.include_router(views.router, prefix="/api/views") app.include_router(octopart.router, prefix="/api/octopart") app.include_router(export.router, prefix="/api/export") app.include_router(connectors.router, prefix="/api/connectors") +app.include_router(git_mgr.router, prefix="/api/git") FRONTEND_DIR = os.path.normpath( os.path.join(os.path.dirname(__file__), "..", "..", "frontend") diff --git a/backend/app/routers/git_mgr.py b/backend/app/routers/git_mgr.py new file mode 100644 index 0000000..4a928c3 --- /dev/null +++ b/backend/app/routers/git_mgr.py @@ -0,0 +1,235 @@ +import json +import re +import subprocess +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from .. import models +from ..database import get_db + +router = APIRouter(tags=["git"]) + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _run(*args, cwd: Path) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], + capture_output=True, text=True, cwd=str(cwd), timeout=30, + ) + + +def _repo_root() -> Path: + backend_dir = Path(__file__).parent.parent.parent + r = _run("rev-parse", "--show-toplevel", cwd=backend_dir) + if r.returncode != 0: + raise HTTPException(500, "Not in a git repository") + return Path(r.stdout.strip()) + + +def _diagrams_dir(repo_root: Path) -> Path: + d = repo_root / "diagrams" + d.mkdir(exist_ok=True) + return d + + +def _safe_name(diagram_id: int, name: str) -> str: + safe = re.sub(r"[^\w\-]", "_", name) + return f"{diagram_id:04d}_{safe}.json" + + +def _diagram_to_dict(diagram, db: Session) -> dict: + return { + "id": diagram.id, + "name": diagram.name, + "description": diagram.description or "", + "devices": [ + { + "id": d.id, "device_type": d.device_type, "label": d.label, + "reference": d.reference, "x": d.x, "y": d.y, + "width": d.width, "height": d.height, + "properties": d.properties, "pins": d.pins, + } + for d in diagram.devices + ], + "wires": [ + { + "id": w.id, "label": w.label, + "from_device_id": w.from_device_id, "from_pin": w.from_pin, + "to_device_id": w.to_device_id, "to_pin": w.to_pin, + "color_primary": w.color_primary, "color_stripe": w.color_stripe, + "gauge": w.gauge, "length": w.length, "length_unit": w.length_unit, + "notes": w.notes, "waypoints": w.waypoints or [], + "twisted_pair": bool(w.twisted_pair), "twist_pitch": w.twist_pitch, + "shielded": bool(w.shielded), "show_size_label": bool(w.show_size_label), + } + for w in diagram.wires + ], + } + + +def _import_data(data: dict, db: Session, name_override: Optional[str] = None) -> models.Diagram: + new_diag = models.Diagram( + name=name_override or data.get("name", "Restored Diagram"), + description=data.get("description", ""), + ) + db.add(new_diag) + db.flush() + id_map = {} + for dev in data.get("devices", []): + nd = models.Device( + diagram_id=new_diag.id, + device_type=dev.get("device_type", "generic"), + label=dev.get("label", ""), + reference=dev.get("reference", ""), + x=dev.get("x", 100), y=dev.get("y", 100), + width=dev.get("width", 120), height=dev.get("height", 60), + properties=dev.get("properties", {}), + pins=dev.get("pins", []), + ) + db.add(nd) + db.flush() + if "id" in dev: + id_map[dev["id"]] = nd.id + for w in data.get("wires", []): + nw = models.Wire( + diagram_id=new_diag.id, + label=w.get("label", ""), + from_device_id=id_map.get(w.get("from_device_id")), + from_pin=w.get("from_pin", ""), + to_device_id=id_map.get(w.get("to_device_id")), + to_pin=w.get("to_pin", ""), + color_primary=w.get("color_primary", "#FF0000"), + color_stripe=w.get("color_stripe"), + gauge=w.get("gauge", "18 AWG"), + length=w.get("length"), + length_unit=w.get("length_unit", "in"), + notes=w.get("notes", ""), + waypoints=w.get("waypoints", []), + twisted_pair=w.get("twisted_pair", False), + twist_pitch=w.get("twist_pitch", 16.0), + shielded=w.get("shielded", False), + show_size_label=w.get("show_size_label", False), + ) + db.add(nw) + db.commit() + db.refresh(new_diag) + return new_diag + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.get("/status") +def git_status(): + repo_root = _repo_root() + + branch_r = _run("rev-parse", "--abbrev-ref", "HEAD", cwd=repo_root) + branch = branch_r.stdout.strip() if branch_r.returncode == 0 else "unknown" + + status_r = _run("status", "--porcelain", cwd=repo_root) + changed = [l for l in status_r.stdout.splitlines() if l.strip()] + + ahead_r = _run("rev-list", "--count", "@{u}..HEAD", cwd=repo_root) + ahead = int(ahead_r.stdout.strip()) if ahead_r.returncode == 0 and ahead_r.stdout.strip().isdigit() else 0 + + return { + "branch": branch, + "is_clean": len(changed) == 0, + "changed_count": len(changed), + "commits_ahead": ahead, + } + + +@router.get("/log") +def git_log(): + repo_root = _repo_root() + r = _run("log", "--pretty=format:%H|%h|%s|%an|%ai", "-30", cwd=repo_root) + if r.returncode != 0: + return [] + entries = [] + for line in r.stdout.splitlines(): + parts = line.split("|", 4) + if len(parts) == 5: + entries.append({ + "hash": parts[0], "short": parts[1], + "message": parts[2], "author": parts[3], "date": parts[4], + }) + return entries + + +class CommitBody(BaseModel): + message: str + + +@router.post("/commit") +def git_commit(body: CommitBody, db: Session = Depends(get_db)): + if not body.message.strip(): + raise HTTPException(400, "Commit message required") + repo_root = _repo_root() + diagrams_dir = _diagrams_dir(repo_root) + + # Export all diagrams to diagrams/ folder + diagrams = db.query(models.Diagram).all() + for diagram in diagrams: + data = _diagram_to_dict(diagram, db) + fname = _safe_name(diagram.id, diagram.name) + (diagrams_dir / fname).write_text(json.dumps(data, indent=2), encoding="utf-8") + + add_r = _run("add", "diagrams/", cwd=repo_root) + if add_r.returncode != 0: + raise HTTPException(500, f"git add failed: {add_r.stderr}") + + commit_r = _run("commit", "-m", body.message.strip(), cwd=repo_root) + if commit_r.returncode != 0: + out = commit_r.stdout + commit_r.stderr + if "nothing to commit" in out: + return {"status": "nothing_to_commit", "output": "Nothing to commit — diagrams unchanged since last commit"} + raise HTTPException(500, f"git commit failed: {commit_r.stderr}") + + return {"status": "committed", "output": commit_r.stdout.strip()} + + +@router.post("/push") +def git_push(): + repo_root = _repo_root() + r = _run("push", cwd=repo_root) + if r.returncode != 0: + raise HTTPException(500, f"git push failed: {r.stderr.strip()}") + return {"status": "pushed", "output": (r.stdout + r.stderr).strip()} + + +@router.get("/history/{commit_hash}") +def git_history_files(commit_hash: str): + repo_root = _repo_root() + r = _run("ls-tree", "--name-only", commit_hash, "diagrams/", cwd=repo_root) + if r.returncode != 0: + return {"files": []} + files = [f for f in r.stdout.splitlines() if f.endswith(".json")] + return {"files": files} + + +class RestoreBody(BaseModel): + commit_hash: str + filepath: str # e.g. "diagrams/0001_My_Diagram.json" + name: Optional[str] = None + + +@router.post("/restore") +def git_restore(body: RestoreBody, db: Session = Depends(get_db)): + repo_root = _repo_root() + r = _run("show", f"{body.commit_hash}:{body.filepath}", cwd=repo_root) + if r.returncode != 0: + raise HTTPException(404, f"File not found in that commit") + + try: + data = json.loads(r.stdout) + except json.JSONDecodeError: + raise HTTPException(500, "Invalid JSON in git history") + + short_hash = body.commit_hash[:7] + name = body.name or f"{data.get('name', 'Diagram')} (restored {short_hash})" + new_diag = _import_data(data, db, name_override=name) + return {"id": new_diag.id, "name": new_diag.name} diff --git a/backend/run.py b/backend/run.py index 4753965..55bbf84 100644 --- a/backend/run.py +++ b/backend/run.py @@ -1,4 +1,4 @@ import uvicorn if __name__ == "__main__": - uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=True) + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/frontend/css/main.css b/frontend/css/main.css index 624f349..c620b93 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -304,6 +304,35 @@ button:active { background: #1a1a3a; } .drc-row:last-child { border-bottom: none; } .drc-none { color: #444466; font-style: italic; } +/* ── Git modal ────────────────────────────────────────────────────────────── */ +.git-badge { + font-size: 11px; padding: 2px 8px; border-radius: 10px; + background: #1a1a30; color: #8899bb; border: 1px solid #2a2a44; +} +.git-badge.git-clean { color: #88cc88; border-color: #336633; } +.git-badge.git-dirty { color: #ddaa44; border-color: #554422; } +.git-section { display: flex; flex-direction: column; gap: 5px; } +.git-section-title { + font-size: 10px; font-weight: 600; text-transform: uppercase; + letter-spacing: .06em; color: #555577; +} +.git-log-row { + display: grid; grid-template-columns: 52px 1fr 80px auto; + align-items: center; gap: 8px; + padding: 4px 6px; border-radius: 3px; background: #0d0d20; + border: 1px solid transparent; +} +.git-log-row:hover { border-color: #2a2a44; } +.git-hash { font-family: monospace; color: #6688cc; font-size: 11px; } +.git-msg { color: #bbc; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.git-date { color: #445; font-size: 10px; text-align: right; } +.git-restore-btn { + font-size: 10px; padding: 2px 6px; + background: #1a2840; border: 1px solid #334466; border-radius: 3px; + color: #88aadd; cursor: pointer; white-space: nowrap; +} +.git-restore-btn:hover { background: #223355; color: #aaccff; } + .cm-label { display: block; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: #555577; margin-bottom: 4px; diff --git a/frontend/index.html b/frontend/index.html index 7bfd66d..ca536c5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -38,6 +38,7 @@
+ + diff --git a/frontend/js/api.js b/frontend/js/api.js index cd399bd..50b8b64 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -60,4 +60,12 @@ const api = { jsonUrl: (id) => `${API_BASE}/export/${id}/json`, formboardUrl: (id) => `${API_BASE}/export/${id}/formboard`, }, + git: { + status: () => apiFetch("/git/status"), + log: () => apiFetch("/git/log"), + commit: (message) => apiFetch("/git/commit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }) }), + push: () => apiFetch("/git/push", { method: "POST" }), + history: (hash) => apiFetch(`/git/history/${hash}`), + restore: (commit_hash, filepath, name) => apiFetch("/git/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ commit_hash, filepath, name }) }), + }, }; diff --git a/frontend/js/app.js b/frontend/js/app.js index 0c770e5..98cc6a5 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -455,6 +455,7 @@ class WiringApp { on("btn-json", () => this._export("json")); on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); }); on("btn-formboard", () => this._export("formboard")); + on("btn-git", () => this._openGitModal()); on("btn-drc", () => this._runDrc()); on("btn-import", () => document.getElementById("import-file-input")?.click()); document.getElementById("import-file-input")?.addEventListener("change", (e) => { @@ -2001,6 +2002,143 @@ class WiringApp { } this.openConnectorModal(connData); } + + // ── Git version control ─────────────────────────────────────────────────── + + async _openGitModal() { + const modal = document.getElementById("git-modal"); + if (!modal) return; + modal.style.display = "flex"; + + // Wire close button once + const closeBtn = document.getElementById("git-close"); + if (!closeBtn._gitBound) { + closeBtn._gitBound = true; + closeBtn.addEventListener("click", () => { modal.style.display = "none"; }); + modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; }); + } + + // Wire action buttons once + const commitBtn = document.getElementById("btn-git-commit"); + if (!commitBtn._gitBound) { + commitBtn._gitBound = true; + commitBtn.addEventListener("click", () => this._gitCommit()); + document.getElementById("btn-git-push").addEventListener("click", () => this._gitPush()); + } + + await this._gitRefresh(); + } + + async _gitRefresh() { + await Promise.all([this._gitLoadStatus(), this._gitLoadLog()]); + } + + async _gitLoadStatus() { + const badge = document.getElementById("git-status-badge"); + if (!badge) return; + try { + const s = await api.git.status(); + const ahead = s.commits_ahead > 0 ? ` · ${s.commits_ahead} ahead` : ""; + const dirty = s.is_clean ? "clean" : `${s.changed_count} changed`; + badge.textContent = `${s.branch} ${dirty}${ahead}`; + badge.className = "git-badge " + (s.is_clean ? "git-clean" : "git-dirty"); + } catch { + badge.textContent = "git unavailable"; + badge.className = "git-badge"; + } + } + + async _gitLoadLog() { + const list = document.getElementById("git-log-list"); + if (!list) return; + list.innerHTML = '