diff --git a/package.json b/package.json index e3afe6e..d8a7774 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@kevisual/context": "^0.0.8", "@kevisual/dts": "^0.0.4", "@kevisual/remote-app": "^0.0.7", - "@kevisual/router": "^0.1.1", + "@kevisual/router": "^0.1.2", "es-toolkit": "^1.45.1", "eventemitter3": "^5.0.4", "fast-glob": "^3.3.3", diff --git a/src/project/util/git.ts b/src/project/util/git.ts index def8507..c267ed3 100644 --- a/src/project/util/git.ts +++ b/src/project/util/git.ts @@ -1,18 +1,20 @@ import { execSync } from 'node:child_process' import path from 'node:path'; -export const getPathnameFromGitUrl = (url: string): string => { +export const getPathnameFromGitUrl = (url: string): { pathname: string, filename: string } => { const _url = new URL(url.replace(/\.git$/, '')); - const pathname = _url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); // 去除开头和结尾的斜杠 - return pathname; + const _pathname = _url.pathname; + const pathname = _pathname.replace(/^\/+/, '').replace(/\/+$/, ''); // 去除开头和结尾的斜杠 + const filename = path.basename(_pathname); + return { pathname, filename }; } -export const getGitPathname = (repoPath: string): { url: string, pathname: string } | null => { +export const getGitPathname = (repoPath: string): { url: string, pathname: string, filename: string } | null => { try { const url = execSync('git config --get remote.origin.url', { cwd: repoPath, encoding: 'utf-8' }).trim(); if (url) { return { url, - pathname: getPathnameFromGitUrl(url), + ...getPathnameFromGitUrl(url), } } return null; diff --git a/src/routes/project-init.ts b/src/routes/project-init.ts index 2b2462b..ffaa80f 100644 --- a/src/routes/project-init.ts +++ b/src/routes/project-init.ts @@ -112,7 +112,7 @@ app.route({ } else { ctx.throw(400, 'repo 参数不能为空,且必须是完整的 URL 或者 owner/repo 格式'); } - const name = getPathnameFromGitUrl(cloneRepoUrl); + const { filename: name } = getPathnameFromGitUrl(cloneRepoUrl); if (!name) { ctx.throw(400, '无法从 repo 参数解析出项目名称,请检查 repo 格式是否正确'); } diff --git a/src/routes/search.ts b/src/routes/search.ts index b61087f..a4116a0 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -13,7 +13,64 @@ app metadata: { args: { q: z.string().optional().describe('搜索关键词,选填;留空或不传则返回全部文件'), - projectPath: z.string().optional().describe('按项目根目录路径过滤,仅返回该项目下的文件,选填'), + // projectPath: z.string().optional().describe('按项目根目录路径过滤,仅返回该项目下的文件,选填'), + filepath: z.string().optional().describe('按文件绝对路径过滤,选填'), + repo: z.string().optional().describe('按代码仓库标识过滤(如 owner/repo),选填'), + title: z.string().optional().describe('按人工标注的标题字段过滤,选填'), + tags: z.array(z.string()).optional().describe('按人工标注的标签列表过滤,选填'), + summary: z.string().optional().describe('按人工标注的摘要字段过滤,选填'), + description: z.string().optional().describe('按人工标注的描述字段过滤,选填'), + link: z.string().optional().describe('按人工标注的外部链接字段过滤,选填'), + sort: z.array(z.string()).optional().describe('排序规则数组,格式为 ["字段:asc"] 或 ["字段:desc"],选填,当 q 为空时默认为 ["projectPath:asc"]'), + limit: z.number().optional().describe('返回结果数量上限,选填,当 q 为空时默认为 1000'), + getContent: z.boolean().optional().describe('是否返回文件内容,默认为 false;如果为 true,则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景'), + projects: z.array(z.string()).optional().describe('按项目名称列表过滤,选填,默认不穿,只过滤当前工作区的项目'), + } + } + }) + .define(async (ctx) => { + type SearchQuery = { q?: string; projectPath?: string; filepath?: string; repo?: string; title?: string; tags?: string[]; summary?: string; description?: string; link?: string; sort?: string[]; limit?: number; getContent?: boolean; projects?: string[] } + let { q, projectPath, filepath, repo, title, tags, summary, description, link, sort, limit, getContent = false, projects } = ctx.query as SearchQuery; + if (!q) { + sort = sort ?? ['projectPath:asc']; + limit = limit ?? 1000; + } + let hits: any[] = []; + const getOnlyProjects = async (projectPaths: string[]) => { + if (projectPaths.length === 0) return []; + let searchPromises = projectPaths.map(projectPath => manager.projectSearch.searchFiles(q, { projectPath, filepath, repo, title, tags, summary, description, link, sort, limit, getContent })); + const results = await Promise.all(searchPromises); + return results.flat(); + } + if (!projects || projects.length === 0) { + // 如果没有指定项目名称列表,则默认搜索当前工作区的所有项目 + const projects = await manager.projectStore.listProjects(); + const projectPaths = projects.filter(p => p.status === 'active').map(p => p.path); + hits = await getOnlyProjects(projectPaths); + } else { + // 如果指定了项目名称列表,则只搜索这些项目, 同时确保这些项目在当前工作区中是存在的 + const _projects = await manager.projectStore.listProjects(); + let _hasProjects = projects.filter(p => _projects.some(_p => _p.path === p)); + hits = await getOnlyProjects(_hasProjects); + } + ctx.body = { list: hits }; + }) + .addTo(app); + +/** +* 搜索文件 +* query: { q, projectPath?, repo? } +*/ +app + .route({ + path: 'project-search', + key: 'search', + description: '在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制', + middleware: ['auth-admin'], + metadata: { + args: { + q: z.string().optional().describe('搜索关键词,选填;留空或不传则返回全部文件'), + projectPath: z.string().describe('按项目根目录路径过滤,仅返回该项目下的文件,必填'), filepath: z.string().optional().describe('按文件绝对路径过滤,选填'), repo: z.string().optional().describe('按代码仓库标识过滤(如 owner/repo),选填'), title: z.string().optional().describe('按人工标注的标题字段过滤,选填'), @@ -28,13 +85,24 @@ app } }) .define(async (ctx) => { - let { q, projectPath, filepath, repo, title, tags, summary, description, link, sort, limit, getContent = false } = ctx.query as { q?: string; projectPath?: string; filepath?: string; repo?: string; title?: string; tags?: string[]; summary?: string; description?: string; link?: string; sort?: string[]; limit?: number; getContent?: boolean }; + type SearchQuery = { q?: string; projectPath?: string; filepath?: string; repo?: string; title?: string; tags?: string[]; summary?: string; description?: string; link?: string; sort?: string[]; limit?: number; getContent?: boolean; projects?: string[] } + let { q, projectPath, filepath, repo, title, tags, summary, description, link, sort, limit, getContent = false, projects } = ctx.query as SearchQuery; if (!q) { sort = sort ?? ['projectPath:asc']; limit = limit ?? 1000; } - const projectSearch = manager.projectSearch; - const hits = await projectSearch.searchFiles(q, { projectPath, filepath, repo, title, tags, summary, description, link, sort, limit, getContent }); + if (!projectPath) { + ctx.throw(400, 'projectPath 参数不能为空'); + return; + } + let hits: any[] = []; + const getOnlyProjects = async (projectPaths: string[]) => { + if (projectPaths.length === 0) return []; + let searchPromises = projectPaths.map(projectPath => manager.projectSearch.searchFiles(q, { projectPath, filepath, repo, title, tags, summary, description, link, sort, limit, getContent })); + const results = await Promise.all(searchPromises); + return results.flat(); + } + hits = await getOnlyProjects([projectPath!]); ctx.body = { list: hits }; }) .addTo(app);