mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 09:55:56 +08:00
chore: 同步 v5.0 基础设施完善到 v5.1
从 v5.0 cherry-pick 的开源项目基础设施改进: 新增配置文件: - .editorconfig, .gitattributes, .prettierrc, .markdownlint.json - commitlint.config.js 新增 GitHub 社区文件: - .github/dependabot.yml — 依赖自动更新 - .github/CODEOWNERS — 自动 PR 审查分配 - .github/FUNDING.yml — 开源赞助入口 新增文档: - ROADMAP.md — 路线图 - SUPPORT.md — 帮助指南 - docs/screenshots/ — 应用截图 新增 Git Hooks: - .husky/pre-commit — lint-staged 自动格式化+修复 - .husky/commit-msg — commitlint 校验 CI 强化: - 新增 Prettier 格式检查 - 新增 Vitest 覆盖率 + Codecov 上报 - 保留 v5.1 已有的 rust-cache + jsdom 全局环境 修复: - index.html 标题 v4.0 → v5.1 - PathEditDialog set-state-in-effect 改用 useRef prevOpen 守卫 - merge-preview.test.tsx no-explicit-any 修复 - 所有 TS/TSX 文件 Prettier 格式化统一 v5.1 保留特性: - @tanstack/react-virtual 虚拟滚动 - jsdom 全局测试环境 - Swatinem/rust-cache CI 加速 - 105 测试全部通过
This commit is contained in:
@@ -37,7 +37,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
|
||||
|
||||
const prevOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!open) { prevOpen.current = false; return; }
|
||||
if (!open) {
|
||||
prevOpen.current = false;
|
||||
return;
|
||||
}
|
||||
if (prevOpen.current) return;
|
||||
prevOpen.current = true;
|
||||
setLoading(true);
|
||||
@@ -67,7 +70,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="flex flex-col" style={{ width: 680, maxHeight: '75vh' }}>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b" style={{ borderColor: 'var(--app-border)' }}>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3 border-b"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
>
|
||||
<h2 className="text-base font-semibold">{t('analyze.title')}</h2>
|
||||
<div className="flex gap-1">
|
||||
{(['conflicts', 'tools'] as TabType[]).map((tb) => (
|
||||
@@ -89,7 +95,10 @@ export function AnalyzeDialog({ open, onClose }: Props) {
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm" style={{ color: 'var(--app-fg)', opacity: 0.6 }}>
|
||||
<div
|
||||
className="flex items-center justify-center py-12 text-sm"
|
||||
style={{ color: 'var(--app-fg)', opacity: 0.6 }}
|
||||
>
|
||||
{t('analyze.scanning')}
|
||||
</div>
|
||||
) : tab === 'conflicts' ? (
|
||||
@@ -215,5 +224,7 @@ function EmptyHint({ text }: { text: string }) {
|
||||
|
||||
function getEnabledPaths(): string[] {
|
||||
const { sysPaths, userPaths } = useAppStore.getState();
|
||||
return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map((e) => e.path);
|
||||
return [...sysPaths.filter((e) => e.enabled), ...userPaths.filter((e) => e.enabled)].map(
|
||||
(e) => e.path,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
interface HelpDialogProps { open: boolean; onClose: () => void; }
|
||||
interface HelpDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -9,9 +12,15 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<h2 className="text-lg font-semibold mb-4">{t('dialog.helpTitle')}</h2>
|
||||
<pre className="text-sm whitespace-pre-wrap font-sans leading-relaxed max-w-lg">{t('help.content')}</pre>
|
||||
<pre className="text-sm whitespace-pre-wrap font-sans leading-relaxed max-w-lg">
|
||||
{t('help.content')}
|
||||
</pre>
|
||||
<div className="flex justify-end mt-4">
|
||||
<button className="px-4 py-1.5 text-sm rounded text-white" style={{ backgroundColor: '#2563eb' }} onClick={onClose}>
|
||||
<button
|
||||
className="px-4 py-1.5 text-sm rounded text-white"
|
||||
style={{ backgroundColor: '#2563eb' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('dialog.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,13 @@ interface ImportDialogProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel }: ImportDialogProps) {
|
||||
export function ImportDialog({
|
||||
open,
|
||||
systemCount,
|
||||
userCount,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: ImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -21,10 +27,40 @@ export function ImportDialog({ open, systemCount, userCount, onSelect, onCancel
|
||||
{userCount > 0 && t('dialog.importUserCount', { count: userCount })}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{systemCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('system')}>{t('dialog.importSystem')}</button>}
|
||||
{userCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('user')}>{t('dialog.importUser')}</button>}
|
||||
{systemCount > 0 && userCount > 0 && <button className="px-4 py-2 text-sm rounded border text-left" style={{ borderColor: 'var(--app-border)' }} onClick={() => onSelect('both')}>{t('dialog.importBoth')}</button>}
|
||||
<button className="px-4 py-2 text-sm rounded border mt-2" style={{ borderColor: 'var(--app-border)' }} onClick={onCancel}>{t('dialog.cancel')}</button>
|
||||
{systemCount > 0 && (
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border text-left"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={() => onSelect('system')}
|
||||
>
|
||||
{t('dialog.importSystem')}
|
||||
</button>
|
||||
)}
|
||||
{userCount > 0 && (
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border text-left"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={() => onSelect('user')}
|
||||
>
|
||||
{t('dialog.importUser')}
|
||||
</button>
|
||||
)}
|
||||
{systemCount > 0 && userCount > 0 && (
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border text-left"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={() => onSelect('both')}
|
||||
>
|
||||
{t('dialog.importBoth')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border mt-2"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
@@ -10,30 +10,54 @@ interface PathEditDialogProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PathEditDialog({ open, title, initialValue, onConfirm, onCancel }: PathEditDialogProps) {
|
||||
export function PathEditDialog({
|
||||
open,
|
||||
title,
|
||||
initialValue,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: PathEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const prevOpen = useRef(open);
|
||||
|
||||
// 对话框打开时重置输入值 — 此模式不会导致级联渲染
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { if (open) setValue(initialValue); }, [open, initialValue]);
|
||||
useEffect(() => {
|
||||
if (open && !prevOpen.current) setValue(initialValue);
|
||||
prevOpen.current = open;
|
||||
}, [open, initialValue]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel}>
|
||||
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
||||
<label className="text-sm mb-2 block">{t('dialog.pathLabel')}</label>
|
||||
<input
|
||||
type="text" autoFocus value={value}
|
||||
type="text"
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onConfirm(value); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onConfirm(value);
|
||||
}}
|
||||
className="w-full min-w-[400px] px-3 py-2 rounded border text-sm outline-none"
|
||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
|
||||
style={{
|
||||
backgroundColor: 'var(--app-list-bg)',
|
||||
color: 'var(--app-fg)',
|
||||
borderColor: 'var(--app-border)',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button className="px-4 py-1.5 text-sm rounded border" style={{ borderColor: 'var(--app-border)' }} onClick={onCancel}>
|
||||
<button
|
||||
className="px-4 py-1.5 text-sm rounded border"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button className="px-4 py-1.5 text-sm rounded text-white" style={{ backgroundColor: '#2563eb' }} onClick={() => onConfirm(value)}>
|
||||
<button
|
||||
className="px-4 py-1.5 text-sm rounded text-white"
|
||||
style={{ backgroundColor: '#2563eb' }}
|
||||
onClick={() => onConfirm(value)}
|
||||
>
|
||||
{t('dialog.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -65,20 +65,23 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
if (!selected || !selectedData) return;
|
||||
if (!window.confirm(t('profile.applyConfirm', { name: selected }))) return;
|
||||
useAppStore.getState().replaceBothPaths(
|
||||
selectedData.sys.map(e => e.path),
|
||||
selectedData.user.map(e => e.path),
|
||||
selectedData.sys.map((e) => e.path),
|
||||
selectedData.user.map((e) => e.path),
|
||||
);
|
||||
// 同步 disabled 状态
|
||||
await invoke('save_disabled_state', {
|
||||
system: selectedData.sys.filter(e => !e.enabled).map(e => e.path),
|
||||
user: selectedData.user.filter(e => !e.enabled).map(e => e.path),
|
||||
system: selectedData.sys.filter((e) => !e.enabled).map((e) => e.path),
|
||||
user: selectedData.user.filter((e) => !e.enabled).map((e) => e.path),
|
||||
});
|
||||
const result = await useAppStore.getState().savePaths();
|
||||
if (result.kind === 'success') {
|
||||
onClose();
|
||||
} else if (result.kind === 'warning') {
|
||||
const { ask } = await import('@tauri-apps/plugin-dialog');
|
||||
const confirmed = await ask(t('status.saveWarningLongPaths'), { title: t('dialog.backupTitle'), kind: 'warning' });
|
||||
const confirmed = await ask(t('status.saveWarningLongPaths'), {
|
||||
title: t('dialog.backupTitle'),
|
||||
kind: 'warning',
|
||||
});
|
||||
if (confirmed) {
|
||||
const forceResult = await useAppStore.getState().savePaths(true);
|
||||
if (forceResult.kind === 'success') {
|
||||
@@ -91,7 +94,10 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
const handleDelete = async (name: string) => {
|
||||
if (!window.confirm(`删除配置文件 "${name}"?`)) return;
|
||||
await invoke('delete_profile', { name });
|
||||
if (selected === name) { setSelected(null); setSelectedData(null); }
|
||||
if (selected === name) {
|
||||
setSelected(null);
|
||||
setSelectedData(null);
|
||||
}
|
||||
refreshProfiles();
|
||||
};
|
||||
|
||||
@@ -106,16 +112,23 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="flex flex-col" style={{ width: 680, maxHeight: '75vh' }}>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b" style={{ borderColor: 'var(--app-border)' }}>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3 border-b"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
>
|
||||
<h2 className="text-base font-semibold">{t('profile.title')}</h2>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t('profile.namePlaceholder')}
|
||||
className="px-2 py-1 text-sm rounded border outline-none w-44"
|
||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
|
||||
style={{
|
||||
backgroundColor: 'var(--app-list-bg)',
|
||||
color: 'var(--app-fg)',
|
||||
borderColor: 'var(--app-border)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="px-3 py-1 text-sm rounded text-white"
|
||||
@@ -138,11 +151,16 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 左侧:列表 */}
|
||||
<div className="w-48 border-r overflow-auto p-2" style={{ borderColor: 'var(--app-border)' }}>
|
||||
<div
|
||||
className="w-48 border-r overflow-auto p-2"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
>
|
||||
{profiles.length === 0 ? (
|
||||
<div className="text-xs text-center py-6" style={{ opacity: 0.5 }}>{t('profile.noProfiles')}</div>
|
||||
<div className="text-xs text-center py-6" style={{ opacity: 0.5 }}>
|
||||
{t('profile.noProfiles')}
|
||||
</div>
|
||||
) : (
|
||||
profiles.map(p => (
|
||||
profiles.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
onClick={() => handleLoad(p.name)}
|
||||
@@ -168,7 +186,9 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-semibold text-sm">{selectedData.name}</span>
|
||||
<span className="text-xs" style={{ opacity: 0.5 }}>{selectedData.modified}</span>
|
||||
<span className="text-xs" style={{ opacity: 0.5 }}>
|
||||
{selectedData.modified}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
@@ -182,7 +202,10 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
<button
|
||||
className="px-3 py-1 text-xs rounded"
|
||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)' }}
|
||||
onClick={() => { setRenameOpen(true); setRenameValue(selectedData.name); }}
|
||||
onClick={() => {
|
||||
setRenameOpen(true);
|
||||
setRenameValue(selectedData.name);
|
||||
}}
|
||||
>
|
||||
{t('profile.rename')}
|
||||
</button>
|
||||
@@ -200,18 +223,32 @@ export function ProfileDialog({ open, onClose }: Props) {
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded border outline-none"
|
||||
style={{ backgroundColor: 'var(--app-list-bg)', color: 'var(--app-fg)', borderColor: 'var(--app-border)' }}
|
||||
style={{
|
||||
backgroundColor: 'var(--app-list-bg)',
|
||||
color: 'var(--app-fg)',
|
||||
borderColor: 'var(--app-border)',
|
||||
}}
|
||||
/>
|
||||
<button className="px-2 py-1 text-xs rounded text-white" style={{ backgroundColor: '#3b82f6' }} onClick={handleRename}>
|
||||
<button
|
||||
className="px-2 py-1 text-xs rounded text-white"
|
||||
style={{ backgroundColor: '#3b82f6' }}
|
||||
onClick={handleRename}
|
||||
>
|
||||
{t('button.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PathSection title={`系统 PATH (${selectedData.sys.length})`} paths={selectedData.sys} />
|
||||
<PathSection title={`用户 PATH (${selectedData.user.length})`} paths={selectedData.user} />
|
||||
<PathSection
|
||||
title={`系统 PATH (${selectedData.sys.length})`}
|
||||
paths={selectedData.sys}
|
||||
/>
|
||||
<PathSection
|
||||
title={`用户 PATH (${selectedData.user.length})`}
|
||||
paths={selectedData.user}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -225,9 +262,13 @@ function PathSection({ title, paths }: { title: string; paths: PathEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium mb-1" style={{ opacity: 0.7 }}>{title}</div>
|
||||
<div className="text-xs font-medium mb-1" style={{ opacity: 0.7 }}>
|
||||
{title}
|
||||
</div>
|
||||
{paths.length === 0 ? (
|
||||
<div className="text-xs" style={{ opacity: 0.4 }}>{t('profile.empty')}</div>
|
||||
<div className="text-xs" style={{ opacity: 0.4 }}>
|
||||
{t('profile.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-48 overflow-auto">
|
||||
{paths.map((e) => (
|
||||
|
||||
@@ -28,19 +28,32 @@ export function AppShell() {
|
||||
const setSelectedIndices = useAppStore((s) => s.setSelectedIndices);
|
||||
|
||||
const [editDialog, setEditDialog] = useState<DialogState['editDialog']>({
|
||||
open: false, index: -1, value: '', target: TargetType.SYSTEM,
|
||||
open: false,
|
||||
index: -1,
|
||||
value: '',
|
||||
target: TargetType.SYSTEM,
|
||||
});
|
||||
const [newDialog, setNewDialog] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [importDialog, setImportDialog] = useState<DialogState['importDialog']>({
|
||||
open: false, system: [], user: [],
|
||||
open: false,
|
||||
system: [],
|
||||
user: [],
|
||||
});
|
||||
const [analyzeOpen, setAnalyzeOpen] = useState(false);
|
||||
const [profilesOpen, setProfilesOpen] = useState(false);
|
||||
|
||||
const actions = useAppActions(activeTab, {
|
||||
editDialog, newDialog, helpOpen, importDialog,
|
||||
setEditDialog, setNewDialog, setHelpOpen, setImportDialog, setAnalyzeOpen, setProfilesOpen,
|
||||
editDialog,
|
||||
newDialog,
|
||||
helpOpen,
|
||||
importDialog,
|
||||
setEditDialog,
|
||||
setNewDialog,
|
||||
setHelpOpen,
|
||||
setImportDialog,
|
||||
setAnalyzeOpen,
|
||||
setProfilesOpen,
|
||||
});
|
||||
|
||||
const tabConfig: { id: TabId; label: string }[] = [
|
||||
@@ -50,14 +63,20 @@ export function AppShell() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen" style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}>
|
||||
<div
|
||||
className="flex flex-col h-screen"
|
||||
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
|
||||
>
|
||||
<TitleBar />
|
||||
|
||||
<div className="flex border-b px-4" style={{ borderColor: 'var(--app-border)' }}>
|
||||
{tabConfig.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => { setActiveTab(tab.id); setSelectedIndices([]); }}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setSelectedIndices([]);
|
||||
}}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition-colors ${activeTab === tab.id ? 'tab-active' : 'opacity-60'}`}
|
||||
style={{ color: activeTab === tab.id ? '#3b82f6' : 'var(--app-fg)' }}
|
||||
>
|
||||
@@ -96,7 +115,10 @@ export function AppShell() {
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'link'; }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (activeTab === 'merged') return;
|
||||
@@ -104,20 +126,47 @@ export function AppShell() {
|
||||
const entry = e.dataTransfer.items[i].webkitGetAsEntry();
|
||||
if (entry?.isDirectory) {
|
||||
const file = e.dataTransfer.files[i] as TauriFile;
|
||||
if (file.path) useAppStore.getState().addPath(file.path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
|
||||
if (file.path)
|
||||
useAppStore
|
||||
.getState()
|
||||
.addPath(file.path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeTab === 'merged' ? <MergePreview /> : <PathTable tabId={activeTab as 'system' | 'user'} />}
|
||||
{activeTab === 'merged' ? (
|
||||
<MergePreview />
|
||||
) : (
|
||||
<PathTable tabId={activeTab as 'system' | 'user'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StatusBar />
|
||||
|
||||
<PathEditDialog open={newDialog} title={t('dialog.newPath')} initialValue="" onConfirm={actions.handleNewConfirm} onCancel={() => setNewDialog(false)} />
|
||||
<PathEditDialog open={editDialog.open} title={t('dialog.editPath')} initialValue={editDialog.value} onConfirm={actions.handleEditConfirm} onCancel={() => setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })} />
|
||||
<PathEditDialog
|
||||
open={newDialog}
|
||||
title={t('dialog.newPath')}
|
||||
initialValue=""
|
||||
onConfirm={actions.handleNewConfirm}
|
||||
onCancel={() => setNewDialog(false)}
|
||||
/>
|
||||
<PathEditDialog
|
||||
open={editDialog.open}
|
||||
title={t('dialog.editPath')}
|
||||
initialValue={editDialog.value}
|
||||
onConfirm={actions.handleEditConfirm}
|
||||
onCancel={() =>
|
||||
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM })
|
||||
}
|
||||
/>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
<ImportDialog open={importDialog.open} systemCount={importDialog.system.length} userCount={importDialog.user.length} onSelect={actions.handleImportSelect} onCancel={() => setImportDialog({ open: false, system: [], user: [] })} />
|
||||
<ImportDialog
|
||||
open={importDialog.open}
|
||||
systemCount={importDialog.system.length}
|
||||
userCount={importDialog.user.length}
|
||||
onSelect={actions.handleImportSelect}
|
||||
onCancel={() => setImportDialog({ open: false, system: [], user: [] })}
|
||||
/>
|
||||
<AnalyzeDialog open={analyzeOpen} onClose={() => setAnalyzeOpen(false)} />
|
||||
<ProfileDialog open={profilesOpen} onClose={() => setProfilesOpen(false)} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Component, type ReactNode } from 'react';
|
||||
|
||||
interface Props { children: ReactNode; }
|
||||
interface State { hasError: boolean; error: string; }
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: '' };
|
||||
@@ -18,7 +23,10 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen" style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}>
|
||||
<div
|
||||
className="flex items-center justify-center h-screen"
|
||||
style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-xl font-bold">应用出错</h2>
|
||||
<p className="text-sm opacity-70">{this.state.error}</p>
|
||||
|
||||
@@ -11,9 +11,7 @@ export function TitleBar() {
|
||||
className="flex items-center justify-between px-4 py-2 border-b select-none"
|
||||
style={{ borderColor: 'var(--app-border)' }}
|
||||
>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{isAdmin ? t('app.name') : t('app.nameReadonly')}
|
||||
</h1>
|
||||
<h1 className="text-lg font-semibold">{isAdmin ? t('app.name') : t('app.nameReadonly')}</h1>
|
||||
<span className="text-sm opacity-60">v{version}</span>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,8 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
const result: PathRow[] = [];
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const p = paths[i];
|
||||
if (p.path.toLowerCase().includes(q)) result.push({ path: p.path, index: i, enabled: p.enabled });
|
||||
if (p.path.toLowerCase().includes(q))
|
||||
result.push({ path: p.path, index: i, enabled: p.enabled });
|
||||
}
|
||||
return result;
|
||||
}, [paths, searchQuery]);
|
||||
@@ -141,7 +142,10 @@ export function PathTable({ tabId }: PathTableProps) {
|
||||
: 'var(--app-list-alt)',
|
||||
}}
|
||||
>
|
||||
<div className="w-8 px-2 py-0.5 text-xs opacity-50" style={{ color: 'var(--app-fg)' }}>
|
||||
<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">
|
||||
|
||||
@@ -36,12 +36,7 @@ export function ToolBar(props: ToolBarProps) {
|
||||
<SearchInput />
|
||||
<div className="flex-1" />
|
||||
<UndoRedoButtons />
|
||||
<button
|
||||
className={btnClass}
|
||||
style={btnStyle}
|
||||
disabled={!isAdmin}
|
||||
onClick={props.onImport}
|
||||
>
|
||||
<button className={btnClass} style={btnStyle} disabled={!isAdmin} onClick={props.onImport}>
|
||||
{t('button.import')}
|
||||
</button>
|
||||
<button className={btnClass} style={btnStyle} onClick={props.onExport}>
|
||||
|
||||
@@ -9,7 +9,9 @@ interface ModalProps {
|
||||
export function Modal({ open, onClose, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const btnClass = 'px-3 py-1 text-sm rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
export const btnClass =
|
||||
'px-3 py-1 text-sm rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
|
||||
export const btnStyle: React.CSSProperties = {
|
||||
backgroundColor: 'var(--app-bg)',
|
||||
|
||||
@@ -28,8 +28,8 @@ export function exportToJson(data: ExportData): string {
|
||||
const obj = {
|
||||
version,
|
||||
timestamp: new Date().toISOString(),
|
||||
system: data.system.map(e => ({ path: e.path, enabled: e.enabled })),
|
||||
user: data.user.map(e => ({ path: e.path, enabled: e.enabled })),
|
||||
system: data.system.map((e) => ({ path: e.path, enabled: e.enabled })),
|
||||
user: data.user.map((e) => ({ path: e.path, enabled: e.enabled })),
|
||||
};
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
@@ -179,10 +179,14 @@ export function importFromJson(content: string): ImportResult {
|
||||
};
|
||||
|
||||
if (Array.isArray(obj.system)) {
|
||||
result.system = obj.system.map(parseEntry).filter((e): e is { path: string; enabled: boolean } => e !== null);
|
||||
result.system = obj.system
|
||||
.map(parseEntry)
|
||||
.filter((e): e is { path: string; enabled: boolean } => e !== null);
|
||||
}
|
||||
if (Array.isArray(obj.user)) {
|
||||
result.user = obj.user.map(parseEntry).filter((e): e is { path: string; enabled: boolean } => e !== null);
|
||||
result.user = obj.user
|
||||
.map(parseEntry)
|
||||
.filter((e): e is { path: string; enabled: boolean } => e !== null);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -210,10 +214,7 @@ export function importFromTxt(content: string): PathEntry[] {
|
||||
|
||||
// ── 自动检测导入 ──
|
||||
|
||||
export function importFromContent(
|
||||
content: string,
|
||||
filepath: string,
|
||||
): ImportResult {
|
||||
export function importFromContent(content: string, filepath: string): ImportResult {
|
||||
const lower = filepath.toLowerCase();
|
||||
if (lower.endsWith('.csv')) {
|
||||
return importFromCsv(content);
|
||||
|
||||
@@ -21,7 +21,11 @@ export function analyzePaths(
|
||||
const lower = entry.path.toLowerCase();
|
||||
const isDuplicate = seen.has(lower);
|
||||
seen.add(lower);
|
||||
result.push({ isValid: validateFn(entry.path), isDuplicate, isEnvVar: entry.path.includes('%') });
|
||||
result.push({
|
||||
isValid: validateFn(entry.path),
|
||||
isDuplicate,
|
||||
isEnvVar: entry.path.includes('%'),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
+31
-7
@@ -5,7 +5,16 @@
|
||||
import type { PathEntry } from './path-entry';
|
||||
|
||||
export const OperationType = {
|
||||
ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7, TOGGLE: 8, IMPORT_BOTH: 9,
|
||||
ADD: 0,
|
||||
DELETE: 1,
|
||||
EDIT: 2,
|
||||
MOVE_UP: 3,
|
||||
MOVE_DOWN: 4,
|
||||
CLEAN: 5,
|
||||
CLEAR: 6,
|
||||
IMPORT: 7,
|
||||
TOGGLE: 8,
|
||||
IMPORT_BOTH: 9,
|
||||
} as const;
|
||||
export type OperationType = (typeof OperationType)[keyof typeof OperationType];
|
||||
|
||||
@@ -47,7 +56,10 @@ export class UndoRedoManager {
|
||||
this.current = this.records.length - 1;
|
||||
}
|
||||
|
||||
undo(sysPaths: readonly PathEntry[], userPaths: readonly PathEntry[]): [PathEntry[], PathEntry[]] | null {
|
||||
undo(
|
||||
sysPaths: readonly PathEntry[],
|
||||
userPaths: readonly PathEntry[],
|
||||
): [PathEntry[], PathEntry[]] | null {
|
||||
if (this.current < 0) return null;
|
||||
|
||||
const rec = this.records[this.current];
|
||||
@@ -103,7 +115,10 @@ export class UndoRedoManager {
|
||||
return [sys, user];
|
||||
}
|
||||
|
||||
redo(sysPaths: readonly PathEntry[], userPaths: readonly PathEntry[]): [PathEntry[], PathEntry[]] | null {
|
||||
redo(
|
||||
sysPaths: readonly PathEntry[],
|
||||
userPaths: readonly PathEntry[],
|
||||
): [PathEntry[], PathEntry[]] | null {
|
||||
if (this.current >= this.records.length - 1) return null;
|
||||
|
||||
this.current++;
|
||||
@@ -159,8 +174,17 @@ export class UndoRedoManager {
|
||||
return [sys, user];
|
||||
}
|
||||
|
||||
canUndo(): boolean { return this.current >= 0; }
|
||||
canRedo(): boolean { return this.current < this.records.length - 1; }
|
||||
clear(): void { this.records = []; this.current = -1; }
|
||||
get historyLength(): number { return this.records.length; }
|
||||
canUndo(): boolean {
|
||||
return this.current >= 0;
|
||||
}
|
||||
canRedo(): boolean {
|
||||
return this.current < this.records.length - 1;
|
||||
}
|
||||
clear(): void {
|
||||
this.records = [];
|
||||
this.current = -1;
|
||||
}
|
||||
get historyLength(): number {
|
||||
return this.records.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useAppStore } from '@/store/app-store';
|
||||
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 {
|
||||
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';
|
||||
@@ -38,9 +43,10 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||
const idx = useAppStore.getState().selectedIndices[0];
|
||||
if (idx === undefined) return;
|
||||
const target = activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM;
|
||||
const list = target === TargetType.SYSTEM
|
||||
? useAppStore.getState().sysPaths
|
||||
: useAppStore.getState().userPaths;
|
||||
const list =
|
||||
target === TargetType.SYSTEM
|
||||
? useAppStore.getState().sysPaths
|
||||
: useAppStore.getState().userPaths;
|
||||
const entry = list[idx];
|
||||
if (entry) setEditDialog({ open: true, index: idx, value: entry.path, target });
|
||||
}, [activeTab, setEditDialog]);
|
||||
@@ -71,14 +77,9 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||
}, [getCurrentTarget]);
|
||||
|
||||
const handleClean = useCallback(() => {
|
||||
const removed = useAppStore.getState().cleanPaths(
|
||||
getCurrentTarget(),
|
||||
is_valid_path_format,
|
||||
);
|
||||
const removed = useAppStore.getState().cleanPaths(getCurrentTarget(), is_valid_path_format);
|
||||
if (removed.length > 0) {
|
||||
useAppStore.getState().setStatusMessage(
|
||||
i18n.t('status.deleted', { count: removed.length }),
|
||||
);
|
||||
useAppStore.getState().setStatusMessage(i18n.t('status.deleted', { count: removed.length }));
|
||||
}
|
||||
}, [getCurrentTarget]);
|
||||
|
||||
@@ -95,9 +96,15 @@ 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.map(e => e.path));
|
||||
useAppStore.getState().replacePaths(
|
||||
TargetType.SYSTEM,
|
||||
result.system.map((e) => e.path),
|
||||
);
|
||||
} else if (result.user.length > 0) {
|
||||
useAppStore.getState().replacePaths(TargetType.USER, result.user.map(e => e.path));
|
||||
useAppStore.getState().replacePaths(
|
||||
TargetType.USER,
|
||||
result.user.map((e) => e.path),
|
||||
);
|
||||
}
|
||||
}, [setImportDialog]);
|
||||
|
||||
@@ -122,7 +129,10 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||
if (result.kind === 'warning') {
|
||||
// 长度超限,需要用户确认
|
||||
const { ask } = await import('@tauri-apps/plugin-dialog');
|
||||
const confirmed = await ask(i18n.t('status.saveWarningLongPaths'), { title: i18n.t('dialog.backupTitle'), kind: 'warning' });
|
||||
const confirmed = await ask(i18n.t('status.saveWarningLongPaths'), {
|
||||
title: i18n.t('dialog.backupTitle'),
|
||||
kind: 'warning',
|
||||
});
|
||||
if (confirmed) {
|
||||
await useAppStore.getState().savePaths(true);
|
||||
}
|
||||
@@ -156,33 +166,62 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
|
||||
|
||||
// ── 弹窗确认 ──
|
||||
|
||||
const handleNewConfirm = useCallback((value: string) => {
|
||||
setNewDialog(false);
|
||||
if (value.trim()) useAppStore.getState().addPath(value.trim(), getCurrentTarget());
|
||||
}, [getCurrentTarget, setNewDialog]);
|
||||
const handleNewConfirm = useCallback(
|
||||
(value: string) => {
|
||||
setNewDialog(false);
|
||||
if (value.trim()) useAppStore.getState().addPath(value.trim(), getCurrentTarget());
|
||||
},
|
||||
[getCurrentTarget, setNewDialog],
|
||||
);
|
||||
|
||||
const handleEditConfirm = useCallback((value: string) => {
|
||||
const d = dialogs.editDialog;
|
||||
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM });
|
||||
if (value.trim()) useAppStore.getState().editPath(d.index, value.trim(), d.target);
|
||||
}, [dialogs.editDialog, setEditDialog]);
|
||||
const handleEditConfirm = useCallback(
|
||||
(value: string) => {
|
||||
const d = dialogs.editDialog;
|
||||
setEditDialog({ open: false, index: -1, value: '', target: TargetType.SYSTEM });
|
||||
if (value.trim()) useAppStore.getState().editPath(d.index, value.trim(), d.target);
|
||||
},
|
||||
[dialogs.editDialog, setEditDialog],
|
||||
);
|
||||
|
||||
const handleImportSelect = useCallback((target: 'system' | 'user' | 'both') => {
|
||||
const { system, user } = dialogs.importDialog;
|
||||
const flat = flattenImportResult({ system, user }, target);
|
||||
if (target === 'both' && flat.system.length > 0 && flat.user.length > 0) {
|
||||
useAppStore.getState().replaceBothPaths(flat.system.map(e => e.path), flat.user.map(e => e.path));
|
||||
} else {
|
||||
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]);
|
||||
const handleImportSelect = useCallback(
|
||||
(target: 'system' | 'user' | 'both') => {
|
||||
const { system, user } = dialogs.importDialog;
|
||||
const flat = flattenImportResult({ system, user }, target);
|
||||
if (target === 'both' && flat.system.length > 0 && flat.user.length > 0) {
|
||||
useAppStore.getState().replaceBothPaths(
|
||||
flat.system.map((e) => e.path),
|
||||
flat.user.map((e) => e.path),
|
||||
);
|
||||
} else {
|
||||
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],
|
||||
);
|
||||
|
||||
return {
|
||||
handleNew, handleEdit, handleBrowse, handleDelete,
|
||||
handleMoveUp, handleMoveDown, handleClean,
|
||||
handleImport, handleExport, handleSave,
|
||||
handleNewConfirm, handleEditConfirm, handleImportSelect,
|
||||
handleNew,
|
||||
handleEdit,
|
||||
handleBrowse,
|
||||
handleDelete,
|
||||
handleMoveUp,
|
||||
handleMoveDown,
|
||||
handleClean,
|
||||
handleImport,
|
||||
handleExport,
|
||||
handleSave,
|
||||
handleNewConfirm,
|
||||
handleEditConfirm,
|
||||
handleImportSelect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,17 +60,15 @@ export function usePathValidation(paths: readonly PathEntry[]) {
|
||||
|
||||
const batch = toValidate.slice(0, 20);
|
||||
Promise.all(
|
||||
batch.map(
|
||||
async (p): Promise<[string, ValidationState]> => {
|
||||
try {
|
||||
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.path, 'unknown'];
|
||||
}
|
||||
},
|
||||
),
|
||||
batch.map(async (p): Promise<[string, ValidationState]> => {
|
||||
try {
|
||||
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.path, 'unknown'];
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
if (cancelled) return;
|
||||
for (const [p] of results) validatedRef.current.add(p);
|
||||
@@ -89,23 +87,19 @@ export function usePathValidation(paths: readonly PathEntry[]) {
|
||||
// 异步展开环境变量(setState 在 .then() 回调中)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const toExpand = paths.filter(
|
||||
(p) => p.path.includes('%') && !expandedRef.current.has(p.path),
|
||||
);
|
||||
const toExpand = paths.filter((p) => p.path.includes('%') && !expandedRef.current.has(p.path));
|
||||
if (toExpand.length === 0) return;
|
||||
|
||||
const batch = toExpand.slice(0, 20);
|
||||
Promise.all(
|
||||
batch.map(
|
||||
async (p): Promise<[string, string]> => {
|
||||
try {
|
||||
const expanded: string = await invoke('expand_env_vars', { path: p.path });
|
||||
return [p.path, expanded !== p.path ? expanded : ''];
|
||||
} catch {
|
||||
return [p.path, ''];
|
||||
}
|
||||
},
|
||||
),
|
||||
batch.map(async (p): Promise<[string, string]> => {
|
||||
try {
|
||||
const expanded: string = await invoke('expand_env_vars', { path: p.path });
|
||||
return [p.path, expanded !== p.path ? expanded : ''];
|
||||
} catch {
|
||||
return [p.path, ''];
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
if (cancelled) return;
|
||||
for (const [p] of results) expandedRef.current.add(p);
|
||||
|
||||
+389
-318
@@ -54,11 +54,12 @@ interface AppState {
|
||||
loadPaths: () => Promise<void>;
|
||||
savePaths: (force?: boolean) => Promise<SaveResult>;
|
||||
initialize: () => Promise<void>;
|
||||
|
||||
}
|
||||
|
||||
function arraysEqual(a: readonly PathEntry[], b: readonly PathEntry[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v.path === b[i].path && v.enabled === b[i].enabled);
|
||||
return (
|
||||
a.length === b.length && a.every((v, i) => v.path === b[i].path && v.enabled === b[i].enabled)
|
||||
);
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => {
|
||||
@@ -69,335 +70,405 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
|
||||
return {
|
||||
sysPaths: [],
|
||||
userPaths: [],
|
||||
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||
_savedSys: [],
|
||||
_savedUser: [],
|
||||
userPaths: [],
|
||||
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||
_savedSys: [],
|
||||
_savedUser: [],
|
||||
|
||||
activeTab: 'system',
|
||||
searchQuery: '',
|
||||
selectedIndices: [],
|
||||
isAdmin: false,
|
||||
statusMessage: '',
|
||||
isModified: false,
|
||||
isLoading: true,
|
||||
isSaving: false,
|
||||
activeTab: 'system',
|
||||
searchQuery: '',
|
||||
selectedIndices: [],
|
||||
isAdmin: false,
|
||||
statusMessage: '',
|
||||
isModified: false,
|
||||
isLoading: true,
|
||||
isSaving: false,
|
||||
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSelectedIndices: (indices) => set({ selectedIndices: indices }),
|
||||
setStatusMessage: (msg) => set({ statusMessage: msg }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSelectedIndices: (indices) => set({ selectedIndices: indices }),
|
||||
setStatusMessage: (msg) => set({ statusMessage: msg }),
|
||||
|
||||
addPath: (path, target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const entry: PathEntry = { path, enabled: true };
|
||||
const newList = [...list, entry];
|
||||
state.undoRedo.push({
|
||||
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
|
||||
oldPaths: [], newPaths: [entry],
|
||||
});
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||
else set({ userPaths: newList });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
editPath: (index, newPath, target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const oldEntry = list[index];
|
||||
if (!oldEntry) return;
|
||||
const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled };
|
||||
state.undoRedo.push({
|
||||
type: OperationType.EDIT, target, index, count: 1,
|
||||
oldPaths: [oldEntry], newPaths: [newEntry],
|
||||
});
|
||||
const newList = [...list];
|
||||
newList[index] = newEntry;
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||
else set({ userPaths: newList });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
deletePaths: (indices, target) => {
|
||||
if (indices.length === 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const sortedDesc = [...indices].sort((a, b) => b - a);
|
||||
const sortedAsc = [...indices].sort((a, b) => a - b);
|
||||
const oldPaths = sortedAsc.map((i) => list[i]);
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.DELETE, target,
|
||||
index: sortedAsc[0], count: sortedAsc.length,
|
||||
oldPaths, newPaths: [],
|
||||
indices: sortedAsc,
|
||||
});
|
||||
|
||||
const toRemove = new Set(sortedDesc);
|
||||
const newList = list.filter((_, i) => !toRemove.has(i));
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [] });
|
||||
else set({ userPaths: newList, selectedIndices: [] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
moveUp: (index, target) => {
|
||||
if (index <= 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
state.undoRedo.push({
|
||||
type: OperationType.MOVE_UP, target, index, count: 1, oldPaths: [], newPaths: [],
|
||||
});
|
||||
const newList = [...list];
|
||||
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1] });
|
||||
else set({ userPaths: newList, selectedIndices: [index - 1] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
moveDown: (index, target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
if (index >= list.length - 1) return;
|
||||
state.undoRedo.push({
|
||||
type: OperationType.MOVE_DOWN, target, index, count: 1, oldPaths: [], newPaths: [],
|
||||
});
|
||||
const newList = [...list];
|
||||
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1] });
|
||||
else set({ userPaths: newList, selectedIndices: [index + 1] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
cleanPaths: (target, validateFn) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const [kept, removed] = pathClean(list, validateFn);
|
||||
|
||||
if (removed.length > 0) {
|
||||
addPath: (path, target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const entry: PathEntry = { path, enabled: true };
|
||||
const newList = [...list, entry];
|
||||
state.undoRedo.push({
|
||||
type: OperationType.CLEAN, target, index: 0, count: removed.length,
|
||||
oldPaths: [...list], newPaths: kept,
|
||||
type: OperationType.ADD,
|
||||
target,
|
||||
index: newList.length - 1,
|
||||
count: 1,
|
||||
oldPaths: [],
|
||||
newPaths: [entry],
|
||||
});
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [] });
|
||||
else set({ userPaths: kept, selectedIndices: [] });
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||
else set({ userPaths: newList });
|
||||
markDirty();
|
||||
}
|
||||
},
|
||||
|
||||
return removed.map(e => e.path);
|
||||
},
|
||||
|
||||
replacePaths: (target, newPaths) => {
|
||||
if (newPaths.length === 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const entries: PathEntry[] = newPaths.map(p => ({ path: p, enabled: true }));
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.IMPORT, target, index: 0, count: entries.length,
|
||||
oldPaths: [...list], newPaths: [...entries],
|
||||
});
|
||||
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: [...entries], selectedIndices: [] });
|
||||
else set({ userPaths: [...entries], selectedIndices: [] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
replaceBothPaths: (sysPaths, userPaths) => {
|
||||
const state = get();
|
||||
const sysEntries: PathEntry[] = sysPaths.map(p => ({ path: p, enabled: true }));
|
||||
const usrEntries: PathEntry[] = userPaths.map(p => ({ path: p, enabled: true }));
|
||||
state.undoRedo.push({
|
||||
type: OperationType.IMPORT_BOTH, target: TargetType.SYSTEM, index: 0,
|
||||
count: sysEntries.length + usrEntries.length,
|
||||
oldPaths: [...state.sysPaths], newPaths: [...sysEntries],
|
||||
oldPathsOther: [...state.userPaths], newPathsOther: [...usrEntries],
|
||||
});
|
||||
set({ sysPaths: [...sysEntries], userPaths: [...usrEntries], selectedIndices: [] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
clearPaths: (target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
if (list.length === 0) return;
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.CLEAR, target, index: 0, count: list.length,
|
||||
oldPaths: [...list], newPaths: [],
|
||||
});
|
||||
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
|
||||
else set({ userPaths: [] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
togglePath: (index, target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const oldEntry = list[index];
|
||||
if (!oldEntry) return;
|
||||
const newEntry: PathEntry = { path: oldEntry.path, enabled: !oldEntry.enabled };
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.TOGGLE, target, index, count: 1,
|
||||
oldPaths: [oldEntry], newPaths: [newEntry],
|
||||
});
|
||||
|
||||
const newList = [...list];
|
||||
newList[index] = newEntry;
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||
else set({ userPaths: newList });
|
||||
markDirty();
|
||||
|
||||
// 即时保存禁用状态
|
||||
const { sysPaths: sys, userPaths: usr } = get();
|
||||
const sysDisabled = sys.filter(e => !e.enabled).map(e => e.path);
|
||||
const usrDisabled = usr.filter(e => !e.enabled).map(e => e.path);
|
||||
invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled })
|
||||
.catch((e) => console.warn('保存禁用状态失败:', e));
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
||||
const result = undoRedo.undo(sysPaths, userPaths);
|
||||
if (result) {
|
||||
set({
|
||||
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||
editPath: (index, newPath, target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const oldEntry = list[index];
|
||||
if (!oldEntry) return;
|
||||
const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled };
|
||||
state.undoRedo.push({
|
||||
type: OperationType.EDIT,
|
||||
target,
|
||||
index,
|
||||
count: 1,
|
||||
oldPaths: [oldEntry],
|
||||
newPaths: [newEntry],
|
||||
});
|
||||
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
||||
invoke('save_disabled_state', {
|
||||
system: result[0].filter(e => !e.enabled).map(e => e.path),
|
||||
user: result[1].filter(e => !e.enabled).map(e => e.path),
|
||||
}).catch((e) => console.warn('保存禁用状态失败:', e));
|
||||
}
|
||||
},
|
||||
const newList = [...list];
|
||||
newList[index] = newEntry;
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||
else set({ userPaths: newList });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
||||
const result = undoRedo.redo(sysPaths, userPaths);
|
||||
if (result) {
|
||||
set({
|
||||
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
|
||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||
deletePaths: (indices, target) => {
|
||||
if (indices.length === 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const sortedDesc = [...indices].sort((a, b) => b - a);
|
||||
const sortedAsc = [...indices].sort((a, b) => a - b);
|
||||
const oldPaths = sortedAsc.map((i) => list[i]);
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.DELETE,
|
||||
target,
|
||||
index: sortedAsc[0],
|
||||
count: sortedAsc.length,
|
||||
oldPaths,
|
||||
newPaths: [],
|
||||
indices: sortedAsc,
|
||||
});
|
||||
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
||||
invoke('save_disabled_state', {
|
||||
system: result[0].filter(e => !e.enabled).map(e => e.path),
|
||||
user: result[1].filter(e => !e.enabled).map(e => e.path),
|
||||
}).catch((e) => console.warn('保存禁用状态失败:', e));
|
||||
}
|
||||
},
|
||||
|
||||
loadPaths: async () => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
const [sysArr, userArr] = await Promise.all([
|
||||
invoke<string[]>('load_system_paths'),
|
||||
invoke<string[]>('load_user_paths'),
|
||||
const toRemove = new Set(sortedDesc);
|
||||
const newList = list.filter((_, i) => !toRemove.has(i));
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [] });
|
||||
else set({ userPaths: newList, selectedIndices: [] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
moveUp: (index, target) => {
|
||||
if (index <= 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
state.undoRedo.push({
|
||||
type: OperationType.MOVE_UP,
|
||||
target,
|
||||
index,
|
||||
count: 1,
|
||||
oldPaths: [],
|
||||
newPaths: [],
|
||||
});
|
||||
const newList = [...list];
|
||||
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1] });
|
||||
else set({ userPaths: newList, selectedIndices: [index - 1] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
moveDown: (index, target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
if (index >= list.length - 1) return;
|
||||
state.undoRedo.push({
|
||||
type: OperationType.MOVE_DOWN,
|
||||
target,
|
||||
index,
|
||||
count: 1,
|
||||
oldPaths: [],
|
||||
newPaths: [],
|
||||
});
|
||||
const newList = [...list];
|
||||
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1] });
|
||||
else set({ userPaths: newList, selectedIndices: [index + 1] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
cleanPaths: (target, validateFn) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const [kept, removed] = pathClean(list, validateFn);
|
||||
|
||||
if (removed.length > 0) {
|
||||
state.undoRedo.push({
|
||||
type: OperationType.CLEAN,
|
||||
target,
|
||||
index: 0,
|
||||
count: removed.length,
|
||||
oldPaths: [...list],
|
||||
newPaths: kept,
|
||||
});
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [] });
|
||||
else set({ userPaths: kept, selectedIndices: [] });
|
||||
markDirty();
|
||||
}
|
||||
|
||||
return removed.map((e) => e.path);
|
||||
},
|
||||
|
||||
replacePaths: (target, newPaths) => {
|
||||
if (newPaths.length === 0) return;
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const entries: PathEntry[] = newPaths.map((p) => ({ path: p, enabled: true }));
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.IMPORT,
|
||||
target,
|
||||
index: 0,
|
||||
count: entries.length,
|
||||
oldPaths: [...list],
|
||||
newPaths: [...entries],
|
||||
});
|
||||
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: [...entries], selectedIndices: [] });
|
||||
else set({ userPaths: [...entries], selectedIndices: [] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
replaceBothPaths: (sysPaths, userPaths) => {
|
||||
const state = get();
|
||||
const sysEntries: PathEntry[] = sysPaths.map((p) => ({ path: p, enabled: true }));
|
||||
const usrEntries: PathEntry[] = userPaths.map((p) => ({ path: p, enabled: true }));
|
||||
state.undoRedo.push({
|
||||
type: OperationType.IMPORT_BOTH,
|
||||
target: TargetType.SYSTEM,
|
||||
index: 0,
|
||||
count: sysEntries.length + usrEntries.length,
|
||||
oldPaths: [...state.sysPaths],
|
||||
newPaths: [...sysEntries],
|
||||
oldPathsOther: [...state.userPaths],
|
||||
newPathsOther: [...usrEntries],
|
||||
});
|
||||
set({ sysPaths: [...sysEntries], userPaths: [...usrEntries], selectedIndices: [] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
clearPaths: (target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
if (list.length === 0) return;
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.CLEAR,
|
||||
target,
|
||||
index: 0,
|
||||
count: list.length,
|
||||
oldPaths: [...list],
|
||||
newPaths: [],
|
||||
});
|
||||
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
|
||||
else set({ userPaths: [] });
|
||||
markDirty();
|
||||
},
|
||||
|
||||
togglePath: (index, target) => {
|
||||
const state = get();
|
||||
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
|
||||
const oldEntry = list[index];
|
||||
if (!oldEntry) return;
|
||||
const newEntry: PathEntry = { path: oldEntry.path, enabled: !oldEntry.enabled };
|
||||
|
||||
state.undoRedo.push({
|
||||
type: OperationType.TOGGLE,
|
||||
target,
|
||||
index,
|
||||
count: 1,
|
||||
oldPaths: [oldEntry],
|
||||
newPaths: [newEntry],
|
||||
});
|
||||
|
||||
const newList = [...list];
|
||||
newList[index] = newEntry;
|
||||
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
|
||||
else set({ userPaths: newList });
|
||||
markDirty();
|
||||
|
||||
// 即时保存禁用状态
|
||||
const { sysPaths: sys, userPaths: usr } = get();
|
||||
const sysDisabled = sys.filter((e) => !e.enabled).map((e) => e.path);
|
||||
const usrDisabled = usr.filter((e) => !e.enabled).map((e) => e.path);
|
||||
invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled }).catch((e) =>
|
||||
console.warn('保存禁用状态失败:', e),
|
||||
);
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
||||
const result = undoRedo.undo(sysPaths, userPaths);
|
||||
if (result) {
|
||||
set({
|
||||
sysPaths: result[0],
|
||||
userPaths: result[1],
|
||||
selectedIndices: [],
|
||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||
});
|
||||
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
||||
invoke('save_disabled_state', {
|
||||
system: result[0].filter((e) => !e.enabled).map((e) => e.path),
|
||||
user: result[1].filter((e) => !e.enabled).map((e) => e.path),
|
||||
}).catch((e) => console.warn('保存禁用状态失败:', e));
|
||||
}
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
|
||||
const result = undoRedo.redo(sysPaths, userPaths);
|
||||
if (result) {
|
||||
set({
|
||||
sysPaths: result[0],
|
||||
userPaths: result[1],
|
||||
selectedIndices: [],
|
||||
// 内联计算 isModified 而非调用 markDirty(),避免两次 set() 导致额外渲染
|
||||
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
|
||||
});
|
||||
// 同步持久化 disabled 状态,与 togglePath 保持一致
|
||||
invoke('save_disabled_state', {
|
||||
system: result[0].filter((e) => !e.enabled).map((e) => e.path),
|
||||
user: result[1].filter((e) => !e.enabled).map((e) => e.path),
|
||||
}).catch((e) => console.warn('保存禁用状态失败:', e));
|
||||
}
|
||||
},
|
||||
|
||||
loadPaths: async () => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
const [sysArr, userArr] = await Promise.all([
|
||||
invoke<string[]>('load_system_paths'),
|
||||
invoke<string[]>('load_user_paths'),
|
||||
]);
|
||||
|
||||
// 加载禁用状态(文件不存在时返回空)
|
||||
let sysDisabled: string[] = [];
|
||||
let usrDisabled: string[] = [];
|
||||
try {
|
||||
const result = await invoke<[string[], string[]]>('load_disabled_state');
|
||||
sysDisabled = result[0];
|
||||
usrDisabled = result[1];
|
||||
} catch {
|
||||
// 文件不存在或损坏,忽略
|
||||
}
|
||||
|
||||
const sysSet = new Set(sysDisabled);
|
||||
const usrSet = new Set(usrDisabled);
|
||||
|
||||
const sysEntries: PathEntry[] = sysArr.map((p) => ({ path: p, enabled: !sysSet.has(p) }));
|
||||
const usrEntries: PathEntry[] = userArr.map((p) => ({ path: p, enabled: !usrSet.has(p) }));
|
||||
|
||||
set({
|
||||
sysPaths: sysEntries,
|
||||
userPaths: usrEntries,
|
||||
_savedSys: [...sysEntries],
|
||||
_savedUser: [...usrEntries],
|
||||
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||
isLoading: false,
|
||||
isModified: false,
|
||||
statusMessage: i18n.t('status.loaded', {
|
||||
sysCount: sysArr.length,
|
||||
userCount: userArr.length,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
|
||||
}
|
||||
},
|
||||
|
||||
savePaths: async (force?: boolean) => {
|
||||
const state = get();
|
||||
if (state.isSaving) return { kind: 'blocked' };
|
||||
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
||||
|
||||
// 只保存 enabled 的路径到注册表
|
||||
const sysPaths = state.sysPaths.filter((e) => e.enabled).map((e) => e.path);
|
||||
const userPaths = state.userPaths.filter((e) => e.enabled).map((e) => e.path);
|
||||
const sysJoined = sysPaths.join(';');
|
||||
const userJoined = userPaths.join(';');
|
||||
|
||||
// 长度检查:非强制模式下返回警告,由 UI 层确认
|
||||
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
|
||||
if (
|
||||
!force &&
|
||||
(sysJoined.length > maxSystemLength ||
|
||||
userJoined.length > maxUserLength ||
|
||||
(sysJoined + userJoined).length > maxCombinedLength)
|
||||
) {
|
||||
set({ isSaving: false, statusMessage: i18n.t('status.saveWarningLongPaths') });
|
||||
return { kind: 'warning', reason: 'lengthExceeded' };
|
||||
}
|
||||
|
||||
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
||||
let backupFailed = false;
|
||||
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, original: origSys }),
|
||||
invoke('save_user_paths', { paths: userPaths, original: origUser }),
|
||||
]);
|
||||
|
||||
// 加载禁用状态(文件不存在时返回空)
|
||||
let sysDisabled: string[] = [];
|
||||
let usrDisabled: string[] = [];
|
||||
try {
|
||||
const result = await invoke<[string[], string[]]>('load_disabled_state');
|
||||
sysDisabled = result[0];
|
||||
usrDisabled = result[1];
|
||||
} catch {
|
||||
// 文件不存在或损坏,忽略
|
||||
}
|
||||
const sysOk = sysResult.status === 'fulfilled';
|
||||
const userOk = userResult.status === 'fulfilled';
|
||||
|
||||
const sysSet = new Set(sysDisabled);
|
||||
const usrSet = new Set(usrDisabled);
|
||||
|
||||
const sysEntries: PathEntry[] = sysArr.map(p => ({ path: p, enabled: !sysSet.has(p) }));
|
||||
const usrEntries: PathEntry[] = userArr.map(p => ({ path: p, enabled: !usrSet.has(p) }));
|
||||
|
||||
set({
|
||||
sysPaths: sysEntries, userPaths: usrEntries,
|
||||
_savedSys: [...sysEntries], _savedUser: [...usrEntries],
|
||||
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||
isLoading: false, isModified: false,
|
||||
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
|
||||
});
|
||||
} catch (e) {
|
||||
set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` });
|
||||
}
|
||||
},
|
||||
|
||||
savePaths: async (force?: boolean) => {
|
||||
const state = get();
|
||||
if (state.isSaving) return { kind: 'blocked' };
|
||||
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
|
||||
|
||||
// 只保存 enabled 的路径到注册表
|
||||
const sysPaths = state.sysPaths.filter(e => e.enabled).map(e => e.path);
|
||||
const userPaths = state.userPaths.filter(e => e.enabled).map(e => e.path);
|
||||
const sysJoined = sysPaths.join(';');
|
||||
const userJoined = userPaths.join(';');
|
||||
|
||||
// 长度检查:非强制模式下返回警告,由 UI 层确认
|
||||
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
|
||||
if (!force && (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength)) {
|
||||
set({ isSaving: false, statusMessage: i18n.t('status.saveWarningLongPaths') });
|
||||
return { kind: 'warning', reason: 'lengthExceeded' };
|
||||
}
|
||||
|
||||
// 备份当前注册表(保存前备份旧值,失败仅警告不中断)
|
||||
let backupFailed = false;
|
||||
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, original: origSys }),
|
||||
invoke('save_user_paths', { paths: userPaths, original: origUser }),
|
||||
]);
|
||||
|
||||
const sysOk = sysResult.status === 'fulfilled';
|
||||
const userOk = userResult.status === 'fulfilled';
|
||||
|
||||
if (sysOk && userOk) {
|
||||
invoke('broadcast_env_change').catch(() => {});
|
||||
const savedSys = [...state.sysPaths], savedUser = [...state.userPaths];
|
||||
set({ isModified: false, isSaving: false,
|
||||
statusMessage: backupFailed ? i18n.t('status.saved_without_backup') : i18n.t('status.saved'),
|
||||
_savedSys: savedSys, _savedUser: savedUser });
|
||||
return { kind: 'success' };
|
||||
} else {
|
||||
const sysErr = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) : '';
|
||||
const usrErr = (!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
|
||||
const parts = [sysErr, usrErr].filter(Boolean);
|
||||
|
||||
const msg = sysOk ? `用户 PATH 保存失败: ${usrErr}` : userOk ? `系统 PATH 保存失败: ${sysErr}` : `保存失败: ${parts.join('; ')}`;
|
||||
|
||||
if (sysOk || userOk) {
|
||||
// partial success
|
||||
set({ isSaving: false });
|
||||
await get().loadPaths(); // reload to avoid state drift
|
||||
set({ statusMessage: msg }); // restore the error message overwritten by loadPaths
|
||||
return { kind: 'partial', message: msg };
|
||||
if (sysOk && userOk) {
|
||||
invoke('broadcast_env_change').catch(() => {});
|
||||
const savedSys = [...state.sysPaths],
|
||||
savedUser = [...state.userPaths];
|
||||
set({
|
||||
isModified: false,
|
||||
isSaving: false,
|
||||
statusMessage: backupFailed
|
||||
? i18n.t('status.saved_without_backup')
|
||||
: i18n.t('status.saved'),
|
||||
_savedSys: savedSys,
|
||||
_savedUser: savedUser,
|
||||
});
|
||||
return { kind: 'success' };
|
||||
} else {
|
||||
set({ isSaving: false, statusMessage: msg });
|
||||
return { kind: 'failure', message: msg };
|
||||
}
|
||||
}
|
||||
},
|
||||
const sysErr = !sysOk && sysResult.status === 'rejected' ? String(sysResult.reason) : '';
|
||||
const usrErr = !userOk && userResult.status === 'rejected' ? String(userResult.reason) : '';
|
||||
const parts = [sysErr, usrErr].filter(Boolean);
|
||||
|
||||
initialize: async () => {
|
||||
try {
|
||||
const isAdmin: boolean = await invoke('check_admin');
|
||||
set({ isAdmin });
|
||||
if (!isAdmin) set({ statusMessage: i18n.t('status.readonly') });
|
||||
} catch {
|
||||
set({ isAdmin: false, statusMessage: i18n.t('status.readonly') });
|
||||
}
|
||||
await get().loadPaths();
|
||||
},
|
||||
};});
|
||||
const msg = sysOk
|
||||
? `用户 PATH 保存失败: ${usrErr}`
|
||||
: userOk
|
||||
? `系统 PATH 保存失败: ${sysErr}`
|
||||
: `保存失败: ${parts.join('; ')}`;
|
||||
|
||||
if (sysOk || userOk) {
|
||||
// partial success
|
||||
set({ isSaving: false });
|
||||
await get().loadPaths(); // reload to avoid state drift
|
||||
set({ statusMessage: msg }); // restore the error message overwritten by loadPaths
|
||||
return { kind: 'partial', message: msg };
|
||||
} else {
|
||||
set({ isSaving: false, statusMessage: msg });
|
||||
return { kind: 'failure', message: msg };
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
initialize: async () => {
|
||||
try {
|
||||
const isAdmin: boolean = await invoke('check_admin');
|
||||
set({ isAdmin });
|
||||
if (!isAdmin) set({ statusMessage: i18n.t('status.readonly') });
|
||||
} catch {
|
||||
set({ isAdmin: false, statusMessage: i18n.t('status.readonly') });
|
||||
}
|
||||
await get().loadPaths();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user