feat(web): panneau Fichiers — navigation projet + téléchargements fichier/dossier ZIP — #211
This commit is contained in:
@ -195,8 +195,9 @@ pub use plugin::{
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||
OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjectFiles, ListProjectFilesInput, ListProjects,
|
||||
ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, ProjectFileEntry,
|
||||
ProjectFileKind, ProjectFileListing, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
|
||||
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
||||
PROJECT_CONTEXT_FILE,
|
||||
};
|
||||
|
||||
@ -4565,6 +4565,7 @@ mod tests {
|
||||
is_file: true,
|
||||
is_dir: false,
|
||||
len: Some(bytes.len() as u64),
|
||||
modified_at_ms: None,
|
||||
});
|
||||
}
|
||||
if files
|
||||
@ -4575,6 +4576,7 @@ mod tests {
|
||||
is_file: false,
|
||||
is_dir: true,
|
||||
len: None,
|
||||
modified_at_ms: None,
|
||||
});
|
||||
}
|
||||
Err(domain::ports::FsError::NotFound(path.as_str().to_owned()))
|
||||
|
||||
216
crates/application/src/project/files.rs
Normal file
216
crates/application/src/project/files.rs
Normal 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()
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user