Skip to main content

opendev_web/routes/sessions/
filesystem.rs

1//! Filesystem navigation routes: file listing, path verification, directory browsing.
2
3use std::path::Path;
4
5use axum::Json;
6use axum::extract::{Query, State};
7use serde::Deserialize;
8
9use crate::error::WebError;
10use crate::state::AppState;
11
12/// Query parameters for file listing.
13#[derive(Debug, Deserialize)]
14pub struct ListFilesQuery {
15    #[serde(default)]
16    pub query: String,
17}
18
19/// Verify path request.
20#[derive(Debug, Deserialize)]
21pub struct VerifyPathRequest {
22    #[serde(default)]
23    pub path: Option<String>,
24}
25
26/// Browse directory request.
27#[derive(Debug, Deserialize)]
28pub struct BrowseDirectoryRequest {
29    #[serde(default)]
30    pub path: String,
31    #[serde(default)]
32    pub show_hidden: bool,
33}
34
35/// List files in the current session's working directory.
36pub(super) async fn list_files(
37    State(state): State<AppState>,
38    Query(params): Query<ListFilesQuery>,
39) -> Result<Json<serde_json::Value>, WebError> {
40    let mgr = state.session_manager().await;
41    let session = mgr.current_session();
42
43    let working_dir = match session.and_then(|s| s.working_directory.as_deref()) {
44        Some(wd) => wd.to_string(),
45        None => {
46            return Ok(Json(serde_json::json!({"files": []})));
47        }
48    };
49
50    let wd_path = Path::new(&working_dir);
51    if !wd_path.exists() || !wd_path.is_dir() {
52        return Ok(Json(serde_json::json!({"files": []})));
53    }
54
55    // Directories to always exclude.
56    let always_exclude: &[&str] = &[
57        ".git",
58        ".hg",
59        ".svn",
60        "node_modules",
61        "__pycache__",
62        ".pytest_cache",
63        ".mypy_cache",
64        ".venv",
65        "venv",
66        ".DS_Store",
67        ".idea",
68        ".vscode",
69        "target",
70        "dist",
71        "build",
72        "out",
73        ".next",
74        ".nuxt",
75        ".cache",
76        ".tox",
77        ".nox",
78        ".gradle",
79        "coverage",
80        "htmlcov",
81    ];
82
83    let query = params.query.to_lowercase();
84    let mut files: Vec<serde_json::Value> = Vec::new();
85    let max_files = 100;
86
87    // Walk directory tree (iterative BFS).
88    let mut stack = vec![wd_path.to_path_buf()];
89    'outer: while let Some(dir) = stack.pop() {
90        let entries = match std::fs::read_dir(&dir) {
91            Ok(e) => e,
92            Err(_) => continue,
93        };
94
95        for entry in entries.flatten() {
96            let file_name = entry.file_name();
97            let name = file_name.to_string_lossy();
98
99            let file_type = match entry.file_type() {
100                Ok(ft) => ft,
101                Err(_) => continue,
102            };
103
104            if file_type.is_dir() {
105                if !always_exclude.contains(&name.as_ref()) {
106                    stack.push(entry.path());
107                }
108                continue;
109            }
110
111            if file_type.is_file() {
112                let rel_path = match entry.path().strip_prefix(wd_path) {
113                    Ok(p) => p.to_string_lossy().to_string(),
114                    Err(_) => continue,
115                };
116
117                // Filter by query if provided.
118                if !query.is_empty() && !rel_path.to_lowercase().contains(&query) {
119                    continue;
120                }
121
122                files.push(serde_json::json!({
123                    "path": rel_path,
124                    "name": name,
125                    "is_file": true,
126                }));
127
128                if files.len() >= max_files {
129                    break 'outer;
130                }
131            }
132        }
133    }
134
135    // Sort by path.
136    files.sort_by(|a, b| {
137        let pa = a["path"].as_str().unwrap_or("");
138        let pb = b["path"].as_str().unwrap_or("");
139        pa.cmp(pb)
140    });
141
142    Ok(Json(serde_json::json!({"files": files})))
143}
144
145/// Verify if a directory path exists and is accessible.
146pub(super) async fn verify_path(
147    State(_state): State<AppState>,
148    Json(payload): Json<VerifyPathRequest>,
149) -> Json<serde_json::Value> {
150    let path_str = payload.path.as_deref().unwrap_or("").trim().to_string();
151
152    if path_str.is_empty() {
153        return Json(serde_json::json!({
154            "exists": false,
155            "is_directory": false,
156            "error": "Path cannot be empty",
157        }));
158    }
159
160    // Expand ~ to home directory.
161    let expanded = if path_str.starts_with('~') {
162        if let Some(home) = dirs_path_home() {
163            path_str.replacen('~', &home, 1)
164        } else {
165            path_str.clone()
166        }
167    } else {
168        path_str.clone()
169    };
170
171    let path = Path::new(&expanded);
172
173    if !path.exists() {
174        return Json(serde_json::json!({
175            "exists": false,
176            "is_directory": false,
177            "error": "Path does not exist",
178        }));
179    }
180
181    if !path.is_dir() {
182        return Json(serde_json::json!({
183            "exists": true,
184            "is_directory": false,
185            "error": "Path is not a directory",
186        }));
187    }
188
189    // Check read access by trying to read_dir.
190    if std::fs::read_dir(path).is_err() {
191        return Json(serde_json::json!({
192            "exists": true,
193            "is_directory": true,
194            "error": "No read access to directory",
195        }));
196    }
197
198    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
199
200    Json(serde_json::json!({
201        "exists": true,
202        "is_directory": true,
203        "path": canonical.to_string_lossy(),
204        "error": null,
205    }))
206}
207
208/// Browse directories at a given path for the workspace picker.
209pub(super) async fn browse_directory(
210    State(_state): State<AppState>,
211    Json(payload): Json<BrowseDirectoryRequest>,
212) -> Json<serde_json::Value> {
213    let raw = payload.path.trim().to_string();
214
215    let target = if raw.is_empty() {
216        // Default to home directory.
217        match dirs_path_home() {
218            Some(home) => std::path::PathBuf::from(home),
219            None => std::path::PathBuf::from("/"),
220        }
221    } else {
222        let expanded = if raw.starts_with('~') {
223            if let Some(home) = dirs_path_home() {
224                raw.replacen('~', &home, 1)
225            } else {
226                raw.clone()
227            }
228        } else {
229            raw.clone()
230        };
231        std::path::PathBuf::from(expanded)
232    };
233
234    let target = target.canonicalize().unwrap_or_else(|_| target.clone());
235
236    if !target.exists() {
237        return Json(serde_json::json!({
238            "current_path": target.to_string_lossy(),
239            "parent_path": target.parent().map(|p| p.to_string_lossy().to_string()),
240            "directories": [],
241            "error": "Path does not exist",
242        }));
243    }
244
245    if !target.is_dir() {
246        return Json(serde_json::json!({
247            "current_path": target.to_string_lossy(),
248            "parent_path": target.parent().map(|p| p.to_string_lossy().to_string()),
249            "directories": [],
250            "error": "Path is not a directory",
251        }));
252    }
253
254    let parent_path = if target.parent() != Some(&target) {
255        target.parent().map(|p| p.to_string_lossy().to_string())
256    } else {
257        None
258    };
259
260    let entries = match std::fs::read_dir(&target) {
261        Ok(e) => e,
262        Err(_) => {
263            return Json(serde_json::json!({
264                "current_path": target.to_string_lossy(),
265                "parent_path": parent_path,
266                "directories": [],
267                "error": "Permission denied reading directory contents",
268            }));
269        }
270    };
271
272    let mut dirs: Vec<serde_json::Value> = Vec::new();
273    for entry in entries.flatten() {
274        let ft = match entry.file_type() {
275            Ok(ft) => ft,
276            Err(_) => continue,
277        };
278        if !ft.is_dir() {
279            continue;
280        }
281        let name = entry.file_name().to_string_lossy().to_string();
282        if name.starts_with('.') && !payload.show_hidden {
283            continue;
284        }
285        // Check read access.
286        if std::fs::read_dir(entry.path()).is_err() {
287            continue;
288        }
289        dirs.push(serde_json::json!({
290            "name": name,
291            "path": entry.path().to_string_lossy(),
292        }));
293    }
294
295    dirs.sort_by(|a, b| {
296        let na = a["name"].as_str().unwrap_or("").to_lowercase();
297        let nb = b["name"].as_str().unwrap_or("").to_lowercase();
298        na.cmp(&nb)
299    });
300
301    Json(serde_json::json!({
302        "current_path": target.to_string_lossy(),
303        "parent_path": parent_path,
304        "directories": dirs,
305        "error": null,
306    }))
307}
308
309/// Helper: get the home directory path as a String.
310fn dirs_path_home() -> Option<String> {
311    std::env::var("HOME")
312        .ok()
313        .or_else(|| std::env::var("USERPROFILE").ok())
314}