chore: 同步 v5.0 基础设施完善到 v5.1
CI / 前端检查 (格式 + 类型 + Lint + 测试 + 覆盖率) (push) Has been cancelled
CI / Rust 检查 (格式 + Check + Clippy + Test) (push) Has been cancelled

从 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:
2026-06-19 19:24:03 +08:00
parent 60de924b08
commit 9453006310
52 changed files with 2783 additions and 619 deletions
+15 -4
View File
@@ -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,
);
}
+12 -3
View File
@@ -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>
+41 -5
View File
@@ -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>
);
+34 -10
View File
@@ -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>
+62 -21
View File
@@ -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) => (
+61 -12
View File
@@ -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>
+11 -3
View File
@@ -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>
+1 -3
View File
@@ -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>
);
+6 -2
View File
@@ -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">
+1 -6
View File
@@ -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}>
+3 -1
View File
@@ -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]);
+2 -1
View File
@@ -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)',