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
+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(); });