Implemented confi snapshots

This commit is contained in:
Martino Ferrari
2026-06-21 23:50:03 +02:00
parent 04d31a15c4
commit 113e5a0fe8
51 changed files with 4921 additions and 241 deletions
+41 -111
View File
@@ -11,20 +11,13 @@ import PropertiesPane from './PropertiesPane';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
import { VersionTree, DiffViewer } from './VersionHistory';
interface Props {
initial: Interface | null;
onDone: (iface: Interface | null) => void;
}
interface VersionMeta {
version: number;
name: string;
tag?: string;
current: boolean;
savedAt: string;
}
function blankInterface(): Interface {
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
}
@@ -83,6 +76,7 @@ const STATIC_WIDGET_TYPES = [
{ type: 'button', label: 'Action Button' },
{ type: 'image', label: 'Image' },
{ type: 'link', label: 'Link' },
{ type: 'configselect', label: 'Config Selector' },
];
export default function EditMode({ initial, onDone }: Props) {
@@ -105,8 +99,9 @@ export default function EditMode({ initial, onDone }: Props) {
// History panel
const [showHistory, setShowHistory] = useState(false);
const [versions, setVersions] = useState<VersionMeta[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
const [verReload, setVerReload] = useState(0);
const [tag, setTag] = useState('');
// Bumped whenever the editor content is replaced wholesale (version load /
// promote) to force a full canvas remount, so no stale widget state lingers.
@@ -328,6 +323,7 @@ export default function EditMode({ initial, onDone }: Props) {
options:
type === 'textlabel' ? { label: 'Label' } :
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
{},
};
handleWidgetsChange([...(iface.widgets || []), newWidget]);
@@ -359,7 +355,8 @@ export default function EditMode({ initial, onDone }: Props) {
setDirty(false);
setTag('');
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
if (showHistory) loadVersions();
setViewingVersion(null);
setVerReload(k => k + 1);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -370,26 +367,12 @@ export default function EditMode({ initial, onDone }: Props) {
// ── Version history ────────────────────────────────────────────────────────
const loadVersions = useCallback(async () => {
if (!iface.id) { setVersions([]); return; }
setHistoryLoading(true);
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
setVersions(await res.json());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setHistoryLoading(false);
}
}, [iface.id]);
const toggleHistory = useCallback(() => {
setShowHistory(s => {
if (!s) loadVersions();
if (!s) setVerReload(k => k + 1);
return !s;
});
}, [loadVersions]);
}, []);
// Load a past revision into the editor non-destructively: the current id is
// preserved, so saving the restored content creates a new revision on top.
@@ -403,45 +386,26 @@ export default function EditMode({ initial, onDone }: Props) {
restored.id = iface.id; // keep editing the same interface
setIface(restored);
setSelectedIds([]);
setDirty(true);
// Viewing a past revision is not itself a change — only subsequent edits
// dirty the editor (saving then promotes it to a new revision).
setDirty(false);
setCanvasNonce(n => n + 1);
undoStack.current = [];
redoStack.current = [];
setHistoryLen(0);
setViewingVersion(version);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, dirty]);
// Edit (or clear) the label of an existing revision in place.
const editVersionTag = useCallback(async (version: number, currentTag: string) => {
// Reload the now-current interface content into the editor (after a promote,
// which the VersionTree performs server-side).
const reloadCurrentIface = useCallback(async () => {
if (!iface.id) return;
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
if (next === null) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
{ method: 'PUT' },
);
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
loadVersions();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, loadVersions]);
// Make a past revision the current one (saved as a new revision on top).
const promoteVersion = useCallback(async (version: number) => {
if (!iface.id) return;
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
// Reload the now-current interface content into the editor.
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
if (!cur.ok) throw new Error(`Reload failed (${cur.status})`);
const reloaded = parseInterface(await cur.text());
setIface(reloaded);
setSelectedIds([]);
@@ -450,25 +414,9 @@ export default function EditMode({ initial, onDone }: Props) {
undoStack.current = [];
redoStack.current = [];
setHistoryLen(0);
setViewingVersion(null);
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
loadVersions();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, dirty, loadVersions]);
// Fork a revision into a brand-new interface.
const forkVersion = useCallback(async (version: number) => {
if (!iface.id) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
const json = await res.json();
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
setVerReload(k => k + 1);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
@@ -733,49 +681,31 @@ export default function EditMode({ initial, onDone }: Props) {
Save snapshot
</button>
</div>
<div class="history-list">
{historyLoading && <div class="history-empty">Loading</div>}
{!historyLoading && versions.length === 0 && (
<div class="history-empty">No saved versions yet.</div>
)}
{!historyLoading && versions.map(v => (
<div
key={v.version}
class={`history-item${v.current ? ' history-item-current' : ''}`}
>
<div class="history-item-main">
<span class="history-version">v{v.version}</span>
{v.tag && <span class="history-tag">{v.tag}</span>}
{v.current && <span class="history-current-badge">current</span>}
</div>
<div class="history-item-meta">
{new Date(v.savedAt).toLocaleString()}
</div>
<div class="history-item-actions">
{!v.current && (
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
Load
</button>
)}
{!v.current && (
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
Set current
</button>
)}
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
Fork
</button>
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
Label
</button>
</div>
</div>
))}
</div>
{!iface.id ? (
<div class="history-empty">Save the interface first to enable history.</div>
) : (
<VersionTree
base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
viewing={viewingVersion}
dirty={dirty}
canWrite={writable}
reloadKey={verReload}
onView={loadVersion}
onForked={() => window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))}
onPromoted={reloadCurrentIface}
onCompare={(av, bv) => setDiff({ av, bv })}
onError={setError} />
)}
</div>
)}
</div>
{diff && iface.id && (
<DiffViewer base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
av={diff.av} bv={diff.bv} title={`${iface.name} — v${diff.av} → v${diff.bv}`}
onClose={() => setDiff(null)} />
)}
{showHelp && (
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
)}