From 921eecc7b6852c0fdc31d14bf7f33002af033837 Mon Sep 17 00:00:00 2001 From: MidnightCrowing <110297461+MidnightCrowing@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:55:05 +0800 Subject: [PATCH 1/2] refactor(backend): split Rust modules by domain --- src-tauri/src/analyzer.rs | 551 +------------ src-tauri/src/analyzer/language.rs | 88 ++ src-tauri/src/analyzer/models.rs | 72 ++ src-tauri/src/analyzer/paths.rs | 31 + src-tauri/src/analyzer/scan.rs | 352 ++++++++ src-tauri/src/command_line.rs | 12 +- src-tauri/src/lib.rs | 16 +- src-tauri/src/project.rs | 1041 +----------------------- src-tauri/src/project/commands.rs | 196 +++++ src-tauri/src/project/editor.rs | 47 ++ src-tauri/src/project/editor/detect.rs | 274 +++++++ src-tauri/src/project/editor/launch.rs | 223 +++++ src-tauri/src/project/license.rs | 196 +++++ src-tauri/src/project/path_safety.rs | 133 +++ src-tauri/src/recent.rs | 480 +---------- src-tauri/src/recent/cli.rs | 222 +++++ src-tauri/src/recent/common.rs | 62 ++ src-tauri/src/recent/jetbrains.rs | 94 +++ src-tauri/src/recent/models.rs | 5 + src-tauri/src/recent/vscode.rs | 111 +++ src-tauri/src/scanner.rs | 623 +------------- src-tauri/src/scanner/detect.rs | 111 +++ src-tauri/src/scanner/models.rs | 193 +++++ src-tauri/src/scanner/scan.rs | 338 ++++++++ src-tauri/src/system.rs | 345 +------- src-tauri/src/system/accent.rs | 87 ++ src-tauri/src/system/external.rs | 9 + src-tauri/src/system/path.rs | 56 ++ src-tauri/src/system/terminal.rs | 196 +++++ src-tauri/src/system/window.rs | 15 + src-tauri/src/webdav.rs | 457 +---------- src-tauri/src/webdav/error.rs | 57 ++ src-tauri/src/webdav/files.rs | 111 +++ src-tauri/src/webdav/models.rs | 36 + src-tauri/src/webdav/remote.rs | 184 +++++ src-tauri/src/webdav/sync.rs | 85 ++ 36 files changed, 3661 insertions(+), 3448 deletions(-) create mode 100644 src-tauri/src/analyzer/language.rs create mode 100644 src-tauri/src/analyzer/models.rs create mode 100644 src-tauri/src/analyzer/paths.rs create mode 100644 src-tauri/src/analyzer/scan.rs create mode 100644 src-tauri/src/project/commands.rs create mode 100644 src-tauri/src/project/editor.rs create mode 100644 src-tauri/src/project/editor/detect.rs create mode 100644 src-tauri/src/project/editor/launch.rs create mode 100644 src-tauri/src/project/license.rs create mode 100644 src-tauri/src/project/path_safety.rs create mode 100644 src-tauri/src/recent/cli.rs create mode 100644 src-tauri/src/recent/common.rs create mode 100644 src-tauri/src/recent/jetbrains.rs create mode 100644 src-tauri/src/recent/models.rs create mode 100644 src-tauri/src/recent/vscode.rs create mode 100644 src-tauri/src/scanner/detect.rs create mode 100644 src-tauri/src/scanner/models.rs create mode 100644 src-tauri/src/scanner/scan.rs create mode 100644 src-tauri/src/system/accent.rs create mode 100644 src-tauri/src/system/external.rs create mode 100644 src-tauri/src/system/path.rs create mode 100644 src-tauri/src/system/terminal.rs create mode 100644 src-tauri/src/system/window.rs create mode 100644 src-tauri/src/webdav/error.rs create mode 100644 src-tauri/src/webdav/files.rs create mode 100644 src-tauri/src/webdav/models.rs create mode 100644 src-tauri/src/webdav/remote.rs create mode 100644 src-tauri/src/webdav/sync.rs diff --git a/src-tauri/src/analyzer.rs b/src-tauri/src/analyzer.rs index 88dde3d..8018a5c 100644 --- a/src-tauri/src/analyzer.rs +++ b/src-tauri/src/analyzer.rs @@ -1,538 +1,15 @@ -use std::{ - collections::BTreeMap, - env, - fs::File, - io::Read, - path::{Path, PathBuf}, +mod language; +mod models; +mod paths; +mod scan; + +#[allow(unused_imports)] +pub use models::{ + FilesResult, LanguageStats, LanguageType, LanguagesResult, LineCounts, LinguistResult, + UnknownResult, }; - -use ignore::{DirEntry, WalkBuilder}; -use serde::Serialize; - -const MAX_LINE_COUNT_BYTES: u64 = 1_500_000; -const DEFAULT_MAX_SCAN_BYTES: u64 = 800 * 1024 * 1024; -const MAX_ALLOWED_SCAN_BYTES: u64 = 10 * 1024 * 1024 * 1024; // 10GB 上限 - -/// 单次语言分析/扫描允许处理的最大累计字节数。 -/// 可通过 `CODENEST_MAX_SCAN_BYTES` 环境变量调整(钳制在 10GB 内)。 -pub fn max_scan_bytes() -> u64 { - env::var("CODENEST_MAX_SCAN_BYTES") - .ok() - .and_then(|value| value.parse::().ok()) - .map(|value| value.min(MAX_ALLOWED_SCAN_BYTES)) - .unwrap_or(DEFAULT_MAX_SCAN_BYTES) -} - -#[derive(Clone, Copy, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum LanguageType { - Data, - Markup, - Programming, - Prose, -} - -#[derive(Clone, Copy)] -struct LanguageDef { - name: &'static str, - language_type: LanguageType, - color: Option<&'static str>, -} - -#[derive(Clone, Default, Serialize)] -pub struct LineCounts { - pub total: u64, - pub content: u64, - pub code: u64, -} - -impl LineCounts { - fn add(&mut self, other: &LineCounts) { - self.total += other.total; - self.content += other.content; - self.code += other.code; - } -} - -#[derive(Clone, Serialize)] -pub struct LanguageStats { - pub bytes: u64, - pub lines: LineCounts, - #[serde(rename = "type")] - pub language_type: LanguageType, - #[serde(skip_serializing_if = "Option::is_none")] - pub parent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub color: Option, -} - -#[derive(Default, Serialize)] -pub struct FilesResult { - pub count: u64, - pub bytes: u64, - pub lines: LineCounts, - pub results: BTreeMap>, - pub alternatives: BTreeMap>>, -} - -#[derive(Default, Serialize)] -pub struct LanguagesResult { - pub count: u64, - pub bytes: u64, - pub lines: LineCounts, - pub results: BTreeMap, -} - -#[derive(Default, Serialize)] -pub struct UnknownResult { - pub count: u64, - pub bytes: u64, - pub lines: LineCounts, - pub extensions: BTreeMap, - pub filenames: BTreeMap, -} - -#[derive(Default, Serialize)] -pub struct LinguistResult { - pub files: FilesResult, - pub languages: LanguagesResult, - pub unknown: UnknownResult, -} - -pub fn analyze_folder(folder_path: &Path) -> Result { - if !folder_path.is_dir() { - return Err("Provided path is not a directory".to_string()); - } - - let byte_budget = max_scan_bytes(); - let mut result = LinguistResult::default(); - let walker = WalkBuilder::new(folder_path) - .hidden(true) - .ignore(true) - .git_ignore(true) - .parents(true) - .filter_entry(|entry| !is_skipped_dir(entry)) - .build(); - - for entry in walker { - let entry = match entry { - Ok(entry) => entry, - Err(_) => continue, - }; - if !entry - .file_type() - .map(|file_type| file_type.is_file()) - .unwrap_or(false) - { - continue; - } - - let path = entry.path(); - if is_skipped_file(path) { - continue; - } - - let metadata = match entry.metadata() { - Ok(metadata) => metadata, - Err(_) => continue, - }; - let bytes = metadata.len(); - - // 累计体积熔断:超出预算时终止分析,避免对超大目录 - // (如误选磁盘根目录)做无界的 IO 和行数统计。 - if result.files.bytes.saturating_add(bytes) > byte_budget { - return Err(format!( - "Directory too large to analyze (over {} MB)", - byte_budget / (1024 * 1024) - )); - } - let language = language_for_path(path); - let lines = language - .filter(|language| should_count_lines(language.language_type, bytes)) - .and_then(|_| count_lines(path)) - .unwrap_or_default(); - let relative_path = relative_path(folder_path, path); - - result.files.count += 1; - result.files.bytes += bytes; - result.files.lines.add(&lines); - - if let Some(language) = language { - result - .files - .results - .insert(relative_path.clone(), Some(language.name.to_string())); - result.files.alternatives.insert(relative_path, Vec::new()); - - let entry = result - .languages - .results - .entry(language.name.to_string()) - .or_insert(LanguageStats { - bytes: 0, - lines: LineCounts::default(), - language_type: language.language_type, - parent: None, - color: language.color.map(ToOwned::to_owned), - }); - entry.bytes += bytes; - entry.lines.add(&lines); - } else { - result.files.results.insert(relative_path.clone(), None); - result.files.alternatives.insert(relative_path, Vec::new()); - result.unknown.count += 1; - result.unknown.bytes += bytes; - result.unknown.lines.add(&lines); - - if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { - *result - .unknown - .extensions - .entry(extension.to_ascii_lowercase()) - .or_default() += 1; - } else if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) { - *result - .unknown - .filenames - .entry(file_name.to_ascii_lowercase()) - .or_default() += 1; - } - } - } - - result.languages.count = result.languages.results.len() as u64; - result.languages.bytes = result - .languages - .results - .values() - .map(|lang| lang.bytes) - .sum(); - for language in result.languages.results.values() { - result.languages.lines.add(&language.lines); - } - - Ok(result) -} - -pub fn is_skipped_dir(entry: &DirEntry) -> bool { - if entry.depth() == 0 { - return false; - } - let Some(file_type) = entry.file_type() else { - return false; - }; - if !file_type.is_dir() { - return false; - } - let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); - matches!( - name.as_str(), - ".git" - | ".hg" - | ".svn" - | ".idea" - | ".vscode" - | ".cache" - | ".gradle" - | ".parcel-cache" - | ".pytest_cache" - | ".mypy_cache" - | ".ruff_cache" - | "node_modules" - | "vendor" - | "dist" - | "build" - | "out" - | "release" - | "debug" - | "coverage" - | "target" - | "__pycache__" - | "venv" - | ".venv" - | ".next" - | ".nuxt" - | ".svelte-kit" - | ".turbo" - ) -} - -fn is_skipped_file(path: &Path) -> bool { - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - - if file_name.ends_with(".min.js") - || file_name.ends_with(".min.css") - || file_name.ends_with(".bundle.js") - || file_name.ends_with(".bundle.css") - { - return true; - } - - let extension = path - .extension() - .and_then(|extension| extension.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - - matches!( - extension.as_str(), - "map" - | "png" - | "jpg" - | "jpeg" - | "gif" - | "webp" - | "avif" - | "bmp" - | "ico" - | "icns" - | "mp4" - | "mov" - | "webm" - | "mkv" - | "mp3" - | "wav" - | "flac" - | "ogg" - | "pdf" - | "zip" - | "rar" - | "7z" - | "gz" - | "tgz" - | "tar" - | "woff" - | "woff2" - | "ttf" - | "otf" - | "eot" - | "wasm" - | "exe" - | "dll" - | "so" - | "dylib" - | "class" - | "jar" - ) -} - -fn should_count_lines(language_type: LanguageType, bytes: u64) -> bool { - bytes <= MAX_LINE_COUNT_BYTES - && matches!( - language_type, - LanguageType::Programming | LanguageType::Markup | LanguageType::Prose - ) -} - -fn count_lines(path: &Path) -> Option { - let mut file = File::open(path).ok()?; - let mut buffer = [0_u8; 16 * 1024]; - let mut total = 0_u64; - let mut content = 0_u64; - let mut has_bytes = false; - let mut line_has_content = false; - let mut last_byte = None; - - loop { - let read = file.read(&mut buffer).ok()?; - if read == 0 { - break; - } - - for byte in &buffer[..read] { - if *byte == 0 { - return None; - } - - has_bytes = true; - last_byte = Some(*byte); - - if *byte == b'\n' { - total += 1; - if line_has_content { - content += 1; - } - line_has_content = false; - } else if *byte != b'\r' && !byte.is_ascii_whitespace() { - line_has_content = true; - } - } - } - - if has_bytes && last_byte != Some(b'\n') { - total += 1; - if line_has_content { - content += 1; - } - } - - Some(LineCounts { - total, - content, - code: content, - }) -} - -fn relative_path(root: &Path, path: &Path) -> String { - path.strip_prefix(root) - .unwrap_or(path) - .to_string_lossy() - .replace('\\', "/") -} - -fn language_for_path(path: &Path) -> Option { - let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); - match file_name.as_str() { - "dockerfile" => return Some(lang("Dockerfile", LanguageType::Programming, "#384d54")), - "makefile" | "gnumakefile" => { - return Some(lang("Makefile", LanguageType::Programming, "#427819")) - } - "cmakelists.txt" => return Some(lang("CMake", LanguageType::Programming, "#DA3434")), - "cargo.lock" => return Some(lang("TOML", LanguageType::Data, "#9c4221")), - "package-lock.json" | "pnpm-lock.yaml" | "yarn.lock" => { - return Some(lang("Lockfile", LanguageType::Data, "#cccccc")); - } - _ => {} - } - - let extension = path.extension()?.to_string_lossy().to_ascii_lowercase(); - match extension.as_str() { - "rs" => Some(lang("Rust", LanguageType::Programming, "#dea584")), - "ts" | "tsx" => Some(lang("TypeScript", LanguageType::Programming, "#3178c6")), - "js" | "jsx" | "mjs" | "cjs" => { - Some(lang("JavaScript", LanguageType::Programming, "#f1e05a")) - } - "vue" => Some(lang("Vue", LanguageType::Programming, "#41b883")), - "py" | "pyw" => Some(lang("Python", LanguageType::Programming, "#3572A5")), - "java" => Some(lang("Java", LanguageType::Programming, "#b07219")), - "kt" | "kts" => Some(lang("Kotlin", LanguageType::Programming, "#A97BFF")), - "go" => Some(lang("Go", LanguageType::Programming, "#00ADD8")), - "php" => Some(lang("PHP", LanguageType::Programming, "#4F5D95")), - "rb" => Some(lang("Ruby", LanguageType::Programming, "#701516")), - "c" => Some(lang("C", LanguageType::Programming, "#555555")), - "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => { - Some(lang("C++", LanguageType::Programming, "#f34b7d")) - } - "h" => Some(lang("C", LanguageType::Programming, "#555555")), - "cs" => Some(lang("C#", LanguageType::Programming, "#178600")), - "fs" | "fsx" => Some(lang("F#", LanguageType::Programming, "#b845fc")), - "swift" => Some(lang("Swift", LanguageType::Programming, "#F05138")), - "dart" => Some(lang("Dart", LanguageType::Programming, "#00B4AB")), - "sh" | "bash" | "zsh" | "fish" => Some(lang("Shell", LanguageType::Programming, "#89e051")), - "ps1" | "psm1" | "psd1" => Some(lang("PowerShell", LanguageType::Programming, "#012456")), - "sql" => Some(lang("SQL", LanguageType::Programming, "#e38c00")), - "r" => Some(lang("R", LanguageType::Programming, "#198CE7")), - "lua" => Some(lang("Lua", LanguageType::Programming, "#000080")), - "html" | "htm" => Some(lang("HTML", LanguageType::Markup, "#e34c26")), - "xml" | "xaml" | "csproj" | "vbproj" | "fsproj" => { - Some(lang("XML", LanguageType::Markup, "#0060ac")) - } - "svg" => Some(lang("SVG", LanguageType::Markup, "#ff9900")), - "md" | "markdown" | "mdx" => Some(lang("Markdown", LanguageType::Prose, "#083fa1")), - "css" => Some(lang("CSS", LanguageType::Markup, "#563d7c")), - "scss" => Some(lang("SCSS", LanguageType::Markup, "#c6538c")), - "sass" => Some(lang("Sass", LanguageType::Markup, "#a53b70")), - "less" => Some(lang("Less", LanguageType::Markup, "#1d365d")), - "json" | "json5" => Some(lang("JSON", LanguageType::Data, "#292929")), - "yaml" | "yml" => Some(lang("YAML", LanguageType::Data, "#cb171e")), - "toml" => Some(lang("TOML", LanguageType::Data, "#9c4221")), - "ini" | "env" | "properties" => Some(lang("INI", LanguageType::Data, "#d1dbe0")), - "gradle" => Some(lang("Gradle", LanguageType::Programming, "#02303a")), - "groovy" => Some(lang("Groovy", LanguageType::Programming, "#4298b8")), - "scala" => Some(lang("Scala", LanguageType::Programming, "#c22d40")), - "svelte" => Some(lang("Svelte", LanguageType::Programming, "#ff3e00")), - "astro" => Some(lang("Astro", LanguageType::Programming, "#ff5d01")), - "pug" => Some(lang("Pug", LanguageType::Markup, "#a86454")), - "handlebars" | "hbs" => Some(lang("Handlebars", LanguageType::Markup, "#f7931e")), - "graphql" | "gql" => Some(lang("GraphQL", LanguageType::Data, "#e10098")), - "csv" | "tsv" => Some(lang("CSV", LanguageType::Data, "#237346")), - _ => None, - } -} - -fn lang(name: &'static str, language_type: LanguageType, color: &'static str) -> LanguageDef { - LanguageDef { - name, - language_type, - color: Some(color), - } -} - -pub fn normalize_path_key(path: &Path) -> String { - let normalized = display_path(&canonical_or_original(path)) - .replace('\\', "/") - .trim_end_matches('/') - .to_string(); - if cfg!(windows) { - normalized.to_ascii_lowercase() - } else { - normalized - } -} - -pub fn canonical_or_original(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) -} - -pub fn display_path(path: &Path) -> String { - strip_windows_verbatim_prefix(&path.to_string_lossy()) -} - -pub fn strip_windows_verbatim_prefix(path: &str) -> String { - if let Some(rest) = path.strip_prefix(r"\\?\UNC\") { - return format!(r"\\{rest}"); - } - if let Some(rest) = path.strip_prefix(r"\\?\") { - return rest.to_string(); - } - path.to_string() -} - -#[cfg(test)] -mod tests { - use std::{fs, sync::Mutex}; - - use super::analyze_folder; - - /// 串行化涉及 `CODENEST_MAX_SCAN_BYTES` 环境变量的测试,避免并行干扰。 - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn analyze_folder_rejects_directories_over_byte_budget() { - let _guard = ENV_LOCK.lock().unwrap(); - let dir = std::env::temp_dir().join("codenest-analyzer-budget-test"); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("main.rs"), "fn main() {}\n".repeat(10)).unwrap(); - - // 预算缩到 1 字节,任何文件都会触发熔断 - std::env::set_var("CODENEST_MAX_SCAN_BYTES", "1"); - let result = analyze_folder(&dir); - std::env::remove_var("CODENEST_MAX_SCAN_BYTES"); - - let _ = fs::remove_dir_all(&dir); - match result { - Err(error) => assert!(error.contains("too large"), "unexpected error: {error}"), - Ok(_) => panic!("oversized directory should be rejected"), - } - } - - #[test] - fn analyze_folder_succeeds_within_budget() { - let _guard = ENV_LOCK.lock().unwrap(); - let dir = std::env::temp_dir().join("codenest-analyzer-ok-test"); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("main.rs"), "fn main() {}\n").unwrap(); - - let result = analyze_folder(&dir); - - let _ = fs::remove_dir_all(&dir); - let result = result.expect("small directory should analyze fine"); - assert!(result.languages.results.contains_key("Rust")); - } -} +#[allow(unused_imports)] +pub use paths::{ + canonical_or_original, display_path, normalize_path_key, strip_windows_verbatim_prefix, +}; +pub use scan::{analyze_folder, is_skipped_dir, max_scan_bytes}; diff --git a/src-tauri/src/analyzer/language.rs b/src-tauri/src/analyzer/language.rs new file mode 100644 index 0000000..bcf55cb --- /dev/null +++ b/src-tauri/src/analyzer/language.rs @@ -0,0 +1,88 @@ +use std::path::Path; + +use super::models::LanguageType; + +#[derive(Clone, Copy)] +pub(super) struct LanguageDef { + pub(super) name: &'static str, + pub(super) language_type: LanguageType, + pub(super) color: Option<&'static str>, +} + +pub(super) fn language_for_path(path: &Path) -> Option { + let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); + match file_name.as_str() { + "dockerfile" => return Some(lang("Dockerfile", LanguageType::Programming, "#384d54")), + "makefile" | "gnumakefile" => { + return Some(lang("Makefile", LanguageType::Programming, "#427819")) + } + "cmakelists.txt" => return Some(lang("CMake", LanguageType::Programming, "#DA3434")), + "cargo.lock" => return Some(lang("TOML", LanguageType::Data, "#9c4221")), + "package-lock.json" | "pnpm-lock.yaml" | "yarn.lock" => { + return Some(lang("Lockfile", LanguageType::Data, "#cccccc")); + } + _ => {} + } + + let extension = path.extension()?.to_string_lossy().to_ascii_lowercase(); + match extension.as_str() { + "rs" => Some(lang("Rust", LanguageType::Programming, "#dea584")), + "ts" | "tsx" => Some(lang("TypeScript", LanguageType::Programming, "#3178c6")), + "js" | "jsx" | "mjs" | "cjs" => { + Some(lang("JavaScript", LanguageType::Programming, "#f1e05a")) + } + "vue" => Some(lang("Vue", LanguageType::Programming, "#41b883")), + "py" | "pyw" => Some(lang("Python", LanguageType::Programming, "#3572A5")), + "java" => Some(lang("Java", LanguageType::Programming, "#b07219")), + "kt" | "kts" => Some(lang("Kotlin", LanguageType::Programming, "#A97BFF")), + "go" => Some(lang("Go", LanguageType::Programming, "#00ADD8")), + "php" => Some(lang("PHP", LanguageType::Programming, "#4F5D95")), + "rb" => Some(lang("Ruby", LanguageType::Programming, "#701516")), + "c" => Some(lang("C", LanguageType::Programming, "#555555")), + "cc" | "cpp" | "cxx" | "hpp" | "hh" | "hxx" => { + Some(lang("C++", LanguageType::Programming, "#f34b7d")) + } + "h" => Some(lang("C", LanguageType::Programming, "#555555")), + "cs" => Some(lang("C#", LanguageType::Programming, "#178600")), + "fs" | "fsx" => Some(lang("F#", LanguageType::Programming, "#b845fc")), + "swift" => Some(lang("Swift", LanguageType::Programming, "#F05138")), + "dart" => Some(lang("Dart", LanguageType::Programming, "#00B4AB")), + "sh" | "bash" | "zsh" | "fish" => Some(lang("Shell", LanguageType::Programming, "#89e051")), + "ps1" | "psm1" | "psd1" => Some(lang("PowerShell", LanguageType::Programming, "#012456")), + "sql" => Some(lang("SQL", LanguageType::Programming, "#e38c00")), + "r" => Some(lang("R", LanguageType::Programming, "#198CE7")), + "lua" => Some(lang("Lua", LanguageType::Programming, "#000080")), + "html" | "htm" => Some(lang("HTML", LanguageType::Markup, "#e34c26")), + "xml" | "xaml" | "csproj" | "vbproj" | "fsproj" => { + Some(lang("XML", LanguageType::Markup, "#0060ac")) + } + "svg" => Some(lang("SVG", LanguageType::Markup, "#ff9900")), + "md" | "markdown" | "mdx" => Some(lang("Markdown", LanguageType::Prose, "#083fa1")), + "css" => Some(lang("CSS", LanguageType::Markup, "#563d7c")), + "scss" => Some(lang("SCSS", LanguageType::Markup, "#c6538c")), + "sass" => Some(lang("Sass", LanguageType::Markup, "#a53b70")), + "less" => Some(lang("Less", LanguageType::Markup, "#1d365d")), + "json" | "json5" => Some(lang("JSON", LanguageType::Data, "#292929")), + "yaml" | "yml" => Some(lang("YAML", LanguageType::Data, "#cb171e")), + "toml" => Some(lang("TOML", LanguageType::Data, "#9c4221")), + "ini" | "env" | "properties" => Some(lang("INI", LanguageType::Data, "#d1dbe0")), + "gradle" => Some(lang("Gradle", LanguageType::Programming, "#02303a")), + "groovy" => Some(lang("Groovy", LanguageType::Programming, "#4298b8")), + "scala" => Some(lang("Scala", LanguageType::Programming, "#c22d40")), + "svelte" => Some(lang("Svelte", LanguageType::Programming, "#ff3e00")), + "astro" => Some(lang("Astro", LanguageType::Programming, "#ff5d01")), + "pug" => Some(lang("Pug", LanguageType::Markup, "#a86454")), + "handlebars" | "hbs" => Some(lang("Handlebars", LanguageType::Markup, "#f7931e")), + "graphql" | "gql" => Some(lang("GraphQL", LanguageType::Data, "#e10098")), + "csv" | "tsv" => Some(lang("CSV", LanguageType::Data, "#237346")), + _ => None, + } +} + +fn lang(name: &'static str, language_type: LanguageType, color: &'static str) -> LanguageDef { + LanguageDef { + name, + language_type, + color: Some(color), + } +} diff --git a/src-tauri/src/analyzer/models.rs b/src-tauri/src/analyzer/models.rs new file mode 100644 index 0000000..a33de7f --- /dev/null +++ b/src-tauri/src/analyzer/models.rs @@ -0,0 +1,72 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +#[derive(Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LanguageType { + Data, + Markup, + Programming, + Prose, +} + +#[derive(Clone, Default, Serialize)] +pub struct LineCounts { + pub total: u64, + pub content: u64, + pub code: u64, +} + +impl LineCounts { + pub(super) fn add(&mut self, other: &LineCounts) { + self.total += other.total; + self.content += other.content; + self.code += other.code; + } +} + +#[derive(Clone, Serialize)] +pub struct LanguageStats { + pub bytes: u64, + pub lines: LineCounts, + #[serde(rename = "type")] + pub language_type: LanguageType, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +#[derive(Default, Serialize)] +pub struct FilesResult { + pub count: u64, + pub bytes: u64, + pub lines: LineCounts, + pub results: BTreeMap>, + pub alternatives: BTreeMap>>, +} + +#[derive(Default, Serialize)] +pub struct LanguagesResult { + pub count: u64, + pub bytes: u64, + pub lines: LineCounts, + pub results: BTreeMap, +} + +#[derive(Default, Serialize)] +pub struct UnknownResult { + pub count: u64, + pub bytes: u64, + pub lines: LineCounts, + pub extensions: BTreeMap, + pub filenames: BTreeMap, +} + +#[derive(Default, Serialize)] +pub struct LinguistResult { + pub files: FilesResult, + pub languages: LanguagesResult, + pub unknown: UnknownResult, +} diff --git a/src-tauri/src/analyzer/paths.rs b/src-tauri/src/analyzer/paths.rs new file mode 100644 index 0000000..2df9e1d --- /dev/null +++ b/src-tauri/src/analyzer/paths.rs @@ -0,0 +1,31 @@ +use std::path::{Path, PathBuf}; + +pub fn normalize_path_key(path: &Path) -> String { + let normalized = display_path(&canonical_or_original(path)) + .replace('\\', "/") + .trim_end_matches('/') + .to_string(); + if cfg!(windows) { + normalized.to_ascii_lowercase() + } else { + normalized + } +} + +pub fn canonical_or_original(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +pub fn display_path(path: &Path) -> String { + strip_windows_verbatim_prefix(&path.to_string_lossy()) +} + +pub fn strip_windows_verbatim_prefix(path: &str) -> String { + if let Some(rest) = path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + if let Some(rest) = path.strip_prefix(r"\\?\") { + return rest.to_string(); + } + path.to_string() +} diff --git a/src-tauri/src/analyzer/scan.rs b/src-tauri/src/analyzer/scan.rs new file mode 100644 index 0000000..dbc41df --- /dev/null +++ b/src-tauri/src/analyzer/scan.rs @@ -0,0 +1,352 @@ +use std::{env, fs::File, io::Read, path::Path}; + +use ignore::{DirEntry, WalkBuilder}; + +use super::{ + language::language_for_path, + models::{LanguageStats, LanguageType, LineCounts, LinguistResult}, +}; + +const MAX_LINE_COUNT_BYTES: u64 = 1_500_000; +const DEFAULT_MAX_SCAN_BYTES: u64 = 800 * 1024 * 1024; +const MAX_ALLOWED_SCAN_BYTES: u64 = 10 * 1024 * 1024 * 1024; // 10GB 上限 + +/// 单次语言分析/扫描允许处理的最大累计字节数。 +/// 可通过 `CODENEST_MAX_SCAN_BYTES` 环境变量调整(钳制在 10GB 内)。 +pub fn max_scan_bytes() -> u64 { + env::var("CODENEST_MAX_SCAN_BYTES") + .ok() + .and_then(|value| value.parse::().ok()) + .map(|value| value.min(MAX_ALLOWED_SCAN_BYTES)) + .unwrap_or(DEFAULT_MAX_SCAN_BYTES) +} + +pub fn analyze_folder(folder_path: &Path) -> Result { + if !folder_path.is_dir() { + return Err("Provided path is not a directory".to_string()); + } + + let byte_budget = max_scan_bytes(); + let mut result = LinguistResult::default(); + let walker = WalkBuilder::new(folder_path) + .hidden(true) + .ignore(true) + .git_ignore(true) + .parents(true) + .filter_entry(|entry| !is_skipped_dir(entry)) + .build(); + + for entry in walker { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + if !entry + .file_type() + .map(|file_type| file_type.is_file()) + .unwrap_or(false) + { + continue; + } + + let path = entry.path(); + if is_skipped_file(path) { + continue; + } + + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(_) => continue, + }; + let bytes = metadata.len(); + + // 累计体积熔断:超出预算时终止分析,避免对超大目录 + // (如误选磁盘根目录)做无界的 IO 和行数统计。 + if result.files.bytes.saturating_add(bytes) > byte_budget { + return Err(format!( + "Directory too large to analyze (over {} MB)", + byte_budget / (1024 * 1024) + )); + } + let language = language_for_path(path); + let lines = language + .filter(|language| should_count_lines(language.language_type, bytes)) + .and_then(|_| count_lines(path)) + .unwrap_or_default(); + let relative_path = relative_path(folder_path, path); + + result.files.count += 1; + result.files.bytes += bytes; + result.files.lines.add(&lines); + + if let Some(language) = language { + result + .files + .results + .insert(relative_path.clone(), Some(language.name.to_string())); + result.files.alternatives.insert(relative_path, Vec::new()); + + let entry = result + .languages + .results + .entry(language.name.to_string()) + .or_insert(LanguageStats { + bytes: 0, + lines: LineCounts::default(), + language_type: language.language_type, + parent: None, + color: language.color.map(ToOwned::to_owned), + }); + entry.bytes += bytes; + entry.lines.add(&lines); + } else { + result.files.results.insert(relative_path.clone(), None); + result.files.alternatives.insert(relative_path, Vec::new()); + result.unknown.count += 1; + result.unknown.bytes += bytes; + result.unknown.lines.add(&lines); + + if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { + *result + .unknown + .extensions + .entry(extension.to_ascii_lowercase()) + .or_default() += 1; + } else if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) { + *result + .unknown + .filenames + .entry(file_name.to_ascii_lowercase()) + .or_default() += 1; + } + } + } + + result.languages.count = result.languages.results.len() as u64; + result.languages.bytes = result + .languages + .results + .values() + .map(|lang| lang.bytes) + .sum(); + for language in result.languages.results.values() { + result.languages.lines.add(&language.lines); + } + + Ok(result) +} + +pub fn is_skipped_dir(entry: &DirEntry) -> bool { + if entry.depth() == 0 { + return false; + } + let Some(file_type) = entry.file_type() else { + return false; + }; + if !file_type.is_dir() { + return false; + } + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + matches!( + name.as_str(), + ".git" + | ".hg" + | ".svn" + | ".idea" + | ".vscode" + | ".cache" + | ".gradle" + | ".parcel-cache" + | ".pytest_cache" + | ".mypy_cache" + | ".ruff_cache" + | "node_modules" + | "vendor" + | "dist" + | "build" + | "out" + | "release" + | "debug" + | "coverage" + | "target" + | "__pycache__" + | "venv" + | ".venv" + | ".next" + | ".nuxt" + | ".svelte-kit" + | ".turbo" + ) +} + +fn is_skipped_file(path: &Path) -> bool { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + + if file_name.ends_with(".min.js") + || file_name.ends_with(".min.css") + || file_name.ends_with(".bundle.js") + || file_name.ends_with(".bundle.css") + { + return true; + } + + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + + matches!( + extension.as_str(), + "map" + | "png" + | "jpg" + | "jpeg" + | "gif" + | "webp" + | "avif" + | "bmp" + | "ico" + | "icns" + | "mp4" + | "mov" + | "webm" + | "mkv" + | "mp3" + | "wav" + | "flac" + | "ogg" + | "pdf" + | "zip" + | "rar" + | "7z" + | "gz" + | "tgz" + | "tar" + | "woff" + | "woff2" + | "ttf" + | "otf" + | "eot" + | "wasm" + | "exe" + | "dll" + | "so" + | "dylib" + | "class" + | "jar" + ) +} + +fn should_count_lines(language_type: LanguageType, bytes: u64) -> bool { + bytes <= MAX_LINE_COUNT_BYTES + && matches!( + language_type, + LanguageType::Programming | LanguageType::Markup | LanguageType::Prose + ) +} + +fn count_lines(path: &Path) -> Option { + let mut file = File::open(path).ok()?; + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0_u64; + let mut content = 0_u64; + let mut has_bytes = false; + let mut line_has_content = false; + let mut last_byte = None; + + loop { + let read = file.read(&mut buffer).ok()?; + if read == 0 { + break; + } + + for byte in &buffer[..read] { + if *byte == 0 { + return None; + } + + has_bytes = true; + last_byte = Some(*byte); + + if *byte == b'\n' { + total += 1; + if line_has_content { + content += 1; + } + line_has_content = false; + } else if *byte != b'\r' && !byte.is_ascii_whitespace() { + line_has_content = true; + } + } + } + + if has_bytes && last_byte != Some(b'\n') { + total += 1; + if line_has_content { + content += 1; + } + } + + Some(LineCounts { + total, + content, + code: content, + }) +} + +fn relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use std::{fs, sync::Mutex}; + + use super::analyze_folder; + + /// 串行化涉及 `CODENEST_MAX_SCAN_BYTES` 环境变量的测试,避免并行干扰。 + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn analyze_folder_rejects_directories_over_byte_budget() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = std::env::temp_dir().join("codenest-analyzer-budget-test"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("main.rs"), "fn main() {}\n".repeat(10)).unwrap(); + + // 预算缩到 1 字节,任何文件都会触发熔断 + std::env::set_var("CODENEST_MAX_SCAN_BYTES", "1"); + let result = analyze_folder(&dir); + std::env::remove_var("CODENEST_MAX_SCAN_BYTES"); + + let _ = fs::remove_dir_all(&dir); + match result { + Err(error) => assert!(error.contains("too large"), "unexpected error: {error}"), + Ok(_) => panic!("oversized directory should be rejected"), + } + } + + #[test] + fn analyze_folder_succeeds_within_budget() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = std::env::temp_dir().join("codenest-analyzer-ok-test"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("main.rs"), "fn main() {}\n").unwrap(); + + let result = analyze_folder(&dir); + + let _ = fs::remove_dir_all(&dir); + let result = result.expect("small directory should analyze fine"); + assert!(result.languages.results.contains_key("Rust")); + } +} diff --git a/src-tauri/src/command_line.rs b/src-tauri/src/command_line.rs index 3be254e..c76ef96 100644 --- a/src-tauri/src/command_line.rs +++ b/src-tauri/src/command_line.rs @@ -59,9 +59,9 @@ pub fn shell_quote(value: &str) -> String { #[cfg(not(target_os = "windows"))] { if !value.is_empty() - && value - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | '\\' | ':')) + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | '\\' | ':') + }) { value.to_string() } else { @@ -79,9 +79,9 @@ pub fn shell_quote(value: &str) -> String { #[cfg(target_os = "windows")] fn cmd_quote(value: &str) -> String { if !value.is_empty() - && value - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '\\' | '/')) + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '\\' | '/') + }) { value.to_string() } else { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b06273..be21808 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,14 +19,14 @@ pub fn run() { data::save_data, dialog::open_file_dialog, dialog::open_folder_dialog, - project::analyze_project, - project::delete_project, - project::detect_editor_command, - project::export_projects, - project::import_projects, - project::open_project, - project::open_remote_project, - project::read_project_license, + project::commands::analyze_project, + project::commands::delete_project, + project::editor::detect_editor_command, + project::commands::export_projects, + project::commands::import_projects, + project::editor::open_project, + project::editor::open_remote_project, + project::license::read_project_license, scanner::detect_cli_history_root_path, scanner::detect_jetbrains_config_root_path, scanner::detect_recent_editor_state_db_path, diff --git a/src-tauri/src/project.rs b/src-tauri/src/project.rs index db1a70b..0fa94f1 100644 --- a/src-tauri/src/project.rs +++ b/src-tauri/src/project.rs @@ -1,1037 +1,4 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, - process::Command, - sync::LazyLock, -}; - -use serde::Serialize; -use tauri::AppHandle; - -use crate::{ - analyzer::{self, LinguistResult}, - command_line::{parse_command_line, quote_command_path, shell_join, shell_quote}, - data, -}; - -const LICENSE_LINE_LIMIT: usize = 100; - -/// 受保护的系统目录根(缓存) -static PROTECTED_SYSTEM_ROOTS: LazyLock> = LazyLock::new(|| { - let mut roots = Vec::new(); - if cfg!(windows) { - for var in [ - "SystemRoot", - "ProgramFiles", - "ProgramFiles(x86)", - "ProgramW6432", - ] { - if let Some(value) = env::var_os(var) { - roots.push(normalize_for_protection_check(Path::new(&value))); - } - } - for fallback in [ - r"C:\Windows", - r"C:\Program Files", - r"C:\Program Files (x86)", - ] { - roots.push(normalize_for_protection_check(Path::new(fallback))); - } - } else { - for root in [ - "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/var", "/sys", "/proc", - "/boot", "/system", - ] { - roots.push(root.to_string()); - } - } - roots.sort(); - roots.dedup(); - roots -}); - -/// 去除 Windows canonicalize 产生的 verbatim 前缀(`\\?\` / `\\?\UNC\`)。 -fn strip_verbatim_prefix(path: &Path) -> PathBuf { - let text = path.to_string_lossy(); - if let Some(stripped) = text.strip_prefix(r"\\?\UNC\") { - PathBuf::from(format!(r"\\{stripped}")) - } else if let Some(stripped) = text.strip_prefix(r"\\?\") { - PathBuf::from(stripped) - } else { - path.to_path_buf() - } -} - -/// 将路径归一化为用于保护检查的形式:去 verbatim 前缀、统一斜杠、 -/// 去尾部斜杠,Windows 下转小写。 -fn normalize_for_protection_check(path: &Path) -> String { - let text = strip_verbatim_prefix(path) - .to_string_lossy() - .replace('\\', "/"); - let trimmed = text.trim_end_matches('/').to_string(); - if cfg!(windows) { - trimmed.to_lowercase() - } else { - trimmed - } -} - -/// 判断(已 canonicalize 的)路径是否位于受保护的系统目录内。 -/// 使用规范化后的路径进行匹配,避免大小写和路径格式差异。 -fn is_protected_system_path(canonical: &Path) -> bool { - let normalized = normalize_for_protection_check(canonical); - let roots = &*PROTECTED_SYSTEM_ROOTS; - - for root in roots { - // 精确匹配根目录或其子路径 - if normalized == *root || normalized.starts_with(&format!("{root}/")) { - return true; - } - } - - false -} - - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LicenseReadResult { - success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - filename: Option, - #[serde(skip_serializing_if = "Option::is_none")] - snippet: Option, - #[serde(skip_serializing_if = "Option::is_none")] - lines: Option, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, -} - -#[derive(Serialize)] -pub struct ProjectMutationResult { - success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[tauri::command] -pub async fn analyze_project(folder_path: String) -> Result { - let path = Path::new(&folder_path); - - if !path.is_absolute() { - return Err("Path must be absolute".to_string()); - } - - let canonical_path = path - .canonicalize() - .map_err(|_| "Cannot access path".to_string())?; - - if is_protected_system_path(&canonical_path) { - return Err("Cannot analyze system directory".to_string()); - } - - tauri::async_runtime::spawn_blocking(move || analyzer::analyze_folder(Path::new(&folder_path))) - .await - .map_err(|error| error.to_string())? -} - -#[tauri::command] -pub fn read_project_license(folder_path: String, max_lines: Option) -> LicenseReadResult { - let root = Path::new(&folder_path); - - if !root.is_absolute() { - return LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some("Path must be absolute".to_string()), - }; - } - - let canonical_path = match root.canonicalize() { - Ok(path) => path, - Err(_) => { - return LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some("Cannot access path".to_string()), - }; - } - }; - - if is_protected_system_path(&canonical_path) { - return LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some("Cannot read from system directory".to_string()), - }; - } - - if !root.is_dir() { - return LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some("Project path does not exist or is not a directory".to_string()), - }; - } - - let entries = match fs::read_dir(root) { - Ok(entries) => entries, - Err(error) => { - return LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some(error.to_string()), - }; - } - }; - - let mut candidates = entries - .flatten() - .filter_map(|entry| { - let path = entry.path(); - let file_name = path.file_name()?.to_string_lossy().into_owned(); - let lower = file_name.to_ascii_lowercase(); - (lower.starts_with("license") - || lower.starts_with("licence") - || lower.starts_with("copying") - || lower.starts_with("unlicense")) - .then_some((file_name, path)) - }) - .collect::>(); - - if candidates.is_empty() { - return LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some("License file not found".to_string()), - }; - } - - candidates.sort_by_key(|(file_name, _)| license_rank(file_name)); - let (file_name, path) = candidates.remove(0); - if !path.is_file() { - return LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some("License file is not a regular file".to_string()), - }; - } - - // 检查文件大小,限制最大 1MB - const MAX_LICENSE_SIZE: u64 = 1024 * 1024; - match fs::metadata(&path) { - Ok(metadata) if metadata.len() > MAX_LICENSE_SIZE => { - return LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some(format!("License file too large: {} bytes", metadata.len())), - }; - } - Err(error) => { - return LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some(error.to_string()), - }; - } - _ => {} - } - - match fs::read_to_string(path) { - Ok(content) => { - let limit = max_lines.unwrap_or(20).clamp(1, LICENSE_LINE_LIMIT); - let lines = content.lines().take(limit).collect::>(); - LicenseReadResult { - success: true, - filename: Some(file_name), - snippet: Some(lines.join("\n")), - lines: Some(lines.len()), - message: None, - } - } - Err(error) => LicenseReadResult { - success: false, - filename: None, - snippet: None, - lines: None, - message: Some(error.to_string()), - }, - } -} - -#[tauri::command] -pub fn open_project( - editor_command: String, - project_path: String, - open_in_terminal: Option, -) -> Result { - if editor_command.trim().is_empty() || project_path.is_empty() { - return Err("Editor command and project path cannot be empty".to_string()); - } - - let project = PathBuf::from(&project_path); - if !project.is_absolute() { - return Err("Project path must be absolute".to_string()); - } - - if !project.exists() { - return Err("Project path does not exist".to_string()); - } - - spawn_editor_command(&editor_command, &project, open_in_terminal.unwrap_or(false))?; - Ok("Project opened".to_string()) -} - -/// 打开远程 SSH 项目:VS Code 系走 Remote-SSH,终端走 ssh -t。 -/// -/// 远程项目没有本地路径,故不做 `is_absolute()/exists()` 校验; -/// 认证/端口/密钥由用户的 ~/.ssh/config 与 ssh 负责。 -#[tauri::command] -pub fn open_remote_project( - host: String, - remote_path: String, - editor_command: String, - mode: String, -) -> Result { - let host = host.trim(); - let remote_path = remote_path.trim(); - if host.is_empty() || remote_path.is_empty() { - return Err("Remote host and path cannot be empty".to_string()); - } - - match mode.as_str() { - "terminal" => spawn_remote_terminal(host, remote_path)?, - "vscode" => { - if editor_command.trim().is_empty() { - return Err("Editor command cannot be empty".to_string()); - } - // 取已配置命令的程序名 token(如 "code"),其余 {project} 之类占位符丢弃 - let args = parse_command_line(&editor_command)?; - let program = PathBuf::from(&args[0]); - let remote_args = vec![ - "--remote".to_string(), - format!("ssh-remote+{host}"), - remote_path.to_string(), - ]; - spawn_editor(&program, &remote_args)?; - } - other => return Err(format!("Unknown remote open mode: {other}")), - } - - Ok("Remote project opened".to_string()) -} - -#[tauri::command] -pub fn detect_editor_command(editor: String) -> Option { - detect_editor_command_template(&editor) -} - -#[tauri::command] -pub fn delete_project(project_path: String) -> ProjectMutationResult { - let path = PathBuf::from(&project_path); - if !path.exists() { - return ProjectMutationResult { - success: false, - message: None, - error: Some("Project path does not exist".to_string()), - }; - } - - if !path.is_absolute() { - return ProjectMutationResult { - success: false, - message: None, - error: Some("Path must be absolute".to_string()), - }; - } - - let canonical_path = match path.canonicalize() { - Ok(p) => p, - Err(_) => { - return ProjectMutationResult { - success: false, - message: None, - error: Some("Cannot access path".to_string()), - }; - } - }; - - if is_protected_system_path(&canonical_path) { - return ProjectMutationResult { - success: false, - message: None, - error: Some("Cannot delete system directory".to_string()), - }; - } - - if trash::delete(&path).is_ok() { - return ProjectMutationResult { - success: true, - message: Some("Project moved to trash".to_string()), - error: None, - }; - } - - let result = if path.is_dir() { - fs::remove_dir_all(&path) - } else { - fs::remove_file(&path) - }; - - match result { - Ok(_) => ProjectMutationResult { - success: true, - message: Some("Project deleted".to_string()), - error: None, - }, - Err(error) => ProjectMutationResult { - success: false, - message: None, - error: Some(error.to_string()), - }, - } -} - -#[tauri::command] -pub fn import_projects(app: AppHandle) -> ProjectMutationResult { - let Some(imported_path) = rfd::FileDialog::new() - .add_filter("JSON Files", &["json"]) - .pick_file() - else { - return ProjectMutationResult { - success: false, - message: Some("No file selected for import".to_string()), - error: None, - }; - }; - - let target = match data::projects_file_path(&app) { - Ok(path) => path, - Err(error) => { - return ProjectMutationResult { - success: false, - message: None, - error: Some(error), - }; - } - }; - if let Some(parent) = target.parent() { - if let Err(error) = fs::create_dir_all(parent) { - return ProjectMutationResult { - success: false, - message: None, - error: Some(error.to_string()), - }; - } - } - - match fs::copy(imported_path, target) { - Ok(_) => ProjectMutationResult { - success: true, - message: Some("Data file imported successfully".to_string()), - error: None, - }, - Err(error) => ProjectMutationResult { - success: false, - message: None, - error: Some(error.to_string()), - }, - } -} - -#[tauri::command] -pub fn export_projects(app: AppHandle) -> ProjectMutationResult { - let Some(exported_path) = rfd::FileDialog::new() - .set_file_name("projects.json") - .add_filter("JSON Files", &["json"]) - .save_file() - else { - return ProjectMutationResult { - success: false, - message: Some("No file path selected for export".to_string()), - error: None, - }; - }; - - let source = match data::projects_file_path(&app) { - Ok(path) => path, - Err(error) => { - return ProjectMutationResult { - success: false, - message: None, - error: Some(error), - }; - } - }; - - match fs::copy(source, exported_path) { - Ok(_) => ProjectMutationResult { - success: true, - message: Some("Data file exported successfully".to_string()), - error: None, - }, - Err(error) => ProjectMutationResult { - success: false, - message: None, - error: Some(error.to_string()), - }, - } -} - -fn spawn_editor_command( - editor_command: &str, - project: &Path, - open_in_terminal: bool, -) -> Result<(), String> { - let has_project_placeholder = editor_command.contains("{project}"); - let mut args = parse_command_line(editor_command)?; - - let project_text = project.to_string_lossy().into_owned(); - for arg in &mut args { - // 占位符替换发生在 parse_command_line 之后:GUI 路径下参数直接传给 - // Command::args 不经过 shell;终端路径下由 shell_join/shell_quote 统一转义。 - *arg = arg - .replace("{project}", &project_text) - .replace("{cwd}", &project_text); - } - - if !open_in_terminal && !has_project_placeholder { - args.push(project_text); - } - - if open_in_terminal { - return spawn_terminal_command(&args, project); - } - - let program = PathBuf::from(&args[0]); - spawn_editor(&program, &args[1..]) -} - -fn spawn_editor(program: &Path, args: &[String]) -> Result<(), String> { - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - // CREATE_NO_WINDOW 抑制控制台窗口:编辑器启动器常是 code.cmd 之类的批处理, - // 默认会让 Windows 给它分配一个控制台窗口(表现为弹出终端)。 - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - - let extension = program - .extension() - .and_then(|extension| extension.to_str()) - .map(str::to_ascii_lowercase); - - if matches!(extension.as_deref(), Some("bat" | "cmd")) { - // .cmd/.bat 不是 PE 可执行文件,必须经 cmd 运行(直接 CreateProcess 会报 - // “不是有效的 Win32 应用程序”)。cmd /C 会去除整条命令行的首尾引号,因此把 - // “程序 + 参数”整体再包一层引号;参数(路径 / --remote / ssh-remote+host) - // 不含双引号,逐个加引号即可安全。 - let mut line = String::new(); - line.push('"'); // 外层起始引号 - line.push('"'); - line.push_str(&program.to_string_lossy()); - line.push('"'); // 程序路径引号 - for arg in args { - line.push_str(" \""); - line.push_str(arg); - line.push('"'); - } - line.push('"'); // 外层结束引号 - - return Command::new("cmd") - .raw_arg("/C") - .raw_arg(&line) - .creation_flags(CREATE_NO_WINDOW) - .spawn() - .map(|_| ()) - .map_err(|error| format!("Failed to launch {}: {error}", program.display())); - } - - return Command::new(program) - .args(args) - .creation_flags(CREATE_NO_WINDOW) - .spawn() - .map(|_| ()) - .map_err(|error| format!("Failed to launch {}: {error}", program.display())); - } - - #[cfg(not(windows))] - { - Command::new(program) - .args(args) - .spawn() - .map(|_| ()) - .map_err(|error| format!("Failed to launch {}: {error}", program.display())) - } -} - -fn spawn_terminal_command(args: &[String], project: &Path) -> Result<(), String> { - let shell_command = shell_join(args); - - if cfg!(target_os = "macos") { - // macOS 的 Terminal.app 通过 osascript 启动,不继承 current_dir, - // 因此把 cd 前缀嵌入命令字符串。 - let project_str = project.to_string_lossy(); - let full_command = format!("cd {} ; {}", shell_quote(&project_str), shell_command); - spawn_terminal_raw(&full_command, None) - } else { - spawn_terminal_raw(&shell_command, Some(project)) - } -} - -/// 在系统终端窗口中运行一条 shell 命令。 -/// -/// `cwd` 仅用于 Windows/Linux 的 `current_dir`;macOS 不继承 current_dir, -/// 调用方需把 `cd` 嵌入命令字符串(远程场景由 ssh 自身 cd,无需本地 cwd)。 -fn spawn_terminal_raw(shell_command: &str, cwd: Option<&Path>) -> Result<(), String> { - if cfg!(windows) { - let mut command = Command::new("cmd"); - if let Some(dir) = cwd { - command.current_dir(dir); - } - command - .args(["/C", "start", "", "cmd", "/K", shell_command]) - .spawn() - .map(|_| ()) - .map_err(|error| error.to_string()) - } else if cfg!(target_os = "macos") { - // AppleScript 字符串需要转义反斜杠和双引号 - let escaped = shell_command.replace('\\', "\\\\").replace('"', "\\\""); - let script = format!("tell application \"Terminal\" to do script \"{}\"", escaped); - - Command::new("osascript") - .args(["-e", &script]) - .spawn() - .map(|_| ()) - .map_err(|error| error.to_string()) - } else { - let mut primary = Command::new("x-terminal-emulator"); - if let Some(dir) = cwd { - primary.current_dir(dir); - } - primary - .args(["-e", "sh", "-lc", shell_command]) - .spawn() - .or_else(|_| { - let mut fallback = Command::new("gnome-terminal"); - if let Some(dir) = cwd { - fallback.current_dir(dir); - } - fallback.args(["--", "sh", "-lc", shell_command]).spawn() - }) - .map(|_| ()) - .map_err(|error| error.to_string()) - } -} - -/// 以 POSIX 单引号包裹远程路径(始终面向远程的 POSIX shell, -/// 不能用本地平台相关的 shell_quote)。 -fn posix_single_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - -/// 打开终端并通过 ssh 进入远程目录。 -fn spawn_remote_terminal(host: &str, remote_path: &str) -> Result<(), String> { - // 远端命令:cd 后保留交互 shell。$SHELL 由远端展开,故路径用 POSIX 单引号保护。 - let inner = format!("cd {} ; exec $SHELL -l", posix_single_quote(remote_path)); - - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - // 用 raw_arg 精确拼出命令行:Rust 默认的 MSVCRT 转义(\")与 cmd 的解析规则冲突, - // 会把 host 连同引号一起塞给 ssh,导致 “hostname contains invalid characters”。 - // host 不含空格,直接传;远端命令整体用双引号包成 ssh 的单个参数。 - return Command::new("cmd") - .raw_arg("/C") - .raw_arg("start") - .raw_arg("\"\"") - .raw_arg("cmd") - .raw_arg("/K") - .raw_arg("ssh") - .raw_arg("-t") - .raw_arg(host) - .raw_arg(format!("\"{inner}\"")) - .spawn() - .map(|_| ()) - .map_err(|error| error.to_string()); - } - - #[cfg(not(windows))] - { - let args = vec!["ssh".to_string(), "-t".to_string(), host.to_string(), inner]; - let shell_command = shell_join(&args); - spawn_terminal_raw(&shell_command, None) - } -} - -fn detect_editor_command_template(editor: &str) -> Option { - let (commands, common_paths, terminal_default) = editor_detection_candidates(editor)?; - for command in commands { - if let Some(path) = find_command_on_path(command) { - return Some(format_detected_command(&path, editor, terminal_default)); - } - } - for path in common_paths { - if path.exists() { - return Some(format_detected_command(&path, editor, terminal_default)); - } - } - find_editor_in_common_directories(editor) - .map(|path| format_detected_command(&path, editor, terminal_default)) -} - -fn editor_detection_candidates( - editor: &str, -) -> Option<(&'static [&'static str], Vec, bool)> { - let mut paths = Vec::new(); - if cfg!(windows) { - let local_app_data = env::var_os("LOCALAPPDATA").map(PathBuf::from); - let program_files = env::var_os("ProgramFiles").map(PathBuf::from); - let program_files_x86 = env::var_os("ProgramFiles(x86)").map(PathBuf::from); - - if let Some((_, _, toolbox_script)) = jetbrains_editor_profile(editor) { - push_join( - &mut paths, - &local_app_data, - &format!("JetBrains/Toolbox/scripts/{toolbox_script}.cmd"), - ); - } - - match editor { - "visual-studio-code" => { - push_join( - &mut paths, - &local_app_data, - "Programs/Microsoft VS Code/Code.exe", - ); - push_join(&mut paths, &program_files, "Microsoft VS Code/Code.exe"); - } - "cursor" => { - push_join(&mut paths, &local_app_data, "Programs/Cursor/Cursor.exe"); - } - "windsurf" => { - push_join( - &mut paths, - &local_app_data, - "Programs/Windsurf/Windsurf.exe", - ); - } - "trae" => { - push_join(&mut paths, &local_app_data, "Programs/Trae/Trae.exe"); - } - "zed" => { - push_join(&mut paths, &local_app_data, "Programs/Zed/Zed.exe"); - } - "sublime-text" => { - push_join(&mut paths, &program_files, "Sublime Text/sublime_text.exe"); - } - "android-studio" => { - push_join( - &mut paths, - &program_files, - "Android/Android Studio/bin/studio64.exe", - ); - } - "visual-studio" => { - for edition in ["Community", "Professional", "Enterprise"] { - push_join( - &mut paths, - &program_files, - &format!("Microsoft Visual Studio/2022/{edition}/Common7/IDE/devenv.exe"), - ); - push_join( - &mut paths, - &program_files_x86, - &format!("Microsoft Visual Studio/2022/{edition}/Common7/IDE/devenv.exe"), - ); - } - } - _ => {} - } - } - - let result = if let Some((commands, _, _)) = jetbrains_editor_profile(editor) { - (commands, false) - } else { - match editor { - "visual-studio" => (&["devenv"][..], false), - "visual-studio-code" => (&["code"][..], false), - "android-studio" => (&["studio", "studio64"][..], false), - "cursor" => (&["cursor"][..], false), - "windsurf" => (&["windsurf"][..], false), - "trae" => (&["trae"][..], false), - "zed" => (&["zed"][..], false), - "sublime-text" => (&["subl", "sublime_text"][..], false), - "neovim" => (&["nvim"][..], true), - "codex-cli" => (&["codex"][..], true), - "claude-code" => (&["claude"][..], true), - "gemini-cli" => (&["gemini"][..], true), - _ => return None, - } - }; - - Some((result.0, paths, result.1)) -} - -fn push_join(paths: &mut Vec, base: &Option, child: &str) { - if let Some(base) = base { - paths.push(base.join(child)); - } -} - -fn jetbrains_editor_profile( - editor: &str, -) -> Option<( - &'static [&'static str], - &'static [&'static str], - &'static str, -)> { - match editor { - "intellij-idea" => Some(( - &["idea", "idea64"][..], - &["idea64.exe", "idea.exe"][..], - "idea", - )), - "pycharm" => Some(( - &["pycharm"][..], - &["pycharm64.exe", "pycharm.exe"][..], - "pycharm", - )), - "phpstorm" => Some(( - &["phpstorm"][..], - &["phpstorm64.exe", "phpstorm.exe"][..], - "phpstorm", - )), - "goLand" => Some(( - &["goland"][..], - &["goland64.exe", "goland.exe"][..], - "goland", - )), - "rider" => Some((&["rider"][..], &["rider64.exe", "rider.exe"][..], "rider")), - "clion" => Some((&["clion"][..], &["clion64.exe", "clion.exe"][..], "clion")), - "rust-rover" => Some(( - &["rustrover"][..], - &["rustrover64.exe", "rustrover.exe"][..], - "rustrover", - )), - "webstorm" => Some(( - &["webstorm"][..], - &["webstorm64.exe", "webstorm.exe"][..], - "webstorm", - )), - "rubymine" => Some(( - &["rubymine"][..], - &["rubymine64.exe", "rubymine.exe"][..], - "rubymine", - )), - _ => None, - } -} - -fn find_command_on_path(command: &str) -> Option { - let path_var = env::var_os("PATH")?; - let extensions = if cfg!(windows) { - env::var("PATHEXT") - .unwrap_or_else(|_| ".EXE;.CMD;.BAT".to_string()) - .split(';') - .map(str::to_ascii_lowercase) - .collect::>() - } else { - vec![String::new()] - }; - - for dir in env::split_paths(&path_var) { - if cfg!(windows) { - for extension in &extensions { - let candidate = dir.join(format!("{command}{extension}")); - if candidate.is_file() { - return Some(candidate); - } - } - } else { - let candidate = dir.join(command); - if candidate.is_file() { - return Some(candidate); - } - } - } - None -} - -fn find_editor_in_common_directories(editor: &str) -> Option { - if !cfg!(windows) { - return None; - } - - let (_, names, _) = jetbrains_editor_profile(editor)?; - - let roots = [ - env::var_os("LOCALAPPDATA") - .map(PathBuf::from) - .map(|path| path.join("Programs")), - env::var_os("ProgramFiles").map(PathBuf::from), - env::var_os("ProgramFiles(x86)").map(PathBuf::from), - ]; - - for root in roots.into_iter().flatten() { - if let Some(path) = find_file_with_names(&root, names, 4) { - return Some(path); - } - } - - None -} - -fn find_file_with_names(root: &Path, names: &[&str], depth: usize) -> Option { - if depth == 0 || !root.is_dir() { - return None; - } - - let entries = fs::read_dir(root).ok()?; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() { - let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); - if names.iter().any(|name| file_name == *name) { - return Some(path); - } - } else if path.is_dir() { - let dir_name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); - let should_search = dir_name.contains("jetbrains") - || dir_name.contains("intellij") - || dir_name.contains("pycharm") - || dir_name.contains("webstorm") - || dir_name.contains("goland") - || dir_name.contains("rider") - || dir_name.contains("clion") - || dir_name.contains("rustrover") - || dir_name.contains("phpstorm") - || dir_name.contains("rubymine") - || dir_name == "bin"; - if should_search { - if let Some(found) = find_file_with_names(&path, names, depth - 1) { - return Some(found); - } - } - } - } - - None -} - -fn format_detected_command(path: &Path, editor: &str, terminal_default: bool) -> String { - let command = quote_command_path(path); - if terminal_default { - if matches!(editor, "neovim") { - format!("{command} .") - } else { - command - } - } else { - format!("{command} {{project}}") - } -} - -fn license_rank(file_name: &str) -> u8 { - let lower = file_name.to_ascii_lowercase(); - let path = Path::new(&lower); - let stem = path - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or(&lower); - let extension = path - .extension() - .and_then(|extension| extension.to_str()) - .unwrap_or(""); - - let base_score = if stem.starts_with("license") { - 0 - } else if stem.starts_with("licence") { - 1 - } else if stem.starts_with("copying") { - 2 - } else if stem.starts_with("unlicense") { - 3 - } else { - 9 - }; - let extension_score = match extension { - "" => 0, - "md" => 1, - "txt" => 2, - _ => 3, - }; - - base_score * 10 + extension_score -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::{is_protected_system_path, strip_verbatim_prefix}; - - #[test] - #[cfg(windows)] - fn strip_verbatim_prefix_removes_windows_prefix() { - assert_eq!( - strip_verbatim_prefix(Path::new(r"\\?\C:\Windows")), - Path::new(r"C:\Windows").to_path_buf() - ); - assert_eq!( - strip_verbatim_prefix(Path::new(r"\\?\UNC\server\share")), - Path::new(r"\\server\share").to_path_buf() - ); - } - - #[test] - #[cfg(windows)] - fn protected_path_check_matches_system_roots() { - assert!(is_protected_system_path(Path::new(r"\\?\C:\Windows"))); - assert!(is_protected_system_path(Path::new( - r"\\?\C:\Windows\System32" - ))); - assert!(is_protected_system_path(Path::new( - r"C:\Program Files\Some App" - ))); - assert!(is_protected_system_path(Path::new( - r"C:\Program Files (x86)\Some App" - ))); - } - - #[test] - #[cfg(windows)] - fn protected_path_check_does_not_overmatch_siblings() { - assert!(!is_protected_system_path(Path::new(r"C:\Windows-backup"))); - assert!(!is_protected_system_path(Path::new(r"C:\Program Filesx"))); - assert!(!is_protected_system_path(Path::new(r"E:\Projects\demo"))); - } - - #[test] - #[cfg(not(windows))] - fn protected_path_check_matches_unix_roots() { - assert!(is_protected_system_path(Path::new("/etc"))); - assert!(is_protected_system_path(Path::new("/usr/bin"))); - assert!(!is_protected_system_path(Path::new("/home/user/projects"))); - assert!(!is_protected_system_path(Path::new("/etcetera"))); - } -} +pub(crate) mod commands; +pub(crate) mod editor; +pub(crate) mod license; +mod path_safety; diff --git a/src-tauri/src/project/commands.rs b/src-tauri/src/project/commands.rs new file mode 100644 index 0000000..6e48621 --- /dev/null +++ b/src-tauri/src/project/commands.rs @@ -0,0 +1,196 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use serde::Serialize; +use tauri::AppHandle; + +use crate::{ + analyzer::{self, LinguistResult}, + data, +}; + +use super::path_safety::is_protected_system_path; + +#[derive(Serialize)] +pub struct ProjectMutationResult { + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[tauri::command] +pub async fn analyze_project(folder_path: String) -> Result { + let path = Path::new(&folder_path); + + if !path.is_absolute() { + return Err("Path must be absolute".to_string()); + } + + let canonical_path = path + .canonicalize() + .map_err(|_| "Cannot access path".to_string())?; + + if is_protected_system_path(&canonical_path) { + return Err("Cannot analyze system directory".to_string()); + } + + tauri::async_runtime::spawn_blocking(move || analyzer::analyze_folder(Path::new(&folder_path))) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub fn delete_project(project_path: String) -> ProjectMutationResult { + let path = PathBuf::from(&project_path); + if !path.exists() { + return ProjectMutationResult { + success: false, + message: None, + error: Some("Project path does not exist".to_string()), + }; + } + + if !path.is_absolute() { + return ProjectMutationResult { + success: false, + message: None, + error: Some("Path must be absolute".to_string()), + }; + } + + let canonical_path = match path.canonicalize() { + Ok(p) => p, + Err(_) => { + return ProjectMutationResult { + success: false, + message: None, + error: Some("Cannot access path".to_string()), + }; + } + }; + + if is_protected_system_path(&canonical_path) { + return ProjectMutationResult { + success: false, + message: None, + error: Some("Cannot delete system directory".to_string()), + }; + } + + if trash::delete(&path).is_ok() { + return ProjectMutationResult { + success: true, + message: Some("Project moved to trash".to_string()), + error: None, + }; + } + + let result = if path.is_dir() { + fs::remove_dir_all(&path) + } else { + fs::remove_file(&path) + }; + + match result { + Ok(_) => ProjectMutationResult { + success: true, + message: Some("Project deleted".to_string()), + error: None, + }, + Err(error) => ProjectMutationResult { + success: false, + message: None, + error: Some(error.to_string()), + }, + } +} + +#[tauri::command] +pub fn import_projects(app: AppHandle) -> ProjectMutationResult { + let Some(imported_path) = rfd::FileDialog::new() + .add_filter("JSON Files", &["json"]) + .pick_file() + else { + return ProjectMutationResult { + success: false, + message: Some("No file selected for import".to_string()), + error: None, + }; + }; + + let target = match data::projects_file_path(&app) { + Ok(path) => path, + Err(error) => { + return ProjectMutationResult { + success: false, + message: None, + error: Some(error), + }; + } + }; + if let Some(parent) = target.parent() { + if let Err(error) = fs::create_dir_all(parent) { + return ProjectMutationResult { + success: false, + message: None, + error: Some(error.to_string()), + }; + } + } + + match fs::copy(imported_path, target) { + Ok(_) => ProjectMutationResult { + success: true, + message: Some("Data file imported successfully".to_string()), + error: None, + }, + Err(error) => ProjectMutationResult { + success: false, + message: None, + error: Some(error.to_string()), + }, + } +} + +#[tauri::command] +pub fn export_projects(app: AppHandle) -> ProjectMutationResult { + let Some(exported_path) = rfd::FileDialog::new() + .set_file_name("projects.json") + .add_filter("JSON Files", &["json"]) + .save_file() + else { + return ProjectMutationResult { + success: false, + message: Some("No file path selected for export".to_string()), + error: None, + }; + }; + + let source = match data::projects_file_path(&app) { + Ok(path) => path, + Err(error) => { + return ProjectMutationResult { + success: false, + message: None, + error: Some(error), + }; + } + }; + + match fs::copy(source, exported_path) { + Ok(_) => ProjectMutationResult { + success: true, + message: Some("Data file exported successfully".to_string()), + error: None, + }, + Err(error) => ProjectMutationResult { + success: false, + message: None, + error: Some(error.to_string()), + }, + } +} diff --git a/src-tauri/src/project/editor.rs b/src-tauri/src/project/editor.rs new file mode 100644 index 0000000..2ff8bc1 --- /dev/null +++ b/src-tauri/src/project/editor.rs @@ -0,0 +1,47 @@ +mod detect; +mod launch; + +use std::path::PathBuf; + +#[tauri::command] +pub fn open_project( + editor_command: String, + project_path: String, + open_in_terminal: Option, +) -> Result { + if editor_command.trim().is_empty() || project_path.is_empty() { + return Err("Editor command and project path cannot be empty".to_string()); + } + + let project = PathBuf::from(&project_path); + if !project.is_absolute() { + return Err("Project path must be absolute".to_string()); + } + + if !project.exists() { + return Err("Project path does not exist".to_string()); + } + + launch::open_project(&editor_command, &project, open_in_terminal.unwrap_or(false))?; + Ok("Project opened".to_string()) +} + +/// 打开远程 SSH 项目:VS Code 系走 Remote-SSH,终端走 ssh -t。 +/// +/// 远程项目没有本地路径,故不做 `is_absolute()/exists()` 校验; +/// 认证/端口/密钥由用户的 ~/.ssh/config 与 ssh 负责。 +#[tauri::command] +pub fn open_remote_project( + host: String, + remote_path: String, + editor_command: String, + mode: String, +) -> Result { + launch::open_remote_project(&host, &remote_path, &editor_command, &mode)?; + Ok("Remote project opened".to_string()) +} + +#[tauri::command] +pub fn detect_editor_command(editor: String) -> Option { + detect::detect_editor_command_template(&editor) +} diff --git a/src-tauri/src/project/editor/detect.rs b/src-tauri/src/project/editor/detect.rs new file mode 100644 index 0000000..059d589 --- /dev/null +++ b/src-tauri/src/project/editor/detect.rs @@ -0,0 +1,274 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +use crate::command_line::quote_command_path; + +pub(super) fn detect_editor_command_template(editor: &str) -> Option { + let (commands, common_paths, terminal_default) = editor_detection_candidates(editor)?; + for command in commands { + if let Some(path) = find_command_on_path(command) { + return Some(format_detected_command(&path, editor, terminal_default)); + } + } + for path in common_paths { + if path.exists() { + return Some(format_detected_command(&path, editor, terminal_default)); + } + } + find_editor_in_common_directories(editor) + .map(|path| format_detected_command(&path, editor, terminal_default)) +} + +fn editor_detection_candidates( + editor: &str, +) -> Option<(&'static [&'static str], Vec, bool)> { + let mut paths = Vec::new(); + if cfg!(windows) { + let local_app_data = env::var_os("LOCALAPPDATA").map(PathBuf::from); + let program_files = env::var_os("ProgramFiles").map(PathBuf::from); + let program_files_x86 = env::var_os("ProgramFiles(x86)").map(PathBuf::from); + + if let Some((_, _, toolbox_script)) = jetbrains_editor_profile(editor) { + push_join( + &mut paths, + &local_app_data, + &format!("JetBrains/Toolbox/scripts/{toolbox_script}.cmd"), + ); + } + + match editor { + "visual-studio-code" => { + push_join( + &mut paths, + &local_app_data, + "Programs/Microsoft VS Code/Code.exe", + ); + push_join(&mut paths, &program_files, "Microsoft VS Code/Code.exe"); + } + "cursor" => { + push_join(&mut paths, &local_app_data, "Programs/Cursor/Cursor.exe"); + } + "windsurf" => { + push_join( + &mut paths, + &local_app_data, + "Programs/Windsurf/Windsurf.exe", + ); + } + "trae" => { + push_join(&mut paths, &local_app_data, "Programs/Trae/Trae.exe"); + } + "zed" => { + push_join(&mut paths, &local_app_data, "Programs/Zed/Zed.exe"); + } + "sublime-text" => { + push_join(&mut paths, &program_files, "Sublime Text/sublime_text.exe"); + } + "android-studio" => { + push_join( + &mut paths, + &program_files, + "Android/Android Studio/bin/studio64.exe", + ); + } + "visual-studio" => { + for edition in ["Community", "Professional", "Enterprise"] { + push_join( + &mut paths, + &program_files, + &format!("Microsoft Visual Studio/2022/{edition}/Common7/IDE/devenv.exe"), + ); + push_join( + &mut paths, + &program_files_x86, + &format!("Microsoft Visual Studio/2022/{edition}/Common7/IDE/devenv.exe"), + ); + } + } + _ => {} + } + } + + let result = if let Some((commands, _, _)) = jetbrains_editor_profile(editor) { + (commands, false) + } else { + match editor { + "visual-studio" => (&["devenv"][..], false), + "visual-studio-code" => (&["code"][..], false), + "android-studio" => (&["studio", "studio64"][..], false), + "cursor" => (&["cursor"][..], false), + "windsurf" => (&["windsurf"][..], false), + "trae" => (&["trae"][..], false), + "zed" => (&["zed"][..], false), + "sublime-text" => (&["subl", "sublime_text"][..], false), + "neovim" => (&["nvim"][..], true), + "codex-cli" => (&["codex"][..], true), + "claude-code" => (&["claude"][..], true), + "gemini-cli" => (&["gemini"][..], true), + _ => return None, + } + }; + + Some((result.0, paths, result.1)) +} + +fn push_join(paths: &mut Vec, base: &Option, child: &str) { + if let Some(base) = base { + paths.push(base.join(child)); + } +} + +fn jetbrains_editor_profile( + editor: &str, +) -> Option<( + &'static [&'static str], + &'static [&'static str], + &'static str, +)> { + match editor { + "intellij-idea" => Some(( + &["idea", "idea64"][..], + &["idea64.exe", "idea.exe"][..], + "idea", + )), + "pycharm" => Some(( + &["pycharm"][..], + &["pycharm64.exe", "pycharm.exe"][..], + "pycharm", + )), + "phpstorm" => Some(( + &["phpstorm"][..], + &["phpstorm64.exe", "phpstorm.exe"][..], + "phpstorm", + )), + "goLand" => Some(( + &["goland"][..], + &["goland64.exe", "goland.exe"][..], + "goland", + )), + "rider" => Some((&["rider"][..], &["rider64.exe", "rider.exe"][..], "rider")), + "clion" => Some((&["clion"][..], &["clion64.exe", "clion.exe"][..], "clion")), + "rust-rover" => Some(( + &["rustrover"][..], + &["rustrover64.exe", "rustrover.exe"][..], + "rustrover", + )), + "webstorm" => Some(( + &["webstorm"][..], + &["webstorm64.exe", "webstorm.exe"][..], + "webstorm", + )), + "rubymine" => Some(( + &["rubymine"][..], + &["rubymine64.exe", "rubymine.exe"][..], + "rubymine", + )), + _ => None, + } +} + +fn find_command_on_path(command: &str) -> Option { + let path_var = env::var_os("PATH")?; + let extensions = if cfg!(windows) { + env::var("PATHEXT") + .unwrap_or_else(|_| ".EXE;.CMD;.BAT".to_string()) + .split(';') + .map(str::to_ascii_lowercase) + .collect::>() + } else { + vec![String::new()] + }; + + for dir in env::split_paths(&path_var) { + if cfg!(windows) { + for extension in &extensions { + let candidate = dir.join(format!("{command}{extension}")); + if candidate.is_file() { + return Some(candidate); + } + } + } else { + let candidate = dir.join(command); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +fn find_editor_in_common_directories(editor: &str) -> Option { + if !cfg!(windows) { + return None; + } + + let (_, names, _) = jetbrains_editor_profile(editor)?; + + let roots = [ + env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Programs")), + env::var_os("ProgramFiles").map(PathBuf::from), + env::var_os("ProgramFiles(x86)").map(PathBuf::from), + ]; + + for root in roots.into_iter().flatten() { + if let Some(path) = find_file_with_names(&root, names, 4) { + return Some(path); + } + } + + None +} + +fn find_file_with_names(root: &Path, names: &[&str], depth: usize) -> Option { + if depth == 0 || !root.is_dir() { + return None; + } + + let entries = fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + let file_name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); + if names.iter().any(|name| file_name == *name) { + return Some(path); + } + } else if path.is_dir() { + let dir_name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); + let should_search = dir_name.contains("jetbrains") + || dir_name.contains("intellij") + || dir_name.contains("pycharm") + || dir_name.contains("webstorm") + || dir_name.contains("goland") + || dir_name.contains("rider") + || dir_name.contains("clion") + || dir_name.contains("rustrover") + || dir_name.contains("phpstorm") + || dir_name.contains("rubymine") + || dir_name == "bin"; + if should_search { + if let Some(found) = find_file_with_names(&path, names, depth - 1) { + return Some(found); + } + } + } + } + + None +} + +fn format_detected_command(path: &Path, editor: &str, terminal_default: bool) -> String { + let command = quote_command_path(path); + if terminal_default { + if matches!(editor, "neovim") { + format!("{command} .") + } else { + command + } + } else { + format!("{command} {{project}}") + } +} diff --git a/src-tauri/src/project/editor/launch.rs b/src-tauri/src/project/editor/launch.rs new file mode 100644 index 0000000..e7dccb1 --- /dev/null +++ b/src-tauri/src/project/editor/launch.rs @@ -0,0 +1,223 @@ +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +use crate::command_line::{parse_command_line, shell_join, shell_quote}; + +pub(super) fn open_project( + editor_command: &str, + project: &Path, + open_in_terminal: bool, +) -> Result<(), String> { + let has_project_placeholder = editor_command.contains("{project}"); + let mut args = parse_command_line(editor_command)?; + + let project_text = project.to_string_lossy().into_owned(); + for arg in &mut args { + // 占位符替换发生在 parse_command_line 之后:GUI 路径下参数直接传给 + // Command::args 不经过 shell;终端路径下由 shell_join/shell_quote 统一转义。 + *arg = arg + .replace("{project}", &project_text) + .replace("{cwd}", &project_text); + } + + if !open_in_terminal && !has_project_placeholder { + args.push(project_text); + } + + if open_in_terminal { + return spawn_terminal_command(&args, project); + } + + let program = PathBuf::from(&args[0]); + spawn_editor(&program, &args[1..]) +} + +pub(super) fn open_remote_project( + host: &str, + remote_path: &str, + editor_command: &str, + mode: &str, +) -> Result<(), String> { + let host = host.trim(); + let remote_path = remote_path.trim(); + if host.is_empty() || remote_path.is_empty() { + return Err("Remote host and path cannot be empty".to_string()); + } + + match mode { + "terminal" => spawn_remote_terminal(host, remote_path), + "vscode" => { + if editor_command.trim().is_empty() { + return Err("Editor command cannot be empty".to_string()); + } + // 取已配置命令的程序名 token(如 "code"),其余 {project} 之类占位符丢弃 + let args = parse_command_line(editor_command)?; + let program = PathBuf::from(&args[0]); + let remote_args = vec![ + "--remote".to_string(), + format!("ssh-remote+{host}"), + remote_path.to_string(), + ]; + spawn_editor(&program, &remote_args) + } + other => Err(format!("Unknown remote open mode: {other}")), + } +} + +fn spawn_editor(program: &Path, args: &[String]) -> Result<(), String> { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW 抑制控制台窗口:编辑器启动器常是 code.cmd 之类的批处理, + // 默认会让 Windows 给它分配一个控制台窗口(表现为弹出终端)。 + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + let extension = program + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase); + + if matches!(extension.as_deref(), Some("bat" | "cmd")) { + // .cmd/.bat 不是 PE 可执行文件,必须经 cmd 运行(直接 CreateProcess 会报 + // “不是有效的 Win32 应用程序”)。cmd /C 会去除整条命令行的首尾引号,因此把 + // “程序 + 参数”整体再包一层引号;参数(路径 / --remote / ssh-remote+host) + // 不含双引号,逐个加引号即可安全。 + let mut line = String::new(); + line.push('"'); // 外层起始引号 + line.push('"'); + line.push_str(&program.to_string_lossy()); + line.push('"'); // 程序路径引号 + for arg in args { + line.push_str(" \""); + line.push_str(arg); + line.push('"'); + } + line.push('"'); // 外层结束引号 + + return Command::new("cmd") + .raw_arg("/C") + .raw_arg(&line) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map(|_| ()) + .map_err(|error| format!("Failed to launch {}: {error}", program.display())); + } + + return Command::new(program) + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map(|_| ()) + .map_err(|error| format!("Failed to launch {}: {error}", program.display())); + } + + #[cfg(not(windows))] + { + Command::new(program) + .args(args) + .spawn() + .map(|_| ()) + .map_err(|error| format!("Failed to launch {}: {error}", program.display())) + } +} + +fn spawn_terminal_command(args: &[String], project: &Path) -> Result<(), String> { + let shell_command = shell_join(args); + + if cfg!(target_os = "macos") { + // macOS 的 Terminal.app 通过 osascript 启动,不继承 current_dir, + // 因此把 cd 前缀嵌入命令字符串。 + let project_str = project.to_string_lossy(); + let full_command = format!("cd {} ; {}", shell_quote(&project_str), shell_command); + spawn_terminal_raw(&full_command, None) + } else { + spawn_terminal_raw(&shell_command, Some(project)) + } +} + +/// 在系统终端窗口中运行一条 shell 命令。 +/// +/// `cwd` 仅用于 Windows/Linux 的 `current_dir`;macOS 不继承 current_dir, +/// 调用方需把 `cd` 嵌入命令字符串(远程场景由 ssh 自身 cd,无需本地 cwd)。 +fn spawn_terminal_raw(shell_command: &str, cwd: Option<&Path>) -> Result<(), String> { + if cfg!(windows) { + let mut command = Command::new("cmd"); + if let Some(dir) = cwd { + command.current_dir(dir); + } + command + .args(["/C", "start", "", "cmd", "/K", shell_command]) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()) + } else if cfg!(target_os = "macos") { + // AppleScript 字符串需要转义反斜杠和双引号 + let escaped = shell_command.replace('\\', "\\\\").replace('"', "\\\""); + let script = format!("tell application \"Terminal\" to do script \"{}\"", escaped); + + Command::new("osascript") + .args(["-e", &script]) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()) + } else { + let mut primary = Command::new("x-terminal-emulator"); + if let Some(dir) = cwd { + primary.current_dir(dir); + } + primary + .args(["-e", "sh", "-lc", shell_command]) + .spawn() + .or_else(|_| { + let mut fallback = Command::new("gnome-terminal"); + if let Some(dir) = cwd { + fallback.current_dir(dir); + } + fallback.args(["--", "sh", "-lc", shell_command]).spawn() + }) + .map(|_| ()) + .map_err(|error| error.to_string()) + } +} + +/// 以 POSIX 单引号包裹远程路径(始终面向远程的 POSIX shell, +/// 不能用本地平台相关的 shell_quote)。 +fn posix_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// 打开终端并通过 ssh 进入远程目录。 +fn spawn_remote_terminal(host: &str, remote_path: &str) -> Result<(), String> { + // 远端命令:cd 后保留交互 shell。$SHELL 由远端展开,故路径用 POSIX 单引号保护。 + let inner = format!("cd {} ; exec $SHELL -l", posix_single_quote(remote_path)); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // 用 raw_arg 精确拼出命令行:Rust 默认的 MSVCRT 转义(\")与 cmd 的解析规则冲突, + // 会把 host 连同引号一起塞给 ssh,导致 “hostname contains invalid characters”。 + // host 不含空格,直接传;远端命令整体用双引号包成 ssh 的单个参数。 + return Command::new("cmd") + .raw_arg("/C") + .raw_arg("start") + .raw_arg("\"\"") + .raw_arg("cmd") + .raw_arg("/K") + .raw_arg("ssh") + .raw_arg("-t") + .raw_arg(host) + .raw_arg(format!("\"{inner}\"")) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()); + } + + #[cfg(not(windows))] + { + let args = vec!["ssh".to_string(), "-t".to_string(), host.to_string(), inner]; + let shell_command = shell_join(&args); + spawn_terminal_raw(&shell_command, None) + } +} diff --git a/src-tauri/src/project/license.rs b/src-tauri/src/project/license.rs new file mode 100644 index 0000000..af932e0 --- /dev/null +++ b/src-tauri/src/project/license.rs @@ -0,0 +1,196 @@ +use std::{fs, path::Path}; + +use serde::Serialize; + +use super::path_safety::is_protected_system_path; + +const LICENSE_LINE_LIMIT: usize = 100; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LicenseReadResult { + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + snippet: Option, + #[serde(skip_serializing_if = "Option::is_none")] + lines: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + +#[tauri::command] +pub fn read_project_license(folder_path: String, max_lines: Option) -> LicenseReadResult { + let root = Path::new(&folder_path); + + if !root.is_absolute() { + return LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some("Path must be absolute".to_string()), + }; + } + + let canonical_path = match root.canonicalize() { + Ok(path) => path, + Err(_) => { + return LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some("Cannot access path".to_string()), + }; + } + }; + + if is_protected_system_path(&canonical_path) { + return LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some("Cannot read from system directory".to_string()), + }; + } + + if !root.is_dir() { + return LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some("Project path does not exist or is not a directory".to_string()), + }; + } + + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) => { + return LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some(error.to_string()), + }; + } + }; + + let mut candidates = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + let file_name = path.file_name()?.to_string_lossy().into_owned(); + let lower = file_name.to_ascii_lowercase(); + (lower.starts_with("license") + || lower.starts_with("licence") + || lower.starts_with("copying") + || lower.starts_with("unlicense")) + .then_some((file_name, path)) + }) + .collect::>(); + + if candidates.is_empty() { + return LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some("License file not found".to_string()), + }; + } + + candidates.sort_by_key(|(file_name, _)| license_rank(file_name)); + let (file_name, path) = candidates.remove(0); + if !path.is_file() { + return LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some("License file is not a regular file".to_string()), + }; + } + + // 检查文件大小,限制最大 1MB + const MAX_LICENSE_SIZE: u64 = 1024 * 1024; + match fs::metadata(&path) { + Ok(metadata) if metadata.len() > MAX_LICENSE_SIZE => { + return LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some(format!("License file too large: {} bytes", metadata.len())), + }; + } + Err(error) => { + return LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some(error.to_string()), + }; + } + _ => {} + } + + match fs::read_to_string(path) { + Ok(content) => { + let limit = max_lines.unwrap_or(20).clamp(1, LICENSE_LINE_LIMIT); + let lines = content.lines().take(limit).collect::>(); + LicenseReadResult { + success: true, + filename: Some(file_name), + snippet: Some(lines.join("\n")), + lines: Some(lines.len()), + message: None, + } + } + Err(error) => LicenseReadResult { + success: false, + filename: None, + snippet: None, + lines: None, + message: Some(error.to_string()), + }, + } +} + +fn license_rank(file_name: &str) -> u8 { + let lower = file_name.to_ascii_lowercase(); + let path = Path::new(&lower); + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(&lower); + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or(""); + + let base_score = if stem.starts_with("license") { + 0 + } else if stem.starts_with("licence") { + 1 + } else if stem.starts_with("copying") { + 2 + } else if stem.starts_with("unlicense") { + 3 + } else { + 9 + }; + let extension_score = match extension { + "" => 0, + "md" => 1, + "txt" => 2, + _ => 3, + }; + + base_score * 10 + extension_score +} diff --git a/src-tauri/src/project/path_safety.rs b/src-tauri/src/project/path_safety.rs new file mode 100644 index 0000000..de3f0ae --- /dev/null +++ b/src-tauri/src/project/path_safety.rs @@ -0,0 +1,133 @@ +use std::{ + env, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +/// 受保护的系统目录根(缓存) +static PROTECTED_SYSTEM_ROOTS: LazyLock> = LazyLock::new(|| { + let mut roots = Vec::new(); + if cfg!(windows) { + for var in [ + "SystemRoot", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramW6432", + ] { + if let Some(value) = env::var_os(var) { + roots.push(normalize_for_protection_check(Path::new(&value))); + } + } + for fallback in [ + r"C:\Windows", + r"C:\Program Files", + r"C:\Program Files (x86)", + ] { + roots.push(normalize_for_protection_check(Path::new(fallback))); + } + } else { + for root in [ + "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/var", "/sys", "/proc", "/boot", + "/system", + ] { + roots.push(root.to_string()); + } + } + roots.sort(); + roots.dedup(); + roots +}); + +/// 去除 Windows canonicalize 产生的 verbatim 前缀(`\\?\` / `\\?\UNC\`)。 +fn strip_verbatim_prefix(path: &Path) -> PathBuf { + let text = path.to_string_lossy(); + if let Some(stripped) = text.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{stripped}")) + } else if let Some(stripped) = text.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + path.to_path_buf() + } +} + +/// 将路径归一化为用于保护检查的形式:去 verbatim 前缀、统一斜杠、 +/// 去尾部斜杠,Windows 下转小写。 +fn normalize_for_protection_check(path: &Path) -> String { + let text = strip_verbatim_prefix(path) + .to_string_lossy() + .replace('\\', "/"); + let trimmed = text.trim_end_matches('/').to_string(); + if cfg!(windows) { + trimmed.to_lowercase() + } else { + trimmed + } +} + +/// 判断(已 canonicalize 的)路径是否位于受保护的系统目录内。 +/// 使用规范化后的路径进行匹配,避免大小写和路径格式差异。 +pub(super) fn is_protected_system_path(canonical: &Path) -> bool { + let normalized = normalize_for_protection_check(canonical); + let roots = &*PROTECTED_SYSTEM_ROOTS; + + for root in roots { + // 精确匹配根目录或其子路径 + if normalized == *root || normalized.starts_with(&format!("{root}/")) { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{is_protected_system_path, strip_verbatim_prefix}; + + #[test] + #[cfg(windows)] + fn strip_verbatim_prefix_removes_windows_prefix() { + assert_eq!( + strip_verbatim_prefix(Path::new(r"\\?\C:\Windows")), + Path::new(r"C:\Windows").to_path_buf() + ); + assert_eq!( + strip_verbatim_prefix(Path::new(r"\\?\UNC\server\share")), + Path::new(r"\\server\share").to_path_buf() + ); + } + + #[test] + #[cfg(windows)] + fn protected_path_check_matches_system_roots() { + assert!(is_protected_system_path(Path::new(r"\\?\C:\Windows"))); + assert!(is_protected_system_path(Path::new( + r"\\?\C:\Windows\System32" + ))); + assert!(is_protected_system_path(Path::new( + r"C:\Program Files\Some App" + ))); + assert!(is_protected_system_path(Path::new( + r"C:\Program Files (x86)\Some App" + ))); + } + + #[test] + #[cfg(windows)] + fn protected_path_check_does_not_overmatch_siblings() { + assert!(!is_protected_system_path(Path::new(r"C:\Windows-backup"))); + assert!(!is_protected_system_path(Path::new(r"C:\Program Filesx"))); + assert!(!is_protected_system_path(Path::new(r"E:\Projects\demo"))); + } + + #[test] + #[cfg(not(windows))] + fn protected_path_check_matches_unix_roots() { + assert!(is_protected_system_path(Path::new("/etc"))); + assert!(is_protected_system_path(Path::new("/usr/bin"))); + assert!(!is_protected_system_path(Path::new("/home/user/projects"))); + assert!(!is_protected_system_path(Path::new("/etcetera"))); + } +} diff --git a/src-tauri/src/recent.rs b/src-tauri/src/recent.rs index 65d38c9..24b6974 100644 --- a/src-tauri/src/recent.rs +++ b/src-tauri/src/recent.rs @@ -1,470 +1,10 @@ -use std::{ - collections::HashSet, - fs, - io::{BufRead, BufReader}, - path::{Path, PathBuf}, -}; - -use percent_encoding::percent_decode_str; -use rusqlite::{Connection, OpenFlags}; -use serde::Deserialize; -use serde_json::Value; -use url::Url; - -use crate::analyzer::{canonical_or_original, display_path, normalize_path_key}; - -#[derive(Clone)] -pub struct RecentProject { - pub path: String, - pub ide: Option, -} - -pub fn collect_from_jetbrains(config_root: Option<&str>) -> Vec { - let Some(config_root) = config_root.filter(|path| !path.is_empty()) else { - return Vec::new(); - }; - let root = Path::new(config_root); - if !root.is_dir() { - return Vec::new(); - } - - let mut candidates = Vec::new(); - let home = dirs::home_dir().unwrap_or_default(); - let entries = match fs::read_dir(root) { - Ok(entries) => entries, - Err(_) => return Vec::new(), - }; - - for entry in entries.flatten() { - let product_dir = entry.path(); - if !product_dir.is_dir() { - continue; - } - - let xml_path = product_dir.join("options").join("recentProjects.xml"); - let Ok(xml) = fs::read_to_string(xml_path) else { - continue; - }; - let Ok(document) = roxmltree::Document::parse(&xml) else { - continue; - }; - - let ide = entry - .file_name() - .to_string_lossy() - .split(char::is_numeric) - .next() - .and_then(map_jetbrains_ide); - - for option in document.descendants().filter(|node| { - node.has_tag_name("option") && node.attribute("name") == Some("additionalInfo") - }) { - for project_entry in option - .descendants() - .filter(|node| node.has_tag_name("entry")) - { - let Some(raw_path) = project_entry.attribute("key") else { - continue; - }; - if raw_path.contains('$') - || raw_path.contains("light-edit") - || raw_path.contains("scratches") - { - continue; - } - let normalized = normalize_jetbrains_path(raw_path, &home); - candidates.push(RecentProject { - path: normalized, - ide: ide.clone(), - }); - } - } - } - - uniq_existing_dirs(candidates) -} - -pub fn collect_from_vscode(db_path: Option<&str>) -> Vec { - let Some(db_path) = db_path.filter(|path| !path.is_empty()) else { - return Vec::new(); - }; - if !Path::new(db_path).is_file() { - return Vec::new(); - } - - let connection = match Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) { - Ok(connection) => connection, - Err(_) => return Vec::new(), - }; - - // 设置忙等待超时和查询超时,避免数据库锁定或慢查询阻塞 - let _ = connection.busy_timeout(std::time::Duration::from_millis(500)); - // pragma_update 返回 Result,需要处理 - let _ = connection.pragma_update(None, "query_only", true); - - let value: Option = connection - .query_row( - "SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'", - [], - |row| row.get(0), - ) - .ok(); - - let Some(value) = value else { - return Vec::new(); - }; - let Ok(history) = serde_json::from_str::(&value) else { - return Vec::new(); - }; - - let mut candidates = Vec::new(); - for entry in history.entries { - if entry.remote_authority.is_some() { - continue; - } - - if let Some(path) = entry.folder_uri.as_deref().and_then(file_uri_to_path) { - candidates.push(RecentProject { path, ide: None }); - continue; - } - - if let Some(workspace) = entry.workspace { - if let Some(config_path) = workspace.config_path() { - if let Some(path) = file_uri_to_path(config_path) { - if let Some(parent) = Path::new(&path).parent() { - candidates.push(RecentProject { - path: parent.to_string_lossy().into_owned(), - ide: None, - }); - } - } - } - continue; - } - - if let Some(path) = entry.file_uri.as_deref().and_then(file_uri_to_path) { - if let Some(parent) = Path::new(&path).parent() { - candidates.push(RecentProject { - path: parent.to_string_lossy().into_owned(), - ide: None, - }); - } - } - } - - uniq_existing_dirs(candidates) -} - -pub fn collect_from_codex(config_root: Option<&str>) -> Vec { - collect_cwd_history(config_root, &["sessions"], "codex-cli") -} - -pub fn collect_from_claude(config_root: Option<&str>) -> Vec { - collect_cwd_history(config_root, &["projects"], "claude-code") -} - -pub fn collect_from_gemini(config_root: Option<&str>) -> Vec { - collect_cwd_history(config_root, &["tmp", "sessions", "history"], "gemini-cli") -} - -fn collect_cwd_history( - config_root: Option<&str>, - preferred_dirs: &[&str], - ide: &str, -) -> Vec { - let Some(config_root) = config_root.filter(|path| !path.is_empty()) else { - return Vec::new(); - }; - let root = Path::new(config_root); - if !root.is_dir() { - return Vec::new(); - } - - let mut files = Vec::new(); - for dir in preferred_dirs { - collect_json_history_files(&root.join(dir), &mut files); - } - if files.is_empty() || ide == "gemini-cli" { - collect_json_history_files(root, &mut files); - } - files.sort(); - files.dedup(); - - files.sort_by(|a, b| { - modified_millis(b) - .cmp(&modified_millis(a)) - .then_with(|| a.cmp(b)) - }); - - let candidates = files - .into_iter() - .take(MAX_RECENT_FILES) - .filter_map(|path| extract_cwd_from_history_file(&path)) - .map(|path| RecentProject { - path, - ide: Some(ide.to_string()), - }) - .collect(); - - uniq_existing_dirs(candidates) -} - -fn collect_json_history_files(root: &Path, out: &mut Vec) { - const MAX_FILES: usize = 1000; - if out.len() >= MAX_FILES { - return; - } - - let Ok(entries) = fs::read_dir(root) else { - return; - }; - - for entry in entries.flatten() { - if out.len() >= MAX_FILES { - break; - } - let path = entry.path(); - if path.is_dir() { - collect_json_history_files(&path, out); - continue; - } - - if matches!( - path.extension().and_then(|extension| extension.to_str()), - Some("jsonl" | "json") - ) { - out.push(path); - } - } -} - -fn extract_cwd_from_history_file(path: &Path) -> Option { - match path.extension().and_then(|extension| extension.to_str()) { - Some("jsonl") => extract_cwd_from_jsonl(path), - Some("json") => extract_cwd_from_json_file(path), - _ => None, - } -} - -const MAX_CLI_HISTORY_JSON_SIZE: u64 = 512 * 1024; -const MAX_JSON_DEPTH: usize = 32; -const MAX_JSONL_LINE_SIZE: usize = 64 * 1024; -const MAX_JSONL_LINES: usize = 80; -const MAX_RECENT_FILES: usize = 500; - -fn extract_cwd_from_jsonl(path: &Path) -> Option { - let file = fs::File::open(path).ok()?; - let reader = BufReader::new(file); - for line in reader.lines().take(MAX_JSONL_LINES).map_while(Result::ok) { - if line.len() > MAX_JSONL_LINE_SIZE { - continue; - } - let Ok(value) = serde_json::from_str::(&line) else { - continue; - }; - if let Some(cwd) = find_cwd_in_json(&value) { - return Some(cwd); - } - } - None -} - -fn extract_cwd_from_json_file(path: &Path) -> Option { - if fs::metadata(path).ok()?.len() > MAX_CLI_HISTORY_JSON_SIZE { - return None; - } - let value = serde_json::from_str::(&fs::read_to_string(path).ok()?).ok()?; - find_cwd_in_json_impl(&value, 0) -} - -fn find_cwd_in_json(value: &Value) -> Option { - find_cwd_in_json_impl(value, 0) -} - -fn find_cwd_in_json_impl(value: &Value, depth: usize) -> Option { - if depth > MAX_JSON_DEPTH { - return None; - } - - match value { - Value::Object(map) => { - if let Some(path) = map - .get("cwd") - .or_else(|| map.get("workingDirectory")) - .or_else(|| map.get("workspaceRoot")) - .and_then(Value::as_str) - .filter(|path| !path.trim().is_empty()) - { - return Some(path.to_string()); - } - - for child in map.values() { - if let Some(path) = find_cwd_in_json_impl(child, depth + 1) { - return Some(path); - } - } - None - } - Value::Array(items) => items.iter().find_map(|v| find_cwd_in_json_impl(v, depth + 1)), - _ => None, - } -} - -fn modified_millis(path: &Path) -> u128 { - path.metadata() - .and_then(|metadata| metadata.modified()) - .ok() - .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis()) - .unwrap_or_default() -} - -fn uniq_existing_dirs(items: Vec) -> Vec { - let mut seen = HashSet::new(); - let mut out = Vec::new(); - - for item in items { - let path = PathBuf::from(&item.path); - if !path.is_dir() { - continue; - } - let canonical = canonical_or_original(&path); - let key = normalize_path_key(&canonical); - if seen.insert(key) { - out.push(RecentProject { - path: display_path(&canonical), - ide: item.ide, - }); - } - } - - out -} - -fn normalize_jetbrains_path(raw_path: &str, home: &Path) -> String { - let with_home = raw_path.replace("$USER_HOME$", &home.to_string_lossy()); - if cfg!(windows) { - with_home.replace('/', "\\") - } else { - with_home - } -} - -fn map_jetbrains_ide(raw_name: &str) -> Option { - let value = match raw_name { - "IntelliJIdea" => "intellij-idea", - "PyCharm" => "pycharm", - "PhpStorm" => "phpstorm", - "GoLand" => "goLand", - "Rider" => "rider", - "CLion" => "clion", - "RustRover" => "rust-rover", - "WebStorm" => "webstorm", - "RubyMine" => "rubymine", - "AndroidStudio" => "android-studio", - _ => return None, - }; - Some(value.to_string()) -} - -fn file_uri_to_path(uri: &str) -> Option { - if !uri.starts_with("file://") { - return None; - } - - if let Ok(url) = Url::parse(uri) { - if let Ok(path) = url.to_file_path() { - return Some(path.to_string_lossy().into_owned()); - } - } - - let stripped = uri.strip_prefix("file://")?; - let decoded = percent_decode_str(stripped).decode_utf8().ok()?; - if cfg!(windows) { - Some(decoded.trim_start_matches('/').replace('/', "\\")) - } else { - Some(format!("/{decoded}")) - } -} - -#[derive(Deserialize)] -struct VscodeHistory { - #[serde(default)] - entries: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct VscodeRecentEntry { - folder_uri: Option, - file_uri: Option, - workspace: Option, - remote_authority: Option, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum VscodeWorkspace { - Object { config_path: String }, - String(String), -} - -impl VscodeWorkspace { - fn config_path(&self) -> Option<&str> { - match self { - VscodeWorkspace::Object { config_path } => Some(config_path), - VscodeWorkspace::String(value) => Some(value), - } - } -} - -#[cfg(test)] -mod tests { - use std::time::{SystemTime, UNIX_EPOCH}; - - use serde_json::json; - - use super::*; - - #[test] - fn collects_codex_cwd_from_jsonl_payload() { - let root = unique_temp_dir("codex-cwd"); - let project = root.join("project"); - let config = root.join("config"); - let sessions = config.join("sessions"); - fs::create_dir_all(&project).expect("project dir should be created"); - fs::create_dir_all(&sessions).expect("sessions dir should be created"); - fs::write( - sessions.join("session.jsonl"), - format!( - "{}\n", - json!({ - "type": "session_meta", - "payload": { - "cwd": project.to_string_lossy() - } - }) - ), - ) - .expect("session file should be written"); - - let config_string = config.to_string_lossy().into_owned(); - let items = collect_from_codex(Some(&config_string)); - assert_eq!(items.len(), 1); - assert_eq!(items[0].ide.as_deref(), Some("codex-cli")); - assert_eq!( - normalize_path_key(Path::new(&items[0].path)), - normalize_path_key(&project) - ); - - let _ = fs::remove_dir_all(root); - } - - fn unique_temp_dir(label: &str) -> PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be valid") - .as_nanos(); - std::env::temp_dir().join(format!("codenest-{label}-{}-{unique}", std::process::id())) - } -} +mod cli; +mod common; +mod jetbrains; +mod models; +mod vscode; + +pub use cli::{collect_from_claude, collect_from_codex, collect_from_gemini}; +pub use jetbrains::collect_from_jetbrains; +pub use models::RecentProject; +pub use vscode::collect_from_vscode; diff --git a/src-tauri/src/recent/cli.rs b/src-tauri/src/recent/cli.rs new file mode 100644 index 0000000..fa92a22 --- /dev/null +++ b/src-tauri/src/recent/cli.rs @@ -0,0 +1,222 @@ +use std::{ + fs, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, +}; + +use serde_json::Value; + +use super::{ + common::{modified_millis, uniq_existing_dirs}, + models::RecentProject, +}; + +const MAX_CLI_HISTORY_JSON_SIZE: u64 = 512 * 1024; +const MAX_JSON_DEPTH: usize = 32; +const MAX_JSONL_LINE_SIZE: usize = 64 * 1024; +const MAX_JSONL_LINES: usize = 80; +const MAX_RECENT_FILES: usize = 500; + +pub fn collect_from_codex(config_root: Option<&str>) -> Vec { + collect_cwd_history(config_root, &["sessions"], "codex-cli") +} + +pub fn collect_from_claude(config_root: Option<&str>) -> Vec { + collect_cwd_history(config_root, &["projects"], "claude-code") +} + +pub fn collect_from_gemini(config_root: Option<&str>) -> Vec { + collect_cwd_history(config_root, &["tmp", "sessions", "history"], "gemini-cli") +} + +fn collect_cwd_history( + config_root: Option<&str>, + preferred_dirs: &[&str], + ide: &str, +) -> Vec { + let Some(config_root) = config_root.filter(|path| !path.is_empty()) else { + return Vec::new(); + }; + let root = Path::new(config_root); + if !root.is_dir() { + return Vec::new(); + } + + let mut files = Vec::new(); + for dir in preferred_dirs { + collect_json_history_files(&root.join(dir), &mut files); + } + if files.is_empty() || ide == "gemini-cli" { + collect_json_history_files(root, &mut files); + } + files.sort(); + files.dedup(); + + files.sort_by(|a, b| { + modified_millis(b) + .cmp(&modified_millis(a)) + .then_with(|| a.cmp(b)) + }); + + let candidates = files + .into_iter() + .take(MAX_RECENT_FILES) + .filter_map(|path| extract_cwd_from_history_file(&path)) + .map(|path| RecentProject { + path, + ide: Some(ide.to_string()), + }) + .collect(); + + uniq_existing_dirs(candidates) +} + +fn collect_json_history_files(root: &Path, out: &mut Vec) { + const MAX_FILES: usize = 1000; + if out.len() >= MAX_FILES { + return; + } + + let Ok(entries) = fs::read_dir(root) else { + return; + }; + + for entry in entries.flatten() { + if out.len() >= MAX_FILES { + break; + } + let path = entry.path(); + if path.is_dir() { + collect_json_history_files(&path, out); + continue; + } + + if matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("jsonl" | "json") + ) { + out.push(path); + } + } +} + +fn extract_cwd_from_history_file(path: &Path) -> Option { + match path.extension().and_then(|extension| extension.to_str()) { + Some("jsonl") => extract_cwd_from_jsonl(path), + Some("json") => extract_cwd_from_json_file(path), + _ => None, + } +} + +fn extract_cwd_from_jsonl(path: &Path) -> Option { + let file = fs::File::open(path).ok()?; + let reader = BufReader::new(file); + for line in reader.lines().take(MAX_JSONL_LINES).map_while(Result::ok) { + if line.len() > MAX_JSONL_LINE_SIZE { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if let Some(cwd) = find_cwd_in_json(&value) { + return Some(cwd); + } + } + None +} + +fn extract_cwd_from_json_file(path: &Path) -> Option { + if fs::metadata(path).ok()?.len() > MAX_CLI_HISTORY_JSON_SIZE { + return None; + } + let value = serde_json::from_str::(&fs::read_to_string(path).ok()?).ok()?; + find_cwd_in_json_impl(&value, 0) +} + +fn find_cwd_in_json(value: &Value) -> Option { + find_cwd_in_json_impl(value, 0) +} + +fn find_cwd_in_json_impl(value: &Value, depth: usize) -> Option { + if depth > MAX_JSON_DEPTH { + return None; + } + + match value { + Value::Object(map) => { + if let Some(path) = map + .get("cwd") + .or_else(|| map.get("workingDirectory")) + .or_else(|| map.get("workspaceRoot")) + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + { + return Some(path.to_string()); + } + + for child in map.values() { + if let Some(path) = find_cwd_in_json_impl(child, depth + 1) { + return Some(path); + } + } + None + } + Value::Array(items) => items + .iter() + .find_map(|v| find_cwd_in_json_impl(v, depth + 1)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use serde_json::json; + + use crate::analyzer::normalize_path_key; + + use super::*; + + #[test] + fn collects_codex_cwd_from_jsonl_payload() { + let root = unique_temp_dir("codex-cwd"); + let project = root.join("project"); + let config = root.join("config"); + let sessions = config.join("sessions"); + fs::create_dir_all(&project).expect("project dir should be created"); + fs::create_dir_all(&sessions).expect("sessions dir should be created"); + fs::write( + sessions.join("session.jsonl"), + format!( + "{}\n", + json!({ + "type": "session_meta", + "payload": { + "cwd": project.to_string_lossy() + } + }) + ), + ) + .expect("session file should be written"); + + let config_string = config.to_string_lossy().into_owned(); + let items = collect_from_codex(Some(&config_string)); + assert_eq!(items.len(), 1); + assert_eq!(items[0].ide.as_deref(), Some("codex-cli")); + assert_eq!( + normalize_path_key(Path::new(&items[0].path)), + normalize_path_key(&project) + ); + + let _ = fs::remove_dir_all(root); + } + + fn unique_temp_dir(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be valid") + .as_nanos(); + std::env::temp_dir().join(format!("codenest-{label}-{}-{unique}", std::process::id())) + } +} diff --git a/src-tauri/src/recent/common.rs b/src-tauri/src/recent/common.rs new file mode 100644 index 0000000..15b4d63 --- /dev/null +++ b/src-tauri/src/recent/common.rs @@ -0,0 +1,62 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, +}; + +use percent_encoding::percent_decode_str; +use url::Url; + +use crate::analyzer::{canonical_or_original, display_path, normalize_path_key}; + +use super::models::RecentProject; + +pub(super) fn modified_millis(path: &Path) -> u128 { + path.metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +pub(super) fn uniq_existing_dirs(items: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + + for item in items { + let path = PathBuf::from(&item.path); + if !path.is_dir() { + continue; + } + let canonical = canonical_or_original(&path); + let key = normalize_path_key(&canonical); + if seen.insert(key) { + out.push(RecentProject { + path: display_path(&canonical), + ide: item.ide, + }); + } + } + + out +} + +pub(super) fn file_uri_to_path(uri: &str) -> Option { + if !uri.starts_with("file://") { + return None; + } + + if let Ok(url) = Url::parse(uri) { + if let Ok(path) = url.to_file_path() { + return Some(path.to_string_lossy().into_owned()); + } + } + + let stripped = uri.strip_prefix("file://")?; + let decoded = percent_decode_str(stripped).decode_utf8().ok()?; + if cfg!(windows) { + Some(decoded.trim_start_matches('/').replace('/', "\\")) + } else { + Some(format!("/{decoded}")) + } +} diff --git a/src-tauri/src/recent/jetbrains.rs b/src-tauri/src/recent/jetbrains.rs new file mode 100644 index 0000000..cf1eceb --- /dev/null +++ b/src-tauri/src/recent/jetbrains.rs @@ -0,0 +1,94 @@ +use std::{fs, path::Path}; + +use super::{common::uniq_existing_dirs, models::RecentProject}; + +pub fn collect_from_jetbrains(config_root: Option<&str>) -> Vec { + let Some(config_root) = config_root.filter(|path| !path.is_empty()) else { + return Vec::new(); + }; + let root = Path::new(config_root); + if !root.is_dir() { + return Vec::new(); + } + + let mut candidates = Vec::new(); + let home = dirs::home_dir().unwrap_or_default(); + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(_) => return Vec::new(), + }; + + for entry in entries.flatten() { + let product_dir = entry.path(); + if !product_dir.is_dir() { + continue; + } + + let xml_path = product_dir.join("options").join("recentProjects.xml"); + let Ok(xml) = fs::read_to_string(xml_path) else { + continue; + }; + let Ok(document) = roxmltree::Document::parse(&xml) else { + continue; + }; + + let ide = entry + .file_name() + .to_string_lossy() + .split(char::is_numeric) + .next() + .and_then(map_jetbrains_ide); + + for option in document.descendants().filter(|node| { + node.has_tag_name("option") && node.attribute("name") == Some("additionalInfo") + }) { + for project_entry in option + .descendants() + .filter(|node| node.has_tag_name("entry")) + { + let Some(raw_path) = project_entry.attribute("key") else { + continue; + }; + if raw_path.contains('$') + || raw_path.contains("light-edit") + || raw_path.contains("scratches") + { + continue; + } + let normalized = normalize_jetbrains_path(raw_path, &home); + candidates.push(RecentProject { + path: normalized, + ide: ide.clone(), + }); + } + } + } + + uniq_existing_dirs(candidates) +} + +fn normalize_jetbrains_path(raw_path: &str, home: &Path) -> String { + let with_home = raw_path.replace("$USER_HOME$", &home.to_string_lossy()); + if cfg!(windows) { + with_home.replace('/', "\\") + } else { + with_home + } +} + +fn map_jetbrains_ide(raw_name: &str) -> Option { + let value = match raw_name { + "IntelliJIdea" => "intellij-idea", + "PyCharm" => "pycharm", + "PhpStorm" => "phpstorm", + "GoLand" => "goLand", + "Rider" => "rider", + "CLion" => "clion", + "RustRover" => "rust-rover", + "WebStorm" => "webstorm", + "RubyMine" => "rubymine", + "AndroidStudio" => "android-studio", + _ => return None, + }; + Some(value.to_string()) +} diff --git a/src-tauri/src/recent/models.rs b/src-tauri/src/recent/models.rs new file mode 100644 index 0000000..cb656b5 --- /dev/null +++ b/src-tauri/src/recent/models.rs @@ -0,0 +1,5 @@ +#[derive(Clone)] +pub struct RecentProject { + pub path: String, + pub ide: Option, +} diff --git a/src-tauri/src/recent/vscode.rs b/src-tauri/src/recent/vscode.rs new file mode 100644 index 0000000..1a0951e --- /dev/null +++ b/src-tauri/src/recent/vscode.rs @@ -0,0 +1,111 @@ +use std::path::Path; + +use rusqlite::{Connection, OpenFlags}; +use serde::Deserialize; + +use super::{ + common::{file_uri_to_path, uniq_existing_dirs}, + models::RecentProject, +}; + +pub fn collect_from_vscode(db_path: Option<&str>) -> Vec { + let Some(db_path) = db_path.filter(|path| !path.is_empty()) else { + return Vec::new(); + }; + if !Path::new(db_path).is_file() { + return Vec::new(); + } + + let connection = match Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) { + Ok(connection) => connection, + Err(_) => return Vec::new(), + }; + + // 设置忙等待超时和查询超时,避免数据库锁定或慢查询阻塞 + let _ = connection.busy_timeout(std::time::Duration::from_millis(500)); + // pragma_update 返回 Result,需要处理 + let _ = connection.pragma_update(None, "query_only", true); + + let value: Option = connection + .query_row( + "SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'", + [], + |row| row.get(0), + ) + .ok(); + + let Some(value) = value else { + return Vec::new(); + }; + let Ok(history) = serde_json::from_str::(&value) else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + for entry in history.entries { + if entry.remote_authority.is_some() { + continue; + } + + if let Some(path) = entry.folder_uri.as_deref().and_then(file_uri_to_path) { + candidates.push(RecentProject { path, ide: None }); + continue; + } + + if let Some(workspace) = entry.workspace { + if let Some(config_path) = workspace.config_path() { + if let Some(path) = file_uri_to_path(config_path) { + if let Some(parent) = Path::new(&path).parent() { + candidates.push(RecentProject { + path: parent.to_string_lossy().into_owned(), + ide: None, + }); + } + } + } + continue; + } + + if let Some(path) = entry.file_uri.as_deref().and_then(file_uri_to_path) { + if let Some(parent) = Path::new(&path).parent() { + candidates.push(RecentProject { + path: parent.to_string_lossy().into_owned(), + ide: None, + }); + } + } + } + + uniq_existing_dirs(candidates) +} + +#[derive(Deserialize)] +struct VscodeHistory { + #[serde(default)] + entries: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct VscodeRecentEntry { + folder_uri: Option, + file_uri: Option, + workspace: Option, + remote_authority: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum VscodeWorkspace { + Object { config_path: String }, + String(String), +} + +impl VscodeWorkspace { + fn config_path(&self) -> Option<&str> { + match self { + VscodeWorkspace::Object { config_path } => Some(config_path), + VscodeWorkspace::String(value) => Some(value), + } + } +} diff --git a/src-tauri/src/scanner.rs b/src-tauri/src/scanner.rs index ba4129c..855d75d 100644 --- a/src-tauri/src/scanner.rs +++ b/src-tauri/src/scanner.rs @@ -1,637 +1,32 @@ -use std::{ - collections::{HashMap, HashSet}, - fs, - hash::{Hash, Hasher}, - path::{Path, PathBuf}, - time::UNIX_EPOCH, -}; +mod detect; +mod models; +mod scan; -use ignore::WalkBuilder; -use serde::{Deserialize, Serialize}; -use tauri::Manager; - -use crate::{ - analyzer::{self, LanguageStats, LanguageType}, - recent::{ - collect_from_claude, collect_from_codex, collect_from_gemini, collect_from_jetbrains, - collect_from_vscode, RecentProject, - }, -}; - -const HISTORY_SCANNER_EDITORS: &[&str] = &[ - "cursor", - "trae", - "windsurf", - "claude-code", - "codex-cli", - "gemini-cli", - "visual-studio-code", -]; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScanPayload { - roots_enabled: bool, - roots: Vec, - ide_enabled: bool, - jetbrains: JetbrainsScannerConfig, - #[serde(default)] - recent_editors: HashMap, - #[serde(default)] - cli_editors: HashMap, - #[serde(default)] - vscode: Option, - existing_paths: Vec, - #[serde(default)] - cache_entries: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct JetbrainsScannerConfig { - enabled: bool, - config_root_path: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct VscodeScannerConfig { - enabled: bool, - state_db_path: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CliScannerConfig { - enabled: bool, - history_root_path: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ScanResult { - items: Vec, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ScanItem { - path: String, - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - main_lang: Option, - #[serde(skip_serializing_if = "Option::is_none")] - main_lang_color: Option, - #[serde(skip_serializing_if = "Option::is_none")] - lang_group: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - ide: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - signature: Option, -} - -#[derive(Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScanCacheEntry { - path: String, - signature: String, - #[serde(default)] - name: String, - #[serde(default)] - main_lang: Option, - #[serde(default)] - main_lang_color: Option, - #[serde(default)] - lang_group: Option>, - #[serde(default)] - error: Option, -} - -#[derive(Clone, Deserialize, Serialize)] -pub struct LangGroupItem { - text: String, - color: String, - percentage: f64, -} +use models::{ScanPayload, ScanResult}; #[tauri::command] pub async fn scan_projects(payload: ScanPayload) -> Result { - tauri::async_runtime::spawn_blocking(move || scan_projects_inner(payload)) + tauri::async_runtime::spawn_blocking(move || scan::scan_projects(payload)) .await .map_err(|error| error.to_string())? } #[tauri::command] pub fn detect_jetbrains_config_root_path(app: tauri::AppHandle) -> Option { - let mut candidates = Vec::new(); - if cfg!(windows) { - if let Ok(app_data) = app.path().app_data_dir() { - let roaming = app_data - .parent() - .and_then(Path::parent) - .map(|path| path.join("JetBrains")); - if let Some(path) = roaming { - candidates.push(path); - } - } - if let Some(data_dir) = dirs::data_dir() { - candidates.push(data_dir.join("JetBrains")); - } - } else if cfg!(target_os = "macos") { - if let Some(home) = dirs::home_dir() { - candidates.push( - home.join("Library") - .join("Application Support") - .join("JetBrains"), - ); - } - } else if let Some(home) = dirs::home_dir() { - candidates.push(home.join(".config").join("JetBrains")); - candidates.push(home.join(".JetBrains")); - } - - candidates - .into_iter() - .find(|path| path.is_dir()) - .map(|path| path.to_string_lossy().into_owned()) + detect::detect_jetbrains_config_root_path(app) } #[tauri::command] pub fn detect_vscode_state_db_path() -> Option { - detect_recent_editor_state_db_path("visual-studio-code".to_string()) + detect::detect_vscode_state_db_path() } #[tauri::command] pub fn detect_recent_editor_state_db_path(editor: String) -> Option { - vscode_history_state_db_candidates(&editor) - .into_iter() - .find(|path| path.is_file()) - .map(|path| path.to_string_lossy().into_owned()) + detect::detect_recent_editor_state_db_path(&editor) } #[tauri::command] pub fn detect_cli_history_root_path(editor: String) -> Option { - let dir_name = match editor.as_str() { - "codex-cli" => ".codex", - "claude-code" => ".claude", - "gemini-cli" => ".gemini", - _ => return None, - }; - dirs::home_dir() - .map(|home| home.join(dir_name)) - .filter(|path| path.is_dir()) - .map(|path| path.to_string_lossy().into_owned()) -} - -fn vscode_history_state_db_candidates(editor: &str) -> Vec { - let app_names = match editor { - "visual-studio-code" => &["Code"][..], - "cursor" => &["Cursor", "cursor"][..], - "windsurf" => &["Windsurf", "windsurf"][..], - "trae" => &["Trae", "trae", "Trae CN", "TraeCN"][..], - _ => return Vec::new(), - }; - - let mut candidates = Vec::new(); - if cfg!(windows) { - if let Some(data_dir) = dirs::data_dir() { - for app_name in app_names { - candidates.push(vscode_history_state_db_path(&data_dir.join(app_name))); - } - } - } else if cfg!(target_os = "macos") { - if let Some(home) = dirs::home_dir() { - let support = home.join("Library").join("Application Support"); - for app_name in app_names { - candidates.push(vscode_history_state_db_path(&support.join(app_name))); - } - } - } else if let Some(home) = dirs::home_dir() { - let config = home.join(".config"); - for app_name in app_names { - candidates.push(vscode_history_state_db_path(&config.join(app_name))); - } - } - - candidates -} - -fn vscode_history_state_db_path(app_data_root: &Path) -> PathBuf { - app_data_root - .join("User") - .join("globalStorage") - .join("state.vscdb") -} - -fn scan_projects_inner(payload: ScanPayload) -> Result { - let mut candidates = Vec::new(); - - if payload.ide_enabled { - for editor in HISTORY_SCANNER_EDITORS { - match *editor { - "cursor" | "trae" | "windsurf" | "visual-studio-code" => { - let config = payload.recent_editors.get(*editor).or_else(|| { - if *editor == "visual-studio-code" { - payload.vscode.as_ref() - } else { - None - } - }); - let Some(config) = config.filter(|config| config.enabled) else { - continue; - }; - - candidates.extend( - collect_from_vscode(Some(&config.state_db_path)) - .into_iter() - .map(|item| RecentProject { - path: item.path, - ide: Some((*editor).to_string()), - }), - ); - } - "claude-code" | "codex-cli" | "gemini-cli" => { - let Some(config) = payload - .cli_editors - .get(*editor) - .filter(|config| config.enabled) - else { - continue; - }; - let root = Some(config.history_root_path.as_str()); - candidates.extend(match *editor { - "claude-code" => collect_from_claude(root), - "codex-cli" => collect_from_codex(root), - "gemini-cli" => collect_from_gemini(root), - _ => Vec::new(), - }); - } - _ => {} - } - } - if payload.jetbrains.enabled { - candidates.extend(collect_from_jetbrains(Some( - &payload.jetbrains.config_root_path, - ))); - } - } - - if payload.roots_enabled { - for root in &payload.roots { - candidates.extend( - list_immediate_subdirs(Path::new(root)) - .into_iter() - .map(|path| RecentProject { - path: analyzer::display_path(&path), - ide: None, - }), - ); - } - } - - let existing = payload - .existing_paths - .iter() - .map(|path| analyzer::normalize_path_key(Path::new(path))) - .collect::>(); - let cache = payload - .cache_entries - .iter() - .map(|entry| { - ( - analyzer::normalize_path_key(Path::new(&entry.path)), - entry.clone(), - ) - }) - .collect::>(); - - let max_scan_bytes = analyzer::max_scan_bytes(); - - let mut seen = HashSet::new(); - let mut items = Vec::new(); - for candidate in candidates { - let path = PathBuf::from(&candidate.path); - if !path.is_dir() { - continue; - } - - let canonical = analyzer::canonical_or_original(&path); - let key = analyzer::normalize_path_key(&canonical); - if existing.contains(&key) || !seen.insert(key.clone()) { - continue; - } - - items.push(scan_one_project( - canonical, - candidate.ide, - max_scan_bytes, - cache.get(&key), - )); - } - - Ok(ScanResult { items }) -} - -fn scan_one_project( - path: PathBuf, - ide: Option, - max_scan_bytes: u64, - cached: Option<&ScanCacheEntry>, -) -> ScanItem { - let name = path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .filter(|name| !name.is_empty()) - .unwrap_or_default(); - let path_string = analyzer::display_path(&path); - - match directory_state_capped(&path, max_scan_bytes) { - Ok(state) if cached.is_some_and(|entry| entry.signature == state.signature) => { - let cached = cached.expect("cache entry checked above"); - ScanItem { - path: path_string, - name: if cached.name.is_empty() { - name - } else { - cached.name.clone() - }, - main_lang: cached.main_lang.clone(), - main_lang_color: cached.main_lang_color.clone(), - lang_group: cached.lang_group.clone(), - ide, - error: cached.error.clone(), - signature: Some(state.signature), - } - } - Ok(state) if state.total_bytes > max_scan_bytes => ScanItem { - path: path_string, - name, - main_lang: None, - main_lang_color: None, - lang_group: None, - ide, - error: Some(format!("Directory too large ({} MB)", state.total_bytes / (1024 * 1024))), - signature: Some(state.signature), - }, - Err(error) => ScanItem { - path: path_string, - name, - main_lang: None, - main_lang_color: None, - lang_group: None, - ide, - error: Some(error), - signature: None, - }, - Ok(state) => match analyzer::analyze_folder(&path) { - Ok(analysis) => { - let mut entries = analysis.languages.results.into_iter().collect::>(); - sort_languages(&mut entries); - let main_lang = entries.first().map(|(name, _)| name.clone()); - let main_lang_color = entries.first().and_then(|(_, stats)| stats.color.clone()); - let lang_group = (!entries.is_empty()).then(|| to_lang_group(&entries)); - - ScanItem { - path: path_string, - name, - main_lang, - main_lang_color, - lang_group, - ide, - error: None, - signature: Some(state.signature), - } - } - Err(error) => ScanItem { - path: path_string, - name, - main_lang: None, - main_lang_color: None, - lang_group: None, - ide, - error: Some(error), - signature: Some(state.signature), - }, - }, - } -} - -fn list_immediate_subdirs(root: &Path) -> Vec { - let Ok(entries) = fs::read_dir(root) else { - return Vec::new(); - }; - entries - .flatten() - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - .collect() -} - -struct DirectoryState { - total_bytes: u64, - signature: String, -} - -fn directory_state_capped(root: &Path, cap: u64) -> Result { - let mut total: u64 = 0; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - let walker = WalkBuilder::new(root) - .hidden(true) - .ignore(true) - .git_ignore(true) - .parents(true) - .filter_entry(|entry| !analyzer::is_skipped_dir(entry)) - .build(); - - for entry in walker { - let entry = match entry { - Ok(entry) => entry, - Err(_) => continue, - }; - if !entry - .file_type() - .map(|file_type| file_type.is_file()) - .unwrap_or(false) - { - continue; - } - let Ok(metadata) = entry.metadata() else { - continue; - }; - let relative = entry.path().strip_prefix(root).unwrap_or(entry.path()); - relative.to_string_lossy().hash(&mut hasher); - let len = metadata.len(); - len.hash(&mut hasher); - if let Ok(modified) = metadata.modified() { - if let Ok(duration) = modified.duration_since(UNIX_EPOCH) { - duration.as_secs().hash(&mut hasher); - duration.subsec_nanos().hash(&mut hasher); - } - } - total = total.saturating_add(len); - if total > cap { - return Ok(DirectoryState { - total_bytes: total, - signature: format!("{:016x}", hasher.finish()), - }); - } - } - Ok(DirectoryState { - total_bytes: total, - signature: format!("{:016x}", hasher.finish()), - }) -} - -fn sort_languages(entries: &mut [(String, LanguageStats)]) { - entries.sort_by(|(name_a, stats_a), (name_b, stats_b)| { - type_priority(stats_a.language_type) - .cmp(&type_priority(stats_b.language_type)) - .then_with(|| stats_b.bytes.cmp(&stats_a.bytes)) - .then_with(|| name_a.cmp(name_b)) - }); -} - -fn type_priority(language_type: LanguageType) -> u8 { - match language_type { - LanguageType::Programming => 1, - LanguageType::Markup => 2, - LanguageType::Data => 3, - LanguageType::Prose => 4, - } -} - -fn to_lang_group(entries: &[(String, LanguageStats)]) -> Vec { - let total_bytes = entries - .iter() - .map(|(_, stats)| stats.bytes) - .sum::() - .max(1) as f64; - let mut big = Vec::new(); - let mut other_percentage = 0.0; - - for (name, stats) in entries { - let percentage = round2((stats.bytes as f64 / total_bytes) * 100.0); - if percentage < 0.5 { - other_percentage += percentage; - } else { - big.push(LangGroupItem { - text: name.clone(), - color: stats.color.clone().unwrap_or_else(|| "#cccccc".to_string()), - percentage, - }); - } - } - - if other_percentage > 0.0 { - big.push(LangGroupItem { - text: "Other".to_string(), - color: "#cccccc".to_string(), - percentage: round2(other_percentage), - }); - } - - big -} - -fn round2(value: f64) -> f64 { - (value * 100.0).round() / 100.0 -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn parses_legacy_vscode_scan_payload() { - let payload: ScanPayload = serde_json::from_value(json!({ - "rootsEnabled": false, - "roots": [], - "ideEnabled": true, - "jetbrains": { - "enabled": false, - "configRootPath": "" - }, - "vscode": { - "enabled": true, - "stateDbPath": "state.vscdb" - }, - "existingPaths": [] - })) - .expect("legacy vscode payload should parse"); - - assert!(payload.recent_editors.is_empty()); - let vscode = payload.vscode.expect("legacy vscode config should exist"); - assert!(vscode.enabled); - assert_eq!(vscode.state_db_path, "state.vscdb"); - } - - #[test] - fn parses_recent_editor_scan_payload() { - let payload: ScanPayload = serde_json::from_value(json!({ - "rootsEnabled": false, - "roots": [], - "ideEnabled": true, - "jetbrains": { - "enabled": false, - "configRootPath": "" - }, - "recentEditors": { - "cursor": { - "enabled": true, - "stateDbPath": "cursor.vscdb" - }, - "visual-studio-code": { - "enabled": false, - "stateDbPath": "" - } - }, - "existingPaths": [] - })) - .expect("recent editor payload should parse"); - - assert!(payload.vscode.is_none()); - let cursor = payload - .recent_editors - .get("cursor") - .expect("cursor config should exist"); - assert!(cursor.enabled); - assert_eq!(cursor.state_db_path, "cursor.vscdb"); - } - - #[test] - fn parses_cli_editor_scan_payload() { - let payload: ScanPayload = serde_json::from_value(json!({ - "rootsEnabled": false, - "roots": [], - "ideEnabled": true, - "jetbrains": { - "enabled": false, - "configRootPath": "" - }, - "cliEditors": { - "codex-cli": { - "enabled": true, - "historyRootPath": ".codex" - } - }, - "existingPaths": [] - })) - .expect("cli editor payload should parse"); - - let codex = payload - .cli_editors - .get("codex-cli") - .expect("codex config should exist"); - assert!(codex.enabled); - assert_eq!(codex.history_root_path, ".codex"); - } - - #[test] - fn ignores_unknown_recent_editor_for_auto_detect() { - assert!(vscode_history_state_db_candidates("unknown-editor").is_empty()); - } + detect::detect_cli_history_root_path(&editor) } diff --git a/src-tauri/src/scanner/detect.rs b/src-tauri/src/scanner/detect.rs new file mode 100644 index 0000000..c68959b --- /dev/null +++ b/src-tauri/src/scanner/detect.rs @@ -0,0 +1,111 @@ +use std::path::{Path, PathBuf}; + +use tauri::Manager; + +pub(super) fn detect_jetbrains_config_root_path(app: tauri::AppHandle) -> Option { + let mut candidates = Vec::new(); + if cfg!(windows) { + if let Ok(app_data) = app.path().app_data_dir() { + let roaming = app_data + .parent() + .and_then(Path::parent) + .map(|path| path.join("JetBrains")); + if let Some(path) = roaming { + candidates.push(path); + } + } + if let Some(data_dir) = dirs::data_dir() { + candidates.push(data_dir.join("JetBrains")); + } + } else if cfg!(target_os = "macos") { + if let Some(home) = dirs::home_dir() { + candidates.push( + home.join("Library") + .join("Application Support") + .join("JetBrains"), + ); + } + } else if let Some(home) = dirs::home_dir() { + candidates.push(home.join(".config").join("JetBrains")); + candidates.push(home.join(".JetBrains")); + } + + candidates + .into_iter() + .find(|path| path.is_dir()) + .map(|path| path.to_string_lossy().into_owned()) +} + +pub(super) fn detect_vscode_state_db_path() -> Option { + detect_recent_editor_state_db_path("visual-studio-code") +} + +pub(super) fn detect_recent_editor_state_db_path(editor: &str) -> Option { + vscode_history_state_db_candidates(editor) + .into_iter() + .find(|path| path.is_file()) + .map(|path| path.to_string_lossy().into_owned()) +} + +pub(super) fn detect_cli_history_root_path(editor: &str) -> Option { + let dir_name = match editor { + "codex-cli" => ".codex", + "claude-code" => ".claude", + "gemini-cli" => ".gemini", + _ => return None, + }; + dirs::home_dir() + .map(|home| home.join(dir_name)) + .filter(|path| path.is_dir()) + .map(|path| path.to_string_lossy().into_owned()) +} + +fn vscode_history_state_db_candidates(editor: &str) -> Vec { + let app_names = match editor { + "visual-studio-code" => &["Code"][..], + "cursor" => &["Cursor", "cursor"][..], + "windsurf" => &["Windsurf", "windsurf"][..], + "trae" => &["Trae", "trae", "Trae CN", "TraeCN"][..], + _ => return Vec::new(), + }; + + let mut candidates = Vec::new(); + if cfg!(windows) { + if let Some(data_dir) = dirs::data_dir() { + for app_name in app_names { + candidates.push(vscode_history_state_db_path(&data_dir.join(app_name))); + } + } + } else if cfg!(target_os = "macos") { + if let Some(home) = dirs::home_dir() { + let support = home.join("Library").join("Application Support"); + for app_name in app_names { + candidates.push(vscode_history_state_db_path(&support.join(app_name))); + } + } + } else if let Some(home) = dirs::home_dir() { + let config = home.join(".config"); + for app_name in app_names { + candidates.push(vscode_history_state_db_path(&config.join(app_name))); + } + } + + candidates +} + +fn vscode_history_state_db_path(app_data_root: &Path) -> PathBuf { + app_data_root + .join("User") + .join("globalStorage") + .join("state.vscdb") +} + +#[cfg(test)] +mod tests { + use super::vscode_history_state_db_candidates; + + #[test] + fn ignores_unknown_recent_editor_for_auto_detect() { + assert!(vscode_history_state_db_candidates("unknown-editor").is_empty()); + } +} diff --git a/src-tauri/src/scanner/models.rs b/src-tauri/src/scanner/models.rs new file mode 100644 index 0000000..0b9f90d --- /dev/null +++ b/src-tauri/src/scanner/models.rs @@ -0,0 +1,193 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +pub(super) const HISTORY_SCANNER_EDITORS: &[&str] = &[ + "cursor", + "trae", + "windsurf", + "claude-code", + "codex-cli", + "gemini-cli", + "visual-studio-code", +]; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanPayload { + pub(super) roots_enabled: bool, + pub(super) roots: Vec, + pub(super) ide_enabled: bool, + pub(super) jetbrains: JetbrainsScannerConfig, + #[serde(default)] + pub(super) recent_editors: HashMap, + #[serde(default)] + pub(super) cli_editors: HashMap, + #[serde(default)] + pub(super) vscode: Option, + pub(super) existing_paths: Vec, + #[serde(default)] + pub(super) cache_entries: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct JetbrainsScannerConfig { + pub(super) enabled: bool, + pub(super) config_root_path: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct VscodeScannerConfig { + pub(super) enabled: bool, + pub(super) state_db_path: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CliScannerConfig { + pub(super) enabled: bool, + pub(super) history_root_path: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanResult { + pub(super) items: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanItem { + pub(super) path: String, + pub(super) name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) main_lang: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) main_lang_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) lang_group: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) ide: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) signature: Option, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ScanCacheEntry { + pub(super) path: String, + pub(super) signature: String, + #[serde(default)] + pub(super) name: String, + #[serde(default)] + pub(super) main_lang: Option, + #[serde(default)] + pub(super) main_lang_color: Option, + #[serde(default)] + pub(super) lang_group: Option>, + #[serde(default)] + pub(super) error: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct LangGroupItem { + pub(super) text: String, + pub(super) color: String, + pub(super) percentage: f64, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn parses_legacy_vscode_scan_payload() { + let payload: ScanPayload = serde_json::from_value(json!({ + "rootsEnabled": false, + "roots": [], + "ideEnabled": true, + "jetbrains": { + "enabled": false, + "configRootPath": "" + }, + "vscode": { + "enabled": true, + "stateDbPath": "state.vscdb" + }, + "existingPaths": [] + })) + .expect("legacy vscode payload should parse"); + + assert!(payload.recent_editors.is_empty()); + let vscode = payload.vscode.expect("legacy vscode config should exist"); + assert!(vscode.enabled); + assert_eq!(vscode.state_db_path, "state.vscdb"); + } + + #[test] + fn parses_recent_editor_scan_payload() { + let payload: ScanPayload = serde_json::from_value(json!({ + "rootsEnabled": false, + "roots": [], + "ideEnabled": true, + "jetbrains": { + "enabled": false, + "configRootPath": "" + }, + "recentEditors": { + "cursor": { + "enabled": true, + "stateDbPath": "cursor.vscdb" + }, + "visual-studio-code": { + "enabled": false, + "stateDbPath": "" + } + }, + "existingPaths": [] + })) + .expect("recent editor payload should parse"); + + assert!(payload.vscode.is_none()); + let cursor = payload + .recent_editors + .get("cursor") + .expect("cursor config should exist"); + assert!(cursor.enabled); + assert_eq!(cursor.state_db_path, "cursor.vscdb"); + } + + #[test] + fn parses_cli_editor_scan_payload() { + let payload: ScanPayload = serde_json::from_value(json!({ + "rootsEnabled": false, + "roots": [], + "ideEnabled": true, + "jetbrains": { + "enabled": false, + "configRootPath": "" + }, + "cliEditors": { + "codex-cli": { + "enabled": true, + "historyRootPath": ".codex" + } + }, + "existingPaths": [] + })) + .expect("cli editor payload should parse"); + + let codex = payload + .cli_editors + .get("codex-cli") + .expect("codex config should exist"); + assert!(codex.enabled); + assert_eq!(codex.history_root_path, ".codex"); + } +} diff --git a/src-tauri/src/scanner/scan.rs b/src-tauri/src/scanner/scan.rs new file mode 100644 index 0000000..e92764c --- /dev/null +++ b/src-tauri/src/scanner/scan.rs @@ -0,0 +1,338 @@ +use std::{ + collections::{HashMap, HashSet}, + fs, + hash::{Hash, Hasher}, + path::{Path, PathBuf}, + time::UNIX_EPOCH, +}; + +use ignore::WalkBuilder; + +use crate::{ + analyzer::{self, LanguageStats, LanguageType}, + recent::{ + collect_from_claude, collect_from_codex, collect_from_gemini, collect_from_jetbrains, + collect_from_vscode, RecentProject, + }, +}; + +use super::models::{ + LangGroupItem, ScanCacheEntry, ScanItem, ScanPayload, ScanResult, HISTORY_SCANNER_EDITORS, +}; + +pub(super) fn scan_projects(payload: ScanPayload) -> Result { + let mut candidates = Vec::new(); + + if payload.ide_enabled { + for editor in HISTORY_SCANNER_EDITORS { + match *editor { + "cursor" | "trae" | "windsurf" | "visual-studio-code" => { + let config = payload.recent_editors.get(*editor).or_else(|| { + if *editor == "visual-studio-code" { + payload.vscode.as_ref() + } else { + None + } + }); + let Some(config) = config.filter(|config| config.enabled) else { + continue; + }; + + candidates.extend( + collect_from_vscode(Some(&config.state_db_path)) + .into_iter() + .map(|item| RecentProject { + path: item.path, + ide: Some((*editor).to_string()), + }), + ); + } + "claude-code" | "codex-cli" | "gemini-cli" => { + let Some(config) = payload + .cli_editors + .get(*editor) + .filter(|config| config.enabled) + else { + continue; + }; + let root = Some(config.history_root_path.as_str()); + candidates.extend(match *editor { + "claude-code" => collect_from_claude(root), + "codex-cli" => collect_from_codex(root), + "gemini-cli" => collect_from_gemini(root), + _ => Vec::new(), + }); + } + _ => {} + } + } + if payload.jetbrains.enabled { + candidates.extend(collect_from_jetbrains(Some( + &payload.jetbrains.config_root_path, + ))); + } + } + + if payload.roots_enabled { + for root in &payload.roots { + candidates.extend( + list_immediate_subdirs(Path::new(root)) + .into_iter() + .map(|path| RecentProject { + path: analyzer::display_path(&path), + ide: None, + }), + ); + } + } + + let existing = payload + .existing_paths + .iter() + .map(|path| analyzer::normalize_path_key(Path::new(path))) + .collect::>(); + let cache = payload + .cache_entries + .iter() + .map(|entry| { + ( + analyzer::normalize_path_key(Path::new(&entry.path)), + entry.clone(), + ) + }) + .collect::>(); + + let max_scan_bytes = analyzer::max_scan_bytes(); + + let mut seen = HashSet::new(); + let mut items = Vec::new(); + for candidate in candidates { + let path = PathBuf::from(&candidate.path); + if !path.is_dir() { + continue; + } + + let canonical = analyzer::canonical_or_original(&path); + let key = analyzer::normalize_path_key(&canonical); + if existing.contains(&key) || !seen.insert(key.clone()) { + continue; + } + + items.push(scan_one_project( + canonical, + candidate.ide, + max_scan_bytes, + cache.get(&key), + )); + } + + Ok(ScanResult { items }) +} + +fn scan_one_project( + path: PathBuf, + ide: Option, + max_scan_bytes: u64, + cached: Option<&ScanCacheEntry>, +) -> ScanItem { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .filter(|name| !name.is_empty()) + .unwrap_or_default(); + let path_string = analyzer::display_path(&path); + + match directory_state_capped(&path, max_scan_bytes) { + Ok(state) if cached.is_some_and(|entry| entry.signature == state.signature) => { + let cached = cached.expect("cache entry checked above"); + ScanItem { + path: path_string, + name: if cached.name.is_empty() { + name + } else { + cached.name.clone() + }, + main_lang: cached.main_lang.clone(), + main_lang_color: cached.main_lang_color.clone(), + lang_group: cached.lang_group.clone(), + ide, + error: cached.error.clone(), + signature: Some(state.signature), + } + } + Ok(state) if state.total_bytes > max_scan_bytes => ScanItem { + path: path_string, + name, + main_lang: None, + main_lang_color: None, + lang_group: None, + ide, + error: Some(format!( + "Directory too large ({} MB)", + state.total_bytes / (1024 * 1024) + )), + signature: Some(state.signature), + }, + Err(error) => ScanItem { + path: path_string, + name, + main_lang: None, + main_lang_color: None, + lang_group: None, + ide, + error: Some(error), + signature: None, + }, + Ok(state) => match analyzer::analyze_folder(&path) { + Ok(analysis) => { + let mut entries = analysis.languages.results.into_iter().collect::>(); + sort_languages(&mut entries); + let main_lang = entries.first().map(|(name, _)| name.clone()); + let main_lang_color = entries.first().and_then(|(_, stats)| stats.color.clone()); + let lang_group = (!entries.is_empty()).then(|| to_lang_group(&entries)); + + ScanItem { + path: path_string, + name, + main_lang, + main_lang_color, + lang_group, + ide, + error: None, + signature: Some(state.signature), + } + } + Err(error) => ScanItem { + path: path_string, + name, + main_lang: None, + main_lang_color: None, + lang_group: None, + ide, + error: Some(error), + signature: Some(state.signature), + }, + }, + } +} + +fn list_immediate_subdirs(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root) else { + return Vec::new(); + }; + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect() +} + +struct DirectoryState { + total_bytes: u64, + signature: String, +} + +fn directory_state_capped(root: &Path, cap: u64) -> Result { + let mut total: u64 = 0; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let walker = WalkBuilder::new(root) + .hidden(true) + .ignore(true) + .git_ignore(true) + .parents(true) + .filter_entry(|entry| !analyzer::is_skipped_dir(entry)) + .build(); + + for entry in walker { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + if !entry + .file_type() + .map(|file_type| file_type.is_file()) + .unwrap_or(false) + { + continue; + } + let Ok(metadata) = entry.metadata() else { + continue; + }; + let relative = entry.path().strip_prefix(root).unwrap_or(entry.path()); + relative.to_string_lossy().hash(&mut hasher); + let len = metadata.len(); + len.hash(&mut hasher); + if let Ok(modified) = metadata.modified() { + if let Ok(duration) = modified.duration_since(UNIX_EPOCH) { + duration.as_secs().hash(&mut hasher); + duration.subsec_nanos().hash(&mut hasher); + } + } + total = total.saturating_add(len); + if total > cap { + return Ok(DirectoryState { + total_bytes: total, + signature: format!("{:016x}", hasher.finish()), + }); + } + } + Ok(DirectoryState { + total_bytes: total, + signature: format!("{:016x}", hasher.finish()), + }) +} + +fn sort_languages(entries: &mut [(String, LanguageStats)]) { + entries.sort_by(|(name_a, stats_a), (name_b, stats_b)| { + type_priority(stats_a.language_type) + .cmp(&type_priority(stats_b.language_type)) + .then_with(|| stats_b.bytes.cmp(&stats_a.bytes)) + .then_with(|| name_a.cmp(name_b)) + }); +} + +fn type_priority(language_type: LanguageType) -> u8 { + match language_type { + LanguageType::Programming => 1, + LanguageType::Markup => 2, + LanguageType::Data => 3, + LanguageType::Prose => 4, + } +} + +fn to_lang_group(entries: &[(String, LanguageStats)]) -> Vec { + let total_bytes = entries + .iter() + .map(|(_, stats)| stats.bytes) + .sum::() + .max(1) as f64; + let mut big = Vec::new(); + let mut other_percentage = 0.0; + + for (name, stats) in entries { + let percentage = round2((stats.bytes as f64 / total_bytes) * 100.0); + if percentage < 0.5 { + other_percentage += percentage; + } else { + big.push(LangGroupItem { + text: name.clone(), + color: stats.color.clone().unwrap_or_else(|| "#cccccc".to_string()), + percentage, + }); + } + } + + if other_percentage > 0.0 { + big.push(LangGroupItem { + text: "Other".to_string(), + color: "#cccccc".to_string(), + percentage: round2(other_percentage), + }); + } + + big +} + +fn round2(value: f64) -> f64 { + (value * 100.0).round() / 100.0 +} diff --git a/src-tauri/src/system.rs b/src-tauri/src/system.rs index 66884e3..e5fcc9e 100644 --- a/src-tauri/src/system.rs +++ b/src-tauri/src/system.rs @@ -1,16 +1,11 @@ -use std::{ - fs, io, - path::{Path, PathBuf}, - process::Command, -}; +mod accent; +mod external; +mod path; +mod terminal; +mod window; use serde::Serialize; -use crate::command_line::parse_command_line; - -#[cfg(target_os = "windows")] -use winreg::{enums::HKEY_CURRENT_USER, RegKey}; - #[derive(Serialize)] pub struct PathExistenceResult { exists: bool, @@ -20,358 +15,50 @@ pub struct PathExistenceResult { #[tauri::command] pub fn format_path(file_path: String) -> String { - let Some(home) = dirs::home_dir() else { - return file_path; - }; - let home = home.to_string_lossy().into_owned(); - - let file_path_for_compare = normalize_path_for_home_compare(&file_path); - let home_for_compare = normalize_path_for_home_compare(&home); - if file_path_for_compare == home_for_compare { - return "~".to_string(); - } - - let Some(normalized_remainder) = file_path_for_compare.strip_prefix(&home_for_compare) else { - return file_path; - }; - - if !normalized_remainder.starts_with('/') { - return file_path; - } - - let remainder = &file_path[home_for_compare.len()..]; - format!("~{remainder}") -} - -#[cfg(target_os = "windows")] -fn normalize_path_for_home_compare(path: &str) -> String { - path.replace('\\', "/") - .trim_end_matches('/') - .to_ascii_lowercase() -} - -#[cfg(not(target_os = "windows"))] -fn normalize_path_for_home_compare(path: &str) -> String { - path.replace('\\', "/").trim_end_matches('/').to_string() + path::format_path(file_path) } #[tauri::command] pub fn check_path_existence(path: String) -> PathExistenceResult { - match fs::metadata(path) { - Ok(_) => PathExistenceResult { - exists: true, - error: None, - }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathExistenceResult { - exists: false, - error: None, - }, - Err(error) => PathExistenceResult { - exists: false, - error: Some(error.to_string()), - }, - } + path::check_path_existence(path) } #[tauri::command] pub fn open_external(url: String) -> bool { - open::that(url).is_ok() + external::open_external(url) } #[tauri::command] pub fn open_in_explorer(path: String) -> bool { - open::that(PathBuf::from(path)).is_ok() + external::open_in_explorer(path) } #[tauri::command] pub fn open_in_terminal(path: String, terminal_command: Option) -> Result { - let path = PathBuf::from(path); - if !path.is_dir() { - return Err("Project path is not a directory".to_string()); - } - - if let Some(command) = terminal_command - .as_deref() - .map(str::trim) - .filter(|cmd| !cmd.is_empty()) - { - spawn_configured_terminal(command, &path)?; - } else { - spawn_default_terminal(&path)?; - } - - Ok("Terminal opened".to_string()) -} - -fn spawn_configured_terminal(command_template: &str, cwd: &Path) -> Result<(), String> { - let args = configured_terminal_args(command_template, cwd)?; - let program = PathBuf::from(&args[0]); - spawn_terminal_program(&program, &args[1..], cwd) -} - -fn configured_terminal_args(command_template: &str, cwd: &Path) -> Result, String> { - let mut args = parse_command_line(command_template)?; - - let cwd_text = cwd.to_string_lossy().into_owned(); - for arg in &mut args { - // 占位符替换发生在 parse_command_line 之后,替换结果作为单个 - // 参数直接传给 Command::args,不经过 shell 解析,无需再转义。 - *arg = arg - .replace("{cwd}", &cwd_text) - .replace("{project}", &cwd_text); - } - - Ok(args) -} - -fn spawn_terminal_program(program: &Path, args: &[String], cwd: &Path) -> Result<(), String> { - if cfg!(windows) { - if let Some(shell) = windows_shell_program_name(program) { - return spawn_windows_shell_terminal(&shell, args, cwd); - } - - let extension = program - .extension() - .and_then(|extension| extension.to_str()) - .map(str::to_ascii_lowercase); - if matches!(extension.as_deref(), Some("bat" | "cmd")) { - let mut command = Command::new("cmd"); - command.current_dir(cwd); - command.args(["/C", "start", "", &program.to_string_lossy()]); - command.args(args); - command.spawn().map_err(|error| error.to_string())?; - return Ok(()); - } - } - - Command::new(program) - .current_dir(cwd) - .args(args) - .spawn() - .map(|_| ()) - .map_err(|error| format_spawn_error(program, error)) -} - -fn format_spawn_error(program: &Path, error: io::Error) -> String { - if error.kind() == io::ErrorKind::NotFound { - return format!("Command not found: {}", program.to_string_lossy()); - } - - error.to_string() -} - -fn windows_shell_program_name(program: &Path) -> Option { - let name = program - .file_stem() - .or_else(|| program.file_name()) - .and_then(|name| name.to_str())? - .to_ascii_lowercase(); - - matches!(name.as_str(), "cmd" | "powershell" | "pwsh").then_some(name) -} - -fn spawn_windows_shell_terminal(shell: &str, args: &[String], cwd: &Path) -> Result<(), String> { - let mut command = Command::new("cmd"); - command.current_dir(cwd); - command.args(["/C", "start", "", shell]); - - if args.is_empty() { - if shell == "cmd" { - command.arg("/K"); - } else { - command.arg("-NoExit"); - } - } else { - command.args(args); - } - - command - .spawn() - .map(|_| ()) - .map_err(|error| error.to_string()) -} - -fn spawn_default_terminal(cwd: &Path) -> Result<(), String> { - if cfg!(windows) { - spawn_default_windows_terminal(cwd) - } else if cfg!(target_os = "macos") { - Command::new("open") - .arg("-a") - .arg("Terminal") - .arg(cwd) - .spawn() - .map(|_| ()) - .map_err(|error| error.to_string()) - } else { - Command::new("x-terminal-emulator") - .current_dir(cwd) - .spawn() - .or_else(|_| Command::new("gnome-terminal").current_dir(cwd).spawn()) - .or_else(|_| Command::new("konsole").current_dir(cwd).spawn()) - .map(|_| ()) - .map_err(|error| error.to_string()) - } -} - -fn spawn_default_windows_terminal(cwd: &Path) -> Result<(), String> { - let cwd_text = cwd.to_string_lossy(); - let mut errors = Vec::new(); - - match Command::new("wt").arg("-d").arg(cwd).spawn() { - Ok(_) => return Ok(()), - Err(error) => errors.push(format!("wt: {error}")), - } - - let powershell_command = format!( - "Set-Location -LiteralPath '{}'", - cwd_text.replace('\'', "''") - ); - match Command::new("pwsh") - .args(["-NoExit", "-Command", &powershell_command]) - .spawn() - { - Ok(_) => return Ok(()), - Err(error) => errors.push(format!("pwsh: {error}")), - } - - match Command::new("powershell") - .args(["-NoExit", "-Command", &powershell_command]) - .spawn() - { - Ok(_) => return Ok(()), - Err(error) => errors.push(format!("powershell: {error}")), - } - - match Command::new("cmd") - .current_dir(cwd) - .args(["/C", "start", "", "cmd", "/K"]) - .spawn() - { - Ok(_) => Ok(()), - Err(error) => { - errors.push(format!("cmd: {error}")); - Err(format!("Failed to open terminal: {}", errors.join("; "))) - } - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::configured_terminal_args; - - #[test] - fn configured_terminal_args_keeps_cwd_with_spaces_as_one_arg() { - let args = - configured_terminal_args("wt -d {cwd}", Path::new(r"E:\Projects\Example App")).unwrap(); - - assert_eq!(args, vec!["wt", "-d", r"E:\Projects\Example App"]); - } + terminal::open_in_terminal(path, terminal_command) } #[tauri::command] -pub fn set_window_theme(_current_theme: String) {} - -#[cfg(target_os = "windows")] -#[link(name = "dwmapi")] -extern "system" { - fn DwmGetColorizationColor(colorization: *mut u32, opaque_blend: *mut i32) -> i32; -} - -#[cfg(target_os = "windows")] -const WINDOWS_ACCENT_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Explorer\Accent"; - -#[cfg(target_os = "windows")] -fn rgb_to_hex(red: u32, green: u32, blue: u32) -> String { - format!("#{red:02x}{green:02x}{blue:02x}") -} - -#[cfg(target_os = "windows")] -fn abgr_to_hex(color: u32) -> String { - let red = color & 0xff; - let green = (color >> 8) & 0xff; - let blue = (color >> 16) & 0xff; - rgb_to_hex(red, green, blue) -} - -#[cfg(target_os = "windows")] -fn argb_to_hex(color: u32) -> String { - let red = (color >> 16) & 0xff; - let green = (color >> 8) & 0xff; - let blue = color & 0xff; - rgb_to_hex(red, green, blue) -} - -#[cfg(target_os = "windows")] -fn read_windows_accent_palette_color(current_theme: Option<&str>) -> Option { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let accent_key = hkcu.open_subkey(WINDOWS_ACCENT_KEY).ok()?; - let palette = accent_key.get_raw_value("AccentPalette").ok()?.bytes; - let palette_index = if current_theme == Some("dark") { 2 } else { 4 }; - let offset = palette_index * 4; - - if palette.len() < offset + 3 { - return None; - } - - Some(rgb_to_hex( - palette[offset].into(), - palette[offset + 1].into(), - palette[offset + 2].into(), - )) -} - -#[cfg(target_os = "windows")] -fn read_windows_accent_color_menu() -> Option { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let accent_key = hkcu.open_subkey(WINDOWS_ACCENT_KEY).ok()?; - let color = accent_key.get_value::("AccentColorMenu").ok()?; - Some(abgr_to_hex(color)) -} - -#[cfg(target_os = "windows")] -fn read_windows_dwm_accent_color() -> Option { - unsafe { - let mut color = 0_u32; - let mut opaque_blend = 0_i32; - if DwmGetColorizationColor(&mut color, &mut opaque_blend) == 0 { - return Some(argb_to_hex(color)); - } - } - - None +pub fn set_window_theme(current_theme: String) { + accent::set_window_theme(current_theme) } #[tauri::command] pub fn get_system_accent_color(current_theme: Option) -> Option { - #[cfg(target_os = "windows")] - { - let current_theme = current_theme.as_deref(); - read_windows_accent_palette_color(current_theme) - .or_else(read_windows_accent_color_menu) - .or_else(read_windows_dwm_accent_color) - } - - #[cfg(not(target_os = "windows"))] - None + accent::get_system_accent_color(current_theme) } #[tauri::command] pub fn minimize_window(window: tauri::WebviewWindow) -> bool { - window.minimize().is_ok() + window::minimize_window(window) } #[tauri::command] pub fn toggle_maximize_window(window: tauri::WebviewWindow) -> bool { - match window.is_maximized() { - Ok(true) => window.unmaximize().is_ok(), - Ok(false) => window.maximize().is_ok(), - Err(_) => false, - } + window::toggle_maximize_window(window) } #[tauri::command] pub fn close_window(window: tauri::WebviewWindow) -> bool { - window.close().is_ok() + window::close_window(window) } diff --git a/src-tauri/src/system/accent.rs b/src-tauri/src/system/accent.rs new file mode 100644 index 0000000..c922c6a --- /dev/null +++ b/src-tauri/src/system/accent.rs @@ -0,0 +1,87 @@ +pub(super) fn set_window_theme(_current_theme: String) {} + +#[cfg(target_os = "windows")] +use winreg::{enums::HKEY_CURRENT_USER, RegKey}; + +#[cfg(target_os = "windows")] +#[link(name = "dwmapi")] +extern "system" { + fn DwmGetColorizationColor(colorization: *mut u32, opaque_blend: *mut i32) -> i32; +} + +#[cfg(target_os = "windows")] +const WINDOWS_ACCENT_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Explorer\Accent"; + +#[cfg(target_os = "windows")] +fn rgb_to_hex(red: u32, green: u32, blue: u32) -> String { + format!("#{red:02x}{green:02x}{blue:02x}") +} + +#[cfg(target_os = "windows")] +fn abgr_to_hex(color: u32) -> String { + let red = color & 0xff; + let green = (color >> 8) & 0xff; + let blue = (color >> 16) & 0xff; + rgb_to_hex(red, green, blue) +} + +#[cfg(target_os = "windows")] +fn argb_to_hex(color: u32) -> String { + let red = (color >> 16) & 0xff; + let green = (color >> 8) & 0xff; + let blue = color & 0xff; + rgb_to_hex(red, green, blue) +} + +#[cfg(target_os = "windows")] +fn read_windows_accent_palette_color(current_theme: Option<&str>) -> Option { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let accent_key = hkcu.open_subkey(WINDOWS_ACCENT_KEY).ok()?; + let palette = accent_key.get_raw_value("AccentPalette").ok()?.bytes; + let palette_index = if current_theme == Some("dark") { 2 } else { 4 }; + let offset = palette_index * 4; + + if palette.len() < offset + 3 { + return None; + } + + Some(rgb_to_hex( + palette[offset].into(), + palette[offset + 1].into(), + palette[offset + 2].into(), + )) +} + +#[cfg(target_os = "windows")] +fn read_windows_accent_color_menu() -> Option { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let accent_key = hkcu.open_subkey(WINDOWS_ACCENT_KEY).ok()?; + let color = accent_key.get_value::("AccentColorMenu").ok()?; + Some(abgr_to_hex(color)) +} + +#[cfg(target_os = "windows")] +fn read_windows_dwm_accent_color() -> Option { + unsafe { + let mut color = 0_u32; + let mut opaque_blend = 0_i32; + if DwmGetColorizationColor(&mut color, &mut opaque_blend) == 0 { + return Some(argb_to_hex(color)); + } + } + + None +} + +pub(super) fn get_system_accent_color(current_theme: Option) -> Option { + #[cfg(target_os = "windows")] + { + let current_theme = current_theme.as_deref(); + read_windows_accent_palette_color(current_theme) + .or_else(read_windows_accent_color_menu) + .or_else(read_windows_dwm_accent_color) + } + + #[cfg(not(target_os = "windows"))] + None +} diff --git a/src-tauri/src/system/external.rs b/src-tauri/src/system/external.rs new file mode 100644 index 0000000..4583315 --- /dev/null +++ b/src-tauri/src/system/external.rs @@ -0,0 +1,9 @@ +use std::path::PathBuf; + +pub(super) fn open_external(url: String) -> bool { + open::that(url).is_ok() +} + +pub(super) fn open_in_explorer(path: String) -> bool { + open::that(PathBuf::from(path)).is_ok() +} diff --git a/src-tauri/src/system/path.rs b/src-tauri/src/system/path.rs new file mode 100644 index 0000000..051909b --- /dev/null +++ b/src-tauri/src/system/path.rs @@ -0,0 +1,56 @@ +use std::fs; + +use super::PathExistenceResult; + +pub(super) fn format_path(file_path: String) -> String { + let Some(home) = dirs::home_dir() else { + return file_path; + }; + let home = home.to_string_lossy().into_owned(); + + let file_path_for_compare = normalize_path_for_home_compare(&file_path); + let home_for_compare = normalize_path_for_home_compare(&home); + if file_path_for_compare == home_for_compare { + return "~".to_string(); + } + + let Some(normalized_remainder) = file_path_for_compare.strip_prefix(&home_for_compare) else { + return file_path; + }; + + if !normalized_remainder.starts_with('/') { + return file_path; + } + + let remainder = &file_path[home_for_compare.len()..]; + format!("~{remainder}") +} + +#[cfg(target_os = "windows")] +fn normalize_path_for_home_compare(path: &str) -> String { + path.replace('\\', "/") + .trim_end_matches('/') + .to_ascii_lowercase() +} + +#[cfg(not(target_os = "windows"))] +fn normalize_path_for_home_compare(path: &str) -> String { + path.replace('\\', "/").trim_end_matches('/').to_string() +} + +pub(super) fn check_path_existence(path: String) -> PathExistenceResult { + match fs::metadata(path) { + Ok(_) => PathExistenceResult { + exists: true, + error: None, + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => PathExistenceResult { + exists: false, + error: None, + }, + Err(error) => PathExistenceResult { + exists: false, + error: Some(error.to_string()), + }, + } +} diff --git a/src-tauri/src/system/terminal.rs b/src-tauri/src/system/terminal.rs new file mode 100644 index 0000000..bb5ba95 --- /dev/null +++ b/src-tauri/src/system/terminal.rs @@ -0,0 +1,196 @@ +use std::{ + io, + path::{Path, PathBuf}, + process::Command, +}; + +use crate::command_line::parse_command_line; + +pub(super) fn open_in_terminal( + path: String, + terminal_command: Option, +) -> Result { + let path = PathBuf::from(path); + if !path.is_dir() { + return Err("Project path is not a directory".to_string()); + } + + if let Some(command) = terminal_command + .as_deref() + .map(str::trim) + .filter(|cmd| !cmd.is_empty()) + { + spawn_configured_terminal(command, &path)?; + } else { + spawn_default_terminal(&path)?; + } + + Ok("Terminal opened".to_string()) +} + +fn spawn_configured_terminal(command_template: &str, cwd: &Path) -> Result<(), String> { + let args = configured_terminal_args(command_template, cwd)?; + let program = PathBuf::from(&args[0]); + spawn_terminal_program(&program, &args[1..], cwd) +} + +fn configured_terminal_args(command_template: &str, cwd: &Path) -> Result, String> { + let mut args = parse_command_line(command_template)?; + + let cwd_text = cwd.to_string_lossy().into_owned(); + for arg in &mut args { + // 占位符替换发生在 parse_command_line 之后,替换结果作为单个 + // 参数直接传给 Command::args,不经过 shell 解析,无需再转义。 + *arg = arg + .replace("{cwd}", &cwd_text) + .replace("{project}", &cwd_text); + } + + Ok(args) +} + +fn spawn_terminal_program(program: &Path, args: &[String], cwd: &Path) -> Result<(), String> { + if cfg!(windows) { + if let Some(shell) = windows_shell_program_name(program) { + return spawn_windows_shell_terminal(&shell, args, cwd); + } + + let extension = program + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase); + if matches!(extension.as_deref(), Some("bat" | "cmd")) { + let mut command = Command::new("cmd"); + command.current_dir(cwd); + command.args(["/C", "start", "", &program.to_string_lossy()]); + command.args(args); + command.spawn().map_err(|error| error.to_string())?; + return Ok(()); + } + } + + Command::new(program) + .current_dir(cwd) + .args(args) + .spawn() + .map(|_| ()) + .map_err(|error| format_spawn_error(program, error)) +} + +fn format_spawn_error(program: &Path, error: io::Error) -> String { + if error.kind() == io::ErrorKind::NotFound { + return format!("Command not found: {}", program.to_string_lossy()); + } + + error.to_string() +} + +fn windows_shell_program_name(program: &Path) -> Option { + let name = program + .file_stem() + .or_else(|| program.file_name()) + .and_then(|name| name.to_str())? + .to_ascii_lowercase(); + + matches!(name.as_str(), "cmd" | "powershell" | "pwsh").then_some(name) +} + +fn spawn_windows_shell_terminal(shell: &str, args: &[String], cwd: &Path) -> Result<(), String> { + let mut command = Command::new("cmd"); + command.current_dir(cwd); + command.args(["/C", "start", "", shell]); + + if args.is_empty() { + if shell == "cmd" { + command.arg("/K"); + } else { + command.arg("-NoExit"); + } + } else { + command.args(args); + } + + command + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn spawn_default_terminal(cwd: &Path) -> Result<(), String> { + if cfg!(windows) { + spawn_default_windows_terminal(cwd) + } else if cfg!(target_os = "macos") { + Command::new("open") + .arg("-a") + .arg("Terminal") + .arg(cwd) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()) + } else { + Command::new("x-terminal-emulator") + .current_dir(cwd) + .spawn() + .or_else(|_| Command::new("gnome-terminal").current_dir(cwd).spawn()) + .or_else(|_| Command::new("konsole").current_dir(cwd).spawn()) + .map(|_| ()) + .map_err(|error| error.to_string()) + } +} + +fn spawn_default_windows_terminal(cwd: &Path) -> Result<(), String> { + let cwd_text = cwd.to_string_lossy(); + let mut errors = Vec::new(); + + match Command::new("wt").arg("-d").arg(cwd).spawn() { + Ok(_) => return Ok(()), + Err(error) => errors.push(format!("wt: {error}")), + } + + let powershell_command = format!( + "Set-Location -LiteralPath '{}'", + cwd_text.replace('\'', "''") + ); + match Command::new("pwsh") + .args(["-NoExit", "-Command", &powershell_command]) + .spawn() + { + Ok(_) => return Ok(()), + Err(error) => errors.push(format!("pwsh: {error}")), + } + + match Command::new("powershell") + .args(["-NoExit", "-Command", &powershell_command]) + .spawn() + { + Ok(_) => return Ok(()), + Err(error) => errors.push(format!("powershell: {error}")), + } + + match Command::new("cmd") + .current_dir(cwd) + .args(["/C", "start", "", "cmd", "/K"]) + .spawn() + { + Ok(_) => Ok(()), + Err(error) => { + errors.push(format!("cmd: {error}")); + Err(format!("Failed to open terminal: {}", errors.join("; "))) + } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::configured_terminal_args; + + #[test] + fn configured_terminal_args_keeps_cwd_with_spaces_as_one_arg() { + let args = + configured_terminal_args("wt -d {cwd}", Path::new(r"E:\Projects\Example App")).unwrap(); + + assert_eq!(args, vec!["wt", "-d", r"E:\Projects\Example App"]); + } +} diff --git a/src-tauri/src/system/window.rs b/src-tauri/src/system/window.rs new file mode 100644 index 0000000..e7853c2 --- /dev/null +++ b/src-tauri/src/system/window.rs @@ -0,0 +1,15 @@ +pub(super) fn minimize_window(window: tauri::WebviewWindow) -> bool { + window.minimize().is_ok() +} + +pub(super) fn toggle_maximize_window(window: tauri::WebviewWindow) -> bool { + match window.is_maximized() { + Ok(true) => window.unmaximize().is_ok(), + Ok(false) => window.maximize().is_ok(), + Err(_) => false, + } +} + +pub(super) fn close_window(window: tauri::WebviewWindow) -> bool { + window.close().is_ok() +} diff --git a/src-tauri/src/webdav.rs b/src-tauri/src/webdav.rs index a081b59..32ea59f 100644 --- a/src-tauri/src/webdav.rs +++ b/src-tauri/src/webdav.rs @@ -1,77 +1,16 @@ -use std::{ - fs, - path::PathBuf, - sync::LazyLock, - time::{SystemTime, UNIX_EPOCH}, -}; +mod error; +mod files; +mod models; +mod remote; +mod sync; -use regex::Regex; -use reqwest::{Client, Method, StatusCode}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tauri::{AppHandle, Manager}; -use url::Url; +use tauri::AppHandle; -use crate::data::{data_file_path, write_data_file_safely, DataFileKind}; - -static URL_CREDENTIALS_PATTERN: LazyLock = LazyLock::new(|| { - Regex::new(r"://[^:/@\s]+:[^@/\s]+@").expect("hardcoded regex should be valid") -}); - -/// 从错误消息中遮蔽 `scheme://user:pass@host` 形式的凭据, -/// 防止 reqwest 错误把 Basic Auth 信息透传给前端。 -fn sanitize_url_for_error(error_msg: &str) -> String { - URL_CREDENTIALS_PATTERN - .replace_all(error_msg, "://***:***@") - .to_string() -} - -/// reqwest 错误统一脱敏转换。 -fn request_error(error: reqwest::Error) -> String { - sanitize_url_for_error(&error.to_string()) -} - -#[derive(Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WebDavConfig { - endpoint: String, - username: Option, - password: Option, - remote_path: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WebDavSyncResult { - success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -struct SyncFile { - kind: DataFileKind, - name: &'static str, - fallback: &'static str, -} - -const SYNC_FILES: [SyncFile; 2] = [ - SyncFile { - kind: DataFileKind::Settings, - name: "settings.json", - fallback: "{}", - }, - SyncFile { - kind: DataFileKind::Projects, - name: "projects.json", - fallback: "[]", - }, -]; +use models::{sync_error, sync_success, WebDavConfig, WebDavSyncResult}; #[tauri::command] pub async fn webdav_upload_data(app: AppHandle, config: WebDavConfig) -> WebDavSyncResult { - match upload_data(app, config).await { + match sync::upload_data(app, config).await { Ok(count) => sync_success(format!("Uploaded {count} files")), Err(error) => sync_error(error), } @@ -79,7 +18,7 @@ pub async fn webdav_upload_data(app: AppHandle, config: WebDavConfig) -> WebDavS #[tauri::command] pub async fn webdav_pull_data(app: AppHandle, config: WebDavConfig) -> WebDavSyncResult { - match pull_data(app, config).await { + match sync::pull_data(app, config).await { Ok(count) => sync_success(format!("Pulled {count} files")), Err(error) => sync_error(error), } @@ -87,384 +26,8 @@ pub async fn webdav_pull_data(app: AppHandle, config: WebDavConfig) -> WebDavSyn #[tauri::command] pub async fn webdav_test_connection(config: WebDavConfig) -> WebDavSyncResult { - match test_connection(config).await { + match remote::test_connection(config).await { Ok(()) => sync_success("WebDAV connection OK".to_string()), Err(error) => sync_error(error), } } - -async fn test_connection(config: WebDavConfig) -> Result<(), String> { - validate_config(&config)?; - - let client = Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|error| error.to_string())?; - let response = with_auth( - client.request(Method::OPTIONS, remote_url(&config, None, None)?), - &config, - ) - .send() - .await - .map_err(request_error)?; - - if response.status().is_success() { - return Ok(()); - } - - Err(format!("WebDAV connection failed: {}", response.status())) -} - -async fn upload_data(app: AppHandle, config: WebDavConfig) -> Result { - validate_config(&config)?; - - let client = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|error| error.to_string())?; - create_remote_dir(&client, &config).await?; - - let mut count = 0; - for file in SYNC_FILES { - let path = data_file_path(&app, file.kind)?; - let raw = read_or_default(path, file.fallback)?; - let content = prepare_upload_content(file.kind, &raw)?; - let url = remote_file_url(&config, file.name)?; - let request = with_auth(client.put(url).body(content), &config) - .header("Content-Type", "application/json"); - let response = request.send().await.map_err(request_error)?; - if !response.status().is_success() { - return Err(format!( - "Failed to upload {}: {}", - file.name, - response.status() - )); - } - count += 1; - } - - Ok(count) -} - -async fn pull_data(app: AppHandle, config: WebDavConfig) -> Result { - validate_config(&config)?; - - let client = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|error| error.to_string())?; - let mut pulled_files = Vec::new(); - for file in SYNC_FILES { - let url = remote_file_url(&config, file.name)?; - let response = with_auth(client.get(url), &config) - .send() - .await - .map_err(request_error)?; - if response.status() == StatusCode::NOT_FOUND { - return Err(format!("Remote file not found: {}", file.name)); - } - if !response.status().is_success() { - return Err(format!( - "Failed to pull {}: {}", - file.name, - response.status() - )); - } - - let raw = response.text().await.map_err(request_error)?; - let content = prepare_pull_content(&app, file.kind, &raw)?; - pulled_files.push((file.kind, content)); - } - - backup_local_sync_files(&app)?; - - let count = pulled_files.len(); - for (kind, content) in pulled_files { - write_data_file_safely(&data_file_path(&app, kind)?, &content)?; - } - - Ok(count) -} - -fn backup_local_sync_files(app: &AppHandle) -> Result { - let backup_root = app - .path() - .app_data_dir() - .map_err(|error| error.to_string())? - .join("backups") - .join(format!( - "webdav-pull-{}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| error.to_string())? - .as_secs() - )); - fs::create_dir_all(&backup_root).map_err(|error| error.to_string())?; - - for file in SYNC_FILES { - let source = data_file_path(app, file.kind)?; - if source.exists() { - fs::copy(source, backup_root.join(file.name)).map_err(|error| error.to_string())?; - } - } - - Ok(backup_root) -} - -async fn create_remote_dir(client: &Client, config: &WebDavConfig) -> Result<(), String> { - let remote_path = normalized_remote_path(config); - if remote_path.is_empty() { - return Ok(()); - } - - let mut current = String::new(); - for part in remote_path.split('/').filter(|part| !part.is_empty()) { - if !current.is_empty() { - current.push('/'); - } - current.push_str(part); - if remote_dir_exists(client, config, ¤t).await? { - continue; - } - - let url = remote_collection_url(config, ¤t)?; - let response = with_auth( - client.request( - Method::from_bytes(b"MKCOL").map_err(|error| error.to_string())?, - url, - ), - config, - ) - .send() - .await - .map_err(request_error)?; - let status = response.status(); - if status.is_success() { - continue; - } - if matches!( - status, - StatusCode::METHOD_NOT_ALLOWED | StatusCode::CONFLICT - ) && remote_dir_exists(client, config, ¤t).await? - { - continue; - } - return Err(format!("Failed to create remote directory: {}", status)); - } - - Ok(()) -} - -async fn remote_dir_exists( - client: &Client, - config: &WebDavConfig, - remote_path: &str, -) -> Result { - let response = with_auth( - client - .request( - Method::from_bytes(b"PROPFIND").map_err(|error| error.to_string())?, - remote_collection_url(config, remote_path)?, - ) - .header("Depth", "0"), - config, - ) - .send() - .await - .map_err(request_error)?; - - let status = response.status(); - if status.is_success() || status.as_u16() == 207 { - return Ok(true); - } - if matches!(status, StatusCode::NOT_FOUND | StatusCode::CONFLICT) { - return Ok(false); - } - - Err(format!("Failed to check remote directory: {}", status)) -} - -fn prepare_upload_content(kind: DataFileKind, raw: &str) -> Result { - let mut value = parse_json(raw)?; - if matches!(kind, DataFileKind::Settings) { - if let Some(object) = value.as_object_mut() { - object.remove("webdav"); - } - } - to_pretty_json(&value) -} - -fn prepare_pull_content(app: &AppHandle, kind: DataFileKind, raw: &str) -> Result { - let mut value = parse_json(raw)?; - if matches!(kind, DataFileKind::Settings) { - let local_path = data_file_path(app, DataFileKind::Settings)?; - if local_path.exists() { - let local = read_or_default(local_path, "{}")?; - match parse_json(&local) { - Ok(local_json) => { - if let Some(webdav) = local_json.get("webdav").cloned() { - if let Some(object) = value.as_object_mut() { - object.insert("webdav".to_string(), webdav); - } - } - } - Err(error) => { - // 本地 settings 损坏时跳过 webdav 合并(凭据可能丢失, - // 用户需重新配置),但记录原因便于排查。 - eprintln!( - "[webdav] Local settings.json is corrupted, skipping webdav config merge: {error}" - ); - } - } - } - } - to_pretty_json(&value) -} - -fn read_or_default(path: PathBuf, fallback: &str) -> Result { - match fs::read_to_string(path) { - Ok(content) => Ok(content), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(fallback.to_string()), - Err(error) => Err(error.to_string()), - } -} - -fn validate_config(config: &WebDavConfig) -> Result<(), String> { - if config.endpoint.trim().is_empty() { - return Err("WebDAV URL is required".to_string()); - } - remote_file_url(config, "settings.json").map(|_| ()) -} - -fn remote_file_url(config: &WebDavConfig, file_name: &str) -> Result { - remote_url( - config, - Some(&normalized_remote_path(config)), - Some(file_name), - ) -} - -fn remote_url( - config: &WebDavConfig, - remote_path: Option<&str>, - file_name: Option<&str>, -) -> Result { - let endpoint = ensure_trailing_slash(config.endpoint.trim()); - let mut url = Url::parse(&endpoint).map_err(|error| error.to_string())?; - { - let mut segments = url - .path_segments_mut() - .map_err(|_| "Invalid WebDAV URL".to_string())?; - if let Some(remote_path) = remote_path { - for segment in remote_path.split('/').filter(|segment| !segment.is_empty()) { - segments.push(segment); - } - } - if let Some(file_name) = file_name { - segments.push(file_name); - } - } - Ok(url) -} - -fn remote_collection_url(config: &WebDavConfig, remote_path: &str) -> Result { - let endpoint = ensure_trailing_slash(config.endpoint.trim()); - let mut url = Url::parse(&endpoint).map_err(|error| error.to_string())?; - { - let mut segments = url - .path_segments_mut() - .map_err(|_| "Invalid WebDAV URL".to_string())?; - for segment in remote_path.split('/').filter(|segment| !segment.is_empty()) { - segments.push(segment); - } - segments.push(""); - } - Ok(url) -} - -fn normalized_remote_path(config: &WebDavConfig) -> String { - config - .remote_path - .as_deref() - .unwrap_or("CodeNest") - .trim() - .trim_matches('/') - .to_string() -} - -fn ensure_trailing_slash(value: &str) -> String { - if value.ends_with('/') { - value.to_string() - } else { - format!("{value}/") - } -} - -fn with_auth(request: reqwest::RequestBuilder, config: &WebDavConfig) -> reqwest::RequestBuilder { - let username = config.username.as_deref().unwrap_or("").trim(); - if username.is_empty() { - return request; - } - request.basic_auth(username, config.password.clone()) -} - -fn parse_json(raw: &str) -> Result { - serde_json::from_str::(raw).map_err(|error| error.to_string()) -} - -fn to_pretty_json(value: &Value) -> Result { - serde_json::to_string_pretty(value).map_err(|error| error.to_string()) -} - -fn sync_success(message: String) -> WebDavSyncResult { - WebDavSyncResult { - success: true, - message: Some(message), - error: None, - } -} - -fn sync_error(error: String) -> WebDavSyncResult { - WebDavSyncResult { - success: false, - message: None, - error: Some(error), - } -} - -#[cfg(test)] -mod tests { - use super::sanitize_url_for_error; - - #[test] - fn sanitize_url_for_error_masks_credentials() { - assert_eq!( - sanitize_url_for_error( - "error sending request for url (https://user:secret@dav.example.com/path)" - ), - "error sending request for url (https://***:***@dav.example.com/path)" - ); - } - - #[test] - fn sanitize_url_for_error_keeps_urls_without_credentials() { - let message = "error sending request for url (https://dav.example.com/path)"; - assert_eq!(sanitize_url_for_error(message), message); - } - - #[test] - fn sanitize_url_for_error_handles_multiple_urls() { - assert_eq!( - sanitize_url_for_error("from http://a:b@x.com to https://c:d@y.com"), - "from http://***:***@x.com to https://***:***@y.com" - ); - } - - #[test] - fn sanitize_url_for_error_handles_ipv6_hosts() { - assert_eq!( - sanitize_url_for_error("error at https://user:secret@[::1]:8080/path"), - "error at https://***:***@[::1]:8080/path" - ); - } -} diff --git a/src-tauri/src/webdav/error.rs b/src-tauri/src/webdav/error.rs new file mode 100644 index 0000000..54254bd --- /dev/null +++ b/src-tauri/src/webdav/error.rs @@ -0,0 +1,57 @@ +use std::sync::LazyLock; + +use regex::Regex; + +static URL_CREDENTIALS_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"://[^:/@\s]+:[^@/\s]+@").expect("hardcoded regex should be valid") +}); + +/// 从错误消息中遮蔽 `scheme://user:pass@host` 形式的凭据, +/// 防止 reqwest 错误把 Basic Auth 信息透传给前端。 +fn sanitize_url_for_error(error_msg: &str) -> String { + URL_CREDENTIALS_PATTERN + .replace_all(error_msg, "://***:***@") + .to_string() +} + +/// reqwest 错误统一脱敏转换。 +pub(super) fn request_error(error: reqwest::Error) -> String { + sanitize_url_for_error(&error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::sanitize_url_for_error; + + #[test] + fn sanitize_url_for_error_masks_credentials() { + assert_eq!( + sanitize_url_for_error( + "error sending request for url (https://user:secret@dav.example.com/path)" + ), + "error sending request for url (https://***:***@dav.example.com/path)" + ); + } + + #[test] + fn sanitize_url_for_error_keeps_urls_without_credentials() { + let message = "error sending request for url (https://dav.example.com/path)"; + assert_eq!(sanitize_url_for_error(message), message); + } + + #[test] + fn sanitize_url_for_error_handles_multiple_urls() { + assert_eq!( + sanitize_url_for_error("from http://a:b@x.com to https://c:d@y.com"), + "from http://***:***@x.com to https://***:***@y.com" + ); + } + + #[test] + fn sanitize_url_for_error_handles_ipv6_hosts() { + assert_eq!( + sanitize_url_for_error("error at https://user:secret@[::1]:8080/path"), + "error at https://***:***@[::1]:8080/path" + ); + } +} diff --git a/src-tauri/src/webdav/files.rs b/src-tauri/src/webdav/files.rs new file mode 100644 index 0000000..b40a59c --- /dev/null +++ b/src-tauri/src/webdav/files.rs @@ -0,0 +1,111 @@ +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde_json::Value; +use tauri::{AppHandle, Manager}; + +use crate::data::{data_file_path, DataFileKind}; + +pub(super) struct SyncFile { + pub(super) kind: DataFileKind, + pub(super) name: &'static str, + pub(super) fallback: &'static str, +} + +pub(super) const SYNC_FILES: [SyncFile; 2] = [ + SyncFile { + kind: DataFileKind::Settings, + name: "settings.json", + fallback: "{}", + }, + SyncFile { + kind: DataFileKind::Projects, + name: "projects.json", + fallback: "[]", + }, +]; + +pub(super) fn backup_local_sync_files(app: &AppHandle) -> Result { + let backup_root = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())? + .join("backups") + .join(format!( + "webdav-pull-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| error.to_string())? + .as_secs() + )); + fs::create_dir_all(&backup_root).map_err(|error| error.to_string())?; + + for file in SYNC_FILES { + let source = data_file_path(app, file.kind)?; + if source.exists() { + fs::copy(source, backup_root.join(file.name)).map_err(|error| error.to_string())?; + } + } + + Ok(backup_root) +} + +pub(super) fn prepare_upload_content(kind: DataFileKind, raw: &str) -> Result { + let mut value = parse_json(raw)?; + if matches!(kind, DataFileKind::Settings) { + if let Some(object) = value.as_object_mut() { + object.remove("webdav"); + } + } + to_pretty_json(&value) +} + +pub(super) fn prepare_pull_content( + app: &AppHandle, + kind: DataFileKind, + raw: &str, +) -> Result { + let mut value = parse_json(raw)?; + if matches!(kind, DataFileKind::Settings) { + let local_path = data_file_path(app, DataFileKind::Settings)?; + if local_path.exists() { + let local = read_or_default(local_path, "{}")?; + match parse_json(&local) { + Ok(local_json) => { + if let Some(webdav) = local_json.get("webdav").cloned() { + if let Some(object) = value.as_object_mut() { + object.insert("webdav".to_string(), webdav); + } + } + } + Err(error) => { + // 本地 settings 损坏时跳过 webdav 合并(凭据可能丢失, + // 用户需重新配置),但记录原因便于排查。 + eprintln!( + "[webdav] Local settings.json is corrupted, skipping webdav config merge: {error}" + ); + } + } + } + } + to_pretty_json(&value) +} + +pub(super) fn read_or_default(path: PathBuf, fallback: &str) -> Result { + match fs::read_to_string(path) { + Ok(content) => Ok(content), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(fallback.to_string()), + Err(error) => Err(error.to_string()), + } +} + +fn parse_json(raw: &str) -> Result { + serde_json::from_str::(raw).map_err(|error| error.to_string()) +} + +fn to_pretty_json(value: &Value) -> Result { + serde_json::to_string_pretty(value).map_err(|error| error.to_string()) +} diff --git a/src-tauri/src/webdav/models.rs b/src-tauri/src/webdav/models.rs new file mode 100644 index 0000000..86d51c4 --- /dev/null +++ b/src-tauri/src/webdav/models.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebDavConfig { + pub(super) endpoint: String, + pub(super) username: Option, + pub(super) password: Option, + pub(super) remote_path: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebDavSyncResult { + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +pub(super) fn sync_success(message: String) -> WebDavSyncResult { + WebDavSyncResult { + success: true, + message: Some(message), + error: None, + } +} + +pub(super) fn sync_error(error: String) -> WebDavSyncResult { + WebDavSyncResult { + success: false, + message: None, + error: Some(error), + } +} diff --git a/src-tauri/src/webdav/remote.rs b/src-tauri/src/webdav/remote.rs new file mode 100644 index 0000000..e1a06c2 --- /dev/null +++ b/src-tauri/src/webdav/remote.rs @@ -0,0 +1,184 @@ +use reqwest::{Client, Method, StatusCode}; +use url::Url; + +use super::{error::request_error, models::WebDavConfig}; + +pub(super) async fn test_connection(config: WebDavConfig) -> Result<(), String> { + validate_config(&config)?; + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|error| error.to_string())?; + let response = with_auth( + client.request(Method::OPTIONS, remote_url(&config, None, None)?), + &config, + ) + .send() + .await + .map_err(request_error)?; + + if response.status().is_success() { + return Ok(()); + } + + Err(format!("WebDAV connection failed: {}", response.status())) +} + +pub(super) async fn create_remote_dir( + client: &Client, + config: &WebDavConfig, +) -> Result<(), String> { + let remote_path = normalized_remote_path(config); + if remote_path.is_empty() { + return Ok(()); + } + + let mut current = String::new(); + for part in remote_path.split('/').filter(|part| !part.is_empty()) { + if !current.is_empty() { + current.push('/'); + } + current.push_str(part); + if remote_dir_exists(client, config, ¤t).await? { + continue; + } + + let url = remote_collection_url(config, ¤t)?; + let response = with_auth( + client.request( + Method::from_bytes(b"MKCOL").map_err(|error| error.to_string())?, + url, + ), + config, + ) + .send() + .await + .map_err(request_error)?; + let status = response.status(); + if status.is_success() { + continue; + } + if matches!( + status, + StatusCode::METHOD_NOT_ALLOWED | StatusCode::CONFLICT + ) && remote_dir_exists(client, config, ¤t).await? + { + continue; + } + return Err(format!("Failed to create remote directory: {}", status)); + } + + Ok(()) +} + +async fn remote_dir_exists( + client: &Client, + config: &WebDavConfig, + remote_path: &str, +) -> Result { + let response = with_auth( + client + .request( + Method::from_bytes(b"PROPFIND").map_err(|error| error.to_string())?, + remote_collection_url(config, remote_path)?, + ) + .header("Depth", "0"), + config, + ) + .send() + .await + .map_err(request_error)?; + + let status = response.status(); + if status.is_success() || status.as_u16() == 207 { + return Ok(true); + } + if matches!(status, StatusCode::NOT_FOUND | StatusCode::CONFLICT) { + return Ok(false); + } + + Err(format!("Failed to check remote directory: {}", status)) +} + +pub(super) fn validate_config(config: &WebDavConfig) -> Result<(), String> { + if config.endpoint.trim().is_empty() { + return Err("WebDAV URL is required".to_string()); + } + remote_file_url(config, "settings.json").map(|_| ()) +} + +pub(super) fn remote_file_url(config: &WebDavConfig, file_name: &str) -> Result { + remote_url( + config, + Some(&normalized_remote_path(config)), + Some(file_name), + ) +} + +fn remote_url( + config: &WebDavConfig, + remote_path: Option<&str>, + file_name: Option<&str>, +) -> Result { + let endpoint = ensure_trailing_slash(config.endpoint.trim()); + let mut url = Url::parse(&endpoint).map_err(|error| error.to_string())?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| "Invalid WebDAV URL".to_string())?; + if let Some(remote_path) = remote_path { + for segment in remote_path.split('/').filter(|segment| !segment.is_empty()) { + segments.push(segment); + } + } + if let Some(file_name) = file_name { + segments.push(file_name); + } + } + Ok(url) +} + +fn remote_collection_url(config: &WebDavConfig, remote_path: &str) -> Result { + let endpoint = ensure_trailing_slash(config.endpoint.trim()); + let mut url = Url::parse(&endpoint).map_err(|error| error.to_string())?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| "Invalid WebDAV URL".to_string())?; + for segment in remote_path.split('/').filter(|segment| !segment.is_empty()) { + segments.push(segment); + } + segments.push(""); + } + Ok(url) +} + +fn normalized_remote_path(config: &WebDavConfig) -> String { + config + .remote_path + .as_deref() + .unwrap_or("CodeNest") + .trim() + .trim_matches('/') + .to_string() +} + +fn ensure_trailing_slash(value: &str) -> String { + if value.ends_with('/') { + value.to_string() + } else { + format!("{value}/") + } +} + +pub(super) fn with_auth( + request: reqwest::RequestBuilder, + config: &WebDavConfig, +) -> reqwest::RequestBuilder { + let username = config.username.as_deref().unwrap_or("").trim(); + if username.is_empty() { + return request; + } + request.basic_auth(username, config.password.clone()) +} diff --git a/src-tauri/src/webdav/sync.rs b/src-tauri/src/webdav/sync.rs new file mode 100644 index 0000000..fd910a6 --- /dev/null +++ b/src-tauri/src/webdav/sync.rs @@ -0,0 +1,85 @@ +use reqwest::{Client, StatusCode}; +use tauri::AppHandle; + +use crate::data::{data_file_path, write_data_file_safely}; + +use super::{ + error::request_error, + files::{ + backup_local_sync_files, prepare_pull_content, prepare_upload_content, read_or_default, + SYNC_FILES, + }, + models::WebDavConfig, + remote::{create_remote_dir, remote_file_url, validate_config, with_auth}, +}; + +pub(super) async fn upload_data(app: AppHandle, config: WebDavConfig) -> Result { + validate_config(&config)?; + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| error.to_string())?; + create_remote_dir(&client, &config).await?; + + let mut count = 0; + for file in SYNC_FILES { + let path = data_file_path(&app, file.kind)?; + let raw = read_or_default(path, file.fallback)?; + let content = prepare_upload_content(file.kind, &raw)?; + let url = remote_file_url(&config, file.name)?; + let request = with_auth(client.put(url).body(content), &config) + .header("Content-Type", "application/json"); + let response = request.send().await.map_err(request_error)?; + if !response.status().is_success() { + return Err(format!( + "Failed to upload {}: {}", + file.name, + response.status() + )); + } + count += 1; + } + + Ok(count) +} + +pub(super) async fn pull_data(app: AppHandle, config: WebDavConfig) -> Result { + validate_config(&config)?; + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| error.to_string())?; + let mut pulled_files = Vec::new(); + for file in SYNC_FILES { + let url = remote_file_url(&config, file.name)?; + let response = with_auth(client.get(url), &config) + .send() + .await + .map_err(request_error)?; + if response.status() == StatusCode::NOT_FOUND { + return Err(format!("Remote file not found: {}", file.name)); + } + if !response.status().is_success() { + return Err(format!( + "Failed to pull {}: {}", + file.name, + response.status() + )); + } + + let raw = response.text().await.map_err(request_error)?; + let content = prepare_pull_content(&app, file.kind, &raw)?; + pulled_files.push((file.kind, content)); + } + + backup_local_sync_files(&app)?; + + let count = pulled_files.len(); + for (kind, content) in pulled_files { + write_data_file_safely(&data_file_path(&app, kind)?, &content)?; + } + + Ok(count) +} From 49d9a0f9cb7e4e0e28a6b56e969f9d9c256c8bd2 Mon Sep 17 00:00:00 2001 From: MidnightCrowing <110297461+MidnightCrowing@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:59:31 +0800 Subject: [PATCH 2/2] chore(release): publish Windows MSI bundles --- .github/workflows/build.yml | 2 +- .github/workflows/release-it.yml | 3 +-- README.md | 2 +- README_EN.md | 2 +- src-tauri/tauri.conf.json | 5 ++--- src-tauri/tauri.windows.conf.json | 2 +- 6 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index faad9e2..61a2b19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,7 @@ jobs: - name: Build Windows bundle if: runner.os == 'Windows' - run: pnpm exec tauri build --ci --bundles nsis + run: pnpm exec tauri build --ci --bundles msi - name: Build bundle if: runner.os != 'Windows' diff --git a/.github/workflows/release-it.yml b/.github/workflows/release-it.yml index 59a7285..f22b37b 100644 --- a/.github/workflows/release-it.yml +++ b/.github/workflows/release-it.yml @@ -100,7 +100,7 @@ jobs: - name: Build Windows bundle if: runner.os == 'Windows' - run: pnpm exec tauri build --ci --bundles nsis + run: pnpm exec tauri build --ci --bundles msi - name: Build bundle if: runner.os != 'Windows' @@ -120,7 +120,6 @@ jobs: -o -name '*.dmg' \ -o -name '*.app.tar.gz' \ -o -name '*.app.tar.gz.sig' \ - -o -name '*setup.exe' \ -o -name '*.msi' \) \ -print0 ) diff --git a/README.md b/README.md index 5c0d830..fd0e05f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ CodeNest 是一款跨平台本地项目管理工具,用来集中整理分散 前往 [Releases](https://github.com/MidnightCrowing/CodeNest/releases) 下载适合系统的安装包。 -Windows 版本默认发布 NSIS 安装包;macOS 和 Linux 构建产物由 GitHub Actions 按平台生成。 +Windows 版本默认发布 MSI 安装包;macOS 和 Linux 构建产物由 GitHub Actions 按平台生成。 也可以在 [Actions](https://github.com/MidnightCrowing/CodeNest/actions) 页面下载最新代码构建产物。下载 Actions 产物需要登录 GitHub。 diff --git a/README_EN.md b/README_EN.md index cf1a853..a195b93 100644 --- a/README_EN.md +++ b/README_EN.md @@ -25,7 +25,7 @@ CodeNest is a cross-platform local project manager for organizing projects scatt Download the installer for your platform from [Releases](https://github.com/MidnightCrowing/CodeNest/releases). -The Windows release publishes an NSIS installer by default. macOS and Linux artifacts are built by GitHub Actions for their platforms. +The Windows release publishes an MSI installer by default. macOS and Linux artifacts are built by GitHub Actions for their platforms. You can also download latest-code build artifacts from [Actions](https://github.com/MidnightCrowing/CodeNest/actions). Downloading Actions artifacts requires a GitHub account. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fc45924..4ec48d6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -38,9 +38,8 @@ "../LICENSE.txt": "LICENSE.txt" }, "windows": { - "nsis": { - "installMode": "both", - "installerIcon": "../src/assets/app-icon.ico" + "wix": { + "language": "en-US" } } } diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index 5d815ac..d9cfbff 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -1,7 +1,7 @@ { "bundle": { "targets": [ - "nsis" + "msi" ] } }