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,216 @@
//! Project file browser use case for the human web surface.
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use domain::ports::{FileSystem, RemotePath};
use domain::Project;
use serde::Serialize;
use crate::error::AppError;
/// Input for [`ListProjectFiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListProjectFilesInput {
/// Opened project.
pub project: Project,
/// Relative directory path inside the project root.
pub path: String,
}
/// Output of [`ListProjectFiles::execute`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectFileListing {
/// Relative path of the listed directory.
pub path: String,
/// Direct children of the listed directory.
pub entries: Vec<ProjectFileEntry>,
}
/// One file-browser entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectFileEntry {
/// Basename displayed in the current directory listing.
pub name: String,
/// Relative path from the project root.
pub path: String,
/// File or directory kind.
pub kind: ProjectFileKind,
/// File size in bytes; absent for directories.
#[serde(skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
/// Last modification timestamp as epoch milliseconds when available.
#[serde(skip_serializing_if = "Option::is_none")]
pub modified_at_ms: Option<u64>,
}
/// Entry kind for the web contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProjectFileKind {
/// Regular file.
File,
/// Directory.
Directory,
}
/// Lists a project directory with root confinement and hidden `.ideai/`.
pub struct ListProjectFiles {
fs: Arc<dyn FileSystem>,
}
impl ListProjectFiles {
/// Builds the use case.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self { fs }
}
/// Lists one project directory.
pub async fn execute(
&self,
input: ListProjectFilesInput,
) -> Result<ProjectFileListing, AppError> {
let root = canonical_project_root(self.fs.as_ref(), &input.project).await?;
let relative = validate_project_relative_path(&input.path)?;
let directory = confined_project_path(self.fs.as_ref(), &root, &relative).await?;
let meta = self
.fs
.metadata(&RemotePath::new(path_string(&directory)))
.await?;
if !meta.is_dir {
return Err(AppError::Invalid(
"project file path is not a directory".to_owned(),
));
}
let mut entries = Vec::new();
for entry in self
.fs
.list(&RemotePath::new(path_string(&directory)))
.await?
{
let name = entry.name;
if name == ".ideai" {
continue;
}
let path = join_relative(&relative, &name);
let full_path = confined_project_path(self.fs.as_ref(), &root, &path).await?;
let meta = self
.fs
.metadata(&RemotePath::new(path_string(&full_path)))
.await?;
let kind = if meta.is_dir {
ProjectFileKind::Directory
} else if meta.is_file {
ProjectFileKind::File
} else {
continue;
};
entries.push(ProjectFileEntry {
name,
path,
kind,
size_bytes: meta.is_file.then_some(meta.len).flatten(),
modified_at_ms: meta.modified_at_ms,
});
}
entries.sort_by(|a, b| {
matches!(b.kind, ProjectFileKind::Directory)
.cmp(&matches!(a.kind, ProjectFileKind::Directory))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.name.cmp(&b.name))
});
Ok(ProjectFileListing {
path: relative,
entries,
})
}
}
/// Canonicalizes the project root used as the confinement anchor.
pub async fn canonical_project_root(
fs: &dyn FileSystem,
project: &Project,
) -> Result<PathBuf, AppError> {
Ok(PathBuf::from(
fs.canonicalize(&RemotePath::new(project.root.as_str()))
.await?
.as_str(),
))
}
/// Validates and normalizes a project-root-relative path.
pub fn validate_project_relative_path(path: &str) -> Result<String, AppError> {
if path.starts_with('/') || path.starts_with('\\') || path.contains('\\') {
return Err(AppError::Invalid(
"project file path must be relative".to_owned(),
));
}
let path = path.trim_matches('/');
if path.is_empty() {
return Ok(String::new());
}
let candidate = Path::new(path);
let mut parts = Vec::new();
for component in candidate.components() {
match component {
Component::Normal(part) => {
let part = part.to_string_lossy();
if part.is_empty() {
return Err(AppError::Invalid("project file path is invalid".to_owned()));
}
parts.push(part.into_owned());
}
_ => {
return Err(AppError::Invalid(
"project file path must not contain traversal".to_owned(),
));
}
}
}
Ok(parts.join("/"))
}
/// Canonicalizes a relative path and guarantees it remains below `root`.
pub async fn confined_project_path(
fs: &dyn FileSystem,
root: &Path,
relative: &str,
) -> Result<PathBuf, AppError> {
let relative = validate_project_relative_path(relative)?;
if relative.split('/').any(|segment| segment == ".ideai") {
return Err(AppError::Invalid("project file path is hidden".to_owned()));
}
let candidate = if relative.is_empty() {
root.to_path_buf()
} else {
root.join(&relative)
};
let canonical = PathBuf::from(
fs.canonicalize(&RemotePath::new(path_string(&candidate)))
.await?
.as_str(),
);
if !canonical.starts_with(root) {
return Err(AppError::Invalid(
"project file path escapes project root".to_owned(),
));
}
Ok(canonical)
}
fn join_relative(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_owned()
} else {
format!("{parent}/{name}")
}
}
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}

View File

@ -16,6 +16,7 @@
mod close;
mod context;
mod create;
mod files;
pub(crate) mod meta;
mod open;
@ -25,6 +26,10 @@ pub use context::{
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
};
pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput};
pub use files::{
canonical_project_root, confined_project_path, validate_project_relative_path,
ListProjectFiles, ListProjectFilesInput, ProjectFileEntry, ProjectFileKind, ProjectFileListing,
};
pub use meta::ProjectMeta;
pub use open::{
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,