diff --git a/Cargo.lock b/Cargo.lock index 2ce055edf4a71415e237ea6c68b4e9fc6e0a82fa..247e92d48ad12334a1670fc96a37718aab36def0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5514,6 +5514,70 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -5548,7 +5612,6 @@ dependencies = [ "ui", "util", "workspace", - "workspace-hack", "zed_actions", ] @@ -6429,6 +6492,7 @@ dependencies = [ "async-trait", "cocoa 0.26.0", "collections", + "encoding", "encodings", "fsevent", "futures 0.3.31", @@ -8806,6 +8870,7 @@ dependencies = [ "ctor", "diffy", "ec4rs", + "encoding", "encodings", "fs", "futures 0.3.31", @@ -20806,6 +20871,7 @@ dependencies = [ "async-lock 2.8.0", "clock", "collections", + "encoding", "encodings", "fs", "futures 0.3.31", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index e962c876a38f788607706aad4e53ee5e0488b08d..f1c277c3ada94b4a908e6939ab9ce2417ce166a1 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -32,6 +32,7 @@ collections.workspace = true context_server.workspace = true db.workspace = true derive_more.workspace = true +encodings.workspace = true fs.workspace = true futures.workspace = true git.workspace = true diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index e7843d3388139f14aa1686fa0016dbfc5d9fdb48..2fe299392ea6ae81dd3d10b4e872e3d7747fd3c1 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -563,7 +563,7 @@ mod tests { use super::*; use crate::{ContextServerRegistry, Templates}; use client::TelemetrySettings; - use encodings::{Encoding, UTF_8}; + use encodings::Encoding; use fs::Fs; use gpui::{TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; @@ -745,7 +745,7 @@ mod tests { path!("/root/src/main.rs").as_ref(), &Rope::from_str_small("initial content"), language::LineEnding::Unix, - Encoding::new(UTF_8), + Encoding::default(), ) .await .unwrap(); diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml deleted file mode 100644 index e481af9c834424237b9c580c664f516f604aeb03..0000000000000000000000000000000000000000 --- a/crates/agent2/Cargo.toml +++ /dev/null @@ -1,104 +0,0 @@ -[package] -name = "agent2" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lib] -path = "src/agent2.rs" - -[features] -test-support = ["db/test-support"] -e2e = [] - -[lints] -workspace = true - -[dependencies] -acp_thread.workspace = true -action_log.workspace = true -agent.workspace = true -agent-client-protocol.workspace = true -agent_servers.workspace = true -agent_settings.workspace = true -anyhow.workspace = true -assistant_context.workspace = true -assistant_tool.workspace = true -assistant_tools.workspace = true -chrono.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -collections.workspace = true -context_server.workspace = true -db.workspace = true -encodings.workspace = true -fs.workspace = true -futures.workspace = true -git.workspace = true -gpui.workspace = true -handlebars = { workspace = true, features = ["rust-embed"] } -html_to_markdown.workspace = true -http_client.workspace = true -indoc.workspace = true -itertools.workspace = true -language.workspace = true -language_model.workspace = true -language_models.workspace = true -log.workspace = true -open.workspace = true -parking_lot.workspace = true -paths.workspace = true -project.workspace = true -prompt_store.workspace = true -rust-embed.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -smol.workspace = true -sqlez.workspace = true -task.workspace = true -telemetry.workspace = true -terminal.workspace = true -thiserror.workspace = true -text.workspace = true -ui.workspace = true -util.workspace = true -uuid.workspace = true -watch.workspace = true -web_search.workspace = true -workspace-hack.workspace = true -zed_env_vars.workspace = true -zstd.workspace = true - - -[dev-dependencies] -agent = { workspace = true, "features" = ["test-support"] } -agent_servers = { workspace = true, "features" = ["test-support"] } -assistant_context = { workspace = true, "features" = ["test-support"] } -ctor.workspace = true -client = { workspace = true, "features" = ["test-support"] } -clock = { workspace = true, "features" = ["test-support"] } -context_server = { workspace = true, "features" = ["test-support"] } -db = { workspace = true, "features" = ["test-support"] } -editor = { workspace = true, "features" = ["test-support"] } -env_logger.workspace = true -fs = { workspace = true, "features" = ["test-support"] } -git = { workspace = true, "features" = ["test-support"] } -gpui = { workspace = true, "features" = ["test-support"] } -gpui_tokio.workspace = true -language = { workspace = true, "features" = ["test-support"] } -language_model = { workspace = true, "features" = ["test-support"] } -lsp = { workspace = true, "features" = ["test-support"] } -pretty_assertions.workspace = true -project = { workspace = true, "features" = ["test-support"] } -reqwest_client.workspace = true -settings = { workspace = true, "features" = ["test-support"] } -tempfile.workspace = true -terminal = { workspace = true, "features" = ["test-support"] } -theme = { workspace = true, "features" = ["test-support"] } -tree-sitter-rust.workspace = true -unindent = { workspace = true } -worktree = { workspace = true, "features" = ["test-support"] } -zlog.workspace = true diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 50ac5848f71dfd55842657a7b834db7b81e02c31..a2a701659eaae664aec14198de8f671c89ac35e6 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -463,97 +463,6 @@ impl MessageEditor { }) } - fn confirm_mention_for_directory( - &mut self, - abs_path: PathBuf, - cx: &mut Context, - ) -> Task> { - fn collect_files_in_path(worktree: &Worktree, path: &Path) -> Vec<(Arc, PathBuf)> { - let mut files = Vec::new(); - - for entry in worktree.child_entries(path) { - if entry.is_dir() { - files.extend(collect_files_in_path(worktree, &entry.path)); - } else if entry.is_file() { - files.push((entry.path.clone(), worktree.full_path(&entry.path))); - } - } - - files - } - - let Some(project_path) = self - .project - .read(cx) - .project_path_for_absolute_path(&abs_path, cx) - else { - return Task::ready(Err(anyhow!("project path not found"))); - }; - let Some(entry) = self.project.read(cx).entry_for_path(&project_path, cx) else { - return Task::ready(Err(anyhow!("project entry not found"))); - }; - let directory_path = entry.path.clone(); - let worktree_id = project_path.worktree_id; - let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) else { - return Task::ready(Err(anyhow!("worktree not found"))); - }; - let project = self.project.clone(); - cx.spawn(async move |_, cx| { - let file_paths = worktree.read_with(cx, |worktree, _cx| { - collect_files_in_path(worktree, &directory_path) - })?; - let descendants_future = cx.update(|cx| { - join_all(file_paths.into_iter().map(|(worktree_path, full_path)| { - let rel_path = worktree_path - .strip_prefix(&directory_path) - .log_err() - .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into()); - - let open_task = project.update(cx, |project, cx| { - project.buffer_store().update(cx, |buffer_store, cx| { - let project_path = ProjectPath { - worktree_id, - path: worktree_path, - }; - buffer_store.open_buffer(project_path, None, false, true, cx) - }) - }); - - cx.spawn(async move |cx| { - let buffer = open_task.await.log_err()?; - let buffer_content = outline::get_buffer_content_or_outline( - buffer.clone(), - Some(&full_path), - &cx, - ) - .await - .ok()?; - - Some((rel_path, full_path, buffer_content.text, buffer)) - }) - })) - })?; - - let contents = cx - .background_spawn(async move { - let (contents, tracked_buffers) = descendants_future - .await - .into_iter() - .flatten() - .map(|(rel_path, full_path, rope, buffer)| { - ((rel_path, full_path, rope), buffer) - }) - .unzip(); - Mention::Text { - content: render_directory_contents(contents), - tracked_buffers, - } - }) - .await; - anyhow::Ok(contents) - }) - } - fn confirm_mention_for_fetch( &mut self, url: url::Url, @@ -1339,7 +1248,7 @@ fn full_mention_for_directory( worktree_id, path: worktree_path, }; - buffer_store.open_buffer(project_path, cx) + buffer_store.open_buffer(project_path, None, false, true, cx) }) }); diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml deleted file mode 100644 index b31561305dec86bdbe829adcee5187463f454879..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/Cargo.toml +++ /dev/null @@ -1,94 +0,0 @@ -[package] -name = "assistant_tools" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/assistant_tools.rs" - -[features] -eval = [] - -[dependencies] -action_log.workspace = true -agent_settings.workspace = true -anyhow.workspace = true -assistant_tool.workspace = true -buffer_diff.workspace = true -chrono.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -collections.workspace = true -component.workspace = true -derive_more.workspace = true -diffy = "0.4.2" -editor.workspace = true -encodings.workspace = true -feature_flags.workspace = true -futures.workspace = true -gpui.workspace = true -handlebars = { workspace = true, features = ["rust-embed"] } -html_to_markdown.workspace = true -http_client.workspace = true -indoc.workspace = true -itertools.workspace = true -language.workspace = true -language_model.workspace = true -log.workspace = true -lsp.workspace = true -markdown.workspace = true -open.workspace = true -paths.workspace = true -portable-pty.workspace = true -project.workspace = true -prompt_store.workspace = true -regex.workspace = true -rust-embed.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -smallvec.workspace = true -streaming_diff.workspace = true -strsim.workspace = true -task.workspace = true -terminal.workspace = true -terminal_view.workspace = true -theme.workspace = true -ui.workspace = true -util.workspace = true -watch.workspace = true -web_search.workspace = true -which.workspace = true -workspace-hack.workspace = true -workspace.workspace = true - -[dev-dependencies] -lsp = { workspace = true, features = ["test-support"] } -client = { workspace = true, features = ["test-support"] } -clock = { workspace = true, features = ["test-support"] } -collections = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -gpui_tokio.workspace = true -fs = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } -language_model = { workspace = true, features = ["test-support"] } -language_models.workspace = true -project = { workspace = true, features = ["test-support"] } -rand.workspace = true -pretty_assertions.workspace = true -reqwest_client.workspace = true -settings = { workspace = true, features = ["test-support"] } -smol.workspace = true -task = { workspace = true, features = ["test-support"]} -tempfile.workspace = true -theme.workspace = true -tree-sitter-rust.workspace = true -workspace = { workspace = true, features = ["test-support"] } -unindent.workspace = true -zlog.workspace = true diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs deleted file mode 100644 index eea65b173ff3ca2c2b6d8663d77ac7013d9121aa..0000000000000000000000000000000000000000 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ /dev/null @@ -1,2440 +0,0 @@ -use crate::{ - Templates, - edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}, - schema::json_schema_for, - ui::{COLLAPSED_LINES, ToolOutputPreview}, -}; -use action_log::ActionLog; -use agent_settings; -use anyhow::{Context as _, Result, anyhow}; -use assistant_tool::{ - AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, -}; -use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use editor::{ - Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey, multibuffer_context_lines, -}; -use futures::StreamExt; -use gpui::{ - Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task, - TextStyleRefinement, WeakEntity, pulsating_between, px, -}; -use indoc::formatdoc; -use language::{ - Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope, - TextBuffer, - language_settings::{self, FormatOnSave, SoftWrap}, -}; -use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use paths; -use project::{ - Project, ProjectPath, - lsp_store::{FormatTrigger, LspFormatTarget}, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::Settings; -use std::{ - cmp::Reverse, - collections::HashSet, - ops::Range, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; -use theme::ThemeSettings; -use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*}; -use util::ResultExt; -use workspace::Workspace; - -pub struct EditFileTool; - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -pub struct EditFileToolInput { - /// A one-line, user-friendly markdown description of the edit. This will be - /// shown in the UI and also passed to another model to perform the edit. - /// - /// Be terse, but also descriptive in what you want to achieve with this - /// edit. Avoid generic instructions. - /// - /// NEVER mention the file path in this description. - /// - /// Fix API endpoint URLs - /// Update copyright year in `page_footer` - /// - /// Make sure to include this field before all the others in the input object - /// so that we can display it immediately. - pub display_description: String, - - /// The full path of the file to create or modify in the project. - /// - /// WARNING: When specifying which file path need changing, you MUST - /// start each path with one of the project's root directories. - /// - /// The following examples assume we have two root directories in the project: - /// - /a/b/backend - /// - /c/d/frontend - /// - /// - /// `backend/src/main.rs` - /// - /// Notice how the file path starts with `backend`. Without that, the path - /// would be ambiguous and the call would fail! - /// - /// - /// - /// `frontend/db.js` - /// - pub path: PathBuf, - - /// The mode of operation on the file. Possible values: - /// - 'edit': Make granular edits to an existing file. - /// - 'create': Create a new file if it doesn't exist. - /// - 'overwrite': Replace the entire contents of an existing file. - /// - /// When a file already exists or you just created it, prefer editing - /// it as opposed to recreating it from scratch. - pub mode: EditFileMode, -} - -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum EditFileMode { - Edit, - Create, - Overwrite, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct EditFileToolOutput { - pub original_path: PathBuf, - pub new_text: String, - pub old_text: Arc, - pub raw_output: Option, -} - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -struct PartialInput { - #[serde(default)] - path: String, - #[serde(default)] - display_description: String, -} - -const DEFAULT_UI_TEXT: &str = "Editing file"; - -impl Tool for EditFileTool { - fn name(&self) -> String { - "edit_file".into() - } - - fn needs_confirmation( - &self, - input: &serde_json::Value, - project: &Entity, - cx: &App, - ) -> bool { - if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions { - return false; - } - - let Ok(input) = serde_json::from_value::(input.clone()) else { - // If it's not valid JSON, it's going to error and confirming won't do anything. - return false; - }; - - // If any path component matches the local settings folder, then this could affect - // the editor in ways beyond the project source, so prompt. - let local_settings_folder = paths::local_settings_folder_relative_path(); - let path = Path::new(&input.path); - if path - .components() - .any(|component| component.as_os_str() == local_settings_folder.as_os_str()) - { - return true; - } - - // It's also possible that the global config dir is configured to be inside the project, - // so check for that edge case too. - if let Ok(canonical_path) = std::fs::canonicalize(&input.path) - && canonical_path.starts_with(paths::config_dir()) - { - return true; - } - - // Check if path is inside the global config directory - // First check if it's already inside project - if not, try to canonicalize - let project_path = project.read(cx).find_project_path(&input.path, cx); - - // If the path is inside the project, and it's not one of the above edge cases, - // then no confirmation is necessary. Otherwise, confirmation is necessary. - project_path.is_none() - } - - fn may_perform_edits(&self) -> bool { - true - } - - fn description(&self) -> String { - include_str!("edit_file_tool/description.md").to_string() - } - - fn icon(&self) -> IconName { - IconName::ToolPencil - } - - 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 = Path::new(&input.path); - let mut description = input.display_description.clone(); - - // Add context about why confirmation may be needed - let local_settings_folder = paths::local_settings_folder_relative_path(); - if path - .components() - .any(|c| c.as_os_str() == local_settings_folder.as_os_str()) - { - description.push_str(" (local settings)"); - } else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) - && canonical_path.starts_with(paths::config_dir()) - { - description.push_str(" (global settings)"); - } - - description - } - Err(_) => "Editing file".to_string(), - } - } - - fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String { - if let Some(input) = serde_json::from_value::(input.clone()).ok() { - let description = input.display_description.trim(); - if !description.is_empty() { - return description.to_string(); - } - - let path = input.path.trim(); - if !path.is_empty() { - return path.to_string(); - } - } - - DEFAULT_UI_TEXT.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(), - }; - - let project_path = match resolve_path(&input, project.clone(), cx) { - Ok(path) => path, - Err(err) => return Task::ready(Err(anyhow!(err))).into(), - }; - - let card = window.and_then(|window| { - window - .update(cx, |_, window, cx| { - cx.new(|cx| { - EditFileToolCard::new(input.path.clone(), project.clone(), window, cx) - }) - }) - .ok() - }); - - let card_clone = card.clone(); - let action_log_clone = action_log.clone(); - let task = cx.spawn(async move |cx: &mut AsyncApp| { - let edit_format = EditFormat::from_model(model.clone())?; - let edit_agent = EditAgent::new( - model, - project.clone(), - action_log_clone, - Templates::new(), - edit_format, - ); - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(project_path.clone(), cx) - })? - .await?; - - let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let old_text = cx - .background_spawn({ - let old_snapshot = old_snapshot.clone(); - async move { Arc::new(old_snapshot.text()) } - }) - .await; - - if let Some(card) = card_clone.as_ref() { - card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?; - } - - let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) { - edit_agent.edit( - buffer.clone(), - input.display_description.clone(), - &request, - cx, - ) - } else { - edit_agent.overwrite( - buffer.clone(), - input.display_description.clone(), - &request, - cx, - ) - }; - - let mut hallucinated_old_text = false; - let mut ambiguous_ranges = Vec::new(); - while let Some(event) = events.next().await { - match event { - EditAgentOutputEvent::Edited { .. } => { - if let Some(card) = card_clone.as_ref() { - card.update(cx, |card, cx| card.update_diff(cx))?; - } - } - EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true, - EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges, - EditAgentOutputEvent::ResolvingEditRange(range) => { - if let Some(card) = card_clone.as_ref() { - card.update(cx, |card, cx| card.reveal_range(range, cx))?; - } - } - } - } - let agent_output = output.await?; - - // If format_on_save is enabled, format the buffer - let format_on_save_enabled = buffer - .read_with(cx, |buffer, cx| { - let settings = language_settings::language_settings( - buffer.language().map(|l| l.name()), - buffer.file(), - cx, - ); - !matches!(settings.format_on_save, FormatOnSave::Off) - }) - .unwrap_or(false); - - if format_on_save_enabled { - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - })?; - let format_task = project.update(cx, |project, cx| { - project.format( - HashSet::from_iter([buffer.clone()]), - LspFormatTarget::Buffers, - false, // Don't push to history since the tool did it. - FormatTrigger::Save, - cx, - ) - })?; - format_task.await.log_err(); - } - - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? - .await?; - - // Notify the action log that we've edited the buffer (*after* formatting has completed). - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - })?; - - let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let (new_text, diff) = cx - .background_spawn({ - let new_snapshot = new_snapshot.clone(); - let old_text = old_text.clone(); - async move { - let new_text = new_snapshot.text(); - let diff = language::unified_diff(&old_text, &new_text); - - (new_text, diff) - } - }) - .await; - - let output = EditFileToolOutput { - original_path: project_path.path.to_path_buf(), - new_text, - old_text, - raw_output: Some(agent_output), - }; - - if let Some(card) = card_clone { - card.update(cx, |card, cx| { - card.update_diff(cx); - card.finalize(cx) - }) - .log_err(); - } - - let input_path = input.path.display(); - if diff.is_empty() { - anyhow::ensure!( - !hallucinated_old_text, - formatdoc! {" - Some edits were produced but none of them could be applied. - Read the relevant sections of {input_path} again so that - I can perform the requested edits. - "} - ); - anyhow::ensure!( - ambiguous_ranges.is_empty(), - { - let line_numbers = ambiguous_ranges - .iter() - .map(|range| range.start.to_string()) - .collect::>() - .join(", "); - formatdoc! {" - matches more than one position in the file (lines: {line_numbers}). Read the - relevant sections of {input_path} again and extend so - that I can perform the requested edits. - "} - } - ); - Ok(ToolResultOutput { - content: ToolResultContent::Text("No edits were made.".into()), - output: serde_json::to_value(output).ok(), - }) - } else { - Ok(ToolResultOutput { - content: ToolResultContent::Text(format!( - "Edited {}:\n\n```diff\n{}\n```", - input_path, diff - )), - output: serde_json::to_value(output).ok(), - }) - } - }); - - ToolResult { - output: task, - card: card.map(AnyToolCard::from), - } - } - - fn deserialize_card( - self: Arc, - output: serde_json::Value, - project: Entity, - window: &mut Window, - cx: &mut App, - ) -> Option { - let output = match serde_json::from_value::(output) { - Ok(output) => output, - Err(_) => return None, - }; - - let card = cx.new(|cx| { - EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx) - }); - - cx.spawn({ - let path: Arc = output.original_path.into(); - let language_registry = project.read(cx).languages().clone(); - let card = card.clone(); - async move |cx| { - let buffer = - build_buffer(output.new_text, path.clone(), &language_registry, cx).await?; - let buffer_diff = - build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx) - .await?; - card.update(cx, |card, cx| { - card.multibuffer.update(cx, |multibuffer, cx| { - let snapshot = buffer.read(cx).snapshot(); - let diff = buffer_diff.read(cx); - let diff_hunk_ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)) - .collect::>(); - - multibuffer.set_excerpts_for_path( - PathKey::for_buffer(&buffer, cx), - buffer, - diff_hunk_ranges, - multibuffer_context_lines(cx), - cx, - ); - multibuffer.add_diff(buffer_diff, cx); - let end = multibuffer.len(cx); - card.total_lines = - Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1); - }); - - cx.notify(); - })?; - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - - Some(card.into()) - } -} - -/// Validate that the file path is valid, meaning: -/// -/// - For `edit` and `overwrite`, the path must point to an existing file. -/// - For `create`, the file must not already exist, but it's parent dir must exist. -fn resolve_path( - input: &EditFileToolInput, - project: Entity, - cx: &mut App, -) -> Result { - let project = project.read(cx); - - match input.mode { - EditFileMode::Edit | EditFileMode::Overwrite => { - let path = project - .find_project_path(&input.path, cx) - .context("Can't edit file: path not found")?; - - let entry = project - .entry_for_path(&path, cx) - .context("Can't edit file: path not found")?; - - anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory"); - Ok(path) - } - - EditFileMode::Create => { - if let Some(path) = project.find_project_path(&input.path, cx) { - anyhow::ensure!( - project.entry_for_path(&path, cx).is_none(), - "Can't create file: file already exists" - ); - } - - let parent_path = input - .path - .parent() - .context("Can't create file: incorrect path")?; - - let parent_project_path = project.find_project_path(&parent_path, cx); - - let parent_entry = parent_project_path - .as_ref() - .and_then(|path| project.entry_for_path(path, cx)) - .context("Can't create file: parent directory doesn't exist")?; - - anyhow::ensure!( - parent_entry.is_dir(), - "Can't create file: parent is not a directory" - ); - - let file_name = input - .path - .file_name() - .context("Can't create file: invalid filename")?; - - let new_file_path = parent_project_path.map(|parent| ProjectPath { - path: Arc::from(parent.path.join(file_name)), - ..parent - }); - - new_file_path.context("Can't create file") - } - } -} - -pub struct EditFileToolCard { - path: PathBuf, - editor: Entity, - multibuffer: Entity, - project: Entity, - buffer: Option>, - base_text: Option>, - buffer_diff: Option>, - revealed_ranges: Vec>, - diff_task: Option>>, - preview_expanded: bool, - error_expanded: Option>, - full_height_expanded: bool, - total_lines: Option, -} - -impl EditFileToolCard { - pub fn new(path: PathBuf, project: Entity, window: &mut Window, cx: &mut App) -> Self { - let expand_edit_card = agent_settings::AgentSettings::get_global(cx).expand_edit_card; - let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly)); - - let editor = cx.new(|cx| { - let mut editor = Editor::new( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sized_by_content: true, - }, - multibuffer.clone(), - Some(project.clone()), - window, - cx, - ); - editor.set_show_gutter(false, cx); - editor.disable_inline_diagnostics(); - editor.disable_expand_excerpt_buttons(cx); - // Keep horizontal scrollbar so user can scroll horizontally if needed - editor.set_show_vertical_scrollbar(false, cx); - editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); - editor.set_soft_wrap_mode(SoftWrap::None, cx); - editor.scroll_manager.set_forbid_vertical_scroll(true); - editor.set_show_indent_guides(false, cx); - editor.set_read_only(true); - editor.set_show_breakpoints(false, cx); - editor.set_show_code_actions(false, cx); - editor.set_show_git_diff_gutter(false, cx); - editor.set_expand_all_diff_hunks(cx); - editor - }); - Self { - path, - project, - editor, - multibuffer, - buffer: None, - base_text: None, - buffer_diff: None, - revealed_ranges: Vec::new(), - diff_task: None, - preview_expanded: true, - error_expanded: None, - full_height_expanded: expand_edit_card, - total_lines: None, - } - } - - pub fn initialize(&mut self, buffer: Entity, cx: &mut App) { - let buffer_snapshot = buffer.read(cx).snapshot(); - let base_text = buffer_snapshot.text(); - let language_registry = buffer.read(cx).language_registry(); - let text_snapshot = buffer.read(cx).text_snapshot(); - - // Create a buffer diff with the current text as the base - let buffer_diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&text_snapshot, cx); - let _ = diff.set_base_text( - buffer_snapshot.clone(), - language_registry, - text_snapshot, - cx, - ); - diff - }); - - self.buffer = Some(buffer); - self.base_text = Some(base_text.into()); - self.buffer_diff = Some(buffer_diff.clone()); - - // Add the diff to the multibuffer - self.multibuffer - .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx)); - } - - pub fn is_loading(&self) -> bool { - self.total_lines.is_none() - } - - pub fn update_diff(&mut self, cx: &mut Context) { - let Some(buffer) = self.buffer.as_ref() else { - return; - }; - let Some(buffer_diff) = self.buffer_diff.as_ref() else { - return; - }; - - let buffer = buffer.clone(); - let buffer_diff = buffer_diff.clone(); - let base_text = self.base_text.clone(); - self.diff_task = Some(cx.spawn(async move |this, cx| { - let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?; - let diff_snapshot = BufferDiff::update_diff( - buffer_diff.clone(), - text_snapshot.clone(), - base_text, - false, - false, - None, - None, - cx, - ) - .await?; - buffer_diff.update(cx, |diff, cx| { - diff.set_snapshot(diff_snapshot, &text_snapshot, cx) - })?; - this.update(cx, |this, cx| this.update_visible_ranges(cx)) - })); - } - - pub fn reveal_range(&mut self, range: Range, cx: &mut Context) { - self.revealed_ranges.push(range); - self.update_visible_ranges(cx); - } - - fn update_visible_ranges(&mut self, cx: &mut Context) { - let Some(buffer) = self.buffer.as_ref() else { - return; - }; - - let ranges = self.excerpt_ranges(cx); - self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| { - multibuffer.set_excerpts_for_path( - PathKey::for_buffer(buffer, cx), - buffer.clone(), - ranges, - multibuffer_context_lines(cx), - cx, - ); - let end = multibuffer.len(cx); - Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1) - }); - cx.notify(); - } - - fn excerpt_ranges(&self, cx: &App) -> Vec> { - let Some(buffer) = self.buffer.as_ref() else { - return Vec::new(); - }; - let Some(diff) = self.buffer_diff.as_ref() else { - return Vec::new(); - }; - - let buffer = buffer.read(cx); - let diff = diff.read(cx); - let mut ranges = diff - .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer, cx) - .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer)) - .collect::>(); - ranges.extend( - self.revealed_ranges - .iter() - .map(|range| range.to_point(buffer)), - ); - ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end))); - - // Merge adjacent ranges - let mut ranges = ranges.into_iter().peekable(); - let mut merged_ranges = Vec::new(); - while let Some(mut range) = ranges.next() { - while let Some(next_range) = ranges.peek() { - if range.end >= next_range.start { - range.end = range.end.max(next_range.end); - ranges.next(); - } else { - break; - } - } - - merged_ranges.push(range); - } - merged_ranges - } - - pub fn finalize(&mut self, cx: &mut Context) -> Result<()> { - let ranges = self.excerpt_ranges(cx); - let buffer = self.buffer.take().context("card was already finalized")?; - let base_text = self - .base_text - .take() - .context("card was already finalized")?; - let language_registry = self.project.read(cx).languages().clone(); - - // Replace the buffer in the multibuffer with the snapshot - let buffer = cx.new(|cx| { - let language = buffer.read(cx).language().cloned(); - let buffer = TextBuffer::new_normalized( - 0, - cx.entity_id().as_non_zero_u64().into(), - buffer.read(cx).line_ending(), - buffer.read(cx).as_rope().clone(), - ); - let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); - buffer.set_language(language, cx); - buffer - }); - - let buffer_diff = cx.spawn({ - let buffer = buffer.clone(); - async move |_this, cx| { - build_buffer_diff(base_text, &buffer, &language_registry, cx).await - } - }); - - cx.spawn(async move |this, cx| { - let buffer_diff = buffer_diff.await?; - this.update(cx, |this, cx| { - this.multibuffer.update(cx, |multibuffer, cx| { - let path_key = PathKey::for_buffer(&buffer, cx); - multibuffer.clear(cx); - multibuffer.set_excerpts_for_path( - path_key, - buffer, - ranges, - multibuffer_context_lines(cx), - cx, - ); - multibuffer.add_diff(buffer_diff.clone(), cx); - }); - - cx.notify(); - }) - }) - .detach_and_log_err(cx); - Ok(()) - } -} - -impl ToolCard for EditFileToolCard { - fn render( - &mut self, - status: &ToolUseStatus, - window: &mut Window, - workspace: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - let error_message = match status { - ToolUseStatus::Error(err) => Some(err), - _ => None, - }; - - let running_or_pending = match status { - ToolUseStatus::Running | ToolUseStatus::Pending => Some(()), - _ => None, - }; - - let should_show_loading = running_or_pending.is_some() && !self.full_height_expanded; - - let path_label_button = h_flex() - .id(("edit-tool-path-label-button", self.editor.entity_id())) - .w_full() - .max_w_full() - .px_1() - .gap_0p5() - .cursor_pointer() - .rounded_sm() - .opacity(0.8) - .hover(|label| { - label - .opacity(1.) - .bg(cx.theme().colors().element_hover.opacity(0.5)) - }) - .tooltip(Tooltip::text("Jump to File")) - .child( - h_flex() - .child( - Icon::new(IconName::ToolPencil) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - div() - .text_size(rems(0.8125)) - .child(self.path.display().to_string()) - .ml_1p5() - .mr_0p5(), - ) - .child( - Icon::new(IconName::ArrowUpRight) - .size(IconSize::Small) - .color(Color::Ignored), - ), - ) - .on_click({ - let path = self.path.clone(); - move |_, window, cx| { - workspace - .update(cx, { - |workspace, cx| { - let Some(project_path) = - workspace.project().read(cx).find_project_path(&path, cx) - else { - return; - }; - let open_task = - workspace.open_path(project_path, None, true, window, cx); - window - .spawn(cx, async move |cx| { - let item = open_task.await?; - if let Some(active_editor) = item.downcast::() { - active_editor - .update_in(cx, |editor, window, cx| { - let snapshot = - editor.buffer().read(cx).snapshot(cx); - let first_hunk = editor - .diff_hunks_in_ranges( - &[editor::Anchor::min() - ..editor::Anchor::max()], - &snapshot, - ) - .next(); - if let Some(first_hunk) = first_hunk { - let first_hunk_start = - first_hunk.multi_buffer_range().start; - editor.change_selections( - Default::default(), - window, - cx, - |selections| { - selections.select_anchor_ranges([ - first_hunk_start - ..first_hunk_start, - ]); - }, - ) - } - }) - .log_err(); - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - }) - .ok(); - } - }) - .into_any_element(); - - let codeblock_header_bg = cx - .theme() - .colors() - .element_background - .blend(cx.theme().colors().editor_foreground.opacity(0.025)); - - let codeblock_header = h_flex() - .flex_none() - .p_1() - .gap_1() - .justify_between() - .rounded_t_md() - .when(error_message.is_none(), |header| { - header.bg(codeblock_header_bg) - }) - .child(path_label_button) - .when(should_show_loading, |header| { - header.pr_1p5().child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_rotate_animation(2), - ) - }) - .when_some(error_message, |header, error_message| { - header.child( - h_flex() - .gap_1() - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .child( - Disclosure::new( - ("edit-file-error-disclosure", self.editor.entity_id()), - self.error_expanded.is_some(), - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener({ - let error_message = error_message.clone(); - - move |this, _event, _window, cx| { - if this.error_expanded.is_some() { - this.error_expanded.take(); - } else { - this.error_expanded = Some(cx.new(|cx| { - Markdown::new(error_message.clone(), None, None, cx) - })) - } - cx.notify(); - } - })), - ), - ) - }) - .when(error_message.is_none() && !self.is_loading(), |header| { - header.child( - Disclosure::new( - ("edit-file-disclosure", self.editor.entity_id()), - self.preview_expanded, - ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .on_click(cx.listener( - move |this, _event, _window, _cx| { - this.preview_expanded = !this.preview_expanded; - }, - )), - ) - }); - - let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| { - let line_height = editor - .style() - .map(|style| style.text.line_height_in_pixels(window.rem_size())) - .unwrap_or_default(); - - editor.set_text_style_refinement(TextStyleRefinement { - font_size: Some( - TextSize::Small - .rems(cx) - .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx)) - .into(), - ), - ..TextStyleRefinement::default() - }); - let element = editor.render(window, cx); - (element.into_any_element(), line_height) - }); - - let border_color = cx.theme().colors().border.opacity(0.6); - - let waiting_for_diff = { - let styles = [ - ("w_4_5", (0.1, 0.85), 2000), - ("w_1_4", (0.2, 0.75), 2200), - ("w_2_4", (0.15, 0.64), 1900), - ("w_3_5", (0.25, 0.72), 2300), - ("w_2_5", (0.3, 0.56), 1800), - ]; - - let mut container = v_flex() - .p_3() - .gap_1() - .border_t_1() - .rounded_b_md() - .border_color(border_color) - .bg(cx.theme().colors().editor_background); - - for (width_method, pulse_range, duration_ms) in styles.iter() { - let (min_opacity, max_opacity) = *pulse_range; - let placeholder = match *width_method { - "w_4_5" => div().w_3_4(), - "w_1_4" => div().w_1_4(), - "w_2_4" => div().w_2_4(), - "w_3_5" => div().w_3_5(), - "w_2_5" => div().w_2_5(), - _ => div().w_1_2(), - } - .id("loading_div") - .h_1() - .rounded_full() - .bg(cx.theme().colors().element_active) - .with_animation( - "loading_pulsate", - Animation::new(Duration::from_millis(*duration_ms)) - .repeat() - .with_easing(pulsating_between(min_opacity, max_opacity)), - |label, delta| label.opacity(delta), - ); - - container = container.child(placeholder); - } - - container - }; - - v_flex() - .mb_2() - .border_1() - .when(error_message.is_some(), |card| card.border_dashed()) - .border_color(border_color) - .rounded_md() - .overflow_hidden() - .child(codeblock_header) - .when_some(self.error_expanded.as_ref(), |card, error_markdown| { - card.child( - v_flex() - .p_2() - .gap_1() - .border_t_1() - .border_dashed() - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .rounded_b_md() - .child( - Label::new("Error") - .size(LabelSize::XSmall) - .color(Color::Error), - ) - .child( - div() - .rounded_md() - .text_ui_sm(cx) - .bg(cx.theme().colors().editor_background) - .child(MarkdownElement::new( - error_markdown.clone(), - markdown_style(window, cx), - )), - ), - ) - }) - .when(self.is_loading() && error_message.is_none(), |card| { - card.child(waiting_for_diff) - }) - .when(self.preview_expanded && !self.is_loading(), |card| { - let editor_view = v_flex() - .relative() - .h_full() - .when(!self.full_height_expanded, |editor_container| { - editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0)) - }) - .overflow_hidden() - .border_t_1() - .border_color(border_color) - .bg(cx.theme().colors().editor_background) - .child(editor); - - card.child( - ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id()) - .with_total_lines(self.total_lines.unwrap_or(0) as usize) - .toggle_state(self.full_height_expanded) - .with_collapsed_fade() - .on_toggle({ - let this = cx.entity().downgrade(); - move |is_expanded, _window, cx| { - if let Some(this) = this.upgrade() { - this.update(cx, |this, _cx| { - this.full_height_expanded = is_expanded; - }); - } - } - }), - ) - }) - } -} - -fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let theme_settings = ThemeSettings::get_global(cx); - let ui_font_size = TextSize::Default.rems(cx); - let mut text_style = window.text_style(); - - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.ui_font.family.clone()), - font_fallbacks: theme_settings.ui_font.fallbacks.clone(), - font_features: Some(theme_settings.ui_font.features.clone()), - font_size: Some(ui_font_size.into()), - color: Some(cx.theme().colors().text), - ..Default::default() - }); - - MarkdownStyle { - base_text_style: text_style.clone(), - selection_background_color: cx.theme().colors().element_selection_background, - ..Default::default() - } -} - -async fn build_buffer( - mut text: String, - path: Arc, - language_registry: &Arc, - cx: &mut AsyncApp, -) -> Result> { - let line_ending = LineEnding::detect(&text); - LineEnding::normalize(&mut text); - let text = Rope::from(text); - let language = cx - .update(|_cx| language_registry.language_for_file_path(&path))? - .await - .ok(); - let buffer = cx.new(|cx| { - let buffer = TextBuffer::new_normalized( - 0, - cx.entity_id().as_non_zero_u64().into(), - line_ending, - text, - ); - let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite); - buffer.set_language(language, cx); - buffer - })?; - Ok(buffer) -} - -async fn build_buffer_diff( - old_text: Arc, - buffer: &Entity, - language_registry: &Arc, - cx: &mut AsyncApp, -) -> Result> { - let buffer = cx.update(|cx| buffer.read(cx).snapshot())?; - - let old_text_rope = cx - .background_spawn({ - let old_text = old_text.clone(); - async move { Rope::from(old_text.as_str()) } - }) - .await; - let base_buffer = cx - .update(|cx| { - Buffer::build_snapshot( - old_text_rope, - buffer.language().cloned(), - Some(language_registry.clone()), - cx, - ) - })? - .await; - - let diff_snapshot = cx - .update(|cx| { - BufferDiffSnapshot::new_with_base_buffer( - buffer.text.clone(), - Some(old_text), - base_buffer, - cx, - ) - })? - .await; - - let secondary_diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&buffer, cx); - diff.set_snapshot(diff_snapshot.clone(), &buffer, cx); - diff - })?; - - cx.new(|cx| { - let mut diff = BufferDiff::new(&buffer.text, cx); - diff.set_snapshot(diff_snapshot, &buffer, cx); - diff.set_secondary_diff(secondary_diff); - diff - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use ::fs::Fs; - use client::TelemetrySettings; - use encodings::Encoding; - use encodings::UTF_8; - use gpui::{TestAppContext, UpdateGlobal}; - use language_model::fake_provider::FakeLanguageModel; - use serde_json::json; - use settings::SettingsStore; - use std::fs; - use util::path; - - #[gpui::test] - async fn test_edit_nonexistent_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({})).await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - let result = cx - .update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Some edit".into(), - path: "root/nonexistent_file.txt".into(), - mode: EditFileMode::Edit, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log, - model, - None, - cx, - ) - .output - }) - .await; - assert_eq!( - result.unwrap_err().to_string(), - "Can't edit file: path not found" - ); - } - - #[gpui::test] - async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) { - let mode = &EditFileMode::Create; - - let result = test_resolve_path(mode, "root/new.txt", cx); - assert_resolved_path_eq(result.await, "new.txt"); - - let result = test_resolve_path(mode, "new.txt", cx); - assert_resolved_path_eq(result.await, "new.txt"); - - let result = test_resolve_path(mode, "dir/new.txt", cx); - assert_resolved_path_eq(result.await, "dir/new.txt"); - - let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't create file: file already exists" - ); - - let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't create file: parent directory doesn't exist" - ); - } - - #[gpui::test] - async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) { - let mode = &EditFileMode::Edit; - - let path_with_root = "root/dir/subdir/existing.txt"; - let path_without_root = "dir/subdir/existing.txt"; - let result = test_resolve_path(mode, path_with_root, cx); - assert_resolved_path_eq(result.await, path_without_root); - - let result = test_resolve_path(mode, path_without_root, cx); - assert_resolved_path_eq(result.await, path_without_root); - - let result = test_resolve_path(mode, "root/nonexistent.txt", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't edit file: path not found" - ); - - let result = test_resolve_path(mode, "root/dir", cx); - assert_eq!( - result.await.unwrap_err().to_string(), - "Can't edit file: path is a directory" - ); - } - - async fn test_resolve_path( - mode: &EditFileMode, - path: &str, - cx: &mut TestAppContext, - ) -> anyhow::Result { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "dir": { - "subdir": { - "existing.txt": "hello" - } - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - let input = EditFileToolInput { - display_description: "Some edit".into(), - path: path.into(), - mode: mode.clone(), - }; - - cx.update(|cx| resolve_path(&input, project, cx)) - } - - fn assert_resolved_path_eq(path: anyhow::Result, expected: &str) { - let actual = path - .expect("Should return valid path") - .path - .to_str() - .unwrap() - .replace("\\", "/"); // Naive Windows paths normalization - assert_eq!(actual, expected); - } - - #[test] - fn still_streaming_ui_text_with_path() { - let input = json!({ - "path": "src/main.rs", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs"); - } - - #[test] - fn still_streaming_ui_text_with_description() { - let input = json!({ - "path": "", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_with_path_and_description() { - let input = json!({ - "path": "src/main.rs", - "display_description": "Fix error handling", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - "Fix error handling", - ); - } - - #[test] - fn still_streaming_ui_text_no_path_or_description() { - let input = json!({ - "path": "", - "display_description": "", - "old_string": "old code", - "new_string": "new code" - }); - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - - #[test] - fn still_streaming_ui_text_with_null() { - let input = serde_json::Value::Null; - - assert_eq!( - EditFileTool.still_streaming_ui_text(&input), - DEFAULT_UI_TEXT, - ); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - TelemetrySettings::register(cx); - agent_settings::AgentSettings::register(cx); - Project::init_settings(cx); - }); - } - - fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) { - cx.update(|cx| { - // Set custom data directory (config will be under data_dir/config) - paths::set_custom_data_dir(data_dir.to_str().unwrap()); - - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - language::init(cx); - TelemetrySettings::register(cx); - agent_settings::AgentSettings::register(cx); - Project::init_settings(cx); - }); - } - - #[gpui::test] - async fn test_format_on_save(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - - // Set up a Rust language with LSP formatting support - let rust_language = Arc::new(language::Language::new( - language::LanguageConfig { - name: "Rust".into(), - matcher: language::LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - None, - )); - - // Register the language and fake LSP - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(rust_language); - - let mut fake_language_servers = language_registry.register_fake_lsp( - "Rust", - language::FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - document_formatting_provider: Some(lsp::OneOf::Left(true)), - ..Default::default() - }, - ..Default::default() - }, - ); - - // Create the file - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - Encoding::new(UTF_8), - ) - .await - .unwrap(); - - // Open the buffer to trigger LSP initialization - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/src/main.rs"), cx) - }) - .await - .unwrap(); - - // Register the buffer with language servers - let _handle = project.update(cx, |project, cx| { - project.register_buffer_with_language_servers(&buffer, cx) - }); - - const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; - const FORMATTED_CONTENT: &str = - "This file was formatted by the fake formatter in the test.\n"; - - // Get the fake language server and set up formatting handler - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.set_request_handler::({ - |_, _| async move { - Ok(Some(vec![lsp::TextEdit { - range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), - new_text: FORMATTED_CONTENT.to_string(), - }])) - } - }); - - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // First, test with format_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.format_on_save = Some(FormatOnSave::On); - settings.defaults.formatter = - Some(language::language_settings::SelectedFormatter::Auto); - }, - ); - }); - }); - - // Have the model stream unformatted content - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Create main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the unformatted content - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Read the file to verify it was formatted automatically - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - // Ignore carriage returns on Windows - new_content.replace("\r\n", "\n"), - FORMATTED_CONTENT, - "Code should be formatted when format_on_save is enabled" - ); - - let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count()); - - assert_eq!( - stale_buffer_count, 0, - "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \ - This causes the agent to think the file was modified externally when it was just formatted.", - stale_buffer_count - ); - - // Next, test with format_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.format_on_save = Some(FormatOnSave::Off); - }, - ); - }); - }); - - // Stream unformatted edits again - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Update main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the unformatted content - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string()); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Verify the file was not formatted - let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - // Ignore carriage returns on Windows - new_content.replace("\r\n", "\n"), - UNFORMATTED_CONTENT, - "Code should not be formatted when format_on_save is disabled" - ); - } - - #[gpui::test] - async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({"src": {}})).await; - - // Create a simple file with trailing whitespace - fs.save( - path!("/root/src/main.rs").as_ref(), - &"initial content".into(), - language::LineEnding::Unix, - Encoding::default(), - ) - .await - .unwrap(); - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let model = Arc::new(FakeLanguageModel::default()); - - // First, test with remove_trailing_whitespace_on_save enabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.remove_trailing_whitespace_on_save = Some(true); - }, - ); - }); - }); - - const CONTENT_WITH_TRAILING_WHITESPACE: &str = - "fn main() { \n println!(\"Hello!\"); \n}\n"; - - // Have the model stream content that contains trailing whitespace - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Create main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the content with trailing whitespace - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Read the file to verify trailing whitespace was removed automatically - assert_eq!( - // Ignore carriage returns on Windows - fs.load(path!("/root/src/main.rs").as_ref()) - .await - .unwrap() - .replace("\r\n", "\n"), - "fn main() {\n println!(\"Hello!\");\n}\n", - "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" - ); - - // Next, test with remove_trailing_whitespace_on_save disabled - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings::( - cx, - |settings| { - settings.defaults.remove_trailing_whitespace_on_save = Some(false); - }, - ); - }); - }); - - // Stream edits again with trailing whitespace - let edit_result = { - let edit_task = cx.update(|cx| { - let input = serde_json::to_value(EditFileToolInput { - display_description: "Update main function".into(), - path: "root/src/main.rs".into(), - mode: EditFileMode::Overwrite, - }) - .unwrap(); - Arc::new(EditFileTool) - .run( - input, - Arc::default(), - project.clone(), - action_log.clone(), - model.clone(), - None, - cx, - ) - .output - }); - - // Stream the content with trailing whitespace - cx.executor().run_until_parked(); - model.send_last_completion_stream_text_chunk( - CONTENT_WITH_TRAILING_WHITESPACE.to_string(), - ); - model.end_last_completion_stream(); - - edit_task.await - }; - assert!(edit_result.is_ok()); - - // Wait for any async operations (e.g. formatting) to complete - cx.executor().run_until_parked(); - - // Verify the file still has trailing whitespace - // Read the file again - it should still have trailing whitespace - let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); - assert_eq!( - // Ignore carriage returns on Windows - final_content.replace("\r\n", "\n"), - CONTENT_WITH_TRAILING_WHITESPACE, - "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" - ); - } - - #[gpui::test] - async fn test_needs_confirmation(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({})).await; - - // Test 1: Path with .zed component should require confirmation - let input_with_zed = json!({ - "display_description": "Edit settings", - "path": ".zed/settings.json", - "mode": "edit" - }); - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_with_zed, &project, cx), - "Path with .zed component should require confirmation" - ); - }); - - // Test 2: Absolute path should require confirmation - let input_absolute = json!({ - "display_description": "Edit file", - "path": "/etc/hosts", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_absolute, &project, cx), - "Absolute path should require confirmation" - ); - }); - - // Test 3: Relative path without .zed should not require confirmation - let input_relative = json!({ - "display_description": "Edit file", - "path": "root/src/main.rs", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input_relative, &project, cx), - "Relative path without .zed should not require confirmation" - ); - }); - - // Test 4: Path with .zed in the middle should require confirmation - let input_zed_middle = json!({ - "display_description": "Edit settings", - "path": "root/.zed/tasks.json", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_zed_middle, &project, cx), - "Path with .zed in any component should require confirmation" - ); - }); - - // Test 5: When always_allow_tool_actions is enabled, no confirmation needed - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.always_allow_tool_actions = true; - agent_settings::AgentSettings::override_global(settings, cx); - - assert!( - !tool.needs_confirmation(&input_with_zed, &project, cx), - "When always_allow_tool_actions is true, no confirmation should be needed" - ); - assert!( - !tool.needs_confirmation(&input_absolute, &project, cx), - "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths" - ); - }); - } - - #[gpui::test] - async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) { - // Set up a custom config directory for testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - - // Test ui_text shows context for various paths - let test_cases = vec![ - ( - json!({ - "display_description": "Update config", - "path": ".zed/settings.json", - "mode": "edit" - }), - "Update config (local settings)", - ".zed path should show local settings context", - ), - ( - json!({ - "display_description": "Fix bug", - "path": "src/.zed/local.json", - "mode": "edit" - }), - "Fix bug (local settings)", - "Nested .zed path should show local settings context", - ), - ( - json!({ - "display_description": "Update readme", - "path": "README.md", - "mode": "edit" - }), - "Update readme", - "Normal path should not show additional context", - ), - ( - json!({ - "display_description": "Edit config", - "path": "config.zed", - "mode": "edit" - }), - "Edit config", - ".zed as extension should not show context", - ), - ]; - - for (input, expected_text, description) in test_cases { - cx.update(|_cx| { - let ui_text = tool.ui_text(&input); - assert_eq!(ui_text, expected_text, "Failed for case: {}", description); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - - // Create a project in /project directory - fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test file outside project requires confirmation - let input_outside = json!({ - "display_description": "Edit file", - "path": "/outside/file.txt", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_outside, &project, cx), - "File outside project should require confirmation" - ); - }); - - // Test file inside project doesn't require confirmation - let input_inside = json!({ - "display_description": "Edit file", - "path": "project/file.txt", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input_inside, &project, cx), - "File inside project should not require confirmation" - ); - }); - } - - #[gpui::test] - async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) { - // Set up a custom data directory for testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/home/user/myproject", json!({})).await; - let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await; - - // Get the actual local settings folder name - let local_settings_folder = paths::local_settings_folder_relative_path(); - - // Test various config path patterns - let test_cases = vec![ - ( - format!("{}/settings.json", local_settings_folder.display()), - true, - "Top-level local settings file".to_string(), - ), - ( - format!( - "myproject/{}/settings.json", - local_settings_folder.display() - ), - true, - "Local settings in project path".to_string(), - ), - ( - format!("src/{}/config.toml", local_settings_folder.display()), - true, - "Local settings in subdirectory".to_string(), - ), - ( - ".zed.backup/file.txt".to_string(), - true, - ".zed.backup is outside project".to_string(), - ), - ( - "my.zed/file.txt".to_string(), - true, - "my.zed is outside project".to_string(), - ), - ( - "myproject/src/file.zed".to_string(), - false, - ".zed as file extension".to_string(), - ), - ( - "myproject/normal/path/file.rs".to_string(), - false, - "Normal file without config paths".to_string(), - ), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {} - path: {}", - description, - path - ); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) { - // Set up a custom data directory for testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - - // Create test files in the global config directory - let global_config_dir = paths::config_dir(); - fs::create_dir_all(&global_config_dir).unwrap(); - let global_settings_path = global_config_dir.join("settings.json"); - fs::write(&global_settings_path, "{}").unwrap(); - - fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test global config paths - let test_cases = vec![ - ( - global_settings_path.to_str().unwrap().to_string(), - true, - "Global settings file should require confirmation", - ), - ( - global_config_dir - .join("keymap.json") - .to_str() - .unwrap() - .to_string(), - true, - "Global keymap file should require confirmation", - ), - ( - "project/normal_file.rs".to_string(), - false, - "Normal project file should not require confirmation", - ), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {}", - description - ); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - - // Create multiple worktree directories - fs.insert_tree( - "/workspace/frontend", - json!({ - "src": { - "main.js": "console.log('frontend');" - } - }), - ) - .await; - fs.insert_tree( - "/workspace/backend", - json!({ - "src": { - "main.rs": "fn main() {}" - } - }), - ) - .await; - fs.insert_tree( - "/workspace/shared", - json!({ - ".zed": { - "settings.json": "{}" - } - }), - ) - .await; - - // Create project with multiple worktrees - let project = Project::test( - fs.clone(), - [ - path!("/workspace/frontend").as_ref(), - path!("/workspace/backend").as_ref(), - path!("/workspace/shared").as_ref(), - ], - cx, - ) - .await; - - // Test files in different worktrees - let test_cases = vec![ - ("frontend/src/main.js", false, "File in first worktree"), - ("backend/src/main.rs", false, "File in second worktree"), - ( - "shared/.zed/settings.json", - true, - ".zed file in third worktree", - ), - ("/etc/hosts", true, "Absolute path outside all worktrees"), - ( - "../outside/file.txt", - true, - "Relative path outside worktrees", - ), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {} - path: {}", - description, - path - ); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - ".zed": { - "settings.json": "{}" - }, - "src": { - ".zed": { - "local.json": "{}" - } - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test edge cases - let test_cases = vec![ - // Empty path - find_project_path returns Some for empty paths - ("", false, "Empty path is treated as project root"), - // Root directory - ("/", true, "Root directory should be outside project"), - // Parent directory references - find_project_path resolves these - ( - "project/../other", - false, - "Path with .. is resolved by find_project_path", - ), - ( - "project/./src/file.rs", - false, - "Path with . should work normally", - ), - // Windows-style paths (if on Windows) - #[cfg(target_os = "windows")] - ("C:\\Windows\\System32\\hosts", true, "Windows system path"), - #[cfg(target_os = "windows")] - ("project\\src\\main.rs", false, "Windows-style project path"), - ]; - - for (path, should_confirm, description) in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert_eq!( - tool.needs_confirmation(&input, &project, cx), - should_confirm, - "Failed for case: {} - path: {}", - description, - path - ); - }); - } - } - - #[gpui::test] - async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - - // Test UI text for various scenarios - let test_cases = vec![ - ( - json!({ - "display_description": "Update config", - "path": ".zed/settings.json", - "mode": "edit" - }), - "Update config (local settings)", - ".zed path should show local settings context", - ), - ( - json!({ - "display_description": "Fix bug", - "path": "src/.zed/local.json", - "mode": "edit" - }), - "Fix bug (local settings)", - "Nested .zed path should show local settings context", - ), - ( - json!({ - "display_description": "Update readme", - "path": "README.md", - "mode": "edit" - }), - "Update readme", - "Normal path should not show additional context", - ), - ( - json!({ - "display_description": "Edit config", - "path": "config.zed", - "mode": "edit" - }), - "Edit config", - ".zed as extension should not show context", - ), - ]; - - for (input, expected_text, description) in test_cases { - cx.update(|_cx| { - let ui_text = tool.ui_text(&input); - assert_eq!(ui_text, expected_text, "Failed for case: {}", description); - }); - } - } - - #[gpui::test] - async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { - init_test(cx); - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "existing.txt": "content", - ".zed": { - "settings.json": "{}" - } - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Test different EditFileMode values - let modes = vec![ - EditFileMode::Edit, - EditFileMode::Create, - EditFileMode::Overwrite, - ]; - - for mode in modes { - // Test .zed path with different modes - let input_zed = json!({ - "display_description": "Edit settings", - "path": "project/.zed/settings.json", - "mode": mode - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_zed, &project, cx), - ".zed path should require confirmation regardless of mode: {:?}", - mode - ); - }); - - // Test outside path with different modes - let input_outside = json!({ - "display_description": "Edit file", - "path": "/outside/file.txt", - "mode": mode - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input_outside, &project, cx), - "Outside path should require confirmation regardless of mode: {:?}", - mode - ); - }); - - // Test normal path with different modes - let input_normal = json!({ - "display_description": "Edit file", - "path": "project/normal.txt", - "mode": mode - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input_normal, &project, cx), - "Normal path should not require confirmation regardless of mode: {:?}", - mode - ); - }); - } - } - - #[gpui::test] - async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) { - // Set up with custom directories for deterministic testing - let temp_dir = tempfile::tempdir().unwrap(); - init_test_with_config(cx, temp_dir.path()); - - let tool = Arc::new(EditFileTool); - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({})).await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - - // Enable always_allow_tool_actions - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.always_allow_tool_actions = true; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - // Test that all paths that normally require confirmation are bypassed - let global_settings_path = paths::config_dir().join("settings.json"); - fs::create_dir_all(paths::config_dir()).unwrap(); - fs::write(&global_settings_path, "{}").unwrap(); - - let test_cases = vec![ - ".zed/settings.json", - "project/.zed/config.toml", - global_settings_path.to_str().unwrap(), - "/etc/hosts", - "/absolute/path/file.txt", - "../outside/project.txt", - ]; - - for path in test_cases { - let input = json!({ - "display_description": "Edit file", - "path": path, - "mode": "edit" - }); - cx.update(|cx| { - assert!( - !tool.needs_confirmation(&input, &project, cx), - "Path {} should not require confirmation when always_allow_tool_actions is true", - path - ); - }); - } - - // Disable always_allow_tool_actions and verify confirmation is required again - cx.update(|cx| { - let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); - settings.always_allow_tool_actions = false; - agent_settings::AgentSettings::override_global(settings, cx); - }); - - // Verify .zed path requires confirmation again - let input = json!({ - "display_description": "Edit file", - "path": ".zed/settings.json", - "mode": "edit" - }); - cx.update(|cx| { - assert!( - tool.needs_confirmation(&input, &project, cx), - ".zed path should require confirmation when always_allow_tool_actions is false" - ); - }); - } -} diff --git a/crates/edit_prediction_context/src/syntax_index.rs b/crates/edit_prediction_context/src/syntax_index.rs index 1353b0ea289f1a6e89c3d51b4ae9acb03d81377d..9b2b884e2f1d82e13bd6cb118ab1d58fbbe6416a 100644 --- a/crates/edit_prediction_context/src/syntax_index.rs +++ b/crates/edit_prediction_context/src/syntax_index.rs @@ -524,6 +524,8 @@ impl SyntaxIndex { let snapshot_task = worktree.update(cx, |worktree, cx| { let load_task = worktree.load_file(&project_path.path, None, false, true, None, cx); + let worktree_abs_path = worktree.abs_path(); + cx.spawn(async move |_this, cx| { let loaded_file = load_task.await?; let language = language.await?; diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 1050eaf88ac9669dd0b98499da459e191778cd9f..dc67ab3ed6c8cfdbe88809e32d615789c01eef60 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -64,18 +64,6 @@ pub struct Jupyter { pub enabled: bool, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct StatusBar { - /// Whether to display the active language button in the status bar. - /// - /// Default: true - pub active_language_button: bool, - /// Whether to show the cursor position button in the status bar. - /// - /// Default: true - pub cursor_position_button: bool, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct Toolbar { pub breadcrumbs: bool, diff --git a/crates/encodings_ui/Cargo.toml b/crates/encodings_ui/Cargo.toml index 31a7783d1332028733e0a6e3192f264f3fbb7f8c..64c5d14161963e536434971d9be3071337c37c75 100644 --- a/crates/encodings_ui/Cargo.toml +++ b/crates/encodings_ui/Cargo.toml @@ -18,7 +18,6 @@ settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true -workspace-hack.workspace = true zed_actions.workspace = true diff --git a/crates/encodings_ui/src/lib.rs b/crates/encodings_ui/src/lib.rs index b77cf991505aaa778e9c4a4aa4637a746a99f949..bb8d2d38583bc977347c192a87e3309910519e7f 100644 --- a/crates/encodings_ui/src/lib.rs +++ b/crates/encodings_ui/src/lib.rs @@ -75,7 +75,7 @@ impl Render for EncodingIndicator { let weak_workspace = workspace.weak_handle(); if let Some(path) = buffer.read(cx).file() { - let path = path.clone().path().to_path_buf(); + let path = path.clone().path().to_rel_path_buf(); workspace.toggle_modal(window, cx, |window, cx| { let selector = EncodingSelector::new( window, @@ -83,7 +83,7 @@ impl Render for EncodingIndicator { Action::Save, Some(buffer.downgrade()), weak_workspace, - Some(path), + Some(path.as_std_path().to_path_buf()), ); selector }); diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index 7746ac8519250d020299aa6b02807c8bea30f0c9..27811859b1ab953846252e40af7633662fa90ae8 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -363,7 +363,6 @@ mod tests { use language::Rope; use project::{FakeFs, Fs, Project}; use settings::SettingsStore; - use project::{FakeFs, Fs, Project, encodings::EncodingWrapper}; use std::path::PathBuf; use unindent::unindent; use util::path; diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 2afc02893fd6a78da4172ffd232d4118036007d9..d24934ee24d400e363fa30c37808299fdc822441 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -69,7 +69,6 @@ unicase = "2.6" util.workspace = true watch.workspace = true zlog.workspace = true -diffy = "0.4.2" encoding = "0.2.33" [dev-dependencies] diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index f68a4229f179db9aa6a1b729a6aa8162f0ccb8cb..21ad263511d078f900922c347fa35b443826e0ab 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -8,8 +8,7 @@ use anyhow::{Context as _, Result, anyhow}; use client::Client; use collections::{HashMap, HashSet, hash_map}; use encodings::Encoding; -use fs::Fs; -use futures::{Future, FutureExt as _, StreamExt, channel::oneshot, future::Shared}; +use futures::{Future, FutureExt as _, channel::oneshot, future::Shared}; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, }; @@ -27,7 +26,7 @@ use rpc::{ use smol::channel::Receiver; use std::{io, pin::pin, sync::Arc, time::Instant}; use text::{BufferId, ReplicaId}; -use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, paths::PathStyle, rel_path::RelPath}; +use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, rel_path::RelPath}; use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId}; /// A set of open buffers. @@ -398,7 +397,7 @@ impl LocalBufferStore { } let save = worktree.update(cx, |worktree, cx| { - worktree.write_file(path.as_ref(), text, line_ending, cx, (*encoding).clone()) + worktree.write_file(path.clone(), text, line_ending, cx, (*encoding).clone()) }); cx.spawn(async move |this, cx| { @@ -633,8 +632,6 @@ impl LocalBufferStore { detect_utf16: bool, cx: &mut Context, ) -> Task>> { - println!("{:?}", encoding); - let load_buffer = worktree.update(cx, |worktree, cx| { let reservation = cx.reserve_entity(); let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64()); @@ -653,7 +650,7 @@ impl LocalBufferStore { let buffer = cx.insert_entity(reservation, |_| { Buffer::build( - text::Buffer::new(0, buffer_id, loaded_file.text), + text::Buffer::new(ReplicaId::LOCAL, buffer_id, loaded_file.text), Some(loaded_file.file), Capability::ReadWrite, ) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 231349d63eef7cfba1b50592108f9e7229a30ed0..2650ca78e534c56bfcb12de2429b141a21360035 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -27,7 +27,7 @@ mod environment; use buffer_diff::BufferDiff; use context_server_store::ContextServerStore; -use encodings::EncodingOptions; +use encodings::{Encoding, EncodingOptions}; pub use environment::ProjectEnvironmentEvent; use git::repository::get_git_committer; use git_store::{Repository, RepositoryId}; @@ -5408,7 +5408,9 @@ impl Project { }; cx.spawn(async move |cx| { let file = worktree - .update(cx, |worktree, cx| worktree.load_file(&rel_path, cx))? + .update(cx, |worktree, cx| { + worktree.load_file(&rel_path, None, false, true, None, cx) + })? .await .context("Failed to load settings file")?; @@ -5423,6 +5425,11 @@ impl Project { Rope::from_str(&new_text, cx.background_executor()), line_ending, cx, + Encoding::default(), + new_text.into(), + line_ending, + cx, + Encoding::default(), ) })? .await diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs new file mode 100644 index 0000000000000000000000000000000000000000..b68632d9ca1e2f47a805944de51e94430947cb9b --- /dev/null +++ b/crates/project/src/project_search.rs @@ -0,0 +1,754 @@ +use std::{ + io::{BufRead, BufReader}, + path::Path, + pin::pin, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use anyhow::Context; +use collections::HashSet; +use fs::Fs; +use futures::{SinkExt, StreamExt, select_biased, stream::FuturesOrdered}; +use gpui::{App, AppContext, AsyncApp, Entity, Task}; +use language::{Buffer, BufferSnapshot}; +use parking_lot::Mutex; +use postage::oneshot; +use rpc::{AnyProtoClient, proto}; +use smol::{ + channel::{Receiver, Sender, bounded, unbounded}, + future::FutureExt, +}; + +use text::BufferId; +use util::{ResultExt, maybe, paths::compare_rel_paths}; +use worktree::{Entry, ProjectEntryId, Snapshot, Worktree}; + +use crate::{ + Project, ProjectItem, ProjectPath, RemotelyCreatedModels, + buffer_store::BufferStore, + search::{SearchQuery, SearchResult}, + worktree_store::WorktreeStore, +}; + +pub struct Search { + buffer_store: Entity, + worktree_store: Entity, + limit: usize, + kind: SearchKind, +} + +/// Represents search setup, before it is actually kicked off with Search::into_results +enum SearchKind { + /// Search for candidates by inspecting file contents on file system, avoiding loading the buffer unless we know that a given file contains a match. + Local { + fs: Arc, + worktrees: Vec>, + }, + /// Query remote host for candidates. As of writing, the host runs a local search in "buffers with matches only" mode. + Remote { + client: AnyProtoClient, + remote_id: u64, + models: Arc>, + }, + /// Run search against a known set of candidates. Even when working with a remote host, this won't round-trip to host. + OpenBuffersOnly, +} + +/// Represents results of project search and allows one to either obtain match positions OR +/// just the handles to buffers that may match the search. Grabbing the handles is cheaper than obtaining full match positions, because in that case we'll look for +/// at most one match in each file. +#[must_use] +pub struct SearchResultsHandle { + results: Receiver, + matching_buffers: Receiver>, + trigger_search: Box Task<()> + Send + Sync>, +} + +impl SearchResultsHandle { + pub fn results(self, cx: &mut App) -> Receiver { + (self.trigger_search)(cx).detach(); + self.results + } + pub fn matching_buffers(self, cx: &mut App) -> Receiver> { + (self.trigger_search)(cx).detach(); + self.matching_buffers + } +} + +#[derive(Clone)] +enum FindSearchCandidates { + Local { + fs: Arc, + /// Start off with all paths in project and filter them based on: + /// - Include filters + /// - Exclude filters + /// - Only open buffers + /// - Scan ignored files + /// Put another way: filter out files that can't match (without looking at file contents) + input_paths_rx: Receiver, + /// After that, if the buffer is not yet loaded, we'll figure out if it contains at least one match + /// based on disk contents of a buffer. This step is not performed for buffers we already have in memory. + confirm_contents_will_match_tx: Sender, + confirm_contents_will_match_rx: Receiver, + /// Of those that contain at least one match (or are already in memory), look for rest of matches (and figure out their ranges). + /// But wait - first, we need to go back to the main thread to open a buffer (& create an entity for it). + get_buffer_for_full_scan_tx: Sender, + }, + Remote, + OpenBuffersOnly, +} + +impl Search { + pub fn local( + fs: Arc, + buffer_store: Entity, + worktree_store: Entity, + limit: usize, + cx: &mut App, + ) -> Self { + let worktrees = worktree_store.read(cx).visible_worktrees(cx).collect(); + Self { + kind: SearchKind::Local { fs, worktrees }, + buffer_store, + worktree_store, + limit, + } + } + + pub(crate) fn remote( + buffer_store: Entity, + worktree_store: Entity, + limit: usize, + client_state: (AnyProtoClient, u64, Arc>), + ) -> Self { + Self { + kind: SearchKind::Remote { + client: client_state.0, + remote_id: client_state.1, + models: client_state.2, + }, + buffer_store, + worktree_store, + limit, + } + } + pub(crate) fn open_buffers_only( + buffer_store: Entity, + worktree_store: Entity, + limit: usize, + ) -> Self { + Self { + kind: SearchKind::OpenBuffersOnly, + buffer_store, + worktree_store, + limit, + } + } + + pub(crate) const MAX_SEARCH_RESULT_FILES: usize = 5_000; + pub(crate) const MAX_SEARCH_RESULT_RANGES: usize = 10_000; + /// Prepares a project search run. The resulting [`SearchResultsHandle`] has to be used to specify whether you're interested in matching buffers + /// or full search results. + pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle { + let mut open_buffers = HashSet::default(); + let mut unnamed_buffers = Vec::new(); + const MAX_CONCURRENT_BUFFER_OPENS: usize = 64; + let buffers = self.buffer_store.read(cx); + for handle in buffers.buffers() { + let buffer = handle.read(cx); + if !buffers.is_searchable(&buffer.remote_id()) { + continue; + } else if let Some(entry_id) = buffer.entry_id(cx) { + open_buffers.insert(entry_id); + } else { + self.limit -= self.limit.saturating_sub(1); + unnamed_buffers.push(handle) + }; + } + let executor = cx.background_executor().clone(); + let (tx, rx) = unbounded(); + let (grab_buffer_snapshot_tx, grab_buffer_snapshot_rx) = unbounded(); + let matching_buffers = grab_buffer_snapshot_rx.clone(); + let trigger_search = Box::new(move |cx: &mut App| { + cx.spawn(async move |cx| { + for buffer in unnamed_buffers { + _ = grab_buffer_snapshot_tx.send(buffer).await; + } + + let (find_all_matches_tx, find_all_matches_rx) = + bounded(MAX_CONCURRENT_BUFFER_OPENS); + + let (candidate_searcher, tasks) = match self.kind { + SearchKind::OpenBuffersOnly => { + let Ok(open_buffers) = cx.update(|cx| self.all_loaded_buffers(&query, cx)) + else { + return; + }; + let fill_requests = cx + .background_spawn(async move { + for buffer in open_buffers { + if let Err(_) = grab_buffer_snapshot_tx.send(buffer).await { + return; + } + } + }) + .boxed_local(); + (FindSearchCandidates::OpenBuffersOnly, vec![fill_requests]) + } + SearchKind::Local { + fs, + ref mut worktrees, + } => { + let (get_buffer_for_full_scan_tx, get_buffer_for_full_scan_rx) = + unbounded(); + let (confirm_contents_will_match_tx, confirm_contents_will_match_rx) = + bounded(64); + let (sorted_search_results_tx, sorted_search_results_rx) = unbounded(); + + let (input_paths_tx, input_paths_rx) = unbounded(); + + let tasks = vec![ + cx.spawn(Self::provide_search_paths( + std::mem::take(worktrees), + query.include_ignored(), + input_paths_tx, + sorted_search_results_tx, + )) + .boxed_local(), + Self::open_buffers( + &self.buffer_store, + get_buffer_for_full_scan_rx, + grab_buffer_snapshot_tx, + cx.clone(), + ) + .boxed_local(), + cx.background_spawn(Self::maintain_sorted_search_results( + sorted_search_results_rx, + get_buffer_for_full_scan_tx.clone(), + self.limit, + )) + .boxed_local(), + ]; + ( + FindSearchCandidates::Local { + fs, + get_buffer_for_full_scan_tx, + confirm_contents_will_match_tx, + confirm_contents_will_match_rx, + input_paths_rx, + }, + tasks, + ) + } + SearchKind::Remote { + client, + remote_id, + models, + } => { + let request = client.request(proto::FindSearchCandidates { + project_id: remote_id, + query: Some(query.to_proto()), + limit: self.limit as _, + }); + let Ok(guard) = cx.update(|cx| { + Project::retain_remotely_created_models_impl( + &models, + &self.buffer_store, + &self.worktree_store, + cx, + ) + }) else { + return; + }; + let buffer_store = self.buffer_store.downgrade(); + let issue_remote_buffers_request = cx + .spawn(async move |cx| { + let _ = maybe!(async move { + let response = request.await?; + + for buffer_id in response.buffer_ids { + let buffer_id = BufferId::new(buffer_id)?; + let buffer = buffer_store + .update(cx, |buffer_store, cx| { + buffer_store.wait_for_remote_buffer(buffer_id, cx) + })? + .await?; + let _ = grab_buffer_snapshot_tx.send(buffer).await; + } + + drop(guard); + anyhow::Ok(()) + }) + .await + .log_err(); + }) + .boxed_local(); + ( + FindSearchCandidates::Remote, + vec![issue_remote_buffers_request], + ) + } + }; + + let matches_count = AtomicUsize::new(0); + let matched_buffer_count = AtomicUsize::new(0); + + let worker_pool = executor.scoped(|scope| { + let num_cpus = executor.num_cpus(); + + assert!(num_cpus > 0); + for _ in 0..executor.num_cpus() - 1 { + let worker = Worker { + query: &query, + open_buffers: &open_buffers, + matched_buffer_count: &matched_buffer_count, + matches_count: &matches_count, + candidates: candidate_searcher.clone(), + find_all_matches_rx: find_all_matches_rx.clone(), + publish_matches: tx.clone(), + }; + scope.spawn(worker.run()); + } + drop(tx); + drop(find_all_matches_rx); + drop(candidate_searcher); + }); + + let buffer_snapshots = Self::grab_buffer_snapshots( + grab_buffer_snapshot_rx, + find_all_matches_tx, + cx.clone(), + ); + futures::future::join_all( + [worker_pool.boxed_local(), buffer_snapshots.boxed_local()] + .into_iter() + .chain(tasks), + ) + .await; + }) + }); + + SearchResultsHandle { + results: rx, + matching_buffers, + trigger_search, + } + } + + fn provide_search_paths( + worktrees: Vec>, + include_ignored: bool, + tx: Sender, + results: Sender>, + ) -> impl AsyncFnOnce(&mut AsyncApp) { + async move |cx| { + _ = maybe!(async move { + for worktree in worktrees { + let (mut snapshot, worktree_settings) = worktree + .read_with(cx, |this, _| { + Some((this.snapshot(), this.as_local()?.settings())) + })? + .context("The worktree is not local")?; + if include_ignored { + // Pre-fetch all of the ignored directories as they're going to be searched. + let mut entries_to_refresh = vec![]; + for entry in snapshot.entries(include_ignored, 0) { + if entry.is_ignored && entry.kind.is_unloaded() { + if !worktree_settings.is_path_excluded(&entry.path) { + entries_to_refresh.push(entry.path.clone()); + } + } + } + let barrier = worktree.update(cx, |this, _| { + let local = this.as_local_mut()?; + let barrier = entries_to_refresh + .into_iter() + .map(|path| local.add_path_prefix_to_scan(path).into_future()) + .collect::>(); + Some(barrier) + })?; + if let Some(barriers) = barrier { + futures::future::join_all(barriers).await; + } + snapshot = worktree.read_with(cx, |this, _| this.snapshot())?; + } + cx.background_executor() + .scoped(|scope| { + scope.spawn(async { + for entry in snapshot.files(include_ignored, 0) { + let (should_scan_tx, should_scan_rx) = oneshot::channel(); + let Ok(_) = tx + .send(InputPath { + entry: entry.clone(), + snapshot: snapshot.clone(), + should_scan_tx, + }) + .await + else { + return; + }; + if results.send(should_scan_rx).await.is_err() { + return; + }; + } + }) + }) + .await; + } + anyhow::Ok(()) + }) + .await; + } + } + + async fn maintain_sorted_search_results( + rx: Receiver>, + paths_for_full_scan: Sender, + limit: usize, + ) { + let mut rx = pin!(rx); + let mut matched = 0; + while let Some(mut next_path_result) = rx.next().await { + let Some(successful_path) = next_path_result.next().await else { + // This math did not produce a match, hence skip it. + continue; + }; + if paths_for_full_scan.send(successful_path).await.is_err() { + return; + }; + matched += 1; + if matched >= limit { + break; + } + } + } + + /// Background workers cannot open buffers by themselves, hence main thread will do it on their behalf. + async fn open_buffers( + buffer_store: &Entity, + rx: Receiver, + find_all_matches_tx: Sender>, + mut cx: AsyncApp, + ) { + let mut rx = pin!(rx.ready_chunks(64)); + _ = maybe!(async move { + while let Some(requested_paths) = rx.next().await { + let mut buffers = buffer_store.update(&mut cx, |this, cx| { + requested_paths + .into_iter() + .map(|path| this.open_buffer(path, None, false, true, cx)) + .collect::>() + })?; + + while let Some(buffer) = buffers.next().await { + if let Some(buffer) = buffer.log_err() { + find_all_matches_tx.send(buffer).await?; + } + } + } + Result::<_, anyhow::Error>::Ok(()) + }) + .await; + } + + async fn grab_buffer_snapshots( + rx: Receiver>, + find_all_matches_tx: Sender<(Entity, BufferSnapshot)>, + mut cx: AsyncApp, + ) { + _ = maybe!(async move { + while let Ok(buffer) = rx.recv().await { + let snapshot = buffer.read_with(&mut cx, |this, _| this.snapshot())?; + find_all_matches_tx.send((buffer, snapshot)).await?; + } + Result::<_, anyhow::Error>::Ok(()) + }) + .await; + } + + fn all_loaded_buffers(&self, search_query: &SearchQuery, cx: &App) -> Vec> { + let worktree_store = self.worktree_store.read(cx); + let mut buffers = search_query + .buffers() + .into_iter() + .flatten() + .filter(|buffer| { + let b = buffer.read(cx); + if let Some(file) = b.file() { + if !search_query.match_path(file.path().as_std_path()) { + return false; + } + if !search_query.include_ignored() + && let Some(entry) = b + .entry_id(cx) + .and_then(|entry_id| worktree_store.entry_for_id(entry_id, cx)) + && entry.is_ignored + { + return false; + } + } + true + }) + .cloned() + .collect::>(); + buffers.sort_by(|a, b| { + let a = a.read(cx); + let b = b.read(cx); + match (a.file(), b.file()) { + (None, None) => a.remote_id().cmp(&b.remote_id()), + (None, Some(_)) => std::cmp::Ordering::Less, + (Some(_), None) => std::cmp::Ordering::Greater, + (Some(a), Some(b)) => compare_rel_paths((a.path(), true), (b.path(), true)), + } + }); + + buffers + } +} + +struct Worker<'search> { + query: &'search SearchQuery, + matched_buffer_count: &'search AtomicUsize, + matches_count: &'search AtomicUsize, + open_buffers: &'search HashSet, + candidates: FindSearchCandidates, + /// Ok, we're back in background: run full scan & find all matches in a given buffer snapshot. + find_all_matches_rx: Receiver<(Entity, BufferSnapshot)>, + /// Cool, we have results; let's share them with the world. + publish_matches: Sender, +} + +impl Worker<'_> { + async fn run(mut self) { + let ( + input_paths_rx, + confirm_contents_will_match_rx, + mut confirm_contents_will_match_tx, + mut get_buffer_for_full_scan_tx, + fs, + ) = match self.candidates { + FindSearchCandidates::Local { + fs, + input_paths_rx, + confirm_contents_will_match_rx, + confirm_contents_will_match_tx, + get_buffer_for_full_scan_tx, + } => ( + input_paths_rx, + confirm_contents_will_match_rx, + confirm_contents_will_match_tx, + get_buffer_for_full_scan_tx, + Some(fs), + ), + FindSearchCandidates::Remote | FindSearchCandidates::OpenBuffersOnly => ( + unbounded().1, + unbounded().1, + unbounded().0, + unbounded().0, + None, + ), + }; + let mut find_all_matches = pin!(self.find_all_matches_rx.fuse()); + let mut find_first_match = pin!(confirm_contents_will_match_rx.fuse()); + let mut scan_path = pin!(input_paths_rx.fuse()); + + loop { + let handler = RequestHandler { + query: self.query, + open_entries: &self.open_buffers, + fs: fs.as_deref(), + matched_buffer_count: self.matched_buffer_count, + matches_count: self.matches_count, + confirm_contents_will_match_tx: &confirm_contents_will_match_tx, + get_buffer_for_full_scan_tx: &get_buffer_for_full_scan_tx, + publish_matches: &self.publish_matches, + }; + // Whenever we notice that some step of a pipeline is closed, we don't want to close subsequent + // steps straight away. Another worker might be about to produce a value that will + // be pushed there, thus we'll replace current worker's pipe with a dummy one. + // That way, we'll only ever close a next-stage channel when ALL workers do so. + select_biased! { + find_all_matches = find_all_matches.next() => { + + if self.publish_matches.is_closed() { + break; + } + let Some(matches) = find_all_matches else { + self.publish_matches = bounded(1).0; + continue; + }; + let result = handler.handle_find_all_matches(matches).await; + if let Some(_should_bail) = result { + + self.publish_matches = bounded(1).0; + continue; + } + }, + find_first_match = find_first_match.next() => { + if let Some(buffer_with_at_least_one_match) = find_first_match { + handler.handle_find_first_match(buffer_with_at_least_one_match).await; + } else { + get_buffer_for_full_scan_tx = bounded(1).0; + } + + }, + scan_path = scan_path.next() => { + if let Some(path_to_scan) = scan_path { + handler.handle_scan_path(path_to_scan).await; + } else { + // If we're the last worker to notice that this is not producing values, close the upstream. + confirm_contents_will_match_tx = bounded(1).0; + } + + } + complete => { + break + }, + + } + } + } +} + +struct RequestHandler<'worker> { + query: &'worker SearchQuery, + fs: Option<&'worker dyn Fs>, + open_entries: &'worker HashSet, + matched_buffer_count: &'worker AtomicUsize, + matches_count: &'worker AtomicUsize, + + confirm_contents_will_match_tx: &'worker Sender, + get_buffer_for_full_scan_tx: &'worker Sender, + publish_matches: &'worker Sender, +} + +struct LimitReached; + +impl RequestHandler<'_> { + async fn handle_find_all_matches( + &self, + (buffer, snapshot): (Entity, BufferSnapshot), + ) -> Option { + let ranges = self + .query + .search(&snapshot, None) + .await + .iter() + .map(|range| snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end)) + .collect::>(); + + let matched_ranges = ranges.len(); + if self.matched_buffer_count.fetch_add(1, Ordering::Release) + > Search::MAX_SEARCH_RESULT_FILES + || self + .matches_count + .fetch_add(matched_ranges, Ordering::Release) + > Search::MAX_SEARCH_RESULT_RANGES + { + _ = self.publish_matches.send(SearchResult::LimitReached).await; + Some(LimitReached) + } else { + _ = self + .publish_matches + .send(SearchResult::Buffer { buffer, ranges }) + .await; + None + } + } + async fn handle_find_first_match(&self, mut entry: MatchingEntry) { + _=maybe!(async move { + let abs_path = entry.worktree_root.join(entry.path.path.as_std_path()); + let Some(file) = self.fs.context("Trying to query filesystem in remote project search")?.open_sync(&abs_path).await.log_err() else { + return anyhow::Ok(()); + }; + + let mut file = BufReader::new(file); + let file_start = file.fill_buf()?; + + if let Err(Some(starting_position)) = + std::str::from_utf8(file_start).map_err(|e| e.error_len()) + { + // Before attempting to match the file content, throw away files that have invalid UTF-8 sequences early on; + // That way we can still match files in a streaming fashion without having look at "obviously binary" files. + log::debug!( + "Invalid UTF-8 sequence in file {abs_path:?} at byte position {starting_position}" + ); + return Ok(()); + } + + if self.query.detect(file).unwrap_or(false) { + // Yes, we should scan the whole file. + entry.should_scan_tx.send(entry.path).await?; + } + Ok(()) + }).await; + } + + async fn handle_scan_path(&self, req: InputPath) { + _ = maybe!(async move { + let InputPath { + entry, + + snapshot, + should_scan_tx, + } = req; + + if entry.is_fifo || !entry.is_file() { + return Ok(()); + } + + if self.query.filters_path() { + let matched_path = if self.query.match_full_paths() { + let mut full_path = snapshot.root_name().as_std_path().to_owned(); + full_path.push(entry.path.as_std_path()); + self.query.match_path(&full_path) + } else { + self.query.match_path(entry.path.as_std_path()) + }; + if !matched_path { + return Ok(()); + } + } + + if self.open_entries.contains(&entry.id) { + // The buffer is already in memory and that's the version we want to scan; + // hence skip the dilly-dally and look for all matches straight away. + self.get_buffer_for_full_scan_tx + .send(ProjectPath { + worktree_id: snapshot.id(), + path: entry.path.clone(), + }) + .await?; + } else { + self.confirm_contents_will_match_tx + .send(MatchingEntry { + should_scan_tx: should_scan_tx, + worktree_root: snapshot.abs_path().clone(), + path: ProjectPath { + worktree_id: snapshot.id(), + path: entry.path.clone(), + }, + }) + .await?; + } + + anyhow::Ok(()) + }) + .await; + } +} + +struct InputPath { + entry: Entry, + snapshot: Snapshot, + should_scan_tx: oneshot::Sender, +} + +struct MatchingEntry { + worktree_root: Arc, + path: ProjectPath, + should_scan_tx: oneshot::Sender, +} diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 8a78c3e0082b289ffc9e0ac04b69028fe39561e5..6671106bffbc44dd0d482c70bd22a65dce4856d3 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -3878,7 +3878,14 @@ async fn test_rename_file_to_new_directory(cx: &mut gpui::TestAppContext) { assert_eq!( worktree .update(cx, |worktree, cx| { - worktree.load_file(rel_path("dir1/dir2/dir3/test.txt"), cx) + worktree.load_file( + rel_path("dir1/dir2/dir3/test.txt"), + None, + false, + true, + None, + cx, + ) }) .await .unwrap() @@ -3925,7 +3932,7 @@ async fn test_rename_file_to_new_directory(cx: &mut gpui::TestAppContext) { assert_eq!( worktree .update(cx, |worktree, cx| { - worktree.load_file(rel_path("dir1/dir2/test.txt"), cx) + worktree.load_file(rel_path("dir1/dir2/test.txt"), None, false, true, None, cx) }) .await .unwrap() @@ -9003,7 +9010,14 @@ async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) { let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; tree.update(cx, |tree, cx| { - tree.load_file(rel_path("project/target/debug/important_text.txt"), cx) + tree.load_file( + rel_path("project/target/debug/important_text.txt"), + None, + false, + true, + None, + cx, + ) }) .await .unwrap(); @@ -9164,7 +9178,14 @@ async fn test_odd_events_for_ignored_dirs( let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.update(cx, |tree, cx| { - tree.load_file(rel_path("target/debug/foo.txt"), cx) + tree.load_file( + rel_path("target/debug/foo.txt"), + None, + false, + true, + None, + cx, + ) }) .await .unwrap(); diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index cc9cf0225a13a7f05d309b3355736940c9c75e63..9859b27daee0a101c8478385b3182d668995843e 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -506,16 +506,7 @@ impl HeadlessProject { let (buffer_store, buffer) = this.update(&mut cx, |this, cx| { let buffer_store = this.buffer_store.clone(); let buffer = this.buffer_store.update(cx, |buffer_store, cx| { - buffer_store.open_buffer( - ProjectPath { - worktree_id, - path: Arc::::from_proto(message.payload.path), - }, - None, - false, - true, - cx, - ) + buffer_store.open_buffer(ProjectPath { worktree_id, path }, None, false, true, cx) }); anyhow::Ok((buffer_store, buffer)) })??; diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index aadf2c2376f360c4c99f2628e05bba9b2d7962a2..fffb6ae2d560afdd58baac543c061c3ad03eab98 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -6,7 +6,6 @@ use agent::{AgentTool, ReadFileTool, ReadFileToolInput, ToolCallEventStream}; use client::{Client, UserStore}; use clock::FakeSystemClock; use collections::{HashMap, HashSet}; -use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModel}; use encodings::Encoding; use extension::ExtensionHostProxy; @@ -17,6 +16,7 @@ use language::{ Buffer, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageRegistry, LineEnding, Rope, language_settings::{AllLanguageSettings, language_settings}, }; +use language_model::LanguageModelToolResultContent; use lsp::{CompletionContext, CompletionResponse, CompletionTriggerKind, LanguageServerName}; use node_runtime::NodeRuntime; use project::{ diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index a36a81f6d95ce5a8907a81e184722615e8baab93..fa40192cb093f12d1f4d2168f9234bd1d9c6abd5 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -706,7 +706,7 @@ impl Worktree { pub fn load_file( &self, - path: &Path, + path: &RelPath, encoding: Option, force: bool, detect_utf16: bool, @@ -1326,7 +1326,7 @@ impl LocalWorktree { fn load_file( &self, - path: &Path, + path: &RelPath, encoding: Option, force: bool, detect_utf16: bool, @@ -3189,7 +3189,7 @@ impl language::LocalFile for File { let abs_path = worktree.absolutize(&self.path); let fs = worktree.fs.clone(); cx.background_spawn(async move { - fs.load_with_encoding(&abs_path?, encoding, force, detect_utf16, buffer_encoding) + fs.load_with_encoding(&abs_path, encoding, force, detect_utf16, buffer_encoding) .await }) } diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index 69ee98f7bdf3bae64f7f9ac6d1474c158c8fdd57..7f873814c0079fd4a21fecf9b1066257da41c928 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -468,7 +468,7 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) { let loaded = tree .update(cx, |tree, cx| { tree.load_file( - "one/node_modules/b/b1.js".as_ref(), + rel_path("one/node_modules/b/b1.js"), None, false, false, @@ -515,7 +515,7 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) { let loaded = tree .update(cx, |tree, cx| { tree.load_file( - "one/node_modules/a/a2.js".as_ref(), + rel_path("one/node_modules/a/a2.js"), None, false, false, @@ -1966,101 +1966,6 @@ fn random_filename(rng: &mut impl Rng) -> String { .collect() } -#[gpui::test] -async fn test_rename_file_to_new_directory(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.background_executor.clone()); - let expected_contents = "content"; - fs.as_fake() - .insert_tree( - "/root", - json!({ - "test.txt": expected_contents - }), - ) - .await; - let worktree = Worktree::local( - Path::new("/root"), - true, - fs.clone(), - Arc::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - cx.read(|cx| worktree.read(cx).as_local().unwrap().scan_complete()) - .await; - - let entry_id = worktree.read_with(cx, |worktree, _| { - worktree.entry_for_path("test.txt").unwrap().id - }); - let _result = worktree - .update(cx, |worktree, cx| { - worktree.rename_entry(entry_id, Path::new("dir1/dir2/dir3/test.txt"), cx) - }) - .await - .unwrap(); - worktree.read_with(cx, |worktree, _| { - assert!( - worktree.entry_for_path("test.txt").is_none(), - "Old file should have been removed" - ); - assert!( - worktree.entry_for_path("dir1/dir2/dir3/test.txt").is_some(), - "Whole directory hierarchy and the new file should have been created" - ); - }); - assert_eq!( - worktree - .update(cx, |worktree, cx| { - worktree.load_file("dir1/dir2/dir3/test.txt".as_ref(), None, cx) - }) - .await - .unwrap() - .text, - expected_contents, - "Moved file's contents should be preserved" - ); - - let entry_id = worktree.read_with(cx, |worktree, _| { - worktree - .entry_for_path("dir1/dir2/dir3/test.txt") - .unwrap() - .id - }); - let _result = worktree - .update(cx, |worktree, cx| { - worktree.rename_entry(entry_id, Path::new("dir1/dir2/test.txt"), cx) - }) - .await - .unwrap(); - worktree.read_with(cx, |worktree, _| { - assert!( - worktree.entry_for_path("test.txt").is_none(), - "First file should not reappear" - ); - assert!( - worktree.entry_for_path("dir1/dir2/dir3/test.txt").is_none(), - "Old file should have been removed" - ); - assert!( - worktree.entry_for_path("dir1/dir2/test.txt").is_some(), - "No error should have occurred after moving into existing directory" - ); - }); - assert_eq!( - worktree - .update(cx, |worktree, cx| { - worktree.load_file("dir1/dir2/test.txt".as_ref(), None, cx) - }) - .await - .unwrap() - .text, - expected_contents, - "Moved file's contents should be preserved" - ); -} - #[gpui::test] async fn test_private_single_file_worktree(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 139f1d63ae792a05a193b61a5ff9925796284cfa..73681693e91c590777fad829974684b005714412 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -52,7 +52,6 @@ debugger_ui.workspace = true diagnostics.workspace = true editor.workspace = true zeta2_tools.workspace = true -edit_prediction_tools.workspace = true encodings.workspace = true encodings_ui.workspace = true env_logger.workspace = true diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index bc6c25105e69eb85e8db3714c48dc30791683109..790c3d3ba2b567ef90b493c9c0dd1dbb43ca0726 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -653,6 +653,7 @@ mod tests { ipc::{self}, }; use editor::Editor; + use encodings::Encoding; use gpui::TestAppContext; use language::LineEnding; use remote::SshConnectionOptions; @@ -863,6 +864,7 @@ mod tests { Path::new(file1_path), &Rope::from_str("content1", cx.background_executor()), LineEnding::Unix, + Encoding::default(), ) .await .unwrap(); @@ -877,6 +879,7 @@ mod tests { Path::new(file2_path), &Rope::from_str("content2", cx.background_executor()), LineEnding::Unix, + Encoding::default(), ) .await .unwrap(); diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 02eb5dcae0ac292c729e58f4414163cfdf9fd635..f558c0e782b6f78348610ccfa62c3641ec6e852a 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -1986,7 +1986,7 @@ mod tests { .worktree_for_root_name("closed_source_worktree", cx) .unwrap(); worktree2.update(cx, |worktree2, cx| { - worktree2.load_file(Path::new("main.rs"), None, false, true, None, cx) + worktree2.load_file(rel_path("main.rs"), None, false, true, None, cx) }) }) .await