设置
diff --git a/apps/desktop/src/renderer/features/toolbar/Toolbar.tsx b/apps/desktop/src/renderer/features/toolbar/Toolbar.tsx
index 7da25a1..1f1cffd 100644
--- a/apps/desktop/src/renderer/features/toolbar/Toolbar.tsx
+++ b/apps/desktop/src/renderer/features/toolbar/Toolbar.tsx
@@ -3,6 +3,7 @@ import { invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared';
import { useDialog } from '../dialog/dialog-store';
import { useTabs } from '../tabs/tab-store';
+import { useState } from 'react';
// 极简浮层通知(FR-B1,后续抽组件)
function notify(msg: string) {
@@ -15,8 +16,9 @@ function notify(msg: string) {
const alert2 = notify;
export function Toolbar() {
- const { loadTree, loadVault, openDoc } = useStore();
+ const { loadTree, loadVault, openDoc, vault } = useStore();
const { showInput } = useDialog();
+ const [recentsOpen, setRecentsOpen] = useState(false);
const newDoc = async () => {
const name = await showInput('新建文档', '输入文档标题…');
if (!name) return;
@@ -51,6 +53,24 @@ export function Toolbar() {
+ {/* FR-V4:最近库下拉快速切换 */}
+ {vault?.recents && vault.recents.length > 1 && (
+
+
+ {recentsOpen && (
+
setRecentsOpen(false)}>
+ {vault.recents.map(r => (
+
{ setRecentsOpen(false); await useStore.getState().saveNow(); await invoke(IPC.vaultSwitch, { root: r }); useTabs.getState().closeAll(); useStore.getState().resetEditor(); await loadVault(); await loadTree(); }}
+ style={{ padding: '6px 10px', cursor: 'pointer', borderRadius: 4, fontSize: 12, color: r === vault.currentRoot ? 'var(--accent)' : 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
+ onMouseEnter={e => (e.currentTarget.style.background = 'var(--control-border)')}
+ onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
+ {r === vault.currentRoot ? '★ ' : ''}{r.split(/[\\/]/).pop()}
+
+ ))}
+
+ )}
+
+ )}
);
}
diff --git a/apps/desktop/tests/e2e/recents-transition.spec.ts b/apps/desktop/tests/e2e/recents-transition.spec.ts
new file mode 100644
index 0000000..d4b7326
--- /dev/null
+++ b/apps/desktop/tests/e2e/recents-transition.spec.ts
@@ -0,0 +1,50 @@
+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 };
+}
+
+// FR-V4:最近库下拉快速切换
+test('FR-V4:最近库下拉快速切换', async () => {
+ const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-v4-'));
+ const ud = nodePath.join(tmp, 'ud');
+ const v1 = nodePath.join(tmp, 'vault1');
+ const v2 = nodePath.join(tmp, 'vault2');
+ await fs.mkdir(ud, { recursive: true });
+ await fs.mkdir(v1, { recursive: true });
+ await fs.mkdir(v2, { recursive: true });
+
+ const a = await launch(ud);
+ // 切换到 v1(自动加入 recents)— 通过 UI 工具栏
+ await a.invoke('vault:switch', { root: v1 });
+ await a.page.waitForTimeout(500);
+ // 切换到 v2
+ await a.invoke('vault:switch', { root: v2 });
+ await a.page.waitForTimeout(500);
+ // 刷新渲染层 vault 元数据
+ await a.page.evaluate(() => (window as any).__STORE__?.getState?.().loadVault?.());
+ await a.page.waitForTimeout(500);
+
+ // 现在 recents 应有 v1 和 v2
+ const meta = await a.invoke('vault:list', null as any);
+ expect(meta.recents.length).toBeGreaterThanOrEqual(2);
+
+ // 点击「最近库」下拉
+ await a.page.getByTestId('recents-toggle').click();
+ await a.page.waitForTimeout(300);
+ // 应显示两个库
+ const dropdown = a.page.locator('div').filter({ has: a.page.getByText(v1.split(/[\\/]/).pop()!) });
+ await expect(dropdown.first()).toBeVisible({ timeout: 3000 });
+
+ await a.app.close();
+});