Files
excalicloud/frontend/src/Explorer.tsx
T
claude BotandClaude Opus 5.5 c78df092c1 Recherche plein texte dans le contenu des schémas
- /api/search cherche dans les noms et dans le texte des schémas (textes et
  libellés, noms de cadres, liens) ; tous les mots requis, casse et accents
  ignorés, correspondances sur le nom en premier.
- Index en mémoire côté serveur : texte extrait à la première recherche, relu
  seulement si le fichier change (date ou taille), rien d'écrit sur le volume.
- L'explorateur affiche l'extrait correspondant sous le nom.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6J7vKwXx7uopxKhWfFJpm
2026-10-07 18:04:21 +02:00

677 lines
23 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { DragEvent, KeyboardEvent, MouseEvent } from "react";
import {
api,
ApiError,
basename,
dirname,
EXT,
join,
stripExt,
withExt,
type Entry,
type Hit,
} from "./api";
import {
CloseIcon,
DeselectIcon,
DotsIcon,
FilePlusIcon,
FolderIcon,
FolderPlusIcon,
FolderUploadIcon,
GridIcon,
ListIcon,
MoveIcon,
RefreshIcon,
SearchIcon,
SelectAllIcon,
TrashIcon,
UpIcon,
UploadIcon,
} from "./icons";
import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer";
import { MoveDialog } from "./MoveDialog";
import { Thumb } from "./Thumb";
export const EMPTY_SCENE = JSON.stringify({
type: "excalidraw",
version: 2,
source: "excalidraw-go",
elements: [],
appState: {},
files: {},
});
interface Props {
/** Schéma ouvert dans l'éditeur (surligné). */
current: string | null;
/** Incrémenté par le parent pour forcer un rafraîchissement (ex. après sauvegarde). */
refreshKey: number;
onOpen: (path: string) => void;
/** Un fichier ou dossier a été déplacé/renommé : le parent met à jour le schéma ouvert. */
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";
const fmtDate = (iso: string) =>
new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
const fmtSize = (n: number) =>
n < 1024 ? `${n} o` : n < 1 << 20 ? `${(n / 1024).toFixed(1)} Ko` : `${(n / (1 << 20)).toFixed(1)} Mo`;
const plural = (n: number, word: string) => `${n} ${word}${n > 1 ? "s" : ""}`;
const DND_TYPE = "application/x-excalidraw-paths";
const initialView = (): View => {
try {
return localStorage.getItem("explorerView") === "list" ? "list" : "grid";
} catch {
return "grid";
}
};
/** Un élément peut-il être déplacé dans targetDir ? (pas sur place, pas dans lui-même) */
const canMoveInto = (path: string, targetDir: string) =>
dirname(path) !== targetDir && targetDir !== path && !targetDir.startsWith(path + "/");
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("");
const [results, setResults] = useState<Hit[] | null>(null);
const [view, setView] = useState<View>(initialView);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [anchor, setAnchor] = useState<string | null>(null); // origine d'une sélection Maj+clic
const [menuFor, setMenuFor] = useState<string | null>(null);
const [moving, setMoving] = useState<string[] | null>(null); // éléments du dialogue « Déplacer vers »
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<{ text: string; detail?: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null); // progression d'un import
const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur
const fileInput = useRef<HTMLInputElement>(null);
const dirInput = useRef<HTMLInputElement>(null);
const list = results ?? entries;
const guard = useCallback(async (fn: () => Promise<unknown>) => {
setError(null);
try {
await fn();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
const refresh = useCallback(
() =>
guard(async () => {
const next = await api.list(dir);
setEntries(next);
setMenuFor(null);
// Oublie les éléments sélectionnés qui ont disparu.
setSelected((s) => {
if (s.size === 0) return s;
const still = new Set(next.map((e) => e.path));
return new Set([...s].filter((p) => still.has(p)));
});
}),
[dir, guard],
);
useEffect(() => {
refresh();
}, [refresh, refreshKey]);
// Changer de dossier ou de recherche réinitialise la sélection.
useEffect(() => {
setSelected(new Set());
setAnchor(null);
}, [dir, results === null]);
// Suit le dossier du schéma ouvert (lien direct, création, déplacement).
useEffect(() => {
if (current) setDir(dirname(current));
}, [current]);
// Recherche avec anti-rebond.
useEffect(() => {
if (!query.trim()) {
setResults(null);
return;
}
const t = setTimeout(() => guard(async () => setResults(await api.search(query))), 250);
return () => clearTimeout(t);
}, [query, guard, refreshKey]);
useEffect(() => {
try {
localStorage.setItem("explorerView", view);
} catch {
/* stockage indisponible */
}
}, [view]);
// --- Sélection -----------------------------------------------------------
const clearSelection = () => {
setSelected(new Set());
setAnchor(null);
};
const toggle = (path: string) => {
setSelected((s) => {
const n = new Set(s);
if (!n.delete(path)) n.add(path);
return n;
});
setAnchor(path);
};
const selectRange = (path: string) => {
const paths = list.map((e) => e.path);
const a = anchor ? paths.indexOf(anchor) : -1;
const b = paths.indexOf(path);
if (a < 0 || b < 0) return toggle(path);
const [lo, hi] = a < b ? [a, b] : [b, a];
setSelected((s) => new Set([...s, ...paths.slice(lo, hi + 1)]));
};
const selectAll = () => setSelected(new Set(list.map((e) => e.path)));
const onItemClick = (ev: MouseEvent, e: Entry) => {
if (ev.shiftKey) return selectRange(e.path);
// Ctrl/Cmd+clic, ou clic quand une sélection est en cours (mode sélection tactile).
if (ev.ctrlKey || ev.metaKey || selected.size > 0) return toggle(e.path);
if (e.isDir) {
setQuery("");
setDir(e.path);
} else onOpen(e.path);
};
const onKeyDown = (ev: KeyboardEvent) => {
if ((ev.target as HTMLElement).tagName === "INPUT") return;
if (ev.key === "Escape" && selected.size > 0) {
ev.preventDefault();
clearSelection();
} else if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "a") {
ev.preventDefault();
selectAll();
} else if (ev.key === "Delete" && selected.size > 0) {
ev.preventDefault();
removeMany([...selected]);
}
};
// --- Actions -------------------------------------------------------------
const newDrawing = () =>
guard(async () => {
const name = prompt("Nom du nouveau schéma :", "Sans titre");
if (!name?.trim()) return;
const path = join(dir, withExt(name.trim()));
await api.write(path, EMPTY_SCENE, "*");
await refresh();
onOpen(path);
});
const newFolder = () =>
guard(async () => {
const name = prompt("Nom du dossier :");
if (!name?.trim()) return;
await api.mkdir(join(dir, name.trim()));
await refresh();
});
const rename = (e: Entry) =>
guard(async () => {
const name = prompt("Nouveau nom :", e.isDir ? e.name : stripExt(e.name));
if (!name?.trim()) return;
const to = join(dirname(e.path), e.isDir ? name.trim() : withExt(name.trim()));
if (to === e.path) return;
await api.move(e.path, to);
onMoved(e.path, to);
await refresh();
});
/** Déplace plusieurs éléments dans targetDir ; les échecs partiels sont listés. */
const moveMany = (paths: string[], targetDir: string) =>
guard(async () => {
const todo = paths.filter((p) => canMoveInto(p, targetDir));
if (todo.length === 0) return;
const res = await api.moveMany(todo, targetDir);
const failed: string[] = [];
for (const r of res) {
if (r.error) failed.push(`${basename(r.from)} : ${r.error}`);
else if (r.to && r.to !== r.from) onMoved(r.from, r.to);
}
clearSelection();
setMoving(null);
await refresh();
if (results) setResults(await api.search(query));
if (failed.length) {
setError(`${plural(failed.length, "élément")} non déplacé${failed.length > 1 ? "s" : ""} — ${failed.join(" ; ")}`);
}
});
const duplicate = (e: Entry) =>
guard(async () => {
const base = join(dirname(e.path), stripExt(e.name));
for (let i = 1; i < 100; i++) {
const to = `${base} (copie${i > 1 ? ` ${i}` : ""})${EXT}`;
try {
await api.copy(e.path, to);
break;
} catch (err) {
if (!(err instanceof ApiError && err.status === 409)) throw err;
}
}
await refresh();
});
const removeMany = (paths: string[]) =>
guard(async () => {
const one = list.find((e) => e.path === paths[0]);
const what =
paths.length > 1
? `${plural(paths.length, "élément")} (dossiers compris avec leur contenu)`
: one?.isDir
? `le dossier « ${one.name} » et tout son contenu`
: `« ${basename(paths[0])} »`;
if (!confirm(`Mettre ${what} à la corbeille ?`)) return;
for (const p of paths) {
await api.remove(p, true);
onDeleted(p);
}
clearSelection();
await refresh();
if (results) setResults(await api.search(query));
});
/**
* Importe fichiers et arborescences dans targetDir : les dossiers sont recréés,
* seuls les .excalidraw sont importés, les schémas déjà présents sont conservés.
*/
const importTree = (pending: Collected | Promise<Collected>, targetDir: string) =>
guard(async () => {
setNotice(null);
setBusy("Lecture…");
try {
const { files, dirs, ignored } = await pending;
for (const d of dirs) {
try {
await api.mkdir(join(targetDir, d));
} catch (err) {
if (!(err instanceof ApiError && err.status === 409)) throw err; // déjà présent
}
}
let done = 0;
const existing: string[] = [];
const failed: string[] = [];
for (const { rel, file } of files) {
setBusy(`Import ${done + existing.length + failed.length + 1}/${files.length}…`);
try {
await api.write(join(targetDir, rel), await file.text(), "*");
done++;
} catch (err) {
if (err instanceof ApiError && err.status === 409) existing.push(rel);
else failed.push(`${rel} : ${err instanceof Error ? err.message : err}`);
}
}
await refresh();
const parts = [`${plural(done, "schéma")} importé${done > 1 ? "s" : ""}`];
if (dirs.length) parts.push(plural(dirs.length, "dossier"));
if (existing.length) parts.push(`${existing.length} déjà présent${existing.length > 1 ? "s" : ""} (conservé${existing.length > 1 ? "s" : ""})`);
if (ignored.length) parts.push(`${plural(ignored.length, "fichier")} ignoré${ignored.length > 1 ? "s" : ""} (pas .excalidraw)`);
const detail = [
...failed.map((f) => `Échec — ${f}`),
...existing.map((f) => `Déjà présent — ${f}`),
...ignored.map((f) => `Ignoré — ${f}`),
].join("\n");
setNotice({ text: parts.join(" · "), detail: detail || undefined });
if (failed.length) setError(`${plural(failed.length, "fichier")} en échec : ${failed.join(" ; ")}`);
} finally {
setBusy(null);
}
});
// --- Glisser-déposer (souris) ; sur mobile : sélection + « Déplacer vers… » -
const onDragStart = (ev: DragEvent, e: Entry) => {
// Glisser un élément sélectionné emporte toute la sélection.
const paths = selected.has(e.path) ? [...selected] : [e.path];
ev.dataTransfer.setData(DND_TYPE, JSON.stringify(paths));
ev.dataTransfer.effectAllowed = "move";
if (paths.length > 1) {
const ghost = document.createElement("div");
ghost.className = "drag-ghost";
ghost.textContent = plural(paths.length, "élément");
document.body.appendChild(ghost);
ev.dataTransfer.setDragImage(ghost, 12, 12);
setTimeout(() => ghost.remove());
}
};
// Cibles de dépôt : dossiers, fil d'Ariane et « .. ». Accepte les éléments de
// l'explorateur (déplacement) et les fichiers/dossiers du système (import).
const dropProps = (targetDir: string) => ({
onDragOver: (ev: DragEvent) => {
const external = hasFiles(ev.dataTransfer);
if (!external && !ev.dataTransfer.types.includes(DND_TYPE)) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = external ? "copy" : "move";
setDropTarget(targetDir);
},
onDragLeave: () => setDropTarget(null),
onDrop: (ev: DragEvent) => {
ev.preventDefault();
ev.stopPropagation(); // l'explorateur n'importe pas une seconde fois
setDropTarget(null);
setFileDrag(false);
if (hasFiles(ev.dataTransfer)) return importTree(collectDrop(ev.dataTransfer), targetDir);
let paths: string[] = [];
try {
paths = JSON.parse(ev.dataTransfer.getData(DND_TYPE));
} catch {
return;
}
moveMany(paths, targetDir);
},
});
// --- Rendu ---------------------------------------------------------------
const crumbs = dir ? dir.split("/") : [];
const importDir = results ? "" : dir; // pendant une recherche, le dossier courant n'est pas affiché
// Dépôt n'importe où dans l'explorateur : import dans le dossier courant.
const zoneProps = {
onDragOver: (ev: DragEvent) => {
if (!hasFiles(ev.dataTransfer)) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "copy";
setFileDrag(true);
},
onDragLeave: (ev: DragEvent) => {
if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) setFileDrag(false);
},
onDrop: (ev: DragEvent) => {
if (!hasFiles(ev.dataTransfer)) return;
ev.preventDefault();
setFileDrag(false);
importTree(collectDrop(ev.dataTransfer), importDir);
},
};
const icon = (e: Entry) =>
e.isDir ? (
<span className="icon folder" aria-hidden>
<FolderIcon size={view === "grid" ? 40 : 20} />
</span>
) : (
<Thumb path={e.path} version={e.modTime} />
);
const actions = (e: Entry) => (
<div className="actions">
<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
</a>
)}
<button className="danger" onClick={() => removeMany([e.path])}>
Supprimer
</button>
{!e.isDir && <span className="muted">{fmtSize(e.size)}</span>}
</div>
);
const item = (e: Hit) => {
const isSel = selected.has(e.path);
const cls = [
"item",
e.isDir ? "dir" : "file",
e.path === current ? "active" : "",
isSel ? "selected" : "",
dropTarget === e.path ? "drop" : "",
].join(" ");
return [
<li key={e.path} className={cls}>
<div
className="row"
draggable
onDragStart={(ev) => onDragStart(ev, e)}
{...(e.isDir ? dropProps(e.path) : {})}
>
<input
type="checkbox"
className="check"
checked={isSel}
aria-label={`Sélectionner ${e.name}`}
onClick={(ev) => {
ev.stopPropagation();
if (ev.shiftKey) {
ev.preventDefault();
selectRange(e.path);
}
}}
onChange={() => toggle(e.path)}
/>
<button className="name" onClick={(ev) => onItemClick(ev, e)} title={e.path}>
{icon(e)}
<span className="label">
{e.isDir ? e.name : stripExt(e.name)}
{results && <small className="muted where"> {dirname(e.path) || "/"}</small>}
{e.snippet && <small className="snippet">{e.snippet}</small>}
</span>
</button>
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
<button
className="icon-btn more"
aria-label="Actions"
title="Actions"
onClick={() => setMenuFor(menuFor === e.path ? null : e.path)}
>
<DotsIcon />
</button>
</div>
{view === "list" && menuFor === e.path && actions(e)}
</li>,
// En grille, le menu occupe toute la largeur sous la vignette.
view === "grid" && menuFor === e.path && (
<li key={`${e.path}#menu`} className="menu-row">
{actions(e)}
</li>
),
];
};
return (
<div className={`explorer ${fileDrag ? "file-drag" : ""}`} onKeyDown={onKeyDown} {...zoneProps}>
<div className="toolbar">
<button className="ex-btn" onClick={newDrawing} title="Nouveau schéma">
<FilePlusIcon />
<span>Schéma</span>
</button>
<button className="ex-btn" onClick={newFolder} title="Nouveau dossier">
<FolderPlusIcon />
<span>Dossier</span>
</button>
<button
className="ex-btn"
onClick={() => fileInput.current?.click()}
title="Importer des fichiers .excalidraw (ou glisser-déposer fichiers et dossiers ici)"
>
<UploadIcon />
<span>Importer</span>
</button>
<button
className="ex-btn square"
onClick={() => dirInput.current?.click()}
title="Importer un dossier complet (arborescence recréée, seuls les .excalidraw sont importés)"
aria-label="Importer un dossier"
>
<FolderUploadIcon />
</button>
<input
ref={fileInput}
type="file"
accept=".excalidraw"
multiple
hidden
onChange={(ev) => {
if (ev.target.files) importTree(collectFiles(ev.target.files), importDir);
ev.target.value = "";
}}
/>
<input
ref={dirInput}
type="file"
multiple
hidden
{...{ webkitdirectory: "" }}
onChange={(ev) => {
if (ev.target.files) importTree(collectFiles(ev.target.files), importDir);
ev.target.value = "";
}}
/>
</div>
<div className="searchbar">
<label className="search">
<SearchIcon />
<input
type="search"
placeholder="Rechercher (noms, textes, IP…)"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
</label>
<div className="segmented" role="group" aria-label="Affichage">
<button
className={`icon-btn ${view === "grid" ? "on" : ""}`}
onClick={() => setView("grid")}
title="Affichage en vignettes"
aria-label="Affichage en vignettes"
aria-pressed={view === "grid"}
>
<GridIcon />
</button>
<button
className={`icon-btn ${view === "list" ? "on" : ""}`}
onClick={() => setView("list")}
title="Affichage en liste"
aria-label="Affichage en liste"
aria-pressed={view === "list"}
>
<ListIcon />
</button>
</div>
<button className="icon-btn" onClick={refresh} title="Rafraîchir" aria-label="Rafraîchir">
<RefreshIcon />
</button>
</div>
{!results && (
<nav className="crumbs">
<button className={dropTarget === "" ? "drop" : ""} onClick={() => setDir("")} {...dropProps("")}>
Racine
</button>
{crumbs.map((c, i) => {
const p = crumbs.slice(0, i + 1).join("/");
return (
<span key={p}>
<span className="muted"> / </span>
<button className={dropTarget === p ? "drop" : ""} onClick={() => setDir(p)} {...dropProps(p)}>
{c}
</button>
</span>
);
})}
</nav>
)}
{selected.size > 0 && (
<div className="selbar">
<button className="icon-btn" onClick={clearSelection} aria-label="Annuler la sélection" title="Annuler la sélection (Échap)">
<CloseIcon />
</button>
<span className="count">{plural(selected.size, "sélectionné")}</span>
<button
className="icon-btn"
onClick={selected.size === list.length ? clearSelection : selectAll}
title={selected.size === list.length ? "Tout désélectionner" : "Tout sélectionner (Ctrl+A)"}
aria-label={selected.size === list.length ? "Tout désélectionner" : "Tout sélectionner"}
>
{selected.size === list.length ? <DeselectIcon /> : <SelectAllIcon />}
</button>
<button className="ex-btn" onClick={() => setMoving([...selected])}>
<MoveIcon />
<span>Déplacer…</span>
</button>
<button className="icon-btn danger" onClick={() => removeMany([...selected])} title="Mettre à la corbeille (Suppr)" aria-label="Supprimer">
<TrashIcon />
</button>
</div>
)}
{error && (
<div className="error" onClick={() => setError(null)}>
{error}
</div>
)}
{busy && <div className="notice busy">{busy}</div>}
{notice && !busy && (
<div className="notice" onClick={() => setNotice(null)} title={notice.detail ? `${notice.detail}\n\n(clic pour masquer)` : "Clic pour masquer"}>
{notice.text}
</div>
)}
<ul className={`entries ${view} ${selected.size > 0 ? "selecting" : ""}`}>
{!results && dir && (
<li className={`item dir up ${dropTarget === dirname(dir) ? "drop" : ""}`}>
<div className="row" {...dropProps(dirname(dir))}>
<button className="name" onClick={() => setDir(dirname(dir))}>
<span className="icon" aria-hidden>
<UpIcon size={view === "grid" ? 32 : 20} />
</span>
<span className="label">..</span>
</button>
</div>
</li>
)}
{list.flatMap(item)}
{list.length === 0 && <li className="muted empty">{results ? "Aucun résultat" : "Dossier vide"}</li>}
</ul>
{fileDrag && (
<div className="drop-hint" aria-hidden>
<FolderUploadIcon size={28} />
<span>Déposer pour importer dans « {importDir ? basename(importDir) : "Racine"} »</span>
<small>Les dossiers sont recréés · seuls les .excalidraw sont importés</small>
</div>
)}
{moving && (
<MoveDialog
paths={moving}
startDir={results ? "" : dir}
onCancel={() => setMoving(null)}
onConfirm={(target) => moveMany(moving, target)}
/>
)}
</div>
);
}