feat(web): panneau Fichiers — navigation projet + téléchargements fichier/dossier ZIP — #211

This commit is contained in:
2026-08-15 10:38:53 +02:00
parent bb1fdecf7c
commit 79fc3d184d
22 changed files with 1648 additions and 78 deletions

View File

@ -199,6 +199,37 @@ describe("request/response gateways preserve the Tauri command contract", () =>
});
});
it("HttpProjectGateway project files use the dedicated REST endpoints", async () => {
const http = new HttpInvoker({ baseUrl: "https://h" });
const request = vi.spyOn(http, "request").mockResolvedValue({
path: "src",
entries: [],
} as never);
const download = vi.spyOn(http, "download").mockResolvedValue(undefined);
const gw = new HttpProjectGateway(http);
await gw.listProjectFiles("p1", "src/lib");
await gw.downloadProjectFile("p1", "README.md");
await gw.downloadProjectDirectory("p1", "src/lib");
expect(request).toHaveBeenCalledWith(
"GET",
"/api/project-files?projectId=p1&path=src%2Flib",
undefined,
"project files listing",
);
expect(download).toHaveBeenCalledWith(
"/api/project-files/download-file?projectId=p1&path=README.md",
"README.md",
"project file download",
);
expect(download).toHaveBeenCalledWith(
"/api/project-files/download-directory?projectId=p1&path=src%2Flib",
"lib.zip",
"project directory download",
);
});
it("HttpGitGateway.stage maps to git_stage with a request envelope", async () => {
const http = new HttpInvoker({ baseUrl: "https://h" });
const spy = vi.spyOn(http, "invoke").mockResolvedValue(undefined as never);

View File

@ -39,6 +39,7 @@ export type FetchLike = (
/** Parsed JSON body (may reject/return undefined for empty bodies). */
json(): Promise<unknown>;
text(): Promise<string>;
blob?(): Promise<Blob>;
}>;
/**
@ -182,4 +183,51 @@ export class HttpInvoker {
}
return parsed as T;
}
async download(path: string, filename: string, what = `GET ${path}`): Promise<void> {
const headers: Record<string, string> = {};
if (this.token) headers.Authorization = `Bearer ${this.token}`;
let res: Awaited<ReturnType<FetchLike>>;
try {
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
method: "GET",
headers,
credentials: "same-origin",
});
} catch (networkError) {
const err: GatewayError = {
code: "TRANSPORT_ERROR",
message: `HTTP request for ${what} failed: ${String(networkError)}`,
};
throw err;
}
if (!res.ok) {
if (res.status === 401) this.onUnauthorized?.();
let parsed: unknown;
try {
parsed = await res.json();
} catch {
parsed = undefined;
}
throw toGatewayError(parsed, `${what} failed (HTTP ${res.status})`);
}
const blob = res.blob ? await res.blob() : new Blob([await res.text()]);
triggerBrowserDownload(blob, filename);
}
}
function triggerBrowserDownload(blob: Blob, filename: string): void {
if (typeof document === "undefined" || typeof URL === "undefined") return;
const href = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = href;
link.download = filename;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(href);
}

View File

@ -37,6 +37,7 @@ import type {
OpenCodeProviderCatalogEntry,
PermissionSet,
Project,
ProjectFileListing,
ProjectMcpToolPermissions,
ProjectPermissions,
ProjectWorkState,
@ -100,6 +101,37 @@ export class HttpProjectGateway implements ProjectGateway {
async updateProjectContext(projectId: string, content: string): Promise<void> {
await this.http.invoke("update_project_context", { request: { projectId, content } });
}
listProjectFiles(projectId: string, path: string): Promise<ProjectFileListing> {
return this.http.request<ProjectFileListing>(
"GET",
projectFilesPath("/api/project-files", projectId, path),
undefined,
"project files listing",
);
}
downloadProjectFile(projectId: string, path: string): Promise<void> {
return this.http.download(
projectFilesPath("/api/project-files/download-file", projectId, path),
projectFileDownloadName(path, "download"),
"project file download",
);
}
downloadProjectDirectory(projectId: string, path: string): Promise<void> {
return this.http.download(
projectFilesPath("/api/project-files/download-directory", projectId, path),
`${projectFileDownloadName(path, "project")}.zip`,
"project directory download",
);
}
}
function projectFilesPath(endpoint: string, projectId: string, path: string): string {
const params = new URLSearchParams({ projectId, path });
return `${endpoint}?${params.toString()}`;
}
function projectFileDownloadName(path: string, fallback: string): string {
return path.split("/").filter(Boolean).at(-1) ?? fallback;
}
export class HttpLayoutGateway implements LayoutGateway {

View File

@ -66,6 +66,7 @@ import type {
PluginWorkspaceStat,
PluginWorkspaceTextFile,
Project,
ProjectFileListing,
JsonValue,
ProjectMcpToolPermissions,
ProjectPermissions,
@ -1262,6 +1263,14 @@ export class MockProjectGateway implements ProjectGateway {
}
this.contexts.set(projectId, content);
}
async listProjectFiles(_projectId: string, path: string): Promise<ProjectFileListing> {
return { path, entries: [] };
}
async downloadProjectFile(): Promise<void> {}
async downloadProjectDirectory(): Promise<void> {}
}
/** Internal per-project layout store entry. */

View File

@ -8,7 +8,7 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project } from "@/domain";
import type { Project, ProjectFileListing } from "@/domain";
import type { ProjectGateway } from "@/ports";
export class TauriProjectGateway implements ProjectGateway {
@ -37,4 +37,16 @@ export class TauriProjectGateway implements ProjectGateway {
request: { projectId, content },
});
}
async listProjectFiles(): Promise<ProjectFileListing> {
throw new Error("Project files browsing is available in the web workspace only.");
}
async downloadProjectFile(): Promise<void> {
throw new Error("Project file download is available in the web workspace only.");
}
async downloadProjectDirectory(): Promise<void> {
throw new Error("Project directory download is available in the web workspace only.");
}
}