From 1b131b39616247df0d6de106b6ed3f091d693e4b Mon Sep 17 00:00:00 2001 From: xiongxiao Date: Fri, 13 Mar 2026 17:22:14 +0800 Subject: [PATCH] feat: initialize project structure with essential files and configurations - Add .gitignore to exclude unnecessary files and directories - Create .npmrc for npm authentication - Add AGENTS.md for project documentation - Initialize package.json with project metadata and dependencies - Implement app.ts to set up the application and project manager - Create file-search module for searching files in a directory - Set up project manager and listener for managing project files - Implement project search functionality with MeiliSearch integration - Add routes for authentication and project management - Create scheduler for task management - Add tests for file searching and project management functionalities --- .gitignore | 14 + .npmrc | 2 + AGENTS.md | 1 + bun.config.ts | 4 + package.json | 31 ++ readme.md | 10 + src/app.ts | 12 + src/file-search/index.ts | 30 ++ src/index.ts | 15 + src/project/manager.ts | 176 ++++++++++ src/project/project-listener/listener.ts | 90 +++++ .../project-search/file-list-content.ts | 43 +++ src/project/project-search/index.ts | 311 ++++++++++++++++++ src/project/project-store.ts | 124 +++++++ src/project/user-interface.ts | 6 + src/project/util/git.ts | 16 + src/routes/auth.ts | 17 + src/routes/file.ts | 81 +++++ src/routes/project.ts | 162 +++++++++ src/routes/search.ts | 39 +++ src/scheduler/index.ts | 56 ++++ test/common.ts | 11 + test/file.ts | 11 + test/remote.ts | 19 ++ test/search.ts | 7 + test/start.ts | 7 + test/wather.ts | 41 +++ 27 files changed, 1336 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 AGENTS.md create mode 100644 bun.config.ts create mode 100644 package.json create mode 100644 readme.md create mode 100644 src/app.ts create mode 100644 src/file-search/index.ts create mode 100644 src/index.ts create mode 100644 src/project/manager.ts create mode 100644 src/project/project-listener/listener.ts create mode 100644 src/project/project-search/file-list-content.ts create mode 100644 src/project/project-search/index.ts create mode 100644 src/project/project-store.ts create mode 100644 src/project/user-interface.ts create mode 100644 src/project/util/git.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/file.ts create mode 100644 src/routes/project.ts create mode 100644 src/routes/search.ts create mode 100644 src/scheduler/index.ts create mode 100644 test/common.ts create mode 100644 test/file.ts create mode 100644 test/remote.ts create mode 100644 test/search.ts create mode 100644 test/start.ts create mode 100644 test/wather.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e6453c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules +.DS_Store + +dist + +pack-dist + + +.env +!.env*example + +libs + +cache-file \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7446745 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} +//registry.npmjs.org/:_authToken=${NPM_TOKEN} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e516510 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +# AGENTS.md diff --git a/bun.config.ts b/bun.config.ts new file mode 100644 index 0000000..88d32ab --- /dev/null +++ b/bun.config.ts @@ -0,0 +1,4 @@ +import { buildWithBun } from '@kevisual/code-builder'; + +await buildWithBun({ naming: 'app', entry: 'src/index.ts', dts: true, clean: true }); + diff --git a/package.json b/package.json new file mode 100644 index 0000000..942dd85 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "@kevisual/project-search", + "version": "0.0.1", + "description": "", + "main": "index.js", + "scripts": { + "dev": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "abearxiong (https://www.xiongxiao.me)", + "license": "MIT", + "packageManager": "pnpm@10.32.1", + "type": "module", + "devDependencies": { + "@kevisual/code-builder": "^0.0.6", + "@kevisual/context": "^0.0.8", + "@kevisual/remote-app": "^0.0.7", + "@kevisual/router": "^0.1.1", + "fast-glob": "^3.3.3", + "meilisearch": "^0.55.0", + "zod": "^4.3.6" + }, + "dependencies": { + "@parcel/watcher": "^2.5.6", + "es-toolkit": "^1.45.1", + "eventemitter3": "^5.0.4" + }, + "exports": { + ".": "./dist/index.js" + } +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2255824 --- /dev/null +++ b/readme.md @@ -0,0 +1,10 @@ +# 对文件项目快速搜索展示 + +## 介绍 + +比如文件夹 `/workspace/projects/project-search` 有很多文件,监听修改后,同步到meilisearh中。增删改查同步。 + +1. 获取文件夹下的.gitignore文件,解析出需要忽略的文件列表。 +2. 监听文件夹下的文件修改事件,过滤掉需要忽略的文件,将修改的文件同步到meilisearch中。 +3. 需要提供一个scheduler任务调度,同步到meilisearch的任务中,如果存在同一个任务还没有被执行,新的任务就不需要被添加到队列中。 +4. 任务调度是一个队列任务列,先进先出,单线程执行。每次执行一个任务,执行完后再执行下一个任务。 \ No newline at end of file diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..18d2d5b --- /dev/null +++ b/src/app.ts @@ -0,0 +1,12 @@ +import { App } from '@kevisual/router'; +import { ProjectManager } from './project/manager'; +import { useKey, useContextKey } from '@kevisual/context'; + + +export const app = useContextKey('app', new App()); +export const manager = useContextKey('project-manager', new ProjectManager({ + meiliSearchOptions: { + apiKey: useKey('CNB_TOKEN') + } +})); + diff --git a/src/file-search/index.ts b/src/file-search/index.ts new file mode 100644 index 0000000..0eadebe --- /dev/null +++ b/src/file-search/index.ts @@ -0,0 +1,30 @@ +import fg from 'fast-glob'; +import path from 'node:path'; +import fs from 'node:fs'; + +const defaultIgnore = ['node_modules', 'dist', 'pack-dist', 'build', 'out', 'coverage', '.git', '.svn', '.hg', + '.next', + '.astro', + ".swc" +]; + +export class FileSearch { + async searchFiles(options: { cwd?: string; ignore?: string[] } = {}) { + const { cwd = process.cwd(), ignore = [] } = options; + const ignoreFile = path.join(cwd, '.gitignore'); + const gitIgnorePatterns = fs.existsSync(ignoreFile) + ? fs.readFileSync(ignoreFile, 'utf-8') + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + : []; + const allIgnore = [...defaultIgnore, ...ignore, ...gitIgnorePatterns]; + const files = await fg('**', { + cwd, + ignore: allIgnore, + dot: true, + onlyFiles: true, + }); + return files; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..26b4582 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,15 @@ +export { app, manager } from './app'; +export { ProjectManager } from './project/manager'; +export { ProjectSearch } from './project/project-search/index'; +export { ProjectListener } from './project/project-listener/listener'; +export { FileSearch } from './file-search/index'; +export { Scheduler } from './scheduler/index'; +import './routes/auth'; +import './routes/project'; +import './routes/search'; +import './routes/file'; + + +if (import.meta.main) { + // +} \ No newline at end of file diff --git a/src/project/manager.ts b/src/project/manager.ts new file mode 100644 index 0000000..40f6cba --- /dev/null +++ b/src/project/manager.ts @@ -0,0 +1,176 @@ +import { ProjectManagerInterface } from "./user-interface"; +import { ProjectSearch } from "./project-search/index"; +import { ProjectStore } from "./project-store"; +import { ProjectListener } from "./project-listener/listener"; +import { EventEmitter } from "eventemitter3"; +import fs from 'node:fs'; +import { CustomError } from "@kevisual/router"; + +type Project = { + name?: string; + path: string; + /** + * 用 git remote url 唯一标识项目,方便搜索结果展示和过滤 + */ + repo: string; + listener: ProjectListener; +}; + +export type ProjectInput = Omit; +export type ProjectInfo = Omit & { status: "active" | "inactive" }; + +type ProjectManagerOpt = { + meiliSearchOptions?: { + apiHost?: string; + apiKey?: string; + } +} +export class ProjectManager implements ProjectManagerInterface { + projects = new Map(); + projectSearch: ProjectSearch; + projectStore: ProjectStore; + emitter: EventEmitter; + + constructor(options: ProjectManagerOpt = {}) { + const projectSearch = new ProjectSearch(options.meiliSearchOptions); + const projectStore = new ProjectStore(options.meiliSearchOptions); + const emitter = new EventEmitter(); + this.projectSearch = projectSearch; + this.projectStore = projectStore; + this.emitter = emitter; + const that = this; + + emitter.on('file-change', ({ type, path: filePath, projectPath }) => { + console.log('[ProjectManager] file-change:', type, filePath); + const project = that.projects.get(projectPath); + if (!project) return; + projectSearch.syncFileChange({ type, path: filePath, projectPath, repo: project.repo }); + }); + } + + /** + * 初始化:从 store 加载已持久化的项目,检查本地目录存在后启动监听 + */ + async init(): Promise { + await this.projectStore.init(); + const docs = await this.projectStore.listProjects(); + for (const doc of docs) { + if (!fs.existsSync(doc.path)) { + console.log(`[ProjectManager] init: skip missing path ${doc.path}`); + continue; + } + try { + await this.addProject({ name: doc.name, path: doc.path, repo: doc.repo }); + } catch (err) { + console.error(`[ProjectManager] init: failed to add project ${doc.path}`, err); + } + } + console.log(`[ProjectManager] init done, loaded ${this.projects.size} project(s)`); + } + + /** + * 添加项目:创建监听器、启动监听、触发全量初始同步 + */ + async addProject(input: ProjectInput): Promise { + // 检查本地目录是否存在 + if (!fs.existsSync(input.path)) { + throw new CustomError(`[ProjectManager] addProject: path does not exist: ${input.path}`); + } + // 若已存在则先停止旧监听 + if (this.projects.has(input.path)) { + await this.stopProject(input.path); + } + const listener = new ProjectListener({ projectPath: input.path, emitter: this.emitter }); + const project: Project = { ...input, listener }; + + this.projects.set(input.path, project); + + // 持久化存储项目信息 + this.projectStore.ensureIndex().then(() => + this.projectStore.addProject({ name: input.name, path: input.path, repo: input.repo }) + ).catch(err => { + console.error('[ProjectManager] projectStore.addProject failed:', err); + }); + + // 启动文件监听 + await listener.startListening(); + console.log(`[ProjectManager] watching: ${input.path}`); + + // 触发全量初始同步(不阻塞返回) + this.projectSearch.initialSync(input.path, input.repo).catch(err => { + console.error('[ProjectManager] initialSync failed:', err); + }); + + return this.getProjectInfo(project); + } + + async removeProject(projectPath: string): Promise { + const project = this.projects.get(projectPath); + if (!project) return; + await project.listener.stopListening(); + this.projects.delete(projectPath); + // 从持久化存储中删除项目及其所有索引文件 + this.projectStore.removeProject(projectPath).catch(err => { + console.error('[ProjectManager] projectStore.removeProject failed:', err); + }); + this.projectSearch.removeProjectFiles(projectPath).catch(err => { + console.error('[ProjectManager] projectSearch.removeProjectFiles failed:', err); + }); + console.log(`[ProjectManager] removed: ${projectPath}`); + } + + getProject(projectPath: string) { + return this.projects.get(projectPath); + } + + /** 停止项目文件监听,但不删除索引数据 */ + async stopProject(projectPath: string): Promise { + const project = this.projects.get(projectPath); + if (!project) return; + await project.listener.stopListening(); + console.log(`[ProjectManager] stopped: ${projectPath}`); + } + + getProjectInfo(project: Project): ProjectInfo { + const { listener, ...info } = project; + return { + ...info, + status: listener.isWatching ? "active" : "inactive", + }; + } + + listProjects(): ProjectInfo[] { + return Array.from(this.projects.values()).map(p => this.getProjectInfo(p)); + } + + async getFile(filepath: string): Promise<{ filepath: string, content: string; type: string } | null> { + if (!fileIsExist(filepath)) { + return null; + } + const read = fs.readFileSync(filepath); + const base64 = read.toString('base64'); + return { + filepath, + content: base64, + type: 'base64', + } + } + + async deleteFile(filepath: string): Promise { + if (!fileIsExist(filepath)) { + return false; + } + fs.unlinkSync(filepath); + return true; + } + +} + +const fileIsExist = (filePath: string): boolean => { + try { + fs.accessSync(filePath, fs.constants.F_OK); + return true; + } catch (err) { + return false; + } +}; \ No newline at end of file diff --git a/src/project/project-listener/listener.ts b/src/project/project-listener/listener.ts new file mode 100644 index 0000000..eddba34 --- /dev/null +++ b/src/project/project-listener/listener.ts @@ -0,0 +1,90 @@ +import ParcelWatcher, { subscribe } from '@parcel/watcher'; +import path from 'node:path'; +import fs from 'node:fs'; +import { EventEmitter } from "eventemitter3"; +const defaultIgnore = ['node_modules', 'dist', 'pack-dist', 'build', 'out', 'coverage', '.git', '.svn', '.hg', + '.next', + '.astro', + ".swc" +]; + +export class ProjectListener { + projectPath: string; + subscribe: ParcelWatcher.AsyncSubscription | null = null; + isWatching = false; + emitter: EventEmitter; + private updateDebounceMap: Map> = new Map(); + constructor(options: { projectPath: string, emitter: EventEmitter }) { + this.projectPath = options.projectPath; + this.emitter = options.emitter; + } + async startListening() { + if (this.isWatching) return; + const projectPath = this.projectPath; + + const ignoreFile = path.join(projectPath, '.gitignore'); + let ignorePatterns: string[] = []; + if (fs.existsSync(ignoreFile)) { + ignorePatterns = fs.readFileSync(ignoreFile, 'utf-8') + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); + } + const allIgnore = [...defaultIgnore, ...ignorePatterns]; + const sub = await subscribe( + this.projectPath, // 监听路径(支持数组) + async (err, events) => { + if (err) throw err; + for (const event of events) { + // 跳过非文件事件(delete 时文件不存在,直接放行) + if (event.type !== 'delete') { + try { + if (!fs.statSync(event.path).isFile()) continue; + } catch { + continue; + } + } + // event: { type: 'create' | 'update' | 'delete', path: '/path/to/file.ts' } + if (event.type === 'update') { + const existing = this.updateDebounceMap.get(event.path); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + this.updateDebounceMap.delete(event.path); + this.emitter.emit('file-change', { + type: event.type, + path: event.path, + projectPath: this.projectPath + }); + }, 100); + this.updateDebounceMap.set(event.path, timer); + } else { + this.emitter.emit('file-change', { + // 事件类型:'create', 'update', 'delete'... + type: event.type, + // 具体文件路径 + path: event.path, + projectPath: this.projectPath + }); + } + } + }, + { + ignore: allIgnore.map(pattern => `**/${pattern}/**`), + backend: 'watchman', // 可选:'fs-events'(默认)、'inotify'、'windows'、'watchman' + } + ); + this.subscribe = sub; + this.isWatching = true; + } + async stopListening() { + if (this.subscribe) { + await this.subscribe.unsubscribe(); + this.subscribe = null; + } + for (const timer of this.updateDebounceMap.values()) { + clearTimeout(timer); + } + this.updateDebounceMap.clear(); + this.isWatching = false; + } +} \ No newline at end of file diff --git a/src/project/project-search/file-list-content.ts b/src/project/project-search/file-list-content.ts new file mode 100644 index 0000000..7f23d83 --- /dev/null +++ b/src/project/project-search/file-list-content.ts @@ -0,0 +1,43 @@ +// 后缀是可以展示为纯文本的文件 +import path from 'node:path'; + +// 常见的文本文件扩展名 +const TEXT_FILE_EXTENSIONS = [ + // 前端/脚本 + '.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + // node 相关 + '.npmrc', '.yarnrc', '.babelrc', '.eslintrc', '.prettierrc', '.stylelintrc', + // 样式 + '.css', '.scss', '.sass', '.less', '.styl', '.postcss', + // 后端/后端语言 + '.java', '.py', '.rb', '.go', '.rs', '.php', '.cs', '.c', '.cpp', '.h', '.hpp', '.kt', '.scala', '.swift', '.m', '.mm', '.dart', '.lua', '.r', + // 数据/配置文件 + '.json', '.yaml', '.yml', '.xml', '.toml', '.ini', '.env', '.properties', '.conf', '.config', + // 文档 + '.md', '.txt', '.rst', '.adoc', '.tex', '.org', + // Shell/脚本 + '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd', '.psm1', + // 其他 + '.sql', '.graphql', '.gql', '.proto', '.dockerfile', '.gitignore', '.gitattributes', '.editorconfig', '.eslintrc', '.prettierrc', '.prettierignore', '.npmrc', '.yarnrc', '.babelrc', '.webpackrc', +]; + +// 没有扩展名的配置文件 +const CONFIG_FILE_NAMES = [ + '.gitignore', '.gitattributes', '.dockerignore', '.editorconfig', '.env', '.env.local', '.env.development', '.env.production', + 'Dockerfile', 'Makefile', 'CMakeLists.txt', 'Vagrantfile', 'Gemfile', 'Podfile', 'Cartfile', + '.bashrc', '.zshrc', '.vimrc', '.inputrc', +]; + +export const isText = (filePath: string): boolean => { + const fileName = path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + // 检查扩展名 + if (TEXT_FILE_EXTENSIONS.includes(ext)) { + return true; + } + // 检查没有扩展名的配置文件 + if (CONFIG_FILE_NAMES.includes(fileName)) { + return true; + } + return false; +} \ No newline at end of file diff --git a/src/project/project-search/index.ts b/src/project/project-search/index.ts new file mode 100644 index 0000000..068fc06 --- /dev/null +++ b/src/project/project-search/index.ts @@ -0,0 +1,311 @@ +import { MeiliSearch } from 'meilisearch'; +import { isText } from "./file-list-content"; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import fg from 'fast-glob'; +import path from 'node:path'; +import { Scheduler } from '../../scheduler/index'; + +export type FileProjectData = { + /** + * meilisearch 文档 ID(文件绝对路径的 base64url 编码,无碰撞且唯一) + */ + id: string; + /** + * 文件路径的 md5 hash,用于快速索引/查找 + */ + hash: string; + /** + * 文件对应相关信息 + * + * 具体文件路径 + */ + filepath: string; + content?: string; + lastModified: number; + size: number; + /** + * 项目路径,文件所在的项目路径,方便搜索结果展示和过滤 + */ + projectPath: string; + repo: string; + + /** + * 人工编辑的字段,方便搜索结果展示和过滤 + */ + title?: string; + tags?: string[]; + summary?: string; + description?: string; + link?: string; +}; + +/** 人工编辑的字段,update 时不覆盖 */ +const MANUAL_FIELDS: (keyof FileProjectData)[] = ['title', 'tags', 'summary', 'description', 'link']; + +const INDEX_NAME = 'project-files'; + +/** 根据文件绝对路径生成稳定的文档 ID(base64url 编码,无碰撞) */ +function fileId(filePath: string): string { + return Buffer.from(filePath).toString('base64url'); +} + +/** 根据文件绝对路径生成 md5 hash */ +function fileHash(filePath: string): string { + return crypto.createHash('md5').update(filePath).digest('hex'); +} + +export class ProjectSearch { + meiliSearch: MeiliSearch; + scheduler: Scheduler; + + constructor({ apiHost, apiKey }: { apiHost?: string; apiKey?: string } = {}) { + this.meiliSearch = new MeiliSearch({ + host: apiHost || 'http://localhost:7700', + apiKey: apiKey || 'masterKey', + }); + this.scheduler = new Scheduler(); + } + + private get index() { + return this.meiliSearch.index(INDEX_NAME); + } + + /** 全文搜索 */ + async searchFiles(query: string = '', options?: { + projectPath?: string; + repo?: string; + title?: string; + tags?: string | string[]; + summary?: string; + description?: string; + link?: string; + /** + * ['projectPath:asc'] + */ + sort?: string[], + limit?: number, + getContent?: boolean; + }) { + const filter: string[] = []; + if (options?.projectPath) filter.push(`projectPath = "${options.projectPath}"`); + if (options?.repo) filter.push(`repo = "${options.repo}"`); + if (options?.title) filter.push(`title = "${options.title}"`); + if (options?.tags) { + const tags = Array.isArray(options.tags) ? options.tags : [options.tags]; + tags.forEach(tag => filter.push(`tags = "${tag}"`)); + } + if (options?.summary) filter.push(`summary = "${options.summary}"`); + if (options?.description) filter.push(`description = "${options.description}"`); + if (options?.link) filter.push(`link = "${options.link}"`); + const limit = options?.limit ?? 1000; + const search = { + filter: filter.length ? filter.join(' AND ') : undefined, + sort: options?.sort ?? ['projectPath:asc'], + } + let hits: FileProjectData[] = []; + if (limit > 1000) { + // meilisearch 单次查询限制 1000 条,超过则使用游标分页查询并合并结果 + let allHits: FileProjectData[] = []; + let offset = 0; + while (true) { + const searchResults = await this.index.search(query, { + ...search, + limit: Math.min(limit - allHits.length, 1000), + offset, + }); + allHits = allHits.concat(searchResults.hits as FileProjectData[]); + if (allHits.length >= limit || searchResults.hits.length < 1000) { + break; + } + offset += 1000; + } + hits = allHits; + } else { + const searchResults = await this.index.search(query, { + ...search, + limit: options?.limit ?? 1000, + }); + hits = searchResults.hits as FileProjectData[]; + } + + if (!options?.getContent) { + hits = hits.map(({ content, ...rest }) => rest); + } + return hits; + } + + /** 初始化索引(设置可过滤字段) */ + async ensureIndex() { + try { + await this.meiliSearch.createIndex(INDEX_NAME, { primaryKey: 'id' }); + } catch (_) { + // index 已存在则忽略 + } + const t1 = await this.index.updateFilterableAttributes(['projectPath', 'repo', 'filepath', 'hash', 'title', 'tags', 'summary', 'description', 'link']); + const t2 = await this.index.updateSearchableAttributes(['content', 'filepath', 'title', 'summary', 'description', 'tags']); + const t3 = await this.index.updateSortableAttributes(['projectPath', 'filepath', 'lastModified', 'size']); + // 等待任务完成,确保过滤字段生效后才能使用 + await this.meiliSearch.tasks.waitForTasks([t1.taskUid, t2.taskUid, t3.taskUid]); + } + + /** 添加或完整覆盖一个文件文档 */ + private async _upsertFile(filePath: string, projectPath: string, repo: string, existingManual?: Partial) { + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + // 文件可能刚被删除 + return; + } + // 文本文件才读取内容 + let content: string | undefined; + if (isText(filePath)) { + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + // 读取失败忽略,content 保持 undefined + } + } + const newHash = fileHash(filePath); + // hash 未变化且已有文档,且无人工编辑字段,则跳过更新 + if (existingManual?.hash === newHash && !MANUAL_FIELDS.some(field => existingManual[field] !== undefined)) { + return; + } + console.log(`[ProjectSearch] updated: ${filePath}`); + const doc: FileProjectData = { + id: fileId(filePath), + hash: newHash, + // 保留人工编辑字段 + ...existingManual, + filepath: filePath, + ...(content !== undefined ? { content } : {}), + lastModified: stat.mtimeMs, + size: stat.size, + projectPath, + repo, + }; + await this.index.addDocuments([doc]); + } + + /** 更新文档的人工编辑字段(title/tags/summary/description/link),不影响文件内容等其他字段 */ + async updateDocument(filePath: string, opts: { + title?: string; + tags?: string[]; + summary?: string; + description?: string; + link?: string; + }) { + const partial: Record = { id: fileId(filePath) }; + if (opts.title !== undefined) partial.title = opts.title; + if (opts.tags !== undefined) partial.tags = opts.tags; + if (opts.summary !== undefined) partial.summary = opts.summary; + if (opts.description !== undefined) partial.description = opts.description; + if (opts.link !== undefined) partial.link = opts.link; + await this.index.updateDocuments([partial]); + } + + /** 删除某个项目下所有已索引的文件文档 */ + async removeProjectFiles(projectPath: string): Promise { + await this.index.deleteDocuments({ filter: `projectPath = "${projectPath}"` }); + } + + /** 将文件变更通过 scheduler 安全入队 */ + syncFileChange({ type, path: filePath, projectPath, repo }: { type: 'create' | 'update' | 'delete'; path: string; projectPath: string; repo: string }) { + if (!filePath) { + console.warn('[ProjectSearch] syncFileChange: filePath is undefined, skipping'); + return; + } + this.scheduler.add(filePath, async () => { + if (type === 'delete') { + try { + await this.index.deleteDocument(fileId(filePath)); + console.log(`[ProjectSearch] deleted: ${filePath}`); + } catch (err) { + console.error(`[ProjectSearch] delete failed: ${filePath}`, err); + } + return; + } + + if (type === 'update') { + // 获取已有文档,保留人工编辑字段 + let existingManual: Partial = {}; + try { + const existing = await this.index.getDocument(fileId(filePath)) as FileProjectData; + for (const field of MANUAL_FIELDS) { + if (existing[field] !== undefined) { + (existingManual as any)[field] = existing[field]; + } + } + existingManual.hash = existing.hash; // 保留 hash 字段,避免重复更新 + } catch { + // 文档不存在,忽略 + } + await this._upsertFile(filePath, projectPath, repo, existingManual); + return; + } + + // create + await this._upsertFile(filePath, projectPath, repo); + console.log(`[ProjectSearch] created: ${filePath}`); + }); + } + + /** + * 全量同步整个项目目录到 meilisearch + * 1. 扫描本地文件,逐一 upsert + * 2. 对比 meilisearch 中已有文档,删除本地已不存在的文件记录 + */ + async initialSync(projectPath: string, repo: string) { + await this.ensureIndex(); + const ignoreFile = path.join(projectPath, '.gitignore'); + const gitIgnore = fs.existsSync(ignoreFile) + ? fs.readFileSync(ignoreFile, 'utf-8') + .split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')) + : []; + const defaultIgnore = ['node_modules', 'dist', 'pack-dist', 'build', 'out', 'coverage', '.git', '.svn', '.hg', '.next', '.astro', '.swc']; + const allIgnore = [...defaultIgnore, ...gitIgnore]; + + const files = await fg('**', { + cwd: projectPath, + ignore: allIgnore, + dot: true, + onlyFiles: true, + absolute: true, + }); + + const localFileSet = new Set(files); + console.log(`[ProjectSearch] initialSync: ${files.length} files in ${projectPath}`); + + // 1. 从 meilisearch 拉取该项目所有已有文档,找出本地不存在的,加入 scheduler 删除 + try { + const PAGE_SIZE = 1000; + let offset = 0; + while (true) { + const result = await this.index.getDocuments({ + filter: `projectPath = "${projectPath}"`, + fields: ['id', 'filepath'], + limit: PAGE_SIZE, + offset, + }); + for (const doc of result.results) { + if (!doc.filepath) continue; // 跳过 filepath 缺失的损坏文档 + if (!localFileSet.has(doc.filepath)) { + this.syncFileChange({ type: 'delete', path: doc.filepath, projectPath, repo }); + } + } + offset += PAGE_SIZE; + if (result.results.length < PAGE_SIZE) break; + } + } catch (err) { + console.error('[ProjectSearch] initialSync: failed to clean stale documents', err); + } + + // 2. upsert 所有本地文件 + for (const filePath of files) { + this.syncFileChange({ type: 'update', path: filePath, projectPath, repo }); + } + } +} \ No newline at end of file diff --git a/src/project/project-store.ts b/src/project/project-store.ts new file mode 100644 index 0000000..bcbdc67 --- /dev/null +++ b/src/project/project-store.ts @@ -0,0 +1,124 @@ +import { MeiliSearch } from 'meilisearch'; + +const PROJECT_INDEX_NAME = 'projects'; + +export type ProjectDoc = { + /** + * 文档 ID,使用 path 的 base64url 编码 + */ + id: string; + name?: string; + path: string; + /** + * 用 git remote url 唯一标识项目 + */ + repo: string; + + /** + * 人工编辑的字段,方便搜索结果展示和过滤 + */ + title?: string; + tags?: string[]; + summary?: string; + description?: string; + link?: string; + /** + * 项目状态:active 活动中 | inactive 非活动 + */ + status?: 'active' | 'inactive'; +}; + +function projectId(projectPath: string): string { + return Buffer.from(projectPath).toString('base64url'); +} + +export class ProjectStore { + meiliSearch: MeiliSearch; + + constructor({ apiHost, apiKey }: { apiHost?: string; apiKey?: string } = {}) { + this.meiliSearch = new MeiliSearch({ + host: apiHost || 'http://localhost:7700', + apiKey: apiKey || 'masterKey', + }); + } + + private get index() { + return this.meiliSearch.index(PROJECT_INDEX_NAME); + } + + async ensureIndex() { + try { + await this.meiliSearch.createIndex(PROJECT_INDEX_NAME, { primaryKey: 'id' }); + } catch (_) { + // index 已存在则忽略 + } + const t1 = await this.index.updateFilterableAttributes(['path', 'repo', 'name', 'status']); + const t2 = await this.index.updateSortableAttributes(['path', 'name']); + await this.meiliSearch.tasks.waitForTasks([t1.taskUid, t2.taskUid]); + } + async init() { + await this.ensureIndex(); + } + async addProject(input: { name?: string; path: string; repo: string; status?: 'active' | 'inactive' }): Promise { + const id = projectId(input.path); + + // 先查询是否已存在 + let existingDoc: ProjectDoc | null = null; + try { + existingDoc = await this.index.getDocument(id); + } catch { + // 不存在则忽略 + } + + // 如果存在,合并文档:只更新 name, path, repo,保留其他字段 + const doc: ProjectDoc = { + id, + name: input.name, + path: input.path, + repo: input.repo, + // 保留原有字段 + title: existingDoc?.title, + tags: existingDoc?.tags, + summary: existingDoc?.summary, + description: existingDoc?.description, + link: existingDoc?.link, + status: input.status ?? existingDoc?.status ?? 'active', + }; + + // MeiliSearch 的 addDocuments 本身就是 upsert 行为 + await this.index.addDocuments([doc]); + } + + async removeProject(projectPath: string): Promise { + await this.index.deleteDocument(projectId(projectPath)); + } + + async listProjects(): Promise { + const result = await this.index.getDocuments({ + limit: 1000, + sort: ['path:asc'], + }); + return result.results; + } + + async getProject(projectPath: string): Promise { + try { + return await this.index.getDocument(projectId(projectPath)); + } catch { + return null; + } + } + + async updateProject(projectPath: string, opts: { name?: string; repo?: string; title?: string; tags?: string[]; summary?: string; description?: string; link?: string; status?: 'active' | 'inactive' }): Promise { + const partial: Record = { id: projectId(projectPath) }; + if (opts.name !== undefined) partial.name = opts.name; + if (opts.repo !== undefined) partial.repo = opts.repo; + if (opts.title !== undefined) partial.title = opts.title; + if (opts.tags !== undefined) partial.tags = opts.tags; + if (opts.summary !== undefined) partial.summary = opts.summary; + if (opts.description !== undefined) partial.description = opts.description; + if (opts.link !== undefined) partial.link = opts.link; + if (opts.status !== undefined) partial.status = opts.status; + await this.index.updateDocuments([partial]); + } +} diff --git a/src/project/user-interface.ts b/src/project/user-interface.ts new file mode 100644 index 0000000..a1df7af --- /dev/null +++ b/src/project/user-interface.ts @@ -0,0 +1,6 @@ +export interface ProjectManagerInterface { + removeProject(path: string): void; + getProject(path: string): any; + listProjects(): any[]; + addProject(project: any): void; +} \ No newline at end of file diff --git a/src/project/util/git.ts b/src/project/util/git.ts new file mode 100644 index 0000000..95c83b5 --- /dev/null +++ b/src/project/util/git.ts @@ -0,0 +1,16 @@ +import { execSync } from 'node:child_process' + +export const getGitPathname = (repoPath: string): string | null => { + try { + const url = execSync('git config --get remote.origin.url', { cwd: repoPath, encoding: 'utf-8' }).trim(); + if (url) { + const _url = new URL(url.replace(/\.git$/, '')); + const pathname = _url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); // 去除开头和结尾的斜杠 + return pathname; + } + return null; + } catch (err) { + console.error(`Failed to get remote URL for repo at ${repoPath}:`, err); + return null; + } +}; \ No newline at end of file diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..4181a47 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,17 @@ +import { app } from '../app.ts'; + +app.route({ + path: 'auth', + key: 'auth', + id: 'auth', + description: '用户身份验证中间件,校验请求令牌的合法性,验证通过后方可访问受保护接口', +}).define(async (ctx) => { }).addTo(app, { overwrite: false }); + +app.route({ + path: 'auth-admin', + key: 'auth-admin', + id: 'auth-admin', + description: '管理员权限验证中间件,在 auth 基础上进一步校验请求方是否具有管理员权限', +}).define(async (ctx) => { + +}).addTo(app, { overwrite: false }); \ No newline at end of file diff --git a/src/routes/file.ts b/src/routes/file.ts new file mode 100644 index 0000000..74686e1 --- /dev/null +++ b/src/routes/file.ts @@ -0,0 +1,81 @@ +import { app, manager } from '../app.ts'; +import { z } from 'zod' + +app.route({ + path: 'project-file', + key: 'get', + middleware: ['auth-admin'], + description: '读取指定路径的项目文件内容,以 base64 格式返回,适用于前端预览或下载场景', + metadata: { + args: { + filepath: z.string().nonempty().describe('要读取的文件绝对路径,必填'), + } + } +}).define(async (ctx) => { + const { filepath } = ctx.query as { filepath: string }; + if (!filepath) ctx.throw(400, 'filepath 不能为空'); + + try { + const file = await manager.getFile(filepath); + ctx.body = { file }; + } catch (error) { + ctx.throw(500, '读取文件失败'); + } +}).addTo(app); + +/** + * 更新文件自定义信息(title/tags/summary/description/link) + */ +app.route({ + path: 'project-file', + key: 'update', + middleware: ['auth-admin'], + description: '更新指定文件的自定义描述信息(title / tags / summary / description / link),用于人工标注和补充文件元数据', + metadata: { + args: { + filepath: z.string().nonempty().describe('要更新的文件绝对路径,必填'), + 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('与文件关联的外部链接(如文档、Issue 等),选填'), + } + } +}).define(async (ctx) => { + const { filepath, title, tags, summary, description, link } = ctx.query as { + filepath: string; + title?: string; + tags?: string[]; + summary?: string; + description?: string; + link?: string; + }; + if (!filepath) ctx.throw(400, 'filepath 不能为空'); + await manager.projectSearch.updateDocument(filepath, { title, tags, summary, description, link }); + ctx.body = { success: true }; +}).addTo(app); + +app.route({ + path: 'project-file', + key: 'delete', + middleware: ['auth-admin'], + description: '从搜索索引中删除指定路径的文件记录,文件本身不受影响', + metadata: { + args: { + filepath: z.string().nonempty().describe('要删除的文件绝对路径,必填'), + } + } +}).define(async (ctx) => { + const { filepath } = ctx.query as { filepath: string }; + if (!filepath) ctx.throw(400, 'filepath 不能为空'); + + try { + const success = await manager.deleteFile(filepath); + if (!success) { + ctx.throw(404, '文件不存在'); + } + ctx.body = { success: true }; + } catch (error) { + ctx.throw(500, '删除文件失败'); + } +}).addTo(app); \ No newline at end of file diff --git a/src/routes/project.ts b/src/routes/project.ts new file mode 100644 index 0000000..919bd90 --- /dev/null +++ b/src/routes/project.ts @@ -0,0 +1,162 @@ +import { z } from 'zod' +import { getGitPathname } from '../project/util/git'; +import { app, manager } from '../app.ts'; + +/** + * 添加项目:启动监听 + 全量初始同步 + * query: { path, repo, name? } + */ +app + .route({ + path: 'project', + key: 'add', + description: '注册并启动项目监控:开始监听项目目录的文件变更,并将所有文件全量同步到 Meilisearch 搜索索引', + middleware: ['auth-admin'], + metadata: { + args: { + filepath: z.string().describe('项目根目录的绝对路径,必填'), + repo: z.string().optional().describe('代码仓库标识,用于搜索结果展示和过滤,格式如 owner/repo,例如 kevisual/cnb,选填(默认自动从 git 配置读取)'), + name: z.string().optional().describe('项目显示名称,用于搜索结果展示,选填(默认使用目录名)'), + } + } + }) + .define(async (ctx) => { + let { filepath, repo, name } = ctx.query as { filepath: string; repo?: string; name?: string }; + if (!filepath) ctx.throw(400, 'filepath 不能为空'); + if (!repo) { + const gitPathname = getGitPathname(filepath); + if (gitPathname) { + repo = gitPathname; + } else { + const pathParts = filepath.split('/'); + repo = pathParts[pathParts.length - 1]; + } + } + const info = await manager.addProject({ path: filepath, repo, name }); + ctx.body = { success: true, data: info }; + }) + .addTo(app); + +app + .route({ + path: 'project', + key: 'remove', + description: '移除项目:停止文件变更监听,并清除该项目在 Meilisearch 索引中的所有数据', + middleware: ['auth-admin'], + metadata: { + args: { + filepath: z.string().nonempty().describe('要移除的项目根目录绝对路径,必填'), + } + } + }) + .define(async (ctx) => { + const { filepath } = ctx.query as { filepath: string }; + if (!filepath) ctx.throw(400, 'filepath 不能为空'); + await manager.removeProject(filepath); + ctx.body = { success: true }; + }) + .addTo(app); + +/** + * 停止项目文件监听 + * query: { filepath } + */ +app + .route({ + path: 'project', + key: 'stop', + description: '暂停项目的文件变更监听,暂停后文件修改将不再实时同步;可通过 add 接口重新启动监听', + middleware: ['auth-admin'], + metadata: { + args: { + filepath: z.string().nonempty().describe('要暂停监听的项目根目录绝对路径,必填'), + } + } + }) + .define(async (ctx) => { + const { filepath } = ctx.query as { filepath: string }; + if (!filepath) ctx.throw(400, 'filepath 不能为空'); + await manager.stopProject(filepath); + ctx.body = { success: true }; + }) + .addTo(app); +/** + * 获取单个项目信息 + * query: { filepath } + */ +app + .route({ + path: 'project', + key: 'get', + description: '获取指定项目的详细信息,包括路径、仓库名称、项目名称及当前监听状态等', + middleware: ['auth-admin'], + metadata: { + args: { + filepath: z.string().nonempty().describe('要查询的项目根目录绝对路径,必填'), + } + } + }) + .define(async (ctx) => { + const { filepath } = ctx.query as { filepath: string }; + if (!filepath) ctx.throw(400, 'filepath 不能为空'); + const project = manager.getProject(filepath); + if (!project) { + ctx.throw(404, '项目不存在'); + return; + } + ctx.body = { success: true, data: manager.getProjectInfo(project) }; + }) + .addTo(app); + +/** + * 列出所有项目 + */ +app + .route({ + path: 'project', + key: 'list', + description: '列出所有已注册的项目及其当前运行状态(路径、仓库名称、监听是否活跃等)', + middleware: ['auth-admin'], + }) + .define(async (ctx) => { + ctx.body = { list: manager.listProjects() }; + }) + .addTo(app); + +/** + * 更新项目自定义信息(name/title/tags/summary/description/link) + * query: { filepath, name?, title?, tags?, summary?, description?, link? } + */ +app + .route({ + path: 'project', + key: 'update', + description: '更新项目的自定义描述信息(name / title / tags / summary / description / link),用于丰富项目元数据以便更好地展示和检索', + middleware: ['auth-admin'], + metadata: { + args: { + filepath: z.string().nonempty().describe('要更新的项目根目录绝对路径,必填'), + name: z.string().optional().describe('项目显示名称,选填'), + 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('项目关联的外部链接(如官网、文档等),选填'), + } + } + }) + .define(async (ctx) => { + const { filepath, name, title, tags, summary, description, link } = ctx.query as { + filepath: string; + name?: string; + title?: string; + tags?: string[]; + summary?: string; + description?: string; + link?: string; + }; + if (!filepath) ctx.throw(400, 'filepath 不能为空'); + await manager.projectStore.updateProject(filepath, { name, title, tags, summary, description, link }); + ctx.body = { success: true }; + }) + .addTo(app); diff --git a/src/routes/search.ts b/src/routes/search.ts new file mode 100644 index 0000000..5a13312 --- /dev/null +++ b/src/routes/search.ts @@ -0,0 +1,39 @@ +import { app, manager } from '../app.ts'; +import { z } from 'zod' +/** + * 搜索文件 + * query: { q, projectPath?, repo? } + */ +app + .route({ + path: 'project-search', + key: 'files', + description: '在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制', + middleware: ['auth-admin'], + metadata: { + args: { + q: z.string().optional().describe('搜索关键词,选填;留空或不传则返回全部文件'), + projectPath: 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 编码返回,适用于前端预览或下载场景'), + } + } + }) + .define(async (ctx) => { + let { q, projectPath, repo, title, tags, summary, description, link, sort, limit, getContent = false } = ctx.query as { q?: string; projectPath?: string; repo?: string; title?: string; tags?: string[]; summary?: string; description?: string; link?: string; sort?: string[]; limit?: number; getContent?: boolean }; + if (!q) { + sort = sort ?? ['projectPath:asc']; + limit = limit ?? 1000; + } + const projectSearch = manager.projectSearch; + const hits = await projectSearch.searchFiles(q, { projectPath, repo, title, tags, summary, description, link, sort, limit, getContent }); + ctx.body = { list: hits }; + }) + .addTo(app); diff --git a/src/scheduler/index.ts b/src/scheduler/index.ts new file mode 100644 index 0000000..6f3d0f6 --- /dev/null +++ b/src/scheduler/index.ts @@ -0,0 +1,56 @@ +/** + * 任务调度器 + * - FIFO 先进先出队列 + * - 单线程执行(一次只执行一个任务) + * - 去重:同一个 key 的任务如果已在队列中等待(未执行),则不重复添加 + */ +type Task = { + key: string; + execute: () => Promise; +}; + +export class Scheduler { + private queue: Task[] = []; + private running = false; + /** 当前在队列中等待执行的任务 key 集合(不含正在执行的) */ + private pendingKeys = new Set(); + + /** + * 添加任务 + * @param key 任务唯一标识(如文件路径) + * @param execute 任务执行函数 + * @returns true 表示已加入队列;false 表示该 key 已存在,跳过 + */ + add(key: string, execute: () => Promise): boolean { + if (this.pendingKeys.has(key)) { + // 同一任务已在队列中,跳过 + return false; + } + this.pendingKeys.add(key); + this.queue.push({ key, execute }); + // 触发执行(如果没有在跑的话) + this._run(); + return true; + } + + private async _run() { + if (this.running) return; + this.running = true; + while (this.queue.length > 0) { + const task = this.queue.shift()!; + // 任务开始执行前,从 pendingKeys 移除,允许后续同 key 任务重新入队 + this.pendingKeys.delete(task.key); + try { + await task.execute(); + } catch (err) { + console.error(`[Scheduler] Task "${task.key}" failed:`, err); + } + } + this.running = false; + } + + /** 队列中等待执行的任务数量(不含正在执行的) */ + get pendingCount(): number { + return this.queue.length; + } +} diff --git a/test/common.ts b/test/common.ts new file mode 100644 index 0000000..c5c4637 --- /dev/null +++ b/test/common.ts @@ -0,0 +1,11 @@ +import path from 'node:path'; +export const projectPath = path.join('/workspace/projects/project-search'); + +import { ProjectManager } from '../src/index'; +import { useKey } from '@kevisual/context'; + +export const manager = new ProjectManager({ + meiliSearchOptions: { + apiKey: useKey('CNB_TOKEN') + } +}); diff --git a/test/file.ts b/test/file.ts new file mode 100644 index 0000000..5056d0d --- /dev/null +++ b/test/file.ts @@ -0,0 +1,11 @@ +import path from "node:path"; +import { FileSearch } from "../src/file-search"; +import { projectPath } from "./common"; +const fileSearch = new FileSearch(); + +async function testSearchFiles() { + const files = await fileSearch.searchFiles({ cwd: projectPath }); + console.log('Found files:', files); +} + +testSearchFiles().catch(console.error); \ No newline at end of file diff --git a/test/remote.ts b/test/remote.ts new file mode 100644 index 0000000..6738268 --- /dev/null +++ b/test/remote.ts @@ -0,0 +1,19 @@ +import { app, manager } from '../src/index' +import { RemoteApp } from '@kevisual/remote-app' + +app.createRouteList() +manager.init().then(() => { + console.log('ProjectManager initialized successfully'); +}).catch((error) => { + console.error('Failed to initialize ProjectManager:', error); +}); +const remote = new RemoteApp({ + app, + id: 'project-search', + username: 'root' +}); +const isConnect = await remote.isConnect(); +if (isConnect) { + console.log('Remote app connected successfully', isConnect); + remote.listenProxy(); +} \ No newline at end of file diff --git a/test/search.ts b/test/search.ts new file mode 100644 index 0000000..dafcffb --- /dev/null +++ b/test/search.ts @@ -0,0 +1,7 @@ +import { manager, projectPath } from "./common"; + +manager.projectSearch.searchFiles('routes', { projectPath }).then(result => { + console.log('Search results:', result); +}).catch(err => { + console.error('Search error:', err); +}); \ No newline at end of file diff --git a/test/start.ts b/test/start.ts new file mode 100644 index 0000000..02c3934 --- /dev/null +++ b/test/start.ts @@ -0,0 +1,7 @@ +import { manager, projectPath } from "./common"; + +manager.addProject({ + name: 'Test Project', + path: projectPath, + repo: 'kevisual/test-repo', +}) \ No newline at end of file diff --git a/test/wather.ts b/test/wather.ts new file mode 100644 index 0000000..9442097 --- /dev/null +++ b/test/wather.ts @@ -0,0 +1,41 @@ +import { subscribe } from '@parcel/watcher'; +import { debounce } from 'es-toolkit'; +import fs from 'node:fs'; +import { projectPath } from "./common"; + +// 每个路径独立的防抖处理函数 +const debouncedHandlers = new Map>(); + +const subscription = await subscribe( + projectPath, // 监听路径(支持数组) + async (err, events) => { + if (err) throw err; + for (const event of events) { + + + // 如果已有防抖函数,先取消 + if (debouncedHandlers.has(event.path)) { + debouncedHandlers.get(event.path)?.cancel(); + } + + // 为每个路径创建独立的防抖处理 + const handler = debounce(() => { + console.log(event.type, event.path, new Date()); // 'create', 'update', 'delete'... + if (!fs.statSync(event.path).isFile()) return; + // 跳过非文件事件 + // event: { type: 'update', path: '/path/to/file.ts', file: true } + debouncedHandlers.delete(event.path); + }, 300); + + debouncedHandlers.set(event.path, handler); + handler(); + } + }, + { + ignore: ['**/node_modules/**', '**/.git/**'], + backend: 'watchman', // 可选:'fs-events'(默认)、'inotify'、'windows'、'watchman' + } +); + +// 后续可取消监听 +// await subscription.unsubscribe(); \ No newline at end of file