mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 01:45:54 +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(() => {
|
||||
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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user