import { existsSync, readdirSync, statSync } from "node:fs"; import { basename, join, relative } from "node:path"; import { resolveWorkspacePath, workspacePathBlockReason } from "../platform/path.ts"; const IGNORED_DIRS = new Set([".git", ".idea", ".pi", ".tmp", ".vscode", "bin", "build", "dist", "node_modules", "obj", "out", "target"]); const DEFAULT_MAX_RESULTS = 50; const MAX_ENTRIES = 50000; export interface FileSearchResult { path: string; type: "file" | "directory"; size?: number; } export function findFiles(cwd: string, input: { root?: string; name: string; maxResults?: number }): FileSearchResult[] { const blockReason = workspacePathBlockReason(cwd, input.root || ".", "搜索"); if (blockReason) throw new Error(blockReason); const root = resolveWorkspacePath(cwd, input.root || "."); const pattern = input.name.trim(); if (!pattern) throw new Error("kd_find_file requires a non-empty name."); if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`Search root is not a directory: ${root}`); const maxResults = clampLimit(input.maxResults); const matcher = wildcardMatcher(pattern); const results: FileSearchResult[] = []; const queue = [root]; let visited = 0; while (queue.length > 0 && results.length < maxResults && visited < MAX_ENTRIES) { const current = queue.shift(); if (!current) break; let children; try { children = readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const child of children) { if (results.length >= maxResults || visited >= MAX_ENTRIES) break; const fullPath = join(current, child.name); visited++; if (child.isDirectory()) { if (!IGNORED_DIRS.has(child.name)) queue.push(fullPath); if (matcher(child.name)) results.push({ path: displayPath(cwd, fullPath), type: "directory" }); continue; } if (child.isFile() && matcher(child.name)) { let size: number | undefined; try { size = statSync(fullPath).size; } catch { size = undefined; } results.push({ path: displayPath(cwd, fullPath), type: "file", size }); } } } return results; } export function listDirectory(cwd: string, input: { path?: string; maxEntries?: number }): FileSearchResult[] { const blockReason = workspacePathBlockReason(cwd, input.path || ".", "列出"); if (blockReason) throw new Error(blockReason); const dir = resolveWorkspacePath(cwd, input.path || "."); if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`Path is not a directory: ${dir}`); const maxEntries = clampLimit(input.maxEntries); return readdirSync(dir, { withFileTypes: true }) .slice(0, maxEntries) .map((entry) => { const fullPath = join(dir, entry.name); if (entry.isDirectory()) return { path: displayPath(cwd, fullPath), type: "directory" as const }; let size: number | undefined; try { size = statSync(fullPath).size; } catch { size = undefined; } return { path: displayPath(cwd, fullPath), type: "file" as const, size }; }); } export function formatFileSearchResults(results: FileSearchResult[]): string { if (results.length === 0) return "未找到匹配项。"; return results.map((item) => `- ${item.type === "directory" ? "[dir]" : "[file]"} ${item.path}${item.size === undefined ? "" : ` (${item.size} bytes)`}`).join("\n"); } function clampLimit(value: number | undefined): number { return Math.max(1, Math.min(Math.trunc(value ?? DEFAULT_MAX_RESULTS), 500)); } function displayPath(cwd: string, fullPath: string): string { const rel = relative(cwd, fullPath); if (rel && !rel.startsWith("..") && !/^[a-zA-Z]:/.test(rel)) return rel.replace(/\//g, "\\"); return fullPath; } function wildcardMatcher(pattern: string): (name: string) => boolean { const normalized = pattern.trim(); if (!/[*?]/.test(normalized)) { const target = normalized.toLowerCase(); return (name) => basename(name).toLowerCase() === target; } const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, "."); const regex = new RegExp(`^${escaped}$`, "i"); return (name) => regex.test(basename(name)); }