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:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user