From 79fc3d184d5a0699d606cadd0a08c800354deefd Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 15 Aug 2026 10:38:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20panneau=20Fichiers=20=E2=80=94=20n?= =?UTF-8?q?avigation=20projet=20+=20t=C3=A9l=C3=A9chargements=20fichier/do?= =?UTF-8?q?ssier=20ZIP=20=E2=80=94=20#211?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/application/src/lib.rs | 5 +- crates/application/src/plugin/mod.rs | 2 + crates/application/src/project/files.rs | 216 +++++ crates/application/src/project/mod.rs | 5 + crates/backend/src/lib.rs | 14 +- crates/domain/src/ports.rs | 19 + crates/infrastructure/src/fs/mod.rs | 16 + crates/web-server/Cargo.toml | 1 + crates/web-server/src/lib.rs | 809 +++++++++++++++++- .../src/adapters/http/httpInvoker.test.ts | 31 + frontend/src/adapters/http/httpInvoker.ts | 48 ++ .../adapters/http/requestResponseGateways.ts | 32 + frontend/src/adapters/mock/index.ts | 9 + frontend/src/adapters/project.ts | 14 +- frontend/src/domain/index.ts | 15 + .../features/web/ProjectFilesPanel.test.tsx | 157 ++++ .../src/features/web/ProjectFilesPanel.tsx | 212 +++++ frontend/src/features/web/WebWorkspace.tsx | 101 ++- .../src/features/web/webSurfaceCatalog.ts | 9 + frontend/src/plugins/runtime/services.test.ts | 3 + frontend/src/ports/index.ts | 7 + 22 files changed, 1648 insertions(+), 78 deletions(-) create mode 100644 crates/application/src/project/files.rs create mode 100644 frontend/src/features/web/ProjectFilesPanel.test.tsx create mode 100644 frontend/src/features/web/ProjectFilesPanel.tsx diff --git a/Cargo.lock b/Cargo.lock index e4ce67d8..26ee4aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5318,6 +5318,7 @@ dependencies = [ "subtle", "tokio", "uuid", + "zip", ] [[package]] diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 4a033e23..8a72cdc2 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -195,8 +195,9 @@ pub use plugin::{ }; pub use project::{ CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject, - CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject, - OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput, + CreateProjectInput, CreateProjectOutput, ListProjectFiles, ListProjectFilesInput, ListProjects, + ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, ProjectFileEntry, + ProjectFileKind, ProjectFileListing, ProjectMeta, ReadProjectContext, ReadProjectContextInput, ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE, }; diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 1c366cf4..97642e85 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -4565,6 +4565,7 @@ mod tests { is_file: true, is_dir: false, len: Some(bytes.len() as u64), + modified_at_ms: None, }); } if files @@ -4575,6 +4576,7 @@ mod tests { is_file: false, is_dir: true, len: None, + modified_at_ms: None, }); } Err(domain::ports::FsError::NotFound(path.as_str().to_owned())) diff --git a/crates/application/src/project/files.rs b/crates/application/src/project/files.rs new file mode 100644 index 00000000..1c5b2b92 --- /dev/null +++ b/crates/application/src/project/files.rs @@ -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, +} + +/// 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, + /// Last modification timestamp as epoch milliseconds when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at_ms: Option, +} + +/// 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, +} + +impl ListProjectFiles { + /// Builds the use case. + #[must_use] + pub fn new(fs: Arc) -> Self { + Self { fs } + } + + /// Lists one project directory. + pub async fn execute( + &self, + input: ListProjectFilesInput, + ) -> Result { + 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 { + 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 { + 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 { + 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() +} diff --git a/crates/application/src/project/mod.rs b/crates/application/src/project/mod.rs index bd383235..91c5d62f 100644 --- a/crates/application/src/project/mod.rs +++ b/crates/application/src/project/mod.rs @@ -16,6 +16,7 @@ mod close; mod context; mod create; +mod files; pub(crate) mod meta; mod open; @@ -25,6 +26,10 @@ pub use context::{ UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE, }; pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput}; +pub use files::{ + canonical_project_root, confined_project_path, validate_project_relative_path, + ListProjectFiles, ListProjectFilesInput, ProjectFileEntry, ProjectFileKind, ProjectFileListing, +}; pub use meta::ProjectMeta; pub use open::{ ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index cdc1754d..9122d632 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -31,11 +31,11 @@ use application::{ InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, - ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListSlashCommands, ListSprints, ListTemplates, - LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, - LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, McpRuntime, - McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, + ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, + ListProjectFiles, ListProjects, ListResumableAgents, ListSkills, ListSlashCommands, + ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, + LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, + McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, @@ -985,6 +985,8 @@ pub struct BackendCore { pub close_tab: Arc, /// List known projects. pub list_projects: Arc, + /// List project files for the human web surface. + pub list_project_files: Arc, /// Read `.ideai/CONTEXT.md`. pub read_project_context: Arc, /// Overwrite `.ideai/CONTEXT.md`. @@ -1588,6 +1590,7 @@ impl BackendCore { let close_project = Arc::new(CloseProject::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_project_files = Arc::new(ListProjectFiles::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))); @@ -3100,6 +3103,7 @@ impl BackendCore { close_project, close_tab, list_projects, + list_project_files, read_project_context, update_project_context, open_terminal, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 59046849..327d7a90 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -537,6 +537,8 @@ pub struct FileMetadata { pub is_dir: bool, /// File length in bytes when known. pub len: Option, + /// Last modification timestamp as epoch milliseconds when known. + pub modified_at_ms: Option, } /// An owned, boxed stream of PTY output chunks. @@ -1916,12 +1918,29 @@ pub trait FileSystem: Send + Sync { is_file: false, is_dir: false, len: None, + modified_at_ms: None, }) } else { 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 { + 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 /// delete), so this is safe to call best-effort. /// diff --git a/crates/infrastructure/src/fs/mod.rs b/crates/infrastructure/src/fs/mod.rs index 4f7d7769..49ccbb62 100644 --- a/crates/infrastructure/src/fs/mod.rs +++ b/crates/infrastructure/src/fs/mod.rs @@ -7,6 +7,7 @@ use std::io; use std::path::Path; +use std::time::UNIX_EPOCH; use async_trait::async_trait; use domain::ports::{DirEntry, FileMetadata, FileSystem, FsError, RemotePath}; @@ -62,9 +63,17 @@ impl FileSystem for LocalFileSystem { is_file: meta.is_file(), is_dir: meta.is_dir(), len: Some(meta.len()), + modified_at_ms: modified_at_ms(&meta), }) } + async fn canonicalize(&self, path: &RemotePath) -> Result { + 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> { match fs::remove_file(path.as_str()).await { Ok(()) => Ok(()), @@ -103,6 +112,13 @@ impl FileSystem for LocalFileSystem { } } +fn modified_at_ms(meta: &std::fs::Metadata) -> Option { + 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)] async fn symlink_impl(src: &Path, dst: &Path) -> io::Result<()> { fs::symlink(src, dst).await diff --git a/crates/web-server/Cargo.toml b/crates/web-server/Cargo.toml index 5b5c60cb..ecc2515a 100644 --- a/crates/web-server/Cargo.toml +++ b/crates/web-server/Cargo.toml @@ -31,6 +31,7 @@ http = "1.4" http-body-util = "0.1" sha2 = { workspace = true } subtle = { workspace = true } +zip = { version = "2", default-features = false, features = ["deflate"] } [features] vector-http = ["backend/vector-http"] diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index d72e1ef8..53e8e233 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -18,11 +18,14 @@ use bytes::Bytes; use cookie::{Cookie, SameSite}; use domain::events::DomainEvent; 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)] use http::Request; 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_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -32,6 +35,7 @@ use tokio::net::TcpListener; use tokio::sync::{mpsc, oneshot}; use tokio::task::JoinHandle; use uuid::Uuid; +use zip::write::SimpleFileOptions; use application::{ AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput, @@ -43,20 +47,20 @@ use application::{ GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput, ListDevicesInput, ListIssuesInput, - ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, - MarkIssueAttachmentSummarizedInput, McpRuntime, OpenProjectInput, PairAttemptDecision, - PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput, - ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, - ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, RenameDeviceInput, - RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput, - ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, - RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, - UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, - UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, - UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, - UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, - UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput, - WriteToTerminalInput, + ListMemoriesInput, ListProjectFilesInput, ListResumableAgentsInput, ListSkillsInput, + ListSprintsInput, LiveSessions, MarkIssueAttachmentSummarizedInput, McpRuntime, + OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput, + ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, + ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, + RenameDeviceInput, RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, + ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, + RevokeDeviceInput, RotateConversationLogInput, StopLiveAgentInput, SyncAgentWithTemplateInput, + TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, + UnlinkIssuesInput, UpdateAgentContextInput, UpdateAgentEffortInput, + UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, + UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput, + UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, + UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput, }; use domain::ports::PtyHandle; 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_OUTPUT_BUFFER: usize = 512; -type ResponseBody = Full; +#[cfg(test)] +type RequestBody = Full; + +#[derive(Debug)] +enum ResponseBody { + Bytes(Bytes), + File(PathBuf), + TempFile(PathBuf), +} + +impl ResponseBody { + fn empty() -> Self { + Self::Bytes(Bytes::new()) + } + + fn bytes(bytes: impl Into) -> 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); /// Runs the `idea --serve` subcommand from already-split CLI arguments. @@ -1024,7 +1059,7 @@ async fn handle_tcp_connection( #[cfg(test)] async fn handle_request( - req: Request, + req: Request, state: Arc, ) -> Response { handle_request_from_peer(req, Arc::clone(&state), Some(state.config.listen.ip())).await @@ -1032,7 +1067,7 @@ async fn handle_request( #[cfg(test)] async fn handle_request_from_peer( - req: Request, + req: Request, state: Arc, peer_ip: Option, ) -> Response { @@ -1114,12 +1149,7 @@ async fn write_http_response( ) -> Result<(), String> { let status = response.status(); let headers = response.headers().clone(); - let body = response - .into_body() - .collect() - .await - .map_err(|err| format!("failed to collect response: {err}"))? - .to_bytes(); + let body = response.into_body(); let reason = status.canonical_reason().unwrap_or("Unknown"); let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes(); 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(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(&body); stream .write_all(&bytes) .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( @@ -1778,6 +1848,37 @@ async fn dispatch_http( refresh_session_cookie(&mut response, &state, &token); 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") => { let (_token, _device) = match authenticated_device(&headers, &state).await { Ok(auth) => auth, @@ -1884,6 +1985,9 @@ async fn dispatch_http( | "/api/invoke" | "/api/logout" | "/api/devices" + | "/api/project-files" + | "/api/project-files/download-file" + | "/api/project-files/download-directory" | "/api/devices/revoke-all", ) => error_response( StatusCode::METHOD_NOT_ALLOWED, @@ -1993,7 +2097,7 @@ fn static_file_response(path: &Path, bytes: Vec, head_only: bool) -> Respons .status(StatusCode::OK) .header(CONTENT_TYPE, content_type(path)) .header("x-content-type-options", "nosniff") - .body(Full::new(Bytes::from(body))) + .body(ResponseBody::bytes(Bytes::from(body))) .expect("static response is valid"); if is_index { response.headers_mut().insert( @@ -2076,6 +2180,421 @@ async fn authenticated_device( Ok((token, device)) } +async fn project_files_list_response( + uri: &Uri, + state: Arc, + origin: Option<&str>, +) -> Response { + 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, + origin: Option<&str>, +) -> Response { + 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, + origin: Option<&str>, +) -> Response { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::(zip_target) + }) + .await + .map_err(|err| AppError::Process(format!("zip task failed: {err}")))? +} + +fn zip_directory( + zip: &mut zip::ZipWriter, + 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 { + 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 { + 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, 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, key: &str) -> Result { + params + .get(key) + .filter(|value| !value.is_empty()) + .cloned() + .ok_or_else(|| format!("missing query parameter: {key}")) +} + +fn percent_decode(raw: &str) -> Result { + 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 { + 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( state: Arc, device: &AuthenticatedDevice, @@ -4484,7 +5003,7 @@ fn json_response( origin: Option<&str>, ) -> Response { 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 .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 { - let mut response = Response::new(Full::new(Bytes::new())); + let mut response = Response::new(ResponseBody::empty()); *response.status_mut() = status; add_cors_headers(&mut response, origin); response @@ -5381,7 +5910,7 @@ mod tests { async fn response_json(response: Response) -> (StatusCode, Value, HeaderMap) { let status = response.status(); 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() { Value::Null } else { @@ -5455,7 +5984,7 @@ mod tests { async fn response_bytes(response: Response) -> (StatusCode, Bytes, HeaderMap) { let status = response.status(); 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) } @@ -7723,6 +8252,220 @@ mod tests { 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] async fn authorized_get_project_work_state_returns_tauri_contract() { let state = state(); diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts index 7950694c..4c5c134e 100644 --- a/frontend/src/adapters/http/httpInvoker.test.ts +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -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 () => { const http = new HttpInvoker({ baseUrl: "https://h" }); const spy = vi.spyOn(http, "invoke").mockResolvedValue(undefined as never); diff --git a/frontend/src/adapters/http/httpInvoker.ts b/frontend/src/adapters/http/httpInvoker.ts index 11476fd7..8c7a9b3e 100644 --- a/frontend/src/adapters/http/httpInvoker.ts +++ b/frontend/src/adapters/http/httpInvoker.ts @@ -39,6 +39,7 @@ export type FetchLike = ( /** Parsed JSON body (may reject/return undefined for empty bodies). */ json(): Promise; text(): Promise; + blob?(): Promise; }>; /** @@ -182,4 +183,51 @@ export class HttpInvoker { } return parsed as T; } + + async download(path: string, filename: string, what = `GET ${path}`): Promise { + const headers: Record = {}; + if (this.token) headers.Authorization = `Bearer ${this.token}`; + + let res: Awaited>; + 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); } diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 66512b17..71f38e76 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -37,6 +37,7 @@ import type { OpenCodeProviderCatalogEntry, PermissionSet, Project, + ProjectFileListing, ProjectMcpToolPermissions, ProjectPermissions, ProjectWorkState, @@ -100,6 +101,37 @@ export class HttpProjectGateway implements ProjectGateway { async updateProjectContext(projectId: string, content: string): Promise { await this.http.invoke("update_project_context", { request: { projectId, content } }); } + listProjectFiles(projectId: string, path: string): Promise { + return this.http.request( + "GET", + projectFilesPath("/api/project-files", projectId, path), + undefined, + "project files listing", + ); + } + downloadProjectFile(projectId: string, path: string): Promise { + return this.http.download( + projectFilesPath("/api/project-files/download-file", projectId, path), + projectFileDownloadName(path, "download"), + "project file download", + ); + } + downloadProjectDirectory(projectId: string, path: string): Promise { + 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 { diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 9522b224..fde9fd9a 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -66,6 +66,7 @@ import type { PluginWorkspaceStat, PluginWorkspaceTextFile, Project, + ProjectFileListing, JsonValue, ProjectMcpToolPermissions, ProjectPermissions, @@ -1262,6 +1263,14 @@ export class MockProjectGateway implements ProjectGateway { } this.contexts.set(projectId, content); } + + async listProjectFiles(_projectId: string, path: string): Promise { + return { path, entries: [] }; + } + + async downloadProjectFile(): Promise {} + + async downloadProjectDirectory(): Promise {} } /** Internal per-project layout store entry. */ diff --git a/frontend/src/adapters/project.ts b/frontend/src/adapters/project.ts index 1f744a16..cd010566 100644 --- a/frontend/src/adapters/project.ts +++ b/frontend/src/adapters/project.ts @@ -8,7 +8,7 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project } from "@/domain"; +import type { Project, ProjectFileListing } from "@/domain"; import type { ProjectGateway } from "@/ports"; export class TauriProjectGateway implements ProjectGateway { @@ -37,4 +37,16 @@ export class TauriProjectGateway implements ProjectGateway { request: { projectId, content }, }); } + + async listProjectFiles(): Promise { + throw new Error("Project files browsing is available in the web workspace only."); + } + + async downloadProjectFile(): Promise { + throw new Error("Project file download is available in the web workspace only."); + } + + async downloadProjectDirectory(): Promise { + throw new Error("Project directory download is available in the web workspace only."); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 55f793c4..bfee93ef 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -13,6 +13,21 @@ export interface HealthReport { 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 // agents/background tasks are active. Mirrors the backend diff --git a/frontend/src/features/web/ProjectFilesPanel.test.tsx b/frontend/src/features/web/ProjectFilesPanel.test.tsx new file mode 100644 index 00000000..8329385b --- /dev/null +++ b/frontend/src/features/web/ProjectFilesPanel.test.tsx @@ -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() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((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({element}); +} + +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, ); + + 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(); + 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, ); + + 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, ); + + 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, ); + + 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, ); + + 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(); + }); +}); diff --git a/frontend/src/features/web/ProjectFilesPanel.tsx b/frontend/src/features/web/ProjectFilesPanel.tsx new file mode 100644 index 00000000..c8a9b374 --- /dev/null +++ b/frontend/src/features/web/ProjectFilesPanel.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [downloadingPath, setDownloadingPath] = useState(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 { + 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 ( + +
+
+

