Detailed changes
@@ -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",
@@ -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"
@@ -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();
@@ -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
@@ -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
@@ -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.
+ ///
+ /// <example>Fix API endpoint URLs</example>
+ /// <example>Update copyright year in `page_footer`</example>
+ ///
+ /// 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
+ ///
+ /// <example>
+ /// `backend/src/main.rs`
+ ///
+ /// Notice how the file path starts with `backend`. Without that, the path
+ /// would be ambiguous and the call would fail!
+ /// </example>
+ ///
+ /// <example>
+ /// `frontend/db.js`
+ /// </example>
+ 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<String>,
+ pub raw_output: Option<EditAgentOutput>,
+}
+
+#[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<Project>,
+ cx: &App,
+ ) -> bool {
+ if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
+ return false;
+ }
+
+ let Ok(input) = serde_json::from_value::<EditFileToolInput>(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<serde_json::Value> {
+ json_schema_for::<EditFileToolInput>(format)
+ }
+
+ fn ui_text(&self, input: &serde_json::Value) -> String {
+ match serde_json::from_value::<EditFileToolInput>(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::<PartialInput>(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<Self>,
+ input: serde_json::Value,
+ request: Arc<LanguageModelRequest>,
+ project: Entity<Project>,
+ action_log: Entity<ActionLog>,
+ model: Arc<dyn LanguageModel>,
+ window: Option<AnyWindowHandle>,
+ cx: &mut App,
+ ) -> ToolResult {
+ let input = match serde_json::from_value::<EditFileToolInput>(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::<Vec<_>>()
+ .join(", ");
+ formatdoc! {"
+ <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
+ relevant sections of {input_path} again and extend <old_text> 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<Self>,
+ output: serde_json::Value,
+ project: Entity<Project>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Option<AnyToolCard> {
+ let output = match serde_json::from_value::<EditFileToolOutput>(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<Path> = 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::<Vec<_>>();
+
+ 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<Project>,
+ cx: &mut App,
+) -> Result<ProjectPath> {
+ 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<Editor>,
+ multibuffer: Entity<MultiBuffer>,
+ project: Entity<Project>,
+ buffer: Option<Entity<Buffer>>,
+ base_text: Option<Arc<String>>,
+ buffer_diff: Option<Entity<BufferDiff>>,
+ revealed_ranges: Vec<Range<Anchor>>,
+ diff_task: Option<Task<Result<()>>>,
+ preview_expanded: bool,
+ error_expanded: Option<Entity<Markdown>>,
+ full_height_expanded: bool,
+ total_lines: Option<u32>,
+}
+
+impl EditFileToolCard {
+ pub fn new(path: PathBuf, project: Entity<Project>, 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<Buffer>, 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<Self>) {
+ 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<Anchor>, cx: &mut Context<Self>) {
+ self.revealed_ranges.push(range);
+ self.update_visible_ranges(cx);
+ }
+
+ fn update_visible_ranges(&mut self, cx: &mut Context<Self>) {
+ 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<Range<Point>> {
+ 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::<Vec<_>>();
+ 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<Self>) -> 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<Workspace>,
+ cx: &mut Context<Self>,
+ ) -> 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::<Editor>() {
+ 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<Path>,
+ language_registry: &Arc<language::LanguageRegistry>,
+ cx: &mut AsyncApp,
+) -> Result<Entity<Buffer>> {
+ 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<String>,
+ buffer: &Entity<Buffer>,
+ language_registry: &Arc<LanguageRegistry>,
+ cx: &mut AsyncApp,
+) -> Result<Entity<BufferDiff>> {
+ 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<ProjectPath> {
+ 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<ProjectPath>, 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::<lsp::request::Formatting, _, _>({
+ |_, _| 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::<language::language_settings::AllLanguageSettings>(
+ 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::<language::language_settings::AllLanguageSettings>(
+ 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::<language::language_settings::AllLanguageSettings>(
+ 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::<language::language_settings::AllLanguageSettings>(
+ 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"
+ );
+ });
+ }
+}
@@ -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
@@ -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();
@@ -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();
@@ -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]
@@ -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]
@@ -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<Self>, editor: WeakEntity<Editor>) {}
-
pub fn new(
encoding: Option<&'static dyn encoding::Encoding>,
workspace: WeakEntity<Workspace>,
@@ -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
+ }
+}
@@ -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<Picker<EncodingSaveOrReopenDelegate>>,
pub current_selection: usize,
- workspace: WeakEntity<Workspace>,
}
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<Picker<EncodingSelectorDelegate>>,
- action: Action,
+ workspace: WeakEntity<Workspace>,
}
pub struct EncodingSelectorDelegate {
@@ -298,12 +305,14 @@ pub mod encoding {
matches: Vec<StringMatch>,
selector: WeakEntity<EncodingSelector>,
buffer: WeakEntity<Buffer>,
+ action: Action,
}
impl EncodingSelectorDelegate {
pub fn new(
selector: WeakEntity<EncodingSelector>,
buffer: WeakEntity<Buffer>,
+ 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<Picker<Self>>,
- ) {
+ fn set_selected_index(&mut self, ix: usize, _: &mut Window, _: &mut Context<Picker<Self>>) {
self.current_selection = ix;
}
@@ -427,21 +432,40 @@ pub mod encoding {
})
}
- fn confirm(
- &mut self,
- secondary: bool,
- window: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) {
+ fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
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<Picker<Self>>) {
+ fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
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<Picker<Self>>,
+ _: bool,
+ _: &mut Window,
+ _: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
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<EncodingSelector>,
action: Action,
buffer: WeakEntity<Buffer>,
+ workspace: WeakEntity<Workspace>,
) -> 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 }
}
}
@@ -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
@@ -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 {
@@ -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
@@ -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<E: serde::de::Error>(self, encoding: &str) -> Result<EncodingWrapper, E> {
+ Ok(EncodingWrapper(
+ encoding::label::encoding_from_whatwg_label(encoding)
+ .ok_or_else(|| serde::de::Error::custom("Invalid Encoding"))?,
+ ))
+ }
+
+ fn visit_string<E: serde::de::Error>(self, encoding: String) -> Result<EncodingWrapper, E> {
+ 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<D>(deserializer: D) -> std::result::Result<Self, D::Error>
+ 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)
@@ -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<Vec<u8>>;
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<PathBuf>;
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::<String>();
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(())
}
@@ -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
@@ -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();
@@ -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
@@ -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<Self>) -> oneshot::Receiver<Option<Transaction>> {
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<Result<String>> {
+ fn load_with_encoding(&self, _: &App, _: &'static dyn Encoding) -> Task<Result<String>> {
unimplemented!()
}
}
@@ -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"] }
@@ -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| {
@@ -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(|| {
@@ -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();
@@ -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
@@ -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();
@@ -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
@@ -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
@@ -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<Option<Size<Pixels>>> = 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
@@ -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
@@ -735,9 +735,10 @@ impl Worktree {
text: Rope,
line_ending: LineEnding,
cx: &Context<Worktree>,
+ encoding: &'static dyn Encoding,
) -> Task<Result<Arc<File>>> {
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<Worktree>,
+ encoding: &'static dyn Encoding,
) -> Task<Result<Arc<File>>> {
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| {
@@ -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();
@@ -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
@@ -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();