mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 09:55:56 +08:00
build, fix, feat, refactor: 优化长列表性能,新增注册表并发校验,升级v5.1.0
- 前端引入@tanstack/react-virtual虚拟列表库,重构PathTable与MergePreview组件,优化大量路径条目下的渲染性能 - 为后端注册表保存接口添加原始路径比对逻辑,防止并发修改导致的配置覆盖,同步更新前端保存逻辑传递原始路径参数 - 替换core模块手动编写的Windows API FFI声明为windows-sys官方库,简化代码维护 - 完善单元测试,新增空数组处理、边界场景的测试用例 - 更新项目依赖与锁定文件,将版本升级至v5.1.0 - 新增项目代码架构审查文档
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PathEntry } from '@/core/path-entry';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
export function MergePreview() {
|
||||
const sysPaths = useAppStore((s) => s.sysPaths);
|
||||
@@ -33,47 +34,62 @@ export function MergePreview() {
|
||||
return merged.filter((r) => r.path.toLowerCase().includes(q));
|
||||
}, [sysPaths, userPaths, searchQuery, t]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr
|
||||
className="sticky top-0 z-10 text-left text-xs uppercase"
|
||||
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||
>
|
||||
<th className="w-10 px-2 py-1">#</th>
|
||||
<th className="px-2 py-1">{t('dialog.pathLabel')}</th>
|
||||
<th className="w-16 px-2 py-1">{t('merge.source')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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;
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={`${source}-${displayIndex}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
||||
color: 'var(--app-fg)',
|
||||
}}
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: allPaths.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 28, // 预估行高 28px
|
||||
initialRect: { width: 800, height: 600 },
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="flex-1 overflow-auto relative">
|
||||
<div
|
||||
className="sticky top-0 z-10 flex text-left text-xs uppercase"
|
||||
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||
>
|
||||
<div className="w-10 px-2 py-1">#</div>
|
||||
<div className="px-2 py-1 flex-1">{t('dialog.pathLabel')}</div>
|
||||
<div className="w-16 px-2 py-1">{t('merge.source')}</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const rowIdx = virtualRow.index;
|
||||
const { path, enabled, source, displayIndex } = allPaths[rowIdx];
|
||||
const textColor = enabled ? 'var(--app-fg)' : '#6b7280';
|
||||
const textDecoration = enabled ? 'none' : 'line-through';
|
||||
const opacity = enabled ? 1 : 0.6;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${source}-${displayIndex}`}
|
||||
className="flex items-center absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
backgroundColor: rowIdx % 2 === 0 ? 'var(--app-list-bg)' : 'var(--app-list-alt)',
|
||||
color: 'var(--app-fg)',
|
||||
}}
|
||||
>
|
||||
<div className="w-10 px-2 py-0.5 text-xs opacity-50">{rowIdx + 1}</div>
|
||||
<div
|
||||
className="px-2 py-0.5 text-sm flex-1 truncate"
|
||||
style={{ color: textColor, textDecoration, opacity }}
|
||||
>
|
||||
<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>
|
||||
{path}
|
||||
</div>
|
||||
<div className="w-16 px-2 py-0.5 text-xs opacity-60">{source}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { TargetType } from '@/core/undo-redo';
|
||||
import { usePathValidation } from '@/hooks/use-path-validation';
|
||||
import type { ValidationState } from '@/hooks/use-path-validation';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
interface PathTableProps {
|
||||
tabId: 'system' | 'user';
|
||||
@@ -80,76 +81,91 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
[isActive, paths],
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 28, // 预估行高 28px
|
||||
initialRect: { width: 800, height: 600 },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr
|
||||
className="sticky top-0 z-10 text-left text-xs uppercase"
|
||||
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">{t('table.path')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(({ path, index, enabled }, rowIdx) => {
|
||||
const v = validations[rowIdx];
|
||||
const isSelected = selectedIndices.includes(index);
|
||||
let textColor = 'var(--app-fg)';
|
||||
if (v.state === 'invalid') textColor = '#dc3545';
|
||||
else if (v.isDuplicate) textColor = '#fd7e14';
|
||||
else if (v.state === 'unknown') textColor = 'var(--app-fg)';
|
||||
<div ref={parentRef} className="flex-1 overflow-auto relative">
|
||||
<div
|
||||
className="sticky top-0 z-10 flex text-left text-xs uppercase"
|
||||
style={{ backgroundColor: 'var(--app-list-alt)', color: 'var(--app-fg)' }}
|
||||
>
|
||||
<div className="w-8 px-2 py-1">#</div>
|
||||
<div className="w-6 px-1 py-1"></div>
|
||||
<div className="px-2 py-1 flex-1">{t('table.path')}</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const rowIdx = virtualRow.index;
|
||||
const { path, index, enabled } = filtered[rowIdx];
|
||||
const v = validations[rowIdx];
|
||||
const isSelected = selectedIndices.includes(index);
|
||||
let textColor = 'var(--app-fg)';
|
||||
if (v.state === 'invalid') textColor = '#dc3545';
|
||||
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;
|
||||
}
|
||||
let textDecoration = 'none';
|
||||
let opacity = 1;
|
||||
if (!enabled) {
|
||||
textColor = '#6b7280';
|
||||
textDecoration = 'line-through';
|
||||
opacity = 0.6;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
onClick={(e) => handleClick(index, e)}
|
||||
onDoubleClick={() => handleDoubleClick(index)}
|
||||
className="cursor-pointer select-none"
|
||||
style={{
|
||||
backgroundColor: isSelected
|
||||
? 'var(--app-select-row)'
|
||||
: rowIdx % 2 === 0
|
||||
? 'var(--app-list-bg)'
|
||||
: 'var(--app-list-alt)',
|
||||
}}
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
onClick={(e) => handleClick(index, e)}
|
||||
onDoubleClick={() => handleDoubleClick(index)}
|
||||
className="cursor-pointer select-none flex items-center absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
backgroundColor: isSelected
|
||||
? 'var(--app-select-row)'
|
||||
: rowIdx % 2 === 0
|
||||
? 'var(--app-list-bg)'
|
||||
: 'var(--app-list-alt)',
|
||||
}}
|
||||
>
|
||||
<div className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="w-6 px-1 py-0.5 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={() => {
|
||||
const target = tabId === 'system' ? TargetType.SYSTEM : TargetType.USER;
|
||||
useAppStore.getState().togglePath(index, target);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="px-2 py-0.5 text-sm truncate flex-1"
|
||||
style={{ color: textColor, textDecoration, opacity }}
|
||||
title={expandedCache.get(path) || undefined}
|
||||
>
|
||||
<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, textDecoration, opacity }}
|
||||
title={expandedCache.get(path) || undefined}
|
||||
>
|
||||
{path}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{path}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,9 +352,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
await invoke('backup_registry', { customDir: null })
|
||||
.catch(() => { backupFailed = true; });
|
||||
|
||||
const origSys = state._savedSys.filter(e => e.enabled).map(e => e.path);
|
||||
const origUser = state._savedUser.filter(e => e.enabled).map(e => e.path);
|
||||
|
||||
const [sysResult, userResult] = await Promise.allSettled([
|
||||
invoke('save_system_paths', { paths: sysPaths }),
|
||||
invoke('save_user_paths', { paths: userPaths }),
|
||||
invoke('save_system_paths', { paths: sysPaths, original: origSys }),
|
||||
invoke('save_user_paths', { paths: userPaths, original: origUser }),
|
||||
]);
|
||||
|
||||
const sysOk = sysResult.status === 'fulfilled';
|
||||
|
||||
Reference in New Issue
Block a user