1253 lines · 40.4 KB · Rust
RawDownload
use super::tool_permissions::{    ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,    resolve_global_skill_path, resolve_project_path,};use crate::{AgentTool, ToolCallEventStream, ToolInput};use agent_client_protocol::schema::v1 as acp;use anyhow::{Context as _, Result, anyhow};use fs::Fs;use futures::StreamExt as _;use gpui::{App, Entity, SharedString, Task};use project::{Project, ProjectPath, WorktreeSettings};use schemars::JsonSchema;use serde::{Deserialize, Serialize};use settings::Settings;use std::fmt::Write;use std::path::Path;use std::sync::Arc;use util::markdown::MarkdownInlineCode; /// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase.////// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.#[derive(Debug, Serialize, Deserialize, JsonSchema)]pub struct ListDirectoryToolInput {    /// The fully-qualified path of the directory to list in the project.    ///    /// This path should never be absolute, and the first component of the path should always be a root directory in a project, unless it's a global agent skill directory under `~/.agents/skills`.    ///    /// <example>    /// If the project has the following root directories:    ///    /// - directory1    /// - directory2    ///    /// You can list the contents of `directory1` by using the path `directory1`.    /// </example>    ///    /// <example>    /// If the project has the following root directories:    ///    /// - foo    /// - bar    ///    /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.    /// </example>    ///    /// <example>    /// To list a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`.    /// </example>    pub path: String,} pub struct ListDirectoryTool {    project: Entity<Project>,} impl ListDirectoryTool {    pub fn new(project: Entity<Project>) -> Self {        Self { project }    }     /// List the contents of a directory under the global skills tree directly    /// via the filesystem. Used for skill resources that live outside any    /// worktree.    async fn list_global_skill_directory(        canonical_path: &Path,        fs: &dyn Fs,        input_path: &str,    ) -> Result<String, String> {        let mut entries = fs            .read_dir(canonical_path)            .await            .map_err(|err| err.to_string())?;         let mut folders = Vec::new();        let mut files = Vec::new();        while let Some(entry) = entries.next().await {            let Ok(entry_path) = entry else {                continue;            };            let display = entry_path.to_string_lossy().into_owned();            // Use a metadata call rather than `is_dir` so we can short-circuit            // on missing entries (e.g. dangling symlinks).            let Ok(Some(metadata)) = fs.metadata(&entry_path).await else {                continue;            };            if metadata.is_dir {                folders.push(display);            } else {                files.push(display);            }        }         folders.sort();        files.sort();         let mut output = String::new();        if !folders.is_empty() {            writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();        }        if !files.is_empty() {            writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();        }        if output.is_empty() {            writeln!(output, "{input_path} is empty.").unwrap();        }        Ok(output)    }     fn build_directory_output(        project: &Entity<Project>,        project_path: &ProjectPath,        input_path: &str,        cx: &App,    ) -> Result<String> {        let worktree = project            .read(cx)            .worktree_for_id(project_path.worktree_id, cx)            .with_context(|| format!("{input_path} is not in a known worktree"))?;         let global_settings = WorktreeSettings::get_global(cx);        let worktree_settings = WorktreeSettings::get(Some(project_path.into()), cx);        let worktree_snapshot = worktree.read(cx).snapshot();        let worktree_root_name = worktree.read(cx).root_name();         let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {            return Err(anyhow!("Path not found: {}", input_path));        };         if !entry.is_dir() {            return Err(anyhow!("{input_path} is not a directory."));        }         let mut folders = Vec::new();        let mut files = Vec::new();         for entry in worktree_snapshot.child_entries(&project_path.path) {            // Skip private and excluded files and directories            if global_settings.is_path_private(&entry.path)                || global_settings.is_path_excluded(&entry.path)            {                continue;            }             let project_path: ProjectPath = (worktree_snapshot.id(), entry.path.clone()).into();            if worktree_settings.is_path_excluded(&project_path.path)                || worktree_settings.is_path_private(&project_path.path)            {                continue;            }             let full_path = worktree_root_name                .join(&entry.path)                .display(worktree_snapshot.path_style())                .into_owned();            if entry.is_dir() {                folders.push(full_path);            } else {                files.push(full_path);            }        }         let mut output = String::new();         if !folders.is_empty() {            writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();        }         if !files.is_empty() {            writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();        }         if output.is_empty() {            writeln!(output, "{input_path} is empty.").unwrap();        }         Ok(output)    }} impl AgentTool for ListDirectoryTool {    type Input = ListDirectoryToolInput;    type Output = String;     const NAME: &'static str = "list_directory";     fn kind() -> acp::ToolKind {        acp::ToolKind::Read    }     fn initial_title(        &self,        input: Result<Self::Input, serde_json::Value>,        _cx: &mut App,    ) -> SharedString {        if let Ok(input) = input {            let path = MarkdownInlineCode(&input.path);            format!("List the {path} directory's contents").into()        } else {            "List directory".into()        }    }     fn run(        self: Arc<Self>,        input: ToolInput<Self::Input>,        event_stream: ToolCallEventStream,        cx: &mut App,    ) -> Task<Result<Self::Output, Self::Output>> {        let project = self.project.clone();        cx.spawn(async move |cx| {            let input = input                .recv()                .await                .map_err(|e| e.to_string())?;             // Sometimes models will return these even though we tell it to give a path and not a glob.            // When this happens, just list the root worktree directories.            if matches!(input.path.as_str(), "." | "" | "./" | "*") {                let output = project.read_with(cx, |project, cx| {                    project                        .worktrees(cx)                        .filter_map(|worktree| {                            let worktree = worktree.read(cx);                            let root_entry = worktree.root_entry()?;                            if root_entry.is_dir() {                                Some(root_entry.path.display(worktree.path_style()))                            } else {                                None                            }                        })                        .collect::<Vec<_>>()                        .join("\n")                });                 return Ok(output);            }             let fs = project.read_with(cx, |project, _cx| project.fs().clone());             // Fast path: a global skill resource lives outside any worktree, so            // standard project-path resolution would refuse it. If the path            // expands and resolves under the global skills tree, list it directly.            if let Some(skill_path) =                resolve_global_skill_path(Path::new(&input.path), fs.as_ref()).await            {                return Self::list_global_skill_directory(                    &skill_path,                    fs.as_ref(),                    &input.path,                )                .await;            }            let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;             let (project_path, symlink_canonical_target) =                project.read_with(cx, |project, cx| -> anyhow::Result<_> {                    let resolved = resolve_project_path(project, &input.path, &canonical_roots, cx)?;                    Ok(match resolved {                        ResolvedProjectPath::Safe(path) => (path, None),                        ResolvedProjectPath::SymlinkEscape {                            project_path,                            canonical_target,                        } => (project_path, Some(canonical_target)),                    })                }).map_err(|e| e.to_string())?;             // Check settings exclusions synchronously            project.read_with(cx, |project, cx| {                let worktree = project                    .worktree_for_id(project_path.worktree_id, cx)                    .with_context(|| {                        format!("{} is not in a known worktree", &input.path)                    })?;                 let global_settings = WorktreeSettings::get_global(cx);                if global_settings.is_path_excluded(&project_path.path) {                    anyhow::bail!(                        "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",                        &input.path                    );                }                 if global_settings.is_path_private(&project_path.path) {                    anyhow::bail!(                        "Cannot list directory because its path matches the user's global `private_files` setting: {}",                        &input.path                    );                }                 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);                if worktree_settings.is_path_excluded(&project_path.path) {                    anyhow::bail!(                        "Cannot list directory because its path matches the user's worktree `file_scan_exclusions` setting: {}",                        &input.path                    );                }                 if worktree_settings.is_path_private(&project_path.path) {                    anyhow::bail!(                        "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",                        &input.path                    );                }                 let worktree_snapshot = worktree.read(cx).snapshot();                let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {                    anyhow::bail!("Path not found: {}", input.path);                };                if !entry.is_dir() {                    anyhow::bail!("{} is not a directory.", input.path);                }                 anyhow::Ok(())            }).map_err(|e| e.to_string())?;             if let Some(canonical_target) = &symlink_canonical_target {                let authorize = cx.update(|cx| {                    authorize_symlink_access(                        Self::NAME,                        &input.path,                        canonical_target,                        &event_stream,                        cx,                    )                });                authorize.await.map_err(|e| e.to_string())?;            }             let list_path = input.path;            cx.update(|cx| {                Self::build_directory_output(&project, &project_path, &list_path, cx)            }).map_err(|e| e.to_string())        })    }} #[cfg(test)]mod tests {    use super::*;    use gpui::{TestAppContext, UpdateGlobal};    use indoc::indoc;    use project::{FakeFs, Project};    use serde_json::json;    use settings::SettingsStore;    use std::path::PathBuf;    use util::path;     fn platform_paths(path_str: &str) -> String {        if cfg!(target_os = "windows") {            path_str.replace("/", "\\")        } else {            path_str.to_string()        }    }     fn init_test(cx: &mut TestAppContext) {        cx.update(|cx| {            let settings_store = SettingsStore::test(cx);            cx.set_global(settings_store);        });    }     #[gpui::test]    async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {        init_test(cx);         let fs = FakeFs::new(cx.executor());        fs.insert_tree(            path!("/project"),            json!({                "src": {                    "main.rs": "fn main() {}",                    "lib.rs": "pub fn hello() {}",                    "models": {                        "user.rs": "struct User {}",                        "post.rs": "struct Post {}"                    },                    "utils": {                        "helper.rs": "pub fn help() {}"                    }                },                "tests": {                    "integration_test.rs": "#[test] fn test() {}"                },                "README.md": "# Project",                "Cargo.toml": "[package]"            }),        )        .await;         let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;        let tool = Arc::new(ListDirectoryTool::new(project));         // Test listing root directory        let input = ListDirectoryToolInput {            path: "project".into(),        };        let output = cx            .update(|cx| {                tool.clone().run(                    ToolInput::resolved(input),                    ToolCallEventStream::test().0,                    cx,                )            })            .await            .unwrap();        assert_eq!(            output,            platform_paths(indoc! {"                # Folders:                project/src                project/tests                 # Files:                project/Cargo.toml                project/README.md            "})        );         // Test listing src directory        let input = ListDirectoryToolInput {            path: "project/src".into(),        };        let output = cx            .update(|cx| {                tool.clone().run(                    ToolInput::resolved(input),                    ToolCallEventStream::test().0,                    cx,                )            })            .await            .unwrap();        assert_eq!(            output,            platform_paths(indoc! {"                # Folders:                project/src/models                project/src/utils                 # Files:                project/src/lib.rs                project/src/main.rs            "})        );         // Test listing directory with only files        let input = ListDirectoryToolInput {            path: "project/tests".into(),        };        let output = cx            .update(|cx| {                tool.clone().run(                    ToolInput::resolved(input),                    ToolCallEventStream::test().0,                    cx,                )            })            .await            .unwrap();        assert!(!output.contains("# Folders:"));        assert!(output.contains("# Files:"));        assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));    }     #[gpui::test]    async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {        init_test(cx);         let fs = FakeFs::new(cx.executor());        fs.insert_tree(            path!("/project"),            json!({                "empty_dir": {}            }),        )        .await;         let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;        let tool = Arc::new(ListDirectoryTool::new(project));         let input = ListDirectoryToolInput {            path: "project/empty_dir".into(),        };        let output = cx            .update(|cx| {                tool.clone().run(                    ToolInput::resolved(input),                    ToolCallEventStream::test().0,                    cx,                )            })            .await            .unwrap();        assert_eq!(output, "project/empty_dir is empty.\n");    }     #[gpui::test]

Lines 1–500 of 1253.