import { NextRequest, NextResponse } from "next/server"; import { execFile } from "child_process"; import { promisify } from "util"; import fs from "fs"; import path from "path"; import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath, } from "@/lib/file-access"; import { buildEntriesFromFiles, filterFileEntries, type FileIndexEntry } from "@/lib/file-fuzzy"; const execFileAsync = promisify(execFile); // Same skip lists as /api/files — only used for the non-git readdir fallback. // Git-tracked repos rely on .gitignore instead (matches the TUI's fd behavior). const IGNORED_NAMES = new Set([ "node_modules", ".git", ".next", "dist", "build", "__pycache__", ".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache", "target", "vendor", ".DS_Store", ]); const IGNORED_SUFFIXES = [".pyc"]; /** Cap on the plain (no-query) response used as the client-side index */ const MAX_FILES = 5000; /** Hard caps on the full in-memory listing that ?q= searches against */ const GIT_HARD_CAP = 200_000; const WALK_HARD_CAP = 50_000; const MAX_WALK_DEPTH = 8; const MAX_QUERY_LENGTH = 500; const CACHE_TTL_MS = 10_000; const CACHE_MAX_ENTRIES = 20; interface FileListing { /** Full listing up to the hard cap (not the client cap) */ files: string[]; /** True when even the hard cap was exceeded */ hardTruncated: boolean; } interface CacheEntry { listing: FileListing; /** Derived lazily on the first ?q= search against this listing */ entries?: FileIndexEntry[]; expiresAt: number; } // Per-cwd cache on globalThis so it survives Next.js hot-reload; the @ menu // re-requests on every open and searches on every keystroke, so listings must // not be recomputed within a short window. declare global { var __piFileIndexCache: Map | undefined; } function getIndexCache(): Map { if (!globalThis.__piFileIndexCache) globalThis.__piFileIndexCache = new Map(); return globalThis.__piFileIndexCache; } async function listWithGit(cwd: string): Promise { try { const { stdout } = await execFileAsync( "git", ["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }, ); const all = stdout.split("\0").filter(Boolean); if (all.length > GIT_HARD_CAP) { return { files: all.slice(0, GIT_HARD_CAP), hardTruncated: true }; } return { files: all, hardTruncated: false }; } catch { // Not a git repo (or git unavailable) — caller falls back to readdir walk. return null; } } function listWithWalk(cwd: string): FileListing { const files: string[] = []; // BFS so shallow files win when the cap truncates the listing. const queue: Array<{ abs: string; rel: string; depth: number }> = [{ abs: cwd, rel: "", depth: 0 }]; while (queue.length > 0) { const { abs, rel, depth } = queue.shift()!; let dirents: fs.Dirent[]; try { dirents = fs.readdirSync(abs, { withFileTypes: true }); } catch { continue; } for (const d of dirents) { if (IGNORED_NAMES.has(d.name) || IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) continue; const childRel = rel ? `${rel}/${d.name}` : d.name; if (d.isDirectory()) { if (depth + 1 <= MAX_WALK_DEPTH) { queue.push({ abs: path.join(abs, d.name), rel: childRel, depth: depth + 1 }); } } else if (d.isFile()) { if (files.length >= WALK_HARD_CAP) { return { files, hardTruncated: true }; } files.push(childRel); } } } return { files, hardTruncated: false }; } // GET /api/file-index?cwd=/abs/path[&q=query] // Without q: { files: string[] (relative to cwd, capped at MAX_FILES), // truncated: boolean } — the client-side index for local filtering. // With q: { matches: { path, isDir }[] } — ranked against the FULL listing so // repos larger than MAX_FILES still find deep files (cap applied after // matching, like the TUI passing the query to fd). // Guarded by the same allow-list as /api/files. export async function GET(req: NextRequest) { try { const cwd = req.nextUrl.searchParams.get("cwd")?.trim() ?? ""; if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) { return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 }); } const query = req.nextUrl.searchParams.get("q")?.slice(0, MAX_QUERY_LENGTH) ?? ""; const allowedRoots = await getAllowedFileRoots(); if (!isFilePathAllowed(cwd, allowedRoots)) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } let stat: fs.Stats; try { stat = fs.statSync(cwd); } catch { return NextResponse.json({ error: "Directory not found" }, { status: 404 }); } if (!stat.isDirectory()) { return NextResponse.json({ error: "Not a directory" }, { status: 400 }); } if (!isExistingFilePathAllowed(cwd, allowedRoots)) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } const cache = getIndexCache(); const now = Date.now(); let cached = cache.get(cwd); if (!cached || cached.expiresAt <= now) { const listing = (await listWithGit(cwd)) ?? listWithWalk(cwd); for (const [key, entry] of cache) { if (entry.expiresAt <= now) cache.delete(key); } if (cache.size >= CACHE_MAX_ENTRIES) cache.clear(); cached = { listing, expiresAt: now + CACHE_TTL_MS }; cache.set(cwd, cached); } if (query) { cached.entries ??= buildEntriesFromFiles(cached.listing.files); return NextResponse.json({ matches: filterFileEntries(cached.entries, query) }); } const { files, hardTruncated } = cached.listing; return NextResponse.json({ files: files.slice(0, MAX_FILES), truncated: hardTruncated || files.length > MAX_FILES, }); } catch (error) { return NextResponse.json({ error: String(error) }, { status: 500 }); } }