{
div()
.id("rule-editor")
@@ -1198,9 +1298,9 @@ impl RulesLibrary {
let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
let rule_editor = &self.rule_editors[&prompt_id];
let focus_handle = rule_editor.body_editor.focus_handle(cx);
- let model = LanguageModelRegistry::read_global(cx)
- .default_model()
- .map(|default| default.model);
+ let registry = LanguageModelRegistry::read_global(cx);
+ let model = registry.default_model().map(|default| default.model);
+ let built_in = prompt_id.is_built_in();
Some(
v_flex()
@@ -1214,14 +1314,15 @@ impl RulesLibrary {
.child(
h_flex()
.group("active-editor-header")
- .pt_2()
- .pl_1p5()
- .pr_2p5()
+ .h_12()
+ .px_2()
.gap_2()
.justify_between()
- .child(
- self.render_active_rule_editor(&rule_editor.title_editor, cx),
- )
+ .child(self.render_active_rule_editor(
+ &rule_editor.title_editor,
+ built_in,
+ cx,
+ ))
.child(
h_flex()
.h_full()
@@ -1258,89 +1359,15 @@ impl RulesLibrary {
.color(Color::Muted),
)
}))
- .child(if prompt_id.is_built_in() {
- div()
- .id("built-in-rule")
- .child(
- Icon::new(IconName::FileLock)
- .color(Color::Muted),
- )
- .tooltip(move |_window, cx| {
- Tooltip::with_meta(
- "Built-in rule",
- None,
- BUILT_IN_TOOLTIP_TEXT,
- cx,
- )
- })
- .into_any()
- } else {
- IconButton::new("delete-rule", IconName::Trash)
- .tooltip(move |_window, cx| {
- Tooltip::for_action(
- "Delete Rule",
- &DeleteRule,
- cx,
- )
- })
- .on_click(|_, window, cx| {
- window
- .dispatch_action(Box::new(DeleteRule), cx);
- })
- .into_any_element()
- })
- .child(
- IconButton::new("duplicate-rule", IconName::BookCopy)
- .tooltip(move |_window, cx| {
- Tooltip::for_action(
- "Duplicate Rule",
- &DuplicateRule,
- cx,
- )
- })
- .on_click(|_, window, cx| {
- window.dispatch_action(
- Box::new(DuplicateRule),
- cx,
- );
- }),
- )
- .child(
- IconButton::new(
- "toggle-default-rule",
- IconName::Paperclip,
- )
- .toggle_state(rule_metadata.default)
- .icon_color(if rule_metadata.default {
- Color::Accent
+ .map(|this| {
+ if built_in {
+ this.child(self.render_built_in_rule_controls())
} else {
- Color::Muted
- })
- .map(|this| {
- if rule_metadata.default {
- this.tooltip(Tooltip::text(
- "Remove from Default Rules",
- ))
- } else {
- this.tooltip(move |_window, cx| {
- Tooltip::with_meta(
- "Add to Default Rules",
- None,
- "Always included in every thread.",
- cx,
- )
- })
- }
- })
- .on_click(
- |_, window, cx| {
- window.dispatch_action(
- Box::new(ToggleDefaultRule),
- cx,
- );
- },
- ),
- ),
+ this.child(self.render_regular_rule_controls(
+ rule_metadata.default,
+ ))
+ }
+ }),
),
)
.child(
@@ -1385,6 +1412,9 @@ impl Render for RulesLibrary {
.on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
this.toggle_default_for_active_rule(window, cx)
}))
+ .on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| {
+ this.restore_default_content_for_active_rule(window, cx)
+ }))
.size_full()
.overflow_hidden()
.font(ui_font)
From 4896f477e2353fa5f6eaf01642533e7f7bd56384 Mon Sep 17 00:00:00 2001
From: max <144637754+mdliss@users.noreply.github.com>
Date: Tue, 16 Dec 2025 12:03:34 -0600
Subject: [PATCH 021/171] Add MCP prompt support to agent threads (#43523)
Fixes #43165
## Problem
MCP prompts were only available in text threads, not agent threads.
Users with MCP servers that expose prompts couldn't use them in the main
agent panel.
## Solution
Added MCP prompt support to agent threads by:
- Creating `ContextServerPromptRegistry` to track MCP prompts from
context servers
- Subscribing to context server events to reload prompts when MCP
servers start/stop
- Converting MCP prompts to available commands that appear in the slash
command menu
- Integrating prompt expansion into the agent message flow
## Testing
Tested with a custom MCP server exposing `explain-code` and
`write-tests` prompts. Prompts now appear in the `/` slash command menu
in agent threads.
Release Notes:
- Added MCP prompt support to agent threads. Prompts from MCP servers
now appear in the slash command menu when typing `/` in agent threads.
---------
Co-authored-by: Agus Zubiaga
---
crates/acp_thread/src/acp_thread.rs | 41 ++-
crates/agent/src/agent.rs | 327 +++++++++++++++++-
crates/agent/src/thread.rs | 60 +++-
.../src/tools/context_server_registry.rs | 160 ++++++++-
crates/agent_ui/src/acp/thread_view.rs | 78 ++++-
crates/context_server/src/types.rs | 2 +-
6 files changed, 627 insertions(+), 41 deletions(-)
diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index 53294a963d9d230c9b06372c26591ede0434ab28..2ec6347fd4aa088d7ae2cc8f5a7b6cef37d3b202 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -43,6 +43,7 @@ pub struct UserMessage {
pub content: ContentBlock,
pub chunks: Vec,
pub checkpoint: Option,
+ pub indented: bool,
}
#[derive(Debug)]
@@ -73,6 +74,7 @@ impl UserMessage {
#[derive(Debug, PartialEq)]
pub struct AssistantMessage {
pub chunks: Vec,
+ pub indented: bool,
}
impl AssistantMessage {
@@ -123,6 +125,14 @@ pub enum AgentThreadEntry {
}
impl AgentThreadEntry {
+ pub fn is_indented(&self) -> bool {
+ match self {
+ Self::UserMessage(message) => message.indented,
+ Self::AssistantMessage(message) => message.indented,
+ Self::ToolCall(_) => false,
+ }
+ }
+
pub fn to_markdown(&self, cx: &App) -> String {
match self {
Self::UserMessage(message) => message.to_markdown(cx),
@@ -1184,6 +1194,16 @@ impl AcpThread {
message_id: Option,
chunk: acp::ContentBlock,
cx: &mut Context,
+ ) {
+ self.push_user_content_block_with_indent(message_id, chunk, false, cx)
+ }
+
+ pub fn push_user_content_block_with_indent(
+ &mut self,
+ message_id: Option,
+ chunk: acp::ContentBlock,
+ indented: bool,
+ cx: &mut Context,
) {
let language_registry = self.project.read(cx).languages().clone();
let path_style = self.project.read(cx).path_style(cx);
@@ -1194,8 +1214,10 @@ impl AcpThread {
id,
content,
chunks,
+ indented: existing_indented,
..
}) = last_entry
+ && *existing_indented == indented
{
*id = message_id.or(id.take());
content.append(chunk.clone(), &language_registry, path_style, cx);
@@ -1210,6 +1232,7 @@ impl AcpThread {
content,
chunks: vec![chunk],
checkpoint: None,
+ indented,
}),
cx,
);
@@ -1221,12 +1244,26 @@ impl AcpThread {
chunk: acp::ContentBlock,
is_thought: bool,
cx: &mut Context,
+ ) {
+ self.push_assistant_content_block_with_indent(chunk, is_thought, false, cx)
+ }
+
+ pub fn push_assistant_content_block_with_indent(
+ &mut self,
+ chunk: acp::ContentBlock,
+ is_thought: bool,
+ indented: bool,
+ cx: &mut Context,
) {
let language_registry = self.project.read(cx).languages().clone();
let path_style = self.project.read(cx).path_style(cx);
let entries_len = self.entries.len();
if let Some(last_entry) = self.entries.last_mut()
- && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry
+ && let AgentThreadEntry::AssistantMessage(AssistantMessage {
+ chunks,
+ indented: existing_indented,
+ }) = last_entry
+ && *existing_indented == indented
{
let idx = entries_len - 1;
cx.emit(AcpThreadEvent::EntryUpdated(idx));
@@ -1255,6 +1292,7 @@ impl AcpThread {
self.push_entry(
AgentThreadEntry::AssistantMessage(AssistantMessage {
chunks: vec![chunk],
+ indented,
}),
cx,
);
@@ -1704,6 +1742,7 @@ impl AcpThread {
content: block,
chunks: message,
checkpoint: None,
+ indented: false,
}),
cx,
);
diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs
index f29b9f405c121b54fd9a4a250e977c593ffc3d4b..693d3abd4497c057a75b4f01c07bd51f311f1fdb 100644
--- a/crates/agent/src/agent.rs
+++ b/crates/agent/src/agent.rs
@@ -5,12 +5,12 @@ mod legacy_thread;
mod native_agent_server;
pub mod outline;
mod templates;
-mod thread;
-mod tools;
-
#[cfg(test)]
mod tests;
+mod thread;
+mod tools;
+use context_server::ContextServerId;
pub use db::*;
pub use history_store::*;
pub use native_agent_server::NativeAgentServer;
@@ -18,11 +18,11 @@ pub use templates::*;
pub use thread::*;
pub use tools::*;
-use acp_thread::{AcpThread, AgentModelSelector};
+use acp_thread::{AcpThread, AgentModelSelector, UserMessageId};
use agent_client_protocol as acp;
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
-use collections::{HashSet, IndexMap};
+use collections::{HashMap, HashSet, IndexMap};
use fs::Fs;
use futures::channel::{mpsc, oneshot};
use futures::future::Shared;
@@ -39,7 +39,6 @@ use prompt_store::{
use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, update_settings_file};
use std::any::Any;
-use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
@@ -252,12 +251,24 @@ impl NativeAgent {
.await;
cx.new(|cx| {
+ let context_server_store = project.read(cx).context_server_store();
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
+
let mut subscriptions = vec![
cx.subscribe(&project, Self::handle_project_event),
cx.subscribe(
&LanguageModelRegistry::global(cx),
Self::handle_models_updated_event,
),
+ cx.subscribe(
+ &context_server_store,
+ Self::handle_context_server_store_updated,
+ ),
+ cx.subscribe(
+ &context_server_registry,
+ Self::handle_context_server_registry_event,
+ ),
];
if let Some(prompt_store) = prompt_store.as_ref() {
subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
@@ -266,16 +277,14 @@ impl NativeAgent {
let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
watch::channel(());
Self {
- sessions: HashMap::new(),
+ sessions: HashMap::default(),
history,
project_context: cx.new(|_| project_context),
project_context_needs_refresh: project_context_needs_refresh_tx,
_maintain_project_context: cx.spawn(async move |this, cx| {
Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await
}),
- context_server_registry: cx.new(|cx| {
- ContextServerRegistry::new(project.read(cx).context_server_store(), cx)
- }),
+ context_server_registry,
templates,
models: LanguageModels::new(cx),
project,
@@ -344,6 +353,9 @@ impl NativeAgent {
pending_save: Task::ready(()),
},
);
+
+ self.update_available_commands(cx);
+
acp_thread
}
@@ -608,6 +620,99 @@ impl NativeAgent {
}
}
+ fn handle_context_server_store_updated(
+ &mut self,
+ _store: Entity,
+ _event: &project::context_server_store::Event,
+ cx: &mut Context,
+ ) {
+ self.update_available_commands(cx);
+ }
+
+ fn handle_context_server_registry_event(
+ &mut self,
+ _registry: Entity,
+ event: &ContextServerRegistryEvent,
+ cx: &mut Context,
+ ) {
+ match event {
+ ContextServerRegistryEvent::ToolsChanged => {}
+ ContextServerRegistryEvent::PromptsChanged => {
+ self.update_available_commands(cx);
+ }
+ }
+ }
+
+ fn update_available_commands(&self, cx: &mut Context) {
+ let available_commands = self.build_available_commands(cx);
+ for session in self.sessions.values() {
+ if let Some(acp_thread) = session.acp_thread.upgrade() {
+ acp_thread.update(cx, |thread, cx| {
+ thread
+ .handle_session_update(
+ acp::SessionUpdate::AvailableCommandsUpdate(
+ acp::AvailableCommandsUpdate::new(available_commands.clone()),
+ ),
+ cx,
+ )
+ .log_err();
+ });
+ }
+ }
+ }
+
+ fn build_available_commands(&self, cx: &App) -> Vec {
+ let registry = self.context_server_registry.read(cx);
+
+ let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default();
+ for context_server_prompt in registry.prompts() {
+ *prompt_name_counts
+ .entry(context_server_prompt.prompt.name.as_str())
+ .or_insert(0) += 1;
+ }
+
+ registry
+ .prompts()
+ .flat_map(|context_server_prompt| {
+ let prompt = &context_server_prompt.prompt;
+
+ let should_prefix = prompt_name_counts
+ .get(prompt.name.as_str())
+ .copied()
+ .unwrap_or(0)
+ > 1;
+
+ let name = if should_prefix {
+ format!("{}.{}", context_server_prompt.server_id, prompt.name)
+ } else {
+ prompt.name.clone()
+ };
+
+ let mut command = acp::AvailableCommand::new(
+ name,
+ prompt.description.clone().unwrap_or_default(),
+ );
+
+ match prompt.arguments.as_deref() {
+ Some([arg]) => {
+ let hint = format!("<{}>", arg.name);
+
+ command = command.input(acp::AvailableCommandInput::Unstructured(
+ acp::UnstructuredCommandInput::new(hint),
+ ));
+ }
+ Some([]) | None => {}
+ Some(_) => {
+ // skip >1 argument commands since we don't support them yet
+ return None;
+ }
+ }
+
+ Some(command)
+ })
+ .collect()
+ }
+
pub fn load_thread(
&mut self,
id: acp::SessionId,
@@ -706,6 +811,102 @@ impl NativeAgent {
history.update(cx, |history, cx| history.reload(cx)).ok();
});
}
+
+ fn send_mcp_prompt(
+ &self,
+ message_id: UserMessageId,
+ session_id: agent_client_protocol::SessionId,
+ prompt_name: String,
+ server_id: ContextServerId,
+ arguments: HashMap,
+ original_content: Vec,
+ cx: &mut Context,
+ ) -> Task> {
+ let server_store = self.context_server_registry.read(cx).server_store().clone();
+ let path_style = self.project.read(cx).path_style(cx);
+
+ cx.spawn(async move |this, cx| {
+ let prompt =
+ crate::get_prompt(&server_store, &server_id, &prompt_name, arguments, cx).await?;
+
+ let (acp_thread, thread) = this.update(cx, |this, _cx| {
+ let session = this
+ .sessions
+ .get(&session_id)
+ .context("Failed to get session")?;
+ anyhow::Ok((session.acp_thread.clone(), session.thread.clone()))
+ })??;
+
+ let mut last_is_user = true;
+
+ thread.update(cx, |thread, cx| {
+ thread.push_acp_user_block(
+ message_id,
+ original_content.into_iter().skip(1),
+ path_style,
+ cx,
+ );
+ })?;
+
+ for message in prompt.messages {
+ let context_server::types::PromptMessage { role, content } = message;
+ let block = mcp_message_content_to_acp_content_block(content);
+
+ match role {
+ context_server::types::Role::User => {
+ let id = acp_thread::UserMessageId::new();
+
+ acp_thread.update(cx, |acp_thread, cx| {
+ acp_thread.push_user_content_block_with_indent(
+ Some(id.clone()),
+ block.clone(),
+ true,
+ cx,
+ );
+ anyhow::Ok(())
+ })??;
+
+ thread.update(cx, |thread, cx| {
+ thread.push_acp_user_block(id, [block], path_style, cx);
+ anyhow::Ok(())
+ })??;
+ }
+ context_server::types::Role::Assistant => {
+ acp_thread.update(cx, |acp_thread, cx| {
+ acp_thread.push_assistant_content_block_with_indent(
+ block.clone(),
+ false,
+ true,
+ cx,
+ );
+ anyhow::Ok(())
+ })??;
+
+ thread.update(cx, |thread, cx| {
+ thread.push_acp_agent_block(block, cx);
+ anyhow::Ok(())
+ })??;
+ }
+ }
+
+ last_is_user = role == context_server::types::Role::User;
+ }
+
+ let response_stream = thread.update(cx, |thread, cx| {
+ if last_is_user {
+ thread.send_existing(cx)
+ } else {
+ // Resume if MCP prompt did not end with a user message
+ thread.resume(cx)
+ }
+ })??;
+
+ cx.update(|cx| {
+ NativeAgentConnection::handle_thread_events(response_stream, acp_thread, cx)
+ })?
+ .await
+ })
+ }
}
/// Wrapper struct that implements the AgentConnection trait
@@ -840,6 +1041,39 @@ impl NativeAgentConnection {
}
}
+struct Command<'a> {
+ prompt_name: &'a str,
+ arg_value: &'a str,
+ explicit_server_id: Option<&'a str>,
+}
+
+impl<'a> Command<'a> {
+ fn parse(prompt: &'a [acp::ContentBlock]) -> Option {
+ let acp::ContentBlock::Text(text_content) = prompt.first()? else {
+ return None;
+ };
+ let text = text_content.text.trim();
+ let command = text.strip_prefix('/')?;
+ let (command, arg_value) = command
+ .split_once(char::is_whitespace)
+ .unwrap_or((command, ""));
+
+ if let Some((server_id, prompt_name)) = command.split_once('.') {
+ Some(Self {
+ prompt_name,
+ arg_value,
+ explicit_server_id: Some(server_id),
+ })
+ } else {
+ Some(Self {
+ prompt_name: command,
+ arg_value,
+ explicit_server_id: None,
+ })
+ }
+ }
+}
+
struct NativeAgentModelSelector {
session_id: acp::SessionId,
connection: NativeAgentConnection,
@@ -1005,6 +1239,47 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
let session_id = params.session_id.clone();
log::info!("Received prompt request for session: {}", session_id);
log::debug!("Prompt blocks count: {}", params.prompt.len());
+
+ if let Some(parsed_command) = Command::parse(¶ms.prompt) {
+ let registry = self.0.read(cx).context_server_registry.read(cx);
+
+ let explicit_server_id = parsed_command
+ .explicit_server_id
+ .map(|server_id| ContextServerId(server_id.into()));
+
+ if let Some(prompt) =
+ registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name)
+ {
+ let arguments = if !parsed_command.arg_value.is_empty()
+ && let Some(arg_name) = prompt
+ .prompt
+ .arguments
+ .as_ref()
+ .and_then(|args| args.first())
+ .map(|arg| arg.name.clone())
+ {
+ HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())])
+ } else {
+ Default::default()
+ };
+
+ let prompt_name = prompt.prompt.name.clone();
+ let server_id = prompt.server_id.clone();
+
+ return self.0.update(cx, |agent, cx| {
+ agent.send_mcp_prompt(
+ id,
+ session_id.clone(),
+ prompt_name,
+ server_id,
+ arguments,
+ params.prompt,
+ cx,
+ )
+ });
+ };
+ };
+
let path_style = self.0.read(cx).project.read(cx).path_style(cx);
self.run_turn(session_id, cx, move |thread, cx| {
@@ -1601,3 +1876,35 @@ mod internal_tests {
});
}
}
+
+fn mcp_message_content_to_acp_content_block(
+ content: context_server::types::MessageContent,
+) -> acp::ContentBlock {
+ match content {
+ context_server::types::MessageContent::Text {
+ text,
+ annotations: _,
+ } => text.into(),
+ context_server::types::MessageContent::Image {
+ data,
+ mime_type,
+ annotations: _,
+ } => acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)),
+ context_server::types::MessageContent::Audio {
+ data,
+ mime_type,
+ annotations: _,
+ } => acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)),
+ context_server::types::MessageContent::Resource {
+ resource,
+ annotations: _,
+ } => {
+ let mut link =
+ acp::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string());
+ if let Some(mime_type) = resource.mime_type {
+ link = link.mime_type(mime_type);
+ }
+ acp::ContentBlock::ResourceLink(link)
+ }
+ }
+}
diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs
index bed3db853a3106ca4df9676d799b4a2bfa0106a0..bb22470b9e7db934f949a13b86fd13f9dc58beed 100644
--- a/crates/agent/src/thread.rs
+++ b/crates/agent/src/thread.rs
@@ -108,7 +108,13 @@ impl Message {
pub fn to_request(&self) -> Vec {
match self {
- Message::User(message) => vec![message.to_request()],
+ Message::User(message) => {
+ if message.content.is_empty() {
+ vec![]
+ } else {
+ vec![message.to_request()]
+ }
+ }
Message::Agent(message) => message.to_request(),
Message::Resume => vec![LanguageModelRequestMessage {
role: Role::User,
@@ -1141,20 +1147,64 @@ impl Thread {
where
T: Into,
{
+ let content = content.into_iter().map(Into::into).collect::>();
+ log::debug!("Thread::send content: {:?}", content);
+
+ self.messages
+ .push(Message::User(UserMessage { id, content }));
+ cx.notify();
+
+ self.send_existing(cx)
+ }
+
+ pub fn send_existing(
+ &mut self,
+ cx: &mut Context,
+ ) -> Result>> {
let model = self.model().context("No language model configured")?;
log::info!("Thread::send called with model: {}", model.name().0);
self.advance_prompt_id();
- let content = content.into_iter().map(Into::into).collect::>();
- log::debug!("Thread::send content: {:?}", content);
+ log::debug!("Total messages in thread: {}", self.messages.len());
+ self.run_turn(cx)
+ }
+ pub fn push_acp_user_block(
+ &mut self,
+ id: UserMessageId,
+ blocks: impl IntoIterator- ,
+ path_style: PathStyle,
+ cx: &mut Context,
+ ) {
+ let content = blocks
+ .into_iter()
+ .map(|block| UserMessageContent::from_content_block(block, path_style))
+ .collect::>();
self.messages
.push(Message::User(UserMessage { id, content }));
cx.notify();
+ }
- log::debug!("Total messages in thread: {}", self.messages.len());
- self.run_turn(cx)
+ pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context) {
+ let text = match block {
+ acp::ContentBlock::Text(text_content) => text_content.text,
+ acp::ContentBlock::Image(_) => "[image]".to_string(),
+ acp::ContentBlock::Audio(_) => "[audio]".to_string(),
+ acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri,
+ acp::ContentBlock::Resource(resource) => match resource.resource {
+ acp::EmbeddedResourceResource::TextResourceContents(resource) => resource.uri,
+ acp::EmbeddedResourceResource::BlobResourceContents(resource) => resource.uri,
+ _ => "[resource]".to_string(),
+ },
+ _ => "[unknown]".to_string(),
+ };
+
+ self.messages.push(Message::Agent(AgentMessage {
+ content: vec![AgentMessageContent::Text(text)],
+ ..Default::default()
+ }));
+ cx.notify();
}
#[cfg(feature = "eval")]
diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs
index 03a0ef84e73d4cbca83d61077d568ec58cd7ae2b..735a47ae9fb99decbf97beb74a590f13f8f74878 100644
--- a/crates/agent/src/tools/context_server_registry.rs
+++ b/crates/agent/src/tools/context_server_registry.rs
@@ -3,11 +3,23 @@ use agent_client_protocol::ToolKind;
use anyhow::{Result, anyhow, bail};
use collections::{BTreeMap, HashMap};
use context_server::ContextServerId;
-use gpui::{App, Context, Entity, SharedString, Task};
+use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task};
use project::context_server_store::{ContextServerStatus, ContextServerStore};
use std::sync::Arc;
use util::ResultExt;
+pub struct ContextServerPrompt {
+ pub server_id: ContextServerId,
+ pub prompt: context_server::types::Prompt,
+}
+
+pub enum ContextServerRegistryEvent {
+ ToolsChanged,
+ PromptsChanged,
+}
+
+impl EventEmitter for ContextServerRegistry {}
+
pub struct ContextServerRegistry {
server_store: Entity,
registered_servers: HashMap,
@@ -16,7 +28,20 @@ pub struct ContextServerRegistry {
struct RegisteredContextServer {
tools: BTreeMap>,
+ prompts: BTreeMap,
load_tools: Task>,
+ load_prompts: Task>,
+}
+
+impl RegisteredContextServer {
+ fn new() -> Self {
+ Self {
+ tools: BTreeMap::default(),
+ prompts: BTreeMap::default(),
+ load_tools: Task::ready(Ok(())),
+ load_prompts: Task::ready(Ok(())),
+ }
+ }
}
impl ContextServerRegistry {
@@ -28,6 +53,7 @@ impl ContextServerRegistry {
};
for server in server_store.read(cx).running_servers() {
this.reload_tools_for_server(server.id(), cx);
+ this.reload_prompts_for_server(server.id(), cx);
}
this
}
@@ -56,6 +82,41 @@ impl ContextServerRegistry {
.map(|(id, server)| (id, &server.tools))
}
+ pub fn prompts(&self) -> impl Iterator
- {
+ self.registered_servers
+ .values()
+ .flat_map(|server| server.prompts.values())
+ }
+
+ pub fn find_prompt(
+ &self,
+ server_id: Option<&ContextServerId>,
+ name: &str,
+ ) -> Option<&ContextServerPrompt> {
+ if let Some(server_id) = server_id {
+ self.registered_servers
+ .get(server_id)
+ .and_then(|server| server.prompts.get(name))
+ } else {
+ self.registered_servers
+ .values()
+ .find_map(|server| server.prompts.get(name))
+ }
+ }
+
+ pub fn server_store(&self) -> &Entity {
+ &self.server_store
+ }
+
+ fn get_or_register_server(
+ &mut self,
+ server_id: &ContextServerId,
+ ) -> &mut RegisteredContextServer {
+ self.registered_servers
+ .entry(server_id.clone())
+ .or_insert_with(RegisteredContextServer::new)
+ }
+
fn reload_tools_for_server(&mut self, server_id: ContextServerId, cx: &mut Context) {
let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
return;
@@ -67,13 +128,7 @@ impl ContextServerRegistry {
return;
}
- let registered_server =
- self.registered_servers
- .entry(server_id.clone())
- .or_insert(RegisteredContextServer {
- tools: BTreeMap::default(),
- load_tools: Task::ready(Ok(())),
- });
+ let registered_server = self.get_or_register_server(&server_id);
registered_server.load_tools = cx.spawn(async move |this, cx| {
let response = client
.request::(())
@@ -94,6 +149,49 @@ impl ContextServerRegistry {
));
registered_server.tools.insert(tool.name(), tool);
}
+ cx.emit(ContextServerRegistryEvent::ToolsChanged);
+ cx.notify();
+ }
+ })
+ });
+ }
+
+ fn reload_prompts_for_server(&mut self, server_id: ContextServerId, cx: &mut Context) {
+ let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else {
+ return;
+ };
+ let Some(client) = server.client() else {
+ return;
+ };
+ if !client.capable(context_server::protocol::ServerCapability::Prompts) {
+ return;
+ }
+
+ let registered_server = self.get_or_register_server(&server_id);
+
+ registered_server.load_prompts = cx.spawn(async move |this, cx| {
+ let response = client
+ .request::(())
+ .await;
+
+ this.update(cx, |this, cx| {
+ let Some(registered_server) = this.registered_servers.get_mut(&server_id) else {
+ return;
+ };
+
+ registered_server.prompts.clear();
+ if let Some(response) = response.log_err() {
+ for prompt in response.prompts {
+ let name: SharedString = prompt.name.clone().into();
+ registered_server.prompts.insert(
+ name,
+ ContextServerPrompt {
+ server_id: server_id.clone(),
+ prompt,
+ },
+ );
+ }
+ cx.emit(ContextServerRegistryEvent::PromptsChanged);
cx.notify();
}
})
@@ -112,9 +210,17 @@ impl ContextServerRegistry {
ContextServerStatus::Starting => {}
ContextServerStatus::Running => {
self.reload_tools_for_server(server_id.clone(), cx);
+ self.reload_prompts_for_server(server_id.clone(), cx);
}
ContextServerStatus::Stopped | ContextServerStatus::Error(_) => {
- self.registered_servers.remove(server_id);
+ if let Some(registered_server) = self.registered_servers.remove(server_id) {
+ if !registered_server.tools.is_empty() {
+ cx.emit(ContextServerRegistryEvent::ToolsChanged);
+ }
+ if !registered_server.prompts.is_empty() {
+ cx.emit(ContextServerRegistryEvent::PromptsChanged);
+ }
+ }
cx.notify();
}
}
@@ -251,3 +357,39 @@ impl AnyAgentTool for ContextServerTool {
Ok(())
}
}
+
+pub fn get_prompt(
+ server_store: &Entity,
+ server_id: &ContextServerId,
+ prompt_name: &str,
+ arguments: HashMap,
+ cx: &mut AsyncApp,
+) -> Task> {
+ let server = match cx.update(|cx| server_store.read(cx).get_running_server(server_id)) {
+ Ok(server) => server,
+ Err(error) => return Task::ready(Err(error)),
+ };
+ let Some(server) = server else {
+ return Task::ready(Err(anyhow::anyhow!("Context server not found")));
+ };
+
+ let Some(protocol) = server.client() else {
+ return Task::ready(Err(anyhow::anyhow!("Context server not initialized")));
+ };
+
+ let prompt_name = prompt_name.to_string();
+
+ cx.background_spawn(async move {
+ let response = protocol
+ .request::(
+ context_server::types::PromptsGetParams {
+ name: prompt_name,
+ arguments: (!arguments.is_empty()).then(|| arguments),
+ meta: None,
+ },
+ )
+ .await?;
+
+ Ok(response)
+ })
+}
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index aa02e22635c1585003fbfc540b50687ae0930ecd..cabdaf920c9597e85176f11f4f3a466c4ab96fe8 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/crates/agent_ui/src/acp/thread_view.rs
@@ -1315,7 +1315,7 @@ impl AcpThreadView {
})?;
anyhow::Ok(())
})
- .detach();
+ .detach_and_log_err(cx);
}
fn open_edited_buffer(
@@ -1940,6 +1940,16 @@ impl AcpThreadView {
window: &mut Window,
cx: &Context,
) -> AnyElement {
+ let is_indented = entry.is_indented();
+ let is_first_indented = is_indented
+ && self.thread().is_some_and(|thread| {
+ thread
+ .read(cx)
+ .entries()
+ .get(entry_ix.saturating_sub(1))
+ .is_none_or(|entry| !entry.is_indented())
+ });
+
let primary = match &entry {
AgentThreadEntry::UserMessage(message) => {
let Some(editor) = self
@@ -1972,7 +1982,9 @@ impl AcpThreadView {
v_flex()
.id(("user_message", entry_ix))
.map(|this| {
- if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
+ if is_first_indented {
+ this.pt_0p5()
+ } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
this.pt(rems_from_px(18.))
} else if rules_item.is_some() {
this.pt_3()
@@ -2018,6 +2030,9 @@ impl AcpThreadView {
.shadow_md()
.bg(cx.theme().colors().editor_background)
.border_1()
+ .when(is_indented, |this| {
+ this.py_2().px_2().shadow_sm()
+ })
.when(editing && !editor_focus, |this| this.border_dashed())
.border_color(cx.theme().colors().border)
.map(|this|{
@@ -2112,7 +2127,10 @@ impl AcpThreadView {
)
.into_any()
}
- AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
+ AgentThreadEntry::AssistantMessage(AssistantMessage {
+ chunks,
+ indented: _,
+ }) => {
let is_last = entry_ix + 1 == total_entries;
let style = default_markdown_style(false, false, window, cx);
@@ -2146,6 +2164,7 @@ impl AcpThreadView {
v_flex()
.px_5()
.py_1p5()
+ .when(is_first_indented, |this| this.pt_0p5())
.when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
@@ -2155,19 +2174,48 @@ impl AcpThreadView {
AgentThreadEntry::ToolCall(tool_call) => {
let has_terminals = tool_call.terminals().next().is_some();
- div().w_full().map(|this| {
- if has_terminals {
- this.children(tool_call.terminals().map(|terminal| {
- self.render_terminal_tool_call(
- entry_ix, terminal, tool_call, window, cx,
- )
- }))
- } else {
- this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
- }
- })
+ div()
+ .w_full()
+ .map(|this| {
+ if has_terminals {
+ this.children(tool_call.terminals().map(|terminal| {
+ self.render_terminal_tool_call(
+ entry_ix, terminal, tool_call, window, cx,
+ )
+ }))
+ } else {
+ this.child(self.render_tool_call(entry_ix, tool_call, window, cx))
+ }
+ })
+ .into_any()
}
- .into_any(),
+ };
+
+ let primary = if is_indented {
+ let line_top = if is_first_indented {
+ rems_from_px(-12.0)
+ } else {
+ rems_from_px(0.0)
+ };
+
+ div()
+ .relative()
+ .w_full()
+ .pl(rems_from_px(20.0))
+ .bg(cx.theme().colors().panel_background.opacity(0.2))
+ .child(
+ div()
+ .absolute()
+ .left(rems_from_px(18.0))
+ .top(line_top)
+ .bottom_0()
+ .w_px()
+ .bg(cx.theme().colors().border.opacity(0.6)),
+ )
+ .child(primary)
+ .into_any_element()
+ } else {
+ primary
};
let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
diff --git a/crates/context_server/src/types.rs b/crates/context_server/src/types.rs
index 03aca4f3caf7995091bbc8e049494b324674a9d3..81a427a289347ad50bf6a11674c4c5867073a274 100644
--- a/crates/context_server/src/types.rs
+++ b/crates/context_server/src/types.rs
@@ -330,7 +330,7 @@ pub struct PromptMessage {
pub content: MessageContent,
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
From 93d79f3862ea56b5c445b534285f843ce6868ea2 Mon Sep 17 00:00:00 2001
From: Mayank Verma
Date: Tue, 16 Dec 2025 23:39:09 +0530
Subject: [PATCH 022/171] git: Add support for repository excludes file
(#42082)
Closes #4824
Release Notes:
- Added support for Git repository excludes file `.git/info/exclude`
---------
Co-authored-by: Cole Miller
Co-authored-by: Cole Miller
---
crates/editor/src/editor_tests.rs | 47 +++++--
crates/editor/src/test/editor_test_context.rs | 6 +
crates/git/src/git.rs | 1 +
crates/worktree/src/ignore.rs | 37 +++++-
crates/worktree/src/worktree.rs | 121 +++++++++++++++++-
crates/worktree/src/worktree_tests.rs | 90 ++++++++++++-
6 files changed, 280 insertions(+), 22 deletions(-)
diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs
index dfc8fd7f901bf1f45352511e3b7e69f7f4d4b367..0fc91832dcaab8ed709739c74be01e51bb491e83 100644
--- a/crates/editor/src/editor_tests.rs
+++ b/crates/editor/src/editor_tests.rs
@@ -25578,6 +25578,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
ˇ log('for else')
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
ˇfor item in items:
@@ -25597,6 +25598,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
// test relative indent is preserved when tab
// for `if`, `elif`, `else`, `while`, `with` and `for`
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
ˇfor item in items:
@@ -25630,6 +25632,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
ˇ return 0
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
ˇtry:
@@ -25646,6 +25649,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
// test relative indent is preserved when tab
// for `try`, `except`, `else`, `finally`, `match` and `def`
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
ˇtry:
@@ -25679,6 +25683,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else:", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
if i == 2:
@@ -25696,6 +25701,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("except:", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25715,6 +25721,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else:", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25738,6 +25745,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("finally:", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25762,6 +25770,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else:", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25787,6 +25796,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("finally:", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25812,6 +25822,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("except:", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25835,6 +25846,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("except:", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
try:
@@ -25856,6 +25868,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else:", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def main():
for i in range(10):
@@ -25872,6 +25885,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("a", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
def f() -> list[str]:
aˇ
@@ -25885,6 +25899,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input(":", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
match 1:
case:ˇ
@@ -25908,6 +25923,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
# COMMENT:
ˇ
@@ -25920,7 +25936,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
- cx.run_until_parked();
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
{
ˇ
@@ -25980,6 +25996,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
ˇ}
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function main() {
ˇfor item in $items; do
@@ -25997,6 +26014,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
"});
// test relative indent is preserved when tab
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function main() {
ˇfor item in $items; do
@@ -26031,6 +26049,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo
ˇ}
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function handle() {
ˇcase \"$1\" in
@@ -26073,6 +26092,7 @@ async fn test_indent_after_input_for_bash(cx: &mut TestAppContext) {
ˇ}
"});
cx.update_editor(|e, window, cx| e.handle_input("#", window, cx));
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function main() {
#ˇ for item in $items; do
@@ -26107,6 +26127,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("else", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
@@ -26122,6 +26143,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("elif", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
@@ -26139,6 +26161,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("fi", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
@@ -26156,6 +26179,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("done", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
while read line; do
echo \"$line\"
@@ -26171,6 +26195,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("done", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
for file in *.txt; do
cat \"$file\"
@@ -26191,6 +26216,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("esac", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
@@ -26213,6 +26239,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("*)", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
@@ -26232,6 +26259,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.handle_input("fi", window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"outer if\"
@@ -26258,6 +26286,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
# COMMENT:
ˇ
@@ -26271,7 +26300,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
- cx.run_until_parked();
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
@@ -26286,7 +26315,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
- cx.run_until_parked();
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
else
@@ -26301,7 +26330,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
- cx.run_until_parked();
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
elif
@@ -26315,7 +26344,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
- cx.run_until_parked();
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
for file in *.txt; do
ˇ
@@ -26329,7 +26358,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
- cx.run_until_parked();
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
@@ -26346,7 +26375,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
- cx.run_until_parked();
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
@@ -26362,7 +26391,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
- cx.run_until_parked();
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
function test() {
ˇ
@@ -26376,7 +26405,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
- cx.run_until_parked();
+ cx.wait_for_autoindent_applied().await;
cx.assert_editor_state(indoc! {"
echo \"test\";
ˇ
diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs
index 511629c59d8f61f1c53f5deaa406f113b9dfc3d9..bcfaeea3a7330539b2f2790e7dbe9a4969c76981 100644
--- a/crates/editor/src/test/editor_test_context.rs
+++ b/crates/editor/src/test/editor_test_context.rs
@@ -305,6 +305,12 @@ impl EditorTestContext {
snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
}
+ pub async fn wait_for_autoindent_applied(&mut self) {
+ if let Some(fut) = self.update_buffer(|buffer, _| buffer.wait_for_autoindent_applied()) {
+ fut.await.ok();
+ }
+ }
+
pub fn set_head_text(&mut self, diff_base: &str) {
self.cx.run_until_parked();
let fs =
diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs
index 8b8f88ef65b86ea9157e1c3217fa01bb0d6355cb..805d8d181ab7a434b565d38bdb2f802a8a3cda1a 100644
--- a/crates/git/src/git.rs
+++ b/crates/git/src/git.rs
@@ -23,6 +23,7 @@ pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
pub const LFS_DIR: &str = "lfs";
pub const COMMIT_MESSAGE: &str = "COMMIT_EDITMSG";
pub const INDEX_LOCK: &str = "index.lock";
+pub const REPO_EXCLUDE: &str = "info/exclude";
actions!(
git,
diff --git a/crates/worktree/src/ignore.rs b/crates/worktree/src/ignore.rs
index 17c362e2d7f78384fe3b9b444353d302c4dac4c5..87487c36df6dc4eca3da43eaab95f83847ba5d1f 100644
--- a/crates/worktree/src/ignore.rs
+++ b/crates/worktree/src/ignore.rs
@@ -13,6 +13,10 @@ pub enum IgnoreStackEntry {
Global {
ignore: Arc,
},
+ RepoExclude {
+ ignore: Arc,
+ parent: Arc,
+ },
Some {
abs_base_path: Arc,
ignore: Arc,
@@ -21,6 +25,12 @@ pub enum IgnoreStackEntry {
All,
}
+#[derive(Debug)]
+pub enum IgnoreKind {
+ Gitignore(Arc),
+ RepoExclude,
+}
+
impl IgnoreStack {
pub fn none() -> Self {
Self {
@@ -43,13 +53,19 @@ impl IgnoreStack {
}
}
- pub fn append(self, abs_base_path: Arc, ignore: Arc) -> Self {
+ pub fn append(self, kind: IgnoreKind, ignore: Arc) -> Self {
let top = match self.top.as_ref() {
IgnoreStackEntry::All => self.top.clone(),
- _ => Arc::new(IgnoreStackEntry::Some {
- abs_base_path,
- ignore,
- parent: self.top.clone(),
+ _ => Arc::new(match kind {
+ IgnoreKind::Gitignore(abs_base_path) => IgnoreStackEntry::Some {
+ abs_base_path,
+ ignore,
+ parent: self.top.clone(),
+ },
+ IgnoreKind::RepoExclude => IgnoreStackEntry::RepoExclude {
+ ignore,
+ parent: self.top.clone(),
+ },
}),
};
Self {
@@ -84,6 +100,17 @@ impl IgnoreStack {
ignore::Match::Whitelist(_) => false,
}
}
+ IgnoreStackEntry::RepoExclude { ignore, parent } => {
+ match ignore.matched(abs_path, is_dir) {
+ ignore::Match::None => IgnoreStack {
+ repo_root: self.repo_root.clone(),
+ top: parent.clone(),
+ }
+ .is_abs_path_ignored(abs_path, is_dir),
+ ignore::Match::Ignore(_) => true,
+ ignore::Match::Whitelist(_) => false,
+ }
+ }
IgnoreStackEntry::Some {
abs_base_path,
ignore,
diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs
index e1ce31c038de9136109c3c8566e5e497dfa4f239..6ec19493840da0b9de3eb55ac483488339ec5e8d 100644
--- a/crates/worktree/src/worktree.rs
+++ b/crates/worktree/src/worktree.rs
@@ -19,7 +19,8 @@ use futures::{
};
use fuzzy::CharBag;
use git::{
- COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, status::GitSummary,
+ COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, REPO_EXCLUDE,
+ status::GitSummary,
};
use gpui::{
App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority,
@@ -71,6 +72,8 @@ use util::{
};
pub use worktree_settings::WorktreeSettings;
+use crate::ignore::IgnoreKind;
+
pub const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
/// A set of local or remote files that are being opened as part of a project.
@@ -233,6 +236,9 @@ impl Default for WorkDirectory {
pub struct LocalSnapshot {
snapshot: Snapshot,
global_gitignore: Option>,
+ /// Exclude files for all git repositories in the worktree, indexed by their absolute path.
+ /// The boolean indicates whether the gitignore needs to be updated.
+ repo_exclude_by_work_dir_abs_path: HashMap, (Arc, bool)>,
/// All of the gitignore files in the worktree, indexed by their absolute path.
/// The boolean indicates whether the gitignore needs to be updated.
ignores_by_parent_abs_path: HashMap, (Arc, bool)>,
@@ -393,6 +399,7 @@ impl Worktree {
let mut snapshot = LocalSnapshot {
ignores_by_parent_abs_path: Default::default(),
global_gitignore: Default::default(),
+ repo_exclude_by_work_dir_abs_path: Default::default(),
git_repositories: Default::default(),
snapshot: Snapshot::new(
cx.entity_id().as_u64(),
@@ -2565,13 +2572,21 @@ impl LocalSnapshot {
} else {
IgnoreStack::none()
};
+
+ if let Some((repo_exclude, _)) = repo_root
+ .as_ref()
+ .and_then(|abs_path| self.repo_exclude_by_work_dir_abs_path.get(abs_path))
+ {
+ ignore_stack = ignore_stack.append(IgnoreKind::RepoExclude, repo_exclude.clone());
+ }
ignore_stack.repo_root = repo_root;
for (parent_abs_path, ignore) in new_ignores.into_iter().rev() {
if ignore_stack.is_abs_path_ignored(parent_abs_path, true) {
ignore_stack = IgnoreStack::all();
break;
} else if let Some(ignore) = ignore {
- ignore_stack = ignore_stack.append(parent_abs_path.into(), ignore);
+ ignore_stack =
+ ignore_stack.append(IgnoreKind::Gitignore(parent_abs_path.into()), ignore);
}
}
@@ -3646,13 +3661,23 @@ impl BackgroundScanner {
let root_abs_path = self.state.lock().await.snapshot.abs_path.clone();
let repo = if self.scanning_enabled {
- let (ignores, repo) = discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await;
+ let (ignores, exclude, repo) =
+ discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await;
self.state
.lock()
.await
.snapshot
.ignores_by_parent_abs_path
.extend(ignores);
+ if let Some(exclude) = exclude {
+ self.state
+ .lock()
+ .await
+ .snapshot
+ .repo_exclude_by_work_dir_abs_path
+ .insert(root_abs_path.as_path().into(), (exclude, false));
+ }
+
repo
} else {
None
@@ -3914,6 +3939,7 @@ impl BackgroundScanner {
let mut relative_paths = Vec::with_capacity(abs_paths.len());
let mut dot_git_abs_paths = Vec::new();
+ let mut work_dirs_needing_exclude_update = Vec::new();
abs_paths.sort_unstable();
abs_paths.dedup_by(|a, b| a.starts_with(b));
{
@@ -3987,6 +4013,18 @@ impl BackgroundScanner {
continue;
};
+ let absolute_path = abs_path.to_path_buf();
+ if absolute_path.ends_with(Path::new(DOT_GIT).join(REPO_EXCLUDE)) {
+ if let Some(repository) = snapshot
+ .git_repositories
+ .values()
+ .find(|repo| repo.common_dir_abs_path.join(REPO_EXCLUDE) == absolute_path)
+ {
+ work_dirs_needing_exclude_update
+ .push(repository.work_directory_abs_path.clone());
+ }
+ }
+
if abs_path.file_name() == Some(OsStr::new(GITIGNORE)) {
for (_, repo) in snapshot
.git_repositories
@@ -4032,6 +4070,19 @@ impl BackgroundScanner {
return;
}
+ if !work_dirs_needing_exclude_update.is_empty() {
+ let mut state = self.state.lock().await;
+ for work_dir_abs_path in work_dirs_needing_exclude_update {
+ if let Some((_, needs_update)) = state
+ .snapshot
+ .repo_exclude_by_work_dir_abs_path
+ .get_mut(&work_dir_abs_path)
+ {
+ *needs_update = true;
+ }
+ }
+ }
+
self.state.lock().await.snapshot.scan_id += 1;
let (scan_job_tx, scan_job_rx) = channel::unbounded();
@@ -4299,7 +4350,8 @@ impl BackgroundScanner {
match build_gitignore(&child_abs_path, self.fs.as_ref()).await {
Ok(ignore) => {
let ignore = Arc::new(ignore);
- ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone());
+ ignore_stack = ignore_stack
+ .append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone());
new_ignore = Some(ignore);
}
Err(error) => {
@@ -4561,11 +4613,24 @@ impl BackgroundScanner {
.await;
if path.is_empty()
- && let Some((ignores, repo)) = new_ancestor_repo.take()
+ && let Some((ignores, exclude, repo)) = new_ancestor_repo.take()
{
log::trace!("updating ancestor git repository");
state.snapshot.ignores_by_parent_abs_path.extend(ignores);
if let Some((ancestor_dot_git, work_directory)) = repo {
+ if let Some(exclude) = exclude {
+ let work_directory_abs_path = self
+ .state
+ .lock()
+ .await
+ .snapshot
+ .work_directory_abs_path(&work_directory);
+
+ state
+ .snapshot
+ .repo_exclude_by_work_dir_abs_path
+ .insert(work_directory_abs_path.into(), (exclude, false));
+ }
state
.insert_git_repository_for_path(
work_directory,
@@ -4663,6 +4728,36 @@ impl BackgroundScanner {
{
let snapshot = &mut self.state.lock().await.snapshot;
let abs_path = snapshot.abs_path.clone();
+
+ snapshot.repo_exclude_by_work_dir_abs_path.retain(
+ |work_dir_abs_path, (exclude, needs_update)| {
+ if *needs_update {
+ *needs_update = false;
+ ignores_to_update.push(work_dir_abs_path.clone());
+
+ if let Some((_, repository)) = snapshot
+ .git_repositories
+ .iter()
+ .find(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path)
+ {
+ let exclude_abs_path =
+ repository.common_dir_abs_path.join(REPO_EXCLUDE);
+ if let Ok(current_exclude) = self
+ .executor
+ .block(build_gitignore(&exclude_abs_path, self.fs.as_ref()))
+ {
+ *exclude = Arc::new(current_exclude);
+ }
+ }
+ }
+
+ snapshot
+ .git_repositories
+ .iter()
+ .any(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path)
+ },
+ );
+
snapshot
.ignores_by_parent_abs_path
.retain(|parent_abs_path, (_, needs_update)| {
@@ -4717,7 +4812,8 @@ impl BackgroundScanner {
let mut ignore_stack = job.ignore_stack;
if let Some((ignore, _)) = snapshot.ignores_by_parent_abs_path.get(&job.abs_path) {
- ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone());
+ ignore_stack =
+ ignore_stack.append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone());
}
let mut entries_by_id_edits = Vec::new();
@@ -4892,6 +4988,9 @@ impl BackgroundScanner {
let preserve = ids_to_preserve.contains(work_directory_id);
if !preserve {
affected_repo_roots.push(entry.dot_git_abs_path.parent().unwrap().into());
+ snapshot
+ .repo_exclude_by_work_dir_abs_path
+ .remove(&entry.work_directory_abs_path);
}
preserve
});
@@ -4931,8 +5030,10 @@ async fn discover_ancestor_git_repo(
root_abs_path: &SanitizedPath,
) -> (
HashMap, (Arc, bool)>,
+ Option>,
Option<(PathBuf, WorkDirectory)>,
) {
+ let mut exclude = None;
let mut ignores = HashMap::default();
for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() {
if index != 0 {
@@ -4968,6 +5069,7 @@ async fn discover_ancestor_git_repo(
// also mark where in the git repo the root folder is located.
return (
ignores,
+ exclude,
Some((
ancestor_dot_git,
WorkDirectory::AboveProject {
@@ -4979,12 +5081,17 @@ async fn discover_ancestor_git_repo(
};
}
+ let repo_exclude_abs_path = ancestor_dot_git.join(REPO_EXCLUDE);
+ if let Ok(repo_exclude) = build_gitignore(&repo_exclude_abs_path, fs.as_ref()).await {
+ exclude = Some(Arc::new(repo_exclude));
+ }
+
// Reached root of git repository.
break;
}
}
- (ignores, None)
+ (ignores, exclude, None)
}
fn build_diff(
diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs
index e58e99ea68ebde51a6c12abfd859296b3cd883c4..12f2863aab6c4b4376157f3499fa332051a4822f 100644
--- a/crates/worktree/src/worktree_tests.rs
+++ b/crates/worktree/src/worktree_tests.rs
@@ -1,7 +1,7 @@
use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
use anyhow::Result;
use fs::{FakeFs, Fs, RealFs, RemoveOptions};
-use git::GITIGNORE;
+use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
use parking_lot::Mutex;
use postage::stream::Stream;
@@ -2412,6 +2412,94 @@ async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppCon
});
}
+#[gpui::test]
+async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(executor);
+ let project_dir = Path::new(path!("/project"));
+ fs.insert_tree(
+ project_dir,
+ json!({
+ ".git": {
+ "info": {
+ "exclude": ".env.*"
+ }
+ },
+ ".env.example": "secret=xxxx",
+ ".env.local": "secret=1234",
+ ".gitignore": "!.env.example",
+ "README.md": "# Repo Exclude",
+ "src": {
+ "main.rs": "fn main() {}",
+ },
+ }),
+ )
+ .await;
+
+ let worktree = Worktree::local(
+ project_dir,
+ true,
+ fs.clone(),
+ Default::default(),
+ true,
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ worktree
+ .update(cx, |worktree, _| {
+ worktree.as_local().unwrap().scan_complete()
+ })
+ .await;
+ cx.run_until_parked();
+
+ // .gitignore overrides .git/info/exclude
+ worktree.update(cx, |worktree, _cx| {
+ let expected_excluded_paths = [];
+ let expected_ignored_paths = [".env.local"];
+ let expected_tracked_paths = [".env.example", "README.md", "src/main.rs"];
+ let expected_included_paths = [];
+
+ check_worktree_entries(
+ worktree,
+ &expected_excluded_paths,
+ &expected_ignored_paths,
+ &expected_tracked_paths,
+ &expected_included_paths,
+ );
+ });
+
+ // Ignore statuses are updated when .git/info/exclude file changes
+ fs.write(
+ &project_dir.join(DOT_GIT).join(REPO_EXCLUDE),
+ ".env.example".as_bytes(),
+ )
+ .await
+ .unwrap();
+ worktree
+ .update(cx, |worktree, _| {
+ worktree.as_local().unwrap().scan_complete()
+ })
+ .await;
+ cx.run_until_parked();
+
+ worktree.update(cx, |worktree, _cx| {
+ let expected_excluded_paths = [];
+ let expected_ignored_paths = [];
+ let expected_tracked_paths = [".env.example", ".env.local", "README.md", "src/main.rs"];
+ let expected_included_paths = [];
+
+ check_worktree_entries(
+ worktree,
+ &expected_excluded_paths,
+ &expected_ignored_paths,
+ &expected_tracked_paths,
+ &expected_included_paths,
+ );
+ });
+}
+
#[track_caller]
fn check_worktree_entries(
tree: &Worktree,
From f21cec7cb1d961a0956b97046001117da3bc17f2 Mon Sep 17 00:00:00 2001
From: Kirill Bulatov
Date: Tue, 16 Dec 2025 20:34:00 +0200
Subject: [PATCH 023/171] Introduce worktree trust mechanism (#44887)
Closes https://github.com/zed-industries/zed/issues/12589
Forces Zed to require user permissions before running any basic
potentially dangerous actions: parsing and synchronizing
`.zed/settings.json`, downloading and spawning any language and MCP
servers (includes `prettier` and `copilot` instances) and all
`NodeRuntime` interactions.
There are more we can add later, among the ideas: DAP downloads on
debugger start, Python virtual environment, etc.
By default, Zed starts in restricted mode and shows a `! Restricted
Mode` in the title bar, no aforementioned actions are executed.
Clicking it or calling `workspace::ToggleWorktreeSecurity` command will
bring a modal to trust worktrees or dismiss the modal:
Agent Panel shows a message too:
This works on local, SSH and WSL remote projects, trusted worktrees are
persisted between Zed restarts.
There's a way to clear all persisted trust with
`workspace::ClearTrustedWorktrees`, this will restart Zed.
This mechanism can be turned off with settings:
```jsonc
"session": {
"trust_all_worktrees": true
}
```
in this mode, all worktrees will be trusted by default, allowing all
actions, but no auto trust will be persisted: hence, when the setting is
changed back, auto trusted worktrees will require another trust
confirmation.
This settings switch was added to the onboarding view also.
Release Notes:
- Introduced worktree trust mechanism, can be turned off with
`"session": { "trust_all_worktrees": true }`
---------
Co-authored-by: Matt Miller
Co-authored-by: Danilo Leal
Co-authored-by: John D. Swanson
---
Cargo.lock | 1 +
assets/keymaps/default-linux.json | 1 +
assets/keymaps/default-macos.json | 1 +
assets/keymaps/default-windows.json | 1 +
assets/settings/default.json | 6 +
crates/agent_ui/src/agent_panel.rs | 245 ++-
crates/agent_ui/src/agent_ui.rs | 10 +
.../remote_editing_collaboration_tests.rs | 286 ++-
crates/collab/src/tests/test_server.rs | 3 +
crates/edit_prediction_cli/src/headless.rs | 5 +-
.../edit_prediction_cli/src/load_project.rs | 1 +
crates/editor/src/editor_tests.rs | 169 +-
crates/eval/src/eval.rs | 2 +-
crates/eval/src/instance.rs | 1 +
crates/git_ui/src/worktree_picker.rs | 1 +
crates/inspector_ui/src/inspector.rs | 1 +
crates/node_runtime/src/node_runtime.rs | 9 +
crates/onboarding/src/basics_page.rs | 48 +-
crates/project/Cargo.toml | 2 +
crates/project/src/context_server_store.rs | 19 +
crates/project/src/lsp_store.rs | 85 +-
crates/project/src/persistence.rs | 411 ++++
crates/project/src/project.rs | 127 +-
crates/project/src/project_settings.rs | 168 +-
crates/project/src/trusted_worktrees.rs | 1933 +++++++++++++++++
crates/project_benchmarks/src/main.rs | 3 +-
crates/proto/proto/worktree.proto | 19 +
crates/proto/proto/zed.proto | 5 +-
crates/proto/src/proto.rs | 10 +-
.../recent_projects/src/remote_connections.rs | 21 +-
crates/recent_projects/src/remote_servers.rs | 1 +
crates/remote_server/Cargo.toml | 2 +-
crates/remote_server/src/headless_project.rs | 62 +
.../remote_server/src/remote_editing_tests.rs | 3 +-
crates/remote_server/src/unix.rs | 7 +
.../settings/src/settings_content/project.rs | 6 +
crates/settings_ui/src/page_data.rs | 22 +
crates/title_bar/src/title_bar.rs | 62 +-
.../components/notification/alert_modal.rs | 231 +-
crates/workspace/src/modal_layer.rs | 17 +-
crates/workspace/src/security_modal.rs | 373 ++++
crates/workspace/src/workspace.rs | 83 +-
crates/zed/src/main.rs | 14 +-
docs/src/SUMMARY.md | 5 +-
docs/src/ai/privacy-and-security.md | 4 +-
docs/src/configuring-zed.md | 41 +
docs/src/worktree-trust.md | 66 +
47 files changed, 4415 insertions(+), 178 deletions(-)
create mode 100644 crates/project/src/persistence.rs
create mode 100644 crates/project/src/trusted_worktrees.rs
create mode 100644 crates/workspace/src/security_modal.rs
create mode 100644 docs/src/worktree-trust.md
diff --git a/Cargo.lock b/Cargo.lock
index f8ff534719080a144ee0541d7b63f8b631017452..b89c0803c7ed9a1b90fc3e8fc55eab10cee7b905 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12421,6 +12421,7 @@ dependencies = [
"context_server",
"dap",
"dap_adapters",
+ "db",
"extension",
"fancy-regex",
"fs",
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 185a2249a7a7f3cd33213a736d38df2f8565b885..5a37614180d46b4a79b97f9a23665cbf5372cc0a 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -45,6 +45,7 @@
"ctrl-alt-z": "edit_prediction::RatePredictions",
"ctrl-alt-shift-i": "edit_prediction::ToggleMenu",
"ctrl-alt-l": "lsp_tool::ToggleMenu",
+ "ctrl-alt-shift-s": "workspace::ToggleWorktreeSecurity",
},
},
{
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index c711615041931a064680c5afce32c4ec06c749b3..8c8094495e16a9f26adaa380f584abe5e3bc2947 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -51,6 +51,7 @@
"ctrl-cmd-i": "edit_prediction::ToggleMenu",
"ctrl-cmd-l": "lsp_tool::ToggleMenu",
"ctrl-cmd-c": "editor::DisplayCursorNames",
+ "ctrl-cmd-s": "workspace::ToggleWorktreeSecurity",
},
},
{
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 1498f1deb98b6258bd92ac2dd0dbf1199c7db64f..74320ae637080da92108f195eabca537e3a71406 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -43,6 +43,7 @@
"ctrl-shift-i": "edit_prediction::ToggleMenu",
"shift-alt-l": "lsp_tool::ToggleMenu",
"ctrl-shift-alt-c": "editor::DisplayCursorNames",
+ "ctrl-shift-alt-s": "workspace::ToggleWorktreeSecurity",
},
},
{
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 0ef3bb70c71bb96828bc1b1c2594376b15bada90..a0e499934428b4bafcbe12b97b2e8fc4747a5f31 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -2062,6 +2062,12 @@
//
// Default: true
"restore_unsaved_buffers": true,
+ // Whether or not to skip worktree trust checks.
+ // When trusted, project settings are synchronized automatically,
+ // language and MCP servers are downloaded and started automatically.
+ //
+ // Default: false
+ "trust_all_worktrees": false,
},
// Zed's Prettier integration settings.
// Allows to enable/disable formatting with Prettier
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 97c7aecb8e34563db0adfa6bdbeda31140fd6cdd..ff8cf8db969e9ef2d1d86b306c0f38fb66a67fde 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -2,10 +2,12 @@ use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
use acp_thread::AcpThread;
use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore};
+use agent_servers::AgentServer;
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use project::{
ExternalAgentServerName,
agent_server_store::{CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME},
+ trusted_worktrees::{RemoteHostLocation, TrustedWorktrees, wait_for_workspace_trust},
};
use serde::{Deserialize, Serialize};
use settings::{
@@ -262,6 +264,17 @@ impl AgentType {
Self::Custom { .. } => Some(IconName::Sparkle),
}
}
+
+ fn is_mcp(&self) -> bool {
+ match self {
+ Self::NativeAgent => false,
+ Self::TextThread => false,
+ Self::Custom { .. } => false,
+ Self::Gemini => true,
+ Self::ClaudeCode => true,
+ Self::Codex => true,
+ }
+ }
}
impl From for AgentType {
@@ -287,7 +300,7 @@ impl ActiveView {
}
}
- pub fn native_agent(
+ fn native_agent(
fs: Arc,
prompt_store: Option>,
history_store: Entity,
@@ -442,6 +455,9 @@ pub struct AgentPanel {
pending_serialization: Option>>,
onboarding: Entity,
selected_agent: AgentType,
+ new_agent_thread_task: Task<()>,
+ show_trust_workspace_message: bool,
+ _worktree_trust_subscription: Option,
}
impl AgentPanel {
@@ -665,6 +681,48 @@ impl AgentPanel {
None
};
+ let mut show_trust_workspace_message = false;
+ let worktree_trust_subscription =
+ TrustedWorktrees::try_get_global(cx).and_then(|trusted_worktrees| {
+ let has_global_trust = trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+ trusted_worktrees.can_trust_workspace(
+ project
+ .read(cx)
+ .remote_connection_options(cx)
+ .map(RemoteHostLocation::from),
+ cx,
+ )
+ });
+ if has_global_trust {
+ None
+ } else {
+ show_trust_workspace_message = true;
+ let project = project.clone();
+ Some(cx.subscribe(
+ &trusted_worktrees,
+ move |agent_panel, trusted_worktrees, _, cx| {
+ let new_show_trust_workspace_message =
+ !trusted_worktrees.update(cx, |trusted_worktrees, cx| {
+ trusted_worktrees.can_trust_workspace(
+ project
+ .read(cx)
+ .remote_connection_options(cx)
+ .map(RemoteHostLocation::from),
+ cx,
+ )
+ });
+ if new_show_trust_workspace_message
+ != agent_panel.show_trust_workspace_message
+ {
+ agent_panel.show_trust_workspace_message =
+ new_show_trust_workspace_message;
+ cx.notify();
+ };
+ },
+ ))
+ }
+ });
+
let mut panel = Self {
active_view,
workspace,
@@ -687,11 +745,14 @@ impl AgentPanel {
height: None,
zoomed: false,
pending_serialization: None,
+ new_agent_thread_task: Task::ready(()),
onboarding,
acp_history,
history_store,
selected_agent: AgentType::default(),
loading: false,
+ show_trust_workspace_message,
+ _worktree_trust_subscription: worktree_trust_subscription,
};
// Initial sync of agent servers from extensions
@@ -884,37 +945,63 @@ impl AgentPanel {
}
};
- let server = ext_agent.server(fs, history);
-
- this.update_in(cx, |this, window, cx| {
- let selected_agent = ext_agent.into();
- if this.selected_agent != selected_agent {
- this.selected_agent = selected_agent;
- this.serialize(cx);
+ if ext_agent.is_mcp() {
+ let wait_task = this.update(cx, |agent_panel, cx| {
+ agent_panel.project.update(cx, |project, cx| {
+ wait_for_workspace_trust(
+ project.remote_connection_options(cx),
+ "context servers",
+ cx,
+ )
+ })
+ })?;
+ if let Some(wait_task) = wait_task {
+ this.update_in(cx, |agent_panel, window, cx| {
+ agent_panel.show_trust_workspace_message = true;
+ cx.notify();
+ agent_panel.new_agent_thread_task =
+ cx.spawn_in(window, async move |agent_panel, cx| {
+ wait_task.await;
+ let server = ext_agent.server(fs, history);
+ agent_panel
+ .update_in(cx, |agent_panel, window, cx| {
+ agent_panel.show_trust_workspace_message = false;
+ cx.notify();
+ agent_panel._external_thread(
+ server,
+ resume_thread,
+ summarize_thread,
+ workspace,
+ project,
+ loading,
+ ext_agent,
+ window,
+ cx,
+ );
+ })
+ .ok();
+ });
+ })?;
+ return Ok(());
}
+ }
- let thread_view = cx.new(|cx| {
- crate::acp::AcpThreadView::new(
- server,
- resume_thread,
- summarize_thread,
- workspace.clone(),
- project,
- this.history_store.clone(),
- this.prompt_store.clone(),
- !loading,
- window,
- cx,
- )
- });
-
- this.set_active_view(
- ActiveView::ExternalAgentThread { thread_view },
- !loading,
+ let server = ext_agent.server(fs, history);
+ this.update_in(cx, |agent_panel, window, cx| {
+ agent_panel._external_thread(
+ server,
+ resume_thread,
+ summarize_thread,
+ workspace,
+ project,
+ loading,
+ ext_agent,
window,
cx,
);
- })
+ })?;
+
+ anyhow::Ok(())
})
.detach_and_log_err(cx);
}
@@ -1423,6 +1510,36 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context,
) {
+ let wait_task = if agent.is_mcp() {
+ self.project.update(cx, |project, cx| {
+ wait_for_workspace_trust(
+ project.remote_connection_options(cx),
+ "context servers",
+ cx,
+ )
+ })
+ } else {
+ None
+ };
+ if let Some(wait_task) = wait_task {
+ self.show_trust_workspace_message = true;
+ cx.notify();
+ self.new_agent_thread_task = cx.spawn_in(window, async move |agent_panel, cx| {
+ wait_task.await;
+ agent_panel
+ .update_in(cx, |agent_panel, window, cx| {
+ agent_panel.show_trust_workspace_message = false;
+ cx.notify();
+ agent_panel._new_agent_thread(agent, window, cx);
+ })
+ .ok();
+ });
+ } else {
+ self._new_agent_thread(agent, window, cx);
+ }
+ }
+
+ fn _new_agent_thread(&mut self, agent: AgentType, window: &mut Window, cx: &mut Context) {
match agent {
AgentType::TextThread => {
window.dispatch_action(NewTextThread.boxed_clone(), cx);
@@ -1477,6 +1594,47 @@ impl AgentPanel {
cx,
);
}
+
+ fn _external_thread(
+ &mut self,
+ server: Rc,
+ resume_thread: Option,
+ summarize_thread: Option,
+ workspace: WeakEntity,
+ project: Entity,
+ loading: bool,
+ ext_agent: ExternalAgent,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let selected_agent = AgentType::from(ext_agent);
+ if self.selected_agent != selected_agent {
+ self.selected_agent = selected_agent;
+ self.serialize(cx);
+ }
+
+ let thread_view = cx.new(|cx| {
+ crate::acp::AcpThreadView::new(
+ server,
+ resume_thread,
+ summarize_thread,
+ workspace.clone(),
+ project,
+ self.history_store.clone(),
+ self.prompt_store.clone(),
+ !loading,
+ window,
+ cx,
+ )
+ });
+
+ self.set_active_view(
+ ActiveView::ExternalAgentThread { thread_view },
+ !loading,
+ window,
+ cx,
+ );
+ }
}
impl Focusable for AgentPanel {
@@ -2557,6 +2715,38 @@ impl AgentPanel {
}
}
+ fn render_workspace_trust_message(&self, cx: &Context) -> Option {
+ if !self.show_trust_workspace_message {
+ return None;
+ }
+
+ let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe.";
+
+ Some(
+ Callout::new()
+ .icon(IconName::Warning)
+ .severity(Severity::Warning)
+ .border_position(ui::BorderPosition::Bottom)
+ .title("You're in Restricted Mode")
+ .description(description)
+ .actions_slot(
+ Button::new("open-trust-modal", "Configure Project Trust")
+ .label_size(LabelSize::Small)
+ .style(ButtonStyle::Outlined)
+ .on_click({
+ cx.listener(move |this, _, window, cx| {
+ this.workspace
+ .update(cx, |workspace, cx| {
+ workspace
+ .show_worktree_trust_security_modal(true, window, cx)
+ })
+ .log_err();
+ })
+ }),
+ ),
+ )
+ }
+
fn key_context(&self) -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("AgentPanel");
@@ -2609,6 +2799,7 @@ impl Render for AgentPanel {
}
}))
.child(self.render_toolbar(window, cx))
+ .children(self.render_workspace_trust_message(cx))
.children(self.render_onboarding(window, cx))
.map(|parent| match &self.active_view {
ActiveView::ExternalAgentThread { thread_view, .. } => parent
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 3a0cc74bef611175b82884bd87e521c5a968d54a..4f759d6a9c7687d2cdf29752c489db2fcb1ffe68 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -171,6 +171,16 @@ impl ExternalAgent {
Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())),
}
}
+
+ pub fn is_mcp(&self) -> bool {
+ match self {
+ Self::Gemini => true,
+ Self::ClaudeCode => true,
+ Self::Codex => true,
+ Self::NativeAgent => false,
+ Self::Custom { .. } => false,
+ }
+ }
}
/// Opens the profile management interface for configuring agent tools and settings.
diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs
index 04403de9fa0883e9d738f3d96b9b2acdf1d66967..a66d7a1856c195a41a495123b468dc2b6ac8a1ca 100644
--- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs
+++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs
@@ -4,6 +4,7 @@ use collections::{HashMap, HashSet};
use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling};
use debugger_ui::debugger_panel::DebugPanel;
+use editor::{Editor, EditorMode, MultiBuffer};
use extension::ExtensionHostProxy;
use fs::{FakeFs, Fs as _, RemoveOptions};
use futures::StreamExt as _;
@@ -12,22 +13,30 @@ use http_client::BlockedHttpClient;
use language::{
FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry,
language_settings::{Formatter, FormatterList, language_settings},
- tree_sitter_typescript,
+ rust_lang, tree_sitter_typescript,
};
use node_runtime::NodeRuntime;
use project::{
ProjectPath,
debugger::session::ThreadId,
lsp_store::{FormatTrigger, LspFormatTarget},
+ trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use remote::RemoteClient;
use remote_server::{HeadlessAppState, HeadlessProject};
use rpc::proto;
use serde_json::json;
-use settings::{LanguageServerFormatterSpecifier, PrettierSettingsContent, SettingsStore};
+use settings::{
+ InlayHintSettingsContent, LanguageServerFormatterSpecifier, PrettierSettingsContent,
+ SettingsStore,
+};
use std::{
path::Path,
- sync::{Arc, atomic::AtomicUsize},
+ sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+ },
+ time::Duration,
};
use task::TcpArgumentsTemplate;
use util::{path, rel_path::rel_path};
@@ -90,13 +99,14 @@ async fn test_sharing_an_ssh_remote_project(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id) = client_a
- .build_ssh_project(path!("/code/project1"), client_ssh, cx_a)
+ .build_ssh_project(path!("/code/project1"), client_ssh, false, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
@@ -250,13 +260,14 @@ async fn test_ssh_collaboration_git_branches(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, _) = client_a
- .build_ssh_project("/project", client_ssh, cx_a)
+ .build_ssh_project("/project", client_ssh, false, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
@@ -454,13 +465,14 @@ async fn test_ssh_collaboration_formatting_with_prettier(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id) = client_a
- .build_ssh_project(path!("/project"), client_ssh, cx_a)
+ .build_ssh_project(path!("/project"), client_ssh, false, cx_a)
.await;
// While the SSH worktree is being scanned, user A shares the remote project.
@@ -615,6 +627,7 @@ async fn test_remote_server_debugger(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
@@ -627,7 +640,7 @@ async fn test_remote_server_debugger(
command_palette_hooks::init(cx);
});
let (project_a, _) = client_a
- .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
+ .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
.await;
let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
@@ -723,6 +736,7 @@ async fn test_slow_adapter_startup_retries(
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
},
+ false,
cx,
)
});
@@ -735,7 +749,7 @@ async fn test_slow_adapter_startup_retries(
command_palette_hooks::init(cx);
});
let (project_a, _) = client_a
- .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a)
+ .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a)
.await;
let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a);
@@ -838,3 +852,259 @@ async fn test_slow_adapter_startup_retries(
shutdown_session.await.unwrap();
}
+
+#[gpui::test]
+async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &mut TestAppContext) {
+ use project::trusted_worktrees::RemoteHostLocation;
+
+ cx_a.update(|cx| {
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
+ });
+ server_cx.update(|cx| {
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
+ });
+
+ let mut server = TestServer::start(cx_a.executor().clone()).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+
+ let server_name = "override-rust-analyzer";
+ let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0));
+
+ let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
+ let remote_fs = FakeFs::new(server_cx.executor());
+ remote_fs
+ .insert_tree(
+ path!("/projects"),
+ json!({
+ "project_a": {
+ ".zed": {
+ "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
+ },
+ "main.rs": "fn main() {}"
+ },
+ "project_b": { "lib.rs": "pub fn lib() {}" }
+ }),
+ )
+ .await;
+
+ server_cx.update(HeadlessProject::init);
+ let remote_http_client = Arc::new(BlockedHttpClient);
+ let node = NodeRuntime::unavailable();
+ let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
+ languages.add(rust_lang());
+
+ let capabilities = lsp::ServerCapabilities {
+ inlay_hint_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ };
+ let mut fake_language_servers = languages.register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ name: server_name,
+ capabilities: capabilities.clone(),
+ initializer: Some(Box::new({
+ let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+ move |fake_server| {
+ let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+ fake_server.set_request_handler::(
+ move |_params, _| {
+ lsp_inlay_hint_request_count.fetch_add(1, Ordering::Release);
+ async move {
+ Ok(Some(vec![lsp::InlayHint {
+ position: lsp::Position::new(0, 0),
+ label: lsp::InlayHintLabel::String("hint".to_string()),
+ kind: None,
+ text_edits: None,
+ tooltip: None,
+ padding_left: None,
+ padding_right: None,
+ data: None,
+ }]))
+ }
+ },
+ );
+ }
+ })),
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ let _headless_project = server_cx.new(|cx| {
+ HeadlessProject::new(
+ HeadlessAppState {
+ session: server_ssh,
+ fs: remote_fs.clone(),
+ http_client: remote_http_client,
+ node_runtime: node,
+ languages,
+ extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
+ },
+ true,
+ cx,
+ )
+ });
+
+ let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
+ let (project_a, worktree_id_a) = client_a
+ .build_ssh_project(path!("/projects/project_a"), client_ssh.clone(), true, cx_a)
+ .await;
+
+ cx_a.update(|cx| {
+ release_channel::init(semver::Version::new(0, 0, 0), cx);
+
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ let language_settings = &mut settings.project.all_languages.defaults;
+ language_settings.inlay_hints = Some(InlayHintSettingsContent {
+ enabled: Some(true),
+ ..InlayHintSettingsContent::default()
+ })
+ });
+ });
+ });
+
+ project_a
+ .update(cx_a, |project, cx| {
+ project.languages().add(rust_lang());
+ project.languages().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ name: server_name,
+ capabilities,
+ ..FakeLspAdapter::default()
+ },
+ );
+ project.find_or_create_worktree(path!("/projects/project_b"), true, cx)
+ })
+ .await
+ .unwrap();
+
+ cx_a.run_until_parked();
+
+ let worktree_ids = project_a.read_with(cx_a, |project, cx| {
+ project
+ .worktrees(cx)
+ .map(|wt| wt.read(cx).id())
+ .collect::>()
+ });
+ assert_eq!(worktree_ids.len(), 2);
+
+ let remote_host = project_a.read_with(cx_a, |project, cx| {
+ project
+ .remote_connection_options(cx)
+ .map(RemoteHostLocation::from)
+ });
+
+ let trusted_worktrees =
+ cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
+
+ let can_trust_a =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_b =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
+ assert!(!can_trust_a, "project_a should be restricted initially");
+ assert!(!can_trust_b, "project_b should be restricted initially");
+
+ let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store());
+ let has_restricted = trusted_worktrees.read_with(cx_a, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(has_restricted, "should have restricted worktrees");
+
+ let buffer_before_approval = project_a
+ .update(cx_a, |project, cx| {
+ project.open_buffer((worktree_id_a, rel_path("main.rs")), cx)
+ })
+ .await
+ .unwrap();
+
+ let (editor, cx_a) = cx_a.add_window_view(|window, cx| {
+ Editor::new(
+ EditorMode::full(),
+ cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)),
+ Some(project_a.clone()),
+ window,
+ cx,
+ )
+ });
+ cx_a.run_until_parked();
+ let fake_language_server = fake_language_servers.next();
+
+ cx_a.read(|cx| {
+ let file = buffer_before_approval.read(cx).file();
+ assert_eq!(
+ language_settings(Some("Rust".into()), file, cx).language_servers,
+ ["...".to_string()],
+ "remote .zed/settings.json must not sync before trust approval"
+ )
+ });
+
+ editor.update_in(cx_a, |editor, window, cx| {
+ editor.handle_input("1", window, cx);
+ });
+ cx_a.run_until_parked();
+ cx_a.executor().advance_clock(Duration::from_secs(1));
+ assert_eq!(
+ lsp_inlay_hint_request_count.load(Ordering::Acquire),
+ 0,
+ "inlay hints must not be queried before trust approval"
+ );
+
+ trusted_worktrees.update(cx_a, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]),
+ remote_host.clone(),
+ cx,
+ );
+ });
+ cx_a.run_until_parked();
+
+ cx_a.read(|cx| {
+ let file = buffer_before_approval.read(cx).file();
+ assert_eq!(
+ language_settings(Some("Rust".into()), file, cx).language_servers,
+ ["override-rust-analyzer".to_string()],
+ "remote .zed/settings.json should sync after trust approval"
+ )
+ });
+ let _fake_language_server = fake_language_server.await.unwrap();
+ editor.update_in(cx_a, |editor, window, cx| {
+ editor.handle_input("1", window, cx);
+ });
+ cx_a.run_until_parked();
+ cx_a.executor().advance_clock(Duration::from_secs(1));
+ assert!(
+ lsp_inlay_hint_request_count.load(Ordering::Acquire) > 0,
+ "inlay hints should be queried after trust approval"
+ );
+
+ let can_trust_a =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_b =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
+ assert!(can_trust_a, "project_a should be trusted after trust()");
+ assert!(!can_trust_b, "project_b should still be restricted");
+
+ trusted_worktrees.update(cx_a, |store, cx| {
+ store.trust(
+ HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]),
+ remote_host.clone(),
+ cx,
+ );
+ });
+
+ let can_trust_a =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx));
+ let can_trust_b =
+ trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx));
+ assert!(can_trust_a, "project_a should remain trusted");
+ assert!(can_trust_b, "project_b should now be trusted");
+
+ let has_restricted_after = trusted_worktrees.read_with(cx_a, |store, cx| {
+ store.has_restricted_worktrees(&worktree_store, cx)
+ });
+ assert!(
+ !has_restricted_after,
+ "should have no restricted worktrees after trusting both"
+ );
+}
diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs
index 959d54cf0864ccddf7273cca0276d18d4f59308b..3abbd1a014b556db02e70b42c239729100f17eb8 100644
--- a/crates/collab/src/tests/test_server.rs
+++ b/crates/collab/src/tests/test_server.rs
@@ -761,6 +761,7 @@ impl TestClient {
&self,
root_path: impl AsRef,
ssh: Entity,
+ init_worktree_trust: bool,
cx: &mut TestAppContext,
) -> (Entity, WorktreeId) {
let project = cx.update(|cx| {
@@ -771,6 +772,7 @@ impl TestClient {
self.app_state.user_store.clone(),
self.app_state.languages.clone(),
self.app_state.fs.clone(),
+ init_worktree_trust,
cx,
)
});
@@ -839,6 +841,7 @@ impl TestClient {
self.app_state.languages.clone(),
self.app_state.fs.clone(),
None,
+ false,
cx,
)
})
diff --git a/crates/edit_prediction_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs
index 2deb96fdbf19a94c5649d87a7bf2f5fea0b601c2..489e78d364d0fdbb08b93eab89fd5f91f345f68e 100644
--- a/crates/edit_prediction_cli/src/headless.rs
+++ b/crates/edit_prediction_cli/src/headless.rs
@@ -8,8 +8,7 @@ use gpui_tokio::Tokio;
use language::LanguageRegistry;
use language_extension::LspAccess;
use node_runtime::{NodeBinaryOptions, NodeRuntime};
-use project::Project;
-use project::project_settings::ProjectSettings;
+use project::{Project, project_settings::ProjectSettings};
use release_channel::{AppCommitSha, AppVersion};
use reqwest_client::ReqwestClient;
use settings::{Settings, SettingsStore};
@@ -115,7 +114,7 @@ pub fn init(cx: &mut App) -> EpAppState {
tx.send(Some(options)).log_err();
})
.detach();
- let node_runtime = NodeRuntime::new(client.http_client(), None, rx);
+ let node_runtime = NodeRuntime::new(client.http_client(), None, rx, None);
let extension_host_proxy = ExtensionHostProxy::global(cx);
diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs
index 38f114d726d3626fac89982b7f3a98c55e92ac07..70daf00b79486fd917556cffaa26b1fd01ed4d28 100644
--- a/crates/edit_prediction_cli/src/load_project.rs
+++ b/crates/edit_prediction_cli/src/load_project.rs
@@ -179,6 +179,7 @@ async fn setup_project(
app_state.languages.clone(),
app_state.fs.clone(),
None,
+ false,
cx,
)
})?;
diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs
index 0fc91832dcaab8ed709739c74be01e51bb491e83..f379b2b4e014ae7f51b5d8ffd842112dba54279b 100644
--- a/crates/editor/src/editor_tests.rs
+++ b/crates/editor/src/editor_tests.rs
@@ -41,14 +41,16 @@ use multi_buffer::{
use parking_lot::Mutex;
use pretty_assertions::{assert_eq, assert_ne};
use project::{
- FakeFs,
+ FakeFs, Project,
debugger::breakpoint_store::{BreakpointState, SourceBreakpoint},
project_settings::LspSettings,
+ trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use serde_json::{self, json};
use settings::{
AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring,
- IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent,
+ IndentGuideColoring, InlayHintSettingsContent, ProjectSettingsContent, SearchSettingsContent,
+ SettingsStore,
};
use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
use std::{
@@ -29364,3 +29366,166 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
cx.assert_editor_state(after);
}
+
+#[gpui::test]
+async fn test_local_worktree_trust(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project.all_languages.defaults.inlay_hints =
+ Some(InlayHintSettingsContent {
+ enabled: Some(true),
+ ..InlayHintSettingsContent::default()
+ });
+ });
+ });
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ ".zed": {
+ "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
+ },
+ "main.rs": "fn main() {}"
+ }),
+ )
+ .await;
+
+ let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0));
+ let server_name = "override-rust-analyzer";
+ let project = Project::test_with_worktree_trust(fs, [path!("/project").as_ref()], cx).await;
+
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(rust_lang());
+
+ let capabilities = lsp::ServerCapabilities {
+ inlay_hint_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ };
+ let mut fake_language_servers = language_registry.register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ name: server_name,
+ capabilities,
+ initializer: Some(Box::new({
+ let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+ move |fake_server| {
+ let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone();
+ fake_server.set_request_handler::(
+ move |_params, _| {
+ lsp_inlay_hint_request_count.fetch_add(1, atomic::Ordering::Release);
+ async move {
+ Ok(Some(vec![lsp::InlayHint {
+ position: lsp::Position::new(0, 0),
+ label: lsp::InlayHintLabel::String("hint".to_string()),
+ kind: None,
+ text_edits: None,
+ tooltip: None,
+ padding_left: None,
+ padding_right: None,
+ data: None,
+ }]))
+ }
+ },
+ );
+ }
+ })),
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ cx.run_until_parked();
+
+ let worktree_id = project.read_with(cx, |project, cx| {
+ project
+ .worktrees(cx)
+ .next()
+ .map(|wt| wt.read(cx).id())
+ .expect("should have a worktree")
+ });
+
+ let trusted_worktrees =
+ cx.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist"));
+
+ let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(!can_trust, "worktree should be restricted initially");
+
+ let buffer_before_approval = project
+ .update(cx, |project, cx| {
+ project.open_buffer((worktree_id, rel_path("main.rs")), cx)
+ })
+ .await
+ .unwrap();
+
+ let (editor, cx) = cx.add_window_view(|window, cx| {
+ Editor::new(
+ EditorMode::full(),
+ cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)),
+ Some(project.clone()),
+ window,
+ cx,
+ )
+ });
+ cx.run_until_parked();
+ let fake_language_server = fake_language_servers.next();
+
+ cx.read(|cx| {
+ let file = buffer_before_approval.read(cx).file();
+ assert_eq!(
+ language::language_settings::language_settings(Some("Rust".into()), file, cx)
+ .language_servers,
+ ["...".to_string()],
+ "local .zed/settings.json must not apply before trust approval"
+ )
+ });
+
+ editor.update_in(cx, |editor, window, cx| {
+ editor.handle_input("1", window, cx);
+ });
+ cx.run_until_parked();
+ cx.executor()
+ .advance_clock(std::time::Duration::from_secs(1));
+ assert_eq!(
+ lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire),
+ 0,
+ "inlay hints must not be queried before trust approval"
+ );
+
+ trusted_worktrees.update(cx, |store, cx| {
+ store.trust(
+ std::collections::HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
+ None,
+ cx,
+ );
+ });
+ cx.run_until_parked();
+
+ cx.read(|cx| {
+ let file = buffer_before_approval.read(cx).file();
+ assert_eq!(
+ language::language_settings::language_settings(Some("Rust".into()), file, cx)
+ .language_servers,
+ ["override-rust-analyzer".to_string()],
+ "local .zed/settings.json should apply after trust approval"
+ )
+ });
+ let _fake_language_server = fake_language_server.await.unwrap();
+ editor.update_in(cx, |editor, window, cx| {
+ editor.handle_input("1", window, cx);
+ });
+ cx.run_until_parked();
+ cx.executor()
+ .advance_clock(std::time::Duration::from_secs(1));
+ assert!(
+ lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire) > 0,
+ "inlay hints should be queried after trust approval"
+ );
+
+ let can_trust_after =
+ trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx));
+ assert!(can_trust_after, "worktree should be trusted after trust()");
+}
diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs
index 80633696b7d5e655bb7db3627568b881642cf62c..3a2891922c80b95c85f0daed25603bea14b41842 100644
--- a/crates/eval/src/eval.rs
+++ b/crates/eval/src/eval.rs
@@ -422,7 +422,7 @@ pub fn init(cx: &mut App) -> Arc {
tx.send(Some(options)).log_err();
})
.detach();
- let node_runtime = NodeRuntime::new(client.http_client(), None, rx);
+ let node_runtime = NodeRuntime::new(client.http_client(), None, rx, None);
let extension_host_proxy = ExtensionHostProxy::global(cx);
debug_adapter_extension::init(extension_host_proxy.clone(), cx);
diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs
index 4c71a5a82b3946a9cc6e22ced378ebaabeec5256..8c9da3eefab61e4fa5897f9d76123c3fe1d5fa8b 100644
--- a/crates/eval/src/instance.rs
+++ b/crates/eval/src/instance.rs
@@ -202,6 +202,7 @@ impl ExampleInstance {
app_state.languages.clone(),
app_state.fs.clone(),
None,
+ false,
cx,
);
diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs
index f6b3e47dec386d906e55e555600a93059d0766d0..875ae55eefae19e24aa26fe75f80d70f8316c82b 100644
--- a/crates/git_ui/src/worktree_picker.rs
+++ b/crates/git_ui/src/worktree_picker.rs
@@ -421,6 +421,7 @@ async fn open_remote_worktree(
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
+ true,
cx,
)
})?;
diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs
index 7f7985df9b98ee286c79e18a665802b1f73fbc1e..a82d27b6d015bef97b50983e05f3e2096a1ef8c7 100644
--- a/crates/inspector_ui/src/inspector.rs
+++ b/crates/inspector_ui/src/inspector.rs
@@ -33,6 +33,7 @@ pub fn init(app_state: Arc, cx: &mut App) {
app_state.languages.clone(),
app_state.fs.clone(),
None,
+ false,
cx,
);
diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs
index 1eb6714500446dbfd2967ed4aa2f514a5f427aba..322117cd717cac5c604ba215a2a1c7e0f7d87f06 100644
--- a/crates/node_runtime/src/node_runtime.rs
+++ b/crates/node_runtime/src/node_runtime.rs
@@ -9,6 +9,8 @@ use serde::Deserialize;
use smol::io::BufReader;
use smol::{fs, lock::Mutex};
use std::fmt::Display;
+use std::future::Future;
+use std::pin::Pin;
use std::{
env::{self, consts},
ffi::OsString,
@@ -46,6 +48,7 @@ struct NodeRuntimeState {
last_options: Option,
options: watch::Receiver