Fichiers

+ +
+ +
+ + {error && ( +
+ {error} +
+ )} + + {downloadingPath && ( +
+ + Préparation du téléchargement… +
+ )} + + {loading && listing === null ? ( +
+ + Chargement des fichiers… +
+ ) : listing && listing.entries.length === 0 ? ( +

+ Ce dossier est vide. +

+ ) : listing ? ( +
    + {listing.entries.map((entry) => ( +
  • +
    +
    + + {entry.kind === "directory" ? "Dossier" : "Fichier"} + + + {entry.name} + +
    +
    + {entry.kind === "file" ? formatBytes(entry.sizeBytes) : "—"} + {formatModified(entry.modifiedAtMs)} +
    +
    +
    + {entry.kind === "directory" && ( + + )} + +
    +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 83c30644..d9d1c213 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -31,16 +31,7 @@ import { useGateways } from "@/app/di"; import { useProjectWorkState } from "@/features/workstate/useProjectWorkState"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel"; -import { - PANEL_TITLE, - floatingPanels, - isDockedTo, - panelsDockedTo, - placementOf, - type PanelId, - type ViewPlacement, - type ViewPlacements, -} from "@/features/projects/viewPlacement"; +import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacement"; import { AgentsPanel } from "@/features/agents"; import { TemplatesPanel } from "@/features/templates"; import { SkillsPanel } from "@/features/skills"; @@ -69,6 +60,7 @@ import { WebTicketsView } from "./tickets/WebTicketsView"; import { WebSprintsView } from "./tickets/WebSprintsView"; import { WebProjectsPanel } from "./WebProjectsPanel"; import { WebMenuSheet } from "./WebMenuSheet"; +import { ProjectFilesPanel } from "./ProjectFilesPanel"; import { WEB_PANEL_META, type WebOpenablePanelId, @@ -205,19 +197,50 @@ function useMobileWorkspace(): boolean { return mobile; } -const WEB_PANEL_SIZE: Record = { +type WebDesktopPanelId = WebProjectPanelId; +type WebDesktopPlacements = Partial>; + +const WEB_PANEL_SIZE: Record = { projects: "md", context: "lg", work: "lg", tickets: "lg", + sprints: "lg", agents: "lg", templates: "lg", skills: "lg", permissions: "md", memory: "lg", git: "lg", + files: "lg", }; +function webPanelTitle(panel: WebDesktopPanelId): string { + return WEB_PANEL_META[panel].label; +} + +function webPlacementOf( + placements: WebDesktopPlacements, + panel: WebDesktopPanelId, +): ViewPlacement { + return placements[panel] ?? "closed"; +} + +function webPanelsDockedTo( + placements: WebDesktopPlacements, + side: "left" | "right", +): WebDesktopPanelId[] { + return (Object.keys(WEB_PANEL_META) as WebDesktopPanelId[]).filter((panel) => + isDockedTo(webPlacementOf(placements, panel), side), + ); +} + +function webFloatingPanels(placements: WebDesktopPlacements): WebDesktopPanelId[] { + return (Object.keys(WEB_PANEL_META) as WebDesktopPanelId[]).filter( + (panel) => webPlacementOf(placements, panel) === "floating", + ); +} + function WebDesktopWorkspace({ projects, openId, @@ -243,7 +266,7 @@ function WebDesktopWorkspace({ onRefreshProjects: () => void; onViewSprintTickets: (sprintId: string) => void; }) { - const [placements, setPlacements] = useState({ + const [placements, setPlacements] = useState({ work: { dock: "right" }, }); const [leftDockWidth, setLeftDockWidth] = useState(340); @@ -259,11 +282,11 @@ function WebDesktopWorkspace({ setViewerConversation(null); }, [openId]); - function setPlacement(panel: PanelId, placement: Exclude) { + function setPlacement(panel: WebDesktopPanelId, placement: Exclude) { setPlacements((prev) => { - const next: ViewPlacements = { ...prev }; + const next: WebDesktopPlacements = { ...prev }; if (placement === "floating") { - for (const other of Object.keys(next) as PanelId[]) { + for (const other of Object.keys(next) as WebDesktopPanelId[]) { if (other !== panel && next[other] === "floating") delete next[other]; } } @@ -272,7 +295,7 @@ function WebDesktopWorkspace({ }); } - function closePanel(panel: PanelId) { + function closePanel(panel: WebDesktopPanelId) { setPlacements((prev) => { if (!(panel in prev)) return prev; const next = { ...prev }; @@ -285,7 +308,7 @@ function WebDesktopWorkspace({ setPlacements((prev) => { let changed = false; const next = { ...prev }; - for (const panel of Object.keys(next) as PanelId[]) { + for (const panel of Object.keys(next) as WebDesktopPanelId[]) { if (next[panel] === "floating") { delete next[panel]; changed = true; @@ -301,22 +324,23 @@ function WebDesktopWorkspace({ dismissFloating(); } - const panelOrder: PanelId[] = [ + const panelOrder: WebDesktopPanelId[] = [ "context", "work", "tickets", + "sprints", "agents", "templates", "skills", "permissions", "memory", "git", + "files", "projects", ]; - const panelToWebId = (panel: PanelId): WebProjectPanelId => panel; - function placementSubmenu(panel: PanelId): MenuBarItem[] { - const placement = placementOf(placements, panel); + function placementSubmenu(panel: WebDesktopPanelId): MenuBarItem[] { + const placement = webPlacementOf(placements, panel); return [ { id: "closed", @@ -351,8 +375,8 @@ function WebDesktopWorkspace({ label: "Panneaux", items: panelOrder.map((panel) => ({ id: panel, - label: PANEL_TITLE[panel], - active: placementOf(placements, panel) !== "closed", + label: webPanelTitle(panel), + active: webPlacementOf(placements, panel) !== "closed", onSelect: () => {}, submenu: placementSubmenu(panel), })), @@ -369,14 +393,14 @@ function WebDesktopWorkspace({ /> ); - function renderPanel(panel: PanelId) { + function renderPanel(panel: WebDesktopPanelId) { if (panel === "projects") return projectsManager; if (!openId) { return

Ouvrir un projet pour utiliser ce panneau.

; } return ( @@ -433,16 +457,16 @@ function WebDesktopWorkspace({ ); } - function renderDockedView(panel: PanelId) { + function renderDockedView(panel: WebDesktopPanelId) { return (
- {PANEL_TITLE[panel]} + {webPanelTitle(panel)}
@@ -451,12 +475,12 @@ function WebDesktopWorkspace({ ); } - const leftPanels = panelsDockedTo(placements, "left"); - const rightPanels = panelsDockedTo(placements, "right"); - const floatingList = floatingPanels(placements); + const leftPanels = webPanelsDockedTo(placements, "left"); + const rightPanels = webPanelsDockedTo(placements, "right"); + const floatingList = webFloatingPanels(placements); const activeLayoutKind = activeLayout?.kind ?? "terminal"; const showInlineProjects = - !openProject && placementOf(placements, "projects") === "closed"; + !openProject && webPlacementOf(placements, "projects") === "closed"; return (
closePanel(panel)} > @@ -611,6 +635,7 @@ function WebMobileTaskFlowShell({ { id: "work", label: "Travail" }, { id: "tickets", label: "Tickets" }, { id: "sprints", label: "Sprints" }, + { id: "files", label: "Fichiers" }, { id: "agents", label: "Agent" }, ]; @@ -665,7 +690,7 @@ function WebMobileTaskFlowShell({