Files
PathEditor/src/components/dialogs/ProfileDialog.tsx
T
Serendipity 9453006310
CI / 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率) (push) Has been cancelled
CI / Rust 检查 (格式 + Check + Clippy + Test) (push) Has been cancelled
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 测试全部通过
2026-06-19 19:24:03 +08:00

296 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { useAppStore } from '@/store/app-store';
import type { PathEntry } from '@/core/path-entry';
interface ProfileMeta {
name: string;
created: string;
modified: string;
}
interface ProfileData {
name: string;
sys: PathEntry[];
user: PathEntry[];
created: string;
modified: string;
}
interface Props {
open: boolean;
onClose: () => void;
}
export function ProfileDialog({ open, onClose }: Props) {
const { t } = useTranslation();
const [profiles, setProfiles] = useState<ProfileMeta[]>([]);
const [newName, setNewName] = useState('');
const [selected, setSelected] = useState<string | null>(null);
const [selectedData, setSelectedData] = useState<ProfileData | null>(null);
const [saving, setSaving] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState('');
const refreshProfiles = useCallback(async () => {
const list = await invoke<ProfileMeta[]>('list_profiles');
setProfiles(list);
}, []);
const prevOpen = useRef(false);
useEffect(() => {
if (open && !prevOpen.current) refreshProfiles();
prevOpen.current = open;
}, [open, refreshProfiles]);
const handleSave = async () => {
if (!newName.trim()) return;
setSaving(true);
const { sysPaths, userPaths } = useAppStore.getState();
await invoke('save_profile', { name: newName.trim(), sys: sysPaths, user: userPaths });
setNewName('');
setSaving(false);
refreshProfiles();
};
const handleLoad = async (name: string) => {
const data = await invoke<ProfileData>('load_profile', { name });
setSelected(name);
setSelectedData(data);
};
const handleApply = async () => {
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),
);
// 同步 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),
});
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',
});
if (confirmed) {
const forceResult = await useAppStore.getState().savePaths(true);
if (forceResult.kind === 'success') {
onClose();
}
}
}
};
const handleDelete = async (name: string) => {
if (!window.confirm(`删除配置文件 "${name}"`)) return;
await invoke('delete_profile', { name });
if (selected === name) {
setSelected(null);
setSelectedData(null);
}
refreshProfiles();
};
const handleRename = async () => {
if (!selected || !renameValue.trim()) return;
await invoke('rename_profile', { oldName: selected, newName: renameValue.trim() });
setRenameOpen(false);
setSelected(renameValue.trim());
refreshProfiles();
};
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)' }}
>
<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)}
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)',
}}
/>
<button
className="px-3 py-1 text-sm rounded text-white"
style={{ backgroundColor: '#3b82f6' }}
disabled={saving || !newName.trim()}
onClick={handleSave}
>
{t('profile.save')}
</button>
<button
onClick={onClose}
className="px-2 py-1 text-sm rounded hover:opacity-70 transition-opacity"
style={{ color: 'var(--app-fg)' }}
title="关闭"
>
</button>
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{/* 左侧:列表 */}
<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>
) : (
profiles.map((p) => (
<div
key={p.name}
onClick={() => handleLoad(p.name)}
className="px-2 py-1.5 text-sm rounded cursor-pointer mb-0.5"
style={{
backgroundColor: selected === p.name ? 'rgba(59,130,246,0.15)' : 'transparent',
color: selected === p.name ? '#3b82f6' : 'var(--app-fg)',
}}
>
{p.name}
</div>
))
)}
</div>
{/* 右侧:详情 */}
<div className="flex-1 p-3 overflow-auto">
{!selectedData ? (
<div className="text-center py-10 text-sm" style={{ opacity: 0.4 }}>
{profiles.length === 0 ? t('profile.noProfiles') : t('profile.selectProfile')}
</div>
) : (
<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>
</div>
<div className="flex gap-1.5 mb-3">
<button
className="px-3 py-1 text-xs rounded text-white"
style={{ backgroundColor: '#3b82f6' }}
onClick={handleApply}
>
{t('profile.apply')}
</button>
<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);
}}
>
{t('profile.rename')}
</button>
<button
className="px-3 py-1 text-xs rounded text-white"
style={{ backgroundColor: '#ef4444' }}
onClick={() => handleDelete(selectedData.name)}
>
{t('profile.delete')}
</button>
</div>
{renameOpen && (
<div className="flex gap-2 mb-2">
<input
type="text"
value={renameValue}
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)',
}}
/>
<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}
/>
</div>
)}
</div>
</div>
</div>
</Modal>
);
}
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>
{paths.length === 0 ? (
<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) => (
<div
key={e.path}
className="text-xs font-mono px-2 py-0.5 rounded flex items-center gap-1.5"
style={{
backgroundColor: 'var(--app-list-bg)',
color: e.enabled ? 'var(--app-fg)' : '#ef4444',
textDecoration: e.enabled ? 'none' : 'line-through',
opacity: e.enabled ? 1 : 0.5,
}}
>
<span style={{ color: e.enabled ? '#22c55e' : '#ef4444', fontSize: 10 }}>
{e.enabled ? '●' : '○'}
</span>
{e.path}
</div>
))}
</div>
)}
</div>
);
}