fix: S1 安全冲刺——切库清空标签页+冲刷(FR-V6)、重命名/移动同步标签页路径(FR-T7)、默认库路径设置页(FR-V5);vaultBrowse IPC+pickDirectory;tab-store closeAll/renamePath;store resetEditor/syncCurrentPath;safety E2E 2 项

This commit is contained in:
hunter-dev
2026-08-26 11:05:22 +08:00
parent 81b410e281
commit c4af03adce
10 changed files with 190 additions and 8 deletions
+1
View File
@@ -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.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.vaultSetDefault, async (_e, p: { root: string }) => { await vault.setDefaultRoot(p.root); });
ipcMain.handle(IPC.vaultPick, async () => vault.pickAndSwitch()); ipcMain.handle(IPC.vaultPick, async () => vault.pickAndSwitch());
ipcMain.handle(IPC.vaultBrowse, async () => vault.pickDirectory());
ipcMain.handle(IPC.treeList, async () => fs2().listTree()); ipcMain.handle(IPC.treeList, async () => fs2().listTree());
ipcMain.handle(IPC.treeRefresh, async () => fs2().listTree()); ipcMain.handle(IPC.treeRefresh, async () => fs2().listTree());
@@ -39,6 +39,13 @@ export class VaultService {
return this.switch(result.filePaths[0]); return this.switch(result.filePaths[0]);
} }
// 仅浏览选择目录路径,不切换(FR-V5 设置页浏览按钮)
async pickDirectory(): Promise<string | null> {
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<VaultMeta> { async switch(root: string): Promise<VaultMeta> {
// 校验是存在的目录 // 校验是存在的目录
const stat = await fs.stat(root).catch(() => null); const stat = await fs.stat(root).catch(() => null);
@@ -4,9 +4,10 @@ import { useState, useMemo } from 'react';
import { invoke } from '../../ipc-client'; import { invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared'; import { IPC } from '@hunter/shared';
import { useDialog } from '../dialog/dialog-store'; import { useDialog } from '../dialog/dialog-store';
import { useTabs } from '../tabs/tab-store';
export function FileTree() { export function FileTree() {
const { tree, loadTree, treeLoading, openDoc, currentPath } = useStore(); const { tree, loadTree, treeLoading, openDoc, currentPath, syncCurrentPath } = useStore();
const { showInput, showConfirm } = useDialog(); const { showInput, showConfirm } = useDialog();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [renaming, setRenaming] = useState<string | null>(null); const [renaming, setRenaming] = useState<string | null>(null);
@@ -26,7 +27,9 @@ export function FileTree() {
}; };
const doRename = async (node: TreeNode) => { const doRename = async (node: TreeNode) => {
const name = await showInput('重命名', '新名称'); if (!name) return; 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(); await loadTree();
}; };
const doDelete = async (node: TreeNode) => { const doDelete = async (node: TreeNode) => {
@@ -38,7 +41,7 @@ export function FileTree() {
}; };
const doMove = async (src: string, destDir: string) => { const doMove = async (src: string, destDir: string) => {
if (src === destDir) return; 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) => { const onDrop = (e: React.DragEvent, destDir: string) => {
@@ -69,7 +72,7 @@ export function FileTree() {
<span style={{ width: 14 }}>{n.type === 'folder' ? (isCollapsed ? '▶' : '▼') : '📄'}</span> <span style={{ width: 14 }}>{n.type === 'folder' ? (isCollapsed ? '▶' : '▼') : '📄'}</span>
{isRenaming ? ( {isRenaming ? (
<input autoFocus value={renameVal} onChange={e => setRenameVal(e.target.value)} <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(() => 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 }} /> onBlur={() => setRenaming(null)} style={{ flex: 1, background: '#15151f', border: '1px solid #4a6a8a', color: '#ddd', borderRadius: 3, padding: '1px 4px', fontSize: 13 }} />
) : ( ) : (
<span style={{ marginLeft: 4, flex: 1 }}>{n.name}</span> <span style={{ marginLeft: 4, flex: 1 }}>{n.name}</span>
@@ -90,7 +93,7 @@ export function FileTree() {
const menuNode = menu && tree.find(n => n.path === menu.path); const menuNode = menu && tree.find(n => n.path === menu.path);
const roots = byParent.get('') ?? []; const roots = byParent.get('') ?? [];
return ( return (
<div style={{ overflow: 'auto', padding: 8, height: '100%' }} onClick={() => setMenu(null)}> <div data-testid="filetree" style={{ overflow: 'auto', padding: 8, height: '100%' }} onClick={() => setMenu(null)}>
{treeLoading && <div style={{ opacity: 0.5 }}>加载中…</div>} {treeLoading && <div style={{ opacity: 0.5 }}>加载中…</div>}
{roots.map(n => renderNode(n, 0))} {roots.map(n => renderNode(n, 0))}
{tree.length === 0 && !treeLoading && <div style={{ opacity: 0.5, fontSize: 12 }}>空库,点击上方「+ 新建文档」开始</div>} {tree.length === 0 && !treeLoading && <div style={{ opacity: 0.5, fontSize: 12 }}>空库,点击上方「+ 新建文档」开始</div>}
@@ -1,6 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useSettings } from './settings-store'; import { useSettings } from './settings-store';
import { useKeymap } from '../keymap/keymap-service'; 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 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' }; 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 }) {
<div style={row}><span style={lbl}>自动换行</span><button onClick={() => s.set('wordWrap', !s.wordWrap)} style={toggle(s.wordWrap)}><span style={dot(s.wordWrap)} /></button></div> <div style={row}><span style={lbl}>自动换行</span><button onClick={() => s.set('wordWrap', !s.wordWrap)} style={toggle(s.wordWrap)}><span style={dot(s.wordWrap)} /></button></div>
</>} </>}
{page === 'other' && <div style={row}><span style={lbl}>删除前确认</span><button onClick={() => s.set('confirmDelete', !s.confirmDelete)} style={toggle(s.confirmDelete)}><span style={dot(s.confirmDelete)} /></button></div>} {page === 'other' && <div style={row}><span style={lbl}>删除前确认</span><button onClick={() => s.set('confirmDelete', !s.confirmDelete)} style={toggle(s.confirmDelete)}><span style={dot(s.confirmDelete)} /></button></div>}
{page === 'path' && <div style={{ ...lbl, opacity: 0.6, padding: '12px 0' }}>文件路径设置将在 M9 完善(默认库路径配置)</div>} {page === 'path' && <PathPage />}
{page === 'keymap' && <> {page === 'keymap' && <>
<div style={{ ...lbl, opacity: 0.6, marginBottom: 8 }}>点击「录入」后按下新组合键(需含 Ctrl/Meta/Alt);Esc 取消</div> <div style={{ ...lbl, opacity: 0.6, marginBottom: 8 }}>点击「录入」后按下新组合键(需含 Ctrl/Meta/Alt);Esc 取消</div>
{bindings.map(b => { {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 }; 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' }; } 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 (
<>
<div style={{ ...row, alignItems: 'flex-start' }}>
<span style={{ ...lbl, paddingTop: 6 }}>当前库路径</span>
<span style={{ fontSize: 12, color: '#9cf', maxWidth: 320, wordBreak: 'break-all', textAlign: 'right' }}>{vault?.currentRoot ?? '—'}</span>
</div>
<div style={{ ...row, alignItems: 'flex-start' }}>
<span style={{ ...lbl, paddingTop: 6 }}>新库路径</span>
<span style={{ display: 'flex', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
<input value={val} onChange={e => setVal(e.target.value)} placeholder="填写或浏览选择目录…" style={{ ...sel, flex: 1, maxWidth: 220 }} />
<button onClick={browse} style={{ ...sel, cursor: 'pointer' }}>浏览…</button>
</span>
</div>
<div style={{ ...row }}>
<span style={lbl} />
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{msg && <span style={{ fontSize: 12, color: msg.startsWith('切换失败') ? '#f88' : (msg.startsWith('已切换') ? '#8c8' : '#9cf') }}>{msg}</span>}
<button onClick={apply} style={{ ...sel, cursor: 'pointer', background: '#2a4a6a', color: '#9cf' }}>立即切换</button>
</span>
</div>
</>
);
}
@@ -14,7 +14,7 @@ export function TabBar() {
if (tabs.length === 0) return null; if (tabs.length === 0) return null;
return ( return (
<div style={{ display: 'flex', borderBottom: '1px solid #2a2a3a', background: '#161620', overflowX: 'auto' }}> <div data-testid="tabbar" style={{ display: 'flex', borderBottom: '1px solid #2a2a3a', background: '#161620', overflowX: 'auto' }}>
{tabs.map(t => ( {tabs.map(t => (
<div key={t.id} onClick={() => setActive(t.id)} <div key={t.id} onClick={() => setActive(t.id)}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', cursor: 'pointer', style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', cursor: 'pointer',
@@ -9,6 +9,8 @@ interface TabState {
closeTab: (id: string) => void; closeTab: (id: string) => void;
setActive: (id: string) => void; setActive: (id: string) => void;
togglePin: (id: string) => void; togglePin: (id: string) => void;
closeAll: () => void; // FR-V6: 切库时清空全部标签页
renamePath: (oldPath: string, newPath: string) => void; // FR-T7: 重命名/移动后同步标签页路径
activePath: () => string | null; activePath: () => string | null;
persist: () => void; persist: () => void;
restore: () => void; restore: () => void;
@@ -54,6 +56,20 @@ export const useTabs = create<TabState>((set, get) => ({
get().persist(); 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; }, activePath: () => { const { tabs, activeId } = get(); return tabs.find(t => t.id === activeId)?.path ?? null; },
persist: () => { persist: () => {
@@ -2,6 +2,7 @@ import { useStore } from '../../store';
import { invoke } from '../../ipc-client'; import { invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared'; import { IPC } from '@hunter/shared';
import { useDialog } from '../dialog/dialog-store'; import { useDialog } from '../dialog/dialog-store';
import { useTabs } from '../tabs/tab-store';
// 极简浮层通知(FR-B1,后续抽组件) // 极简浮层通知(FR-B1,后续抽组件)
function notify(msg: string) { function notify(msg: string) {
@@ -30,7 +31,12 @@ export function Toolbar() {
await loadTree(); await loadTree();
}; };
const switchVault = async () => { 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 loadVault();
await loadTree(); await loadTree();
}; };
+11
View File
@@ -27,6 +27,8 @@ interface EditorState {
addLink: (link: string) => void; addLink: (link: string) => void;
removeLink: (link: string) => void; removeLink: (link: string) => void;
saveNow: () => Promise<void>; saveNow: () => Promise<void>;
resetEditor: () => void; // FR-V6: 切库时重置编辑区
syncCurrentPath: (oldPath: string, newPath: string) => void; // FR-T7: 重命名/移动后同步当前文档路径
} }
// 简单的每文档保存队列(M1 单文档编辑) // 简单的每文档保存队列(M1 单文档编辑)
@@ -111,4 +113,13 @@ export const useStore = create<EditorState>((set, get) => ({
console.error('save failed', e); 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) });
},
})); }));
+89
View File
@@ -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();
});
+2
View File
@@ -9,6 +9,7 @@ export const IPC = {
vaultSwitch: 'vault:switch', vaultSwitch: 'vault:switch',
vaultSetDefault: 'vault:set-default', vaultSetDefault: 'vault:set-default',
vaultPick: 'vault:pick', vaultPick: 'vault:pick',
vaultBrowse: 'vault:browse',
treeList: 'tree:list', treeList: 'tree:list',
treeRefresh: 'tree:refresh', treeRefresh: 'tree:refresh',
fileCreate: 'file:create', fileCreate: 'file:create',
@@ -40,6 +41,7 @@ export interface IpcMap {
[IPC.vaultSwitch]: { args: { root: string }; result: VaultMeta }; [IPC.vaultSwitch]: { args: { root: string }; result: VaultMeta };
[IPC.vaultSetDefault]: { args: { root: string }; result: void }; [IPC.vaultSetDefault]: { args: { root: string }; result: void };
[IPC.vaultPick]: { args: void; result: VaultMeta | null }; [IPC.vaultPick]: { args: void; result: VaultMeta | null };
[IPC.vaultBrowse]: { args: void; result: string | null };
[IPC.treeList]: { args: void; result: TreeNode[] }; [IPC.treeList]: { args: void; result: TreeNode[] };
[IPC.treeRefresh]: { args: void; result: TreeNode[] }; [IPC.treeRefresh]: { args: void; result: TreeNode[] };
[IPC.fileCreate]: { args: { basePath: string; name: string; isFolder: boolean }; result: { path: string } }; [IPC.fileCreate]: { args: { basePath: string; name: string; isFolder: boolean }; result: { path: string } };