feat: 工作目录支持页面内目录浏览器

This commit is contained in:
2026-10-24 23:20:20 +08:00
parent dd7612bc74
commit e555610037
15 changed files with 1154 additions and 16 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ url = "2.5.8"
utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.3.0"
uuid = { version = "1.28.0", features = ["v4"] }
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_JobObjects"] }
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] }
[dev-dependencies]
http-body-util = "0.1.5"
+38 -1
View File
@@ -13,7 +13,10 @@ use utoipa::{OpenApi, ToSchema};
use crate::{
cron::{self, CronPreviewRequest, CronPreviewResponse},
error::{ApiError, ApiJson, ApiResult, ErrorResponse},
directory_browser::{
self, DirectoryEntry, DirectoryError, DirectoryListingResponse, ListDirectoriesQuery,
},
error::{ApiError, ApiJson, ApiQuery, ApiResult, ErrorResponse},
python_env::{
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
},
@@ -129,6 +132,36 @@ pub async fn probe_python_venv(
.map_err(ApiError::internal)
}
#[utoipa::path(
get,
path = "/api/v1/system/directories",
tag = "system",
params(ListDirectoriesQuery),
responses(
(status = 200, description = "Subdirectories of the requested path, or the drive list", body = DirectoryListingResponse),
(status = 400, description = "The path is not an existing absolute directory", body = ErrorResponse)
)
)]
pub async fn list_directories(
ApiQuery(query): ApiQuery<ListDirectoriesQuery>,
) -> ApiResult<Json<DirectoryListingResponse>> {
tokio::task::spawn_blocking(move || directory_browser::list_directories(&query))
.await
.map_err(ApiError::internal)?
.map(Json)
.map_err(|error| match error {
DirectoryError::RelativePath(message) => {
ApiError::bad_request("DIRECTORY_PATH_INVALID", message)
}
DirectoryError::NotFound(message) => {
ApiError::bad_request("DIRECTORY_NOT_FOUND", message)
}
DirectoryError::Unreadable(message) => {
ApiError::bad_request("DIRECTORY_UNREADABLE", message)
}
})
}
#[derive(OpenApi)]
#[openapi(
info(
@@ -141,6 +174,7 @@ pub async fn probe_python_venv(
session,
preview_cron,
probe_python_venv,
list_directories,
list_tasks,
create_task,
get_task,
@@ -166,6 +200,8 @@ pub async fn probe_python_venv(
VenvProbeStatus,
VenvSource,
InterpreterKind,
DirectoryEntry,
DirectoryListingResponse,
ErrorResponse,
TaskActionType,
HttpMethod,
@@ -215,6 +251,7 @@ pub fn router(state: AppState) -> Router {
"/api/v1/python/venv",
axum::routing::post(probe_python_venv),
)
.route("/api/v1/system/directories", get(list_directories))
.route("/api/v1/openapi.json", get(openapi_json))
.merge(crate::tasks::routes::router())
.merge(crate::runs::routes::router())
+238
View File
@@ -0,0 +1,238 @@
use std::{
fs,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
/// 单个目录最多返回的条目数,避免超大目录拖垮页面。
pub const MAX_ENTRIES: usize = 500;
#[derive(Debug, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ListDirectoriesQuery {
/// 绝对目录路径;留空表示列出盘符
pub path: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct DirectoryEntry {
/// 用于展示的名称
pub name: String,
/// 绝对路径,选中后回填到表单
pub path: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct DirectoryListingResponse {
/// 当前目录;为 null 表示正在列出盘符
pub path: Option<String>,
/// 上一级目录;盘符根目录和盘符列表为 null
pub parent: Option<String>,
pub entries: Vec<DirectoryEntry>,
/// 条目超过上限被截断时为 true
pub truncated: bool,
}
#[derive(Debug)]
pub enum DirectoryError {
/// 传入的不是绝对路径
RelativePath(String),
/// 路径不存在或不是目录
NotFound(String),
/// 目录存在但无法读取(例如权限不足)
Unreadable(String),
}
pub fn list_directories(
query: &ListDirectoriesQuery,
) -> Result<DirectoryListingResponse, DirectoryError> {
let Some(requested) = query
.path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(DirectoryListingResponse {
path: None,
parent: None,
entries: logical_drives(),
truncated: false,
});
};
let directory = PathBuf::from(requested);
if !directory.is_absolute() {
return Err(DirectoryError::RelativePath(format!(
"需要绝对路径: {requested}"
)));
}
if !directory.is_dir() {
return Err(DirectoryError::NotFound(format!(
"目录不存在: {}",
directory.display()
)));
}
let reader = fs::read_dir(&directory).map_err(|error| {
DirectoryError::Unreadable(format!("无法读取目录 {}: {error}", directory.display()))
})?;
let mut entries = Vec::new();
let mut truncated = false;
for entry in reader.flatten() {
let entry_path = entry.path();
let is_directory = entry
.file_type()
.map(|kind| kind.is_dir())
.unwrap_or_else(|_| entry_path.is_dir());
if !is_directory {
continue;
}
entries.push(DirectoryEntry {
name: entry.file_name().to_string_lossy().into_owned(),
path: entry_path.to_string_lossy().into_owned(),
});
if entries.len() >= MAX_ENTRIES {
truncated = true;
break;
}
}
entries.sort_by_key(|entry| entry.name.to_lowercase());
Ok(DirectoryListingResponse {
path: Some(directory.to_string_lossy().into_owned()),
parent: parent_of(&directory),
entries,
truncated,
})
}
fn parent_of(directory: &Path) -> Option<String> {
directory
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(|parent| parent.to_string_lossy().into_owned())
}
#[cfg(windows)]
fn logical_drives() -> Vec<DirectoryEntry> {
let mask = unsafe { windows_sys::Win32::Storage::FileSystem::GetLogicalDrives() };
(0..26u32)
.filter(|index| mask & (1 << index) != 0)
.map(|index| {
let letter = char::from(b'A' + index as u8);
DirectoryEntry {
name: format!("{letter}:\\"),
path: format!("{letter}:\\"),
}
})
.collect()
}
#[cfg(not(windows))]
fn logical_drives() -> Vec<DirectoryEntry> {
vec![DirectoryEntry {
name: "/".to_owned(),
path: "/".to_owned(),
}]
}
#[cfg(test)]
mod tests {
use super::*;
fn query(path: Option<&str>) -> ListDirectoriesQuery {
ListDirectoriesQuery {
path: path.map(str::to_owned),
}
}
#[test]
fn lists_only_directories_sorted_by_name() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join("zeta")).unwrap();
fs::create_dir(temp.path().join("Alpha")).unwrap();
fs::write(temp.path().join("file.txt"), "x").unwrap();
let listing = list_directories(&query(Some(temp.path().to_str().unwrap()))).unwrap();
let names = listing
.entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>();
assert_eq!(names, vec!["Alpha", "zeta"]);
assert_eq!(
listing.parent.as_deref(),
Some(temp.path().parent().unwrap().to_str().unwrap())
);
assert!(!listing.truncated);
}
#[test]
fn returns_absolute_paths_for_entries() {
let temp = tempfile::TempDir::new().unwrap();
let nested = temp.path().join("nested");
fs::create_dir(&nested).unwrap();
let listing = list_directories(&query(Some(temp.path().to_str().unwrap()))).unwrap();
assert_eq!(listing.entries[0].path, nested.to_string_lossy());
assert_eq!(listing.path.as_deref(), Some(temp.path().to_str().unwrap()));
}
#[test]
fn rejects_relative_paths() {
let error = list_directories(&query(Some("relative\\dir"))).unwrap_err();
assert!(matches!(error, DirectoryError::RelativePath(_)));
}
#[test]
fn reports_missing_directories() {
let temp = tempfile::TempDir::new().unwrap();
let missing = temp.path().join("does-not-exist");
let error = list_directories(&query(Some(missing.to_str().unwrap()))).unwrap_err();
assert!(matches!(error, DirectoryError::NotFound(_)));
}
#[test]
fn treats_files_as_missing_directories() {
let temp = tempfile::TempDir::new().unwrap();
let file = temp.path().join("file.txt");
fs::write(&file, "x").unwrap();
let error = list_directories(&query(Some(file.to_str().unwrap()))).unwrap_err();
assert!(matches!(error, DirectoryError::NotFound(_)));
}
#[test]
fn lists_logical_drives_when_path_is_empty() {
let listing = list_directories(&query(None)).unwrap();
assert!(listing.path.is_none());
assert!(listing.parent.is_none());
assert!(!listing.entries.is_empty());
#[cfg(windows)]
assert!(
listing
.entries
.iter()
.all(|entry| entry.path.ends_with(":\\"))
);
}
#[test]
fn drive_roots_have_no_parent() {
let listing = list_directories(&query(logical_drives().first().map(|_| "C:\\")));
// 某些机器没有 C 盘,此时跳过断言。
if let Ok(listing) = listing {
assert!(listing.parent.is_none());
}
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod api;
pub mod cron;
pub mod database;
pub mod directory_browser;
pub mod error;
pub mod process_job;
pub mod python_env;
+84
View File
@@ -1,3 +1,5 @@
use std::fs;
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -71,4 +73,86 @@ async fn openapi_endpoint_describes_health_api() {
assert_eq!(json["info"]["title"], "TaskPilot API");
assert!(json["paths"]["/api/v1/health"]["get"].is_object());
assert!(json["paths"]["/api/v1/system/directories"]["get"].is_object());
}
fn encode_query_path(path: &std::path::Path) -> String {
path.to_string_lossy()
.replace('%', "%25")
.replace(':', "%3A")
.replace('\\', "%5C")
}
async fn get_json(app: &axum::Router, uri: &str) -> (StatusCode, Value) {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
}
#[tokio::test]
async fn directory_listing_endpoint_returns_subdirectories() {
let (app, temp_dir) = test_app().await;
let project = temp_dir.path().join("project");
fs::create_dir_all(project.join("nested")).expect("nested directory should be created");
fs::write(project.join("file.txt"), "x").expect("file should be written");
let uri = format!(
"/api/v1/system/directories?path={}",
encode_query_path(&project)
);
let (status, body) = get_json(&app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["path"], project.to_string_lossy().into_owned());
let entries = body["entries"]
.as_array()
.expect("entries should be an array");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["name"], "nested");
assert_eq!(
entries[0]["path"],
project.join("nested").to_string_lossy().into_owned()
);
}
#[tokio::test]
async fn directory_listing_endpoint_rejects_relative_paths() {
let (app, _temp_dir) = test_app().await;
let (status, body) = get_json(&app, "/api/v1/system/directories?path=relative").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "DIRECTORY_PATH_INVALID");
}
#[tokio::test]
async fn directory_listing_endpoint_returns_drives_without_path() {
let (app, _temp_dir) = test_app().await;
let (status, body) = get_json(&app, "/api/v1/system/directories").await;
assert_eq!(status, StatusCode::OK);
assert!(body["path"].is_null());
assert!(
!body["entries"]
.as_array()
.expect("entries should be an array")
.is_empty()
);
}