Historique des versions : snapshots horodatés, différences, restauration

- Avant d'écraser un schéma, le contenu précédent est copié dans
  DATA_DIR/.history/<chemin>/<date>.excalidraw, au plus une version par
  HISTORY_INTERVAL (10 min) ; rétention HISTORY_KEEP (50) et HISTORY_MAX_AGE.
  Pas de git : l'image reste distroless.
- API : GET /api/history, GET /api/history/version, POST /api/history/snapshot,
  POST /api/history/restore (le contenu remplacé devient une version).
- L'historique suit renommages et déplacements, part à la corbeille avec le
  schéma supprimé.
- Dialogue Historique (barre du haut, menu de l'explorateur) : aperçu,
  différences colorées avec l'état actuel, restaurer, restaurer en copie,
  créer une version.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-07 18:03:08 +02:00
1 parent 88f7750577
commit 9f9ec3b3a4
14 files changed
+1005 -7

No files matched your search

+47 -1
View File
@@ -16,7 +16,8 @@ import type {
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import { api, ApiError, basename, stripExt, type Settings } from "./api";
import { Explorer } from "./Explorer";
import { CloseIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon, UpdateIcon } from "./icons";
import { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon, UpdateIcon } from "./icons";
import { HistoryDialog } from "./HistoryDialog";
import { Splitter } from "./Splitter";
import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
import { pendingVersion, useUpdateStatus } from "./update";
@@ -91,6 +92,7 @@ export default function App() {
const updateStatus = useUpdateStatus();
const pending = pendingVersion(updateStatus, settings.channel);
const [customizing, setCustomizing] = useState<false | "all" | "excalidraw">(false);
const [historyFor, setHistoryFor] = useState<string | null>(null);
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
@@ -335,6 +337,28 @@ export default function App() {
[closeFile],
);
// --- Historique -------------------------------------------------------------
/** Les modifications en cours sont enregistrées avant d'afficher l'historique. */
const showHistory = useCallback(
async (path: string) => {
if (path === pathRef.current) await save();
setHistoryFor(path);
},
[save],
);
const restoreVersion = useCallback(
async (path: string, id: string) => {
const open = path === pathRef.current;
if (open) await save();
await api.restore(path, id);
if (open) await openFile(path, { skipFlush: true });
setRefreshKey((k) => k + 1);
},
[save, openFile],
);
// --- Rendu -----------------------------------------------------------------
return (
@@ -378,6 +402,14 @@ export default function App() {
<SaveIcon />
<span className="save-label">Enregistrer</span>
</button>
<button
className="icon-btn"
aria-label="Historique des versions"
title="Historique des versions"
onClick={() => showHistory(scene.path)}
>
<HistoryIcon />
</button>
<button
className="icon-btn"
aria-label="Fermer le schéma"
@@ -425,6 +457,7 @@ export default function App() {
onOpen={(p) => openFile(p)}
onMoved={onMoved}
onDeleted={onDeleted}
onHistory={showHistory}
/>
</aside>
<Splitter
@@ -461,6 +494,19 @@ export default function App() {
)}
</main>
{historyFor && (
<HistoryDialog
path={historyFor}
theme={theme}
onRestore={(id) => restoreVersion(historyFor, id)}
onCopied={(p) => {
setRefreshKey((k) => k + 1);
openFile(p);
}}
onClose={() => setHistoryFor(null)}
/>
)}
{customizing && (
<SettingsDialog
settings={settings}
+4 -1
View File
@@ -52,6 +52,8 @@ interface Props {
onMoved: (from: string, to: string) => void;
/** Un fichier ou dossier a été supprimé. */
onDeleted: (path: string) => void;
/** Affiche l'historique des versions d'un schéma. */
onHistory: (path: string) => void;
}
type View = "list" | "grid";
@@ -78,7 +80,7 @@ const initialView = (): View => {
const canMoveInto = (path: string, targetDir: string) =>
dirname(path) !== targetDir && targetDir !== path && !targetDir.startsWith(path + "/");
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Props) {
export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHistory }: Props) {
const [dir, setDir] = useState(() => (current ? dirname(current) : ""));
const [entries, setEntries] = useState<Entry[]>([]);
const [query, setQuery] = useState("");
@@ -421,6 +423,7 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
<button onClick={() => rename(e)}>Renommer</button>
<button onClick={() => setMoving([e.path])}>Déplacer…</button>
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
{!e.isDir && <button onClick={() => onHistory(e.path)}>Historique…</button>}
{!e.isDir && (
<a href={api.downloadUrl(e.path)} download={e.name}>
Télécharger
+298
View File
@@ -0,0 +1,298 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { exportToSvg, restoreElements } from "@excalidraw/excalidraw";
import type { BinaryFiles } from "@excalidraw/excalidraw/types";
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import { api, basename, dirname, join, stripExt, withExt, type Version } from "./api";
import { DownloadIcon, HistoryIcon } from "./icons";
interface Props {
path: string;
theme: "light" | "dark";
/** Restaure la version (le parent enregistre et recharge le schéma ouvert). */
onRestore: (id: string) => Promise<void>;
/** Une copie a été créée à partir d'une version. */
onCopied: (path: string) => void;
onClose: () => void;
}
interface Scene {
elements: readonly ExcalidrawElement[];
files: BinaryFiles;
background: string;
}
type View = "version" | "current" | "diff";
const CURRENT = "";
const parse = (text: string): Scene => {
const data = JSON.parse(text);
return {
elements: restoreElements(data.elements ?? [], null).filter((e) => !e.isDeleted),
files: data.files ?? {},
background: data.appState?.viewBackgroundColor ?? "#ffffff",
};
};
const fmtDate = (iso: string) =>
new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
const fmtAge = (iso: string) => {
const s = (Date.now() - new Date(iso).getTime()) / 1000;
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
if (s < 3600) return rtf.format(-Math.max(1, Math.round(s / 60)), "minute");
if (s < 86400) return rtf.format(-Math.round(s / 3600), "hour");
return rtf.format(-Math.round(s / 86400), "day");
};
const fmtSize = (n: number) => (n < 1 << 20 ? `${Math.max(1, Math.round(n / 1024))} Ko` : `${(n / (1 << 20)).toFixed(1)} Mo`);
// Différences élément par élément (identifiants stables dans Excalidraw).
const DIFF = {
added: { stroke: "#2f9e44", fill: "#b2f2bb", label: "ajouté" },
removed: { stroke: "#e03131", fill: "#ffc9c9", label: "supprimé" },
changed: { stroke: "#f08c00", fill: "#ffec99", label: "modifié" },
} as const;
type Change = keyof typeof DIFF;
function diff(older: Scene, now: Scene) {
const before = new Map(older.elements.map((e) => [e.id, e]));
const after = new Map(now.elements.map((e) => [e.id, e]));
const kind = new Map<string, Change>();
for (const [id, e] of after) {
const o = before.get(id);
if (!o) kind.set(id, "added");
else if (o.version !== e.version || o.versionNonce !== e.versionNonce) kind.set(id, "changed");
}
for (const id of before.keys()) if (!after.has(id)) kind.set(id, "removed");
const count = { added: 0, removed: 0, changed: 0 };
for (const k of kind.values()) count[k]++;
// Scène de comparaison : l'état actuel plus les éléments disparus, colorés
// selon le changement ; ce qui n'a pas bougé est estompé.
const paint = (e: ExcalidrawElement): ExcalidrawElement => {
const container = "containerId" in e ? e.containerId : null; // texte d'une forme
const k = kind.get(e.id) ?? (container ? kind.get(container) : undefined);
if (!k) return { ...e, opacity: 20 };
const c = DIFF[k];
return {
...e,
opacity: 100,
strokeColor: c.stroke,
backgroundColor: e.backgroundColor === "transparent" ? "transparent" : c.fill,
...(k === "removed" ? { strokeStyle: "dashed" as const } : {}),
};
};
const elements = [...now.elements, ...older.elements.filter((e) => !after.has(e.id))].map(paint);
return { count, scene: { elements, files: { ...older.files, ...now.files }, background: now.background } };
}
/** Historique d'un schéma : aperçu, comparaison avec l'état actuel, restauration. */
export function HistoryDialog({ path, theme, onRestore, onCopied, onClose }: Props) {
const [versions, setVersions] = useState<Version[] | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [view, setView] = useState<View>("diff");
const [scenes, setScenes] = useState<Record<string, Scene>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const preview = useRef<HTMLDivElement>(null);
const name = stripExt(basename(path));
const run = async (op: () => Promise<unknown>) => {
setBusy(true);
setError(null);
try {
await op();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const loadList = () =>
run(async () => {
const [vs, cur] = await Promise.all([api.history(path), api.read(path)]);
setScenes((s) => ({ ...s, [CURRENT]: parse(cur.text) }));
setVersions(vs);
setSelected((sel) => sel ?? vs[0]?.id ?? null);
});
useEffect(() => {
loadList();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Contenu de la version choisie (mis en cache : une version ne change pas).
useEffect(() => {
if (!selected || scenes[selected]) return;
let live = true;
api
.readVersion(path, selected)
.then((t) => live && setScenes((s) => ({ ...s, [selected]: parse(t) })))
.catch((e) => live && setError(e.message));
return () => {
live = false;
};
}, [path, selected, scenes]);
const older = selected ? scenes[selected] : undefined;
const now = scenes[CURRENT];
const comparison = useMemo(() => (older && now ? diff(older, now) : null), [older, now]);
const shown = view === "current" ? now : view === "version" ? older : comparison?.scene;
// Aperçu SVG rendu par Excalidraw.
useEffect(() => {
const box = preview.current;
if (!box) return;
box.replaceChildren();
if (!shown) return;
if (shown.elements.length === 0) {
box.textContent = "Schéma vide";
return;
}
let live = true;
exportToSvg({
elements: shown.elements,
appState: {
viewBackgroundColor: shown.background,
exportBackground: true,
exportWithDarkMode: theme === "dark",
},
files: shown.files,
exportPadding: 16,
})
.then((svg: SVGSVGElement) => {
if (!live) return;
svg.removeAttribute("width");
svg.removeAttribute("height");
box.replaceChildren(svg);
})
.catch((e: Error) => live && setError(`Aperçu impossible : ${e.message}`));
return () => {
live = false;
};
}, [shown, theme]);
const current = versions?.find((v) => v.id === selected);
const restore = () =>
current &&
confirm(`Restaurer la version du ${fmtDate(current.time)} ?\n\nL'état actuel est conservé dans l'historique.`) &&
run(async () => {
await onRestore(current.id);
onClose();
});
const copy = () =>
current &&
run(async () => {
const d = new Date(current.time);
const stamp = `${d.toLocaleDateString().replace(/\//g, "-")} ${String(d.getHours()).padStart(2, "0")}h${String(d.getMinutes()).padStart(2, "0")}`;
const to = join(dirname(path), withExt(`${name} (version du ${stamp})`));
await api.write(to, await api.readVersion(path, current.id), "*");
onCopied(to);
onClose();
});
const snapshot = () =>
run(async () => {
const v = await api.snapshot(path);
setSelected(v.id);
await loadList();
});
return (
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal history"
role="dialog"
aria-modal="true"
aria-label={`Historique de ${name}`}
onClick={(e) => e.stopPropagation()}
>
<div className="history-head">
<h3>Historique · {name}</h3>
<button disabled={busy} onClick={snapshot} title="Enregistrer l'état actuel comme version">
<HistoryIcon />
<span>Créer une version</span>
</button>
</div>
<div className="history-body">
<ul className="versions-list">
{versions?.map((v) => (
<li key={v.id}>
<button className={v.id === selected ? "on" : ""} onClick={() => setSelected(v.id)}>
<span>{fmtDate(v.time)}</span>
<small className="muted">
{fmtAge(v.time)} · {fmtSize(v.size)}
</small>
</button>
</li>
))}
{versions?.length === 0 && (
<li className="muted empty">
Aucune version pour l'instant. Une version est conservée automatiquement quand le schéma est
modifié après une pause ; « Créer une version » en fixe une tout de suite.
</li>
)}
</ul>
<div className="history-preview">
<div className="segmented" role="group" aria-label="Aperçu">
{(
[
["diff", "Différences"],
["version", "Cette version"],
["current", "Actuelle"],
] as [View, string][]
).map(([id, label]) => (
<button key={id} className={view === id ? "on" : ""} disabled={!selected} onClick={() => setView(id)}>
{label}
</button>
))}
</div>
<div className="svg-box" ref={preview} />
{view === "diff" && comparison && (
<p className="legend">
<span className="muted">Depuis cette version :</span>
{(Object.keys(DIFF) as Change[]).map((k) => (
<span key={k}>
<i style={{ background: DIFF[k].stroke }} />
{comparison.count[k]} {DIFF[k].label}
{comparison.count[k] > 1 ? "s" : ""}
</span>
))}
</p>
)}
</div>
</div>
{error && <div className="error">{error}</div>}
<div className="modal-actions">
{current && (
<a className="link" href={api.versionUrl(path, current.id)} download={`${name}.excalidraw`} title="Télécharger cette version">
<DownloadIcon />
</a>
)}
<span className="spacer" />
<button onClick={onClose}>Fermer</button>
<button disabled={busy || !current} onClick={copy} title="Créer un nouveau schéma à partir de cette version">
Restaurer en copie
</button>
<button className="primary" disabled={busy || !current} onClick={restore}>
Restaurer
</button>
</div>
</div>
</div>
);
}
+23
View File
@@ -32,6 +32,13 @@ export interface UpdateStatus {
checkedAt?: string;
}
/** Version enregistrée d'un schéma (time : date de ce contenu). */
export interface Version {
id: string;
time: string;
size: number;
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
@@ -98,6 +105,22 @@ export const api = {
await request(`/api/entry?path=${q(path)}&recursive=${recursive}`, { method: "DELETE" });
},
history: async (path: string): Promise<Version[]> =>
(await request(`/api/history?path=${q(path)}`)).json(),
readVersion: async (path: string, id: string): Promise<string> =>
(await request(`/api/history/version?path=${q(path)}&id=${q(id)}`)).text(),
/** Crée une version du contenu courant (sans attendre l'intervalle automatique). */
snapshot: async (path: string): Promise<Version> =>
(await request("/api/history/snapshot", json({ path }))).json(),
/** Restaure une version ; le contenu remplacé devient lui-même une version. */
restore: async (path: string, id: string): Promise<Entry> =>
(await request("/api/history/restore", json({ path, id }))).json(),
versionUrl: (path: string, id: string) => `/api/history/version?path=${q(path)}&id=${q(id)}`,
readLibrary: async (): Promise<unknown> => (await request("/api/library")).json(),
writeLibrary: async (body: string): Promise<void> => {
+14
View File
@@ -149,3 +149,17 @@ export const CopyIcon = icon(
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
</>,
);
export const HistoryIcon = icon(
<>
<path d="M12 8v4l2 2" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</>,
);
export const DownloadIcon = icon(
<>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
<path d="M7 11l5 5 5-5M12 4v12" />
</>,
);
+122
View File
@@ -1005,3 +1005,125 @@ li.active > .row {
background: var(--ex-primary);
color: var(--ex-surface-lowest);
}
/* --- Historique des versions ----------------------------------------------- */
.modal.history {
width: min(920px, 100%);
height: min(640px, 92dvh);
max-height: none;
}
.history-head {
display: flex;
align-items: center;
gap: 8px;
}
.history-head h3 {
flex: 1;
min-width: 0;
}
.history-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 240px 1fr;
gap: 10px;
}
.versions-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--ex-radius-lg);
background: var(--ex-surface-lowest);
}
.versions-list button {
width: 100%;
height: auto;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 8px 10px;
border-radius: 0;
background: transparent;
box-shadow: none;
font-size: 13px;
letter-spacing: normal;
}
.versions-list button.on {
background: var(--accent-soft);
color: var(--ex-on-primary-container);
}
.versions-list small {
font-size: 11px;
}
.versions-list .empty {
padding: 10px;
font-size: 13px;
}
.history-preview {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.history-preview .segmented {
align-self: flex-start;
}
.history-preview .segmented button {
height: calc(var(--ex-btn-lg) - 6px);
background: transparent;
box-shadow: none;
}
.history-preview .segmented button.on {
background: var(--ex-primary-container);
color: var(--ex-on-primary-container);
}
.svg-box {
flex: 1;
min-height: 0;
display: grid;
place-items: center;
padding: 8px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--ex-radius-lg);
background: var(--ex-surface-lowest);
color: var(--muted);
}
.svg-box svg {
max-width: 100%;
max-height: 100%;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin: 0;
font-size: 12px;
}
.legend i {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 5px;
border-radius: 2px;
}
.modal-actions a.link {
display: inline-flex;
padding: 6px;
}
@media (max-width: 640px) {
.modal.history {
height: 92dvh;
}
.history-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(90px, 30%) 1fr;
}
.history-head button span {
display: none;
}
}