diff --git a/apps/desktop/src/main/ipc/handlers.ts b/apps/desktop/src/main/ipc/handlers.ts index ee98d13..8f8037e 100644 --- a/apps/desktop/src/main/ipc/handlers.ts +++ b/apps/desktop/src/main/ipc/handlers.ts @@ -15,6 +15,7 @@ export function registerIpc(vault: VaultService) { ipcMain.handle(IPC.vaultSwitch, async (_e, p: { root: string }) => vault.switch(p.root)); ipcMain.handle(IPC.vaultSetDefault, async (_e, p: { root: string }) => { await vault.setDefaultRoot(p.root); }); ipcMain.handle(IPC.vaultPick, async () => vault.pickAndSwitch()); + ipcMain.handle(IPC.vaultBrowse, async () => vault.pickDirectory()); ipcMain.handle(IPC.treeList, async () => fs2().listTree()); ipcMain.handle(IPC.treeRefresh, async () => fs2().listTree()); diff --git a/apps/desktop/src/main/services/vault-service.ts b/apps/desktop/src/main/services/vault-service.ts index 8eb757c..dc1aa0a 100644 --- a/apps/desktop/src/main/services/vault-service.ts +++ b/apps/desktop/src/main/services/vault-service.ts @@ -39,6 +39,13 @@ export class VaultService { return this.switch(result.filePaths[0]); } + // 仅浏览选择目录路径,不切换(FR-V5 设置页浏览按钮) + async pickDirectory(): Promise { + const result = await dialog.showOpenDialog({ properties: ['openDirectory'] }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; + } + async switch(root: string): Promise { // 校验是存在的目录 const stat = await fs.stat(root).catch(() => null); diff --git a/apps/desktop/src/renderer/features/file-tree/FileTree.tsx b/apps/desktop/src/renderer/features/file-tree/FileTree.tsx index 4c79b0e..4e806ab 100644 --- a/apps/desktop/src/renderer/features/file-tree/FileTree.tsx +++ b/apps/desktop/src/renderer/features/file-tree/FileTree.tsx @@ -4,9 +4,10 @@ import { useState, useMemo } from 'react'; import { invoke } from '../../ipc-client'; import { IPC } from '@hunter/shared'; import { useDialog } from '../dialog/dialog-store'; +import { useTabs } from '../tabs/tab-store'; export function FileTree() { - const { tree, loadTree, treeLoading, openDoc, currentPath } = useStore(); + const { tree, loadTree, treeLoading, openDoc, currentPath, syncCurrentPath } = useStore(); const { showInput, showConfirm } = useDialog(); const [collapsed, setCollapsed] = useState>(new Set()); const [renaming, setRenaming] = useState(null); @@ -26,7 +27,9 @@ export function FileTree() { }; const doRename = async (node: TreeNode) => { const name = await showInput('重命名', '新名称'); if (!name) return; - await invoke(IPC.fileRename, { from: node.path, toName: name }); + const r = await invoke(IPC.fileRename, { from: node.path, toName: name }); + useTabs.getState().renamePath(node.path, r.path); // FR-T7: 同步标签页路径 + syncCurrentPath(node.path, r.path); await loadTree(); }; const doDelete = async (node: TreeNode) => { @@ -38,7 +41,7 @@ export function FileTree() { }; const doMove = async (src: string, destDir: string) => { if (src === destDir) return; - try { await invoke(IPC.fileMove, { src, destDir }); await loadTree(); } catch (e) { console.error(e); } + 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 onDrop = (e: React.DragEvent, destDir: string) => { @@ -69,7 +72,7 @@ export function FileTree() { {n.type === 'folder' ? (isCollapsed ? '▶' : '▼') : '📄'} {isRenaming ? ( setRenameVal(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { setRenaming(null); invoke(IPC.fileRename, { from: n.path, toName: renameVal }).then(() => loadTree()); } if (e.key === 'Escape') setRenaming(null); }} + 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: '#15151f', border: '1px solid #4a6a8a', color: '#ddd', borderRadius: 3, padding: '1px 4px', fontSize: 13 }} /> ) : ( {n.name} @@ -90,7 +93,7 @@ export function FileTree() { const menuNode = menu && tree.find(n => n.path === menu.path); const roots = byParent.get('') ?? []; return ( -
setMenu(null)}> +
setMenu(null)}> {treeLoading &&
加载中…
} {roots.map(n => renderNode(n, 0))} {tree.length === 0 && !treeLoading &&
空库,点击上方「+ 新建文档」开始
} diff --git a/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx b/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx index c312d9d..387b40b 100644 --- a/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx +++ b/apps/desktop/src/renderer/features/settings/SettingsPanel.tsx @@ -1,6 +1,10 @@ import { useState, useEffect } from 'react'; import { useSettings } from './settings-store'; import { useKeymap } from '../keymap/keymap-service'; +import { useStore } from '../../store'; +import { useTabs } from '../tabs/tab-store'; +import { invoke } from '../../ipc-client'; +import { IPC } from '@hunter/shared'; const pageBtn: React.CSSProperties = { padding: '8px 14px', border: 'none', background: 'transparent', color: '#888', cursor: 'pointer', borderBottom: '2px solid transparent', fontSize: 13 }; const row: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: '1px solid #2a2a3a' }; @@ -54,7 +58,7 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
自动换行
} {page === 'other' &&
删除前确认
} - {page === 'path' &&
文件路径设置将在 M9 完善(默认库路径配置)
} + {page === 'path' && } {page === 'keymap' && <>
点击「录入」后按下新组合键(需含 Ctrl/Meta/Alt);Esc 取消
{bindings.map(b => { @@ -81,3 +85,46 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) { const sel: React.CSSProperties = { background: '#15151f', border: '1px solid #3a3a4a', color: '#ddd', borderRadius: 4, padding: '4px 8px', fontSize: 13 }; function dot(on: boolean): React.CSSProperties { return { position: 'absolute', top: 2, left: on ? 18 : 2, width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'left 0.2s' }; } + +// FR-V5: 默认库路径设置页——查看当前库、浏览选择、立即切换 +function PathPage() { + const { vault, loadVault, loadTree } = useStore(); + const [val, setVal] = useState(vault?.currentRoot ?? ''); + const [msg, setMsg] = useState(''); + const browse = async () => { const p = await invoke(IPC.vaultBrowse, undefined as never); if (p) setVal(p); }; + const apply = async () => { + if (!val.trim()) { setMsg('请填写或浏览选择库路径'); return; } + try { + setMsg('切换中…'); + await useStore.getState().saveNow(); // 先冲刷未保存到旧库 + await invoke(IPC.vaultSwitch, { root: val.trim() }); + useTabs.getState().closeAll(); // 清空标签页(FR-V6) + useStore.getState().resetEditor(); // 重置编辑区 + await loadVault(); + await loadTree(); + setMsg('已切换到:' + val.trim()); + } catch (e: any) { setMsg('切换失败:' + (e?.message ?? String(e))); } + }; + return ( + <> +
+ 当前库路径 + {vault?.currentRoot ?? '—'} +
+
+ 新库路径 + + setVal(e.target.value)} placeholder="填写或浏览选择目录…" style={{ ...sel, flex: 1, maxWidth: 220 }} /> + + +
+
+ + + {msg && {msg}} + + +
+ + ); +} diff --git a/apps/desktop/src/renderer/features/tabs/TabBar.tsx b/apps/desktop/src/renderer/features/tabs/TabBar.tsx index 5e4f939..9eff425 100644 --- a/apps/desktop/src/renderer/features/tabs/TabBar.tsx +++ b/apps/desktop/src/renderer/features/tabs/TabBar.tsx @@ -14,7 +14,7 @@ export function TabBar() { if (tabs.length === 0) return null; return ( -
+
{tabs.map(t => (
setActive(t.id)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', cursor: 'pointer', diff --git a/apps/desktop/src/renderer/features/tabs/tab-store.ts b/apps/desktop/src/renderer/features/tabs/tab-store.ts index b1ad173..9b5b533 100644 --- a/apps/desktop/src/renderer/features/tabs/tab-store.ts +++ b/apps/desktop/src/renderer/features/tabs/tab-store.ts @@ -9,6 +9,8 @@ interface TabState { closeTab: (id: string) => void; setActive: (id: string) => void; togglePin: (id: string) => void; + closeAll: () => void; // FR-V6: 切库时清空全部标签页 + renamePath: (oldPath: string, newPath: string) => void; // FR-T7: 重命名/移动后同步标签页路径 activePath: () => string | null; persist: () => void; restore: () => void; @@ -54,6 +56,20 @@ export const useTabs = create((set, get) => ({ get().persist(); }, + closeAll: () => { set({ tabs: [], activeId: null }); get().persist(); }, + + renamePath: (oldPath, newPath) => { + set(s => ({ + tabs: s.tabs.map(t => { + if (t.path === oldPath) return { ...t, path: newPath }; + // 文件夹重命名/移动:子文档标签页路径前缀替换 + if (oldPath && t.path.startsWith(oldPath + '/')) return { ...t, path: newPath + t.path.slice(oldPath.length) }; + return t; + }), + })); + get().persist(); + }, + activePath: () => { const { tabs, activeId } = get(); return tabs.find(t => t.id === activeId)?.path ?? null; }, persist: () => { diff --git a/apps/desktop/src/renderer/features/toolbar/Toolbar.tsx b/apps/desktop/src/renderer/features/toolbar/Toolbar.tsx index eedc0dd..e16c1b6 100644 --- a/apps/desktop/src/renderer/features/toolbar/Toolbar.tsx +++ b/apps/desktop/src/renderer/features/toolbar/Toolbar.tsx @@ -2,6 +2,7 @@ import { useStore } from '../../store'; import { invoke } from '../../ipc-client'; import { IPC } from '@hunter/shared'; import { useDialog } from '../dialog/dialog-store'; +import { useTabs } from '../tabs/tab-store'; // 极简浮层通知(FR-B1,后续抽组件) function notify(msg: string) { @@ -30,7 +31,12 @@ export function Toolbar() { await loadTree(); }; const switchVault = async () => { - await invoke(IPC.vaultPick, undefined as never); + // FR-V6: 先冲刷未保存内容到旧库,再切换;取消则保留现状 + await useStore.getState().saveNow(); + const picked = await invoke(IPC.vaultPick, undefined as never); + if (!picked) return; + useTabs.getState().closeAll(); + useStore.getState().resetEditor(); await loadVault(); await loadTree(); }; diff --git a/apps/desktop/src/renderer/store.ts b/apps/desktop/src/renderer/store.ts index 85b909f..424d432 100644 --- a/apps/desktop/src/renderer/store.ts +++ b/apps/desktop/src/renderer/store.ts @@ -27,6 +27,8 @@ interface EditorState { addLink: (link: string) => void; removeLink: (link: string) => void; saveNow: () => Promise; + resetEditor: () => void; // FR-V6: 切库时重置编辑区 + syncCurrentPath: (oldPath: string, newPath: string) => void; // FR-T7: 重命名/移动后同步当前文档路径 } // 简单的每文档保存队列(M1 单文档编辑) @@ -111,4 +113,13 @@ export const useStore = create((set, get) => ({ console.error('save failed', e); } }, + + resetEditor: () => { set({ currentPath: null, title: '', body: '', meta: {}, saveState: 'saved' }); }, + + syncCurrentPath: (oldPath, newPath) => { + const { currentPath } = get(); + if (!currentPath) return; + if (currentPath === oldPath) { set({ currentPath: newPath }); return; } + if (oldPath && currentPath.startsWith(oldPath + '/')) set({ currentPath: newPath + currentPath.slice(oldPath.length) }); + }, })); \ No newline at end of file diff --git a/apps/desktop/tests/e2e/safety.spec.ts b/apps/desktop/tests/e2e/safety.spec.ts new file mode 100644 index 0000000..05d7be7 --- /dev/null +++ b/apps/desktop/tests/e2e/safety.spec.ts @@ -0,0 +1,89 @@ +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 }; +} + +test('FR-T7:重命名文档后标签页路径同步', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-t7-')); + 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.page.getByRole('button', { name: /新建文档/ }).click(); + await a.page.getByPlaceholder(/输入文档标题/).fill('原稿'); + await a.page.getByPlaceholder(/输入文档标题/).press('Enter'); + await expect(a.page.getByTestId('tabbar').getByText('原稿', { exact: true })).toBeVisible({ timeout: 10000 }); + + // 文件树悬停「原稿」→ 点重命名 → 输入「改稿」→ 回车 + await a.page.getByTestId('filetree').getByText('原稿', { exact: true }).hover(); + await a.page.getByTestId('filetree').getByTitle('重命名').click(); + // 重命名 input 是 filetree 内唯一的 input(搜索框在 filetree 外) + const renameInput = a.page.getByTestId('filetree').locator('input'); + await renameInput.fill('改稿'); + await renameInput.press('Enter'); + + // 标签页文本应从「原稿」变为「改稿」(路径已同步) + await expect(a.page.getByTestId('tabbar').getByText('改稿', { exact: true })).toBeVisible({ timeout: 10000 }); + // 「原稿」标签页文本应消失 + await expect(a.page.getByTestId('tabbar').getByText('原稿', { exact: true })).toHaveCount(0); + await a.app.close(); +}); + +test('FR-V5+V6:设置页切换默认库路径 + 切库清空标签页', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-v56-')); + const userData = nodePath.join(tmp, 'userdata'); + const vault1 = nodePath.join(tmp, 'vault1'); + const vault2 = nodePath.join(tmp, 'vault2'); + await fs.mkdir(userData, { recursive: true }); + await fs.mkdir(vault1, { recursive: true }); + await fs.mkdir(vault2, { recursive: true }); + + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault1 }); + + // 在 vault1 新建并打开文档 → 标签页出现 + await a.page.getByRole('button', { name: /新建文档/ }).click(); + await a.page.getByPlaceholder(/输入文档标题/).fill('库1文档'); + await a.page.getByPlaceholder(/输入文档标题/).press('Enter'); + await expect(a.page.getByTestId('tabbar').getByText('库1文档', { exact: true })).toBeVisible({ timeout: 10000 }); + + // 设置 → 文件路径页 → 填入 vault2 路径 → 立即切换 + await a.page.getByTitle('设置').click(); + await a.page.getByRole('button', { name: '文件路径' }).click(); + await a.page.getByPlaceholder(/填写或浏览/).fill(vault2); + await a.page.getByRole('button', { name: '立即切换' }).click(); + + // 等待切换完成 + await expect(a.page.getByText(/已切换/)).toBeVisible({ timeout: 10000 }); + + // FR-V6:标签页应被清空(TabBar 返回 null → DOM 中无 tabbar) + await expect(a.page.getByTestId('tabbar')).toHaveCount(0); + + // FR-V5:库元数据应更新为 vault2 + const meta: { currentRoot: string } = await a.invoke('vault:list', null); + expect(meta.currentRoot).toBe(vault2); + + // 关闭设置面板(点击遮罩层),在 vault2 新建文档应正常工作 + await a.page.mouse.click(5, 5); + await a.page.getByRole('button', { name: /新建文档/ }).click(); + await a.page.getByPlaceholder(/输入文档标题/).fill('库2文档'); + await a.page.getByPlaceholder(/输入文档标题/).press('Enter'); + await expect(a.page.getByTestId('tabbar').getByText('库2文档', { exact: true })).toBeVisible({ timeout: 10000 }); + await a.app.close(); +}); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index 6e947c7..c977210 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -9,6 +9,7 @@ export const IPC = { vaultSwitch: 'vault:switch', vaultSetDefault: 'vault:set-default', vaultPick: 'vault:pick', + vaultBrowse: 'vault:browse', treeList: 'tree:list', treeRefresh: 'tree:refresh', fileCreate: 'file:create', @@ -40,6 +41,7 @@ export interface IpcMap { [IPC.vaultSwitch]: { args: { root: string }; result: VaultMeta }; [IPC.vaultSetDefault]: { args: { root: string }; result: void }; [IPC.vaultPick]: { args: void; result: VaultMeta | null }; + [IPC.vaultBrowse]: { args: void; result: string | null }; [IPC.treeList]: { args: void; result: TreeNode[] }; [IPC.treeRefresh]: { args: void; result: TreeNode[] }; [IPC.fileCreate]: { args: { basePath: string; name: string; isFolder: boolean }; result: { path: string } };