@@ -1,2440 +0,0 @@
-use crate::{
- Templates,
- edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
- schema::json_schema_for,
- ui::{COLLAPSED_LINES, ToolOutputPreview},
-};
-use action_log::ActionLog;
-use agent_settings;
-use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{
- AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus,
-};
-use buffer_diff::{BufferDiff, BufferDiffSnapshot};
-use editor::{
- Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey, multibuffer_context_lines,
-};
-use futures::StreamExt;
-use gpui::{
- Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
- TextStyleRefinement, WeakEntity, pulsating_between, px,
-};
-use indoc::formatdoc;
-use language::{
- Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope,
- TextBuffer,
- language_settings::{self, FormatOnSave, SoftWrap},
-};
-use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
-use markdown::{Markdown, MarkdownElement, MarkdownStyle};
-use paths;
-use project::{
- Project, ProjectPath,
- lsp_store::{FormatTrigger, LspFormatTarget},
-};
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use settings::Settings;
-use std::{
- cmp::Reverse,
- collections::HashSet,
- ops::Range,
- path::{Path, PathBuf},
- sync::Arc,
- time::Duration,
-};
-use theme::ThemeSettings;
-use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*};
-use util::ResultExt;
-use workspace::Workspace;
-
-pub struct EditFileTool;
-
-#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
-pub struct EditFileToolInput {
- /// A one-line, user-friendly markdown description of the edit. This will be
- /// shown in the UI and also passed to another model to perform the edit.
- ///
- /// Be terse, but also descriptive in what you want to achieve with this
- /// edit. Avoid generic instructions.
- ///
- /// NEVER mention the file path in this description.
- ///
- /// <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;
- use client::TelemetrySettings;
- use encodings::Encoding;
- use encodings::UTF_8;
- use gpui::{TestAppContext, UpdateGlobal};
- use language_model::fake_provider::FakeLanguageModel;
- use serde_json::json;
- use settings::SettingsStore;
- use std::fs;
- use util::path;
-
- #[gpui::test]
- async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
- init_test(cx);
-
- let fs = project::FakeFs::new(cx.executor());
- fs.insert_tree("/root", json!({})).await;
- let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
- let action_log = cx.new(|_| ActionLog::new(project.clone()));
- let model = Arc::new(FakeLanguageModel::default());
- let result = cx
- .update(|cx| {
- let input = serde_json::to_value(EditFileToolInput {
- display_description: "Some edit".into(),
- path: "root/nonexistent_file.txt".into(),
- mode: EditFileMode::Edit,
- })
- .unwrap();
- Arc::new(EditFileTool)
- .run(
- input,
- Arc::default(),
- project.clone(),
- action_log,
- model,
- None,
- cx,
- )
- .output
- })
- .await;
- assert_eq!(
- result.unwrap_err().to_string(),
- "Can't edit file: path not found"
- );
- }
-
- #[gpui::test]
- async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
- let mode = &EditFileMode::Create;
-
- let result = test_resolve_path(mode, "root/new.txt", cx);
- assert_resolved_path_eq(result.await, "new.txt");
-
- let result = test_resolve_path(mode, "new.txt", cx);
- assert_resolved_path_eq(result.await, "new.txt");
-
- let result = test_resolve_path(mode, "dir/new.txt", cx);
- assert_resolved_path_eq(result.await, "dir/new.txt");
-
- let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
- assert_eq!(
- result.await.unwrap_err().to_string(),
- "Can't create file: file already exists"
- );
-
- let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
- assert_eq!(
- result.await.unwrap_err().to_string(),
- "Can't create file: parent directory doesn't exist"
- );
- }
-
- #[gpui::test]
- async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
- let mode = &EditFileMode::Edit;
-
- let path_with_root = "root/dir/subdir/existing.txt";
- let path_without_root = "dir/subdir/existing.txt";
- let result = test_resolve_path(mode, path_with_root, cx);
- assert_resolved_path_eq(result.await, path_without_root);
-
- let result = test_resolve_path(mode, path_without_root, cx);
- assert_resolved_path_eq(result.await, path_without_root);
-
- let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
- assert_eq!(
- result.await.unwrap_err().to_string(),
- "Can't edit file: path not found"
- );
-
- let result = test_resolve_path(mode, "root/dir", cx);
- assert_eq!(
- result.await.unwrap_err().to_string(),
- "Can't edit file: path is a directory"
- );
- }
-
- async fn test_resolve_path(
- mode: &EditFileMode,
- path: &str,
- cx: &mut TestAppContext,
- ) -> anyhow::Result<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,
- Encoding::new(UTF_8),
- )
- .await
- .unwrap();
-
- // Open the buffer to trigger LSP initialization
- let buffer = project
- .update(cx, |project, cx| {
- project.open_local_buffer(path!("/root/src/main.rs"), cx)
- })
- .await
- .unwrap();
-
- // Register the buffer with language servers
- let _handle = project.update(cx, |project, cx| {
- project.register_buffer_with_language_servers(&buffer, cx)
- });
-
- const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
- const FORMATTED_CONTENT: &str =
- "This file was formatted by the fake formatter in the test.\n";
-
- // Get the fake language server and set up formatting handler
- let fake_language_server = fake_language_servers.next().await.unwrap();
- fake_language_server.set_request_handler::<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,
- Encoding::default(),
- )
- .await
- .unwrap();
-
- let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
- let action_log = cx.new(|_| ActionLog::new(project.clone()));
- let model = Arc::new(FakeLanguageModel::default());
-
- // First, test with remove_trailing_whitespace_on_save enabled
- cx.update(|cx| {
- SettingsStore::update_global(cx, |store, cx| {
- store.update_user_settings::<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"
- );
- });
- }
-}
@@ -0,0 +1,754 @@
+use std::{
+ io::{BufRead, BufReader},
+ path::Path,
+ pin::pin,
+ sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+ },
+};
+
+use anyhow::Context;
+use collections::HashSet;
+use fs::Fs;
+use futures::{SinkExt, StreamExt, select_biased, stream::FuturesOrdered};
+use gpui::{App, AppContext, AsyncApp, Entity, Task};
+use language::{Buffer, BufferSnapshot};
+use parking_lot::Mutex;
+use postage::oneshot;
+use rpc::{AnyProtoClient, proto};
+use smol::{
+ channel::{Receiver, Sender, bounded, unbounded},
+ future::FutureExt,
+};
+
+use text::BufferId;
+use util::{ResultExt, maybe, paths::compare_rel_paths};
+use worktree::{Entry, ProjectEntryId, Snapshot, Worktree};
+
+use crate::{
+ Project, ProjectItem, ProjectPath, RemotelyCreatedModels,
+ buffer_store::BufferStore,
+ search::{SearchQuery, SearchResult},
+ worktree_store::WorktreeStore,
+};
+
+pub struct Search {
+ buffer_store: Entity<BufferStore>,
+ worktree_store: Entity<WorktreeStore>,
+ limit: usize,
+ kind: SearchKind,
+}
+
+/// Represents search setup, before it is actually kicked off with Search::into_results
+enum SearchKind {
+ /// Search for candidates by inspecting file contents on file system, avoiding loading the buffer unless we know that a given file contains a match.
+ Local {
+ fs: Arc<dyn Fs>,
+ worktrees: Vec<Entity<Worktree>>,
+ },
+ /// Query remote host for candidates. As of writing, the host runs a local search in "buffers with matches only" mode.
+ Remote {
+ client: AnyProtoClient,
+ remote_id: u64,
+ models: Arc<Mutex<RemotelyCreatedModels>>,
+ },
+ /// Run search against a known set of candidates. Even when working with a remote host, this won't round-trip to host.
+ OpenBuffersOnly,
+}
+
+/// Represents results of project search and allows one to either obtain match positions OR
+/// just the handles to buffers that may match the search. Grabbing the handles is cheaper than obtaining full match positions, because in that case we'll look for
+/// at most one match in each file.
+#[must_use]
+pub struct SearchResultsHandle {
+ results: Receiver<SearchResult>,
+ matching_buffers: Receiver<Entity<Buffer>>,
+ trigger_search: Box<dyn FnOnce(&mut App) -> Task<()> + Send + Sync>,
+}
+
+impl SearchResultsHandle {
+ pub fn results(self, cx: &mut App) -> Receiver<SearchResult> {
+ (self.trigger_search)(cx).detach();
+ self.results
+ }
+ pub fn matching_buffers(self, cx: &mut App) -> Receiver<Entity<Buffer>> {
+ (self.trigger_search)(cx).detach();
+ self.matching_buffers
+ }
+}
+
+#[derive(Clone)]
+enum FindSearchCandidates {
+ Local {
+ fs: Arc<dyn Fs>,
+ /// Start off with all paths in project and filter them based on:
+ /// - Include filters
+ /// - Exclude filters
+ /// - Only open buffers
+ /// - Scan ignored files
+ /// Put another way: filter out files that can't match (without looking at file contents)
+ input_paths_rx: Receiver<InputPath>,
+ /// After that, if the buffer is not yet loaded, we'll figure out if it contains at least one match
+ /// based on disk contents of a buffer. This step is not performed for buffers we already have in memory.
+ confirm_contents_will_match_tx: Sender<MatchingEntry>,
+ confirm_contents_will_match_rx: Receiver<MatchingEntry>,
+ /// Of those that contain at least one match (or are already in memory), look for rest of matches (and figure out their ranges).
+ /// But wait - first, we need to go back to the main thread to open a buffer (& create an entity for it).
+ get_buffer_for_full_scan_tx: Sender<ProjectPath>,
+ },
+ Remote,
+ OpenBuffersOnly,
+}
+
+impl Search {
+ pub fn local(
+ fs: Arc<dyn Fs>,
+ buffer_store: Entity<BufferStore>,
+ worktree_store: Entity<WorktreeStore>,
+ limit: usize,
+ cx: &mut App,
+ ) -> Self {
+ let worktrees = worktree_store.read(cx).visible_worktrees(cx).collect();
+ Self {
+ kind: SearchKind::Local { fs, worktrees },
+ buffer_store,
+ worktree_store,
+ limit,
+ }
+ }
+
+ pub(crate) fn remote(
+ buffer_store: Entity<BufferStore>,
+ worktree_store: Entity<WorktreeStore>,
+ limit: usize,
+ client_state: (AnyProtoClient, u64, Arc<Mutex<RemotelyCreatedModels>>),
+ ) -> Self {
+ Self {
+ kind: SearchKind::Remote {
+ client: client_state.0,
+ remote_id: client_state.1,
+ models: client_state.2,
+ },
+ buffer_store,
+ worktree_store,
+ limit,
+ }
+ }
+ pub(crate) fn open_buffers_only(
+ buffer_store: Entity<BufferStore>,
+ worktree_store: Entity<WorktreeStore>,
+ limit: usize,
+ ) -> Self {
+ Self {
+ kind: SearchKind::OpenBuffersOnly,
+ buffer_store,
+ worktree_store,
+ limit,
+ }
+ }
+
+ pub(crate) const MAX_SEARCH_RESULT_FILES: usize = 5_000;
+ pub(crate) const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
+ /// Prepares a project search run. The resulting [`SearchResultsHandle`] has to be used to specify whether you're interested in matching buffers
+ /// or full search results.
+ pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle {
+ let mut open_buffers = HashSet::default();
+ let mut unnamed_buffers = Vec::new();
+ const MAX_CONCURRENT_BUFFER_OPENS: usize = 64;
+ let buffers = self.buffer_store.read(cx);
+ for handle in buffers.buffers() {
+ let buffer = handle.read(cx);
+ if !buffers.is_searchable(&buffer.remote_id()) {
+ continue;
+ } else if let Some(entry_id) = buffer.entry_id(cx) {
+ open_buffers.insert(entry_id);
+ } else {
+ self.limit -= self.limit.saturating_sub(1);
+ unnamed_buffers.push(handle)
+ };
+ }
+ let executor = cx.background_executor().clone();
+ let (tx, rx) = unbounded();
+ let (grab_buffer_snapshot_tx, grab_buffer_snapshot_rx) = unbounded();
+ let matching_buffers = grab_buffer_snapshot_rx.clone();
+ let trigger_search = Box::new(move |cx: &mut App| {
+ cx.spawn(async move |cx| {
+ for buffer in unnamed_buffers {
+ _ = grab_buffer_snapshot_tx.send(buffer).await;
+ }
+
+ let (find_all_matches_tx, find_all_matches_rx) =
+ bounded(MAX_CONCURRENT_BUFFER_OPENS);
+
+ let (candidate_searcher, tasks) = match self.kind {
+ SearchKind::OpenBuffersOnly => {
+ let Ok(open_buffers) = cx.update(|cx| self.all_loaded_buffers(&query, cx))
+ else {
+ return;
+ };
+ let fill_requests = cx
+ .background_spawn(async move {
+ for buffer in open_buffers {
+ if let Err(_) = grab_buffer_snapshot_tx.send(buffer).await {
+ return;
+ }
+ }
+ })
+ .boxed_local();
+ (FindSearchCandidates::OpenBuffersOnly, vec![fill_requests])
+ }
+ SearchKind::Local {
+ fs,
+ ref mut worktrees,
+ } => {
+ let (get_buffer_for_full_scan_tx, get_buffer_for_full_scan_rx) =
+ unbounded();
+ let (confirm_contents_will_match_tx, confirm_contents_will_match_rx) =
+ bounded(64);
+ let (sorted_search_results_tx, sorted_search_results_rx) = unbounded();
+
+ let (input_paths_tx, input_paths_rx) = unbounded();
+
+ let tasks = vec![
+ cx.spawn(Self::provide_search_paths(
+ std::mem::take(worktrees),
+ query.include_ignored(),
+ input_paths_tx,
+ sorted_search_results_tx,
+ ))
+ .boxed_local(),
+ Self::open_buffers(
+ &self.buffer_store,
+ get_buffer_for_full_scan_rx,
+ grab_buffer_snapshot_tx,
+ cx.clone(),
+ )
+ .boxed_local(),
+ cx.background_spawn(Self::maintain_sorted_search_results(
+ sorted_search_results_rx,
+ get_buffer_for_full_scan_tx.clone(),
+ self.limit,
+ ))
+ .boxed_local(),
+ ];
+ (
+ FindSearchCandidates::Local {
+ fs,
+ get_buffer_for_full_scan_tx,
+ confirm_contents_will_match_tx,
+ confirm_contents_will_match_rx,
+ input_paths_rx,
+ },
+ tasks,
+ )
+ }
+ SearchKind::Remote {
+ client,
+ remote_id,
+ models,
+ } => {
+ let request = client.request(proto::FindSearchCandidates {
+ project_id: remote_id,
+ query: Some(query.to_proto()),
+ limit: self.limit as _,
+ });
+ let Ok(guard) = cx.update(|cx| {
+ Project::retain_remotely_created_models_impl(
+ &models,
+ &self.buffer_store,
+ &self.worktree_store,
+ cx,
+ )
+ }) else {
+ return;
+ };
+ let buffer_store = self.buffer_store.downgrade();
+ let issue_remote_buffers_request = cx
+ .spawn(async move |cx| {
+ let _ = maybe!(async move {
+ let response = request.await?;
+
+ for buffer_id in response.buffer_ids {
+ let buffer_id = BufferId::new(buffer_id)?;
+ let buffer = buffer_store
+ .update(cx, |buffer_store, cx| {
+ buffer_store.wait_for_remote_buffer(buffer_id, cx)
+ })?
+ .await?;
+ let _ = grab_buffer_snapshot_tx.send(buffer).await;
+ }
+
+ drop(guard);
+ anyhow::Ok(())
+ })
+ .await
+ .log_err();
+ })
+ .boxed_local();
+ (
+ FindSearchCandidates::Remote,
+ vec![issue_remote_buffers_request],
+ )
+ }
+ };
+
+ let matches_count = AtomicUsize::new(0);
+ let matched_buffer_count = AtomicUsize::new(0);
+
+ let worker_pool = executor.scoped(|scope| {
+ let num_cpus = executor.num_cpus();
+
+ assert!(num_cpus > 0);
+ for _ in 0..executor.num_cpus() - 1 {
+ let worker = Worker {
+ query: &query,
+ open_buffers: &open_buffers,
+ matched_buffer_count: &matched_buffer_count,
+ matches_count: &matches_count,
+ candidates: candidate_searcher.clone(),
+ find_all_matches_rx: find_all_matches_rx.clone(),
+ publish_matches: tx.clone(),
+ };
+ scope.spawn(worker.run());
+ }
+ drop(tx);
+ drop(find_all_matches_rx);
+ drop(candidate_searcher);
+ });
+
+ let buffer_snapshots = Self::grab_buffer_snapshots(
+ grab_buffer_snapshot_rx,
+ find_all_matches_tx,
+ cx.clone(),
+ );
+ futures::future::join_all(
+ [worker_pool.boxed_local(), buffer_snapshots.boxed_local()]
+ .into_iter()
+ .chain(tasks),
+ )
+ .await;
+ })
+ });
+
+ SearchResultsHandle {
+ results: rx,
+ matching_buffers,
+ trigger_search,
+ }
+ }
+
+ fn provide_search_paths(
+ worktrees: Vec<Entity<Worktree>>,
+ include_ignored: bool,
+ tx: Sender<InputPath>,
+ results: Sender<oneshot::Receiver<ProjectPath>>,
+ ) -> impl AsyncFnOnce(&mut AsyncApp) {
+ async move |cx| {
+ _ = maybe!(async move {
+ for worktree in worktrees {
+ let (mut snapshot, worktree_settings) = worktree
+ .read_with(cx, |this, _| {
+ Some((this.snapshot(), this.as_local()?.settings()))
+ })?
+ .context("The worktree is not local")?;
+ if include_ignored {
+ // Pre-fetch all of the ignored directories as they're going to be searched.
+ let mut entries_to_refresh = vec![];
+ for entry in snapshot.entries(include_ignored, 0) {
+ if entry.is_ignored && entry.kind.is_unloaded() {
+ if !worktree_settings.is_path_excluded(&entry.path) {
+ entries_to_refresh.push(entry.path.clone());
+ }
+ }
+ }
+ let barrier = worktree.update(cx, |this, _| {
+ let local = this.as_local_mut()?;
+ let barrier = entries_to_refresh
+ .into_iter()
+ .map(|path| local.add_path_prefix_to_scan(path).into_future())
+ .collect::<Vec<_>>();
+ Some(barrier)
+ })?;
+ if let Some(barriers) = barrier {
+ futures::future::join_all(barriers).await;
+ }
+ snapshot = worktree.read_with(cx, |this, _| this.snapshot())?;
+ }
+ cx.background_executor()
+ .scoped(|scope| {
+ scope.spawn(async {
+ for entry in snapshot.files(include_ignored, 0) {
+ let (should_scan_tx, should_scan_rx) = oneshot::channel();
+ let Ok(_) = tx
+ .send(InputPath {
+ entry: entry.clone(),
+ snapshot: snapshot.clone(),
+ should_scan_tx,
+ })
+ .await
+ else {
+ return;
+ };
+ if results.send(should_scan_rx).await.is_err() {
+ return;
+ };
+ }
+ })
+ })
+ .await;
+ }
+ anyhow::Ok(())
+ })
+ .await;
+ }
+ }
+
+ async fn maintain_sorted_search_results(
+ rx: Receiver<oneshot::Receiver<ProjectPath>>,
+ paths_for_full_scan: Sender<ProjectPath>,
+ limit: usize,
+ ) {
+ let mut rx = pin!(rx);
+ let mut matched = 0;
+ while let Some(mut next_path_result) = rx.next().await {
+ let Some(successful_path) = next_path_result.next().await else {
+ // This math did not produce a match, hence skip it.
+ continue;
+ };
+ if paths_for_full_scan.send(successful_path).await.is_err() {
+ return;
+ };
+ matched += 1;
+ if matched >= limit {
+ break;
+ }
+ }
+ }
+
+ /// Background workers cannot open buffers by themselves, hence main thread will do it on their behalf.
+ async fn open_buffers(
+ buffer_store: &Entity<BufferStore>,
+ rx: Receiver<ProjectPath>,
+ find_all_matches_tx: Sender<Entity<Buffer>>,
+ mut cx: AsyncApp,
+ ) {
+ let mut rx = pin!(rx.ready_chunks(64));
+ _ = maybe!(async move {
+ while let Some(requested_paths) = rx.next().await {
+ let mut buffers = buffer_store.update(&mut cx, |this, cx| {
+ requested_paths
+ .into_iter()
+ .map(|path| this.open_buffer(path, None, false, true, cx))
+ .collect::<FuturesOrdered<_>>()
+ })?;
+
+ while let Some(buffer) = buffers.next().await {
+ if let Some(buffer) = buffer.log_err() {
+ find_all_matches_tx.send(buffer).await?;
+ }
+ }
+ }
+ Result::<_, anyhow::Error>::Ok(())
+ })
+ .await;
+ }
+
+ async fn grab_buffer_snapshots(
+ rx: Receiver<Entity<Buffer>>,
+ find_all_matches_tx: Sender<(Entity<Buffer>, BufferSnapshot)>,
+ mut cx: AsyncApp,
+ ) {
+ _ = maybe!(async move {
+ while let Ok(buffer) = rx.recv().await {
+ let snapshot = buffer.read_with(&mut cx, |this, _| this.snapshot())?;
+ find_all_matches_tx.send((buffer, snapshot)).await?;
+ }
+ Result::<_, anyhow::Error>::Ok(())
+ })
+ .await;
+ }
+
+ fn all_loaded_buffers(&self, search_query: &SearchQuery, cx: &App) -> Vec<Entity<Buffer>> {
+ let worktree_store = self.worktree_store.read(cx);
+ let mut buffers = search_query
+ .buffers()
+ .into_iter()
+ .flatten()
+ .filter(|buffer| {
+ let b = buffer.read(cx);
+ if let Some(file) = b.file() {
+ if !search_query.match_path(file.path().as_std_path()) {
+ return false;
+ }
+ if !search_query.include_ignored()
+ && let Some(entry) = b
+ .entry_id(cx)
+ .and_then(|entry_id| worktree_store.entry_for_id(entry_id, cx))
+ && entry.is_ignored
+ {
+ return false;
+ }
+ }
+ true
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ buffers.sort_by(|a, b| {
+ let a = a.read(cx);
+ let b = b.read(cx);
+ match (a.file(), b.file()) {
+ (None, None) => a.remote_id().cmp(&b.remote_id()),
+ (None, Some(_)) => std::cmp::Ordering::Less,
+ (Some(_), None) => std::cmp::Ordering::Greater,
+ (Some(a), Some(b)) => compare_rel_paths((a.path(), true), (b.path(), true)),
+ }
+ });
+
+ buffers
+ }
+}
+
+struct Worker<'search> {
+ query: &'search SearchQuery,
+ matched_buffer_count: &'search AtomicUsize,
+ matches_count: &'search AtomicUsize,
+ open_buffers: &'search HashSet<ProjectEntryId>,
+ candidates: FindSearchCandidates,
+ /// Ok, we're back in background: run full scan & find all matches in a given buffer snapshot.
+ find_all_matches_rx: Receiver<(Entity<Buffer>, BufferSnapshot)>,
+ /// Cool, we have results; let's share them with the world.
+ publish_matches: Sender<SearchResult>,
+}
+
+impl Worker<'_> {
+ async fn run(mut self) {
+ let (
+ input_paths_rx,
+ confirm_contents_will_match_rx,
+ mut confirm_contents_will_match_tx,
+ mut get_buffer_for_full_scan_tx,
+ fs,
+ ) = match self.candidates {
+ FindSearchCandidates::Local {
+ fs,
+ input_paths_rx,
+ confirm_contents_will_match_rx,
+ confirm_contents_will_match_tx,
+ get_buffer_for_full_scan_tx,
+ } => (
+ input_paths_rx,
+ confirm_contents_will_match_rx,
+ confirm_contents_will_match_tx,
+ get_buffer_for_full_scan_tx,
+ Some(fs),
+ ),
+ FindSearchCandidates::Remote | FindSearchCandidates::OpenBuffersOnly => (
+ unbounded().1,
+ unbounded().1,
+ unbounded().0,
+ unbounded().0,
+ None,
+ ),
+ };
+ let mut find_all_matches = pin!(self.find_all_matches_rx.fuse());
+ let mut find_first_match = pin!(confirm_contents_will_match_rx.fuse());
+ let mut scan_path = pin!(input_paths_rx.fuse());
+
+ loop {
+ let handler = RequestHandler {
+ query: self.query,
+ open_entries: &self.open_buffers,
+ fs: fs.as_deref(),
+ matched_buffer_count: self.matched_buffer_count,
+ matches_count: self.matches_count,
+ confirm_contents_will_match_tx: &confirm_contents_will_match_tx,
+ get_buffer_for_full_scan_tx: &get_buffer_for_full_scan_tx,
+ publish_matches: &self.publish_matches,
+ };
+ // Whenever we notice that some step of a pipeline is closed, we don't want to close subsequent
+ // steps straight away. Another worker might be about to produce a value that will
+ // be pushed there, thus we'll replace current worker's pipe with a dummy one.
+ // That way, we'll only ever close a next-stage channel when ALL workers do so.
+ select_biased! {
+ find_all_matches = find_all_matches.next() => {
+
+ if self.publish_matches.is_closed() {
+ break;
+ }
+ let Some(matches) = find_all_matches else {
+ self.publish_matches = bounded(1).0;
+ continue;
+ };
+ let result = handler.handle_find_all_matches(matches).await;
+ if let Some(_should_bail) = result {
+
+ self.publish_matches = bounded(1).0;
+ continue;
+ }
+ },
+ find_first_match = find_first_match.next() => {
+ if let Some(buffer_with_at_least_one_match) = find_first_match {
+ handler.handle_find_first_match(buffer_with_at_least_one_match).await;
+ } else {
+ get_buffer_for_full_scan_tx = bounded(1).0;
+ }
+
+ },
+ scan_path = scan_path.next() => {
+ if let Some(path_to_scan) = scan_path {
+ handler.handle_scan_path(path_to_scan).await;
+ } else {
+ // If we're the last worker to notice that this is not producing values, close the upstream.
+ confirm_contents_will_match_tx = bounded(1).0;
+ }
+
+ }
+ complete => {
+ break
+ },
+
+ }
+ }
+ }
+}
+
+struct RequestHandler<'worker> {
+ query: &'worker SearchQuery,
+ fs: Option<&'worker dyn Fs>,
+ open_entries: &'worker HashSet<ProjectEntryId>,
+ matched_buffer_count: &'worker AtomicUsize,
+ matches_count: &'worker AtomicUsize,
+
+ confirm_contents_will_match_tx: &'worker Sender<MatchingEntry>,
+ get_buffer_for_full_scan_tx: &'worker Sender<ProjectPath>,
+ publish_matches: &'worker Sender<SearchResult>,
+}
+
+struct LimitReached;
+
+impl RequestHandler<'_> {
+ async fn handle_find_all_matches(
+ &self,
+ (buffer, snapshot): (Entity<Buffer>, BufferSnapshot),
+ ) -> Option<LimitReached> {
+ let ranges = self
+ .query
+ .search(&snapshot, None)
+ .await
+ .iter()
+ .map(|range| snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end))
+ .collect::<Vec<_>>();
+
+ let matched_ranges = ranges.len();
+ if self.matched_buffer_count.fetch_add(1, Ordering::Release)
+ > Search::MAX_SEARCH_RESULT_FILES
+ || self
+ .matches_count
+ .fetch_add(matched_ranges, Ordering::Release)
+ > Search::MAX_SEARCH_RESULT_RANGES
+ {
+ _ = self.publish_matches.send(SearchResult::LimitReached).await;
+ Some(LimitReached)
+ } else {
+ _ = self
+ .publish_matches
+ .send(SearchResult::Buffer { buffer, ranges })
+ .await;
+ None
+ }
+ }
+ async fn handle_find_first_match(&self, mut entry: MatchingEntry) {
+ _=maybe!(async move {
+ let abs_path = entry.worktree_root.join(entry.path.path.as_std_path());
+ let Some(file) = self.fs.context("Trying to query filesystem in remote project search")?.open_sync(&abs_path).await.log_err() else {
+ return anyhow::Ok(());
+ };
+
+ let mut file = BufReader::new(file);
+ let file_start = file.fill_buf()?;
+
+ if let Err(Some(starting_position)) =
+ std::str::from_utf8(file_start).map_err(|e| e.error_len())
+ {
+ // Before attempting to match the file content, throw away files that have invalid UTF-8 sequences early on;
+ // That way we can still match files in a streaming fashion without having look at "obviously binary" files.
+ log::debug!(
+ "Invalid UTF-8 sequence in file {abs_path:?} at byte position {starting_position}"
+ );
+ return Ok(());
+ }
+
+ if self.query.detect(file).unwrap_or(false) {
+ // Yes, we should scan the whole file.
+ entry.should_scan_tx.send(entry.path).await?;
+ }
+ Ok(())
+ }).await;
+ }
+
+ async fn handle_scan_path(&self, req: InputPath) {
+ _ = maybe!(async move {
+ let InputPath {
+ entry,
+
+ snapshot,
+ should_scan_tx,
+ } = req;
+
+ if entry.is_fifo || !entry.is_file() {
+ return Ok(());
+ }
+
+ if self.query.filters_path() {
+ let matched_path = if self.query.match_full_paths() {
+ let mut full_path = snapshot.root_name().as_std_path().to_owned();
+ full_path.push(entry.path.as_std_path());
+ self.query.match_path(&full_path)
+ } else {
+ self.query.match_path(entry.path.as_std_path())
+ };
+ if !matched_path {
+ return Ok(());
+ }
+ }
+
+ if self.open_entries.contains(&entry.id) {
+ // The buffer is already in memory and that's the version we want to scan;
+ // hence skip the dilly-dally and look for all matches straight away.
+ self.get_buffer_for_full_scan_tx
+ .send(ProjectPath {
+ worktree_id: snapshot.id(),
+ path: entry.path.clone(),
+ })
+ .await?;
+ } else {
+ self.confirm_contents_will_match_tx
+ .send(MatchingEntry {
+ should_scan_tx: should_scan_tx,
+ worktree_root: snapshot.abs_path().clone(),
+ path: ProjectPath {
+ worktree_id: snapshot.id(),
+ path: entry.path.clone(),
+ },
+ })
+ .await?;
+ }
+
+ anyhow::Ok(())
+ })
+ .await;
+ }
+}
+
+struct InputPath {
+ entry: Entry,
+ snapshot: Snapshot,
+ should_scan_tx: oneshot::Sender<ProjectPath>,
+}
+
+struct MatchingEntry {
+ worktree_root: Arc<Path>,
+ path: ProjectPath,
+ should_scan_tx: oneshot::Sender<ProjectPath>,
+}