From 78698866706b9d4ff3137a075f9efa71ff0d8802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Thu, 28 May 2026 10:02:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20PATH=20=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E5=88=86=E6=9E=90=E5=8A=9F=E8=83=BD=20=E2=80=94=20?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E6=A3=80=E6=B5=8B=20+=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scan_conflicts: 检测不同目录中的同名可执行文件(遮蔽冲突) - scan_tools: 扫描各目录提供的可执行文件,支持关键词搜索 - Rust scanner.rs 后端,前端 AnalyzeDialog 弹窗 - 工具栏新增「分析」按钮 Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/commands/mod.rs | 7 +- src-tauri/src/commands/scanner.rs | 114 ++++++++++++ src-tauri/src/lib.rs | 2 + src/components/dialogs/AnalyzeDialog.tsx | 216 +++++++++++++++++++++++ src/components/layout/AppShell.tsx | 6 +- src/components/toolbar/ToolBar.tsx | 4 + src/hooks/use-app-actions.ts | 1 + src/i18n/locales/en.json | 13 ++ src/i18n/locales/zh-CN.json | 13 ++ 9 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/commands/scanner.rs create mode 100644 src/components/dialogs/AnalyzeDialog.tsx diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 73aa901..1b2df8b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ -pub mod registry; -pub mod system; pub mod backup; -pub mod fs; pub mod disabled; +pub mod fs; +pub mod registry; +pub mod scanner; +pub mod system; diff --git a/src-tauri/src/commands/scanner.rs b/src-tauri/src/commands/scanner.rs new file mode 100644 index 0000000..b8c022f --- /dev/null +++ b/src-tauri/src/commands/scanner.rs @@ -0,0 +1,114 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "cmd", "com", "ps1"]; + +#[derive(serde::Serialize, Clone)] +pub struct ConflictLocation { + pub dir: String, + pub priority: usize, +} + +#[derive(serde::Serialize, Clone)] +pub struct ConflictEntry { + pub name: String, + pub locations: Vec, +} + +#[derive(serde::Serialize)] +pub struct ToolGroup { + pub dir: String, + pub exists: bool, + pub exes: Vec, +} + +/// 扫描 PATH 中的可执行文件冲突 +/// +/// 遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件, +/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」) +#[tauri::command] +pub fn scan_conflicts(paths: Vec) -> Result, String> { + // exe_name (小写) → [(priority, dir)] + let mut map: HashMap> = HashMap::new(); + + for (priority, dir) in paths.iter().enumerate() { + let p = Path::new(dir); + if !p.is_dir() { + continue; + } + let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?; + for entry in entries.flatten() { + let fname = entry.file_name(); + let name = fname.to_string_lossy(); + if let Some(ext) = Path::new(name.as_ref()).extension() { + let ext_lower = ext.to_ascii_lowercase(); + if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) { + let key = name.to_lowercase(); + map.entry(key).or_default().push((priority, dir.clone())); + } + } + } + } + + let mut results: Vec = map + .into_iter() + .filter(|(_, locs)| locs.len() >= 2) + .map(|(name, locs)| ConflictEntry { + name, + locations: locs + .into_iter() + .map(|(priority, dir)| ConflictLocation { dir, priority }) + .collect(), + }) + .collect(); + + results.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(results) +} + +/// 扫描 PATH 中各目录提供的可执行文件 +/// +/// query 非空时只返回文件名包含关键词的结果 +#[tauri::command] +pub fn scan_tools(paths: Vec, query: String) -> Result, String> { + let query_lower = query.to_lowercase(); + let mut groups: Vec = Vec::new(); + + for dir in &paths { + let p = Path::new(dir); + if !p.is_dir() { + groups.push(ToolGroup { + dir: dir.clone(), + exists: false, + exes: vec![], + }); + continue; + } + + let entries = fs::read_dir(p).map_err(|e| format!("无法读取目录 {}: {}", dir, e))?; + let mut exes: Vec = Vec::new(); + + for entry in entries.flatten() { + let fname = entry.file_name(); + let name = fname.to_string_lossy(); + if let Some(ext) = Path::new(name.as_ref()).extension() { + let ext_lower = ext.to_ascii_lowercase(); + if EXECUTABLE_EXTENSIONS.contains(&ext_lower.to_str().unwrap_or("")) { + if query_lower.is_empty() || name.to_lowercase().contains(&query_lower) { + exes.push(name.to_string()); + } + } + } + } + + exes.sort(); + groups.push(ToolGroup { + dir: dir.clone(), + exists: true, + exes, + }); + } + + Ok(groups) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3a41db6..d70d1d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,8 @@ pub fn run() { commands::fs::read_text_file, commands::disabled::save_disabled_state, commands::disabled::load_disabled_state, + commands::scanner::scan_conflicts, + commands::scanner::scan_tools, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/dialogs/AnalyzeDialog.tsx b/src/components/dialogs/AnalyzeDialog.tsx new file mode 100644 index 0000000..186128d --- /dev/null +++ b/src/components/dialogs/AnalyzeDialog.tsx @@ -0,0 +1,216 @@ +import { useState, useEffect, useMemo } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '@/components/ui/Modal'; +import { useAppStore } from '@/store/app-store'; + +interface ConflictLocation { + dir: string; + priority: number; +} + +interface ConflictEntry { + name: string; + locations: ConflictLocation[]; +} + +interface ToolGroup { + dir: string; + exists: boolean; + exes: string[]; +} + +type TabType = 'conflicts' | 'tools'; + +interface Props { + open: boolean; + onClose: () => void; +} + +export function AnalyzeDialog({ open, onClose }: Props) { + const { t } = useTranslation(); + const [tab, setTab] = useState('conflicts'); + const [loading, setLoading] = useState(false); + const [conflicts, setConflicts] = useState([]); + const [toolGroups, setToolGroups] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + if (!open) return; + setLoading(true); + const paths = getEnabledPaths(); + Promise.all([ + invoke('scan_conflicts', { paths }), + invoke('scan_tools', { paths, query: '' }), + ]) + .then(([c, t]) => { + setConflicts(c); + setToolGroups(t); + }) + .catch(console.error) + .finally(() => setLoading(false)); + }, [open]); + + // 搜索的工具清单 + const filteredTools = useMemo(() => { + if (!searchQuery.trim()) return toolGroups; + const q = searchQuery.toLowerCase(); + return toolGroups + .map((g) => ({ ...g, exes: g.exes.filter((e) => e.toLowerCase().includes(q)) })) + .filter((g) => g.exes.length > 0); + }, [toolGroups, searchQuery]); + + return ( + +
+ {/* 标题栏 */} +
+

