Diff-based file editing and file discovery tools
Project Files
src / list-files.ts
import type { Options } from "globby";
import * as os from "os";
import * as path from "path";
function normalizePath(p: string): string {
// normalize resolve ./.. segments, removes duplicate slashes, and standardizes path separators
let normalized = path.normalize(p);
// however it doesn't remove trailing slashes
// remove trailing slash, except for root paths
if (
normalized.length > 1 &&
(normalized.endsWith("/") || normalized.endsWith("\\"))
) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
// Safe path comparison that works across different platforms
export function arePathsEqual(path1?: string, path2?: string): boolean {
if (!path1 && !path2) {
return true;
}
if (!path1 || !path2) {
return false;
}
path1 = normalizePath(path1);
path2 = normalizePath(path2);
if (process.platform === "win32") {
return path1.toLowerCase() === path2.toLowerCase();
}
return path1 === path2;
}
// Constants
const DEFAULT_IGNORE_DIRECTORIES = [
"node_modules",
"__pycache__",
"env",
"venv",
"target/dependency",
"build/dependencies",
"dist",
"out",
"bundle",
"vendor",
"tmp",
"temp",
"deps",
"Pods",
];
// Helper functions
function isRestrictedPath(absolutePath: string): boolean {
const root =
process.platform === "win32" ? path.parse(absolutePath).root : "/";
const isRoot = arePathsEqual(absolutePath, root);
if (isRoot) {
return true;
}
const homeDir = os.homedir();
const isHomeDir = arePathsEqual(absolutePath, homeDir);
if (isHomeDir) {
return true;
}
return false;
}
function isTargetingHiddenDirectory(absolutePath: string): boolean {
const dirName = path.basename(absolutePath);
return dirName.startsWith(".");
}
function buildIgnorePatterns(absolutePath: string): string[] {
const isTargetHidden = isTargetingHiddenDirectory(absolutePath);
const patterns = [...DEFAULT_IGNORE_DIRECTORIES];
// Only ignore hidden directories if we're not explicitly targeting a hidden directory
if (!isTargetHidden) {
patterns.push(".*");
}
return patterns.map((dir) => `**/${dir}/**`);
}
export async function listFiles(
dirPath: string,
recursive: boolean,
limit: number
): Promise<[string[], boolean]> {
const { globby } = await import("globby");
const absolutePath = path.resolve(dirPath);
// Do not allow listing files in root or home directory
if (isRestrictedPath(absolutePath)) {
return [[], false];
}
const options: Options = {
cwd: dirPath,
dot: true, // do not ignore hidden files/directories
absolute: true,
markDirectories: true, // Append a / on any directories matched
gitignore: recursive, // globby ignores any files that are gitignored
ignore: recursive ? buildIgnorePatterns(absolutePath) : undefined,
onlyFiles: false, // include directories in results
suppressErrors: true,
};
const filePaths = recursive
? await globbyLevelByLevel(limit, options)
: (await globby("*", options)).slice(0, limit);
return [filePaths, filePaths.length >= limit];
}
/*
Breadth-first traversal of directory structure level by level up to a limit:
- Queue-based approach ensures proper breadth-first traversal
- Processes directory patterns level by level
- Captures a representative sample of the directory structure up to the limit
- Minimizes risk of missing deeply nested files
- Notes:
- Relies on globby to mark directories with /
- Potential for loops if symbolic links reference back to parent (we could use followSymlinks: false but that may not be ideal for some projects and it's pointless if they're not using symlinks wrong)
- Timeout mechanism prevents infinite loops
*/
async function globbyLevelByLevel(limit: number, options?: Options) {
const { globby } = await import("globby");
const results: Set<string> = new Set();
const queue: string[] = ["*"];
const globbingProcess = async () => {
while (queue.length > 0 && results.size < limit) {
const pattern = queue.shift()!;
const filesAtLevel = await globby(pattern, options);
for (const file of filesAtLevel) {
if (results.size >= limit) {
break;
}
results.add(file);
if (file.endsWith("/")) {
// Escape parentheses in the path to prevent glob pattern interpretation
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard)
// Without escaping, glob treats parentheses as special pattern grouping characters
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)");
queue.push(`${escapedFile}*`);
}
}
}
return Array.from(results).slice(0, limit);
};
// Timeout after 10 seconds and return partial results
const timeoutPromise = new Promise<string[]>((_, reject) => {
setTimeout(() => reject(new Error("Globbing timeout")), 10_000);
});
try {
return await Promise.race([globbingProcess(), timeoutPromise]);
} catch (_error) {
console.warn("Globbing timed out, returning partial results");
return Array.from(results);
}
}
export function formatFilesList(
absolutePath: string,
files: string[],
didHitLimit: boolean
): string {
const sorted = files
.map((file) => {
// convert absolute path to relative path
const relativePath = path
.relative(absolutePath, file)
.replace(/\\/g, "/");
return file.endsWith("/") ? relativePath + "/" : relativePath;
})
// Sort so files are listed under their respective directories to make it clear what files are children of what directories. Since we build file list top down, even if file list is truncated it will show directories that cline can then explore further.
.sort((a, b) => {
const aParts = a.split("/"); // only works if we use toPosix first
const bParts = b.split("/");
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
if (aParts[i] !== bParts[i]) {
// If one is a directory and the other isn't at this level, sort the directory first
if (i + 1 === aParts.length && i + 1 < bParts.length) {
return -1;
}
if (i + 1 === bParts.length && i + 1 < aParts.length) {
return 1;
}
// Otherwise, sort alphabetically
return aParts[i].localeCompare(bParts[i], undefined, {
numeric: true,
sensitivity: "base",
});
}
}
// If all parts are the same up to the length of the shorter path,
// the shorter one comes first
return aParts.length - bParts.length;
});
if (didHitLimit) {
return `${sorted.join(
"\n"
)}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)`;
} else if (sorted.length === 0 || (sorted.length === 1 && sorted[0] === "")) {
return "No files found.";
} else {
return sorted.join("\n");
}
}
src / list-files.ts
import type { Options } from "globby";
import * as os from "os";
import * as path from "path";
function normalizePath(p: string): string {
// normalize resolve ./.. segments, removes duplicate slashes, and standardizes path separators
let normalized = path.normalize(p);
// however it doesn't remove trailing slashes
// remove trailing slash, except for root paths
if (
normalized.length > 1 &&
(normalized.endsWith("/") || normalized.endsWith("\\"))
) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
// Safe path comparison that works across different platforms
export function arePathsEqual(path1?: string, path2?: string): boolean {
if (!path1 && !path2) {
return true;
}
if (!path1 || !path2) {
return false;
}
path1 = normalizePath(path1);
path2 = normalizePath(path2);
if (process.platform === "win32") {
return path1.toLowerCase() === path2.toLowerCase();
}
return path1 === path2;
}
// Constants
const DEFAULT_IGNORE_DIRECTORIES = [
"node_modules",
"__pycache__",
"env",
"venv",
"target/dependency",
"build/dependencies",
"dist",
"out",
"bundle",
"vendor",
"tmp",
"temp",
"deps",
"Pods",
];
// Helper functions
function isRestrictedPath(absolutePath: string): boolean {
const root =
process.platform === "win32" ? path.parse(absolutePath).root : "/";
const isRoot = arePathsEqual(absolutePath, root);
if (isRoot) {
return true;
}
const homeDir = os.homedir();
const isHomeDir = arePathsEqual(absolutePath, homeDir);
if (isHomeDir) {
return true;
}
return false;
}
function isTargetingHiddenDirectory(absolutePath: string): boolean {
const dirName = path.basename(absolutePath);
return dirName.startsWith(".");
}
function buildIgnorePatterns(absolutePath: string): string[] {
const isTargetHidden = isTargetingHiddenDirectory(absolutePath);
const patterns = [...DEFAULT_IGNORE_DIRECTORIES];
// Only ignore hidden directories if we're not explicitly targeting a hidden directory
if (!isTargetHidden) {
patterns.push(".*");
}
return patterns.map((dir) => `**/${dir}/**`);
}
export async function listFiles(
dirPath: string,
recursive: boolean,
limit: number
): Promise<[string[], boolean]> {
const { globby } = await import("globby");
const absolutePath = path.resolve(dirPath);
// Do not allow listing files in root or home directory
if (isRestrictedPath(absolutePath)) {
return [[], false];
}
const options: Options = {
cwd: dirPath,
dot: true, // do not ignore hidden files/directories
absolute: true,
markDirectories: true, // Append a / on any directories matched
gitignore: recursive, // globby ignores any files that are gitignored
ignore: recursive ? buildIgnorePatterns(absolutePath) : undefined,
onlyFiles: false, // include directories in results
suppressErrors: true,
};
const filePaths = recursive
? await globbyLevelByLevel(limit, options)
: (await globby("*", options)).slice(0, limit);
return [filePaths, filePaths.length >= limit];
}
/*
Breadth-first traversal of directory structure level by level up to a limit:
- Queue-based approach ensures proper breadth-first traversal
- Processes directory patterns level by level
- Captures a representative sample of the directory structure up to the limit
- Minimizes risk of missing deeply nested files
- Notes:
- Relies on globby to mark directories with /
- Potential for loops if symbolic links reference back to parent (we could use followSymlinks: false but that may not be ideal for some projects and it's pointless if they're not using symlinks wrong)
- Timeout mechanism prevents infinite loops
*/
async function globbyLevelByLevel(limit: number, options?: Options) {
const { globby } = await import("globby");
const results: Set<string> = new Set();
const queue: string[] = ["*"];
const globbingProcess = async () => {
while (queue.length > 0 && results.size < limit) {
const pattern = queue.shift()!;
const filesAtLevel = await globby(pattern, options);
for (const file of filesAtLevel) {
if (results.size >= limit) {
break;
}
results.add(file);
if (file.endsWith("/")) {
// Escape parentheses in the path to prevent glob pattern interpretation
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard)
// Without escaping, glob treats parentheses as special pattern grouping characters
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)");
queue.push(`${escapedFile}*`);
}
}
}
return Array.from(results).slice(0, limit);
};
// Timeout after 10 seconds and return partial results
const timeoutPromise = new Promise<string[]>((_, reject) => {
setTimeout(() => reject(new Error("Globbing timeout")), 10_000);
});
try {
return await Promise.race([globbingProcess(), timeoutPromise]);
} catch (_error) {
console.warn("Globbing timed out, returning partial results");
return Array.from(results);
}
}
export function formatFilesList(
absolutePath: string,
files: string[],
didHitLimit: boolean
): string {
const sorted = files
.map((file) => {
// convert absolute path to relative path
const relativePath = path
.relative(absolutePath, file)
.replace(/\\/g, "/");
return file.endsWith("/") ? relativePath + "/" : relativePath;
})
// Sort so files are listed under their respective directories to make it clear what files are children of what directories. Since we build file list top down, even if file list is truncated it will show directories that cline can then explore further.
.sort((a, b) => {
const aParts = a.split("/"); // only works if we use toPosix first
const bParts = b.split("/");
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
if (aParts[i] !== bParts[i]) {
// If one is a directory and the other isn't at this level, sort the directory first
if (i + 1 === aParts.length && i + 1 < bParts.length) {
return -1;
}
if (i + 1 === bParts.length && i + 1 < aParts.length) {
return 1;
}
// Otherwise, sort alphabetically
return aParts[i].localeCompare(bParts[i], undefined, {
numeric: true,
sensitivity: "base",
});
}
}
// If all parts are the same up to the length of the shorter path,
// the shorter one comes first
return aParts.length - bParts.length;
});
if (didHitLimit) {
return `${sorted.join(
"\n"
)}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)`;
} else if (sorted.length === 0 || (sorted.length === 1 && sorted[0] === "")) {
return "No files found.";
} else {
return sorted.join("\n");
}
}