chore: 官网拆分 homepage + 关于页瘦身 + 端口 5175(当前可用状态基线)
This commit is contained in:
1 parent
5eb11826db
commit
be151cb68f
85 files changed
+4420
-1530
No files matched your search
+58
-262
@@ -1,13 +1,12 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::domain::ssh::OperationResponse;
|
||||
use crate::domain::{files::*, ssh::OperationResponse};
|
||||
use crate::services::files::{self, expand_home};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -36,53 +35,6 @@ pub struct DialogResult {
|
||||
file_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PathResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ReadFileResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFileEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: String,
|
||||
size: u64,
|
||||
modified: u64,
|
||||
permissions: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFilesResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
files: Option<Vec<LocalFileEntry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalStatsResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stats: Option<LocalFileEntry>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn show_open_dialog(options: DialogOptions) -> DialogResult {
|
||||
let dialog = configure_dialog(rfd::FileDialog::new(), &options);
|
||||
@@ -139,44 +91,25 @@ pub fn show_save_dialog(app: AppHandle, options: DialogOptions) -> DialogResult
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_file(file_path: String) -> ReadFileResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match fs::read(expand_home(&file_path)) {
|
||||
Ok(data) => ReadFileResponse {
|
||||
success: true,
|
||||
data: Some(STANDARD.encode(data)),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => ReadFileResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || files::read_file(file_path))
|
||||
.await
|
||||
.unwrap_or_else(|_| ReadFileResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| ReadFileResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_file(file_path: String, data: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match STANDARD.decode(&data) {
|
||||
Err(error) => OperationResponse::coded(
|
||||
crate::domain::error::codes::INVALID_BASE64,
|
||||
error.to_string(),
|
||||
),
|
||||
Ok(bytes) => {
|
||||
operation(fs::write(expand_home(&file_path), bytes).map_err(|error| error.to_string()))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"write file background task failed",
|
||||
)
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::write_file(file_path, data))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"write file background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -204,111 +137,72 @@ pub fn get_downloads_path(app: AppHandle) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_downloads_folder(app: AppHandle) -> Result<(), String> {
|
||||
pub async fn open_downloads_folder(app: AppHandle) -> Result<(), String> {
|
||||
let path = app
|
||||
.path()
|
||||
.download_dir()
|
||||
.map_err(|error| error.to_string())?;
|
||||
fs::create_dir_all(&path).map_err(|error| format!("创建下载目录失败: {error}"))?;
|
||||
open::that(&path).map_err(|error| format!("打开下载目录失败: {error}"))
|
||||
tauri::async_runtime::spawn_blocking(move || files::open_downloads_folder(path))
|
||||
.await
|
||||
.map_err(|error| crate::domain::error::AppError::TaskJoin(error.to_string()).to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match read_local_dir(&expand_home(&path)) {
|
||||
Ok(files) => LocalFilesResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalFilesResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_read_dir(path))
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalFilesResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalFilesResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_get_stats(path: String) -> LocalStatsResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path = expand_home(&path);
|
||||
match local_entry(&path) {
|
||||
Ok(stats) => LocalStatsResponse {
|
||||
success: true,
|
||||
stats: Some(stats),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_get_stats(path))
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_mkdir(path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"mkdir background task failed",
|
||||
)
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_mkdir(path))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"mkdir background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_delete(path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path = expand_home(&path);
|
||||
let result = if path.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
};
|
||||
operation(result.map_err(|error| error.to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"delete background task failed",
|
||||
)
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_delete(path))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"delete background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn local_rename(old_path: String, new_path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operation(
|
||||
fs::rename(expand_home(&old_path), expand_home(&new_path))
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"rename background task failed",
|
||||
)
|
||||
})
|
||||
tauri::async_runtime::spawn_blocking(move || files::local_rename(old_path, new_path))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"rename background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -372,97 +266,6 @@ fn configure_dialog(mut dialog: rfd::FileDialog, options: &DialogOptions) -> rfd
|
||||
dialog
|
||||
}
|
||||
|
||||
fn read_local_dir(path: &Path) -> Result<Vec<LocalFileEntry>, String> {
|
||||
let mut files = Vec::new();
|
||||
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
files.push(local_entry(&entry.path())?);
|
||||
}
|
||||
files.sort_by(|a, b| {
|
||||
((a.entry_type == "file"), a.name.to_lowercase())
|
||||
.cmp(&((b.entry_type == "file"), b.name.to_lowercase()))
|
||||
});
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn local_entry(path: &Path) -> Result<LocalFileEntry, String> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
|
||||
let entry_type = if metadata.file_type().is_symlink() {
|
||||
"symlink"
|
||||
} else if metadata.is_dir() {
|
||||
"directory"
|
||||
} else {
|
||||
"file"
|
||||
};
|
||||
Ok(LocalFileEntry {
|
||||
name: path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
entry_type: entry_type.to_string(),
|
||||
size: metadata.len(),
|
||||
modified: metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0),
|
||||
permissions: permissions(&metadata, entry_type),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permissions(metadata: &fs::Metadata, kind: &str) -> String {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode();
|
||||
let prefix = if kind == "directory" {
|
||||
'd'
|
||||
} else if kind == "symlink" {
|
||||
'l'
|
||||
} else {
|
||||
'-'
|
||||
};
|
||||
format!("{}{}", prefix, permission_bits(mode))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn permissions(_metadata: &fs::Metadata, kind: &str) -> String {
|
||||
if kind == "directory" {
|
||||
"drwxr-xr-x".to_string()
|
||||
} else {
|
||||
"-rw-r--r--".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permission_bits(mode: u32) -> String {
|
||||
let mut output = String::new();
|
||||
for shift in [6, 3, 0] {
|
||||
let bits = (mode >> shift) & 7;
|
||||
output.push(if bits & 4 != 0 { 'r' } else { '-' });
|
||||
output.push(if bits & 2 != 0 { 'w' } else { '-' });
|
||||
output.push(if bits & 1 != 0 { 'x' } else { '-' });
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn expand_home(value: &str) -> PathBuf {
|
||||
if value == "~" {
|
||||
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
|
||||
}
|
||||
let relative = value
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| value.strip_prefix("~\\"));
|
||||
if let Some(relative) = relative {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(std::path::Path::new(relative));
|
||||
}
|
||||
}
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
fn path_result(path: PathBuf, open: bool) -> DialogResult {
|
||||
let value = path.to_string_lossy().to_string();
|
||||
DialogResult {
|
||||
@@ -495,13 +298,6 @@ fn canceled() -> DialogResult {
|
||||
}
|
||||
}
|
||||
|
||||
fn operation(result: Result<(), String>) -> OperationResponse {
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -9,3 +9,33 @@ pub mod codes {
|
||||
pub const KNOWN_HOSTS_PATH: &str = "native.knownHostsPath";
|
||||
pub const INVALID_HOST_KEY_DECISION: &str = "native.invalidHostKeyDecision";
|
||||
}
|
||||
|
||||
/// Domain failures retain their cause while exposing stable IPC codes where available.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
InvalidBase64(#[from] base64::DecodeError),
|
||||
#[error("{0}")]
|
||||
TaskJoin(String),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn code(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Io(_) => None,
|
||||
Self::InvalidBase64(_) => Some(codes::INVALID_BASE64),
|
||||
Self::TaskJoin(_) => Some(codes::TASK_JOIN),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppError> for crate::domain::ssh::OperationResponse {
|
||||
fn from(error: AppError) -> Self {
|
||||
match error.code() {
|
||||
Some(code) => Self::coded(code, error.to_string()),
|
||||
None => Self::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PathResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ReadFileResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
#[serde(rename = "type")]
|
||||
pub entry_type: String,
|
||||
pub size: u64,
|
||||
pub modified: u64,
|
||||
pub permissions: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFilesResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub files: Option<Vec<LocalFileEntry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalStatsResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stats: Option<LocalFileEntry>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod ai;
|
||||
pub mod desktop;
|
||||
pub mod error;
|
||||
pub mod files;
|
||||
pub mod local_terminal;
|
||||
pub mod serial;
|
||||
pub mod ssh;
|
||||
@@ -47,6 +47,7 @@ pub fn run() {
|
||||
infrastructure::file_log::init();
|
||||
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
for argument in args {
|
||||
if argument.starts_with("ssh://") || argument.starts_with("opsxshell://") {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
use crate::domain::{error::AppError, files::*, ssh::OperationResponse};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub fn read_file(file_path: String) -> ReadFileResponse {
|
||||
match fs::read(expand_home(&file_path)) {
|
||||
Ok(data) => ReadFileResponse {
|
||||
success: true,
|
||||
data: Some(STANDARD.encode(data)),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => ReadFileResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_file(file_path: String, data: String) -> OperationResponse {
|
||||
let result = (|| -> Result<(), AppError> {
|
||||
let bytes = STANDARD.decode(&data)?;
|
||||
fs::write(expand_home(&file_path), bytes)?;
|
||||
Ok(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
match read_local_dir(&expand_home(&path)) {
|
||||
Ok(files) => LocalFilesResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalFilesResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_get_stats(path: String) -> LocalStatsResponse {
|
||||
let path = expand_home(&path);
|
||||
match local_entry(&path) {
|
||||
Ok(stats) => LocalStatsResponse {
|
||||
success: true,
|
||||
stats: Some(stats),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_mkdir(path: String) -> OperationResponse {
|
||||
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
|
||||
}
|
||||
|
||||
pub fn local_delete(path: String) -> OperationResponse {
|
||||
let path = expand_home(&path);
|
||||
let result = if path.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
};
|
||||
operation(result.map_err(|error| error.to_string()))
|
||||
}
|
||||
|
||||
pub fn local_rename(old_path: String, new_path: String) -> OperationResponse {
|
||||
operation(
|
||||
fs::rename(expand_home(&old_path), expand_home(&new_path))
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn read_local_dir(path: &Path) -> Result<Vec<LocalFileEntry>, String> {
|
||||
let mut files = Vec::new();
|
||||
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
files.push(local_entry(&entry.path())?);
|
||||
}
|
||||
files.sort_by(|a, b| {
|
||||
((a.entry_type == "file"), a.name.to_lowercase())
|
||||
.cmp(&((b.entry_type == "file"), b.name.to_lowercase()))
|
||||
});
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn local_entry(path: &Path) -> Result<LocalFileEntry, String> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
|
||||
let entry_type = if metadata.file_type().is_symlink() {
|
||||
"symlink"
|
||||
} else if metadata.is_dir() {
|
||||
"directory"
|
||||
} else {
|
||||
"file"
|
||||
};
|
||||
Ok(LocalFileEntry {
|
||||
name: path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
entry_type: entry_type.to_string(),
|
||||
size: metadata.len(),
|
||||
modified: metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0),
|
||||
permissions: permissions(&metadata, entry_type),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permissions(metadata: &fs::Metadata, kind: &str) -> String {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = metadata.permissions().mode();
|
||||
let prefix = if kind == "directory" {
|
||||
'd'
|
||||
} else if kind == "symlink" {
|
||||
'l'
|
||||
} else {
|
||||
'-'
|
||||
};
|
||||
format!("{}{}", prefix, permission_bits(mode))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn permissions(_metadata: &fs::Metadata, kind: &str) -> String {
|
||||
if kind == "directory" {
|
||||
"drwxr-xr-x".to_string()
|
||||
} else {
|
||||
"-rw-r--r--".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn permission_bits(mode: u32) -> String {
|
||||
let mut output = String::new();
|
||||
for shift in [6, 3, 0] {
|
||||
let bits = (mode >> shift) & 7;
|
||||
output.push(if bits & 4 != 0 { 'r' } else { '-' });
|
||||
output.push(if bits & 2 != 0 { 'w' } else { '-' });
|
||||
output.push(if bits & 1 != 0 { 'x' } else { '-' });
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn expand_home(value: &str) -> PathBuf {
|
||||
if value == "~" {
|
||||
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
|
||||
}
|
||||
let relative = value
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| value.strip_prefix("~\\"));
|
||||
if let Some(relative) = relative {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(std::path::Path::new(relative));
|
||||
}
|
||||
}
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
fn operation(result: Result<(), String>) -> OperationResponse {
|
||||
match result {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the native downloads path in the command, then perform I/O off the UI thread.
|
||||
pub fn open_downloads_folder(path: PathBuf) -> Result<(), String> {
|
||||
fs::create_dir_all(&path).map_err(|error| format!("创建下载目录失败: {error}"))?;
|
||||
open::that(&path).map_err(|error| format!("打开下载目录失败: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("opsxshell-files-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn path(&self, name: &str) -> String {
|
||||
self.0.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_file_roundtrip_and_directory_operations() {
|
||||
let dir = TestDirectory::new();
|
||||
let bytes = [0, 255, 128, 10];
|
||||
let file = dir.path("z.bin");
|
||||
assert!(write_file(file.clone(), STANDARD.encode(bytes)).success);
|
||||
assert_eq!(read_file(file.clone()).data, Some(STANDARD.encode(bytes)));
|
||||
assert!(local_mkdir(dir.path("folder")).success);
|
||||
let listing = local_read_dir(dir.path(""));
|
||||
assert!(listing.success);
|
||||
let entries = listing.files.unwrap();
|
||||
assert_eq!(entries[0].entry_type, "directory");
|
||||
assert_eq!(entries[1].name, "z.bin");
|
||||
assert_eq!(local_get_stats(file.clone()).stats.unwrap().size, 4);
|
||||
let renamed = dir.path("renamed.bin");
|
||||
assert!(local_rename(file.clone(), renamed.clone()).success);
|
||||
assert!(!Path::new(&file).exists());
|
||||
assert!(local_delete(renamed.clone()).success);
|
||||
assert!(!read_file(renamed).success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_base64_never_overwrites_existing_file() {
|
||||
let dir = TestDirectory::new();
|
||||
let path = dir.path("keep.bin");
|
||||
fs::write(&path, b"keep").unwrap();
|
||||
let response = write_file(path.clone(), "%%%".into());
|
||||
assert!(!response.success);
|
||||
assert_eq!(
|
||||
response.error_code.as_deref(),
|
||||
Some(crate::domain::error::codes::INVALID_BASE64)
|
||||
);
|
||||
assert_eq!(fs::read(path).unwrap(), b"keep");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod ai;
|
||||
pub mod credential_store;
|
||||
pub mod deep_link;
|
||||
pub mod desktop;
|
||||
pub mod files;
|
||||
pub mod license;
|
||||
pub mod local_terminal;
|
||||
pub mod serial;
|
||||
|
||||
Reference in new issue
Block a user