use crate::schema::json_schema_for; use anyhow::{Result, anyhow}; use assistant_tool::{ActionLog, Tool, ToolResult}; use gpui::{AnyWindowHandle, App, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use project::{Project, WorktreeSettings}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::{fmt::Write, path::Path, sync::Arc}; use ui::IconName; use util::markdown::MarkdownInlineCode; #[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. /// /// /// If the project has the following root directories: /// /// - directory1 /// - directory2 /// /// You can list the contents of `directory1` by using the path `directory1`. /// /// /// /// 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`. /// pub path: String, } pub struct ListDirectoryTool; impl Tool for ListDirectoryTool { fn name(&self) -> String { "list_directory".into() } fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { false } fn may_perform_edits(&self) -> bool { false } fn description(&self) -> String { include_str!("./list_directory_tool/description.md").into() } fn icon(&self) -> IconName { IconName::Folder } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { json_schema_for::(format) } fn ui_text(&self, input: &serde_json::Value) -> String { match serde_json::from_value::(input.clone()) { Ok(input) => { let path = MarkdownInlineCode(&input.path); format!("List the {path} directory's contents") } Err(_) => "List directory".to_string(), } } fn run( self: Arc, input: serde_json::Value, _request: Arc, project: Entity, _action_log: Entity, _model: Arc, _window: Option, cx: &mut App, ) -> ToolResult { let input = match serde_json::from_value::(input) { Ok(input) => input, Err(err) => return Task::ready(Err(anyhow!(err))).into(), }; // 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(cx) .worktrees(cx) .filter_map(|worktree| { worktree.read(cx).root_entry().and_then(|entry| { if entry.is_dir() { entry.path.to_str() } else { None } }) }) .collect::>() .join("\n"); return Task::ready(Ok(output.into())).into(); } let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else { return Task::ready(Err(anyhow!("Path {} not found in project", input.path))).into(); }; let Some(worktree) = project .read(cx) .worktree_for_id(project_path.worktree_id, cx) else { return Task::ready(Err(anyhow!("Worktree not found"))).into(); }; // Check if the directory whose contents we're listing is itself excluded or private let global_settings = WorktreeSettings::get_global(cx); if global_settings.is_path_excluded(&project_path.path) { return Task::ready(Err(anyhow!( "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}", &input.path ))) .into(); } if global_settings.is_path_private(&project_path.path) { return Task::ready(Err(anyhow!( "Cannot list directory because its path matches the user's global `private_files` setting: {}", &input.path ))) .into(); } let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); if worktree_settings.is_path_excluded(&project_path.path) { return Task::ready(Err(anyhow!( "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}", &input.path ))) .into(); } if worktree_settings.is_path_private(&project_path.path) { return Task::ready(Err(anyhow!( "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}", &input.path ))) .into(); } let worktree_snapshot = worktree.read(cx).snapshot(); let worktree_root_name = worktree.read(cx).root_name().to_string(); let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into(); }; if !entry.is_dir() { return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into(); } let worktree_snapshot = worktree.read(cx).snapshot(); 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; } if project .read(cx) .find_project_path(&entry.path, cx) .map(|project_path| { let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); worktree_settings.is_path_excluded(&project_path.path) || worktree_settings.is_path_private(&project_path.path) }) .unwrap_or(false) { continue; } let full_path = Path::new(&worktree_root_name) .join(&entry.path) .display() .to_string(); 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, "{} is empty.", input.path).unwrap(); } Task::ready(Ok(output.into())).into() } } #[cfg(test)] mod tests { use super::*; use assistant_tool::Tool; use gpui::{AppContext, TestAppContext, UpdateGlobal}; use indoc::indoc; use language_model::fake_provider::FakeLanguageModel; use project::{FakeFs, Project, WorktreeSettings}; use serde_json::json; use settings::SettingsStore; 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); language::init(cx); Project::init_settings(cx); }); } #[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 action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let tool = Arc::new(ListDirectoryTool); // Test listing root directory let input = json!({ "path": "project" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await .unwrap(); let content = result.content.as_str().unwrap(); assert_eq!( content, platform_paths(indoc! {" # Folders: project/src project/tests # Files: project/Cargo.toml project/README.md "}) ); // Test listing src directory let input = json!({ "path": "project/src" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await .unwrap(); let content = result.content.as_str().unwrap(); assert_eq!( content, 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 = json!({ "path": "project/tests" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await .unwrap(); let content = result.content.as_str().unwrap(); assert!(!content.contains("# Folders:")); assert!(content.contains("# Files:")); assert!(content.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 action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let tool = Arc::new(ListDirectoryTool); let input = json!({ "path": "project/empty_dir" }); let result = cx .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx)) .output .await .unwrap(); let content = result.content.as_str().unwrap(); assert_eq!(content, "project/empty_dir is empty.\n"); } #[gpui::test] async fn test_list_directory_error_cases(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ "file.txt": "content" }), ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let tool = Arc::new(ListDirectoryTool); // Test non-existent path let input = json!({ "path": "project/nonexistent" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Path not found")); // Test trying to list a file instead of directory let input = json!({ "path": "project/file.txt" }); let result = cx .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx)) .output .await; assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("is not a directory") ); } #[gpui::test] async fn test_list_directory_security(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ "normal_dir": { "file1.txt": "content", "file2.txt": "content" }, ".mysecrets": "SECRET_KEY=abc123", ".secretdir": { "config": "special configuration", "secret.txt": "secret content" }, ".mymetadata": "custom metadata", "visible_dir": { "normal.txt": "normal content", "special.privatekey": "private key content", "data.mysensitive": "sensitive data", ".hidden_subdir": { "hidden_file.txt": "hidden content" } } }), ) .await; // Configure settings explicitly cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings::(cx, |settings| { settings.file_scan_exclusions = Some(vec![ "**/.secretdir".to_string(), "**/.mymetadata".to_string(), "**/.hidden_subdir".to_string(), ]); settings.private_files = Some(vec![ "**/.mysecrets".to_string(), "**/*.privatekey".to_string(), "**/*.mysensitive".to_string(), ]); }); }); }); let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let tool = Arc::new(ListDirectoryTool); // Listing root directory should exclude private and excluded files let input = json!({ "path": "project" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await .unwrap(); let content = result.content.as_str().unwrap(); // Should include normal directories assert!(content.contains("normal_dir"), "Should list normal_dir"); assert!(content.contains("visible_dir"), "Should list visible_dir"); // Should NOT include excluded or private files assert!( !content.contains(".secretdir"), "Should not list .secretdir (file_scan_exclusions)" ); assert!( !content.contains(".mymetadata"), "Should not list .mymetadata (file_scan_exclusions)" ); assert!( !content.contains(".mysecrets"), "Should not list .mysecrets (private_files)" ); // Trying to list an excluded directory should fail let input = json!({ "path": "project/.secretdir" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await; assert!( result.is_err(), "Should not be able to list excluded directory" ); assert!( result .unwrap_err() .to_string() .contains("file_scan_exclusions"), "Error should mention file_scan_exclusions" ); // Listing a directory should exclude private files within it let input = json!({ "path": "project/visible_dir" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await .unwrap(); let content = result.content.as_str().unwrap(); // Should include normal files assert!(content.contains("normal.txt"), "Should list normal.txt"); // Should NOT include private files assert!( !content.contains("privatekey"), "Should not list .privatekey files (private_files)" ); assert!( !content.contains("mysensitive"), "Should not list .mysensitive files (private_files)" ); // Should NOT include subdirectories that match exclusions assert!( !content.contains(".hidden_subdir"), "Should not list .hidden_subdir (file_scan_exclusions)" ); } #[gpui::test] async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); // Create first worktree with its own private files fs.insert_tree( path!("/worktree1"), json!({ ".zed": { "settings.json": r#"{ "file_scan_exclusions": ["**/fixture.*"], "private_files": ["**/secret.rs", "**/config.toml"] }"# }, "src": { "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", "secret.rs": "const API_KEY: &str = \"secret_key_1\";", "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" }, "tests": { "test.rs": "mod tests { fn test_it() {} }", "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" } }), ) .await; // Create second worktree with different private files fs.insert_tree( path!("/worktree2"), json!({ ".zed": { "settings.json": r#"{ "file_scan_exclusions": ["**/internal.*"], "private_files": ["**/private.js", "**/data.json"] }"# }, "lib": { "public.js": "export function greet() { return 'Hello from worktree2'; }", "private.js": "const SECRET_TOKEN = \"private_token_2\";", "data.json": "{\"api_key\": \"json_secret_key\"}" }, "docs": { "README.md": "# Public Documentation", "internal.md": "# Internal Secrets and Configuration" } }), ) .await; // Set global settings cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings::(cx, |settings| { settings.file_scan_exclusions = Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); settings.private_files = Some(vec!["**/.env".to_string()]); }); }); }); let project = Project::test( fs.clone(), [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], cx, ) .await; // Wait for worktrees to be fully scanned cx.executor().run_until_parked(); let action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); let tool = Arc::new(ListDirectoryTool); // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings let input = json!({ "path": "worktree1/src" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await .unwrap(); let content = result.content.as_str().unwrap(); assert!(content.contains("main.rs"), "Should list main.rs"); assert!( !content.contains("secret.rs"), "Should not list secret.rs (local private_files)" ); assert!( !content.contains("config.toml"), "Should not list config.toml (local private_files)" ); // Test listing worktree1/tests - should exclude fixture.sql based on local settings let input = json!({ "path": "worktree1/tests" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await .unwrap(); let content = result.content.as_str().unwrap(); assert!(content.contains("test.rs"), "Should list test.rs"); assert!( !content.contains("fixture.sql"), "Should not list fixture.sql (local file_scan_exclusions)" ); // Test listing worktree2/lib - should exclude private.js and data.json based on local settings let input = json!({ "path": "worktree2/lib" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await .unwrap(); let content = result.content.as_str().unwrap(); assert!(content.contains("public.js"), "Should list public.js"); assert!( !content.contains("private.js"), "Should not list private.js (local private_files)" ); assert!( !content.contains("data.json"), "Should not list data.json (local private_files)" ); // Test listing worktree2/docs - should exclude internal.md based on local settings let input = json!({ "path": "worktree2/docs" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await .unwrap(); let content = result.content.as_str().unwrap(); assert!(content.contains("README.md"), "Should list README.md"); assert!( !content.contains("internal.md"), "Should not list internal.md (local file_scan_exclusions)" ); // Test trying to list an excluded directory directly let input = json!({ "path": "worktree1/src/secret.rs" }); let result = cx .update(|cx| { tool.clone().run( input, Arc::default(), project.clone(), action_log.clone(), model.clone(), None, cx, ) }) .output .await; // This should fail because we're trying to list a file, not a directory assert!(result.is_err(), "Should fail when trying to list a file"); } }