mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
0291831479
commit
5e7461096b
5 files changed
+134
-32
No files matched your search
+78
-19
@@ -51,27 +51,39 @@ export interface GitCommit {
|
||||
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 {
|
||||
const res = await fetch("/api/git/log");
|
||||
const res = await fetch(`/api/git/log?limit=40${projQuery(project)}`);
|
||||
return (await res.json()).commits;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGitDiff(hash?: string): Promise<string> {
|
||||
const url = hash ? `/api/git/diff?hash=${encodeURIComponent(hash)}` : "/api/git/diff";
|
||||
const res = await fetch(url);
|
||||
export async function getGitDiff(
|
||||
hash?: string,
|
||||
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 "";
|
||||
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", {
|
||||
method: "POST",
|
||||
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");
|
||||
return res.json();
|
||||
@@ -214,6 +226,7 @@ export interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
model?: string;
|
||||
project?: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
message_count?: number;
|
||||
@@ -400,29 +413,39 @@ export interface FileNode {
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
export async function listFiles(): Promise<FileNode[]> {
|
||||
const res = await fetch("/api/files");
|
||||
export async function listFiles(project?: string | null): Promise<FileNode[]> {
|
||||
const query = project ? `?project=${encodeURIComponent(project)}` : "";
|
||||
const res = await fetch(`/api/files${query}`);
|
||||
return (await res.json()).tree;
|
||||
}
|
||||
|
||||
export async function fileContent(path: string): Promise<string> {
|
||||
const res = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
|
||||
export async function fileContent(
|
||||
path: string,
|
||||
project?: string | null
|
||||
): Promise<string> {
|
||||
const res = await fetch(
|
||||
`/api/files/content?path=${encodeURIComponent(path)}${projQuery(project)}`
|
||||
);
|
||||
if (!res.ok) return "";
|
||||
return (await res.json()).content;
|
||||
}
|
||||
|
||||
export async function deleteFile(path: string): Promise<void> {
|
||||
const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
export async function deleteFile(
|
||||
path: string,
|
||||
project?: string | null
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`/api/files?path=${encodeURIComponent(path)}${projQuery(project)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
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");
|
||||
link.href = `/api/files/download?path=${encodeURIComponent(path)}`;
|
||||
link.href = `/api/files/download?path=${encodeURIComponent(path)}${projQuery(project)}`;
|
||||
link.download = path.split(/[\\/]/).pop() ?? "fichier";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
@@ -473,15 +496,51 @@ export async function listSessions(): Promise<Session[]> {
|
||||
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", {
|
||||
method: "POST",
|
||||
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();
|
||||
}
|
||||
|
||||
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(
|
||||
id: string
|
||||
): Promise<{ session: Session; messages: Message[] }> {
|
||||
|
||||
@@ -61,7 +61,7 @@ export function FilesView() {
|
||||
}
|
||||
|
||||
function WorkspaceTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
|
||||
const { openPreview, previewPath, removeFile } = useStore();
|
||||
const { openPreview, previewPath, removeFile, currentProject } = useStore();
|
||||
const confirmDelete = (node: FileNode) => {
|
||||
const msg =
|
||||
node.type === "dir"
|
||||
@@ -93,7 +93,7 @@ function WorkspaceTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
|
||||
</button>
|
||||
{node.type === "file" && (
|
||||
<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"
|
||||
title={`Télécharger ${node.name}`}
|
||||
>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function LeftPanel() {
|
||||
}
|
||||
|
||||
function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
|
||||
const { openPreview, previewPath, removeFile } = useStore();
|
||||
const { openPreview, previewPath, removeFile, currentProject } = useStore();
|
||||
const confirmDelete = (n: FileNode) => {
|
||||
const msg =
|
||||
n.type === "dir"
|
||||
@@ -156,7 +156,7 @@ function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
downloadFile(n.path);
|
||||
downloadFile(n.path, currentProject());
|
||||
}}
|
||||
className="hidden flex-none text-muted-2 hover:text-accent group-hover:block"
|
||||
title={`Télécharger ${n.name}`}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function PreviewPanel() {
|
||||
{previewPath && (
|
||||
<>
|
||||
<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"
|
||||
title={`Télécharger ${previewPath}`}
|
||||
>
|
||||
@@ -202,21 +202,24 @@ export function PreviewPanel() {
|
||||
/** Onglet Git : historique des commits, diff colorisé, retour arrière. */
|
||||
function GitPanel() {
|
||||
const refreshFiles = useStore((s) => s.refreshFiles);
|
||||
const currentProject = useStore((s) => s.currentProject);
|
||||
const project = currentProject();
|
||||
const [commits, setCommits] = useState<GitCommit[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [diff, setDiff] = useState("");
|
||||
const [busy, setBusy] = 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(() => {
|
||||
load();
|
||||
}, []);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project]);
|
||||
|
||||
const openDiff = async (hash: string) => {
|
||||
setSelected(hash);
|
||||
setDiff("Chargement…");
|
||||
setDiff((await getGitDiff(hash)) || "(diff vide)");
|
||||
setDiff((await getGitDiff(hash, project)) || "(diff vide)");
|
||||
};
|
||||
|
||||
const doRevert = async (hash: string) => {
|
||||
@@ -225,7 +228,7 @@ function GitPanel() {
|
||||
setBusy(hash);
|
||||
setError(null);
|
||||
try {
|
||||
await revertCommit(hash);
|
||||
await revertCommit(hash, project);
|
||||
await load();
|
||||
await refreshFiles();
|
||||
if (selected === hash) setSelected(null);
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
deleteFile,
|
||||
deleteSession,
|
||||
getConfig,
|
||||
listProjects,
|
||||
setSessionProject,
|
||||
getModels,
|
||||
getSession,
|
||||
getStatus,
|
||||
@@ -77,6 +79,11 @@ interface LokiState {
|
||||
removeSession: (id: string) => Promise<void>;
|
||||
renameSession: (id: string, title: 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>;
|
||||
stopStreaming: () => void;
|
||||
|
||||
@@ -110,6 +117,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
fileTree: [],
|
||||
previewPath: null,
|
||||
previewContent: "",
|
||||
projects: [],
|
||||
config: null,
|
||||
availableTools: [],
|
||||
pendingShell: null,
|
||||
@@ -156,7 +164,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
},
|
||||
|
||||
openPreview: async (path) => {
|
||||
const content = await fileContent(path);
|
||||
const content = await fileContent(path, get().currentProject());
|
||||
set({ previewPath: path, previewContent: content });
|
||||
},
|
||||
|
||||
@@ -197,12 +205,39 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
|
||||
refreshFiles: async () => {
|
||||
try {
|
||||
set({ fileTree: await listFiles() });
|
||||
set({ fileTree: await listFiles(get().currentProject()) });
|
||||
} catch {
|
||||
/* 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 () => {
|
||||
try {
|
||||
const status = await getStatus();
|
||||
@@ -250,7 +285,10 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
},
|
||||
|
||||
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;
|
||||
set({
|
||||
currentSessionId: s.id,
|
||||
@@ -272,6 +310,8 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
? {}
|
||||
: { streamContent: "", streamStatus: "", streamNotice: null }),
|
||||
});
|
||||
// L'arborescence suit le projet de la session ouverte.
|
||||
await get().refreshFiles();
|
||||
},
|
||||
|
||||
removeSession: async (id) => {
|
||||
@@ -290,7 +330,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
},
|
||||
|
||||
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é.
|
||||
const preview = get().previewPath;
|
||||
if (preview && (preview === path || preview.startsWith(path + "/"))) {
|
||||
|
||||
Reference in new issue
Block a user