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:
2026-05-25 19:24:13 +08:00
parent 8967fe34e5
commit b1acb3690c
2 changed files with 144 additions and 27 deletions
+47 -7
View File
@@ -136,7 +136,28 @@ export function AppShell() {
}, []);
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>
{/* 路径列表 */}
{activeTab === 'merged' ? (
<MergePreview />
) : (
<PathTable tabId={activeTab as 'system' | 'user'} />
)}
{/* 路径列表(支持拖拽文件夹) */}
<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' ? (
<MergePreview />
) : (
<PathTable tabId={activeTab as 'system' | 'user'} />
)}
</div>
<StatusBar />
+97 -20
View File
@@ -1,11 +1,16 @@
import { useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { validatePath } from '@/hooks/use-path-validation';
import { invoke } from '@tauri-apps/api/core';
interface PathTableProps {
tabId: 'system' | 'user';
}
interface PathRow {
path: string;
index: number;
}
export function PathTable({ tabId }: PathTableProps) {
const sysPaths = useAppStore((s) => s.sysPaths);
const userPaths = useAppStore((s) => s.userPaths);
@@ -17,20 +22,94 @@ export function PathTable({ tabId }: PathTableProps) {
const paths = tabId === 'system' ? sysPaths : userPaths;
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 }));
const q = searchQuery.toLowerCase();
const result: { path: string; index: number }[] = [];
const result: PathRow[] = [];
for (let i = 0; i < paths.length; i++) {
if (paths.get(i)!.toLowerCase().includes(q)) {
result.push({ path: paths.get(i)!, index: i });
}
const p = paths.get(i)!;
if (p.toLowerCase().includes(q)) result.push({ path: p, index: i });
}
return result;
}, [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 seen = new Set<string>();
return filtered.map(({ path }) => {
@@ -38,11 +117,12 @@ export function PathTable({ tabId }: PathTableProps) {
const isDuplicate = seen.has(lower);
seen.add(lower);
return {
isValid: validatePath(path),
isValid: validationCache.get(path) ?? true,
isDuplicate,
isEnvVar: path.includes('%'),
};
});
}, [filtered]);
}, [filtered, validationCache]);
const handleClick = useCallback(
(realIndex: number, e: React.MouseEvent) => {
@@ -62,7 +142,6 @@ export function PathTable({ tabId }: PathTableProps) {
const handleDoubleClick = useCallback(
(realIndex: number) => {
if (!isActive) return;
// 双击编辑 — 由 AppShell 处理(通过事件)
window.dispatchEvent(
new CustomEvent('path-dblclick', {
detail: { index: realIndex, path: paths.get(realIndex) },
@@ -78,10 +157,7 @@ export function PathTable({ tabId }: PathTableProps) {
<thead>
<tr
className="sticky top-0 z-10 text-left text-xs uppercase"
style={{
backgroundColor: 'var(--app-list-alt)',
color: 'var(--app-fg)',
}}
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
>
<th className="w-8 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)',
}}
>
<td
className="px-2 py-0.5 text-xs opacity-50"
style={{ color: 'var(--app-fg)' }}
>
<td className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
{index + 1}
</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}
</td>
</tr>