feat(projets): client API + store re-racinés par projet

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-07-19 10:57:34 +02:00
1 parent 0291831479
commit 5e7461096b
5 files changed
+134 -32

No files matched your search

+78 -19
View File
@@ -51,27 +51,39 @@ export interface GitCommit {
files_changed: number; files_changed: number;
} }
export async function getGitLog(): Promise<GitCommit[]> { const projQuery = (project?: string | null) =>
project ? `&project=${encodeURIComponent(project)}` : "";
export async function getGitLog(project?: string | null): Promise<GitCommit[]> {
try { try {
const res = await fetch("/api/git/log"); const res = await fetch(`/api/git/log?limit=40${projQuery(project)}`);
return (await res.json()).commits; return (await res.json()).commits;
} catch { } catch {
return []; return [];
} }
} }
export async function getGitDiff(hash?: string): Promise<string> { export async function getGitDiff(
const url = hash ? `/api/git/diff?hash=${encodeURIComponent(hash)}` : "/api/git/diff"; hash?: string,
const res = await fetch(url); project?: string | null
): Promise<string> {
const params = new URLSearchParams();
if (hash) params.set("hash", hash);
if (project) params.set("project", project);
const qs = params.toString();
const res = await fetch(`/api/git/diff${qs ? `?${qs}` : ""}`);
if (!res.ok) return ""; if (!res.ok) return "";
return (await res.json()).diff; return (await res.json()).diff;
} }
export async function revertCommit(hash: string): Promise<{ message: string }> { export async function revertCommit(
hash: string,
project?: string | null
): Promise<{ message: string }> {
const res = await fetch("/api/git/revert", { const res = await fetch("/api/git/revert", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hash }), body: JSON.stringify({ hash, project: project ?? null }),
}); });
if (!res.ok) throw await apiError(res, "revert impossible"); if (!res.ok) throw await apiError(res, "revert impossible");
return res.json(); return res.json();
@@ -214,6 +226,7 @@ export interface Session {
id: string; id: string;
title: string; title: string;
model?: string; model?: string;
project?: string | null;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
message_count?: number; message_count?: number;
@@ -400,29 +413,39 @@ export interface FileNode {
children?: FileNode[]; children?: FileNode[];
} }
export async function listFiles(): Promise<FileNode[]> { export async function listFiles(project?: string | null): Promise<FileNode[]> {
const res = await fetch("/api/files"); const query = project ? `?project=${encodeURIComponent(project)}` : "";
const res = await fetch(`/api/files${query}`);
return (await res.json()).tree; return (await res.json()).tree;
} }
export async function fileContent(path: string): Promise<string> { export async function fileContent(
const res = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`); path: string,
project?: string | null
): Promise<string> {
const res = await fetch(
`/api/files/content?path=${encodeURIComponent(path)}${projQuery(project)}`
);
if (!res.ok) return ""; if (!res.ok) return "";
return (await res.json()).content; return (await res.json()).content;
} }
export async function deleteFile(path: string): Promise<void> { export async function deleteFile(
const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`, { path: string,
method: "DELETE", project?: string | null
}); ): Promise<void> {
const res = await fetch(
`/api/files?path=${encodeURIComponent(path)}${projQuery(project)}`,
{ method: "DELETE" }
);
if (!res.ok) { if (!res.ok) {
throw await apiError(res, `suppression impossible (${res.status})`); throw await apiError(res, `suppression impossible (${res.status})`);
} }
} }
export function downloadFile(path: string): void { export function downloadFile(path: string, project?: string | null): void {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = `/api/files/download?path=${encodeURIComponent(path)}`; link.href = `/api/files/download?path=${encodeURIComponent(path)}${projQuery(project)}`;
link.download = path.split(/[\\/]/).pop() ?? "fichier"; link.download = path.split(/[\\/]/).pop() ?? "fichier";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@@ -473,15 +496,51 @@ export async function listSessions(): Promise<Session[]> {
return (await res.json()).sessions; return (await res.json()).sessions;
} }
export async function createSession(model?: string): Promise<Session> { export async function createSession(
model?: string,
project?: string | null
): Promise<Session> {
const res = await fetch("/api/sessions", { const res = await fetch("/api/sessions", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Nouvelle session", model }), body: JSON.stringify({
title: "Nouvelle session",
model,
project: project ?? null,
}),
}); });
return res.json(); return res.json();
} }
export async function listProjects(): Promise<{
projects: { name: string; files: number }[];
root_files: number;
}> {
const res = await fetch("/api/projects");
return res.json();
}
export async function createProject(name: string): Promise<void> {
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw await apiError(res, "création du projet impossible");
}
export async function setSessionProject(
id: string,
project: string | null
): Promise<void> {
const res = await fetch(`/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project: project ?? "" }),
});
if (!res.ok) throw await apiError(res, "changement de projet impossible");
}
export async function getSession( export async function getSession(
id: string id: string
): Promise<{ session: Session; messages: Message[] }> { ): Promise<{ session: Session; messages: Message[] }> {
+2 -2
View File
@@ -61,7 +61,7 @@ export function FilesView() {
} }
function WorkspaceTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) { function WorkspaceTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
const { openPreview, previewPath, removeFile } = useStore(); const { openPreview, previewPath, removeFile, currentProject } = useStore();
const confirmDelete = (node: FileNode) => { const confirmDelete = (node: FileNode) => {
const msg = const msg =
node.type === "dir" node.type === "dir"
@@ -93,7 +93,7 @@ function WorkspaceTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
</button> </button>
{node.type === "file" && ( {node.type === "file" && (
<button <button
onClick={() => downloadFile(node.path)} onClick={() => downloadFile(node.path, currentProject())}
className="flex items-center gap-1 border-2 border-line bg-base px-2 py-1 text-[11px] text-accent" className="flex items-center gap-1 border-2 border-line bg-base px-2 py-1 text-[11px] text-accent"
title={`Télécharger ${node.name}`} title={`Télécharger ${node.name}`}
> >
+2 -2
View File
@@ -117,7 +117,7 @@ export function LeftPanel() {
} }
function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) { function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
const { openPreview, previewPath, removeFile } = useStore(); const { openPreview, previewPath, removeFile, currentProject } = useStore();
const confirmDelete = (n: FileNode) => { const confirmDelete = (n: FileNode) => {
const msg = const msg =
n.type === "dir" n.type === "dir"
@@ -156,7 +156,7 @@ function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
<button <button
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
downloadFile(n.path); downloadFile(n.path, currentProject());
}} }}
className="hidden flex-none text-muted-2 hover:text-accent group-hover:block" className="hidden flex-none text-muted-2 hover:text-accent group-hover:block"
title={`Télécharger ${n.name}`} title={`Télécharger ${n.name}`}
+8 -5
View File
@@ -98,7 +98,7 @@ export function PreviewPanel() {
{previewPath && ( {previewPath && (
<> <>
<button <button
onClick={() => downloadFile(previewPath)} onClick={() => downloadFile(previewPath, useStore.getState().currentProject())}
className="flex h-8 items-center gap-1.5 border-[3px] border-line bg-card px-2.5 text-[12px] text-accent" className="flex h-8 items-center gap-1.5 border-[3px] border-line bg-card px-2.5 text-[12px] text-accent"
title={`Télécharger ${previewPath}`} title={`Télécharger ${previewPath}`}
> >
@@ -202,21 +202,24 @@ export function PreviewPanel() {
/** Onglet Git : historique des commits, diff colorisé, retour arrière. */ /** Onglet Git : historique des commits, diff colorisé, retour arrière. */
function GitPanel() { function GitPanel() {
const refreshFiles = useStore((s) => s.refreshFiles); const refreshFiles = useStore((s) => s.refreshFiles);
const currentProject = useStore((s) => s.currentProject);
const project = currentProject();
const [commits, setCommits] = useState<GitCommit[]>([]); const [commits, setCommits] = useState<GitCommit[]>([]);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [diff, setDiff] = useState(""); const [diff, setDiff] = useState("");
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const load = () => getGitLog().then(setCommits).catch(() => {}); const load = () => getGitLog(project).then(setCommits).catch(() => {});
useEffect(() => { useEffect(() => {
load(); load();
}, []); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [project]);
const openDiff = async (hash: string) => { const openDiff = async (hash: string) => {
setSelected(hash); setSelected(hash);
setDiff("Chargement…"); setDiff("Chargement…");
setDiff((await getGitDiff(hash)) || "(diff vide)"); setDiff((await getGitDiff(hash, project)) || "(diff vide)");
}; };
const doRevert = async (hash: string) => { const doRevert = async (hash: string) => {
@@ -225,7 +228,7 @@ function GitPanel() {
setBusy(hash); setBusy(hash);
setError(null); setError(null);
try { try {
await revertCommit(hash); await revertCommit(hash, project);
await load(); await load();
await refreshFiles(); await refreshFiles();
if (selected === hash) setSelected(null); if (selected === hash) setSelected(null);
+44 -4
View File
@@ -4,6 +4,8 @@ import {
deleteFile, deleteFile,
deleteSession, deleteSession,
getConfig, getConfig,
listProjects,
setSessionProject,
getModels, getModels,
getSession, getSession,
getStatus, getStatus,
@@ -77,6 +79,11 @@ interface LokiState {
removeSession: (id: string) => Promise<void>; removeSession: (id: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>; renameSession: (id: string, title: string) => Promise<void>;
removeFile: (path: string) => Promise<void>; removeFile: (path: string) => Promise<void>;
projects: { name: string; files: number }[];
refreshProjects: () => Promise<void>;
currentProject: () => string | null;
setProject: (name: string | null) => Promise<void>;
sendMessage: (content: string) => Promise<void>; sendMessage: (content: string) => Promise<void>;
stopStreaming: () => void; stopStreaming: () => void;
@@ -110,6 +117,7 @@ export const useStore = create<LokiState>((set, get) => ({
fileTree: [], fileTree: [],
previewPath: null, previewPath: null,
previewContent: "", previewContent: "",
projects: [],
config: null, config: null,
availableTools: [], availableTools: [],
pendingShell: null, pendingShell: null,
@@ -156,7 +164,7 @@ export const useStore = create<LokiState>((set, get) => ({
}, },
openPreview: async (path) => { openPreview: async (path) => {
const content = await fileContent(path); const content = await fileContent(path, get().currentProject());
set({ previewPath: path, previewContent: content }); set({ previewPath: path, previewContent: content });
}, },
@@ -197,12 +205,39 @@ export const useStore = create<LokiState>((set, get) => ({
refreshFiles: async () => { refreshFiles: async () => {
try { try {
set({ fileTree: await listFiles() }); set({ fileTree: await listFiles(get().currentProject()) });
} catch { } catch {
/* workspace indisponible */ /* workspace indisponible */
} }
}, },
refreshProjects: async () => {
try {
const { projects } = await listProjects();
set({ projects });
} catch {
/* backend indisponible */
}
},
currentProject: () => {
const s = get().sessions.find((x) => x.id === get().currentSessionId);
return s?.project ?? null;
},
setProject: async (name) => {
const sid = get().currentSessionId;
if (!sid) {
const s = await createSession(get().selectedModel || undefined, name);
set({ currentSessionId: s.id, messages: [] });
} else {
await setSessionProject(sid, name);
}
await get().refreshSessions();
set({ previewPath: null, previewContent: "" });
await get().refreshFiles();
},
refreshStatus: async () => { refreshStatus: async () => {
try { try {
const status = await getStatus(); const status = await getStatus();
@@ -250,7 +285,10 @@ export const useStore = create<LokiState>((set, get) => ({
}, },
newSession: async () => { newSession: async () => {
const s = await createSession(get().selectedModel || undefined); // La nouvelle session hérite du projet de la session courante.
const s = await createSession(
get().selectedModel || undefined, get().currentProject()
);
const streaming = get().streaming; const streaming = get().streaming;
set({ set({
currentSessionId: s.id, currentSessionId: s.id,
@@ -272,6 +310,8 @@ export const useStore = create<LokiState>((set, get) => ({
? {} ? {}
: { streamContent: "", streamStatus: "", streamNotice: null }), : { streamContent: "", streamStatus: "", streamNotice: null }),
}); });
// L'arborescence suit le projet de la session ouverte.
await get().refreshFiles();
}, },
removeSession: async (id) => { removeSession: async (id) => {
@@ -290,7 +330,7 @@ export const useStore = create<LokiState>((set, get) => ({
}, },
removeFile: async (path) => { removeFile: async (path) => {
await deleteFile(path); await deleteFile(path, get().currentProject());
// Ferme l'aperçu si le fichier supprimé (ou son dossier) y est affiché. // Ferme l'aperçu si le fichier supprimé (ou son dossier) y est affiché.
const preview = get().previewPath; const preview = get().previewPath;
if (preview && (preview === path || preview.startsWith(path + "/"))) { if (preview && (preview === path || preview.startsWith(path + "/"))) {