//! 快速面板:基于 7-Zip(binaries/7z.exe)的批量文件操作。 //! //! - 批量解压:逐文件调用 7z 控制台,支持一次传入统一密码(`-p`), //! 每完成一个文件通过事件推送进度,前端可实时展示。 //! - 批量重命名:`regex` 匹配文件名生成预览,确认后执行 `fs::rename`。 //! - 目录列表:供前端展示当前目录的压缩包 / 文件列表。 use std::path::{Path, PathBuf}; use std::process::Command; use serde::{Deserialize, Serialize}; use specta::Type; use tauri::{AppHandle, Emitter, Manager}; use crate::constants::events::QUICKPANEL_EXTRACT_PROGRESS; /// 7z 可执行文件名(与 7z.dll 同目录,位于 resources/binaries)。 const _7Z_EXE: &str = "binaries/7z.exe"; /// 支持的压缩包扩展名(解压面板中列出)。 const ARCHIVE_EXTS: &[&str] = &[ ".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".tbz", ".xz", ".txz", ".zst", ".tzst", ".cab", ".lzma", ]; /// 定位 7z 可执行文件(resources/binaries/7z.exe)。 fn locate_7z(app: &AppHandle) -> Option { let path = app .path() .resolve(_7Z_EXE, tauri::path::BaseDirectory::Resource) .ok()?; if path.exists() { Some(path) } else { None } } #[derive(Serialize, Deserialize, Type, Clone)] #[serde(rename_all = "camelCase")] pub struct ArchiveInfo { pub name: String, pub path: String, pub size: u64, } #[derive(Serialize, Deserialize, Type, Clone)] #[serde(rename_all = "camelCase")] pub struct FileEntry { pub name: String, pub path: String, pub is_dir: bool, pub size: u64, } #[derive(Serialize, Deserialize, Type, Clone)] #[serde(rename_all = "camelCase")] pub struct ExtractResult { pub name: String, pub path: String, pub ok: bool, pub error: String, } #[derive(Serialize, Deserialize, Type, Clone)] #[serde(rename_all = "camelCase")] pub struct ExtractProgress { pub done: usize, pub total: usize, pub current: String, pub ok: bool, pub error: String, } #[derive(Serialize, Deserialize, Type, Clone)] #[serde(rename_all = "camelCase")] pub struct RenamePreview { pub path: String, pub old_name: String, pub new_name: String, pub error: String, } #[derive(Serialize, Deserialize, Type, Clone)] #[serde(rename_all = "camelCase")] pub struct RenameItem { pub path: String, pub old_name: String, pub new_name: String, } #[derive(Serialize, Deserialize, Type, Clone)] #[serde(rename_all = "camelCase")] pub struct RenameResult { pub old_name: String, pub new_name: String, pub ok: bool, pub error: String, } fn is_archive(name: &str) -> bool { let lower = name.to_lowercase(); ARCHIVE_EXTS.iter().any(|ext| lower.ends_with(ext)) } /// 列出目录下的压缩包文件(供批量解压面板使用)。 #[tauri::command] #[specta::specta] pub async fn quickpanel_list_archives(dir: String) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { let d = Path::new(&dir); if !d.is_dir() { return Err(format!("目录不存在: {dir}")); } let mut out = Vec::new(); for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? { let Ok(entry) = entry else { continue }; let path = entry.path(); if !path.is_file() { continue; } let name = path .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); if is_archive(&name) { let size = path.metadata().map(|m| m.len()).unwrap_or(0); out.push(ArchiveInfo { name, path: path.to_string_lossy().to_string(), size, }); } } out.sort_by(|a, b| a.name.cmp(&b.name)); Ok(out) }) .await .map_err(|e| format!("读取压缩包失败: {e}"))? } /// 列出目录下的全部条目(供批量重命名/删除面板使用,不含子目录递归)。 #[tauri::command] #[specta::specta] pub async fn quickpanel_list_dir(dir: String) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { let d = Path::new(&dir); if !d.is_dir() { return Err(format!("目录不存在: {dir}")); } let mut out = Vec::new(); for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? { let Ok(entry) = entry else { continue }; let path = entry.path(); let is_dir = path.is_dir(); let name = path .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); let size = if is_dir { 0 } else { path.metadata().map(|m| m.len()).unwrap_or(0) }; out.push(FileEntry { name, path: path.to_string_lossy().to_string(), is_dir, size, }); } out.sort_by(|a, b| { b.is_dir .cmp(&a.is_dir) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); Ok(out) }) .await .map_err(|e| format!("读取目录失败: {e}"))? } fn extract_one( exe: &Path, archive: &str, dest: &str, password: Option<&str>, into_subfolder: bool, ) -> Result<(), String> { let archive_path = Path::new(archive); let mut cmd = Command::new(exe); cmd.arg("x").arg(archive_path); if into_subfolder { // 解压到「压缩包同名子文件夹」,避免文件散落在当前目录。 let sub = archive_path .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "extracted".into()); let sub_dir = Path::new(dest).join(&sub); std::fs::create_dir_all(&sub_dir).map_err(|e| format!("创建目录 {sub} 失败: {e}"))?; cmd.arg(format!("-o{}", sub_dir.display())); } else { cmd.arg(format!("-o{}", dest)); } cmd.arg("-y"); // 全自动覆盖确认 if let Some(p) = password.filter(|p| !p.is_empty()) { cmd.arg(format!("-p{p}")); } // 静默普通输出,仅错误进 stderr,逐文件粒度足够时无需 -bsp1 进度。 cmd.arg("-bso0").arg("-bse1").arg("-bsp0"); // 以 7z.exe 所在目录为工作目录,确保同目录的 7z.dll 可被加载。 if let Some(dir) = exe.parent() { cmd.current_dir(dir); } let output = cmd.output().map_err(|e| format!("启动 7-Zip 失败: {e}"))?; if output.status.success() { Ok(()) } else { let msg = String::from_utf8_lossy(&output.stderr).trim().to_string(); let code = output.status.code().unwrap_or(-1); Err(if msg.is_empty() { format!("退出码 {code}") } else { msg }) } } /// 批量解压。`files` 为压缩包路径列表,`dest_dir` 为目标目录, /// `password` 为统一解压密码(可空),`into_subfolder` 是否解压到同名子文件夹。 /// 每完成一个文件通过 `quickpanel-extract-progress` 事件推送进度。 #[tauri::command] #[specta::specta] pub async fn quickpanel_batch_extract( app: AppHandle, files: Vec, dest_dir: String, password: Option, into_subfolder: bool, ) -> Result, String> { let exe = locate_7z(&app).ok_or("未找到 7-Zip 组件(binaries/7z.exe),请重新安装")?; let total = files.len(); let results = tauri::async_runtime::spawn_blocking(move || { let mut results = Vec::with_capacity(total); for (idx, file) in files.iter().enumerate() { let err = extract_one(&exe, file, &dest_dir, password.as_deref(), into_subfolder); let name = Path::new(file) .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| file.clone()); let (ok, error) = match err { Ok(()) => (true, String::new()), Err(e) => (false, e), }; results.push(ExtractResult { name, path: file.clone(), ok, error: error.clone(), }); let _ = app.emit( QUICKPANEL_EXTRACT_PROGRESS, ExtractProgress { done: idx + 1, total, current: results[idx].name.clone(), ok, error, }, ); } results }) .await .map_err(|e| format!("解压任务异常终止: {e}"))?; Ok(results) } /// 正则批量重命名预览:对每个文件名应用 `pattern → replacement`, /// 仅返回有匹配的文件,`newName` 为替换结果。 #[tauri::command] #[specta::specta] pub async fn quickpanel_preview_rename( files: Vec, pattern: String, replacement: String, ) -> Result, String> { let re = regex::Regex::new(&pattern).map_err(|e| format!("正则表达式无效: {e}"))?; let mut out = Vec::new(); for file in files { let name = Path::new(&file) .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); if !re.is_match(&name) { continue; } let new_name = re.replace_all(&name, replacement.as_str()).to_string(); let error = if new_name.is_empty() || new_name == name { "名称未变化".to_string() } else { String::new() }; out.push(RenamePreview { path: file, old_name: name, new_name, error, }); } Ok(out) } /// 执行重命名。同一目录下若目标已存在则跳过该项。 #[tauri::command] #[specta::specta] pub async fn quickpanel_apply_rename(items: Vec) -> Result, String> { let mut out = Vec::new(); for item in items { let result = (|| -> Result<(), String> { if item.new_name.is_empty() { return Err("新文件名为空".into()); } if item.new_name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) { return Err("文件名包含非法字符".into()); } let old = Path::new(&item.path); let parent = old.parent().unwrap_or(Path::new(".")); let new_path = parent.join(&item.new_name); if new_path.exists() { return Err("目标已存在".into()); } std::fs::rename(old, &new_path).map_err(|e| format!("重命名失败: {e}"))?; Ok(()) })(); out.push(RenameResult { old_name: item.old_name, new_name: item.new_name, ok: result.is_ok(), error: result.err().unwrap_or_default(), }); } Ok(out) }