diff --git a/Cargo.lock b/Cargo.lock index 8820eb703630996f03b27aba6213bc5b04ec2bf3..d466c739f2aebf86b46fe1c5b6c3e3309b06c11d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,7 @@ dependencies = [ "db", "derive_more 0.99.20", "editor", + "encoding", "env_logger 0.11.8", "fs", "futures 0.3.31", @@ -3354,6 +3355,7 @@ dependencies = [ "dashmap 6.1.0", "debugger_ui", "editor", + "encoding", "envy", "extension", "file_finder", @@ -5589,6 +5591,7 @@ dependencies = [ name = "encodings" version = "0.1.0" dependencies = [ + "anyhow", "editor", "encoding", "fuzzy", @@ -5977,6 +5980,7 @@ dependencies = [ "criterion", "ctor", "dap", + "encoding", "extension", "fs", "futures 0.3.31", @@ -6490,6 +6494,7 @@ dependencies = [ "paths", "proto", "rope", + "schemars", "serde", "serde_json", "smol", @@ -7178,6 +7183,7 @@ dependencies = [ "ctor", "db", "editor", + "encoding", "futures 0.3.31", "fuzzy", "git", @@ -13068,6 +13074,7 @@ dependencies = [ "context_server", "dap", "dap_adapters", + "encoding", "extension", "fancy-regex 0.14.0", "fs", @@ -14043,6 +14050,7 @@ dependencies = [ "dap_adapters", "debug_adapter_extension", "editor", + "encoding", "env_logger 0.11.8", "extension", "extension_host", @@ -20807,6 +20815,7 @@ dependencies = [ "component", "dap", "db", + "encoding", "fs", "futures 0.3.31", "gpui", diff --git a/Cargo.toml b/Cargo.toml index bf29873d2f1f552f178b80efbdf78477ea6ab577..155099bfb5920f610a710c773de789e34a557664 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -498,6 +498,7 @@ documented = "0.9.1" dotenvy = "0.15.0" ec4rs = "1.1" emojis = "0.6.1" +encoding = "0.2.33" env_logger = "0.11" exec = "0.3.1" fancy-regex = "0.14.0" diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 078273dbb8a4399e1770ca08daeb1f7f44491e2a..aea3d64c39ccb584f3bbe74a12a0fc9459e21667 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -563,7 +563,8 @@ mod tests { use super::*; use crate::{ContextServerRegistry, Templates}; use client::TelemetrySettings; - use fs::Fs; + use encoding::all::UTF_8; + use fs::{Fs, encodings::EncodingWrapper}; use gpui::{TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; use prompt_store::ProjectContext; @@ -744,6 +745,7 @@ mod tests { path!("/root/src/main.rs").as_ref(), &Rope::from_str_small("initial content"), language::LineEnding::Unix, + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -911,6 +913,7 @@ mod tests { path!("/root/src/main.rs").as_ref(), &Rope::from_str_small("initial content"), language::LineEnding::Unix, + EncodingWrapper::new(UTF_8), ) .await .unwrap(); diff --git a/crates/agent2/Cargo.toml b/crates/agent2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a6b82f29b1e9cd5a93d0cb7e658cf51528f00969 --- /dev/null +++ b/crates/agent2/Cargo.toml @@ -0,0 +1,104 @@ +[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 +encoding.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/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..e48dc9a0c5edeca1f41a40a34e2415711f7ac520 --- /dev/null +++ b/crates/assistant_tools/Cargo.toml @@ -0,0 +1,94 @@ +[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 +encoding.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 new file mode 100644 index 0000000000000000000000000000000000000000..3fa63e59ef7cdcd85ad5c1c8a304f900e807e245 --- /dev/null +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -0,0 +1,2439 @@ +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, encodings::EncodingWrapper}; + use client::TelemetrySettings; + use encoding::all::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, + EncodingWrapper::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, + EncodingWrapper::new(UTF_8), + ) + .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/collab/Cargo.toml b/crates/collab/Cargo.toml index c8467da7954b195c0eef09ce1bed8361d7fa2c7b..f48cebaaaa91a0e1a9180dc49ceb3699f7e469dc 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -31,6 +31,7 @@ chrono.workspace = true clock.workspace = true collections.workspace = true dashmap.workspace = true +encoding.workspace = true envy = "0.4.2" futures.workspace = true gpui.workspace = true diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 37e6622b0343bca9ae6b9179c830071999bf51df..1e0e5b4c09c91061e48a786318baecc9ab663521 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -12,7 +12,8 @@ use buffer_diff::{DiffHunkSecondaryStatus, DiffHunkStatus, assert_hunks}; use call::{ActiveCall, ParticipantLocation, Room, room}; use client::{RECEIVE_TIMEOUT, User}; use collections::{HashMap, HashSet}; -use fs::{FakeFs, Fs as _, RemoveOptions}; +use encoding::all::UTF_8; +use fs::{FakeFs, Fs as _, RemoveOptions, encodings::EncodingWrapper}; use futures::{StreamExt as _, channel::mpsc}; use git::{ repository::repo_path, @@ -3701,6 +3702,7 @@ async fn test_buffer_reloading( path!("/dir/a.txt").as_ref(), &new_contents, LineEnding::Windows, + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -4481,6 +4483,7 @@ async fn test_reloading_buffer_manually( path!("/a/a.rs").as_ref(), &Rope::from_str_small("let seven = 7;"), LineEnding::Unix, + EncodingWrapper::new(UTF_8), ) .await .unwrap(); diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 399f1a663fe72798a4269804955dcfd3678c5cca..9106edd483a5f426f4d4f11feb059eb97d26e8f5 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -5,7 +5,8 @@ use async_trait::async_trait; use call::ActiveCall; use collections::{BTreeMap, HashMap}; use editor::Bias; -use fs::{FakeFs, Fs as _}; +use encoding::all::UTF_8; +use fs::{FakeFs, Fs as _, encodings::EncodingWrapper}; use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::{BackgroundExecutor, Entity, TestAppContext}; use language::{ @@ -943,6 +944,7 @@ impl RandomizedTest for ProjectCollaborationTest { &path, &Rope::from_str_small(content.as_str()), text::LineEnding::Unix, + EncodingWrapper::new(UTF_8), ) .await .unwrap(); diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 01a1f8d70b9d2bcfb04bb505b8346c4da2aa8263..047707169d2c7ae5bbf0a156c86c289127aa5506 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -30,6 +30,7 @@ client.workspace = true collections.workspace = true command_palette_hooks.workspace = true dirs.workspace = true +encoding.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true @@ -53,7 +54,6 @@ ui.workspace = true util.workspace = true workspace.workspace = true itertools.workspace = true -encoding = "0.2.33" [target.'cfg(windows)'.dependencies] diff --git a/crates/encodings/Cargo.toml b/crates/encodings/Cargo.toml index 70b11dd545df5afc472fe3da97cc586341dab407..dd47679500f5bcda62577387cbddb8690284508b 100644 --- a/crates/encodings/Cargo.toml +++ b/crates/encodings/Cargo.toml @@ -5,6 +5,7 @@ publish.workspace = true edition.workspace = true [dependencies] +anyhow.workspace = true ui.workspace = true workspace.workspace = true gpui.workspace = true @@ -12,7 +13,7 @@ picker.workspace = true util.workspace = true fuzzy.workspace = true editor.workspace = true -encoding = "0.2.33" +encoding.workspace = true language.workspace = true [lints] diff --git a/crates/encodings/src/lib.rs b/crates/encodings/src/lib.rs index 53155159980f612d402ecf66ea37783acef10914..91c1e8799a3483fedb128d0220fd02c2c043c3ad 100644 --- a/crates/encodings/src/lib.rs +++ b/crates/encodings/src/lib.rs @@ -1,3 +1,4 @@ +///! A crate for handling file encodings in the text editor. use editor::Editor; use encoding::Encoding; use encoding::all::{ @@ -12,7 +13,7 @@ use ui::{Button, ButtonCommon, Context, LabelSize, Render, Tooltip, Window, div} use ui::{Clickable, ParentElement}; use workspace::{ItemHandle, StatusItemView, Workspace}; -use crate::selectors::save_or_reopen::{EncodingSaveOrReopenSelector, get_current_encoding}; +use crate::selectors::save_or_reopen::EncodingSaveOrReopenSelector; /// A status bar item that shows the current file encoding and allows changing it. pub struct EncodingIndicator { @@ -44,8 +45,6 @@ impl Render for EncodingIndicator { } impl EncodingIndicator { - pub fn get_current_encoding(&self, cx: &mut Context, editor: WeakEntity) {} - pub fn new( encoding: Option<&'static dyn encoding::Encoding>, workspace: WeakEntity, @@ -187,3 +186,48 @@ pub fn encoding_from_index(index: usize) -> &'static dyn Encoding { _ => UTF_8, } } + +/// Get an encoding from its name. +pub fn encoding_from_name(name: &str) -> &'static dyn Encoding { + match name { + "UTF-8" => UTF_8, + "UTF-16 LE" => UTF_16LE, + "UTF-16 BE" => UTF_16BE, + "IBM866" => IBM866, + "ISO 8859-1" => ISO_8859_1, + "ISO 8859-2" => ISO_8859_2, + "ISO 8859-3" => ISO_8859_3, + "ISO 8859-4" => ISO_8859_4, + "ISO 8859-5" => ISO_8859_5, + "ISO 8859-6" => ISO_8859_6, + "ISO 8859-7" => ISO_8859_7, + "ISO 8859-8" => ISO_8859_8, + "ISO 8859-10" => ISO_8859_10, + "ISO 8859-13" => ISO_8859_13, + "ISO 8859-14" => ISO_8859_14, + "ISO 8859-15" => ISO_8859_15, + "ISO 8859-16" => ISO_8859_16, + "KOI8-R" => KOI8_R, + "KOI8-U" => KOI8_U, + "MacRoman" => MAC_ROMAN, + "Mac Cyrillic" => MAC_CYRILLIC, + "Windows-874" => WINDOWS_874, + "Windows-1250" => WINDOWS_1250, + "Windows-1251" => WINDOWS_1251, + "Windows-1252" => WINDOWS_1252, + "Windows-1253" => WINDOWS_1253, + "Windows-1254" => WINDOWS_1254, + "Windows-1255" => WINDOWS_1255, + "Windows-1256" => WINDOWS_1256, + "Windows-1257" => WINDOWS_1257, + "Windows-1258" => WINDOWS_1258, + "Windows-949" => WINDOWS_949, + "EUC-JP" => EUC_JP, + "ISO 2022-JP" => ISO_2022_JP, + "GBK" => GBK, + "GB18030" => GB18030, + "Big5" => BIG5_2003, + "HZ-GB-2312" => HZ, + _ => UTF_8, // Default to UTF-8 for unknown names + } +} diff --git a/crates/encodings/src/selectors.rs b/crates/encodings/src/selectors.rs index c25b56be56be2de9a80f1597a02b72b7498a91e1..9cca1551ec8f6def6648a10ca09a1bd65f37e419 100644 --- a/crates/encodings/src/selectors.rs +++ b/crates/encodings/src/selectors.rs @@ -1,30 +1,28 @@ +/// This module contains the encoding selectors for saving or reopening files with a different encoding. +/// It provides a modal view that allows the user to choose between saving with a different encoding +/// or reopening with a different encoding, and then selecting the desired encoding from a list. pub mod save_or_reopen { use editor::Editor; use gpui::Styled; use gpui::{AppContext, ParentElement}; use picker::Picker; use picker::PickerDelegate; - use std::cell::RefCell; - use std::ops::{Deref, DerefMut}; - use std::rc::Rc; - use std::sync::Arc; use std::sync::atomic::AtomicBool; use util::ResultExt; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{DismissEvent, Entity, EventEmitter, Focusable, WeakEntity}; - use ui::{Context, HighlightedLabel, Label, ListItem, Render, Window, rems, v_flex}; + use ui::{Context, HighlightedLabel, ListItem, Render, Window, rems, v_flex}; use workspace::{ModalView, Workspace}; - use crate::selectors::encoding::{Action, EncodingSelector, EncodingSelectorDelegate}; + use crate::selectors::encoding::{Action, EncodingSelector}; /// A modal view that allows the user to select between saving with a different encoding or /// reopening with a different encoding. pub struct EncodingSaveOrReopenSelector { picker: Entity>, pub current_selection: usize, - workspace: WeakEntity, } impl EncodingSaveOrReopenSelector { @@ -41,7 +39,6 @@ pub mod save_or_reopen { Self { picker, current_selection: 0, - workspace, } } @@ -119,9 +116,17 @@ pub mod save_or_reopen { .read(cx) .active_excerpt(cx)?; + let weak_workspace = workspace.read(cx).weak_handle(); + workspace.update(cx, |workspace, cx| { workspace.toggle_modal(window, cx, |window, cx| { - EncodingSelector::new(window, cx, Action::Save, buffer.downgrade()) + EncodingSelector::new( + window, + cx, + Action::Save, + buffer.downgrade(), + weak_workspace, + ) }) }); } @@ -134,9 +139,17 @@ pub mod save_or_reopen { .read(cx) .active_excerpt(cx)?; + let weak_workspace = workspace.read(cx).weak_handle(); + workspace.update(cx, |workspace, cx| { workspace.toggle_modal(window, cx, |window, cx| { - EncodingSelector::new(window, cx, Action::Reopen, buffer.downgrade()) + EncodingSelector::new( + window, + cx, + Action::Reopen, + buffer.downgrade(), + weak_workspace, + ) }) }); } @@ -165,7 +178,7 @@ pub mod save_or_reopen { ) { self.current_selection = ix; self.selector - .update(cx, |selector, cx| { + .update(cx, |selector, _cx| { selector.current_selection = ix; }) .log_err(); @@ -217,7 +230,7 @@ pub mod save_or_reopen { .min(delegate.matches.len().saturating_sub(1)); delegate .selector - .update(cx, |selector, cx| { + .update(cx, |selector, _cx| { selector.current_selection = delegate.current_selection }) .log_err(); @@ -263,33 +276,27 @@ pub mod save_or_reopen { } } +/// This module contains the encoding selector for choosing an encoding to save or reopen a file with. pub mod encoding { - use std::{ - ops::DerefMut, - rc::{Rc, Weak}, - sync::{Arc, atomic::AtomicBool}, - }; + use std::sync::atomic::AtomicBool; use fuzzy::{StringMatch, StringMatchCandidate}; - use gpui::{ - AppContext, BackgroundExecutor, DismissEvent, Entity, EventEmitter, Focusable, Length, - WeakEntity, actions, - }; + use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, WeakEntity}; use language::Buffer; use picker::{Picker, PickerDelegate}; use ui::{ - Context, DefiniteLength, HighlightedLabel, Label, ListItem, ListItemSpacing, ParentElement, - Render, Styled, Window, rems, v_flex, + Context, HighlightedLabel, ListItem, ListItemSpacing, ParentElement, Render, Styled, + Window, rems, v_flex, }; use util::{ResultExt, TryFutureExt}; use workspace::{ModalView, Workspace}; - use crate::encoding_from_index; + use crate::encoding_from_name; /// A modal view that allows the user to select an encoding from a list of encodings. pub struct EncodingSelector { picker: Entity>, - action: Action, + workspace: WeakEntity, } pub struct EncodingSelectorDelegate { @@ -298,12 +305,14 @@ pub mod encoding { matches: Vec, selector: WeakEntity, buffer: WeakEntity, + action: Action, } impl EncodingSelectorDelegate { pub fn new( selector: WeakEntity, buffer: WeakEntity, + action: Action, ) -> EncodingSelectorDelegate { EncodingSelectorDelegate { current_selection: 0, @@ -350,6 +359,7 @@ pub mod encoding { matches: Vec::new(), selector, buffer, + action, } } } @@ -365,12 +375,7 @@ pub mod encoding { self.current_selection } - fn set_selected_index( - &mut self, - ix: usize, - window: &mut Window, - cx: &mut Context>, - ) { + fn set_selected_index(&mut self, ix: usize, _: &mut Window, _: &mut Context>) { self.current_selection = ix; } @@ -427,21 +432,40 @@ pub mod encoding { }) } - fn confirm( - &mut self, - secondary: bool, - window: &mut Window, - cx: &mut Context>, - ) { + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { if let Some(buffer) = self.buffer.upgrade() { buffer.update(cx, |buffer, cx| { - buffer.encoding = encoding_from_index(self.current_selection) + buffer.encoding = + encoding_from_name(self.matches[self.current_selection].string.as_str()); + if self.action == Action::Reopen { + let executor = cx.background_executor().clone(); + executor.spawn(buffer.reload(cx)).detach(); + } else if self.action == Action::Save { + let executor = cx.background_executor().clone(); + + let workspace = self + .selector + .upgrade() + .unwrap() + .read(cx) + .workspace + .upgrade() + .unwrap(); + + executor + .spawn(workspace.update(cx, |workspace, cx| { + workspace + .save_active_item(workspace::SaveIntent::Save, window, cx) + .log_err() + })) + .detach(); + } }); } self.dismissed(window, cx); } - fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { + fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { self.selector .update(cx, |_, cx| cx.emit(DismissEvent)) .log_err(); @@ -450,9 +474,9 @@ pub mod encoding { fn render_match( &self, ix: usize, - selected: bool, - window: &mut Window, - cx: &mut Context>, + _: bool, + _: &mut Window, + _: &mut Context>, ) -> Option { Some( ListItem::new(ix) @@ -466,6 +490,7 @@ pub mod encoding { } /// The action to perform after selecting an encoding. + #[derive(PartialEq, Clone)] pub enum Action { Save, Reopen, @@ -477,11 +502,13 @@ pub mod encoding { cx: &mut Context, action: Action, buffer: WeakEntity, + workspace: WeakEntity, ) -> EncodingSelector { - let delegate = EncodingSelectorDelegate::new(cx.entity().downgrade(), buffer); + let delegate = + EncodingSelectorDelegate::new(cx.entity().downgrade(), buffer, action.clone()); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); - EncodingSelector { picker, action } + EncodingSelector { picker, workspace } } } diff --git a/crates/extension_host/Cargo.toml b/crates/extension_host/Cargo.toml index 16cbd9ac0c0ef938322f2b57789c7542549a570a..6ddd4e7a76ec101be78984b12b91705c12d3663b 100644 --- a/crates/extension_host/Cargo.toml +++ b/crates/extension_host/Cargo.toml @@ -23,6 +23,7 @@ async-trait.workspace = true client.workspace = true collections.workspace = true dap.workspace = true +encoding.workspace = true extension.workspace = true fs.workspace = true futures.workspace = true diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 50b5169f7ad1196a3628c59d4fda6162126b2190..468d25427dda92dafbc5cae9d9203c0ab76085a9 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -12,6 +12,7 @@ use async_tar::Archive; use client::ExtensionProvides; use client::{Client, ExtensionMetadata, GetExtensionsResponse, proto, telemetry::Telemetry}; use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; +use encoding::all::UTF_8; pub use extension::ExtensionManifest; use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use extension::{ @@ -20,6 +21,7 @@ use extension::{ ExtensionLanguageServerProxy, ExtensionSlashCommandProxy, ExtensionSnippetProxy, ExtensionThemeProxy, }; +use fs::encodings::EncodingWrapper; use fs::{Fs, RemoveOptions}; use futures::future::join_all; use futures::{ @@ -1506,6 +1508,7 @@ impl ExtensionStore { &index_path, &Rope::from_str(&index_json, &executor), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .context("failed to save extension index") @@ -1678,6 +1681,7 @@ impl ExtensionStore { &tmp_dir.join(EXTENSION_TOML), &Rope::from_str_small(&manifest_toml), language::LineEnding::Unix, + EncodingWrapper::new(UTF_8), ) .await?; } else { diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 176241a64a253a1b52f1c6bcbb9ee62cefc090d7..17f99feb0646db2237d8e422b59c4a0cb2270f25 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -16,6 +16,7 @@ anyhow.workspace = true async-tar.workspace = true async-trait.workspace = true collections.workspace = true +encoding.workspace = true futures.workspace = true git.workspace = true gpui.workspace = true diff --git a/crates/fs/src/encodings.rs b/crates/fs/src/encodings.rs index b0a1264a1442685a3ecd7afc503fe2d135ac3121..8aecbcb7646f2588c5a3a9e124250c678fd28611 100644 --- a/crates/fs/src/encodings.rs +++ b/crates/fs/src/encodings.rs @@ -1,14 +1,70 @@ -use anyhow::{Error, Result}; +//! Encoding and decoding utilities using the `encoding` crate. +use std::fmt::Debug; +use anyhow::{Error, Result}; use encoding::Encoding; +use serde::{Deserialize, de::Visitor}; /// A wrapper around `encoding::Encoding` to implement `Send` and `Sync`. /// Since the reference is static, it is safe to send it across threads. pub struct EncodingWrapper(&'static dyn Encoding); +impl Debug for EncodingWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("EncodingWrapper") + .field(&self.0.name()) + .finish() + } +} + +pub struct EncodingWrapperVisitor; + +impl<'vi> Visitor<'vi> for EncodingWrapperVisitor { + type Value = EncodingWrapper; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a valid encoding name") + } + + fn visit_str(self, encoding: &str) -> Result { + Ok(EncodingWrapper( + encoding::label::encoding_from_whatwg_label(encoding) + .ok_or_else(|| serde::de::Error::custom("Invalid Encoding"))?, + )) + } + + fn visit_string(self, encoding: String) -> Result { + Ok(EncodingWrapper( + encoding::label::encoding_from_whatwg_label(&encoding) + .ok_or_else(|| serde::de::Error::custom("Invalid Encoding"))?, + )) + } +} + +impl<'de> Deserialize<'de> for EncodingWrapper { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(EncodingWrapperVisitor) + } +} + +impl PartialEq for EncodingWrapper { + fn eq(&self, other: &Self) -> bool { + self.0.name() == other.0.name() + } +} + unsafe impl Send for EncodingWrapper {} unsafe impl Sync for EncodingWrapper {} +impl Clone for EncodingWrapper { + fn clone(&self) -> Self { + EncodingWrapper(self.0) + } +} + impl EncodingWrapper { pub fn new(encoding: &'static dyn Encoding) -> EncodingWrapper { EncodingWrapper(encoding) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index c97b2b671cd3a4d32168b6e3404873410f55c49a..dd7f3d1e049bdb03e0972d3f32245d468e95b5be 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -62,6 +62,7 @@ use std::ffi::OsStr; #[cfg(any(test, feature = "test-support"))] pub use fake_git_repo::{LOAD_HEAD_TEXT_TASK, LOAD_INDEX_TEXT_TASK}; use crate::encodings::EncodingWrapper; +use crate::encodings::from_utf8; pub trait Watcher: Send + Sync { fn add(&self, path: &Path) -> Result<()>; @@ -129,7 +130,13 @@ pub trait Fs: Send + Sync { async fn load_bytes(&self, path: &Path) -> Result>; async fn atomic_write(&self, path: PathBuf, text: String) -> Result<()>; - async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()>; + async fn save( + &self, + path: &Path, + text: &Rope, + line_ending: LineEnding, + encoding: EncodingWrapper, + ) -> Result<()>; async fn write(&self, path: &Path, content: &[u8]) -> Result<()>; async fn canonicalize(&self, path: &Path) -> Result; async fn is_file(&self, path: &Path) -> bool; @@ -674,7 +681,13 @@ impl Fs for RealFs { Ok(()) } - async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> { + async fn save( + &self, + path: &Path, + text: &Rope, + line_ending: LineEnding, + encoding: EncodingWrapper, + ) -> Result<()> { let buffer_size = text.summary().len.min(10 * 1024); if let Some(path) = path.parent() { self.create_dir(path).await?; @@ -682,7 +695,9 @@ impl Fs for RealFs { let file = smol::fs::File::create(path).await?; let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file); for chunk in chunks(text, line_ending) { - writer.write_all(chunk.as_bytes()).await?; + writer + .write_all(&from_utf8(chunk.to_string(), encoding.clone()).await?) + .await?; } writer.flush().await?; Ok(()) @@ -2395,14 +2410,22 @@ impl Fs for FakeFs { Ok(()) } - async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> { + async fn save( + &self, + path: &Path, + text: &Rope, + line_ending: LineEnding, + encoding: EncodingWrapper, + ) -> Result<()> { + use crate::encodings::from_utf8; + self.simulate_random_delay().await; let path = normalize_path(path); let content = chunks(text, line_ending).collect::(); if let Some(path) = path.parent() { self.create_dir(path).await?; } - self.write_file_internal(path, content.into_bytes(), false)?; + self.write_file_internal(path, from_utf8(content, encoding).await?, false)?; Ok(()) } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 486e43fea94f53e2ad9fd67d88cfe2279afb353c..4c22b7f4ff07de1272daace76c4be593b7c4dd34 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -29,6 +29,7 @@ command_palette_hooks.workspace = true component.workspace = true db.workspace = true editor.workspace = true +encoding.workspace = true futures.workspace = true fuzzy.workspace = true git.workspace = true diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index a99b7f8e2428ca0bcf726f2ac7661df171bef34a..2494177e9d9089f2729b8e5726f4344c3e6b23be 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -358,10 +358,12 @@ impl Render for FileDiffView { mod tests { use super::*; use editor::test::editor_test_context::assert_state_with_diff; + use encoding::all::UTF_8; use gpui::TestAppContext; 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; @@ -440,6 +442,7 @@ mod tests { ", )), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -474,6 +477,7 @@ mod tests { ", )), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 269ed731b8f01e3c3ab63125aed964c27ad5cce8..f90c2680324509f5ec5e8a3d9d40d1cd80c06e6b 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -30,7 +30,9 @@ anyhow.workspace = true async-trait.workspace = true clock.workspace = true collections.workspace = true +diffy = "0.4.2" ec4rs.workspace = true +encoding.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index a1af6990bf464791417a2ad4b1c51b9907d1e2b7..709804184dcc18a4e9f16f4ee60fd02ad9ae0edd 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -22,7 +22,7 @@ use clock::Lamport; pub use clock::ReplicaId; use collections::HashMap; use encoding::Encoding; -use fs::{Fs, MTime, RealFs}; +use fs::MTime; use futures::channel::oneshot; use gpui::{ App, AppContext as _, BackgroundExecutor, Context, Entity, EventEmitter, HighlightStyle, @@ -1348,7 +1348,7 @@ impl Buffer { /// Reloads the contents of the buffer from disk. pub fn reload(&mut self, cx: &Context) -> oneshot::Receiver> { let (tx, rx) = futures::channel::oneshot::channel(); - let encoding = self.encoding.clone(); + let encoding = self.encoding; let prev_version = self.text.version(); self.reload_task = Some(cx.spawn(async move |this, cx| { let Some((new_mtime, new_text)) = this.update(cx, |this, cx| { @@ -5238,11 +5238,7 @@ impl LocalFile for TestFile { unimplemented!() } - fn load_with_encoding( - &self, - cx: &App, - encoding: &'static dyn Encoding, - ) -> Task> { + fn load_with_encoding(&self, _: &App, _: &'static dyn Encoding) -> Task> { unimplemented!() } } diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index d9285a8c24ec5130dd8ce8abf5bbd77c830e0f3f..d598ee7685c995d90abeb4cf5c704e3c2a2bdf0b 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -39,6 +39,7 @@ clock.workspace = true collections.workspace = true context_server.workspace = true dap.workspace = true +encoding.workspace = true extension.workspace = true fancy-regex.workspace = true fs.workspace = true @@ -90,6 +91,7 @@ worktree.workspace = true zeroize.workspace = true zlog.workspace = true + [dev-dependencies] client = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 3fb702518690585d3237324c04802c9deec0892e..d8dc6f4e0ffba78489e5f00de191b5c2709d3ed1 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -387,6 +387,8 @@ impl LocalBufferStore { let version = buffer.version(); let buffer_id = buffer.remote_id(); let file = buffer.file().cloned(); + let encoding = buffer.encoding; + if file .as_ref() .is_some_and(|file| file.disk_state() == DiskState::New) @@ -395,7 +397,7 @@ impl LocalBufferStore { } let save = worktree.update(cx, |worktree, cx| { - worktree.write_file(path, text, line_ending, cx) + worktree.write_file(path.as_ref(), text, line_ending, cx, encoding) }); cx.spawn(async move |this, cx| { diff --git a/crates/project/src/prettier_store.rs b/crates/project/src/prettier_store.rs index 3743f9769eaaff7f3acd1cc5bad16e31f6e80987..50b88e7e850a51732f36fc187eb5b2d2b66c36ac 100644 --- a/crates/project/src/prettier_store.rs +++ b/crates/project/src/prettier_store.rs @@ -7,7 +7,8 @@ use std::{ use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; -use fs::Fs; +use encoding::all::UTF_8; +use fs::{Fs, encodings::EncodingWrapper}; use futures::{ FutureExt, future::{self, Shared}, @@ -981,10 +982,12 @@ async fn save_prettier_server_file( executor: &BackgroundExecutor, ) -> anyhow::Result<()> { let prettier_wrapper_path = default_prettier_dir().join(prettier::PRETTIER_SERVER_FILE); + let encoding_wrapper = EncodingWrapper::new(UTF_8); fs.save( &prettier_wrapper_path, &text::Rope::from_str(prettier::PRETTIER_SERVER_JS, executor), text::LineEnding::Unix, + encoding_wrapper, ) .await .with_context(|| { diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 3dc918d5a757af56038471e1a601d6f2cf7dbbe1..281bfb3b2410ae8ec2db8ba27abe9e4277fb1bd3 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -12,7 +12,8 @@ use buffer_diff::{ BufferDiffEvent, CALCULATE_DIFF_TASK, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind, assert_hunks, }; -use fs::FakeFs; +use encoding::all::UTF_8; +use fs::{FakeFs, encodings::EncodingWrapper}; use futures::{StreamExt, future}; use git::{ GitHostingProviderRegistry, @@ -1459,10 +1460,14 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon ) .await .unwrap(); + + let encoding_wrapper = EncodingWrapper::new(UTF_8); + fs.save( path!("/the-root/Cargo.lock").as_ref(), &Rope::default(), Default::default(), + encoding_wrapper.clone(), ) .await .unwrap(); @@ -1470,6 +1475,7 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon path!("/the-stdlib/LICENSE").as_ref(), &Rope::default(), Default::default(), + encoding_wrapper.clone(), ) .await .unwrap(); @@ -1477,6 +1483,7 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon path!("/the/stdlib/src/string.rs").as_ref(), &Rope::default(), Default::default(), + encoding_wrapper, ) .await .unwrap(); @@ -4068,12 +4075,15 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) // the next file change occurs. cx.executor().deprioritize(*language::BUFFER_DIFF_TASK); + let encoding_wrapper = EncodingWrapper::new(UTF_8); + // Change the buffer's file on disk, and then wait for the file change // to be detected by the worktree, so that the buffer starts reloading. fs.save( path!("/dir/file1").as_ref(), &Rope::from_str("the first contents", cx.background_executor()), Default::default(), + encoding_wrapper.clone(), ) .await .unwrap(); @@ -4085,6 +4095,7 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) path!("/dir/file1").as_ref(), &Rope::from_str("the second contents", cx.background_executor()), Default::default(), + encoding_wrapper, ) .await .unwrap(); @@ -4123,12 +4134,15 @@ async fn test_edit_buffer_while_it_reloads(cx: &mut gpui::TestAppContext) { // the next file change occurs. cx.executor().deprioritize(*language::BUFFER_DIFF_TASK); + let encoding_wrapper = EncodingWrapper::new(UTF_8); + // Change the buffer's file on disk, and then wait for the file change // to be detected by the worktree, so that the buffer starts reloading. fs.save( path!("/dir/file1").as_ref(), &Rope::from_str("the first contents", cx.background_executor()), Default::default(), + encoding_wrapper, ) .await .unwrap(); @@ -4803,10 +4817,14 @@ async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) { let (new_contents, new_offsets) = marked_text_offsets("oneˇ\nthree ˇFOURˇ five\nsixtyˇ seven\n"); + + let encoding_wrapper = EncodingWrapper::new(UTF_8); + fs.save( path!("/dir/the-file").as_ref(), &Rope::from_str(new_contents.as_str(), cx.background_executor()), LineEnding::Unix, + encoding_wrapper, ) .await .unwrap(); @@ -4834,11 +4852,14 @@ async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) { assert!(!buffer.has_conflict()); }); + let encoding_wrapper = EncodingWrapper::new(UTF_8); + // Change the file on disk again, adding blank lines to the beginning. fs.save( path!("/dir/the-file").as_ref(), &Rope::from_str("\n\n\nAAAA\naaa\nBB\nbbbbb\n", cx.background_executor()), LineEnding::Unix, + encoding_wrapper, ) .await .unwrap(); @@ -4885,12 +4906,15 @@ async fn test_buffer_line_endings(cx: &mut gpui::TestAppContext) { assert_eq!(buffer.line_ending(), LineEnding::Windows); }); + let encoding_wrapper = EncodingWrapper::new(UTF_8); + // Change a file's line endings on disk from unix to windows. The buffer's // state updates correctly. fs.save( path!("/dir/file1").as_ref(), &Rope::from_str("aaa\nb\nc\n", cx.background_executor()), LineEnding::Windows, + encoding_wrapper, ) .await .unwrap(); diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 5034b24e0661eb87665d9b805b2bbc5d2ca577cd..8e9869542125e54252d8df603553d15dcd8f3183 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -28,6 +28,7 @@ clap.workspace = true client.workspace = true dap_adapters.workspace = true debug_adapter_extension.workspace = true +encoding.workspace = true env_logger.workspace = true extension.workspace = true extension_host.workspace = true diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index c7e09e3f681d770959709893561cf7a1ba377b37..ed696312f7a2d982f18f8168a7436c62b0f2442f 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -6,10 +6,10 @@ use agent::{AgentTool, ReadFileTool, ReadFileToolInput, ToolCallEventStream}; use client::{Client, UserStore}; use clock::FakeSystemClock; use collections::{HashMap, HashSet}; -use language_model::LanguageModelToolResultContent; +use encoding::all::UTF_8; use extension::ExtensionHostProxy; -use fs::{FakeFs, Fs}; +use fs::{FakeFs, Fs, encodings::EncodingWrapper}; use gpui::{AppContext as _, Entity, SemanticVersion, SharedString, TestAppContext}; use http_client::{BlockedHttpClient, FakeHttpClient}; use language::{ @@ -122,6 +122,7 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test path!("/code/project1/src/main.rs").as_ref(), &Rope::from_str_small("fn main() {}"), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -768,6 +769,7 @@ async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppCont &PathBuf::from(path!("/code/project1/src/lib.rs")), &Rope::from_str_small("bangles"), LineEnding::Unix, + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -783,6 +785,7 @@ async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppCont &PathBuf::from(path!("/code/project1/src/lib.rs")), &Rope::from_str_small("bloop"), LineEnding::Unix, + EncodingWrapper::new(UTF_8), ) .await .unwrap(); diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 4cea29508f437d6753a78155965b94259a2d7884..0aa24f40f40da9c7d85cd8052658c195a3c907ad 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -54,6 +54,7 @@ vim_mode_setting.workspace = true workspace.workspace = true zed_actions.workspace = true + [dev-dependencies] assets.workspace = true command_palette.workspace = true diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index d5d3016ab2704392c6cc9cc4bcebf6d50701d3be..163e068d1363a8e1df6688d1d045179a0a8ee10d 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -35,6 +35,7 @@ clock.workspace = true collections.workspace = true component.workspace = true db.workspace = true +encoding.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 28dabeb5f63c7efa1e227554021e7f5ded40466f..086e621ac7c5e31f2376a9854ff0161eb81b75f9 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -19,6 +19,8 @@ mod workspace_settings; pub use crate::notifications::NotificationFrame; pub use dock::Panel; +use encoding::all::UTF_8; +use fs::encodings::EncodingWrapper; pub use path_list::PathList; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -132,7 +134,6 @@ use crate::persistence::{ }; use crate::{item::ItemBufferKind, notifications::NotificationId}; - pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); static ZED_WINDOW_SIZE: LazyLock>> = LazyLock::new(|| { @@ -7587,8 +7588,14 @@ pub fn create_and_open_local_file( let fs = workspace.read_with(cx, |workspace, _| workspace.app_state().fs.clone())?; if !fs.is_file(path).await { fs.create_file(path, Default::default()).await?; - fs.save(path, &default_content(cx), Default::default()) - .await?; + let encoding_wrapper = EncodingWrapper::new(UTF_8); + fs.save( + path, + &default_content(), + Default::default(), + encoding_wrapper, + ) + .await?; } let mut items = workspace diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index e16b0292060afa7f8300a3dc76d8fe44a2778331..0dc81b73fd32ad1bea57d5a324101218cf39b384 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -27,6 +27,7 @@ anyhow.workspace = true async-lock.workspace = true clock.workspace = true collections.workspace = true +encoding.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 0fd03f321a6f313c45cbff3c6ac6279a557598ee..51842bc89d2f7b7ac532a85470e8936180ee3228 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -735,9 +735,10 @@ impl Worktree { text: Rope, line_ending: LineEnding, cx: &Context, + encoding: &'static dyn Encoding, ) -> Task>> { match self { - Worktree::Local(this) => this.write_file(path, text, line_ending, cx), + Worktree::Local(this) => this.write_file(path, text, line_ending, cx, encoding), Worktree::Remote(_) => { Task::ready(Err(anyhow!("remote worktree can't yet write files"))) } @@ -1450,15 +1451,21 @@ impl LocalWorktree { text: Rope, line_ending: LineEnding, cx: &Context, + encoding: &'static dyn Encoding, ) -> Task>> { let fs = self.fs.clone(); let is_private = self.is_path_private(&path); let abs_path = self.absolutize(&path); + let encoding_wrapper = EncodingWrapper::new(encoding); + let write = cx.background_spawn({ let fs = fs.clone(); let abs_path = abs_path.clone(); - async move { fs.save(&abs_path, &text, line_ending).await } + async move { + fs.save(&abs_path, &text, line_ending, encoding_wrapper) + .await + } }); cx.spawn(async move |this, cx| { diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index 1cce23712ae88f0e42faf240099ebecd9000fc4e..c6e6aeac87d61998c087c16c03eeaf226f32e5d7 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -3,7 +3,8 @@ use crate::{ worktree_settings::WorktreeSettings, }; use anyhow::Result; -use fs::{FakeFs, Fs, RealFs, RemoveOptions}; +use encoding::all::UTF_8; +use fs::{FakeFs, Fs, RealFs, RemoveOptions, encodings::EncodingWrapper}; use git::GITIGNORE; use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext}; use parking_lot::Mutex; @@ -651,6 +652,7 @@ async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) { "/root/.gitignore".as_ref(), &Rope::from_str("e", cx.background_executor()), Default::default(), + encoding_wrapper, ) .await .unwrap(); @@ -724,6 +726,7 @@ async fn test_write_file(cx: &mut TestAppContext) { Rope::from_str("hello", cx.background_executor()), Default::default(), cx, + UTF_8, ) }) .await @@ -735,6 +738,7 @@ async fn test_write_file(cx: &mut TestAppContext) { Rope::from_str("world", cx.background_executor()), Default::default(), cx, + UTF_8, ) }) .await @@ -1769,6 +1773,7 @@ fn randomly_mutate_worktree( Rope::default(), Default::default(), cx, + UTF_8, ); cx.background_spawn(async move { task.await?; @@ -1857,10 +1862,12 @@ async fn randomly_mutate_fs( ignore_path.strip_prefix(root_path).unwrap(), ignore_contents ); + let encoding_wrapper = EncodingWrapper::new(UTF_8); fs.save( &ignore_path, &Rope::from_str(ignore_contents.as_str(), executor), Default::default(), + encoding_wrapper, ) .await .unwrap(); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 8fa237b7f98cb326b50f78b6dbe7c462a3166cea..25dc614bfd63c04e14ef78bed851207d0d68ecb7 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -52,6 +52,7 @@ debugger_ui.workspace = true diagnostics.workspace = true editor.workspace = true zeta2_tools.workspace = true +encoding.workspace = true encodings.workspace = true env_logger.workspace = true extension.workspace = true @@ -166,7 +167,6 @@ zeta.workspace = true zeta2.workspace = true zlog.workspace = true zlog_settings.workspace = true -encoding = "0.2.33" [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 735dce07ece59c216f04d1c8ce19ec5428213337..607fb302970fb1eb35ccf2d0a1132c051e3a65a0 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2176,6 +2176,8 @@ mod tests { use assets::Assets; use collections::HashSet; use editor::{DisplayPoint, Editor, SelectionEffects, display_map::DisplayRow}; + use encoding::all::UTF_8; + use fs::encodings::EncodingWrapper; use gpui::{ Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, SemanticVersion, TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle, actions, @@ -4381,6 +4383,7 @@ mod tests { "/settings.json".as_ref(), &Rope::from_str_small(r#"{"base_keymap": "Atom"}"#), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -4391,6 +4394,7 @@ mod tests { "/keymap.json".as_ref(), &Rope::from_str_small(r#"[{"bindings": {"backspace": "test_only::ActionA"}}]"#), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -4439,6 +4443,7 @@ mod tests { "/keymap.json".as_ref(), &Rope::from_str_small(r#"[{"bindings": {"backspace": "test_only::ActionB"}}]"#), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -4459,6 +4464,7 @@ mod tests { "/settings.json".as_ref(), &Rope::from_str_small(r#"{"base_keymap": "JetBrains"}"#), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -4499,6 +4505,7 @@ mod tests { "/settings.json".as_ref(), &Rope::from_str_small(r#"{"base_keymap": "Atom"}"#), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -4508,6 +4515,7 @@ mod tests { "/keymap.json".as_ref(), &Rope::from_str_small(r#"[{"bindings": {"backspace": "test_only::ActionA"}}]"#), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -4551,6 +4559,7 @@ mod tests { "/keymap.json".as_ref(), &Rope::from_str_small(r#"[{"bindings": {"backspace": null}}]"#), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap(); @@ -4571,6 +4580,7 @@ mod tests { "/settings.json".as_ref(), &Rope::from_str_small(r#"{"base_keymap": "JetBrains"}"#), Default::default(), + EncodingWrapper::new(UTF_8), ) .await .unwrap();