bamboo_agent/server/handlers/
workspace.rs1use actix_web::{web, HttpResponse};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5use crate::server::app_state::AppState;
6use crate::server::error::AppError;
7
8fn validate_workspace_path(input_path: &str) -> Result<PathBuf, AppError> {
11 let trimmed = input_path.trim();
12
13 if trimmed.is_empty() {
14 return Err(AppError::BadRequest("Path cannot be empty".to_string()));
15 }
16
17 if trimmed.contains("..") {
19 return Err(AppError::BadRequest(
20 "Path cannot contain '..' sequences".to_string(),
21 ));
22 }
23
24 let path = PathBuf::from(trimmed);
25
26 let canonical = path.canonicalize().map_err(|e| {
28 if e.kind() == std::io::ErrorKind::NotFound {
29 AppError::NotFound(format!("Path does not exist: {}", trimmed))
30 } else {
31 AppError::BadRequest(format!("Invalid path: {}", e))
32 }
33 })?;
34
35 Ok(canonical)
36}
37
38#[derive(Deserialize)]
39pub struct WorkspacePathRequest {
40 path: String,
41}
42
43#[derive(Deserialize)]
44pub struct BrowseFolderRequest {
45 path: Option<String>,
46}
47
48#[derive(Deserialize)]
49pub struct WorkspaceFilesRequest {
50 path: String,
51 max_depth: Option<usize>,
52 max_entries: Option<usize>,
53 include_hidden: Option<bool>,
54}
55
56#[derive(Serialize)]
57struct BrowseFolderResponse {
58 current_path: String,
59 parent_path: Option<String>,
60 folders: Vec<FolderItem>,
61}
62
63#[derive(Serialize)]
64struct FolderItem {
65 name: String,
66 path: String,
67}
68
69#[derive(Serialize)]
70struct WorkspaceFileEntry {
71 name: String,
72 path: String,
73 is_directory: bool,
74}
75
76#[derive(Serialize, Deserialize, Clone)]
77struct WorkspaceMetadata {
78 workspace_name: Option<String>,
79 description: Option<String>,
80 tags: Option<Vec<String>>,
81}
82
83#[derive(Serialize, Deserialize, Clone)]
84struct RecentWorkspaceEntry {
85 path: String,
86 metadata: Option<WorkspaceMetadata>,
87 last_opened: u64,
88}
89
90#[derive(Serialize, Deserialize, Default)]
91struct RecentWorkspaceStore {
92 items: Vec<RecentWorkspaceEntry>,
93}
94
95#[derive(Serialize)]
96struct WorkspaceInfo {
97 path: String,
98 is_valid: bool,
99 error_message: Option<String>,
100 file_count: Option<u64>,
101 last_modified: Option<String>,
102 size_bytes: Option<u64>,
103 workspace_name: Option<String>,
104}
105
106#[derive(Serialize)]
107struct PathSuggestion {
108 path: String,
109 name: String,
110 description: Option<String>,
111 suggestion_type: String,
112}
113
114#[derive(Serialize)]
115struct PathSuggestionsResponse {
116 suggestions: Vec<PathSuggestion>,
117}
118
119#[derive(Deserialize)]
120pub struct AddRecentWorkspaceRequest {
121 path: String,
122 metadata: Option<WorkspaceMetadata>,
123}
124
125fn home_dir() -> Result<PathBuf, AppError> {
126 let home = std::env::var_os("HOME")
127 .or_else(|| std::env::var_os("USERPROFILE"))
128 .ok_or_else(|| AppError::InternalError(anyhow::anyhow!("HOME not set")))?;
129 Ok(PathBuf::from(home))
130}
131
132fn workspace_store_path(app_data_dir: &Path) -> PathBuf {
133 app_data_dir.join("workspaces").join("recent.json")
134}
135
136const DEFAULT_MAX_DEPTH: usize = 6;
137const DEFAULT_MAX_ENTRIES: usize = 2000;
138const MAX_ALLOWED_ENTRIES: usize = 10000;
139const IGNORED_DIRS: [&str; 10] = [
140 ".git",
141 "node_modules",
142 "target",
143 "dist",
144 "build",
145 ".next",
146 ".turbo",
147 ".cache",
148 ".idea",
149 ".vscode",
150];
151
152fn should_skip_entry(name: &str, is_dir: bool, include_hidden: bool) -> bool {
153 if !include_hidden && name.starts_with('.') {
154 return true;
155 }
156 if is_dir && IGNORED_DIRS.iter().any(|ignored| ignored == &name) {
157 return true;
158 }
159 false
160}
161
162fn to_display_name(root: &Path, path: &Path) -> String {
163 path.strip_prefix(root)
164 .unwrap_or(path)
165 .to_string_lossy()
166 .to_string()
167}
168
169async fn load_recent_store(app_data_dir: &Path) -> Result<RecentWorkspaceStore, AppError> {
170 let path = workspace_store_path(app_data_dir);
171 let content = match tokio::fs::read_to_string(&path).await {
172 Ok(c) => c,
173 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
174 return Ok(RecentWorkspaceStore::default())
175 }
176 Err(e) => return Err(AppError::StorageError(e)),
177 };
178 let store = serde_json::from_str::<RecentWorkspaceStore>(&content)
179 .map_err(AppError::SerializationError)?;
180 Ok(store)
181}
182
183async fn save_recent_store(
184 app_data_dir: &Path,
185 store: &RecentWorkspaceStore,
186) -> Result<(), AppError> {
187 let path = workspace_store_path(app_data_dir);
188 if let Some(parent) = path.parent() {
189 tokio::fs::create_dir_all(parent).await?;
190 }
191 let content = serde_json::to_string_pretty(store).map_err(AppError::SerializationError)?;
192 tokio::fs::write(&path, content).await?;
193 Ok(())
194}
195
196async fn build_workspace_info(path: &str) -> WorkspaceInfo {
197 let workspace_name = PathBuf::from(path)
198 .file_name()
199 .and_then(|s| s.to_str())
200 .map(|s| s.to_string());
201
202 let metadata = tokio::fs::metadata(path).await;
203 match metadata {
204 Ok(meta) => {
205 if !meta.is_dir() {
206 return WorkspaceInfo {
207 path: path.to_string(),
208 is_valid: false,
209 error_message: Some("Not a directory".to_string()),
210 file_count: None,
211 last_modified: None,
212 size_bytes: None,
213 workspace_name,
214 };
215 }
216
217 let mut count: u64 = 0;
218 if let Ok(mut entries) = tokio::fs::read_dir(path).await {
219 while let Ok(Some(_)) = entries.next_entry().await {
220 count += 1;
221 }
222 }
223
224 WorkspaceInfo {
225 path: path.to_string(),
226 is_valid: true,
227 error_message: None,
228 file_count: Some(count),
229 last_modified: None,
230 size_bytes: None,
231 workspace_name,
232 }
233 }
234 Err(err) => WorkspaceInfo {
235 path: path.to_string(),
236 is_valid: false,
237 error_message: Some(err.to_string()),
238 file_count: None,
239 last_modified: None,
240 size_bytes: None,
241 workspace_name,
242 },
243 }
244}
245
246pub async fn validate_workspace(
247 _app_state: web::Data<AppState>,
248 payload: web::Json<WorkspacePathRequest>,
249) -> Result<HttpResponse, AppError> {
250 let path = payload.path.trim();
252 if path.is_empty() {
253 return Err(AppError::BadRequest("Path cannot be empty".to_string()));
254 }
255
256 let info = build_workspace_info(path).await;
257 Ok(HttpResponse::Ok().json(info))
258}
259
260pub async fn get_recent_workspaces(
261 app_state: web::Data<AppState>,
262) -> Result<HttpResponse, AppError> {
263 let store = load_recent_store(&app_state.app_data_dir).await?;
264 let mut infos = Vec::new();
265 for item in store.items.iter() {
266 let mut info = build_workspace_info(&item.path).await;
267 if info.workspace_name.is_none() {
268 info.workspace_name = item
269 .metadata
270 .as_ref()
271 .and_then(|m| m.workspace_name.clone());
272 }
273 infos.push(info);
274 }
275 Ok(HttpResponse::Ok().json(infos))
276}
277
278pub async fn add_recent_workspace(
279 app_state: web::Data<AppState>,
280 payload: web::Json<AddRecentWorkspaceRequest>,
281) -> Result<HttpResponse, AppError> {
282 let mut store = load_recent_store(&app_state.app_data_dir).await?;
283 let now = std::time::SystemTime::now()
284 .duration_since(std::time::SystemTime::UNIX_EPOCH)
285 .unwrap_or_default()
286 .as_secs();
287
288 if let Some(existing) = store.items.iter_mut().find(|i| i.path == payload.path) {
289 existing.metadata = payload.metadata.clone();
290 existing.last_opened = now;
291 } else {
292 store.items.insert(
293 0,
294 RecentWorkspaceEntry {
295 path: payload.path.clone(),
296 metadata: payload.metadata.clone(),
297 last_opened: now,
298 },
299 );
300 }
301
302 store
303 .items
304 .sort_by(|a, b| b.last_opened.cmp(&a.last_opened));
305 store.items.truncate(50);
306
307 save_recent_store(&app_state.app_data_dir, &store).await?;
308
309 Ok(HttpResponse::NoContent().finish())
310}
311
312pub async fn get_workspace_suggestions(
313 app_state: web::Data<AppState>,
314) -> Result<HttpResponse, AppError> {
315 let mut suggestions: Vec<PathSuggestion> = Vec::new();
316
317 let home = home_dir()?;
318 let home_str = home.to_string_lossy().to_string();
319 suggestions.push(PathSuggestion {
320 path: home_str.clone(),
321 name: "Home".to_string(),
322 description: None,
323 suggestion_type: "home".to_string(),
324 });
325
326 let candidates = vec![
327 ("documents", "Documents"),
328 ("desktop", "Desktop"),
329 ("downloads", "Downloads"),
330 ];
331
332 for (suggestion_type, folder) in candidates {
333 let path = home.join(folder);
334 if tokio::fs::metadata(&path).await.is_ok() {
335 suggestions.push(PathSuggestion {
336 path: path.to_string_lossy().to_string(),
337 name: folder.to_string(),
338 description: None,
339 suggestion_type: suggestion_type.to_string(),
340 });
341 }
342 }
343
344 let store = load_recent_store(&app_state.app_data_dir).await?;
345 for item in store.items.iter() {
346 let name = item
347 .metadata
348 .as_ref()
349 .and_then(|m| m.workspace_name.clone())
350 .or_else(|| {
351 PathBuf::from(&item.path)
352 .file_name()
353 .and_then(|s| s.to_str())
354 .map(|s| s.to_string())
355 })
356 .unwrap_or_else(|| item.path.clone());
357
358 suggestions.push(PathSuggestion {
359 path: item.path.clone(),
360 name,
361 description: None,
362 suggestion_type: "recent".to_string(),
363 });
364 }
365
366 let mut seen = std::collections::HashSet::new();
367 suggestions.retain(|item| seen.insert(item.path.clone()));
368
369 Ok(HttpResponse::Ok().json(PathSuggestionsResponse { suggestions }))
370}
371
372pub async fn browse_folder(
373 _app_state: web::Data<AppState>,
374 payload: web::Json<BrowseFolderRequest>,
375) -> Result<HttpResponse, AppError> {
376 let target_path = match payload.path.as_ref() {
377 Some(path) if !path.trim().is_empty() => {
378 validate_workspace_path(path)?
380 }
381 _ => home_dir()?,
382 };
383
384 let metadata = tokio::fs::metadata(&target_path).await?;
385 if !metadata.is_dir() {
386 return Err(AppError::NotFound("Folder".to_string()));
387 }
388
389 let mut entries = tokio::fs::read_dir(&target_path).await?;
390 let mut folders = Vec::new();
391 while let Some(entry) = entries.next_entry().await? {
392 let file_type = entry.file_type().await?;
393 if !file_type.is_dir() {
394 continue;
395 }
396 let path = entry.path();
397 let name = path
398 .file_name()
399 .and_then(|s| s.to_str())
400 .unwrap_or_default()
401 .to_string();
402 folders.push(FolderItem {
403 name,
404 path: path.to_string_lossy().to_string(),
405 });
406 }
407
408 folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
409
410 let parent_path = target_path
411 .parent()
412 .map(|p| p.to_string_lossy().to_string());
413
414 Ok(HttpResponse::Ok().json(BrowseFolderResponse {
415 current_path: target_path.to_string_lossy().to_string(),
416 parent_path,
417 folders,
418 }))
419}
420
421pub async fn list_workspace_files(
422 _app_state: web::Data<AppState>,
423 payload: web::Json<WorkspaceFilesRequest>,
424) -> Result<HttpResponse, AppError> {
425 let root_path = validate_workspace_path(&payload.path)?;
427
428 let metadata = match tokio::fs::metadata(&root_path).await {
429 Ok(meta) => meta,
430 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
431 return Err(AppError::NotFound("Workspace".to_string()))
432 }
433 Err(err) => return Err(AppError::StorageError(err)),
434 };
435 if !metadata.is_dir() {
436 return Err(AppError::NotFound("Workspace".to_string()));
437 }
438
439 let max_depth = payload.max_depth.unwrap_or(DEFAULT_MAX_DEPTH);
440 let mut max_entries = payload.max_entries.unwrap_or(DEFAULT_MAX_ENTRIES);
441 if max_entries > MAX_ALLOWED_ENTRIES {
442 max_entries = MAX_ALLOWED_ENTRIES;
443 }
444 let include_hidden = payload.include_hidden.unwrap_or(false);
445
446 let mut files: Vec<WorkspaceFileEntry> = Vec::new();
447 let mut stack: Vec<(PathBuf, usize)> = vec![(root_path.clone(), 0)];
448
449 while let Some((current_path, depth)) = stack.pop() {
450 let mut entries = tokio::fs::read_dir(¤t_path).await?;
451 while let Some(entry) = entries.next_entry().await? {
452 let file_type = entry.file_type().await?;
453 if file_type.is_symlink() {
454 continue;
455 }
456
457 let name = entry.file_name().to_string_lossy().to_string();
458 let is_dir = file_type.is_dir();
459 if should_skip_entry(&name, is_dir, include_hidden) {
460 continue;
461 }
462
463 let path = entry.path();
464 if is_dir {
465 if depth < max_depth {
466 stack.push((path, depth + 1));
467 }
468 continue;
469 }
470
471 files.push(WorkspaceFileEntry {
472 name: to_display_name(&root_path, &path),
473 path: path.to_string_lossy().to_string(),
474 is_directory: false,
475 });
476
477 if files.len() >= max_entries {
478 return Ok(HttpResponse::Ok().json(files));
479 }
480 }
481 }
482
483 Ok(HttpResponse::Ok().json(files))
484}
485
486pub fn config(cfg: &mut web::ServiceConfig) {
487 cfg.route("/workspace/validate", web::post().to(validate_workspace))
488 .route("/workspace/recent", web::get().to(get_recent_workspaces))
489 .route("/workspace/recent", web::post().to(add_recent_workspace))
490 .route(
491 "/workspace/suggestions",
492 web::get().to(get_workspace_suggestions),
493 )
494 .route("/workspace/browse-folder", web::post().to(browse_folder))
495 .route("/workspace/files", web::post().to(list_workspace_files));
496}