diff --git a/apps/desktop/src/main/ipc/handlers.ts b/apps/desktop/src/main/ipc/handlers.ts index d9a3b74..04a31af 100644 --- a/apps/desktop/src/main/ipc/handlers.ts +++ b/apps/desktop/src/main/ipc/handlers.ts @@ -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 }) => diff --git a/apps/desktop/src/renderer/features/dialog/InputDialog.tsx b/apps/desktop/src/renderer/features/dialog/InputDialog.tsx index 180b5fa..915ab17 100644 --- a/apps/desktop/src/renderer/features/dialog/InputDialog.tsx +++ b/apps/desktop/src/renderer/features/dialog/InputDialog.tsx @@ -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 (
{ 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 }}> -
+
{ 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)' }}>
{title}
- {mode === 'confirm' ? ( -
{message}
+ {mode === 'confirm' || mode === 'alert' ? ( +
{message}
) : ( 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' }} /> )}
- + {mode !== 'alert' && }
diff --git a/apps/desktop/src/renderer/features/dialog/dialog-store.ts b/apps/desktop/src/renderer/features/dialog/dialog-store.ts index dfd3e55..14834d1 100644 --- a/apps/desktop/src/renderer/features/dialog/dialog-store.ts +++ b/apps/desktop/src/renderer/features/dialog/dialog-store.ts @@ -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; showConfirm: (title: string, message: string) => Promise; + showAlert: (title: string, message: string) => Promise; close: (value: string | null) => void; } @@ -18,5 +19,6 @@ export const useDialog = create((set, get) => ({ open: false, mode: 'input', title: '', message: '', placeholder: '', resolve: null, showInput: (title, placeholder = '') => new Promise((resolve) => { set({ open: true, mode: 'input', title, placeholder, resolve }); }), showConfirm: (title, message) => new Promise((resolve) => { set({ open: true, mode: 'confirm', title, message, resolve: ((v: string | null) => resolve(v === 'yes')) }); }), + showAlert: (title, message) => new Promise((resolve) => { set({ open: true, mode: 'alert', title, message, resolve: (() => resolve()) }); }), close: (value) => { const r = get().resolve; set({ open: false, resolve: null }); r?.(value); }, })); diff --git a/apps/desktop/src/renderer/features/file-tree/FileTree.tsx b/apps/desktop/src/renderer/features/file-tree/FileTree.tsx index 0c9e436..b811a17 100644 --- a/apps/desktop/src/renderer/features/file-tree/FileTree.tsx +++ b/apps/desktop/src/renderer/features/file-tree/FileTree.tsx @@ -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>(new Set()); const [renaming, setRenaming] = useState(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 ? ( 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 }} /> ) : ( {n.name} )} {isHover && !isRenaming && ( e.stopPropagation()}> - {n.type === 'folder' && } - - + {n.type === 'folder' && <> + + + + } + + )}
@@ -130,6 +149,7 @@ export function FileTree() { {menuNode.type === 'folder' && <> { doCreate(menuNode.path, false); setMenu(null); }}>新建文档 { doCreate(menuNode.path, true); setMenu(null); }}>新建文件夹 + { doImportFolder(menuNode.path); setMenu(null); }}>导入文件夹 } { setRenaming(menuNode.path); setRenameVal(menuNode.name); setMenu(null); }}>重命名 { doDelete(menuNode); setMenu(null); }}>删除 diff --git a/apps/desktop/tests/e2e/filetree-ops.spec.ts b/apps/desktop/tests/e2e/filetree-ops.spec.ts new file mode 100644 index 0000000..66b141f --- /dev/null +++ b/apps/desktop/tests/e2e/filetree-ops.spec.ts @@ -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(); +}); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index 6f0a8fe..ee53061 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -17,6 +17,7 @@ export const IPC = { fileMove: 'file:move', fileDelete: 'file:delete', fileImport: 'file:import', + fileImportFolder: 'file:import-folder', assetPickImage: 'asset:pick-image', assetWrite: 'asset:write', docRead: 'doc:read', @@ -53,6 +54,7 @@ export interface IpcMap { [IPC.fileMove]: { args: { src: string; destDir: string }; result: { path: string } }; [IPC.fileDelete]: { args: { path: string; recursive: boolean }; result: void }; [IPC.fileImport]: { args: { sources: string[]; destDir: string }; result: { ok: string[]; failed: { path: string; reason: string }[] } }; + [IPC.fileImportFolder]: { args: { destDir: string }; result: { ok: string[]; failed: { path: string; reason: string }[] } }; [IPC.assetPickImage]: { args: void; result: string | null }; [IPC.assetWrite]: { args: { name: string; dataBase64: string }; result: string }; [IPC.docRead]: { args: { path: string }; result: ReadDocResult };