Add git version control panel and LAN hosting support

- Git modal (⎇ Git button): commit all diagrams as JSON to diagrams/ folder,
  push to remote, view history, restore any diagram from any past commit
- Diagrams exported to diagrams/{id}_{name}.json on each commit so diffs are
  human-readable and individual diagrams can be restored independently
- backend/run.py: changed host to 0.0.0.0 for LAN access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 19:48:29 -04:00
parent e4a80dc0e6
commit 01e7b5630b
7 changed files with 450 additions and 2 deletions
+2 -1
View File
@@ -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")
+235
View File
@@ -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}
+1 -1
View File
@@ -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)
+29
View File
@@ -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;
+37
View File
@@ -38,6 +38,7 @@
</div>
<div class="toolbar-right">
<button id="btn-git" title="Git version control">⎇ Git</button>
<button id="btn-drc" title="Run design-rule check">⚠ DRC</button>
<div id="saved-indicator">✓ Saved</div>
<div class="export-wrap">
@@ -419,6 +420,42 @@
</div>
</div>
<div id="git-modal" style="display:none">
<div class="modal-box" style="min-width:520px;max-width:700px">
<div class="modal-header">
<h3>⎇ Git Version Control</h3>
<span id="git-status-badge" class="git-badge"></span>
</div>
<div class="modal-body" style="padding:12px;gap:10px;display:flex;flex-direction:column">
<!-- Commit section -->
<div class="git-section">
<label class="git-section-title">Commit all diagrams</label>
<div style="display:flex;gap:6px;align-items:flex-start">
<textarea id="git-commit-msg" placeholder="Describe what changed…" rows="2"
style="flex:1;resize:vertical;font-size:12px;padding:5px;background:#0d0d20;color:#ccd;border:1px solid #2a2a44;border-radius:4px"></textarea>
<div style="display:flex;flex-direction:column;gap:4px">
<button id="btn-git-commit" class="btn-primary" style="white-space:nowrap">⊕ Commit</button>
<button id="btn-git-push" style="white-space:nowrap">⇧ Push</button>
</div>
</div>
<div id="git-op-status" style="font-size:11px;color:#88cc88;min-height:16px"></div>
</div>
<!-- History section -->
<div class="git-section" style="flex:1">
<label class="git-section-title">Commit history</label>
<div id="git-log-list" style="max-height:320px;overflow-y:auto;font-size:11px;display:flex;flex-direction:column;gap:1px"></div>
</div>
</div>
<div class="modal-footer">
<div style="flex:1"></div>
<button id="git-close">Close</button>
</div>
</div>
</div>
<script src="https://unpkg.com/konva@9/konva.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/deviceTypes.js"></script>
+8
View File
@@ -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 }) }),
},
};
+138
View File
@@ -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 = '<div style="color:#556;padding:4px">Loading…</div>';
try {
const entries = await api.git.log();
if (!entries.length) {
list.innerHTML = '<div style="color:#556;padding:4px">No commits yet</div>';
return;
}
list.innerHTML = "";
entries.forEach(e => {
const row = document.createElement("div");
row.className = "git-log-row";
row.innerHTML = `
<span class="git-hash">${e.short}</span>
<span class="git-msg">${this._esc(e.message)}</span>
<span class="git-date">${e.date.slice(0, 10)}</span>
<button class="git-restore-btn" data-hash="${e.hash}" title="Restore a diagram from this commit">↩ Restore</button>
`;
row.querySelector(".git-restore-btn").addEventListener("click", () => this._gitRestoreFlow(e));
list.appendChild(row);
});
} catch {
list.innerHTML = '<div style="color:#e06c6c;padding:4px">Failed to load log</div>';
}
}
async _gitCommit() {
const msg = document.getElementById("git-commit-msg")?.value?.trim();
if (!msg) { alert("Enter a commit message first."); return; }
const status = document.getElementById("git-op-status");
status.textContent = "Committing…"; status.style.color = "#aabb88";
try {
const r = await api.git.commit(msg);
if (r.status === "nothing_to_commit") {
status.textContent = "Nothing to commit — diagrams unchanged.";
status.style.color = "#8899bb";
} else {
status.textContent = "✓ Committed: " + r.output.split("\n")[0];
status.style.color = "#88cc88";
document.getElementById("git-commit-msg").value = "";
await this._gitRefresh();
}
} catch (e) {
status.textContent = "✗ " + (e.message || "Commit failed");
status.style.color = "#e06c6c";
}
}
async _gitPush() {
const status = document.getElementById("git-op-status");
status.textContent = "Pushing…"; status.style.color = "#aabb88";
try {
const r = await api.git.push();
status.textContent = "✓ Pushed. " + (r.output || "").split("\n")[0];
status.style.color = "#88cc88";
await this._gitLoadStatus();
} catch (e) {
status.textContent = "✗ Push failed: " + (e.message || "unknown error");
status.style.color = "#e06c6c";
}
}
async _gitRestoreFlow(entry) {
const status = document.getElementById("git-op-status");
status.textContent = `Loading files from ${entry.short}`; status.style.color = "#aabb88";
try {
const { files } = await api.git.history(entry.hash);
if (!files.length) { status.textContent = "No diagrams in that commit."; status.style.color = "#8899bb"; return; }
// Build a small picker inline
const pick = files.map((f, i) => `${i + 1}. ${f.replace("diagrams/", "").replace(/\.json$/, "").replace(/^\d{4}_/, "").replace(/_/g, " ")}`).join("\n");
const choice = prompt(`Diagrams in commit ${entry.short} — "${entry.message}"\n\nEnter number to restore:\n${pick}`);
if (!choice) { status.textContent = ""; return; }
const idx = parseInt(choice) - 1;
if (isNaN(idx) || idx < 0 || idx >= files.length) { status.textContent = "Invalid selection."; return; }
const filepath = files[idx];
status.textContent = `Restoring ${filepath}`; status.style.color = "#aabb88";
const r = await api.git.restore(entry.hash, filepath);
status.textContent = `✓ Restored as "${r.name}" (id ${r.id})`;
status.style.color = "#88cc88";
await this.loadDiagramList();
} catch (e) {
status.textContent = "✗ Restore failed: " + (e.message || "unknown");
status.style.color = "#e06c6c";
}
}
_esc(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
}
window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });