feat: 工作目录支持页面内目录浏览器
This commit is contained in:
+1
-1
@@ -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
@@ -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())
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user