feat(web): panneau Fichiers — navigation projet + téléchargements fichier/dossier ZIP — #211
This commit is contained in:
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user