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' && <>
+
>}
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 };