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

@ -0,0 +1,157 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ReactElement } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import { createMockGateways } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import type { ProjectFileListing } from "@/domain";
import { ProjectFilesPanel } from "./ProjectFilesPanel";
import { WebWorkspace } from "./WebWorkspace";
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
function listing(path: string, entries: ProjectFileListing["entries"]): ProjectFileListing {
return { path, entries };
}
beforeEach(() => {
vi.restoreAllMocks();
window.innerWidth = 1024;
window.dispatchEvent(new Event("resize"));
});
function renderWithGateways(gateways: Gateways, element: ReactElement) {
return render(<DIProvider gateways={gateways}>{element}</DIProvider>);
}
describe("ProjectFilesPanel", () => {
it("navigates progressively and keeps download actions explicit", async () => {
const gateways = createMockGateways();
const listProjectFiles = vi.fn(async (_projectId: string, path: string) => {
if (path === "src") return listing("src", []);
return listing("", [
{
name: "src",
path: "src",
kind: "directory",
modifiedAtMs: 1_786_780_000_000,
},
{
name: "README.md",
path: "README.md",
kind: "file",
sizeBytes: 2048,
modifiedAtMs: 1_786_780_010_000,
},
]);
});
gateways.project.listProjectFiles = listProjectFiles;
gateways.project.downloadProjectFile = vi.fn(async () => {});
gateways.project.downloadProjectDirectory = vi.fn(async () => {});
renderWithGateways(gateways, <ProjectFilesPanel projectId="p1" />);
expect(await screen.findByText("README.md")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
await waitFor(() => expect(listProjectFiles).toHaveBeenLastCalledWith("p1", "src"));
expect(await screen.findByText("Ce dossier est vide.")).toBeTruthy();
expect(screen.getByRole("button", { name: "src" })).toBeTruthy();
expect(gateways.project.downloadProjectDirectory).not.toHaveBeenCalled();
});
it("downloads files and folders through separate row actions with a preparation state", async () => {
const gateways = createMockGateways();
const fileDownload = deferred<void>();
gateways.project.listProjectFiles = vi.fn(async () =>
listing("", [
{ name: "docs", path: "docs", kind: "directory" },
{ name: "README.md", path: "README.md", kind: "file", sizeBytes: 1024 },
]),
);
gateways.project.downloadProjectFile = vi.fn(() => fileDownload.promise);
gateways.project.downloadProjectDirectory = vi.fn(async () => {});
renderWithGateways(gateways, <ProjectFilesPanel projectId="p1" />);
const list = await screen.findByRole("list", { name: "Fichiers du projet" });
const readmeRow = within(list).getByText("README.md").closest("li");
const docsRow = within(list).getByText("docs").closest("li");
expect(readmeRow).toBeTruthy();
expect(docsRow).toBeTruthy();
fireEvent.click(within(readmeRow!).getByRole("button", { name: "Télécharger" }));
expect(await screen.findByText("Préparation du téléchargement…")).toBeTruthy();
expect(gateways.project.downloadProjectFile).toHaveBeenCalledWith("p1", "README.md");
expect(gateways.project.downloadProjectDirectory).not.toHaveBeenCalled();
fileDownload.resolve();
await waitFor(() =>
expect(screen.queryByText("Préparation du téléchargement…")).toBeNull(),
);
fireEvent.click(
within(docsRow!).getByRole("button", { name: "Télécharger le dossier" }),
);
expect(gateways.project.downloadProjectDirectory).toHaveBeenCalledWith("p1", "docs");
});
it("surfaces list errors", async () => {
const gateways = createMockGateways();
gateways.project.listProjectFiles = vi.fn(async () => {
throw { code: "INVALID", message: "path outside project" };
});
renderWithGateways(gateways, <ProjectFilesPanel projectId="p1" />);
expect((await screen.findByRole("alert")).textContent).toContain("path outside project");
});
});
describe("WebWorkspace files integration", () => {
async function setupWorkspace() {
const gateways = createMockGateways();
await gateways.project.createProject("IdeA", "/srv/idea");
gateways.project.listProjectFiles = vi.fn(async () =>
listing("", [{ name: "README.md", path: "README.md", kind: "file", sizeBytes: 512 }]),
);
gateways.project.downloadProjectFile = vi.fn(async () => {});
gateways.project.downloadProjectDirectory = vi.fn(async () => {});
return gateways;
}
it("opens Fichiers from the desktop Panneaux menu", async () => {
const gateways = await setupWorkspace();
renderWithGateways(gateways, <WebWorkspace />);
fireEvent.click(await screen.findByText("IdeA"));
fireEvent.click(await screen.findByRole("button", { name: "Panneaux" }));
fireEvent.click(await screen.findByRole("button", { name: "Fichiers" }));
fireEvent.click(await screen.findByRole("button", { name: "Flottant" }));
expect(await screen.findByTestId("project-files-panel")).toBeTruthy();
expect(screen.getByText("README.md")).toBeTruthy();
});
it("exposes Fichiers as a first-level mobile destination", async () => {
window.innerWidth = 390;
window.dispatchEvent(new Event("resize"));
const gateways = await setupWorkspace();
renderWithGateways(gateways, <WebWorkspace />);
fireEvent.click(await screen.findByText("IdeA"));
fireEvent.click(await screen.findByRole("button", { name: "Fichiers" }));
expect(screen.getByTestId("web-workspace").getAttribute("data-shell")).toBe(
"mobile-task-flow",
);
expect(await screen.findByTestId("project-files-panel")).toBeTruthy();
});
});

View File

@ -0,0 +1,212 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useGateways } from "@/app/di";
import type { GatewayError, ProjectFileEntry, ProjectFileListing } from "@/domain";
import { Button, Panel, Spinner, cn } from "@/shared";
export interface ProjectFilesPanelProps {
projectId: string;
mobile?: boolean;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
function formatBytes(value: number | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
if (value < 1024) return `${value} o`;
const units = ["Ko", "Mo", "Go", "To"];
let current = value / 1024;
let index = 0;
while (current >= 1024 && index < units.length - 1) {
current /= 1024;
index += 1;
}
return `${current >= 10 ? current.toFixed(0) : current.toFixed(1)} ${units[index]}`;
}
function formatModified(value: number | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "—";
try {
return new Date(value).toLocaleString();
} catch {
return "—";
}
}
function joinPath(base: string, name: string): string {
return [base, name].filter(Boolean).join("/");
}
function breadcrumbs(path: string): Array<{ label: string; path: string }> {
const parts = path.split("/").filter(Boolean);
return [
{ label: "Racine", path: "" },
...parts.map((part, index) => ({
label: part,
path: parts.slice(0, index + 1).join("/"),
})),
];
}
export function ProjectFilesPanel({ projectId, mobile = false }: ProjectFilesPanelProps) {
const { project } = useGateways();
const [path, setPath] = useState("");
const [listing, setListing] = useState<ProjectFileListing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [downloadingPath, setDownloadingPath] = useState<string | null>(null);
const crumbs = useMemo(() => breadcrumbs(listing?.path ?? path), [listing?.path, path]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setListing(await project.listProjectFiles(projectId, path));
} catch (e) {
setError(describe(e));
setListing(null);
} finally {
setLoading(false);
}
}, [path, project, projectId]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
project
.listProjectFiles(projectId, path)
.then((next) => {
if (!cancelled) setListing(next);
})
.catch((e) => {
if (!cancelled) {
setError(describe(e));
setListing(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [path, project, projectId]);
async function download(entry: ProjectFileEntry): Promise<void> {
if (downloadingPath) return;
setError(null);
setDownloadingPath(entry.path);
try {
if (entry.kind === "directory") {
await project.downloadProjectDirectory(projectId, entry.path);
} else {
await project.downloadProjectFile(projectId, entry.path);
}
} catch (e) {
setError(describe(e));
} finally {
setDownloadingPath(null);
}
}
return (
<Panel
data-testid="project-files-panel"
className={cn("flex min-h-0 flex-col gap-3", mobile && "border-0 p-0 shadow-none")}
>
<div className="flex shrink-0 flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-content">Fichiers</h2>
<Button variant="ghost" size="sm" loading={loading} onClick={() => void load()}>
Rafraîchir
</Button>
</div>
<nav aria-label="Fil d'Ariane fichiers" className="flex min-w-0 flex-wrap gap-1 text-xs">
{crumbs.map((crumb, index) => (
<span key={crumb.path || "root"} className="flex min-w-0 items-center gap-1">
{index > 0 && <span className="text-faint">/</span>}
<button
type="button"
disabled={crumb.path === path}
className={cn(
"min-w-0 truncate rounded-sm px-1 py-0.5 text-muted hover:text-content",
crumb.path === path && "cursor-default text-content",
)}
onClick={() => setPath(crumb.path)}
>
{crumb.label}
</button>
</span>
))}
</nav>
</div>
{error && (
<div role="alert" className="rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger">
{error}
</div>
)}
{downloadingPath && (
<div role="status" className="flex items-center gap-2 text-xs text-muted">
<Spinner size={12} />
Préparation du téléchargement…
</div>
)}
{loading && listing === null ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
Chargement des fichiers…
</div>
) : listing && listing.entries.length === 0 ? (
<p className="rounded-md border border-border bg-raised/40 p-3 text-sm text-muted">
Ce dossier est vide.
</p>
) : listing ? (
<ul className="flex min-h-0 flex-col divide-y divide-border overflow-auto" aria-label="Fichiers du projet">
{listing.entries.map((entry) => (
<li key={entry.path} className="flex min-w-0 flex-col gap-2 py-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-xs text-faint">
{entry.kind === "directory" ? "Dossier" : "Fichier"}
</span>
<span className="min-w-0 truncate text-sm font-medium text-content">
{entry.name}
</span>
</div>
<div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted">
<span>{entry.kind === "file" ? formatBytes(entry.sizeBytes) : "—"}</span>
<span>{formatModified(entry.modifiedAtMs)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{entry.kind === "directory" && (
<Button size="sm" variant="ghost" onClick={() => setPath(entry.path || joinPath(path, entry.name))}>
Ouvrir
</Button>
)}
<Button
size="sm"
variant="ghost"
loading={downloadingPath === entry.path}
disabled={Boolean(downloadingPath)}
onClick={() => void download(entry)}
>
{entry.kind === "directory" ? "Télécharger le dossier" : "Télécharger"}
</Button>
</div>
</li>
))}
</ul>
) : null}
</Panel>
);
}

View File

@ -31,16 +31,7 @@ import { useGateways } from "@/app/di";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
import {
PANEL_TITLE,
floatingPanels,
isDockedTo,
panelsDockedTo,
placementOf,
type PanelId,
type ViewPlacement,
type ViewPlacements,
} from "@/features/projects/viewPlacement";
import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacement";
import { AgentsPanel } from "@/features/agents";
import { TemplatesPanel } from "@/features/templates";
import { SkillsPanel } from "@/features/skills";
@ -69,6 +60,7 @@ import { WebTicketsView } from "./tickets/WebTicketsView";
import { WebSprintsView } from "./tickets/WebSprintsView";
import { WebProjectsPanel } from "./WebProjectsPanel";
import { WebMenuSheet } from "./WebMenuSheet";
import { ProjectFilesPanel } from "./ProjectFilesPanel";
import {
WEB_PANEL_META,
type WebOpenablePanelId,
@ -205,19 +197,50 @@ function useMobileWorkspace(): boolean {
return mobile;
}
const WEB_PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
type WebDesktopPanelId = WebProjectPanelId;
type WebDesktopPlacements = Partial<Record<WebDesktopPanelId, ViewPlacement>>;
const WEB_PANEL_SIZE: Record<WebDesktopPanelId, FloatingWindowSize> = {
projects: "md",
context: "lg",
work: "lg",
tickets: "lg",
sprints: "lg",
agents: "lg",
templates: "lg",
skills: "lg",
permissions: "md",
memory: "lg",
git: "lg",
files: "lg",
};
function webPanelTitle(panel: WebDesktopPanelId): string {
return WEB_PANEL_META[panel].label;
}
function webPlacementOf(
placements: WebDesktopPlacements,
panel: WebDesktopPanelId,
): ViewPlacement {
return placements[panel] ?? "closed";
}
function webPanelsDockedTo(
placements: WebDesktopPlacements,
side: "left" | "right",
): WebDesktopPanelId[] {
return (Object.keys(WEB_PANEL_META) as WebDesktopPanelId[]).filter((panel) =>
isDockedTo(webPlacementOf(placements, panel), side),
);
}
function webFloatingPanels(placements: WebDesktopPlacements): WebDesktopPanelId[] {
return (Object.keys(WEB_PANEL_META) as WebDesktopPanelId[]).filter(
(panel) => webPlacementOf(placements, panel) === "floating",
);
}
function WebDesktopWorkspace({
projects,
openId,
@ -243,7 +266,7 @@ function WebDesktopWorkspace({
onRefreshProjects: () => void;
onViewSprintTickets: (sprintId: string) => void;
}) {
const [placements, setPlacements] = useState<ViewPlacements>({
const [placements, setPlacements] = useState<WebDesktopPlacements>({
work: { dock: "right" },
});
const [leftDockWidth, setLeftDockWidth] = useState(340);
@ -259,11 +282,11 @@ function WebDesktopWorkspace({
setViewerConversation(null);
}, [openId]);
function setPlacement(panel: PanelId, placement: Exclude<ViewPlacement, "detached">) {
function setPlacement(panel: WebDesktopPanelId, placement: Exclude<ViewPlacement, "detached">) {
setPlacements((prev) => {
const next: ViewPlacements = { ...prev };
const next: WebDesktopPlacements = { ...prev };
if (placement === "floating") {
for (const other of Object.keys(next) as PanelId[]) {
for (const other of Object.keys(next) as WebDesktopPanelId[]) {
if (other !== panel && next[other] === "floating") delete next[other];
}
}
@ -272,7 +295,7 @@ function WebDesktopWorkspace({
});
}
function closePanel(panel: PanelId) {
function closePanel(panel: WebDesktopPanelId) {
setPlacements((prev) => {
if (!(panel in prev)) return prev;
const next = { ...prev };
@ -285,7 +308,7 @@ function WebDesktopWorkspace({
setPlacements((prev) => {
let changed = false;
const next = { ...prev };
for (const panel of Object.keys(next) as PanelId[]) {
for (const panel of Object.keys(next) as WebDesktopPanelId[]) {
if (next[panel] === "floating") {
delete next[panel];
changed = true;
@ -301,22 +324,23 @@ function WebDesktopWorkspace({
dismissFloating();
}
const panelOrder: PanelId[] = [
const panelOrder: WebDesktopPanelId[] = [
"context",
"work",
"tickets",
"sprints",
"agents",
"templates",
"skills",
"permissions",
"memory",
"git",
"files",
"projects",
];
const panelToWebId = (panel: PanelId): WebProjectPanelId => panel;
function placementSubmenu(panel: PanelId): MenuBarItem[] {
const placement = placementOf(placements, panel);
function placementSubmenu(panel: WebDesktopPanelId): MenuBarItem[] {
const placement = webPlacementOf(placements, panel);
return [
{
id: "closed",
@ -351,8 +375,8 @@ function WebDesktopWorkspace({
label: "Panneaux",
items: panelOrder.map((panel) => ({
id: panel,
label: PANEL_TITLE[panel],
active: placementOf(placements, panel) !== "closed",
label: webPanelTitle(panel),
active: webPlacementOf(placements, panel) !== "closed",
onSelect: () => {},
submenu: placementSubmenu(panel),
})),
@ -369,14 +393,14 @@ function WebDesktopWorkspace({
/>
);
function renderPanel(panel: PanelId) {
function renderPanel(panel: WebDesktopPanelId) {
if (panel === "projects") return projectsManager;
if (!openId) {
return <p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>;
}
return (
<WebProjectPanelBody
panel={panelToWebId(panel) as WebOpenablePanelId}
panel={panel as WebOpenablePanelId}
projectId={openId}
root={openRoot}
focusSprintId={focusSprintId}
@ -385,9 +409,9 @@ function WebDesktopWorkspace({
);
}
function DockControls({ panel }: { panel: PanelId }) {
const placement = placementOf(placements, panel);
const title = PANEL_TITLE[panel];
function DockControls({ panel }: { panel: WebDesktopPanelId }) {
const placement = webPlacementOf(placements, panel);
const title = webPanelTitle(panel);
const activeClass = "bg-raised text-content ring-1 ring-border-strong";
return (
<div className="flex shrink-0 items-center gap-1">
@ -433,16 +457,16 @@ function WebDesktopWorkspace({
);
}
function renderDockedView(panel: PanelId) {
function renderDockedView(panel: WebDesktopPanelId) {
return (
<section
key={panel}
aria-label={`${PANEL_TITLE[panel]} panel`}
aria-label={`${webPanelTitle(panel)} panel`}
className="flex min-h-0 flex-1 flex-col border-b border-border last:border-b-0"
>
<header className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<span className="truncate text-sm font-medium text-content">
{PANEL_TITLE[panel]}
{webPanelTitle(panel)}
</span>
<DockControls panel={panel} />
</header>
@ -451,12 +475,12 @@ function WebDesktopWorkspace({
);
}
const leftPanels = panelsDockedTo(placements, "left");
const rightPanels = panelsDockedTo(placements, "right");
const floatingList = floatingPanels(placements);
const leftPanels = webPanelsDockedTo(placements, "left");
const rightPanels = webPanelsDockedTo(placements, "right");
const floatingList = webFloatingPanels(placements);
const activeLayoutKind = activeLayout?.kind ?? "terminal";
const showInlineProjects =
!openProject && placementOf(placements, "projects") === "closed";
!openProject && webPlacementOf(placements, "projects") === "closed";
return (
<div
@ -564,7 +588,7 @@ function WebDesktopWorkspace({
<FloatingWindow
key={panel}
open
title={PANEL_TITLE[panel]}
title={webPanelTitle(panel)}
size={WEB_PANEL_SIZE[panel]}
onClose={() => closePanel(panel)}
>
@ -611,6 +635,7 @@ function WebMobileTaskFlowShell({
{ id: "work", label: "Travail" },
{ id: "tickets", label: "Tickets" },
{ id: "sprints", label: "Sprints" },
{ id: "files", label: "Fichiers" },
{ id: "agents", label: "Agent" },
];
@ -665,7 +690,7 @@ function WebMobileTaskFlowShell({
<nav
data-testid="web-mobile-bottom-nav"
aria-label="Navigation mobile"
className="grid shrink-0 grid-cols-5 border-t border-border bg-surface pb-[env(safe-area-inset-bottom)]"
className="grid shrink-0 grid-cols-6 border-t border-border bg-surface pb-[env(safe-area-inset-bottom)]"
>
{navItems.map((item) => {
const disabled = item.id !== "projects" && !openId;
@ -858,6 +883,8 @@ export function WebProjectPanelBody({
);
case "git":
return <GitPanel projectId={projectId} />;
case "files":
return <ProjectFilesPanel projectId={projectId} mobile={mobile} />;
}
}

View File

@ -11,6 +11,7 @@ export type WebProjectPanelId =
| "permissions"
| "memory"
| "git"
| "files"
| "projects";
export type WebOpenablePanelId = Exclude<WebProjectPanelId, "projects">;
@ -37,6 +38,7 @@ export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
"permissions",
"memory",
"git",
"files",
"projects",
];
@ -111,6 +113,13 @@ export const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
description: "État Git du projet",
placement: "primary",
},
files: {
id: "files",
label: "Fichiers",
shortLabel: "Fichiers",
description: "Parcours et téléchargement des fichiers du projet",
placement: "primary",
},
projects: {
id: "projects",
label: "Projets",