merge(web): panneau Fichiers — navigation projet + téléchargements — #211 (QA verte)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -5318,6 +5318,7 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -195,8 +195,9 @@ pub use plugin::{
|
|||||||
};
|
};
|
||||||
pub use project::{
|
pub use project::{
|
||||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
CreateProjectInput, CreateProjectOutput, ListProjectFiles, ListProjectFilesInput, ListProjects,
|
||||||
OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
|
ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, ProjectFileEntry,
|
||||||
|
ProjectFileKind, ProjectFileListing, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
|
||||||
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
||||||
PROJECT_CONTEXT_FILE,
|
PROJECT_CONTEXT_FILE,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4565,6 +4565,7 @@ mod tests {
|
|||||||
is_file: true,
|
is_file: true,
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
len: Some(bytes.len() as u64),
|
len: Some(bytes.len() as u64),
|
||||||
|
modified_at_ms: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if files
|
if files
|
||||||
@ -4575,6 +4576,7 @@ mod tests {
|
|||||||
is_file: false,
|
is_file: false,
|
||||||
is_dir: true,
|
is_dir: true,
|
||||||
len: None,
|
len: None,
|
||||||
|
modified_at_ms: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(domain::ports::FsError::NotFound(path.as_str().to_owned()))
|
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 close;
|
||||||
mod context;
|
mod context;
|
||||||
mod create;
|
mod create;
|
||||||
|
mod files;
|
||||||
pub(crate) mod meta;
|
pub(crate) mod meta;
|
||||||
mod open;
|
mod open;
|
||||||
|
|
||||||
@ -25,6 +26,10 @@ pub use context::{
|
|||||||
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
|
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
|
||||||
};
|
};
|
||||||
pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput};
|
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 meta::ProjectMeta;
|
||||||
pub use open::{
|
pub use open::{
|
||||||
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
|
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
|
||||||
|
|||||||
@ -31,11 +31,11 @@ use application::{
|
|||||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
||||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles,
|
||||||
ListResumableAgents, ListSkills, ListSlashCommands, ListSprints, ListTemplates,
|
ListProjectFiles, ListProjects, ListResumableAgents, ListSkills, ListSlashCommands,
|
||||||
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
||||||
LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, McpRuntime,
|
LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized,
|
||||||
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView,
|
||||||
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
||||||
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
||||||
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||||
@ -985,6 +985,8 @@ pub struct BackendCore {
|
|||||||
pub close_tab: Arc<CloseTab>,
|
pub close_tab: Arc<CloseTab>,
|
||||||
/// List known projects.
|
/// List known projects.
|
||||||
pub list_projects: Arc<ListProjects>,
|
pub list_projects: Arc<ListProjects>,
|
||||||
|
/// List project files for the human web surface.
|
||||||
|
pub list_project_files: Arc<ListProjectFiles>,
|
||||||
/// Read `.ideai/CONTEXT.md`.
|
/// Read `.ideai/CONTEXT.md`.
|
||||||
pub read_project_context: Arc<ReadProjectContext>,
|
pub read_project_context: Arc<ReadProjectContext>,
|
||||||
/// Overwrite `.ideai/CONTEXT.md`.
|
/// Overwrite `.ideai/CONTEXT.md`.
|
||||||
@ -1588,6 +1590,7 @@ impl BackendCore {
|
|||||||
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
|
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port)));
|
||||||
let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port)));
|
let close_tab = Arc::new(CloseTab::new(Arc::clone(&store_port)));
|
||||||
let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port)));
|
let list_projects = Arc::new(ListProjects::new(Arc::clone(&store_port)));
|
||||||
|
let list_project_files = Arc::new(ListProjectFiles::new(Arc::clone(&fs_port)));
|
||||||
let read_project_context = Arc::new(ReadProjectContext::new(Arc::clone(&fs_port)));
|
let read_project_context = Arc::new(ReadProjectContext::new(Arc::clone(&fs_port)));
|
||||||
let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port)));
|
let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port)));
|
||||||
|
|
||||||
@ -3100,6 +3103,7 @@ impl BackendCore {
|
|||||||
close_project,
|
close_project,
|
||||||
close_tab,
|
close_tab,
|
||||||
list_projects,
|
list_projects,
|
||||||
|
list_project_files,
|
||||||
read_project_context,
|
read_project_context,
|
||||||
update_project_context,
|
update_project_context,
|
||||||
open_terminal,
|
open_terminal,
|
||||||
|
|||||||
@ -537,6 +537,8 @@ pub struct FileMetadata {
|
|||||||
pub is_dir: bool,
|
pub is_dir: bool,
|
||||||
/// File length in bytes when known.
|
/// File length in bytes when known.
|
||||||
pub len: Option<u64>,
|
pub len: Option<u64>,
|
||||||
|
/// Last modification timestamp as epoch milliseconds when known.
|
||||||
|
pub modified_at_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An owned, boxed stream of PTY output chunks.
|
/// An owned, boxed stream of PTY output chunks.
|
||||||
@ -1916,12 +1918,29 @@ pub trait FileSystem: Send + Sync {
|
|||||||
is_file: false,
|
is_file: false,
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
len: None,
|
len: None,
|
||||||
|
modified_at_ms: None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Canonicalizes a path, following symlinks when the backing filesystem does.
|
||||||
|
///
|
||||||
|
/// The default preserves source compatibility for remote/test adapters that do
|
||||||
|
/// not expose canonicalization; local confinement-sensitive use cases should
|
||||||
|
/// use an adapter that overrides this method.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`FsError`] on failure.
|
||||||
|
async fn canonicalize(&self, path: &RemotePath) -> Result<RemotePath, FsError> {
|
||||||
|
if self.exists(path).await? {
|
||||||
|
Ok(path.clone())
|
||||||
|
} else {
|
||||||
|
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes a single file. A **missing** file is treated as success (idempotent
|
/// Removes a single file. A **missing** file is treated as success (idempotent
|
||||||
/// delete), so this is safe to call best-effort.
|
/// delete), so this is safe to call best-effort.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{DirEntry, FileMetadata, FileSystem, FsError, RemotePath};
|
use domain::ports::{DirEntry, FileMetadata, FileSystem, FsError, RemotePath};
|
||||||
@ -62,9 +63,17 @@ impl FileSystem for LocalFileSystem {
|
|||||||
is_file: meta.is_file(),
|
is_file: meta.is_file(),
|
||||||
is_dir: meta.is_dir(),
|
is_dir: meta.is_dir(),
|
||||||
len: Some(meta.len()),
|
len: Some(meta.len()),
|
||||||
|
modified_at_ms: modified_at_ms(&meta),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn canonicalize(&self, path: &RemotePath) -> Result<RemotePath, FsError> {
|
||||||
|
fs::canonicalize(path.as_str())
|
||||||
|
.await
|
||||||
|
.map(|path| RemotePath::new(path.to_string_lossy().into_owned()))
|
||||||
|
.map_err(|e| map_io(path, &e))
|
||||||
|
}
|
||||||
|
|
||||||
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||||
match fs::remove_file(path.as_str()).await {
|
match fs::remove_file(path.as_str()).await {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
@ -103,6 +112,13 @@ impl FileSystem for LocalFileSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn modified_at_ms(meta: &std::fs::Metadata) -> Option<u64> {
|
||||||
|
meta.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> {
|
async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> {
|
||||||
fs::symlink(src, dst).await
|
fs::symlink(src, dst).await
|
||||||
|
|||||||
@ -31,6 +31,7 @@ http = "1.4"
|
|||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
subtle = { workspace = true }
|
subtle = { workspace = true }
|
||||||
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
vector-http = ["backend/vector-http"]
|
vector-http = ["backend/vector-http"]
|
||||||
|
|||||||
@ -18,11 +18,14 @@ use bytes::Bytes;
|
|||||||
use cookie::{Cookie, SameSite};
|
use cookie::{Cookie, SameSite};
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::ports::BackgroundTaskPortError;
|
use domain::ports::BackgroundTaskPortError;
|
||||||
use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE};
|
use http::header::{HeaderValue, CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use http::Request;
|
use http::Request;
|
||||||
use http::{HeaderMap, Method, Response, StatusCode, Uri};
|
use http::{HeaderMap, Method, Response, StatusCode, Uri};
|
||||||
use http_body_util::{BodyExt, Full};
|
#[cfg(test)]
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
#[cfg(test)]
|
||||||
|
use http_body_util::Full;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@ -32,6 +35,7 @@ use tokio::net::TcpListener;
|
|||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use zip::write::SimpleFileOptions;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
|
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
|
||||||
@ -43,20 +47,20 @@ use application::{
|
|||||||
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
||||||
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||||
LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput,
|
LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput,
|
||||||
ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions,
|
ListMemoriesInput, ListProjectFilesInput, ListResumableAgentsInput, ListSkillsInput,
|
||||||
MarkIssueAttachmentSummarizedInput, McpRuntime, OpenProjectInput, PairAttemptDecision,
|
ListSprintsInput, LiveSessions, MarkIssueAttachmentSummarizedInput, McpRuntime,
|
||||||
PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput,
|
OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
|
||||||
ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput,
|
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
|
||||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput,
|
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
|
||||||
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
|
RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput,
|
||||||
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
|
ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput,
|
||||||
RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
|
RevokeDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||||
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
|
TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput,
|
||||||
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
|
UnlinkIssuesInput, UpdateAgentContextInput, UpdateAgentEffortInput,
|
||||||
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
|
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
|
||||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
|
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
|
||||||
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
|
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
|
||||||
WriteToTerminalInput,
|
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
|
||||||
};
|
};
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
use domain::IssueActor;
|
use domain::IssueActor;
|
||||||
@ -119,7 +123,38 @@ const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|||||||
const WS_MAX_PAYLOAD: usize = 64 * 1024;
|
const WS_MAX_PAYLOAD: usize = 64 * 1024;
|
||||||
const WS_OUTPUT_BUFFER: usize = 512;
|
const WS_OUTPUT_BUFFER: usize = 512;
|
||||||
|
|
||||||
type ResponseBody = Full<Bytes>;
|
#[cfg(test)]
|
||||||
|
type RequestBody = Full<Bytes>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum ResponseBody {
|
||||||
|
Bytes(Bytes),
|
||||||
|
File(PathBuf),
|
||||||
|
TempFile(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseBody {
|
||||||
|
fn empty() -> Self {
|
||||||
|
Self::Bytes(Bytes::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(bytes: impl Into<Bytes>) -> Self {
|
||||||
|
Self::Bytes(bytes.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
async fn into_bytes(self) -> Bytes {
|
||||||
|
match self {
|
||||||
|
Self::Bytes(bytes) => bytes,
|
||||||
|
Self::File(path) => Bytes::from(tokio::fs::read(&path).await.unwrap_or_default()),
|
||||||
|
Self::TempFile(path) => {
|
||||||
|
let bytes = tokio::fs::read(&path).await.unwrap_or_default();
|
||||||
|
let _ = tokio::fs::remove_file(path).await;
|
||||||
|
Bytes::from(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
static NEXT_PAIRING_GENERATION_ID: AtomicU64 = AtomicU64::new(1);
|
static NEXT_PAIRING_GENERATION_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
/// Runs the `idea --serve` subcommand from already-split CLI arguments.
|
/// Runs the `idea --serve` subcommand from already-split CLI arguments.
|
||||||
@ -1024,7 +1059,7 @@ async fn handle_tcp_connection(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
async fn handle_request(
|
async fn handle_request(
|
||||||
req: Request<ResponseBody>,
|
req: Request<RequestBody>,
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
) -> Response<ResponseBody> {
|
) -> Response<ResponseBody> {
|
||||||
handle_request_from_peer(req, Arc::clone(&state), Some(state.config.listen.ip())).await
|
handle_request_from_peer(req, Arc::clone(&state), Some(state.config.listen.ip())).await
|
||||||
@ -1032,7 +1067,7 @@ async fn handle_request(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
async fn handle_request_from_peer(
|
async fn handle_request_from_peer(
|
||||||
req: Request<ResponseBody>,
|
req: Request<RequestBody>,
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
peer_ip: Option<IpAddr>,
|
peer_ip: Option<IpAddr>,
|
||||||
) -> Response<ResponseBody> {
|
) -> Response<ResponseBody> {
|
||||||
@ -1114,12 +1149,7 @@ async fn write_http_response(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let headers = response.headers().clone();
|
let headers = response.headers().clone();
|
||||||
let body = response
|
let body = response.into_body();
|
||||||
.into_body()
|
|
||||||
.collect()
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("failed to collect response: {err}"))?
|
|
||||||
.to_bytes();
|
|
||||||
let reason = status.canonical_reason().unwrap_or("Unknown");
|
let reason = status.canonical_reason().unwrap_or("Unknown");
|
||||||
let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes();
|
let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes();
|
||||||
for (name, value) in &headers {
|
for (name, value) in &headers {
|
||||||
@ -1128,13 +1158,53 @@ async fn write_http_response(
|
|||||||
bytes.extend_from_slice(value.as_bytes());
|
bytes.extend_from_slice(value.as_bytes());
|
||||||
bytes.extend_from_slice(b"\r\n");
|
bytes.extend_from_slice(b"\r\n");
|
||||||
}
|
}
|
||||||
bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes());
|
match &body {
|
||||||
|
ResponseBody::Bytes(body) => {
|
||||||
|
bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes());
|
||||||
|
}
|
||||||
|
ResponseBody::File(path) | ResponseBody::TempFile(path) => {
|
||||||
|
if let Ok(meta) = tokio::fs::metadata(path).await {
|
||||||
|
bytes.extend_from_slice(format!("content-length: {}\r\n", meta.len()).as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
bytes.extend_from_slice(b"connection: close\r\n\r\n");
|
bytes.extend_from_slice(b"connection: close\r\n\r\n");
|
||||||
bytes.extend_from_slice(&body);
|
|
||||||
stream
|
stream
|
||||||
.write_all(&bytes)
|
.write_all(&bytes)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| format!("failed to write response: {err}"))
|
.map_err(|err| format!("failed to write response head: {err}"))?;
|
||||||
|
match body {
|
||||||
|
ResponseBody::Bytes(body) => stream
|
||||||
|
.write_all(&body)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("failed to write response body: {err}")),
|
||||||
|
ResponseBody::File(path) => stream_file_body(stream, &path).await,
|
||||||
|
ResponseBody::TempFile(path) => {
|
||||||
|
let result = stream_file_body(stream, &path).await;
|
||||||
|
let _ = tokio::fs::remove_file(path).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_file_body(stream: &mut tokio::net::TcpStream, path: &Path) -> Result<(), String> {
|
||||||
|
let mut file = tokio::fs::File::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("failed to open response file {}: {err}", path.display()))?;
|
||||||
|
let mut buffer = [0_u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = file
|
||||||
|
.read(&mut buffer)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("failed to read response file {}: {err}", path.display()))?;
|
||||||
|
if read == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.write_all(&buffer[..read])
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("failed to write response file: {err}"))?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_ws_upgrade(
|
async fn handle_ws_upgrade(
|
||||||
@ -1778,6 +1848,37 @@ async fn dispatch_http(
|
|||||||
refresh_session_cookie(&mut response, &state, &token);
|
refresh_session_cookie(&mut response, &state, &token);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
(Method::GET, "/api/project-files") => {
|
||||||
|
let (token, _device) = match authenticated_device(&headers, &state).await {
|
||||||
|
Ok(auth) => auth,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let mut response =
|
||||||
|
project_files_list_response(&uri, Arc::clone(&state), origin.as_deref()).await;
|
||||||
|
refresh_session_cookie(&mut response, &state, &token);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
(Method::GET, "/api/project-files/download-file") => {
|
||||||
|
let (token, _device) = match authenticated_device(&headers, &state).await {
|
||||||
|
Ok(auth) => auth,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let mut response =
|
||||||
|
project_file_download_response(&uri, Arc::clone(&state), origin.as_deref()).await;
|
||||||
|
refresh_session_cookie(&mut response, &state, &token);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
(Method::GET, "/api/project-files/download-directory") => {
|
||||||
|
let (token, _device) = match authenticated_device(&headers, &state).await {
|
||||||
|
Ok(auth) => auth,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let mut response =
|
||||||
|
project_directory_download_response(&uri, Arc::clone(&state), origin.as_deref())
|
||||||
|
.await;
|
||||||
|
refresh_session_cookie(&mut response, &state, &token);
|
||||||
|
response
|
||||||
|
}
|
||||||
(Method::POST, "/api/devices/revoke-all") => {
|
(Method::POST, "/api/devices/revoke-all") => {
|
||||||
let (_token, _device) = match authenticated_device(&headers, &state).await {
|
let (_token, _device) = match authenticated_device(&headers, &state).await {
|
||||||
Ok(auth) => auth,
|
Ok(auth) => auth,
|
||||||
@ -1884,6 +1985,9 @@ async fn dispatch_http(
|
|||||||
| "/api/invoke"
|
| "/api/invoke"
|
||||||
| "/api/logout"
|
| "/api/logout"
|
||||||
| "/api/devices"
|
| "/api/devices"
|
||||||
|
| "/api/project-files"
|
||||||
|
| "/api/project-files/download-file"
|
||||||
|
| "/api/project-files/download-directory"
|
||||||
| "/api/devices/revoke-all",
|
| "/api/devices/revoke-all",
|
||||||
) => error_response(
|
) => error_response(
|
||||||
StatusCode::METHOD_NOT_ALLOWED,
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
@ -1993,7 +2097,7 @@ fn static_file_response(path: &Path, bytes: Vec<u8>, head_only: bool) -> Respons
|
|||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(CONTENT_TYPE, content_type(path))
|
.header(CONTENT_TYPE, content_type(path))
|
||||||
.header("x-content-type-options", "nosniff")
|
.header("x-content-type-options", "nosniff")
|
||||||
.body(Full::new(Bytes::from(body)))
|
.body(ResponseBody::bytes(Bytes::from(body)))
|
||||||
.expect("static response is valid");
|
.expect("static response is valid");
|
||||||
if is_index {
|
if is_index {
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
@ -2076,6 +2180,421 @@ async fn authenticated_device(
|
|||||||
Ok((token, device))
|
Ok((token, device))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn project_files_list_response(
|
||||||
|
uri: &Uri,
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
origin: Option<&str>,
|
||||||
|
) -> Response<ResponseBody> {
|
||||||
|
let params = match query_params(uri) {
|
||||||
|
Ok(params) => params,
|
||||||
|
Err(err) => return error_response(StatusCode::BAD_REQUEST, "INVALID", err, origin),
|
||||||
|
};
|
||||||
|
let project_id = match required_query(¶ms, "projectId") {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => return error_response(StatusCode::BAD_REQUEST, "INVALID", err, origin),
|
||||||
|
};
|
||||||
|
let path = params.get("path").cloned().unwrap_or_default();
|
||||||
|
let project = match resolve_project_readonly(&project_id, &state.app).await {
|
||||||
|
Ok(project) => project,
|
||||||
|
Err(err) => return error_dto_response(error_status(&err), err, origin),
|
||||||
|
};
|
||||||
|
let listing = match state
|
||||||
|
.app
|
||||||
|
.list_project_files
|
||||||
|
.execute(ListProjectFilesInput { project, path })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(listing) => listing,
|
||||||
|
Err(err) => {
|
||||||
|
let err = ErrorDto::from(err);
|
||||||
|
return error_dto_response(error_status(&err), err, origin);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(listing).expect("project file listing serializes");
|
||||||
|
json_response(StatusCode::OK, &value, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn project_file_download_response(
|
||||||
|
uri: &Uri,
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
origin: Option<&str>,
|
||||||
|
) -> Response<ResponseBody> {
|
||||||
|
let resolved = match resolve_project_download_path(uri, &state).await {
|
||||||
|
Ok(resolved) => resolved,
|
||||||
|
Err(response) => return response.with_origin(origin),
|
||||||
|
};
|
||||||
|
let meta = match tokio::fs::metadata(&resolved.full_path).await {
|
||||||
|
Ok(meta) => meta,
|
||||||
|
Err(err) => {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"NOT_FOUND",
|
||||||
|
format!("project file not found: {err}"),
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !meta.is_file() {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"INVALID",
|
||||||
|
"project file path is not a file",
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let filename = download_name(&resolved.relative, "project-file");
|
||||||
|
binary_file_response(
|
||||||
|
ResponseBody::File(resolved.full_path),
|
||||||
|
content_type(Path::new(&filename)),
|
||||||
|
&filename,
|
||||||
|
origin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn project_directory_download_response(
|
||||||
|
uri: &Uri,
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
origin: Option<&str>,
|
||||||
|
) -> Response<ResponseBody> {
|
||||||
|
let resolved = match resolve_project_download_path(uri, &state).await {
|
||||||
|
Ok(resolved) => resolved,
|
||||||
|
Err(response) => return response.with_origin(origin),
|
||||||
|
};
|
||||||
|
let meta = match tokio::fs::metadata(&resolved.full_path).await {
|
||||||
|
Ok(meta) => meta,
|
||||||
|
Err(err) => {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"NOT_FOUND",
|
||||||
|
format!("project directory not found: {err}"),
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !meta.is_dir() {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"INVALID",
|
||||||
|
"project file path is not a directory",
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let archive_name = format!(
|
||||||
|
"{}.zip",
|
||||||
|
download_name(&resolved.relative, &resolved.project_name)
|
||||||
|
);
|
||||||
|
let zip_path = match create_project_directory_zip(resolved).await {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(err) => {
|
||||||
|
return error_response(StatusCode::BAD_REQUEST, "INVALID", err.to_string(), origin);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
binary_file_response(
|
||||||
|
ResponseBody::TempFile(zip_path),
|
||||||
|
"application/zip",
|
||||||
|
&archive_name,
|
||||||
|
origin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ResolvedProjectPath {
|
||||||
|
root: PathBuf,
|
||||||
|
full_path: PathBuf,
|
||||||
|
relative: String,
|
||||||
|
project_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_project_download_path(
|
||||||
|
uri: &Uri,
|
||||||
|
state: &ServerState,
|
||||||
|
) -> Result<ResolvedProjectPath, ProjectPathResponseError> {
|
||||||
|
let params = query_params(uri).map_err(ProjectPathResponseError::bad_request)?;
|
||||||
|
let project_id =
|
||||||
|
required_query(¶ms, "projectId").map_err(ProjectPathResponseError::bad_request)?;
|
||||||
|
let relative = params.get("path").cloned().unwrap_or_default();
|
||||||
|
let project = resolve_project_readonly(&project_id, &state.app)
|
||||||
|
.await
|
||||||
|
.map_err(ProjectPathResponseError::dto)?;
|
||||||
|
let root = canonical_project_root_for_http(&project)
|
||||||
|
.await
|
||||||
|
.map_err(ProjectPathResponseError::app)?;
|
||||||
|
let relative = application::project::validate_project_relative_path(&relative)
|
||||||
|
.map_err(ProjectPathResponseError::app)?;
|
||||||
|
let full_path = confined_project_path_for_http(&root, &relative)
|
||||||
|
.await
|
||||||
|
.map_err(ProjectPathResponseError::app)?;
|
||||||
|
Ok(ResolvedProjectPath {
|
||||||
|
root,
|
||||||
|
full_path,
|
||||||
|
relative,
|
||||||
|
project_name: project.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn canonical_project_root_for_http(project: &Project) -> Result<PathBuf, AppError> {
|
||||||
|
tokio::fs::canonicalize(project.root.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", project.root.as_str())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn confined_project_path_for_http(root: &Path, relative: &str) -> Result<PathBuf, AppError> {
|
||||||
|
let relative = application::project::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 = tokio::fs::canonicalize(&candidate)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", candidate.display())))?;
|
||||||
|
if !canonical.starts_with(root) {
|
||||||
|
return Err(AppError::Invalid(
|
||||||
|
"project file path escapes project root".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProjectPathResponseError {
|
||||||
|
BadRequest(String),
|
||||||
|
Dto(ErrorDto),
|
||||||
|
App(AppError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectPathResponseError {
|
||||||
|
fn bad_request(err: String) -> Self {
|
||||||
|
Self::BadRequest(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dto(err: ErrorDto) -> Self {
|
||||||
|
Self::Dto(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app(err: AppError) -> Self {
|
||||||
|
Self::App(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_origin(self, origin: Option<&str>) -> Response<ResponseBody> {
|
||||||
|
match self {
|
||||||
|
Self::BadRequest(err) => {
|
||||||
|
error_response(StatusCode::BAD_REQUEST, "INVALID", err, origin)
|
||||||
|
}
|
||||||
|
Self::Dto(err) => error_dto_response(error_status(&err), err, origin),
|
||||||
|
Self::App(err) => {
|
||||||
|
let err = ErrorDto::from(err);
|
||||||
|
error_dto_response(error_status(&err), err, origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_project_directory_zip(resolved: ResolvedProjectPath) -> Result<PathBuf, AppError> {
|
||||||
|
let zip_path = std::env::temp_dir().join(format!("idea-project-files-{}.zip", Uuid::new_v4()));
|
||||||
|
let archive_root = download_name(&resolved.relative, &resolved.project_name);
|
||||||
|
let root = resolved.root;
|
||||||
|
let source = resolved.full_path;
|
||||||
|
let zip_target = zip_path.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let file = std::fs::File::create(&zip_target)
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", zip_target.display())))?;
|
||||||
|
let mut zip = zip::ZipWriter::new(file);
|
||||||
|
zip_directory(&mut zip, &root, &source, &archive_root)?;
|
||||||
|
zip.finish()
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("zip finish failed: {err}")))?;
|
||||||
|
Ok::<PathBuf, AppError>(zip_target)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::Process(format!("zip task failed: {err}")))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zip_directory(
|
||||||
|
zip: &mut zip::ZipWriter<std::fs::File>,
|
||||||
|
root: &Path,
|
||||||
|
directory: &Path,
|
||||||
|
archive_root: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||||
|
let mut stack = vec![directory.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
let entries = std::fs::read_dir(¤t)
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", current.display())))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", current.display())))?;
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if name == ".ideai" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
let symlink_meta = std::fs::symlink_metadata(&path)
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
|
||||||
|
if symlink_meta.file_type().is_symlink() {
|
||||||
|
let canonical = std::fs::canonicalize(&path)
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
|
||||||
|
if !canonical.starts_with(root) {
|
||||||
|
return Err(AppError::Invalid(
|
||||||
|
"project directory contains a symlink escaping project root".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(AppError::Invalid(
|
||||||
|
"project directory symlinks are not downloadable".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let meta = std::fs::metadata(&path)
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
|
||||||
|
let canonical = std::fs::canonicalize(&path)
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
|
||||||
|
if !canonical.starts_with(root) {
|
||||||
|
return Err(AppError::Invalid(
|
||||||
|
"project directory entry escapes project root".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(directory)
|
||||||
|
.map_err(|_| AppError::Invalid("project directory entry is invalid".to_owned()))?;
|
||||||
|
let archive_name = archive_path(archive_root, rel)?;
|
||||||
|
if meta.is_dir() {
|
||||||
|
zip.add_directory(format!("{archive_name}/"), options)
|
||||||
|
.map_err(|err| {
|
||||||
|
AppError::FileSystem(format!("zip add directory failed: {err}"))
|
||||||
|
})?;
|
||||||
|
stack.push(path);
|
||||||
|
} else if meta.is_file() {
|
||||||
|
zip.start_file(archive_name, options)
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("zip add file failed: {err}")))?;
|
||||||
|
let mut file = std::fs::File::open(&path)
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
|
||||||
|
std::io::copy(&mut file, zip)
|
||||||
|
.map_err(|err| AppError::FileSystem(format!("zip write failed: {err}")))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archive_path(prefix: &str, relative: &Path) -> Result<String, AppError> {
|
||||||
|
let mut parts = vec![sanitize_download_component(prefix)];
|
||||||
|
for component in relative.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::Normal(part) => {
|
||||||
|
let part = part.to_string_lossy();
|
||||||
|
if part == ".ideai" {
|
||||||
|
return Err(AppError::Invalid("project file path is hidden".to_owned()));
|
||||||
|
}
|
||||||
|
parts.push(part.into_owned());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::Invalid(
|
||||||
|
"project archive path is invalid".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(parts.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binary_file_response(
|
||||||
|
body: ResponseBody,
|
||||||
|
content_type: &'static str,
|
||||||
|
filename: &str,
|
||||||
|
origin: Option<&str>,
|
||||||
|
) -> Response<ResponseBody> {
|
||||||
|
let mut response = Response::new(body);
|
||||||
|
*response.status_mut() = StatusCode::OK;
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
CONTENT_DISPOSITION,
|
||||||
|
HeaderValue::from_str(&format!(
|
||||||
|
"attachment; filename=\"{}\"",
|
||||||
|
filename.replace('"', "")
|
||||||
|
))
|
||||||
|
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
|
||||||
|
);
|
||||||
|
add_cors_headers(&mut response, origin);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_params(uri: &Uri) -> Result<HashMap<String, String>, String> {
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
let Some(query) = uri.query() else {
|
||||||
|
return Ok(params);
|
||||||
|
};
|
||||||
|
for pair in query.split('&').filter(|pair| !pair.is_empty()) {
|
||||||
|
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
|
||||||
|
params.insert(percent_decode(key)?, percent_decode(value)?);
|
||||||
|
}
|
||||||
|
Ok(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_query(params: &HashMap<String, String>, key: &str) -> Result<String, String> {
|
||||||
|
params
|
||||||
|
.get(key)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("missing query parameter: {key}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(raw: &str) -> Result<String, String> {
|
||||||
|
let mut bytes = Vec::with_capacity(raw.len());
|
||||||
|
let mut chars = raw.as_bytes().iter().copied();
|
||||||
|
while let Some(byte) = chars.next() {
|
||||||
|
if byte == b'%' {
|
||||||
|
let hi = chars
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "invalid percent encoding".to_owned())?;
|
||||||
|
let lo = chars
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "invalid percent encoding".to_owned())?;
|
||||||
|
bytes.push((hex_value(hi)? << 4) | hex_value(lo)?);
|
||||||
|
} else if byte == b'+' {
|
||||||
|
bytes.push(b' ');
|
||||||
|
} else {
|
||||||
|
bytes.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(bytes).map_err(|_| "query parameter is not UTF-8".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_value(byte: u8) -> Result<u8, String> {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => Ok(byte - b'0'),
|
||||||
|
b'a'..=b'f' => Ok(byte - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Ok(byte - b'A' + 10),
|
||||||
|
_ => Err("invalid percent encoding".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_name(relative: &str, fallback: &str) -> String {
|
||||||
|
relative
|
||||||
|
.rsplit('/')
|
||||||
|
.find(|part| !part.is_empty())
|
||||||
|
.map(sanitize_download_component)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| sanitize_download_component(fallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_download_component(raw: &str) -> String {
|
||||||
|
let sanitized: String = raw
|
||||||
|
.chars()
|
||||||
|
.map(|ch| match ch {
|
||||||
|
'/' | '\\' | '"' | '\r' | '\n' | '\t' => '_',
|
||||||
|
ch if ch.is_control() => '_',
|
||||||
|
ch => ch,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sanitized = sanitized.trim_matches('.').trim().to_owned();
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
"project".to_owned()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_devices_response(
|
async fn list_devices_response(
|
||||||
state: Arc<ServerState>,
|
state: Arc<ServerState>,
|
||||||
device: &AuthenticatedDevice,
|
device: &AuthenticatedDevice,
|
||||||
@ -4484,7 +5003,7 @@ fn json_response(
|
|||||||
origin: Option<&str>,
|
origin: Option<&str>,
|
||||||
) -> Response<ResponseBody> {
|
) -> Response<ResponseBody> {
|
||||||
let body = serde_json::to_vec(value).expect("JSON value serializes");
|
let body = serde_json::to_vec(value).expect("JSON value serializes");
|
||||||
let mut response = Response::new(Full::new(Bytes::from(body)));
|
let mut response = Response::new(ResponseBody::bytes(Bytes::from(body)));
|
||||||
*response.status_mut() = status;
|
*response.status_mut() = status;
|
||||||
response
|
response
|
||||||
.headers_mut()
|
.headers_mut()
|
||||||
@ -4518,8 +5037,18 @@ fn error_response(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn error_status(error: &ErrorDto) -> StatusCode {
|
||||||
|
match error.code.as_str() {
|
||||||
|
"NOT_FOUND" => StatusCode::NOT_FOUND,
|
||||||
|
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
|
||||||
|
"CONFLICT" => StatusCode::CONFLICT,
|
||||||
|
"INVALID" => StatusCode::BAD_REQUEST,
|
||||||
|
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cors_response(status: StatusCode, origin: Option<&str>) -> Response<ResponseBody> {
|
fn cors_response(status: StatusCode, origin: Option<&str>) -> Response<ResponseBody> {
|
||||||
let mut response = Response::new(Full::new(Bytes::new()));
|
let mut response = Response::new(ResponseBody::empty());
|
||||||
*response.status_mut() = status;
|
*response.status_mut() = status;
|
||||||
add_cors_headers(&mut response, origin);
|
add_cors_headers(&mut response, origin);
|
||||||
response
|
response
|
||||||
@ -5381,7 +5910,7 @@ mod tests {
|
|||||||
async fn response_json(response: Response<ResponseBody>) -> (StatusCode, Value, HeaderMap) {
|
async fn response_json(response: Response<ResponseBody>) -> (StatusCode, Value, HeaderMap) {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let headers = response.headers().clone();
|
let headers = response.headers().clone();
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().into_bytes().await;
|
||||||
let value = if body.is_empty() {
|
let value = if body.is_empty() {
|
||||||
Value::Null
|
Value::Null
|
||||||
} else {
|
} else {
|
||||||
@ -5455,7 +5984,7 @@ mod tests {
|
|||||||
async fn response_bytes(response: Response<ResponseBody>) -> (StatusCode, Bytes, HeaderMap) {
|
async fn response_bytes(response: Response<ResponseBody>) -> (StatusCode, Bytes, HeaderMap) {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let headers = response.headers().clone();
|
let headers = response.headers().clone();
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().into_bytes().await;
|
||||||
(status, body, headers)
|
(status, body, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7723,6 +8252,220 @@ mod tests {
|
|||||||
assert!(body["root"].is_string());
|
assert!(body["root"].is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_project_files_lists_relative_paths_and_hides_ideai() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Project Files").await;
|
||||||
|
let project = resolve_project_readonly(&project_id, &state.app)
|
||||||
|
.await
|
||||||
|
.expect("project resolves");
|
||||||
|
std::fs::create_dir_all(format!("{}/src", project.root.as_str())).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
format!("{}/src/main.rs", project.root.as_str()),
|
||||||
|
"fn main() {}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(format!("{}/README.md", project.root.as_str()), "readme").unwrap();
|
||||||
|
std::fs::create_dir_all(format!("{}/.ideai", project.root.as_str())).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
format!("{}/.ideai/secret.txt", project.root.as_str()),
|
||||||
|
"secret",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let (status, body, _) = response_json(
|
||||||
|
request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/project-files?projectId={project_id}&path="),
|
||||||
|
json!({}),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body["path"], "");
|
||||||
|
let entries = body["entries"].as_array().unwrap();
|
||||||
|
assert!(entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry["name"] == "src" && entry["kind"] == "directory"));
|
||||||
|
assert!(entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry["path"] == "README.md" && entry["kind"] == "file"));
|
||||||
|
assert!(!entries.iter().any(|entry| entry["name"] == ".ideai"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_project_files_rejects_traversal_and_hidden_paths() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Project Files Security").await;
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let (traversal_status, traversal_body, _) = response_json(
|
||||||
|
request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/project-files?projectId={project_id}&path=../"),
|
||||||
|
json!({}),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (hidden_status, hidden_body, _) = response_json(
|
||||||
|
request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/project-files?projectId={project_id}&path=.ideai"),
|
||||||
|
json!({}),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(traversal_status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(traversal_body["code"], "INVALID");
|
||||||
|
assert_eq!(hidden_status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(hidden_body["code"], "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_project_file_download_requires_file_and_returns_binary() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Project File Download").await;
|
||||||
|
let project = resolve_project_readonly(&project_id, &state.app)
|
||||||
|
.await
|
||||||
|
.expect("project resolves");
|
||||||
|
std::fs::create_dir_all(format!("{}/src", project.root.as_str())).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
format!("{}/src/main.rs", project.root.as_str()),
|
||||||
|
"fn main() {}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let (status, body, headers) = response_bytes(
|
||||||
|
request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::GET,
|
||||||
|
&format!(
|
||||||
|
"/api/project-files/download-file?projectId={project_id}&path=src/main.rs"
|
||||||
|
),
|
||||||
|
json!({}),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (dir_status, dir_body, _) = response_json(
|
||||||
|
request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/project-files/download-file?projectId={project_id}&path=src"),
|
||||||
|
json!({}),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(body, Bytes::from_static(b"fn main() {}\n"));
|
||||||
|
assert!(headers
|
||||||
|
.get(CONTENT_DISPOSITION)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("main.rs"));
|
||||||
|
assert_eq!(dir_status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(dir_body["code"], "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_project_directory_download_returns_zip_and_requires_directory() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Project Directory Download").await;
|
||||||
|
let project = resolve_project_readonly(&project_id, &state.app)
|
||||||
|
.await
|
||||||
|
.expect("project resolves");
|
||||||
|
std::fs::create_dir_all(format!("{}/src/nested", project.root.as_str())).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
format!("{}/src/nested/lib.rs", project.root.as_str()),
|
||||||
|
"pub fn x() {}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let (status, body, headers) = response_bytes(
|
||||||
|
request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/project-files/download-directory?projectId={project_id}&path=src"),
|
||||||
|
json!({}),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (file_status, file_body, _) = response_json(
|
||||||
|
request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/project-files/download-directory?projectId={project_id}&path=src/nested/lib.rs"),
|
||||||
|
json!({}),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert!(body.starts_with(b"PK"));
|
||||||
|
assert_eq!(
|
||||||
|
headers.get(CONTENT_TYPE).unwrap(),
|
||||||
|
HeaderValue::from_static("application/zip")
|
||||||
|
);
|
||||||
|
assert_eq!(file_status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(file_body["code"], "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn authorized_project_files_rejects_symlink_escaping_root() {
|
||||||
|
let state = state();
|
||||||
|
let project_id = create_project_for_test(&state, "Project Files Symlink").await;
|
||||||
|
let project = resolve_project_readonly(&project_id, &state.app)
|
||||||
|
.await
|
||||||
|
.expect("project resolves");
|
||||||
|
let outside = std::env::temp_dir().join(format!("idea-outside-{}", Uuid::new_v4()));
|
||||||
|
std::fs::write(&outside, "outside").unwrap();
|
||||||
|
std::os::unix::fs::symlink(&outside, format!("{}/outside-link", project.root.as_str()))
|
||||||
|
.unwrap();
|
||||||
|
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||||
|
|
||||||
|
let (status, body, _) = response_json(
|
||||||
|
request(
|
||||||
|
Arc::clone(&state),
|
||||||
|
Method::GET,
|
||||||
|
&format!(
|
||||||
|
"/api/project-files/download-file?projectId={project_id}&path=outside-link"
|
||||||
|
),
|
||||||
|
json!({}),
|
||||||
|
&[("cookie", &cookie)],
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(body["code"], "INVALID");
|
||||||
|
let _ = std::fs::remove_file(outside);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn authorized_get_project_work_state_returns_tauri_contract() {
|
async fn authorized_get_project_work_state_returns_tauri_contract() {
|
||||||
let state = state();
|
let state = state();
|
||||||
|
|||||||
@ -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 () => {
|
it("HttpGitGateway.stage maps to git_stage with a request envelope", async () => {
|
||||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||||
const spy = vi.spyOn(http, "invoke").mockResolvedValue(undefined as never);
|
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). */
|
/** Parsed JSON body (may reject/return undefined for empty bodies). */
|
||||||
json(): Promise<unknown>;
|
json(): Promise<unknown>;
|
||||||
text(): Promise<string>;
|
text(): Promise<string>;
|
||||||
|
blob?(): Promise<Blob>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -182,4 +183,51 @@ export class HttpInvoker {
|
|||||||
}
|
}
|
||||||
return parsed as T;
|
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,
|
OpenCodeProviderCatalogEntry,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
Project,
|
Project,
|
||||||
|
ProjectFileListing,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
@ -100,6 +101,37 @@ export class HttpProjectGateway implements ProjectGateway {
|
|||||||
async updateProjectContext(projectId: string, content: string): Promise<void> {
|
async updateProjectContext(projectId: string, content: string): Promise<void> {
|
||||||
await this.http.invoke("update_project_context", { request: { projectId, content } });
|
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 {
|
export class HttpLayoutGateway implements LayoutGateway {
|
||||||
|
|||||||
@ -66,6 +66,7 @@ import type {
|
|||||||
PluginWorkspaceStat,
|
PluginWorkspaceStat,
|
||||||
PluginWorkspaceTextFile,
|
PluginWorkspaceTextFile,
|
||||||
Project,
|
Project,
|
||||||
|
ProjectFileListing,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
@ -1262,6 +1263,14 @@ export class MockProjectGateway implements ProjectGateway {
|
|||||||
}
|
}
|
||||||
this.contexts.set(projectId, content);
|
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. */
|
/** Internal per-project layout store entry. */
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type { Project } from "@/domain";
|
import type { Project, ProjectFileListing } from "@/domain";
|
||||||
import type { ProjectGateway } from "@/ports";
|
import type { ProjectGateway } from "@/ports";
|
||||||
|
|
||||||
export class TauriProjectGateway implements ProjectGateway {
|
export class TauriProjectGateway implements ProjectGateway {
|
||||||
@ -37,4 +37,16 @@ export class TauriProjectGateway implements ProjectGateway {
|
|||||||
request: { projectId, content },
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,21 @@ export interface HealthReport {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One project file-system entry exposed by the web project-files surface. */
|
||||||
|
export interface ProjectFileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
kind: "file" | "directory";
|
||||||
|
sizeBytes?: number;
|
||||||
|
modifiedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listing for one project-relative directory. */
|
||||||
|
export interface ProjectFileListing {
|
||||||
|
path: string;
|
||||||
|
entries: ProjectFileEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App-exit work guard (ticket #83) — confirmation before quitting IdeA while
|
// App-exit work guard (ticket #83) — confirmation before quitting IdeA while
|
||||||
// agents/background tasks are active. Mirrors the backend
|
// agents/background tasks are active. Mirrors the backend
|
||||||
|
|||||||
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 { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
|
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
|
||||||
import {
|
import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacement";
|
||||||
PANEL_TITLE,
|
|
||||||
floatingPanels,
|
|
||||||
isDockedTo,
|
|
||||||
panelsDockedTo,
|
|
||||||
placementOf,
|
|
||||||
type PanelId,
|
|
||||||
type ViewPlacement,
|
|
||||||
type ViewPlacements,
|
|
||||||
} from "@/features/projects/viewPlacement";
|
|
||||||
import { AgentsPanel } from "@/features/agents";
|
import { AgentsPanel } from "@/features/agents";
|
||||||
import { TemplatesPanel } from "@/features/templates";
|
import { TemplatesPanel } from "@/features/templates";
|
||||||
import { SkillsPanel } from "@/features/skills";
|
import { SkillsPanel } from "@/features/skills";
|
||||||
@ -69,6 +60,7 @@ import { WebTicketsView } from "./tickets/WebTicketsView";
|
|||||||
import { WebSprintsView } from "./tickets/WebSprintsView";
|
import { WebSprintsView } from "./tickets/WebSprintsView";
|
||||||
import { WebProjectsPanel } from "./WebProjectsPanel";
|
import { WebProjectsPanel } from "./WebProjectsPanel";
|
||||||
import { WebMenuSheet } from "./WebMenuSheet";
|
import { WebMenuSheet } from "./WebMenuSheet";
|
||||||
|
import { ProjectFilesPanel } from "./ProjectFilesPanel";
|
||||||
import {
|
import {
|
||||||
WEB_PANEL_META,
|
WEB_PANEL_META,
|
||||||
type WebOpenablePanelId,
|
type WebOpenablePanelId,
|
||||||
@ -205,19 +197,50 @@ function useMobileWorkspace(): boolean {
|
|||||||
return mobile;
|
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",
|
projects: "md",
|
||||||
context: "lg",
|
context: "lg",
|
||||||
work: "lg",
|
work: "lg",
|
||||||
tickets: "lg",
|
tickets: "lg",
|
||||||
|
sprints: "lg",
|
||||||
agents: "lg",
|
agents: "lg",
|
||||||
templates: "lg",
|
templates: "lg",
|
||||||
skills: "lg",
|
skills: "lg",
|
||||||
permissions: "md",
|
permissions: "md",
|
||||||
memory: "lg",
|
memory: "lg",
|
||||||
git: "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({
|
function WebDesktopWorkspace({
|
||||||
projects,
|
projects,
|
||||||
openId,
|
openId,
|
||||||
@ -243,7 +266,7 @@ function WebDesktopWorkspace({
|
|||||||
onRefreshProjects: () => void;
|
onRefreshProjects: () => void;
|
||||||
onViewSprintTickets: (sprintId: string) => void;
|
onViewSprintTickets: (sprintId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [placements, setPlacements] = useState<ViewPlacements>({
|
const [placements, setPlacements] = useState<WebDesktopPlacements>({
|
||||||
work: { dock: "right" },
|
work: { dock: "right" },
|
||||||
});
|
});
|
||||||
const [leftDockWidth, setLeftDockWidth] = useState(340);
|
const [leftDockWidth, setLeftDockWidth] = useState(340);
|
||||||
@ -259,11 +282,11 @@ function WebDesktopWorkspace({
|
|||||||
setViewerConversation(null);
|
setViewerConversation(null);
|
||||||
}, [openId]);
|
}, [openId]);
|
||||||
|
|
||||||
function setPlacement(panel: PanelId, placement: Exclude<ViewPlacement, "detached">) {
|
function setPlacement(panel: WebDesktopPanelId, placement: Exclude<ViewPlacement, "detached">) {
|
||||||
setPlacements((prev) => {
|
setPlacements((prev) => {
|
||||||
const next: ViewPlacements = { ...prev };
|
const next: WebDesktopPlacements = { ...prev };
|
||||||
if (placement === "floating") {
|
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];
|
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) => {
|
setPlacements((prev) => {
|
||||||
if (!(panel in prev)) return prev;
|
if (!(panel in prev)) return prev;
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
@ -285,7 +308,7 @@ function WebDesktopWorkspace({
|
|||||||
setPlacements((prev) => {
|
setPlacements((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next = { ...prev };
|
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") {
|
if (next[panel] === "floating") {
|
||||||
delete next[panel];
|
delete next[panel];
|
||||||
changed = true;
|
changed = true;
|
||||||
@ -301,22 +324,23 @@ function WebDesktopWorkspace({
|
|||||||
dismissFloating();
|
dismissFloating();
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelOrder: PanelId[] = [
|
const panelOrder: WebDesktopPanelId[] = [
|
||||||
"context",
|
"context",
|
||||||
"work",
|
"work",
|
||||||
"tickets",
|
"tickets",
|
||||||
|
"sprints",
|
||||||
"agents",
|
"agents",
|
||||||
"templates",
|
"templates",
|
||||||
"skills",
|
"skills",
|
||||||
"permissions",
|
"permissions",
|
||||||
"memory",
|
"memory",
|
||||||
"git",
|
"git",
|
||||||
|
"files",
|
||||||
"projects",
|
"projects",
|
||||||
];
|
];
|
||||||
const panelToWebId = (panel: PanelId): WebProjectPanelId => panel;
|
|
||||||
|
|
||||||
function placementSubmenu(panel: PanelId): MenuBarItem[] {
|
function placementSubmenu(panel: WebDesktopPanelId): MenuBarItem[] {
|
||||||
const placement = placementOf(placements, panel);
|
const placement = webPlacementOf(placements, panel);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "closed",
|
id: "closed",
|
||||||
@ -351,8 +375,8 @@ function WebDesktopWorkspace({
|
|||||||
label: "Panneaux",
|
label: "Panneaux",
|
||||||
items: panelOrder.map((panel) => ({
|
items: panelOrder.map((panel) => ({
|
||||||
id: panel,
|
id: panel,
|
||||||
label: PANEL_TITLE[panel],
|
label: webPanelTitle(panel),
|
||||||
active: placementOf(placements, panel) !== "closed",
|
active: webPlacementOf(placements, panel) !== "closed",
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
submenu: placementSubmenu(panel),
|
submenu: placementSubmenu(panel),
|
||||||
})),
|
})),
|
||||||
@ -369,14 +393,14 @@ function WebDesktopWorkspace({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
function renderPanel(panel: PanelId) {
|
function renderPanel(panel: WebDesktopPanelId) {
|
||||||
if (panel === "projects") return projectsManager;
|
if (panel === "projects") return projectsManager;
|
||||||
if (!openId) {
|
if (!openId) {
|
||||||
return <p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>;
|
return <p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<WebProjectPanelBody
|
<WebProjectPanelBody
|
||||||
panel={panelToWebId(panel) as WebOpenablePanelId}
|
panel={panel as WebOpenablePanelId}
|
||||||
projectId={openId}
|
projectId={openId}
|
||||||
root={openRoot}
|
root={openRoot}
|
||||||
focusSprintId={focusSprintId}
|
focusSprintId={focusSprintId}
|
||||||
@ -385,9 +409,9 @@ function WebDesktopWorkspace({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DockControls({ panel }: { panel: PanelId }) {
|
function DockControls({ panel }: { panel: WebDesktopPanelId }) {
|
||||||
const placement = placementOf(placements, panel);
|
const placement = webPlacementOf(placements, panel);
|
||||||
const title = PANEL_TITLE[panel];
|
const title = webPanelTitle(panel);
|
||||||
const activeClass = "bg-raised text-content ring-1 ring-border-strong";
|
const activeClass = "bg-raised text-content ring-1 ring-border-strong";
|
||||||
return (
|
return (
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
@ -433,16 +457,16 @@ function WebDesktopWorkspace({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDockedView(panel: PanelId) {
|
function renderDockedView(panel: WebDesktopPanelId) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
key={panel}
|
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"
|
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">
|
<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">
|
<span className="truncate text-sm font-medium text-content">
|
||||||
{PANEL_TITLE[panel]}
|
{webPanelTitle(panel)}
|
||||||
</span>
|
</span>
|
||||||
<DockControls panel={panel} />
|
<DockControls panel={panel} />
|
||||||
</header>
|
</header>
|
||||||
@ -451,12 +475,12 @@ function WebDesktopWorkspace({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftPanels = panelsDockedTo(placements, "left");
|
const leftPanels = webPanelsDockedTo(placements, "left");
|
||||||
const rightPanels = panelsDockedTo(placements, "right");
|
const rightPanels = webPanelsDockedTo(placements, "right");
|
||||||
const floatingList = floatingPanels(placements);
|
const floatingList = webFloatingPanels(placements);
|
||||||
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||||||
const showInlineProjects =
|
const showInlineProjects =
|
||||||
!openProject && placementOf(placements, "projects") === "closed";
|
!openProject && webPlacementOf(placements, "projects") === "closed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -564,7 +588,7 @@ function WebDesktopWorkspace({
|
|||||||
<FloatingWindow
|
<FloatingWindow
|
||||||
key={panel}
|
key={panel}
|
||||||
open
|
open
|
||||||
title={PANEL_TITLE[panel]}
|
title={webPanelTitle(panel)}
|
||||||
size={WEB_PANEL_SIZE[panel]}
|
size={WEB_PANEL_SIZE[panel]}
|
||||||
onClose={() => closePanel(panel)}
|
onClose={() => closePanel(panel)}
|
||||||
>
|
>
|
||||||
@ -611,6 +635,7 @@ function WebMobileTaskFlowShell({
|
|||||||
{ id: "work", label: "Travail" },
|
{ id: "work", label: "Travail" },
|
||||||
{ id: "tickets", label: "Tickets" },
|
{ id: "tickets", label: "Tickets" },
|
||||||
{ id: "sprints", label: "Sprints" },
|
{ id: "sprints", label: "Sprints" },
|
||||||
|
{ id: "files", label: "Fichiers" },
|
||||||
{ id: "agents", label: "Agent" },
|
{ id: "agents", label: "Agent" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -665,7 +690,7 @@ function WebMobileTaskFlowShell({
|
|||||||
<nav
|
<nav
|
||||||
data-testid="web-mobile-bottom-nav"
|
data-testid="web-mobile-bottom-nav"
|
||||||
aria-label="Navigation mobile"
|
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) => {
|
{navItems.map((item) => {
|
||||||
const disabled = item.id !== "projects" && !openId;
|
const disabled = item.id !== "projects" && !openId;
|
||||||
@ -858,6 +883,8 @@ export function WebProjectPanelBody({
|
|||||||
);
|
);
|
||||||
case "git":
|
case "git":
|
||||||
return <GitPanel projectId={projectId} />;
|
return <GitPanel projectId={projectId} />;
|
||||||
|
case "files":
|
||||||
|
return <ProjectFilesPanel projectId={projectId} mobile={mobile} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export type WebProjectPanelId =
|
|||||||
| "permissions"
|
| "permissions"
|
||||||
| "memory"
|
| "memory"
|
||||||
| "git"
|
| "git"
|
||||||
|
| "files"
|
||||||
| "projects";
|
| "projects";
|
||||||
|
|
||||||
export type WebOpenablePanelId = Exclude<WebProjectPanelId, "projects">;
|
export type WebOpenablePanelId = Exclude<WebProjectPanelId, "projects">;
|
||||||
@ -37,6 +38,7 @@ export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
|||||||
"permissions",
|
"permissions",
|
||||||
"memory",
|
"memory",
|
||||||
"git",
|
"git",
|
||||||
|
"files",
|
||||||
"projects",
|
"projects",
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -111,6 +113,13 @@ export const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
|||||||
description: "État Git du projet",
|
description: "État Git du projet",
|
||||||
placement: "primary",
|
placement: "primary",
|
||||||
},
|
},
|
||||||
|
files: {
|
||||||
|
id: "files",
|
||||||
|
label: "Fichiers",
|
||||||
|
shortLabel: "Fichiers",
|
||||||
|
description: "Parcours et téléchargement des fichiers du projet",
|
||||||
|
placement: "primary",
|
||||||
|
},
|
||||||
projects: {
|
projects: {
|
||||||
id: "projects",
|
id: "projects",
|
||||||
label: "Projets",
|
label: "Projets",
|
||||||
|
|||||||
@ -65,6 +65,9 @@ function gateways(overrides: {
|
|||||||
closeProject: vi.fn(),
|
closeProject: vi.fn(),
|
||||||
readProjectContext: vi.fn(async () => "project context"),
|
readProjectContext: vi.fn(async () => "project context"),
|
||||||
updateProjectContext: vi.fn(),
|
updateProjectContext: vi.fn(),
|
||||||
|
listProjectFiles: vi.fn(async (_projectId, path) => ({ path, entries: [] })),
|
||||||
|
downloadProjectFile: vi.fn(),
|
||||||
|
downloadProjectDirectory: vi.fn(),
|
||||||
...overrides.project,
|
...overrides.project,
|
||||||
};
|
};
|
||||||
const workState: WorkStateGateway = {
|
const workState: WorkStateGateway = {
|
||||||
|
|||||||
@ -68,6 +68,7 @@ import type {
|
|||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
PageDirection,
|
PageDirection,
|
||||||
Project,
|
Project,
|
||||||
|
ProjectFileListing,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
ProjectSystemPermissions,
|
ProjectSystemPermissions,
|
||||||
@ -558,6 +559,12 @@ export interface ProjectGateway {
|
|||||||
readProjectContext(projectId: string): Promise<string>;
|
readProjectContext(projectId: string): Promise<string>;
|
||||||
/** Overwrites the shared project context stored at `.ideai/CONTEXT.md`. */
|
/** Overwrites the shared project context stored at `.ideai/CONTEXT.md`. */
|
||||||
updateProjectContext(projectId: string, content: string): Promise<void>;
|
updateProjectContext(projectId: string, content: string): Promise<void>;
|
||||||
|
/** Lists one project-relative directory for the web project files surface. */
|
||||||
|
listProjectFiles(projectId: string, path: string): Promise<ProjectFileListing>;
|
||||||
|
/** Downloads one project-relative file. */
|
||||||
|
downloadProjectFile(projectId: string, path: string): Promise<void>;
|
||||||
|
/** Downloads one project-relative directory as an archive. */
|
||||||
|
downloadProjectDirectory(projectId: string, path: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Layout: load the terminal grid tree and apply mutating operations (L4). */
|
/** Layout: load the terminal grid tree and apply mutating operations (L4). */
|
||||||
|
|||||||
Reference in New Issue
Block a user