mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 09:55:56 +08:00
feat: 路径验证颜色编码、环境变量 tooltip、长度检查、拖拽添加
- PathTable 异步调用 Rust validate_path 实现红/橙色编码 - 含 %VAR% 的路径悬浮显示展开后的 tooltip - 保存前检查 PATH 长度(系统2048/用户2048/合并8191),超限弹框确认 - 支持拖放文件夹到列表区域添加路径 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -136,7 +136,28 @@ export function AppShell() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
useAppStore.getState().savePaths();
|
const state = useAppStore.getState();
|
||||||
|
const sysJoined = state.sysPaths.toArray().join(';');
|
||||||
|
const userJoined = state.userPaths.toArray().join(';');
|
||||||
|
const combined = sysJoined + ';' + userJoined;
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (sysJoined.length > 2048) {
|
||||||
|
warnings.push(`系统 PATH 长度 ${sysJoined.length} 超过建议值 2048`);
|
||||||
|
}
|
||||||
|
if (userJoined.length > 2048) {
|
||||||
|
warnings.push(`用户 PATH 长度 ${userJoined.length} 超过建议值 2048`);
|
||||||
|
}
|
||||||
|
if (combined.length > 8191) {
|
||||||
|
warnings.push(`合并 PATH 长度 ${combined.length} 超过命令行安全限制 8191`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
const msg = warnings.join('\n') + '\n\n是否继续保存?';
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.savePaths();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── 键盘快捷键 ──
|
// ── 键盘快捷键 ──
|
||||||
@@ -248,12 +269,31 @@ export function AppShell() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 路径列表 */}
|
{/* 路径列表(支持拖拽文件夹) */}
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'link';
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeTab === 'merged') return;
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const path = (files[i] as any).path;
|
||||||
|
if (path) {
|
||||||
|
useAppStore.getState().addPath(path, getCurrentTarget());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{activeTab === 'merged' ? (
|
{activeTab === 'merged' ? (
|
||||||
<MergePreview />
|
<MergePreview />
|
||||||
) : (
|
) : (
|
||||||
<PathTable tabId={activeTab as 'system' | 'user'} />
|
<PathTable tabId={activeTab as 'system' | 'user'} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { validatePath } from '@/hooks/use-path-validation';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
interface PathTableProps {
|
interface PathTableProps {
|
||||||
tabId: 'system' | 'user';
|
tabId: 'system' | 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PathRow {
|
||||||
|
path: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function PathTable({ tabId }: PathTableProps) {
|
export function PathTable({ tabId }: PathTableProps) {
|
||||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||||
const userPaths = useAppStore((s) => s.userPaths);
|
const userPaths = useAppStore((s) => s.userPaths);
|
||||||
@@ -17,20 +22,94 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
const paths = tabId === 'system' ? sysPaths : userPaths;
|
const paths = tabId === 'system' ? sysPaths : userPaths;
|
||||||
const isActive = activeTab === tabId;
|
const isActive = activeTab === tabId;
|
||||||
|
|
||||||
|
// 本次会话中已验证过的路径缓存(key=path, value=isValid)
|
||||||
|
const [validationCache, setValidationCache] = useState<Map<string, boolean>>(new Map());
|
||||||
|
// 环境变量展开结果缓存(key=path, value=expanded)
|
||||||
|
const [expandedCache, setExpandedCache] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
// 过滤搜索
|
// 过滤搜索
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo<PathRow[]>(() => {
|
||||||
if (!searchQuery) return paths.all.map((p, i) => ({ path: p, index: i }));
|
if (!searchQuery) return paths.all.map((p, i) => ({ path: p, index: i }));
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
const result: { path: string; index: number }[] = [];
|
const result: PathRow[] = [];
|
||||||
for (let i = 0; i < paths.length; i++) {
|
for (let i = 0; i < paths.length; i++) {
|
||||||
if (paths.get(i)!.toLowerCase().includes(q)) {
|
const p = paths.get(i)!;
|
||||||
result.push({ path: paths.get(i)!, index: i });
|
if (p.toLowerCase().includes(q)) result.push({ path: p, index: i });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [paths, searchQuery]);
|
}, [paths, searchQuery]);
|
||||||
|
|
||||||
// 路径验证状态
|
// 异步验证未缓存的路径
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const allPaths = paths.all;
|
||||||
|
|
||||||
|
// 找出未缓存的路径
|
||||||
|
const toValidate = allPaths.filter((p) => !validationCache.has(p));
|
||||||
|
if (toValidate.length === 0) return;
|
||||||
|
|
||||||
|
// 批量验证(限制并发 20)
|
||||||
|
const batch = toValidate.slice(0, 20);
|
||||||
|
Promise.all(
|
||||||
|
batch.map(async (p): Promise<[string, boolean]> => {
|
||||||
|
try {
|
||||||
|
if (p.includes('%')) return [p, true];
|
||||||
|
const valid: boolean = await invoke('validate_path', { path: p });
|
||||||
|
return [p, valid];
|
||||||
|
} catch {
|
||||||
|
return [p, true];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((results) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setValidationCache((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [p, v] of results) {
|
||||||
|
next.set(p, v);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [paths, validationCache]);
|
||||||
|
|
||||||
|
// 异步展开环境变量(用于 tooltip)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const toExpand = paths.all.filter(
|
||||||
|
(p) => p.includes('%') && !expandedCache.has(p),
|
||||||
|
);
|
||||||
|
if (toExpand.length === 0) return;
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
toExpand.map(async (p): Promise<[string, string]> => {
|
||||||
|
try {
|
||||||
|
const expanded: string = await invoke('expand_env_vars', { path: p });
|
||||||
|
return [p, expanded !== p ? expanded : ''];
|
||||||
|
} catch {
|
||||||
|
return [p, ''];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((results) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setExpandedCache((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [p, v] of results) {
|
||||||
|
next.set(p, v);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [paths, expandedCache]);
|
||||||
|
|
||||||
|
// 所有路径都默认有效(异步验证结果回来后再精确染色)
|
||||||
const validations = useMemo(() => {
|
const validations = useMemo(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return filtered.map(({ path }) => {
|
return filtered.map(({ path }) => {
|
||||||
@@ -38,11 +117,12 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
const isDuplicate = seen.has(lower);
|
const isDuplicate = seen.has(lower);
|
||||||
seen.add(lower);
|
seen.add(lower);
|
||||||
return {
|
return {
|
||||||
isValid: validatePath(path),
|
isValid: validationCache.get(path) ?? true,
|
||||||
isDuplicate,
|
isDuplicate,
|
||||||
|
isEnvVar: path.includes('%'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [filtered]);
|
}, [filtered, validationCache]);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(realIndex: number, e: React.MouseEvent) => {
|
(realIndex: number, e: React.MouseEvent) => {
|
||||||
@@ -62,7 +142,6 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
const handleDoubleClick = useCallback(
|
const handleDoubleClick = useCallback(
|
||||||
(realIndex: number) => {
|
(realIndex: number) => {
|
||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
// 双击编辑 — 由 AppShell 处理(通过事件)
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('path-dblclick', {
|
new CustomEvent('path-dblclick', {
|
||||||
detail: { index: realIndex, path: paths.get(realIndex) },
|
detail: { index: realIndex, path: paths.get(realIndex) },
|
||||||
@@ -78,10 +157,7 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr
|
<tr
|
||||||
className="sticky top-0 z-10 text-left text-xs uppercase"
|
className="sticky top-0 z-10 text-left text-xs uppercase"
|
||||||
style={{
|
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||||
backgroundColor: 'var(--app-list-alt)',
|
|
||||||
color: 'var(--app-fg)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<th className="w-8 px-2 py-1">#</th>
|
<th className="w-8 px-2 py-1">#</th>
|
||||||
<th className="px-2 py-1">路径</th>
|
<th className="px-2 py-1">路径</th>
|
||||||
@@ -109,13 +185,14 @@ export function PathTable({ tabId }: PathTableProps) {
|
|||||||
: 'var(--app-list-alt)',
|
: 'var(--app-list-alt)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td
|
<td className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
|
||||||
className="px-2 py-0.5 text-xs opacity-50"
|
|
||||||
style={{ color: 'var(--app-fg)' }}
|
|
||||||
>
|
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-0.5 text-sm" style={{ color: textColor }}>
|
<td
|
||||||
|
className="px-2 py-0.5 text-sm truncate max-w-2xl"
|
||||||
|
style={{ color: textColor }}
|
||||||
|
title={expandedCache.get(path) || undefined}
|
||||||
|
>
|
||||||
{path}
|
{path}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user