diff --git a/apps/desktop/src/main/ipc/handlers.ts b/apps/desktop/src/main/ipc/handlers.ts index c8bb187..d9a3b74 100644 --- a/apps/desktop/src/main/ipc/handlers.ts +++ b/apps/desktop/src/main/ipc/handlers.ts @@ -49,6 +49,7 @@ export function registerIpc(vault: VaultService) { fs2().writeDoc({ path: p.path, meta: p.meta, body: p.body })); ipcMain.handle(IPC.search, async (_e, p: { query: string }) => vault.searchService?.search(p.query) ?? []); ipcMain.handle(IPC.tagStats, async () => vault.searchService?.tagStats() ?? []); + ipcMain.handle(IPC.tagPaths, async () => vault.searchService?.tagPaths() ?? {}); ipcMain.handle(IPC.graph, async () => { const fs2 = vault.fileService; if (!fs2) return { nodes: [], edges: [] }; diff --git a/apps/desktop/src/main/services/search-service.ts b/apps/desktop/src/main/services/search-service.ts index 905b215..d0f32d3 100644 --- a/apps/desktop/src/main/services/search-service.ts +++ b/apps/desktop/src/main/services/search-service.ts @@ -12,8 +12,8 @@ export class SearchService { constructor(private root: string, private fileService: { getRoot: () => string }) {} // 遍历库读取所有文档,建立简易索引(M4:内存全量,大库后续优化) - private async readAll(): Promise<{ path: string; title: string; body: string; tags: string[] }[]> { - const out: { path: string; title: string; body: string; tags: string[] }[] = []; + private async readAll(): Promise<{ path: string; title: string; body: string; tags: string[]; links: string[] }[]> { + const out: { path: string; title: string; body: string; tags: string[]; links: string[] }[] = []; const walk = async (dirAbs: string, dirRel: string) => { const entries = await fs.readdir(dirAbs, { withFileTypes: true }); for (const e of entries) { @@ -25,7 +25,7 @@ export class SearchService { const raw = await fs.readFile(abs, 'utf8'); const { meta, body } = splitFrontmatter(raw); const relPosix = rel.split(nodePath.sep).join('/'); - out.push({ path: relPosix, title: meta.title ?? e.name.slice(0, -MD_EXT.length), body, tags: meta.tags ?? [] }); + out.push({ path: relPosix, title: meta.title ?? e.name.slice(0, -MD_EXT.length), body, tags: meta.tags ?? [], links: meta.links ?? [] }); } } }; @@ -39,7 +39,7 @@ export class SearchService { const all = await this.readAll(); const hits: SearchHit[] = []; for (const d of all) { - const hay = (d.title + '\n' + d.body).toLowerCase(); + const hay = (d.title + '\n' + d.body + '\n' + d.tags.join(' ') + '\n' + (d.links ?? []).join(' ')).toLowerCase(); const idx = hay.indexOf(q); if (idx !== -1) { // 摘要:命中位置附近,超长截尾保留 ~120 字符 @@ -58,4 +58,14 @@ export class SearchService { for (const d of all) for (const t of d.tags) m.set(t, (m.get(t) ?? 0) + 1); return [...m.entries()].map(([tag, count]) => ({ tag, count })).sort((a, b) => a.tag.localeCompare(b.tag)); } + + // FR-R7:返回标签→文档路径列表映射,供 FileTree AND 过滤 + async tagPaths(): Promise> { + const all = await this.readAll(); + const m = new Map(); + for (const d of all) for (const t of d.tags) { const arr = m.get(t) ?? []; arr.push(d.path); m.set(t, arr); } + const out: Record = {}; + for (const [tag, paths] of m) out[tag] = paths; + return out; + } } diff --git a/apps/desktop/src/renderer/features/file-tree/FileTree.tsx b/apps/desktop/src/renderer/features/file-tree/FileTree.tsx index 5d69deb..0c9e436 100644 --- a/apps/desktop/src/renderer/features/file-tree/FileTree.tsx +++ b/apps/desktop/src/renderer/features/file-tree/FileTree.tsx @@ -1,13 +1,13 @@ import { useStore } from '../../store'; import type { TreeNode } from '@hunter/shared'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } 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, syncCurrentPath } = useStore(); + const { tree, loadTree, treeLoading, openDoc, currentPath, syncCurrentPath, tagFilter } = useStore(); const { showInput, showConfirm } = useDialog(); const [collapsed, setCollapsed] = useState>(new Set()); const [renaming, setRenaming] = useState(null); @@ -17,7 +17,34 @@ export function FileTree() { const [menu, setMenu] = useState<{ path: string; x: number; y: number } | null>(null); const toggle = (p: string) => { const n = new Set(collapsed); if (n.has(p)) n.delete(p); else n.add(p); setCollapsed(n); }; - const byParent = useMemo(() => { const m = new Map(); for (const n of tree) { const a = m.get(n.parent) ?? []; a.push(n); m.set(n.parent, a); } return m; }, [tree]); + // FR-R7:标签 AND 过滤——选中标签时仅保留命中全部标签的 .md 文件(及其祖先目录) + const [tagPaths, setTagPaths] = useState | null>(null); + useEffect(() => { + if (tagFilter.length === 0) { setTagPaths(null); return; } + invoke(IPC.tagPaths, undefined as never).then((all: Record) => { + const sets = tagFilter.map(t => new Set(all[t] ?? [])); + if (sets.length === 0) { setTagPaths(null); return; } + const inter = new Set(); + for (const p of sets[0]) { if (sets.every(s => s.has(p))) inter.add(p); } + setTagPaths(inter); + }).catch(() => setTagPaths(null)); + }, [tagFilter]); + + const filteredTree = useMemo(() => { + if (!tagPaths || tagFilter.length === 0) return tree; + const keep = new Set(); + for (const n of tree) { + if (n.type === 'doc' && tagPaths.has(n.path)) { + let p: string | undefined = n.parent; + while (p !== undefined && p !== '' && !keep.has(p)) { keep.add(p); const parent = tree.find(x => x.path === p); p = parent?.parent; } + keep.add(n.path); + } + if (n.type === 'folder' && keep.has(n.path)) keep.add(n.path); + } + return tree.filter(n => keep.has(n.path) || tagPaths.has(n.path)); + }, [tree, tagPaths, tagFilter]); + + const byParent = useMemo(() => { const m = new Map(); for (const n of filteredTree) { const a = m.get(n.parent) ?? []; a.push(n); m.set(n.parent, a); } return m; }, [filteredTree]); const doCreate = async (basePath: string, isFolder: boolean) => { const name = await showInput(isFolder ? '新建文件夹' : '新建文档', isFolder ? '文件夹名称' : '文档标题'); diff --git a/apps/desktop/src/renderer/features/search/TagFilter.tsx b/apps/desktop/src/renderer/features/search/TagFilter.tsx index 00800c1..610f1ca 100644 --- a/apps/desktop/src/renderer/features/search/TagFilter.tsx +++ b/apps/desktop/src/renderer/features/search/TagFilter.tsx @@ -7,15 +7,26 @@ export function TagFilter() { const { tree } = useStore(); const [tags, setTags] = useState([]); const [selected, setSelected] = useState([]); + const [collapsed, setCollapsed] = useState(false); useEffect(() => { invoke(IPC.tagStats, undefined as never).then(setTags).catch(() => {}); }, [tree.length]); + // FR-R7:选中标签同步到 store 供 FileTree AND 过滤 + useEffect(() => { useStore.setState({ tagFilter: selected }); }, [selected]); if (tags.length === 0) return null; const toggle = (t: string) => setSelected(s => s.includes(t) ? s.filter(x => x !== t) : [...s, t]); + const label = '标签(' + tags.length + ')' + (selected.length > 0 ? ' · 已选' + selected.length : ''); return ( -
-
标签({tags.length})
-
{tags.map(t => ( - toggle(t.tag)} style={{ cursor: 'pointer', padding: '1px 7px', borderRadius: 10, fontSize: 11, background: selected.includes(t.tag) ? 'var(--accent)' : 'var(--control-border)', color: selected.includes(t.tag) ? '#fff' : 'var(--text-secondary)' }}>{t.tag} {t.count} - ))}
+
+
+
setCollapsed(c => !c)}>{label}
+ +
+ {!collapsed && ( +
+ {tags.map(t => ( + toggle(t.tag)} style={{ cursor: 'pointer', padding: '1px 7px', borderRadius: 10, fontSize: 11, background: selected.includes(t.tag) ? 'var(--accent)' : 'var(--control-border)', color: selected.includes(t.tag) ? '#fff' : 'var(--text-secondary)' }}>{t.tag} {t.count} + ))} +
+ )}
); -} \ No newline at end of file +} diff --git a/apps/desktop/src/renderer/store.ts b/apps/desktop/src/renderer/store.ts index 424d432..4e4a28b 100644 --- a/apps/desktop/src/renderer/store.ts +++ b/apps/desktop/src/renderer/store.ts @@ -15,6 +15,7 @@ interface EditorState { body: string; meta: DocMeta; saveState: SaveState; + tagFilter: string[]; // FR-R7:选中标签列表,FileTree 据此 AND 过滤 // actions loadVault: () => Promise; loadTree: () => Promise; @@ -43,6 +44,7 @@ export const useStore = create((set, get) => ({ body: '', meta: {}, saveState: 'saved', + tagFilter: [], loadVault: async () => { const vault = await invoke(IPC.vaultList, undefined as never); diff --git a/apps/desktop/tests/e2e/search-tag.spec.ts b/apps/desktop/tests/e2e/search-tag.spec.ts new file mode 100644 index 0000000..5970e93 --- /dev/null +++ b/apps/desktop/tests/e2e/search-tag.spec.ts @@ -0,0 +1,84 @@ +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-R2:搜索覆盖标签元数据 +test('FR-R2:搜索覆盖标签元数据', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-r2-')); + 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 }); + + // 创建文档并写入带标签的 frontmatter + await a.invoke('doc:write', { path: 'tagged.md', meta: { title: '特殊文档', tags: ['unique-tag-xyz'] }, body: '正文内容' }); + await a.page.waitForTimeout(500); + + // 搜索标签名 -> 应命中 + await a.page.getByPlaceholder(/搜索/).fill('unique-tag-xyz'); + await a.page.waitForTimeout(1000); + // 搜索结果应包含标题 + const resultArea = a.page.getByPlaceholder(/搜索/).locator('..').locator('..'); + await expect(resultArea.getByText('特殊文档')).toBeVisible({ timeout: 8000 }); + + await a.app.close(); +}); + +// FR-R6:标签栏可折叠 +test('FR-R6:标签栏可折叠', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-r6-')); + 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('doc:write', { path: 'tagged.md', meta: { title: '标签文档', tags: ['test-tag'] }, body: '正文' }); + await a.page.waitForTimeout(500); + // 刷新树 + await a.page.getByTitle('刷新').click(); + await a.page.waitForTimeout(500); + + // 标签栏应可见 + const tagHeader = a.page.getByText(/标签(/); + await expect(tagHeader).toBeVisible({ timeout: 5000 }); + + // 点击折叠 + await tagHeader.click(); + await a.page.waitForTimeout(300); + // 标签列表应隐藏(折叠后不显示标签 span) + const tagSpans = a.page.locator('span').filter({ hasText: 'test-tag' }); + await expect(tagSpans).toHaveCount(0, { timeout: 3000 }); + + // 再次点击展开 + await a.page.getByText(/标签(/).click(); + await a.page.waitForTimeout(300); + await expect(tagSpans).toBeVisible({ timeout: 3000 }); + + await a.app.close(); +}); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index 9f76813..6f0a8fe 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -24,6 +24,7 @@ export const IPC = { docReveal: 'doc:reveal', search: 'search', tagStats: 'tag:stats', + tagPaths: 'tag:paths', graph: 'graph', winMinimize: 'win:minimize', winMaximize: 'win:maximize', @@ -59,6 +60,7 @@ export interface IpcMap { [IPC.docReveal]: { args: { path: string }; result: void }; [IPC.search]: { args: { query: string }; result: SearchHit[] }; [IPC.tagStats]: { args: void; result: TagStat[] }; + [IPC.tagPaths]: { args: void; result: Record }; [IPC.graph]: { args: void; result: GraphData }; [IPC.winMinimize]: { args: void; result: void }; [IPC.winMaximize]: { args: void; result: void };