feat(M1): FR-F3/F7/F8/F11/F12 文件树操作补齐——文件夹悬停加新建文档/新建文件夹/导入文件夹按钮(F3); 行内重命名 onBlur 改为提交(F7); 删除改 shell.trashItem 移入回收站(F8); fileImportFolder IPC+对话框导入文件夹(F11); 导入结果 showAlert 弹窗列失败项(F12); dialog-store 加 alert 模式; filetree-ops E2E 3 项

This commit is contained in:
hunter-dev
2026-08-26 15:15:35 +08:00
parent 3390e91406
commit 8f6fd7082b
6 changed files with 136 additions and 13 deletions
+19 -3
View File
@@ -26,10 +26,19 @@ export function registerIpc(vault: VaultService) {
fs2().rename(p.from, p.toName));
ipcMain.handle(IPC.fileMove, async (_e, p: { src: string; destDir: string }) =>
fs2().move(p.src, p.destDir));
ipcMain.handle(IPC.fileDelete, async (_e, p: { path: string; recursive: boolean }) =>
fs2().delete(p.path, p.recursive));
// FR-F8:删除优先移入回收站(shell.trashItem),失败回退永久删除
ipcMain.handle(IPC.fileDelete, async (_e, p: { path: string; recursive: boolean }) => {
const abs = nodePath.join(fs2().getRoot(), p.path);
try {
await shell.trashItem(abs);
} catch (e) {
// 回退:回收站不可用时永久删除
console.error('trashItem failed, fallback to permanent delete:', e);
await fs2().delete(p.path, p.recursive);
}
});
// FR-F11:导入——sources 为空时弹系统对话框(支持文件夹导入)
ipcMain.handle(IPC.fileImport, async (_e, p: { sources: string[]; destDir: string }) => {
// 若 sources 为空,弹出系统选择对话框
let sources = p.sources;
if (sources.length === 0) {
const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'], filters: [{ name: 'Markdown', extensions: ['md'] }] });
@@ -39,6 +48,13 @@ export function registerIpc(vault: VaultService) {
return fs2().importFiles(sources, p.destDir);
});
// FR-F11:导入文件夹——选择外部文件夹递归复制进库
ipcMain.handle(IPC.fileImportFolder, async (_e, p: { destDir: string }) => {
const r = await dialog.showOpenDialog({ properties: ['openDirectory'] });
if (r.canceled || r.filePaths.length === 0) return { ok: [], failed: [] };
return fs2().importFiles(r.filePaths, p.destDir);
});
// FR-E6:图片入库
ipcMain.handle(IPC.assetPickImage, async () => vault.pickImage());
ipcMain.handle(IPC.assetWrite, async (_e, p: { name: string; dataBase64: string }) =>
@@ -11,22 +11,23 @@ export function InputDialog() {
const submit = () => {
if (mode === 'confirm') { close('yes'); return; }
if (mode === 'alert') { close('ok'); return; }
close(value.trim() || null);
};
return (
<div onClick={e => { if (e.target === e.currentTarget) close(null); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 8, padding: 20, minWidth: 360, boxShadow: '0 8px 32px rgba(0,0,0,0.4)' }}>
<div onKeyDown={e => { if (e.key === 'Escape') close(null); }} style={{ background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 8, padding: 20, minWidth: 360, boxShadow: '0 8px 32px rgba(0,0,0,0.4)' }}>
<div style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-primary)' }}>{title}</div>
{mode === 'confirm' ? (
<div style={{ color: '#e88', fontSize: 13, marginBottom: 16 }}>{message}</div>
{mode === 'confirm' || mode === 'alert' ? (
<div style={{ color: mode === 'confirm' ? '#e88' : 'var(--text-secondary)', fontSize: 13, marginBottom: 16, whiteSpace: 'pre-wrap' }}>{message}</div>
) : (
<input ref={inputRef} value={value} onChange={e => setValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') close(null); }}
placeholder={placeholder} style={{ width: '100%', background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', color: 'var(--text-primary)', borderRadius: 4, padding: '8px 12px', fontSize: 14, outline: 'none' }} />
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<button onClick={() => close(null)} style={btn}>取消</button>
{mode !== 'alert' && <button onClick={() => close(null)} style={btn}>取消</button>}
<button onClick={submit} style={{ ...btn, background: mode === 'confirm' ? '#5a2a2a' : 'var(--accent)', color: mode === 'confirm' ? '#f88' : 'var(--accent-text)' }}>{mode === 'confirm' ? '确认删除' : '确定'}</button>
</div>
</div>
@@ -1,6 +1,6 @@
import { create } from 'zustand';
type Mode = 'input' | 'confirm';
type Mode = 'input' | 'confirm' | 'alert';
interface DialogState {
open: boolean;
@@ -11,6 +11,7 @@ interface DialogState {
resolve: ((value: string | null) => void) | null;
showInput: (title: string, placeholder?: string) => Promise<string | null>;
showConfirm: (title: string, message: string) => Promise<boolean>;
showAlert: (title: string, message: string) => Promise<void>;
close: (value: string | null) => void;
}
@@ -18,5 +19,6 @@ export const useDialog = create<DialogState>((set, get) => ({
open: false, mode: 'input', title: '', message: '', placeholder: '', resolve: null,
showInput: (title, placeholder = '') => new Promise<string | null>((resolve) => { set({ open: true, mode: 'input', title, placeholder, resolve }); }),
showConfirm: (title, message) => new Promise<boolean>((resolve) => { set({ open: true, mode: 'confirm', title, message, resolve: ((v: string | null) => resolve(v === 'yes')) }); }),
showAlert: (title, message) => new Promise<void>((resolve) => { set({ open: true, mode: 'alert', title, message, resolve: (() => resolve()) }); }),
close: (value) => { const r = get().resolve; set({ open: false, resolve: null }); r?.(value); },
}));
@@ -8,7 +8,7 @@ import { useTabs } from '../tabs/tab-store';
export function FileTree() {
const { tree, loadTree, treeLoading, openDoc, currentPath, syncCurrentPath, tagFilter } = useStore();
const { showInput, showConfirm } = useDialog();
const { showInput, showConfirm, showAlert } = useDialog();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [renaming, setRenaming] = useState<string | null>(null);
const [renameVal, setRenameVal] = useState('');
@@ -71,6 +71,21 @@ export function FileTree() {
try { const r = await invoke(IPC.fileMove, { src, destDir }); useTabs.getState().renamePath(src, r.path); syncCurrentPath(src, r.path); await loadTree(); } catch (e) { console.error(e); }
};
const doImportFolder = async (destDir: string) => {
try {
const result = await invoke(IPC.fileImportFolder, { destDir });
const okCount = result.ok.length;
const failed = result.failed;
const failLines = failed.length > 0 ? failed.map(f => f.path + ': ' + f.reason).join('\n') : '';
const nl = '\n';
const summary = '成功导入 ' + okCount + ' 项' + (failed.length > 0 ? nl + nl + '失败项(' + failed.length + '):' + nl + failLines : '');
await showAlert('导入结果', summary);
await loadTree();
} catch (e) {
await showAlert('导入失败', e instanceof Error ? e.message : String(e));
}
};
const onDrop = (e: React.DragEvent, destDir: string) => {
e.preventDefault(); setDragOver(null);
const src = e.dataTransfer.getData('text/path'); if (src) doMove(src, destDir);
@@ -100,15 +115,19 @@ export function FileTree() {
{isRenaming ? (
<input autoFocus value={renameVal} onChange={e => setRenameVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { setRenaming(null); invoke(IPC.fileRename, { from: n.path, toName: renameVal }).then(r => { useTabs.getState().renamePath(n.path, r.path); syncCurrentPath(n.path, r.path); loadTree(); }); } if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => setRenaming(null)} style={{ flex: 1, background: 'var(--bg-secondary)', border: '1px solid #4a6a8a', color: 'var(--text-primary)', borderRadius: 3, padding: '1px 4px', fontSize: 13 }} />
onBlur={() => { if (renameVal.trim() && renameVal !== n.name) { setRenaming(null); invoke(IPC.fileRename, { from: n.path, toName: renameVal }).then(r2 => { useTabs.getState().renamePath(n.path, r2.path); syncCurrentPath(n.path, r2.path); loadTree(); }).catch(() => {}); } else setRenaming(null); }} style={{ flex: 1, background: 'var(--bg-secondary)', border: '1px solid #4a6a8a', color: 'var(--text-primary)', borderRadius: 3, padding: '1px 4px', fontSize: 13 }} />
) : (
<span style={{ marginLeft: 4, flex: 1 }}>{n.name}</span>
)}
{isHover && !isRenaming && (
<span style={{ display: 'flex', gap: 2 }} onClick={e => e.stopPropagation()}>
{n.type === 'folder' && <button title="新建" onClick={() => doCreate(n.path, false)} style={miniBtn}>+</button>}
<button title="重命名" onClick={() => { setRenaming(n.path); setRenameVal(n.name); }} style={miniBtn}>✎</button>
<button title="删除" onClick={() => doDelete(n)} style={miniBtn}>🗑</button>
{n.type === 'folder' && <>
<button title="新建文档" onClick={() => doCreate(n.path, false)} style={miniBtn}>+</button>
<button title="新建文件夹" onClick={() => doCreate(n.path, true)} style={miniBtn}>F+</button>
<button title="导入文件夹" onClick={() => doImportFolder(n.path)} style={miniBtn}>Imp</button>
</>}
<button title="重命名" onClick={() => { setRenaming(n.path); setRenameVal(n.name); }} style={miniBtn}>Edit</button>
<button title="删除" onClick={() => doDelete(n)} style={miniBtn}>Del</button>
</span>
)}
</div>
@@ -130,6 +149,7 @@ export function FileTree() {
{menuNode.type === 'folder' && <>
<MenuItem onClick={() => { doCreate(menuNode.path, false); setMenu(null); }}>新建文档</MenuItem>
<MenuItem onClick={() => { doCreate(menuNode.path, true); setMenu(null); }}>新建文件夹</MenuItem>
<MenuItem onClick={() => { doImportFolder(menuNode.path); setMenu(null); }}>导入文件夹</MenuItem>
</>}
<MenuItem onClick={() => { setRenaming(menuNode.path); setRenameVal(menuNode.name); setMenu(null); }}>重命名</MenuItem>
<MenuItem danger onClick={() => { doDelete(menuNode); setMenu(null); }}>删除</MenuItem>
@@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
async function launch(userData: string) {
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
return { app, page, invoke };
}
async function newDoc(a: { page: any; invoke: any }, title: string) {
await a.page.getByRole('button', { name: /新建文档/ }).click();
await a.page.getByPlaceholder(/输入文档标题/).fill(title);
await a.page.getByPlaceholder(/输入文档标题/).press('Enter');
await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 });
}
// FR-F7:行内重命名失焦提交(onBlur 而非取消)
test('FR-F7:行内重命名失焦提交', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-f7-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await newDoc(a, '原名文档');
await a.page.waitForTimeout(500);
const node = a.page.getByTestId('filetree').getByText('原名文档', { exact: true });
await node.hover();
await a.page.getByTitle('重命名').click();
const input = a.page.getByTestId('filetree').locator('input');
await input.fill('重命名后');
await a.page.locator('body').click();
await a.page.waitForTimeout(500);
await expect(a.page.getByTestId('filetree').getByText('重命名后', { exact: true })).toBeVisible({ timeout: 5000 });
await a.app.close();
});
// FR-F8:删除后从树中移除
test('FR-F8:删除文件后从树中移除', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-f8-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await newDoc(a, '待删文档');
await a.page.waitForTimeout(500);
const node = a.page.getByTestId('filetree').getByText('待删文档', { exact: true });
await node.hover();
await a.page.getByTestId('filetree').getByTitle('删除').click();
await a.page.getByRole('button', { name: '确认删除' }).click();
await a.page.waitForTimeout(500);
await expect(a.page.getByTestId('filetree').getByText('待删文档', { exact: true })).toHaveCount(0, { timeout: 5000 });
await a.app.close();
});
// FR-F3:文件夹悬停显示新建文件夹按钮
test('FR-F3:文件夹悬停显示新建文件夹按钮', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-f3-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await a.invoke('file:create', { basePath: '', name: '测试文件夹', isFolder: true });
await a.page.getByTitle('刷新').click();
await a.page.waitForTimeout(500);
const folder = a.page.getByTestId('filetree').getByText('测试文件夹', { exact: true });
await folder.hover();
await expect(a.page.getByTitle('新建文件夹')).toBeVisible({ timeout: 3000 });
await a.app.close();
});