Files
PathEditor/src/hooks/use-app-actions.ts
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

228 lines
7.7 KiB
TypeScript

import { useCallback, useEffect } from 'react';
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 type { PathEntry } from '@/core/path-entry';
import { is_valid_path_format } from '@/core/validation';
import { useKeyboard } from './use-keyboard';
import i18n from '@/i18n';
import type { TabId } from '@/store/app-store';
export interface DialogState {
editDialog: { open: boolean; index: number; value: string; target: TargetType };
newDialog: boolean;
helpOpen: boolean;
importDialog: { open: boolean; system: PathEntry[]; user: PathEntry[] };
setEditDialog: (v: DialogState['editDialog']) => void;
setNewDialog: (v: boolean) => void;
setHelpOpen: (v: boolean) => void;
setImportDialog: (v: DialogState['importDialog']) => void;
setAnalyzeOpen: (v: boolean) => void;
setProfilesOpen: (v: boolean) => void;
}
export function useAppActions(activeTab: TabId, dialogs: DialogState) {
const { setEditDialog, setNewDialog, setHelpOpen, setImportDialog } = dialogs;
const getCurrentTarget = useCallback((): TargetType => {
return activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM;
}, [activeTab]);
// ── CRUD ──
const handleNew = useCallback(() => setNewDialog(true), [setNewDialog]);
const handleEdit = useCallback(() => {
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 entry = list[idx];
if (entry) setEditDialog({ open: true, index: idx, value: entry.path, target });
}, [activeTab, setEditDialog]);
const handleBrowse = useCallback(async () => {
const selected = await open({ directory: true, multiple: false });
if (selected && typeof selected === 'string') {
useAppStore.getState().addPath(selected, getCurrentTarget());
}
}, [getCurrentTarget]);
const handleDelete = useCallback(() => {
const idx = useAppStore.getState().selectedIndices;
if (idx.length === 0) return;
useAppStore.getState().deletePaths(idx, getCurrentTarget());
}, [getCurrentTarget]);
const handleMoveUp = useCallback(() => {
const idx = useAppStore.getState().selectedIndices[0];
if (idx === undefined) return;
useAppStore.getState().moveUp(idx, getCurrentTarget());
}, [getCurrentTarget]);
const handleMoveDown = useCallback(() => {
const idx = useAppStore.getState().selectedIndices[0];
if (idx === undefined) return;
useAppStore.getState().moveDown(idx, getCurrentTarget());
}, [getCurrentTarget]);
const handleClean = useCallback(() => {
const removed = useAppStore.getState().cleanPaths(getCurrentTarget(), is_valid_path_format);
if (removed.length > 0) {
useAppStore.getState().setStatusMessage(i18n.t('status.deleted', { count: removed.length }));
}
}, [getCurrentTarget]);
// ── 导入导出 ──
const handleImport = useCallback(async () => {
const selected = await open({
filters: [{ name: '受支持格式', extensions: ['json', 'csv', 'txt'] }],
multiple: false,
});
if (!selected || typeof selected !== 'string') return;
const content = await invoke<string>('read_text_file', { path: selected });
const result = importFromContent(content, selected);
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),
);
} else if (result.user.length > 0) {
useAppStore.getState().replacePaths(
TargetType.USER,
result.user.map((e) => e.path),
);
}
}, [setImportDialog]);
const handleExport = useCallback((format: 'json' | 'csv' = 'json') => {
const state = useAppStore.getState();
const data = { system: state.sysPaths, user: state.userPaths };
const isCsv = format === 'csv';
const content = isCsv ? exportToCsv(data) : exportToJson(data);
const mime = isCsv ? 'text/csv' : 'application/json';
const ext = isCsv ? '.csv' : '.json';
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `patheditor_export${ext}`;
a.click();
URL.revokeObjectURL(url);
}, []);
const handleSave = useCallback(async () => {
const result = await useAppStore.getState().savePaths();
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',
});
if (confirmed) {
await useAppStore.getState().savePaths(true);
}
}
}, []);
// ── 键盘 ──
useKeyboard({
onNew: handleNew,
onSave: handleSave,
onDelete: handleDelete,
onUndo: () => useAppStore.getState().undo(),
onRedo: () => useAppStore.getState().redo(),
onHelp: () => setHelpOpen(true),
});
// ── 双击编辑 ──
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail && typeof detail.index === 'number') {
const target = getCurrentTarget();
setEditDialog({ open: true, index: detail.index, value: detail.path, target });
}
};
window.addEventListener('path-dblclick', handler);
return () => window.removeEventListener('path-dblclick', handler);
}, [getCurrentTarget, setEditDialog]);
// ── 弹窗确认 ──
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 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,
};
}