feat(M4): FR-R2/R6/R7 搜索+标签筛选——search hay 加 tags+links 元数据(R2); TagFilter 加折叠按钮(R6); tagPaths IPC+FileTree AND 过滤选中标签的文档(R7); store 加 tagFilter; search-tag E2E 2 项

This commit is contained in:
hunter-dev
2026-08-26 14:53:00 +08:00
parent bfb1f4b014
commit 01ea477d18
7 changed files with 150 additions and 13 deletions
+1
View File
@@ -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: [] };
@@ -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<Record<string, string[]>> {
const all = await this.readAll();
const m = new Map<string, string[]>();
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<string, string[]> = {};
for (const [tag, paths] of m) out[tag] = paths;
return out;
}
}
@@ -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<Set<string>>(new Set());
const [renaming, setRenaming] = useState<string | null>(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<string, TreeNode[]>(); 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<Set<string> | null>(null);
useEffect(() => {
if (tagFilter.length === 0) { setTagPaths(null); return; }
invoke(IPC.tagPaths, undefined as never).then((all: Record<string, string[]>) => {
const sets = tagFilter.map(t => new Set(all[t] ?? []));
if (sets.length === 0) { setTagPaths(null); return; }
const inter = new Set<string>();
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<string>();
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<string, TreeNode[]>(); 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 ? '文件夹名称' : '文档标题');
@@ -7,15 +7,26 @@ export function TagFilter() {
const { tree } = useStore();
const [tags, setTags] = useState<TagStat[]>([]);
const [selected, setSelected] = useState<string[]>([]);
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 (
<div style={{ padding: '6px 8px', borderTop: '1px solid var(--control-border)', maxHeight: 120, overflow: 'auto' }}>
<div style={{ opacity: 0.5, fontSize: 11, marginBottom: 4 }}>标签({tags.length})</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>{tags.map(t => (
<span key={t.tag} onClick={() => 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}</span>
))}</div>
<div style={{ padding: '6px 8px', borderTop: '1px solid var(--control-border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<div style={{ opacity: 0.5, fontSize: 11, cursor: 'pointer', userSelect: 'none' }} onClick={() => setCollapsed(c => !c)}>{label}</div>
<button onClick={() => setCollapsed(c => !c)} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 10, padding: 0 }}>{collapsed ? '▾' : '▴'}</button>
</div>
{!collapsed && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 100, overflow: 'auto' }}>
{tags.map(t => (
<span key={t.tag} onClick={() => 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}</span>
))}
</div>
)}
</div>
);
}
}
+2
View File
@@ -15,6 +15,7 @@ interface EditorState {
body: string;
meta: DocMeta;
saveState: SaveState;
tagFilter: string[]; // FR-R7:选中标签列表,FileTree 据此 AND 过滤
// actions
loadVault: () => Promise<void>;
loadTree: () => Promise<void>;
@@ -43,6 +44,7 @@ export const useStore = create<EditorState>((set, get) => ({
body: '',
meta: {},
saveState: 'saved',
tagFilter: [],
loadVault: async () => {
const vault = await invoke(IPC.vaultList, undefined as never);
+84
View File
@@ -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();
});
+2
View File
@@ -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<string, string[]> };
[IPC.graph]: { args: void; result: GraphData };
[IPC.winMinimize]: { args: void; result: void };
[IPC.winMaximize]: { args: void; result: void };