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:
@@ -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 }) }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,"&").replace(/</g,"<").replace(/>/g,">"); }
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });
|
||||
|
||||
Reference in New Issue
Block a user