feat(web): panneau Fichiers — navigation projet + téléchargements fichier/dossier ZIP — #211
This commit is contained in:
157
frontend/src/features/web/ProjectFilesPanel.test.tsx
Normal file
157
frontend/src/features/web/ProjectFilesPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
212
frontend/src/features/web/ProjectFilesPanel.tsx
Normal file
212
frontend/src/features/web/ProjectFilesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user