{t('analyze.title')}

+
+ {(['conflicts', 'tools'] as TabType[]).map((tb) => ( + + ))} +
+
+ + {/* 内容 */} +
+ {loading ? ( +
+ {t('analyze.scanning')} +
+ ) : tab === 'conflicts' ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function ConflictsTab({ conflicts }: { conflicts: ConflictEntry[] }) { + const { t } = useTranslation(); + if (conflicts.length === 0) { + return ; + } + return ( +
+

+ {t('analyze.conflictCount', { count: conflicts.length })} +

+ + + + + + + + + {conflicts.map((c) => ( + + + + + ))} + +
EXE{t('analyze.priority')}
{c.name} + {c.locations.map((loc, i) => ( +
+ {i === 0 ? '✓' : '✗'} {loc.dir} + {i > 0 && ( + + ({t('analyze.shadowed')}) + + )} +
+ ))} +
+
+ ); +} + +function ToolsTab({ + groups, + query, + onQueryChange, +}: { + groups: ToolGroup[]; + query: string; + onQueryChange: (q: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+ onQueryChange(e.target.value)} + placeholder={t('analyze.searchPlaceholder')} + className="w-full px-3 py-1.5 text-sm rounded mb-3 border outline-none" + style={{ + backgroundColor: 'var(--app-list-bg)', + color: 'var(--app-fg)', + borderColor: 'var(--app-border)', + }} + /> + {groups.length === 0 ? ( + + ) : ( + groups.map((g) => ( +
+
+ {g.dir} {!g.exists && '(不存在)'} +
+
+ {g.exes.map((exe) => ( + + {exe} + + ))} +
+
+ )) + )} +
+ ); +} + +function EmptyHint({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} + +function getEnabledPaths(): string[] { + const { sysPaths, userPaths } = useAppStore.getState(); + return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map((e) => e.path); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 1cbd2ec..0478ee6 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -12,6 +12,7 @@ import { MergePreview } from '@/components/path-list/MergePreview'; import { PathEditDialog } from '@/components/dialogs/PathEditDialog'; import { HelpDialog } from '@/components/dialogs/HelpDialog'; import { ImportDialog } from '@/components/dialogs/ImportDialog'; +import { AnalyzeDialog } from '@/components/dialogs/AnalyzeDialog'; import { useAppActions, type DialogState } from '@/hooks/use-app-actions'; /** Tauri's File object includes the native filesystem path */ @@ -33,10 +34,11 @@ export function AppShell() { const [importDialog, setImportDialog] = useState({ open: false, system: [], user: [], }); + const [analyzeOpen, setAnalyzeOpen] = useState(false); const actions = useAppActions(activeTab, { editDialog, newDialog, helpOpen, importDialog, - setEditDialog, setNewDialog, setHelpOpen, setImportDialog, + setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen, }); const tabConfig: { id: TabId; label: string }[] = [ @@ -84,6 +86,7 @@ export function AppShell() { const current = localStorage.getItem('i18nextLng') || 'zh-CN'; i18n.changeLanguage(current === 'zh-CN' ? 'en' : 'zh-CN'); }} + onAnalyze={() => setAnalyzeOpen(true)} onDarkMode={() => useThemeStore.getState().toggle()} /> @@ -112,6 +115,7 @@ export function AppShell() { setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })} /> setHelpOpen(false)} /> setImportDialog({ open: false, system: [], user: [] })} /> + setAnalyzeOpen(false)} /> ); } diff --git a/src/components/toolbar/ToolBar.tsx b/src/components/toolbar/ToolBar.tsx index b3a17af..6b6d28a 100644 --- a/src/components/toolbar/ToolBar.tsx +++ b/src/components/toolbar/ToolBar.tsx @@ -20,6 +20,7 @@ interface ToolBarProps { onHelp: () => void; onLanguage: () => void; onDarkMode: () => void; + onAnalyze: () => void; } export function ToolBar(props: ToolBarProps) { @@ -66,6 +67,9 @@ export function ToolBar(props: ToolBarProps) { + diff --git a/src/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index 5690c02..e5b5163 100644 --- a/src/hooks/use-app-actions.ts +++ b/src/hooks/use-app-actions.ts @@ -19,6 +19,7 @@ export interface DialogState { setNewDialog: (v: boolean) => void; setHelpOpen: (v: boolean) => void; setImportDialog: (v: DialogState['importDialog']) => void; + setAnalyzeOpen: (v: boolean) => void; } export function useAppActions(activeTab: TabId, dialogs: DialogState) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f7b551b..35f4690 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -21,6 +21,7 @@ "save": "OK", "cancel": "Cancel", "help": "Help", + "analyze": "Analyze", "undo": "Undo", "redo": "Redo", "darkMode": "Dark Mode", @@ -70,6 +71,18 @@ "cancel": "Cancel", "search": "Search paths..." }, + "analyze": { + "title": "PATH Analysis", + "conflicts": "Conflicts", + "tools": "Tools", + "scanning": "Scanning...", + "noConflicts": "No executable conflicts found", + "noTools": "No matching executables found", + "priority": "Prioritized", + "shadowed": "Shadowed", + "searchPlaceholder": "Search executable name...", + "conflictCount": "{{count}} file conflict(s) found" + }, "help": { "content": "PathEditor v4.0 — Windows System Environment Variable (PATH) Editor\n\nFeatures:\n• Create/Edit/Delete path entries\n• Move Up/Down to adjust priority\n• One-click cleanup of invalid & duplicate paths\n• Import/Export JSON, CSV, TXT formats\n• Full Undo/Redo support\n\nShortcuts:\n• Ctrl+N New\n• Ctrl+S Save\n• Ctrl+Z Undo\n• Ctrl+Y Redo\n• Ctrl+F Search\n• Delete Delete selected\n• F1 Help\n\nAuthor: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0033eca..1fcf377 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -21,6 +21,7 @@ "save": "确定", "cancel": "取消", "help": "帮助", + "analyze": "分析", "undo": "撤销", "redo": "重做", "darkMode": "深色模式", @@ -70,6 +71,18 @@ "cancel": "取消", "search": "搜索路径..." }, + "analyze": { + "title": "PATH 分析", + "conflicts": "冲突检测", + "tools": "工具清单", + "scanning": "正在扫描...", + "noConflicts": "未发现可执行文件冲突", + "noTools": "未找到匹配的可执行文件", + "priority": "优先执行", + "shadowed": "被遮蔽", + "searchPlaceholder": "搜索可执行文件名...", + "conflictCount": "发现 {{count}} 个文件冲突" + }, "help": { "content": "PathEditor v4.0 — Windows 系统环境变量 (PATH) 编辑器\n\n功能:\n• 新建/编辑/删除路径条目\n• 上移/下移调整优先级\n• 一键清理无效和重复路径\n• 导入/导出 JSON、CSV、TXT 格式\n• 完整撤销/重做支持\n\n快捷键:\n• Ctrl+N 新建\n• Ctrl+S 保存\n• Ctrl+Z 撤销\n• Ctrl+Y 重做\n• Ctrl+F 搜索\n• Delete 删除选中\n• F1 帮助\n\n作者: 刘航宇\nGitHub: https://github.com/LHY0125/PathEditor" }