feat: UI 组件适配 PathEntry — 复选框列、禁用行灰显删除线

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 13:56:52 +08:00
parent e646a84291
commit d6e535aa98
3 changed files with 89 additions and 40 deletions
+45 -19
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import { useTranslation } from 'react-i18next';
import type { PathEntry } from '@/core/path-entry';
export function MergePreview() {
const sysPaths = useAppStore((s) => s.sysPaths);
@@ -9,13 +10,27 @@ export function MergePreview() {
const { t } = useTranslation();
const allPaths = useMemo(() => {
const result: { path: string; source: string; index: number }[] = [];
sysPaths.forEach((p, i) => result.push({ path: p, source: t('merge.system'), index: i }));
userPaths.forEach((p, i) => result.push({ path: p, source: t('merge.user'), index: i }));
const seen = new Set<string>();
const merged: (PathEntry & { source: string; displayIndex: number })[] = [];
if (!searchQuery) return result;
for (const entry of sysPaths) {
const lower = entry.path.toLowerCase();
if (!seen.has(lower)) {
seen.add(lower);
merged.push({ ...entry, source: t('merge.system'), displayIndex: merged.length });
}
}
for (const entry of userPaths) {
const lower = entry.path.toLowerCase();
if (!seen.has(lower)) {
seen.add(lower);
merged.push({ ...entry, source: t('merge.user'), displayIndex: merged.length });
}
}
if (!searchQuery) return merged;
const q = searchQuery.toLowerCase();
return result.filter((r) => r.path.toLowerCase().includes(q));
return merged.filter((r) => r.path.toLowerCase().includes(q));
}, [sysPaths, userPaths, searchQuery, t]);
return (
@@ -32,20 +47,31 @@ export function MergePreview() {
</tr>
</thead>
<tbody>
{allPaths.map(({ path, source, index }, rowIdx) => (
<tr
key={`${source}-${index}`}
style={{
backgroundColor:
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
color: 'var(--app-fg)',
}}
>
<td className="px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</td>
<td className="px-2 py-0.5 text-sm">{path}</td>
<td className="px-2 py-0.5 text-xs opacity-60">{source}</td>
</tr>
))}
{allPaths.map(({ path, enabled, source, displayIndex }, rowIdx) => {
const textColor = enabled ? 'var(--app-fg)' : '#6b7280';
const textDecoration = enabled ? 'none' : 'line-through';
const opacity = enabled ? 1 : 0.6;
return (
<tr
key={`${source}-${displayIndex}`}
style={{
backgroundColor:
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
color: 'var(--app-fg)',
}}
>
<td className="px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</td>
<td
className="px-2 py-0.5 text-sm"
style={{ color: textColor, textDecoration, opacity }}
>
{path}
</td>
<td className="px-2 py-0.5 text-xs opacity-60">{source}</td>
</tr>
);
})}
</tbody>
</table>
</div>
+36 -14
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
import { invoke } from '@tauri-apps/api/core';
import { TargetType } from '@/core/undo-redo';
interface PathTableProps {
tabId: 'system' | 'user';
@@ -9,6 +10,7 @@ interface PathTableProps {
interface PathRow {
path: string;
index: number;
enabled: boolean;
}
type ValidationState = 'valid' | 'invalid' | 'unknown';
@@ -35,12 +37,12 @@ export function PathTable({ tabId }: PathTableProps) {
// 过滤搜索
const filtered = useMemo<PathRow[]>(() => {
if (!searchQuery) return paths.map((p, i) => ({ path: p, index: i }));
if (!searchQuery) return paths.map((p, i) => ({ path: p.path, index: i, enabled: p.enabled }));
const q = searchQuery.toLowerCase();
const result: PathRow[] = [];
for (let i = 0; i < paths.length; i++) {
const p = paths[i];
if (p.toLowerCase().includes(q)) result.push({ path: p, index: i });
if (p.path.toLowerCase().includes(q)) result.push({ path: p.path, index: i, enabled: p.enabled });
}
return result;
}, [paths, searchQuery]);
@@ -48,18 +50,18 @@ export function PathTable({ tabId }: PathTableProps) {
// 异步验证未缓存的路径
useEffect(() => {
let cancelled = false;
const toValidate = paths.filter((p) => !validatedRef.current.has(p));
const toValidate = paths.filter((p) => !validatedRef.current.has(p.path));
if (toValidate.length === 0) return;
const batch = toValidate.slice(0, 20);
Promise.all(
batch.map(async (p): Promise<[string, ValidationState]> => {
try {
if (p.includes('%')) return [p, 'valid'];
const valid: boolean = await invoke('validate_path', { path: p });
return [p, valid ? 'valid' : 'invalid'];
if (p.path.includes('%')) return [p.path, 'valid'];
const valid: boolean = await invoke('validate_path', { path: p.path });
return [p.path, valid ? 'valid' : 'invalid'];
} catch {
return [p, 'unknown'];
return [p.path, 'unknown'];
}
}),
).then((results) => {
@@ -79,7 +81,7 @@ export function PathTable({ tabId }: PathTableProps) {
useEffect(() => {
let cancelled = false;
const toExpand = paths.filter(
(p) => p.includes('%') && !expandedRef.current.has(p),
(p) => p.path.includes('%') && !expandedRef.current.has(p.path),
);
if (toExpand.length === 0) return;
@@ -87,10 +89,10 @@ export function PathTable({ tabId }: PathTableProps) {
Promise.all(
batch.map(async (p): Promise<[string, string]> => {
try {
const expanded: string = await invoke('expand_env_vars', { path: p });
return [p, expanded !== p ? expanded : ''];
const expanded: string = await invoke('expand_env_vars', { path: p.path });
return [p.path, expanded !== p.path ? expanded : ''];
} catch {
return [p, ''];
return [p.path, ''];
}
}),
).then((results) => {
@@ -141,7 +143,7 @@ export function PathTable({ tabId }: PathTableProps) {
if (!isActive) return;
window.dispatchEvent(
new CustomEvent('path-dblclick', {
detail: { index: realIndex, path: paths[realIndex] },
detail: { index: realIndex, path: paths[realIndex].path },
}),
);
},
@@ -157,11 +159,12 @@ export function PathTable({ tabId }: PathTableProps) {
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
>
<th className="w-8 px-2 py-1">#</th>
<th className="w-6 px-1 py-1"></th>
<th className="px-2 py-1"></th>
</tr>
</thead>
<tbody>
{filtered.map(({ path, index }, rowIdx) => {
{filtered.map(({ path, index, enabled }, rowIdx) => {
const v = validations[rowIdx];
const isSelected = selectedIndices.includes(index);
let textColor = 'var(--app-fg)';
@@ -169,6 +172,14 @@ export function PathTable({ tabId }: PathTableProps) {
else if (v.isDuplicate) textColor = '#fd7e14';
else if (v.state === 'unknown') textColor = 'var(--app-fg)';
let textDecoration = 'none';
let opacity = 1;
if (!enabled) {
textColor = '#6b7280';
textDecoration = 'line-through';
opacity = 0.6;
}
return (
<tr
key={index}
@@ -186,9 +197,20 @@ export function PathTable({ tabId }: PathTableProps) {
<td className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
{index + 1}
</td>
<td className="w-6 px-1 py-0.5">
<input
type="checkbox"
checked={enabled}
onChange={() => {
const target = tabId === 'system' ? TargetType.SYSTEM : TargetType.USER;
useAppStore.getState().togglePath(index, target);
}}
className="cursor-pointer"
/>
</td>
<td
className="px-2 py-0.5 text-sm truncate max-w-2xl"
style={{ color: textColor }}
style={{ color: textColor, textDecoration, opacity }}
title={expandedCache.get(path) || undefined}
>
{path}
+8 -7
View File
@@ -4,6 +4,7 @@ import { TargetType } from '@/core/undo-redo';
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { importFromContent, exportToJson, exportToCsv, flattenImportResult } from '@/core/import-export';
import type { PathEntry } from '@/core/path-entry';
import { is_valid_path_format } from '@/core/validation';
import { useKeyboard } from './use-keyboard';
import i18n from '@/i18n';
@@ -13,7 +14,7 @@ export interface DialogState {
editDialog: { open: boolean; index: number; value: string; target: TargetType };
newDialog: boolean;
helpOpen: boolean;
importDialog: { open: boolean; system: string[]; user: string[] };
importDialog: { open: boolean; system: PathEntry[]; user: PathEntry[] };
setEditDialog: (v: DialogState['editDialog']) => void;
setNewDialog: (v: boolean) => void;
setHelpOpen: (v: boolean) => void;
@@ -38,8 +39,8 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
const list = target === TargetType.SYSTEM
? useAppStore.getState().sysPaths
: useAppStore.getState().userPaths;
const value = list[idx];
if (value) setEditDialog({ open: true, index: idx, value, target });
const entry = list[idx];
if (entry) setEditDialog({ open: true, index: idx, value: entry.path, target });
}, [activeTab, setEditDialog]);
const handleBrowse = useCallback(async () => {
@@ -92,9 +93,9 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
if (result.system.length > 0 && result.user.length > 0) {
setImportDialog({ open: true, system: result.system, user: result.user });
} else if (result.system.length > 0) {
useAppStore.getState().replacePaths(TargetType.SYSTEM, result.system);
useAppStore.getState().replacePaths(TargetType.SYSTEM, result.system.map(e => e.path));
} else if (result.user.length > 0) {
useAppStore.getState().replacePaths(TargetType.USER, result.user);
useAppStore.getState().replacePaths(TargetType.USER, result.user.map(e => e.path));
}
}, [setImportDialog]);
@@ -159,8 +160,8 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
const { system, user } = dialogs.importDialog;
const flat = flattenImportResult({ system, user }, target);
if (flat.system.length > 0) useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system);
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user);
if (flat.system.length > 0) useAppStore.getState().replacePaths(TargetType.SYSTEM, flat.system.map(e => e.path));
if (flat.user.length > 0) useAppStore.getState().replacePaths(TargetType.USER, flat.user.map(e => e.path));
setImportDialog({ open: false, system: [], user: [] });
}, [dialogs.importDialog, setImportDialog]);