Merge remote-tracking branch 'origin/main' into acp-onb-modal

This commit is contained in:
Danilo Leal 2025-08-26 08:43:02 -03:00
commit 51ebaa82b0
74 changed files with 2052 additions and 2158 deletions

1
Cargo.lock generated
View file

@ -13521,6 +13521,7 @@ dependencies = [
"smol", "smol",
"sysinfo", "sysinfo",
"telemetry_events", "telemetry_events",
"thiserror 2.0.12",
"toml 0.8.20", "toml 0.8.20",
"unindent", "unindent",
"util", "util",

View file

@ -428,11 +428,13 @@
"g h": "vim::StartOfLine", "g h": "vim::StartOfLine",
"g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s"
"g e": "vim::EndOfDocument", "g e": "vim::EndOfDocument",
"g .": "vim::HelixGotoLastModification", // go to last modification
"g r": "editor::FindAllReferences", // zed specific "g r": "editor::FindAllReferences", // zed specific
"g t": "vim::WindowTop", "g t": "vim::WindowTop",
"g c": "vim::WindowMiddle", "g c": "vim::WindowMiddle",
"g b": "vim::WindowBottom", "g b": "vim::WindowBottom",
"shift-r": "editor::Paste",
"x": "editor::SelectLine", "x": "editor::SelectLine",
"shift-x": "editor::SelectLine", "shift-x": "editor::SelectLine",
"%": "editor::SelectAll", "%": "editor::SelectAll",

View file

@ -162,6 +162,12 @@
// 2. Always quit the application // 2. Always quit the application
// "on_last_window_closed": "quit_app", // "on_last_window_closed": "quit_app",
"on_last_window_closed": "platform_default", "on_last_window_closed": "platform_default",
// Whether to show padding for zoomed panels.
// When enabled, zoomed center panels (e.g. code editor) will have padding all around,
// while zoomed bottom/left/right panels will have padding to the top/right/left (respectively).
//
// Default: true
"zoomed_padding": true,
// Whether to use the system provided dialogs for Open and Save As. // Whether to use the system provided dialogs for Open and Save As.
// When set to false, Zed will use the built-in keyboard-first pickers. // When set to false, Zed will use the built-in keyboard-first pickers.
"use_system_path_prompts": true, "use_system_path_prompts": true,
@ -1629,6 +1635,9 @@
"allowed": true "allowed": true
} }
}, },
"Kotlin": {
"language_servers": ["kotlin-language-server", "!kotlin-lsp", "..."]
},
"LaTeX": { "LaTeX": {
"formatter": "language_server", "formatter": "language_server",
"language_servers": ["texlab", "..."], "language_servers": ["texlab", "..."],

View file

@ -183,16 +183,15 @@ impl ToolCall {
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
cx: &mut App, cx: &mut App,
) -> Self { ) -> Self {
let title = if let Some((first_line, _)) = tool_call.title.split_once("\n") {
first_line.to_owned() + ""
} else {
tool_call.title
};
Self { Self {
id: tool_call.id, id: tool_call.id,
label: cx.new(|cx| { label: cx
Markdown::new( .new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)),
tool_call.title.into(),
Some(language_registry.clone()),
None,
cx,
)
}),
kind: tool_call.kind, kind: tool_call.kind,
content: tool_call content: tool_call
.content .content
@ -233,7 +232,11 @@ impl ToolCall {
if let Some(title) = title { if let Some(title) = title {
self.label.update(cx, |label, cx| { self.label.update(cx, |label, cx| {
label.replace(title, cx); if let Some((first_line, _)) = title.split_once("\n") {
label.replace(first_line.to_owned() + "", cx)
} else {
label.replace(title, cx);
}
}); });
} }
@ -756,6 +759,8 @@ pub struct AcpThread {
connection: Rc<dyn AgentConnection>, connection: Rc<dyn AgentConnection>,
session_id: acp::SessionId, session_id: acp::SessionId,
token_usage: Option<TokenUsage>, token_usage: Option<TokenUsage>,
prompt_capabilities: acp::PromptCapabilities,
_observe_prompt_capabilities: Task<anyhow::Result<()>>,
} }
#[derive(Debug)] #[derive(Debug)]
@ -770,11 +775,12 @@ pub enum AcpThreadEvent {
Stopped, Stopped,
Error, Error,
LoadError(LoadError), LoadError(LoadError),
PromptCapabilitiesUpdated,
} }
impl EventEmitter<AcpThreadEvent> for AcpThread {} impl EventEmitter<AcpThreadEvent> for AcpThread {}
#[derive(PartialEq, Eq)] #[derive(PartialEq, Eq, Debug)]
pub enum ThreadStatus { pub enum ThreadStatus {
Idle, Idle,
WaitingForToolConfirmation, WaitingForToolConfirmation,
@ -821,7 +827,20 @@ impl AcpThread {
project: Entity<Project>, project: Entity<Project>,
action_log: Entity<ActionLog>, action_log: Entity<ActionLog>,
session_id: acp::SessionId, session_id: acp::SessionId,
mut prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
cx: &mut Context<Self>,
) -> Self { ) -> Self {
let prompt_capabilities = *prompt_capabilities_rx.borrow();
let task = cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| {
loop {
let caps = prompt_capabilities_rx.recv().await?;
this.update(cx, |this, cx| {
this.prompt_capabilities = caps;
cx.emit(AcpThreadEvent::PromptCapabilitiesUpdated);
})?;
}
});
Self { Self {
action_log, action_log,
shared_buffers: Default::default(), shared_buffers: Default::default(),
@ -833,9 +852,15 @@ impl AcpThread {
connection, connection,
session_id, session_id,
token_usage: None, token_usage: None,
prompt_capabilities,
_observe_prompt_capabilities: task,
} }
} }
pub fn prompt_capabilities(&self) -> acp::PromptCapabilities {
self.prompt_capabilities
}
pub fn connection(&self) -> &Rc<dyn AgentConnection> { pub fn connection(&self) -> &Rc<dyn AgentConnection> {
&self.connection &self.connection
} }
@ -2599,13 +2624,19 @@ mod tests {
.into(), .into(),
); );
let action_log = cx.new(|_| ActionLog::new(project.clone())); let action_log = cx.new(|_| ActionLog::new(project.clone()));
let thread = cx.new(|_cx| { let thread = cx.new(|cx| {
AcpThread::new( AcpThread::new(
"Test", "Test",
self.clone(), self.clone(),
project, project,
action_log, action_log,
session_id.clone(), session_id.clone(),
watch::Receiver::constant(acp::PromptCapabilities {
image: true,
audio: true,
embedded_context: true,
}),
cx,
) )
}); });
self.sessions.lock().insert(session_id, thread.downgrade()); self.sessions.lock().insert(session_id, thread.downgrade());
@ -2639,14 +2670,6 @@ mod tests {
} }
} }
fn prompt_capabilities(&self) -> acp::PromptCapabilities {
acp::PromptCapabilities {
image: true,
audio: true,
embedded_context: true,
}
}
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
let sessions = self.sessions.lock(); let sessions = self.sessions.lock();
let thread = sessions.get(session_id).unwrap().clone(); let thread = sessions.get(session_id).unwrap().clone();

View file

@ -38,8 +38,6 @@ pub trait AgentConnection {
cx: &mut App, cx: &mut App,
) -> Task<Result<acp::PromptResponse>>; ) -> Task<Result<acp::PromptResponse>>;
fn prompt_capabilities(&self) -> acp::PromptCapabilities;
fn resume( fn resume(
&self, &self,
_session_id: &acp::SessionId, _session_id: &acp::SessionId,
@ -329,13 +327,19 @@ mod test_support {
) -> Task<gpui::Result<Entity<AcpThread>>> { ) -> Task<gpui::Result<Entity<AcpThread>>> {
let session_id = acp::SessionId(self.sessions.lock().len().to_string().into()); let session_id = acp::SessionId(self.sessions.lock().len().to_string().into());
let action_log = cx.new(|_| ActionLog::new(project.clone())); let action_log = cx.new(|_| ActionLog::new(project.clone()));
let thread = cx.new(|_cx| { let thread = cx.new(|cx| {
AcpThread::new( AcpThread::new(
"Test", "Test",
self.clone(), self.clone(),
project, project,
action_log, action_log,
session_id.clone(), session_id.clone(),
watch::Receiver::constant(acp::PromptCapabilities {
image: true,
audio: true,
embedded_context: true,
}),
cx,
) )
}); });
self.sessions.lock().insert( self.sessions.lock().insert(
@ -348,14 +352,6 @@ mod test_support {
Task::ready(Ok(thread)) Task::ready(Ok(thread))
} }
fn prompt_capabilities(&self) -> acp::PromptCapabilities {
acp::PromptCapabilities {
image: true,
audio: true,
embedded_context: true,
}
}
fn authenticate( fn authenticate(
&self, &self,
_method_id: acp::AuthMethodId, _method_id: acp::AuthMethodId,

View file

@ -21,12 +21,12 @@ use ui::prelude::*;
use util::ResultExt as _; use util::ResultExt as _;
use workspace::{Item, Workspace}; use workspace::{Item, Workspace};
actions!(acp, [OpenDebugTools]); actions!(dev, [OpenAcpLogs]);
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
cx.observe_new( cx.observe_new(
|workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| { |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
workspace.register_action(|workspace, _: &OpenDebugTools, window, cx| { workspace.register_action(|workspace, _: &OpenAcpLogs, window, cx| {
let acp_tools = let acp_tools =
Box::new(cx.new(|cx| AcpTools::new(workspace.project().clone(), cx))); Box::new(cx.new(|cx| AcpTools::new(workspace.project().clone(), cx)));
workspace.add_item_to_active_pane(acp_tools, None, true, window, cx); workspace.add_item_to_active_pane(acp_tools, None, true, window, cx);

View file

@ -180,7 +180,7 @@ impl NativeAgent {
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
cx: &mut AsyncApp, cx: &mut AsyncApp,
) -> Result<Entity<NativeAgent>> { ) -> Result<Entity<NativeAgent>> {
log::info!("Creating new NativeAgent"); log::debug!("Creating new NativeAgent");
let project_context = cx let project_context = cx
.update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))? .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))?
@ -240,13 +240,16 @@ impl NativeAgent {
let title = thread.title(); let title = thread.title();
let project = thread.project.clone(); let project = thread.project.clone();
let action_log = thread.action_log.clone(); let action_log = thread.action_log.clone();
let acp_thread = cx.new(|_cx| { let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone();
let acp_thread = cx.new(|cx| {
acp_thread::AcpThread::new( acp_thread::AcpThread::new(
title, title,
connection, connection,
project.clone(), project.clone(),
action_log.clone(), action_log.clone(),
session_id.clone(), session_id.clone(),
prompt_capabilities_rx,
cx,
) )
}); });
let subscriptions = vec![ let subscriptions = vec![
@ -756,7 +759,7 @@ impl NativeAgentConnection {
} }
} }
log::info!("Response stream completed"); log::debug!("Response stream completed");
anyhow::Ok(acp::PromptResponse { anyhow::Ok(acp::PromptResponse {
stop_reason: acp::StopReason::EndTurn, stop_reason: acp::StopReason::EndTurn,
}) })
@ -781,7 +784,7 @@ impl AgentModelSelector for NativeAgentConnection {
model_id: acp_thread::AgentModelId, model_id: acp_thread::AgentModelId,
cx: &mut App, cx: &mut App,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
log::info!("Setting model for session {}: {}", session_id, model_id); log::debug!("Setting model for session {}: {}", session_id, model_id);
let Some(thread) = self let Some(thread) = self
.0 .0
.read(cx) .read(cx)
@ -852,7 +855,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
cx: &mut App, cx: &mut App,
) -> Task<Result<Entity<acp_thread::AcpThread>>> { ) -> Task<Result<Entity<acp_thread::AcpThread>>> {
let agent = self.0.clone(); let agent = self.0.clone();
log::info!("Creating new thread for project at: {:?}", cwd); log::debug!("Creating new thread for project at: {:?}", cwd);
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
log::debug!("Starting thread creation in async context"); log::debug!("Starting thread creation in async context");
@ -917,7 +920,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
.into_iter() .into_iter()
.map(Into::into) .map(Into::into)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
log::info!("Converted prompt to message: {} chars", content.len()); log::debug!("Converted prompt to message: {} chars", content.len());
log::debug!("Message id: {:?}", id); log::debug!("Message id: {:?}", id);
log::debug!("Message content: {:?}", content); log::debug!("Message content: {:?}", content);
@ -925,14 +928,6 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
}) })
} }
fn prompt_capabilities(&self) -> acp::PromptCapabilities {
acp::PromptCapabilities {
image: true,
audio: false,
embedded_context: true,
}
}
fn resume( fn resume(
&self, &self,
session_id: &acp::SessionId, session_id: &acp::SessionId,

View file

@ -22,6 +22,10 @@ impl NativeAgentServer {
} }
impl AgentServer for NativeAgentServer { impl AgentServer for NativeAgentServer {
fn telemetry_id(&self) -> &'static str {
"zed"
}
fn name(&self) -> SharedString { fn name(&self) -> SharedString {
"Zed Agent".into() "Zed Agent".into()
} }
@ -44,7 +48,7 @@ impl AgentServer for NativeAgentServer {
project: &Entity<Project>, project: &Entity<Project>,
cx: &mut App, cx: &mut App,
) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> { ) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
log::info!( log::debug!(
"NativeAgentServer::connect called for path: {:?}", "NativeAgentServer::connect called for path: {:?}",
_root_dir _root_dir
); );
@ -63,7 +67,7 @@ impl AgentServer for NativeAgentServer {
// Create the connection wrapper // Create the connection wrapper
let connection = NativeAgentConnection(agent); let connection = NativeAgentConnection(agent);
log::info!("NativeAgentServer connection established successfully"); log::debug!("NativeAgentServer connection established successfully");
Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>) Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>)
}) })

View file

@ -72,6 +72,7 @@ async fn test_echo(cx: &mut TestAppContext) {
} }
#[gpui::test] #[gpui::test]
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_thinking(cx: &mut TestAppContext) { async fn test_thinking(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake(); let fake_model = model.as_fake();
@ -1347,6 +1348,7 @@ async fn test_cancellation(cx: &mut TestAppContext) {
} }
#[gpui::test] #[gpui::test]
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) { async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake(); let fake_model = model.as_fake();
@ -1685,6 +1687,7 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) {
} }
#[gpui::test] #[gpui::test]
#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_title_generation(cx: &mut TestAppContext) { async fn test_title_generation(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake(); let fake_model = model.as_fake();

View file

@ -575,11 +575,22 @@ pub struct Thread {
templates: Arc<Templates>, templates: Arc<Templates>,
model: Option<Arc<dyn LanguageModel>>, model: Option<Arc<dyn LanguageModel>>,
summarization_model: Option<Arc<dyn LanguageModel>>, summarization_model: Option<Arc<dyn LanguageModel>>,
prompt_capabilities_tx: watch::Sender<acp::PromptCapabilities>,
pub(crate) prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
pub(crate) project: Entity<Project>, pub(crate) project: Entity<Project>,
pub(crate) action_log: Entity<ActionLog>, pub(crate) action_log: Entity<ActionLog>,
} }
impl Thread { impl Thread {
fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities {
let image = model.map_or(true, |model| model.supports_images());
acp::PromptCapabilities {
image,
audio: false,
embedded_context: true,
}
}
pub fn new( pub fn new(
project: Entity<Project>, project: Entity<Project>,
project_context: Entity<ProjectContext>, project_context: Entity<ProjectContext>,
@ -590,6 +601,8 @@ impl Thread {
) -> Self { ) -> Self {
let profile_id = AgentSettings::get_global(cx).default_profile.clone(); let profile_id = AgentSettings::get_global(cx).default_profile.clone();
let action_log = cx.new(|_cx| ActionLog::new(project.clone())); let action_log = cx.new(|_cx| ActionLog::new(project.clone()));
let (prompt_capabilities_tx, prompt_capabilities_rx) =
watch::channel(Self::prompt_capabilities(model.as_deref()));
Self { Self {
id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()), id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()),
prompt_id: PromptId::new(), prompt_id: PromptId::new(),
@ -617,6 +630,8 @@ impl Thread {
templates, templates,
model, model,
summarization_model: None, summarization_model: None,
prompt_capabilities_tx,
prompt_capabilities_rx,
project, project,
action_log, action_log,
} }
@ -750,6 +765,8 @@ impl Thread {
.or_else(|| registry.default_model()) .or_else(|| registry.default_model())
.map(|model| model.model) .map(|model| model.model)
}); });
let (prompt_capabilities_tx, prompt_capabilities_rx) =
watch::channel(Self::prompt_capabilities(model.as_deref()));
Self { Self {
id, id,
@ -779,6 +796,8 @@ impl Thread {
project, project,
action_log, action_log,
updated_at: db_thread.updated_at, updated_at: db_thread.updated_at,
prompt_capabilities_tx,
prompt_capabilities_rx,
} }
} }
@ -946,10 +965,12 @@ impl Thread {
pub fn set_model(&mut self, model: Arc<dyn LanguageModel>, cx: &mut Context<Self>) { pub fn set_model(&mut self, model: Arc<dyn LanguageModel>, cx: &mut Context<Self>) {
let old_usage = self.latest_token_usage(); let old_usage = self.latest_token_usage();
self.model = Some(model); self.model = Some(model);
let new_caps = Self::prompt_capabilities(self.model.as_deref());
let new_usage = self.latest_token_usage(); let new_usage = self.latest_token_usage();
if old_usage != new_usage { if old_usage != new_usage {
cx.emit(TokenUsageUpdated(new_usage)); cx.emit(TokenUsageUpdated(new_usage));
} }
self.prompt_capabilities_tx.send(new_caps).log_err();
cx.notify() cx.notify()
} }
@ -1088,7 +1109,7 @@ impl Thread {
self.messages.push(Message::Resume); self.messages.push(Message::Resume);
cx.notify(); cx.notify();
log::info!("Total messages in thread: {}", self.messages.len()); log::debug!("Total messages in thread: {}", self.messages.len());
self.run_turn(cx) self.run_turn(cx)
} }
@ -1106,7 +1127,7 @@ impl Thread {
{ {
let model = self.model().context("No language model configured")?; let model = self.model().context("No language model configured")?;
log::info!("Thread::send called with model: {:?}", model.name()); log::info!("Thread::send called with model: {}", model.name().0);
self.advance_prompt_id(); self.advance_prompt_id();
let content = content.into_iter().map(Into::into).collect::<Vec<_>>(); let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
@ -1116,7 +1137,7 @@ impl Thread {
.push(Message::User(UserMessage { id, content })); .push(Message::User(UserMessage { id, content }));
cx.notify(); cx.notify();
log::info!("Total messages in thread: {}", self.messages.len()); log::debug!("Total messages in thread: {}", self.messages.len());
self.run_turn(cx) self.run_turn(cx)
} }
@ -1140,44 +1161,14 @@ impl Thread {
event_stream: event_stream.clone(), event_stream: event_stream.clone(),
tools: self.enabled_tools(profile, &model, cx), tools: self.enabled_tools(profile, &model, cx),
_task: cx.spawn(async move |this, cx| { _task: cx.spawn(async move |this, cx| {
log::info!("Starting agent turn execution"); log::debug!("Starting agent turn execution");
let turn_result: Result<()> = async { let turn_result = Self::run_turn_internal(&this, model, &event_stream, cx).await;
let mut intent = CompletionIntent::UserPrompt;
loop {
Self::stream_completion(&this, &model, intent, &event_stream, cx).await?;
let mut end_turn = true;
this.update(cx, |this, cx| {
// Generate title if needed.
if this.title.is_none() && this.pending_title_generation.is_none() {
this.generate_title(cx);
}
// End the turn if the model didn't use tools.
let message = this.pending_message.as_ref();
end_turn =
message.map_or(true, |message| message.tool_results.is_empty());
this.flush_pending_message(cx);
})?;
if this.read_with(cx, |this, _| this.tool_use_limit_reached)? {
log::info!("Tool use limit reached, completing turn");
return Err(language_model::ToolUseLimitReachedError.into());
} else if end_turn {
log::info!("No tool uses found, completing turn");
return Ok(());
} else {
intent = CompletionIntent::ToolResults;
}
}
}
.await;
_ = this.update(cx, |this, cx| this.flush_pending_message(cx)); _ = this.update(cx, |this, cx| this.flush_pending_message(cx));
match turn_result { match turn_result {
Ok(()) => { Ok(()) => {
log::info!("Turn execution completed"); log::debug!("Turn execution completed");
event_stream.send_stop(acp::StopReason::EndTurn); event_stream.send_stop(acp::StopReason::EndTurn);
} }
Err(error) => { Err(error) => {
@ -1203,20 +1194,17 @@ impl Thread {
Ok(events_rx) Ok(events_rx)
} }
async fn stream_completion( async fn run_turn_internal(
this: &WeakEntity<Self>, this: &WeakEntity<Self>,
model: &Arc<dyn LanguageModel>, model: Arc<dyn LanguageModel>,
completion_intent: CompletionIntent,
event_stream: &ThreadEventStream, event_stream: &ThreadEventStream,
cx: &mut AsyncApp, cx: &mut AsyncApp,
) -> Result<()> { ) -> Result<()> {
log::debug!("Stream completion started successfully"); let mut attempt = 0;
let mut intent = CompletionIntent::UserPrompt;
let mut attempt = None;
loop { loop {
let request = this.update(cx, |this, cx| { let request =
this.build_completion_request(completion_intent, cx) this.update(cx, |this, cx| this.build_completion_request(intent, cx))??;
})??;
telemetry::event!( telemetry::event!(
"Agent Thread Completion", "Agent Thread Completion",
@ -1227,23 +1215,19 @@ impl Thread {
attempt attempt
); );
log::info!( log::debug!("Calling model.stream_completion, attempt {}", attempt);
"Calling model.stream_completion, attempt {}",
attempt.unwrap_or(0)
);
let mut events = model let mut events = model
.stream_completion(request, cx) .stream_completion(request, cx)
.await .await
.map_err(|error| anyhow!(error))?; .map_err(|error| anyhow!(error))?;
let mut tool_results = FuturesUnordered::new(); let mut tool_results = FuturesUnordered::new();
let mut error = None; let mut error = None;
while let Some(event) = events.next().await { while let Some(event) = events.next().await {
log::trace!("Received completion event: {:?}", event);
match event { match event {
Ok(event) => { Ok(event) => {
log::trace!("Received completion event: {:?}", event);
tool_results.extend(this.update(cx, |this, cx| { tool_results.extend(this.update(cx, |this, cx| {
this.handle_streamed_completion_event(event, event_stream, cx) this.handle_completion_event(event, event_stream, cx)
})??); })??);
} }
Err(err) => { Err(err) => {
@ -1253,8 +1237,9 @@ impl Thread {
} }
} }
let end_turn = tool_results.is_empty();
while let Some(tool_result) = tool_results.next().await { while let Some(tool_result) = tool_results.next().await {
log::info!("Tool finished {:?}", tool_result); log::debug!("Tool finished {:?}", tool_result);
event_stream.update_tool_call_fields( event_stream.update_tool_call_fields(
&tool_result.tool_use_id, &tool_result.tool_use_id,
@ -1275,65 +1260,83 @@ impl Thread {
})?; })?;
} }
this.update(cx, |this, cx| {
this.flush_pending_message(cx);
if this.title.is_none() && this.pending_title_generation.is_none() {
this.generate_title(cx);
}
})?;
if let Some(error) = error { if let Some(error) = error {
let completion_mode = this.read_with(cx, |thread, _cx| thread.completion_mode())?; attempt += 1;
if completion_mode == CompletionMode::Normal { let retry =
return Err(anyhow!(error))?; this.update(cx, |this, _| this.handle_completion_error(error, attempt))??;
} let timer = cx.background_executor().timer(retry.duration);
event_stream.send_retry(retry);
let Some(strategy) = Self::retry_strategy_for(&error) else { timer.await;
return Err(anyhow!(error))?; this.update(cx, |this, _cx| {
};
let max_attempts = match &strategy {
RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
};
let attempt = attempt.get_or_insert(0u8);
*attempt += 1;
let attempt = *attempt;
if attempt > max_attempts {
return Err(anyhow!(error))?;
}
let delay = match &strategy {
RetryStrategy::ExponentialBackoff { initial_delay, .. } => {
let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32);
Duration::from_secs(delay_secs)
}
RetryStrategy::Fixed { delay, .. } => *delay,
};
log::debug!("Retry attempt {attempt} with delay {delay:?}");
event_stream.send_retry(acp_thread::RetryStatus {
last_error: error.to_string().into(),
attempt: attempt as usize,
max_attempts: max_attempts as usize,
started_at: Instant::now(),
duration: delay,
});
cx.background_executor().timer(delay).await;
this.update(cx, |this, cx| {
this.flush_pending_message(cx);
if let Some(Message::Agent(message)) = this.messages.last() { if let Some(Message::Agent(message)) = this.messages.last() {
if message.tool_results.is_empty() { if message.tool_results.is_empty() {
intent = CompletionIntent::UserPrompt;
this.messages.push(Message::Resume); this.messages.push(Message::Resume);
} }
} }
})?; })?;
} else { } else if this.read_with(cx, |this, _| this.tool_use_limit_reached)? {
return Err(language_model::ToolUseLimitReachedError.into());
} else if end_turn {
return Ok(()); return Ok(());
} else {
intent = CompletionIntent::ToolResults;
attempt = 0;
} }
} }
} }
fn handle_completion_error(
&mut self,
error: LanguageModelCompletionError,
attempt: u8,
) -> Result<acp_thread::RetryStatus> {
if self.completion_mode == CompletionMode::Normal {
return Err(anyhow!(error));
}
let Some(strategy) = Self::retry_strategy_for(&error) else {
return Err(anyhow!(error));
};
let max_attempts = match &strategy {
RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
};
if attempt > max_attempts {
return Err(anyhow!(error));
}
let delay = match &strategy {
RetryStrategy::ExponentialBackoff { initial_delay, .. } => {
let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32);
Duration::from_secs(delay_secs)
}
RetryStrategy::Fixed { delay, .. } => *delay,
};
log::debug!("Retry attempt {attempt} with delay {delay:?}");
Ok(acp_thread::RetryStatus {
last_error: error.to_string().into(),
attempt: attempt as usize,
max_attempts: max_attempts as usize,
started_at: Instant::now(),
duration: delay,
})
}
/// A helper method that's called on every streamed completion event. /// A helper method that's called on every streamed completion event.
/// Returns an optional tool result task, which the main agentic loop will /// Returns an optional tool result task, which the main agentic loop will
/// send back to the model when it resolves. /// send back to the model when it resolves.
fn handle_streamed_completion_event( fn handle_completion_event(
&mut self, &mut self,
event: LanguageModelCompletionEvent, event: LanguageModelCompletionEvent,
event_stream: &ThreadEventStream, event_stream: &ThreadEventStream,
@ -1528,7 +1531,7 @@ impl Thread {
}); });
let supports_images = self.model().is_some_and(|model| model.supports_images()); let supports_images = self.model().is_some_and(|model| model.supports_images());
let tool_result = tool.run(tool_use.input, tool_event_stream, cx); let tool_result = tool.run(tool_use.input, tool_event_stream, cx);
log::info!("Running tool {}", tool_use.name); log::debug!("Running tool {}", tool_use.name);
Some(cx.foreground_executor().spawn(async move { Some(cx.foreground_executor().spawn(async move {
let tool_result = tool_result.await.and_then(|output| { let tool_result = tool_result.await.and_then(|output| {
if let LanguageModelToolResultContent::Image(_) = &output.llm_output if let LanguageModelToolResultContent::Image(_) = &output.llm_output
@ -1640,7 +1643,7 @@ impl Thread {
summary.extend(lines.next()); summary.extend(lines.next());
} }
log::info!("Setting summary: {}", summary); log::debug!("Setting summary: {}", summary);
let summary = SharedString::from(summary); let summary = SharedString::from(summary);
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@ -1657,7 +1660,7 @@ impl Thread {
return; return;
}; };
log::info!( log::debug!(
"Generating title with model: {:?}", "Generating title with model: {:?}",
self.summarization_model.as_ref().map(|model| model.name()) self.summarization_model.as_ref().map(|model| model.name())
); );
@ -1799,8 +1802,8 @@ impl Thread {
log::debug!("Completion mode: {:?}", self.completion_mode); log::debug!("Completion mode: {:?}", self.completion_mode);
let messages = self.build_request_messages(cx); let messages = self.build_request_messages(cx);
log::info!("Request will include {} messages", messages.len()); log::debug!("Request will include {} messages", messages.len());
log::info!("Request includes {} tools", tools.len()); log::debug!("Request includes {} tools", tools.len());
let request = LanguageModelRequest { let request = LanguageModelRequest {
thread_id: Some(self.id.to_string()), thread_id: Some(self.id.to_string()),

View file

@ -136,12 +136,17 @@ impl AgentTool for FetchTool {
fn run( fn run(
self: Arc<Self>, self: Arc<Self>,
input: Self::Input, input: Self::Input,
_event_stream: ToolCallEventStream, event_stream: ToolCallEventStream,
cx: &mut App, cx: &mut App,
) -> Task<Result<Self::Output>> { ) -> Task<Result<Self::Output>> {
let authorize = event_stream.authorize(input.url.clone(), cx);
let text = cx.background_spawn({ let text = cx.background_spawn({
let http_client = self.http_client.clone(); let http_client = self.http_client.clone();
async move { Self::build_message(http_client, &input.url).await } async move {
authorize.await?;
Self::build_message(http_client, &input.url).await
}
}); });
cx.foreground_executor().spawn(async move { cx.foreground_executor().spawn(async move {

View file

@ -165,16 +165,17 @@ fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Resu
.collect(); .collect();
cx.background_spawn(async move { cx.background_spawn(async move {
Ok(snapshots let mut results = Vec::new();
.iter() for snapshot in snapshots {
.flat_map(|snapshot| { for entry in snapshot.entries(false, 0) {
let root_name = PathBuf::from(snapshot.root_name()); let root_name = PathBuf::from(snapshot.root_name());
snapshot if path_matcher.is_match(root_name.join(&entry.path)) {
.entries(false, 0) results.push(snapshot.abs_path().join(entry.path.as_ref()));
.map(move |entry| root_name.join(&entry.path)) }
.filter(|path| path_matcher.is_match(&path)) }
}) }
.collect())
Ok(results)
}) })
} }
@ -215,8 +216,8 @@ mod test {
assert_eq!( assert_eq!(
matches, matches,
&[ &[
PathBuf::from("root/apple/banana/carrot"), PathBuf::from(path!("/root/apple/banana/carrot")),
PathBuf::from("root/apple/bandana/carbonara") PathBuf::from(path!("/root/apple/bandana/carbonara"))
] ]
); );
@ -227,8 +228,8 @@ mod test {
assert_eq!( assert_eq!(
matches, matches,
&[ &[
PathBuf::from("root/apple/banana/carrot"), PathBuf::from(path!("/root/apple/banana/carrot")),
PathBuf::from("root/apple/bandana/carbonara") PathBuf::from(path!("/root/apple/bandana/carbonara"))
] ]
); );
} }

View file

@ -11,6 +11,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::Settings; use settings::Settings;
use std::{path::Path, sync::Arc}; use std::{path::Path, sync::Arc};
use util::markdown::MarkdownCodeBlock;
use crate::{AgentTool, ToolCallEventStream}; use crate::{AgentTool, ToolCallEventStream};
@ -243,6 +244,19 @@ impl AgentTool for ReadFileTool {
}]), }]),
..Default::default() ..Default::default()
}); });
if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
let markdown = MarkdownCodeBlock {
tag: &input.path,
text,
}
.to_string();
event_stream.update_fields(ToolCallUpdateFields {
content: Some(vec![acp::ToolCallContent::Content {
content: markdown.into(),
}]),
..Default::default()
})
}
} }
})?; })?;

View file

@ -162,12 +162,34 @@ impl AgentConnection for AcpConnection {
let conn = self.connection.clone(); let conn = self.connection.clone();
let sessions = self.sessions.clone(); let sessions = self.sessions.clone();
let cwd = cwd.to_path_buf(); let cwd = cwd.to_path_buf();
let context_server_store = project.read(cx).context_server_store().read(cx);
let mcp_servers = context_server_store
.configured_server_ids()
.iter()
.filter_map(|id| {
let configuration = context_server_store.configuration_for_server(id)?;
let command = configuration.command();
Some(acp::McpServer {
name: id.0.to_string(),
command: command.path.clone(),
args: command.args.clone(),
env: if let Some(env) = command.env.as_ref() {
env.iter()
.map(|(name, value)| acp::EnvVariable {
name: name.clone(),
value: value.clone(),
})
.collect()
} else {
vec![]
},
})
})
.collect();
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
let response = conn let response = conn
.new_session(acp::NewSessionRequest { .new_session(acp::NewSessionRequest { mcp_servers, cwd })
mcp_servers: vec![],
cwd,
})
.await .await
.map_err(|err| { .map_err(|err| {
if err.code == acp::ErrorCode::AUTH_REQUIRED.code { if err.code == acp::ErrorCode::AUTH_REQUIRED.code {
@ -185,13 +207,16 @@ impl AgentConnection for AcpConnection {
let session_id = response.session_id; let session_id = response.session_id;
let action_log = cx.new(|_| ActionLog::new(project.clone()))?; let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
let thread = cx.new(|_cx| { let thread = cx.new(|cx| {
AcpThread::new( AcpThread::new(
self.server_name.clone(), self.server_name.clone(),
self.clone(), self.clone(),
project, project,
action_log, action_log,
session_id.clone(), session_id.clone(),
// ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically.
watch::Receiver::constant(self.prompt_capabilities),
cx,
) )
})?; })?;
@ -263,7 +288,9 @@ impl AgentConnection for AcpConnection {
match serde_json::from_value(data.clone()) { match serde_json::from_value(data.clone()) {
Ok(ErrorDetails { details }) => { Ok(ErrorDetails { details }) => {
if suppress_abort_err && details.contains("This operation was aborted") if suppress_abort_err
&& (details.contains("This operation was aborted")
|| details.contains("The user aborted a request"))
{ {
Ok(acp::PromptResponse { Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Cancelled, stop_reason: acp::StopReason::Cancelled,
@ -279,10 +306,6 @@ impl AgentConnection for AcpConnection {
}) })
} }
fn prompt_capabilities(&self) -> acp::PromptCapabilities {
self.prompt_capabilities
}
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) {
session.suppress_abort_err = true; session.suppress_abort_err = true;

View file

@ -1,524 +0,0 @@
// Translates old acp agents into the new schema
use action_log::ActionLog;
use agent_client_protocol as acp;
use agentic_coding_protocol::{self as acp_old, AgentRequest as _};
use anyhow::{Context as _, Result, anyhow};
use futures::channel::oneshot;
use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity};
use project::Project;
use std::{any::Any, cell::RefCell, path::Path, rc::Rc};
use ui::App;
use util::ResultExt as _;
use crate::AgentServerCommand;
use acp_thread::{AcpThread, AgentConnection, AuthRequired};
#[derive(Clone)]
struct OldAcpClientDelegate {
thread: Rc<RefCell<WeakEntity<AcpThread>>>,
cx: AsyncApp,
next_tool_call_id: Rc<RefCell<u64>>,
// sent_buffer_versions: HashMap<Entity<Buffer>, HashMap<u64, BufferSnapshot>>,
}
impl OldAcpClientDelegate {
fn new(thread: Rc<RefCell<WeakEntity<AcpThread>>>, cx: AsyncApp) -> Self {
Self {
thread,
cx,
next_tool_call_id: Rc::new(RefCell::new(0)),
}
}
}
impl acp_old::Client for OldAcpClientDelegate {
async fn stream_assistant_message_chunk(
&self,
params: acp_old::StreamAssistantMessageChunkParams,
) -> Result<(), acp_old::Error> {
let cx = &mut self.cx.clone();
cx.update(|cx| {
self.thread
.borrow()
.update(cx, |thread, cx| match params.chunk {
acp_old::AssistantMessageChunk::Text { text } => {
thread.push_assistant_content_block(text.into(), false, cx)
}
acp_old::AssistantMessageChunk::Thought { thought } => {
thread.push_assistant_content_block(thought.into(), true, cx)
}
})
.log_err();
})?;
Ok(())
}
async fn request_tool_call_confirmation(
&self,
request: acp_old::RequestToolCallConfirmationParams,
) -> Result<acp_old::RequestToolCallConfirmationResponse, acp_old::Error> {
let cx = &mut self.cx.clone();
let old_acp_id = *self.next_tool_call_id.borrow() + 1;
self.next_tool_call_id.replace(old_acp_id);
let tool_call = into_new_tool_call(
acp::ToolCallId(old_acp_id.to_string().into()),
request.tool_call,
);
let mut options = match request.confirmation {
acp_old::ToolCallConfirmation::Edit { .. } => vec![(
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
acp::PermissionOptionKind::AllowAlways,
"Always Allow Edits".to_string(),
)],
acp_old::ToolCallConfirmation::Execute { root_command, .. } => vec![(
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
acp::PermissionOptionKind::AllowAlways,
format!("Always Allow {}", root_command),
)],
acp_old::ToolCallConfirmation::Mcp {
server_name,
tool_name,
..
} => vec![
(
acp_old::ToolCallConfirmationOutcome::AlwaysAllowMcpServer,
acp::PermissionOptionKind::AllowAlways,
format!("Always Allow {}", server_name),
),
(
acp_old::ToolCallConfirmationOutcome::AlwaysAllowTool,
acp::PermissionOptionKind::AllowAlways,
format!("Always Allow {}", tool_name),
),
],
acp_old::ToolCallConfirmation::Fetch { .. } => vec![(
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
acp::PermissionOptionKind::AllowAlways,
"Always Allow".to_string(),
)],
acp_old::ToolCallConfirmation::Other { .. } => vec![(
acp_old::ToolCallConfirmationOutcome::AlwaysAllow,
acp::PermissionOptionKind::AllowAlways,
"Always Allow".to_string(),
)],
};
options.extend([
(
acp_old::ToolCallConfirmationOutcome::Allow,
acp::PermissionOptionKind::AllowOnce,
"Allow".to_string(),
),
(
acp_old::ToolCallConfirmationOutcome::Reject,
acp::PermissionOptionKind::RejectOnce,
"Reject".to_string(),
),
]);
let mut outcomes = Vec::with_capacity(options.len());
let mut acp_options = Vec::with_capacity(options.len());
for (index, (outcome, kind, label)) in options.into_iter().enumerate() {
outcomes.push(outcome);
acp_options.push(acp::PermissionOption {
id: acp::PermissionOptionId(index.to_string().into()),
name: label,
kind,
})
}
let response = cx
.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.request_tool_call_authorization(tool_call.into(), acp_options, cx)
})
})??
.context("Failed to update thread")?
.await;
let outcome = match response {
Ok(option_id) => outcomes[option_id.0.parse::<usize>().unwrap_or(0)],
Err(oneshot::Canceled) => acp_old::ToolCallConfirmationOutcome::Cancel,
};
Ok(acp_old::RequestToolCallConfirmationResponse {
id: acp_old::ToolCallId(old_acp_id),
outcome,
})
}
async fn push_tool_call(
&self,
request: acp_old::PushToolCallParams,
) -> Result<acp_old::PushToolCallResponse, acp_old::Error> {
let cx = &mut self.cx.clone();
let old_acp_id = *self.next_tool_call_id.borrow() + 1;
self.next_tool_call_id.replace(old_acp_id);
cx.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.upsert_tool_call(
into_new_tool_call(acp::ToolCallId(old_acp_id.to_string().into()), request),
cx,
)
})
})??
.context("Failed to update thread")?;
Ok(acp_old::PushToolCallResponse {
id: acp_old::ToolCallId(old_acp_id),
})
}
async fn update_tool_call(
&self,
request: acp_old::UpdateToolCallParams,
) -> Result<(), acp_old::Error> {
let cx = &mut self.cx.clone();
cx.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.update_tool_call(
acp::ToolCallUpdate {
id: acp::ToolCallId(request.tool_call_id.0.to_string().into()),
fields: acp::ToolCallUpdateFields {
status: Some(into_new_tool_call_status(request.status)),
content: Some(
request
.content
.into_iter()
.map(into_new_tool_call_content)
.collect::<Vec<_>>(),
),
..Default::default()
},
},
cx,
)
})
})?
.context("Failed to update thread")??;
Ok(())
}
async fn update_plan(&self, request: acp_old::UpdatePlanParams) -> Result<(), acp_old::Error> {
let cx = &mut self.cx.clone();
cx.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.update_plan(
acp::Plan {
entries: request
.entries
.into_iter()
.map(into_new_plan_entry)
.collect(),
},
cx,
)
})
})?
.context("Failed to update thread")?;
Ok(())
}
async fn read_text_file(
&self,
acp_old::ReadTextFileParams { path, line, limit }: acp_old::ReadTextFileParams,
) -> Result<acp_old::ReadTextFileResponse, acp_old::Error> {
let content = self
.cx
.update(|cx| {
self.thread.borrow().update(cx, |thread, cx| {
thread.read_text_file(path, line, limit, false, cx)
})
})?
.context("Failed to update thread")?
.await?;
Ok(acp_old::ReadTextFileResponse { content })
}
async fn write_text_file(
&self,
acp_old::WriteTextFileParams { path, content }: acp_old::WriteTextFileParams,
) -> Result<(), acp_old::Error> {
self.cx
.update(|cx| {
self.thread
.borrow()
.update(cx, |thread, cx| thread.write_text_file(path, content, cx))
})?
.context("Failed to update thread")?
.await?;
Ok(())
}
}
fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams) -> acp::ToolCall {
acp::ToolCall {
id,
title: request.label,
kind: acp_kind_from_old_icon(request.icon),
status: acp::ToolCallStatus::InProgress,
content: request
.content
.into_iter()
.map(into_new_tool_call_content)
.collect(),
locations: request
.locations
.into_iter()
.map(into_new_tool_call_location)
.collect(),
raw_input: None,
raw_output: None,
}
}
fn acp_kind_from_old_icon(icon: acp_old::Icon) -> acp::ToolKind {
match icon {
acp_old::Icon::FileSearch => acp::ToolKind::Search,
acp_old::Icon::Folder => acp::ToolKind::Search,
acp_old::Icon::Globe => acp::ToolKind::Search,
acp_old::Icon::Hammer => acp::ToolKind::Other,
acp_old::Icon::LightBulb => acp::ToolKind::Think,
acp_old::Icon::Pencil => acp::ToolKind::Edit,
acp_old::Icon::Regex => acp::ToolKind::Search,
acp_old::Icon::Terminal => acp::ToolKind::Execute,
}
}
fn into_new_tool_call_status(status: acp_old::ToolCallStatus) -> acp::ToolCallStatus {
match status {
acp_old::ToolCallStatus::Running => acp::ToolCallStatus::InProgress,
acp_old::ToolCallStatus::Finished => acp::ToolCallStatus::Completed,
acp_old::ToolCallStatus::Error => acp::ToolCallStatus::Failed,
}
}
fn into_new_tool_call_content(content: acp_old::ToolCallContent) -> acp::ToolCallContent {
match content {
acp_old::ToolCallContent::Markdown { markdown } => markdown.into(),
acp_old::ToolCallContent::Diff { diff } => acp::ToolCallContent::Diff {
diff: into_new_diff(diff),
},
}
}
fn into_new_diff(diff: acp_old::Diff) -> acp::Diff {
acp::Diff {
path: diff.path,
old_text: diff.old_text,
new_text: diff.new_text,
}
}
fn into_new_tool_call_location(location: acp_old::ToolCallLocation) -> acp::ToolCallLocation {
acp::ToolCallLocation {
path: location.path,
line: location.line,
}
}
fn into_new_plan_entry(entry: acp_old::PlanEntry) -> acp::PlanEntry {
acp::PlanEntry {
content: entry.content,
priority: into_new_plan_priority(entry.priority),
status: into_new_plan_status(entry.status),
}
}
fn into_new_plan_priority(priority: acp_old::PlanEntryPriority) -> acp::PlanEntryPriority {
match priority {
acp_old::PlanEntryPriority::Low => acp::PlanEntryPriority::Low,
acp_old::PlanEntryPriority::Medium => acp::PlanEntryPriority::Medium,
acp_old::PlanEntryPriority::High => acp::PlanEntryPriority::High,
}
}
fn into_new_plan_status(status: acp_old::PlanEntryStatus) -> acp::PlanEntryStatus {
match status {
acp_old::PlanEntryStatus::Pending => acp::PlanEntryStatus::Pending,
acp_old::PlanEntryStatus::InProgress => acp::PlanEntryStatus::InProgress,
acp_old::PlanEntryStatus::Completed => acp::PlanEntryStatus::Completed,
}
}
pub struct AcpConnection {
pub name: &'static str,
pub connection: acp_old::AgentConnection,
pub _child_status: Task<Result<()>>,
pub current_thread: Rc<RefCell<WeakEntity<AcpThread>>>,
}
impl AcpConnection {
pub fn stdio(
name: &'static str,
command: AgentServerCommand,
root_dir: &Path,
cx: &mut AsyncApp,
) -> Task<Result<Self>> {
let root_dir = root_dir.to_path_buf();
cx.spawn(async move |cx| {
let mut child = util::command::new_smol_command(&command.path)
.args(command.args.iter())
.current_dir(root_dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true)
.spawn()?;
let stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
log::trace!("Spawned (pid: {})", child.id());
let foreground_executor = cx.foreground_executor().clone();
let thread_rc = Rc::new(RefCell::new(WeakEntity::new_invalid()));
let (connection, io_fut) = acp_old::AgentConnection::connect_to_agent(
OldAcpClientDelegate::new(thread_rc.clone(), cx.clone()),
stdin,
stdout,
move |fut| foreground_executor.spawn(fut).detach(),
);
let io_task = cx.background_spawn(async move {
io_fut.await.log_err();
});
let child_status = cx.background_spawn(async move {
let result = match child.status().await {
Err(e) => Err(anyhow!(e)),
Ok(result) if result.success() => Ok(()),
Ok(result) => Err(anyhow!(result)),
};
drop(io_task);
result
});
Ok(Self {
name,
connection,
_child_status: child_status,
current_thread: thread_rc,
})
})
}
}
impl AgentConnection for AcpConnection {
fn new_thread(
self: Rc<Self>,
project: Entity<Project>,
_cwd: &Path,
cx: &mut App,
) -> Task<Result<Entity<AcpThread>>> {
let task = self.connection.request_any(
acp_old::InitializeParams {
protocol_version: acp_old::ProtocolVersion::latest(),
}
.into_any(),
);
let current_thread = self.current_thread.clone();
cx.spawn(async move |cx| {
let result = task.await?;
let result = acp_old::InitializeParams::response_from_any(result)?;
if !result.is_authenticated {
anyhow::bail!(AuthRequired::new())
}
cx.update(|cx| {
let thread = cx.new(|cx| {
let session_id = acp::SessionId("acp-old-no-id".into());
let action_log = cx.new(|_| ActionLog::new(project.clone()));
AcpThread::new(self.name, self.clone(), project, action_log, session_id)
});
current_thread.replace(thread.downgrade());
thread
})
})
}
fn auth_methods(&self) -> &[acp::AuthMethod] {
&[]
}
fn authenticate(&self, _method_id: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>> {
let task = self
.connection
.request_any(acp_old::AuthenticateParams.into_any());
cx.foreground_executor().spawn(async move {
task.await?;
Ok(())
})
}
fn prompt(
&self,
_id: Option<acp_thread::UserMessageId>,
params: acp::PromptRequest,
cx: &mut App,
) -> Task<Result<acp::PromptResponse>> {
let chunks = params
.prompt
.into_iter()
.filter_map(|block| match block {
acp::ContentBlock::Text(text) => {
Some(acp_old::UserMessageChunk::Text { text: text.text })
}
acp::ContentBlock::ResourceLink(link) => Some(acp_old::UserMessageChunk::Path {
path: link.uri.into(),
}),
_ => None,
})
.collect();
let task = self
.connection
.request_any(acp_old::SendUserMessageParams { chunks }.into_any());
cx.foreground_executor().spawn(async move {
task.await?;
anyhow::Ok(acp::PromptResponse {
stop_reason: acp::StopReason::EndTurn,
})
})
}
fn prompt_capabilities(&self) -> acp::PromptCapabilities {
acp::PromptCapabilities {
image: false,
audio: false,
embedded_context: false,
}
}
fn cancel(&self, _session_id: &acp::SessionId, cx: &mut App) {
let task = self
.connection
.request_any(acp_old::CancelSendMessageParams.into_any());
cx.foreground_executor()
.spawn(async move {
task.await?;
anyhow::Ok(())
})
.detach_and_log_err(cx)
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
}

View file

@ -1,376 +0,0 @@
use acp_tools::AcpConnectionRegistry;
use action_log::ActionLog;
use agent_client_protocol::{self as acp, Agent as _, ErrorCode};
use anyhow::anyhow;
use collections::HashMap;
use futures::AsyncBufReadExt as _;
use futures::channel::oneshot;
use futures::io::BufReader;
use project::Project;
use serde::Deserialize;
use std::path::Path;
use std::rc::Rc;
use std::{any::Any, cell::RefCell};
use anyhow::{Context as _, Result};
use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
use crate::{AgentServerCommand, acp::UnsupportedVersion};
use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError};
pub struct AcpConnection {
server_name: &'static str,
connection: Rc<acp::ClientSideConnection>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>,
prompt_capabilities: acp::PromptCapabilities,
_io_task: Task<Result<()>>,
}
pub struct AcpSession {
thread: WeakEntity<AcpThread>,
suppress_abort_err: bool,
}
const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1;
impl AcpConnection {
pub async fn stdio(
server_name: &'static str,
command: AgentServerCommand,
root_dir: &Path,
cx: &mut AsyncApp,
) -> Result<Self> {
let mut child = util::command::new_smol_command(&command.path)
.args(command.args.iter().map(|arg| arg.as_str()))
.envs(command.env.iter().flatten())
.current_dir(root_dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let stdout = child.stdout.take().context("Failed to take stdout")?;
let stdin = child.stdin.take().context("Failed to take stdin")?;
let stderr = child.stderr.take().context("Failed to take stderr")?;
log::trace!("Spawned (pid: {})", child.id());
let sessions = Rc::new(RefCell::new(HashMap::default()));
let client = ClientDelegate {
sessions: sessions.clone(),
cx: cx.clone(),
};
let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, {
let foreground_executor = cx.foreground_executor().clone();
move |fut| {
foreground_executor.spawn(fut).detach();
}
});
let io_task = cx.background_spawn(io_task);
cx.background_spawn(async move {
let mut stderr = BufReader::new(stderr);
let mut line = String::new();
while let Ok(n) = stderr.read_line(&mut line).await
&& n > 0
{
log::warn!("agent stderr: {}", &line);
line.clear();
}
})
.detach();
cx.spawn({
let sessions = sessions.clone();
async move |cx| {
let status = child.status().await?;
for session in sessions.borrow().values() {
session
.thread
.update(cx, |thread, cx| {
thread.emit_load_error(LoadError::Exited { status }, cx)
})
.ok();
}
anyhow::Ok(())
}
})
.detach();
let connection = Rc::new(connection);
cx.update(|cx| {
AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| {
registry.set_active_connection(server_name, &connection, cx)
});
})?;
let response = connection
.initialize(acp::InitializeRequest {
protocol_version: acp::VERSION,
client_capabilities: acp::ClientCapabilities {
fs: acp::FileSystemCapability {
read_text_file: true,
write_text_file: true,
},
},
})
.await?;
if response.protocol_version < MINIMUM_SUPPORTED_VERSION {
return Err(UnsupportedVersion.into());
}
Ok(Self {
auth_methods: response.auth_methods,
connection,
server_name,
sessions,
prompt_capabilities: response.agent_capabilities.prompt_capabilities,
_io_task: io_task,
})
}
}
impl AgentConnection for AcpConnection {
fn new_thread(
self: Rc<Self>,
project: Entity<Project>,
cwd: &Path,
cx: &mut App,
) -> Task<Result<Entity<AcpThread>>> {
let conn = self.connection.clone();
let sessions = self.sessions.clone();
let cwd = cwd.to_path_buf();
cx.spawn(async move |cx| {
let response = conn
.new_session(acp::NewSessionRequest {
mcp_servers: vec![],
cwd,
})
.await
.map_err(|err| {
if err.code == acp::ErrorCode::AUTH_REQUIRED.code {
let mut error = AuthRequired::new();
if err.message != acp::ErrorCode::AUTH_REQUIRED.message {
error = error.with_description(err.message);
}
anyhow!(error)
} else {
anyhow!(err)
}
})?;
let session_id = response.session_id;
let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
let thread = cx.new(|_cx| {
AcpThread::new(
self.server_name,
self.clone(),
project,
action_log,
session_id.clone(),
)
})?;
let session = AcpSession {
thread: thread.downgrade(),
suppress_abort_err: false,
};
sessions.borrow_mut().insert(session_id, session);
Ok(thread)
})
}
fn auth_methods(&self) -> &[acp::AuthMethod] {
&self.auth_methods
}
fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>> {
let conn = self.connection.clone();
cx.foreground_executor().spawn(async move {
let result = conn
.authenticate(acp::AuthenticateRequest {
method_id: method_id.clone(),
})
.await?;
Ok(result)
})
}
fn prompt(
&self,
_id: Option<acp_thread::UserMessageId>,
params: acp::PromptRequest,
cx: &mut App,
) -> Task<Result<acp::PromptResponse>> {
let conn = self.connection.clone();
let sessions = self.sessions.clone();
let session_id = params.session_id.clone();
cx.foreground_executor().spawn(async move {
let result = conn.prompt(params).await;
let mut suppress_abort_err = false;
if let Some(session) = sessions.borrow_mut().get_mut(&session_id) {
suppress_abort_err = session.suppress_abort_err;
session.suppress_abort_err = false;
}
match result {
Ok(response) => Ok(response),
Err(err) => {
if err.code != ErrorCode::INTERNAL_ERROR.code {
anyhow::bail!(err)
}
let Some(data) = &err.data else {
anyhow::bail!(err)
};
// Temporary workaround until the following PR is generally available:
// https://github.com/google-gemini/gemini-cli/pull/6656
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ErrorDetails {
details: Box<str>,
}
match serde_json::from_value(data.clone()) {
Ok(ErrorDetails { details }) => {
if suppress_abort_err && details.contains("This operation was aborted")
{
Ok(acp::PromptResponse {
stop_reason: acp::StopReason::Cancelled,
})
} else {
Err(anyhow!(details))
}
}
Err(_) => Err(anyhow!(err)),
}
}
}
})
}
fn prompt_capabilities(&self) -> acp::PromptCapabilities {
self.prompt_capabilities
}
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) {
session.suppress_abort_err = true;
}
let conn = self.connection.clone();
let params = acp::CancelNotification {
session_id: session_id.clone(),
};
cx.foreground_executor()
.spawn(async move { conn.cancel(params).await })
.detach();
}
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
}
struct ClientDelegate {
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
cx: AsyncApp,
}
impl acp::Client for ClientDelegate {
async fn request_permission(
&self,
arguments: acp::RequestPermissionRequest,
) -> Result<acp::RequestPermissionResponse, acp::Error> {
let cx = &mut self.cx.clone();
let rx = self
.sessions
.borrow()
.get(&arguments.session_id)
.context("Failed to get session")?
.thread
.update(cx, |thread, cx| {
thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx)
})?;
let result = rx?.await;
let outcome = match result {
Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option },
Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled,
};
Ok(acp::RequestPermissionResponse { outcome })
}
async fn write_text_file(
&self,
arguments: acp::WriteTextFileRequest,
) -> Result<(), acp::Error> {
let cx = &mut self.cx.clone();
let task = self
.sessions
.borrow()
.get(&arguments.session_id)
.context("Failed to get session")?
.thread
.update(cx, |thread, cx| {
thread.write_text_file(arguments.path, arguments.content, cx)
})?;
task.await?;
Ok(())
}
async fn read_text_file(
&self,
arguments: acp::ReadTextFileRequest,
) -> Result<acp::ReadTextFileResponse, acp::Error> {
let cx = &mut self.cx.clone();
let task = self
.sessions
.borrow()
.get(&arguments.session_id)
.context("Failed to get session")?
.thread
.update(cx, |thread, cx| {
thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx)
})?;
let content = task.await?;
Ok(acp::ReadTextFileResponse { content })
}
async fn session_notification(
&self,
notification: acp::SessionNotification,
) -> Result<(), acp::Error> {
let cx = &mut self.cx.clone();
let sessions = self.sessions.borrow();
let session = sessions
.get(&notification.session_id)
.context("Failed to get session")?;
session.thread.update(cx, |thread, cx| {
thread.handle_session_update(notification.update, cx)
})??;
Ok(())
}
}

View file

@ -36,6 +36,7 @@ pub trait AgentServer: Send {
fn name(&self) -> SharedString; fn name(&self) -> SharedString;
fn empty_state_headline(&self) -> SharedString; fn empty_state_headline(&self) -> SharedString;
fn empty_state_message(&self) -> SharedString; fn empty_state_message(&self) -> SharedString;
fn telemetry_id(&self) -> &'static str;
fn connect( fn connect(
&self, &self,
@ -97,7 +98,7 @@ pub struct AgentServerCommand {
} }
impl AgentServerCommand { impl AgentServerCommand {
pub(crate) async fn resolve( pub async fn resolve(
path_bin_name: &'static str, path_bin_name: &'static str,
extra_args: &[&'static str], extra_args: &[&'static str],
fallback_path: Option<&Path>, fallback_path: Option<&Path>,

View file

@ -43,6 +43,10 @@ use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri
pub struct ClaudeCode; pub struct ClaudeCode;
impl AgentServer for ClaudeCode { impl AgentServer for ClaudeCode {
fn telemetry_id(&self) -> &'static str {
"claude-code"
}
fn name(&self) -> SharedString { fn name(&self) -> SharedString {
"Claude Code".into() "Claude Code".into()
} }
@ -249,13 +253,19 @@ impl AgentConnection for ClaudeAgentConnection {
}); });
let action_log = cx.new(|_| ActionLog::new(project.clone()))?; let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
let thread = cx.new(|_cx| { let thread = cx.new(|cx| {
AcpThread::new( AcpThread::new(
"Claude Code", "Claude Code",
self.clone(), self.clone(),
project, project,
action_log, action_log,
session_id.clone(), session_id.clone(),
watch::Receiver::constant(acp::PromptCapabilities {
image: true,
audio: false,
embedded_context: true,
}),
cx,
) )
})?; })?;
@ -319,14 +329,6 @@ impl AgentConnection for ClaudeAgentConnection {
cx.foreground_executor().spawn(async move { end_rx.await? }) cx.foreground_executor().spawn(async move { end_rx.await? })
} }
fn prompt_capabilities(&self) -> acp::PromptCapabilities {
acp::PromptCapabilities {
image: true,
audio: false,
embedded_context: true,
}
}
fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
let sessions = self.sessions.borrow(); let sessions = self.sessions.borrow();
let Some(session) = sessions.get(session_id) else { let Some(session) = sessions.get(session_id) else {

View file

@ -22,6 +22,10 @@ impl CustomAgentServer {
} }
impl crate::AgentServer for CustomAgentServer { impl crate::AgentServer for CustomAgentServer {
fn telemetry_id(&self) -> &'static str {
"custom"
}
fn name(&self) -> SharedString { fn name(&self) -> SharedString {
self.name.clone() self.name.clone()
} }

View file

@ -17,6 +17,10 @@ pub struct Gemini;
const ACP_ARG: &str = "--experimental-acp"; const ACP_ARG: &str = "--experimental-acp";
impl AgentServer for Gemini { impl AgentServer for Gemini {
fn telemetry_id(&self) -> &'static str {
"gemini-cli"
}
fn name(&self) -> SharedString { fn name(&self) -> SharedString {
"Gemini CLI".into() "Gemini CLI".into()
} }
@ -53,7 +57,7 @@ impl AgentServer for Gemini {
return Err(LoadError::NotInstalled { return Err(LoadError::NotInstalled {
error_message: "Failed to find Gemini CLI binary".into(), error_message: "Failed to find Gemini CLI binary".into(),
install_message: "Install Gemini CLI".into(), install_message: "Install Gemini CLI".into(),
install_command: "npm install -g @google/gemini-cli@preview".into() install_command: Self::install_command().into(),
}.into()); }.into());
}; };
@ -88,7 +92,7 @@ impl AgentServer for Gemini {
current_version current_version
).into(), ).into(),
upgrade_message: "Upgrade Gemini CLI to latest".into(), upgrade_message: "Upgrade Gemini CLI to latest".into(),
upgrade_command: "npm install -g @google/gemini-cli@preview".into(), upgrade_command: Self::upgrade_command().into(),
}.into()) }.into())
} }
} }
@ -101,6 +105,20 @@ impl AgentServer for Gemini {
} }
} }
impl Gemini {
pub fn binary_name() -> &'static str {
"gemini"
}
pub fn install_command() -> &'static str {
"npm install -g @google/gemini-cli@preview"
}
pub fn upgrade_command() -> &'static str {
"npm install -g @google/gemini-cli@preview"
}
}
#[cfg(test)] #[cfg(test)]
pub(crate) mod tests { pub(crate) mod tests {
use super::*; use super::*;

View file

@ -373,7 +373,7 @@ impl MessageEditor {
if Img::extensions().contains(&extension) && !extension.contains("svg") { if Img::extensions().contains(&extension) && !extension.contains("svg") {
if !self.prompt_capabilities.get().image { if !self.prompt_capabilities.get().image {
return Task::ready(Err(anyhow!("This agent does not support images yet"))); return Task::ready(Err(anyhow!("This model does not support images yet")));
} }
let task = self let task = self
.project .project

View file

@ -462,7 +462,7 @@ impl AcpThreadHistory {
cx.notify(); cx.notify();
})) }))
.end_slot::<IconButton>(if hovered || selected { .end_slot::<IconButton>(if hovered {
Some( Some(
IconButton::new("delete", IconName::Trash) IconButton::new("delete", IconName::Trash)
.shape(IconButtonShape::Square) .shape(IconButtonShape::Square)

View file

@ -35,6 +35,7 @@ use prompt_store::{PromptId, PromptStore};
use rope::Point; use rope::Point;
use settings::{Settings as _, SettingsStore}; use settings::{Settings as _, SettingsStore};
use std::cell::Cell; use std::cell::Cell;
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use std::{collections::BTreeMap, rc::Rc, time::Duration}; use std::{collections::BTreeMap, rc::Rc, time::Duration};
@ -274,6 +275,7 @@ pub struct AcpThreadView {
edits_expanded: bool, edits_expanded: bool,
plan_expanded: bool, plan_expanded: bool,
editor_expanded: bool, editor_expanded: bool,
should_be_following: bool,
editing_message: Option<usize>, editing_message: Option<usize>,
prompt_capabilities: Rc<Cell<PromptCapabilities>>, prompt_capabilities: Rc<Cell<PromptCapabilities>>,
is_loading_contents: bool, is_loading_contents: bool,
@ -385,6 +387,7 @@ impl AcpThreadView {
edits_expanded: false, edits_expanded: false,
plan_expanded: false, plan_expanded: false,
editor_expanded: false, editor_expanded: false,
should_be_following: false,
history_store, history_store,
hovered_recent_history_item: None, hovered_recent_history_item: None,
prompt_capabilities, prompt_capabilities,
@ -472,7 +475,7 @@ impl AcpThreadView {
let action_log = thread.read(cx).action_log().clone(); let action_log = thread.read(cx).action_log().clone();
this.prompt_capabilities this.prompt_capabilities
.set(connection.prompt_capabilities()); .set(thread.read(cx).prompt_capabilities());
let count = thread.read(cx).entries().len(); let count = thread.read(cx).entries().len();
this.list_state.splice(0..0, count); this.list_state.splice(0..0, count);
@ -890,6 +893,8 @@ impl AcpThreadView {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let agent_telemetry_id = self.agent.telemetry_id();
self.thread_error.take(); self.thread_error.take();
self.editing_message.take(); self.editing_message.take();
self.thread_feedback.clear(); self.thread_feedback.clear();
@ -897,6 +902,13 @@ impl AcpThreadView {
let Some(thread) = self.thread().cloned() else { let Some(thread) = self.thread().cloned() else {
return; return;
}; };
if self.should_be_following {
self.workspace
.update(cx, |workspace, cx| {
workspace.follow(CollaboratorId::Agent, window, cx);
})
.ok();
}
self.is_loading_contents = true; self.is_loading_contents = true;
let guard = cx.new(|_| ()); let guard = cx.new(|_| ());
@ -927,6 +939,9 @@ impl AcpThreadView {
} }
}); });
drop(guard); drop(guard);
telemetry::event!("Agent Message Sent", agent = agent_telemetry_id);
thread.send(contents, cx) thread.send(contents, cx)
})?; })?;
send.await send.await
@ -938,6 +953,16 @@ impl AcpThreadView {
this.handle_thread_error(err, cx); this.handle_thread_error(err, cx);
}) })
.ok(); .ok();
} else {
this.update(cx, |this, cx| {
this.should_be_following = this
.workspace
.update(cx, |workspace, _| {
workspace.is_being_followed(CollaboratorId::Agent)
})
.unwrap_or_default();
})
.ok();
} }
}) })
.detach(); .detach();
@ -1144,6 +1169,10 @@ impl AcpThreadView {
}); });
} }
} }
AcpThreadEvent::PromptCapabilitiesUpdated => {
self.prompt_capabilities
.set(thread.read(cx).prompt_capabilities());
}
AcpThreadEvent::TokenUsageUpdated => {} AcpThreadEvent::TokenUsageUpdated => {}
} }
cx.notify(); cx.notify();
@ -1223,30 +1252,44 @@ impl AcpThreadView {
pending_auth_method.replace(method.clone()); pending_auth_method.replace(method.clone());
let authenticate = connection.authenticate(method, cx); let authenticate = connection.authenticate(method, cx);
cx.notify(); cx.notify();
self.auth_task = Some(cx.spawn_in(window, { self.auth_task =
let project = self.project.clone(); Some(cx.spawn_in(window, {
let agent = self.agent.clone(); let project = self.project.clone();
async move |this, cx| { let agent = self.agent.clone();
let result = authenticate.await; async move |this, cx| {
let result = authenticate.await;
this.update_in(cx, |this, window, cx| { match &result {
if let Err(err) = result { Ok(_) => telemetry::event!(
this.handle_thread_error(err, cx); "Authenticate Agent Succeeded",
} else { agent = agent.telemetry_id()
this.thread_state = Self::initial_state( ),
agent, Err(_) => {
None, telemetry::event!(
this.workspace.clone(), "Authenticate Agent Failed",
project.clone(), agent = agent.telemetry_id(),
window, )
cx, }
)
} }
this.auth_task.take()
}) this.update_in(cx, |this, window, cx| {
.ok(); if let Err(err) = result {
} this.handle_thread_error(err, cx);
})); } else {
this.thread_state = Self::initial_state(
agent,
None,
this.workspace.clone(),
project.clone(),
window,
cx,
)
}
this.auth_task.take()
})
.ok();
}
}));
} }
fn authorize_tool_call( fn authorize_tool_call(
@ -1254,6 +1297,7 @@ impl AcpThreadView {
tool_call_id: acp::ToolCallId, tool_call_id: acp::ToolCallId,
option_id: acp::PermissionOptionId, option_id: acp::PermissionOptionId,
option_kind: acp::PermissionOptionKind, option_kind: acp::PermissionOptionKind,
window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let Some(thread) = self.thread() else { let Some(thread) = self.thread() else {
@ -1262,6 +1306,13 @@ impl AcpThreadView {
thread.update(cx, |thread, cx| { thread.update(cx, |thread, cx| {
thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx); thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx);
}); });
if self.should_be_following {
self.workspace
.update(cx, |workspace, cx| {
workspace.follow(CollaboratorId::Agent, window, cx);
})
.ok();
}
cx.notify(); cx.notify();
} }
@ -1305,14 +1356,23 @@ impl AcpThreadView {
None None
}; };
let has_checkpoint_button = message
.checkpoint
.as_ref()
.is_some_and(|checkpoint| checkpoint.show);
let agent_name = self.agent.name(); let agent_name = self.agent.name();
v_flex() v_flex()
.id(("user_message", entry_ix)) .id(("user_message", entry_ix))
.map(|this| if rules_item.is_some() { .map(|this| {
this.pt_3() if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
} else { this.pt_4()
this.pt_2() } else if rules_item.is_some() {
this.pt_3()
} else {
this.pt_2()
}
}) })
.pb_4() .pb_4()
.px_2() .px_2()
@ -1492,12 +1552,11 @@ impl AcpThreadView {
return primary; return primary;
}; };
let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); let primary = if entry_ix == total_entries - 1 {
let primary = if entry_ix == total_entries - 1 && !is_generating {
v_flex() v_flex()
.w_full() .w_full()
.child(primary) .child(primary)
.child(self.render_thread_controls(cx)) .child(self.render_thread_controls(&thread, cx))
.when_some( .when_some(
self.thread_feedback.comments_editor.clone(), self.thread_feedback.comments_editor.clone(),
|this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)), |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)),
@ -1639,15 +1698,16 @@ impl AcpThreadView {
.into_any_element() .into_any_element()
} }
fn render_tool_call_icon( fn render_tool_call(
&self, &self,
group_name: SharedString,
entry_ix: usize, entry_ix: usize,
is_collapsible: bool,
is_open: bool,
tool_call: &ToolCall, tool_call: &ToolCall,
window: &Window,
cx: &Context<Self>, cx: &Context<Self>,
) -> Div { ) -> Div {
let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix));
let card_header_id = SharedString::from("inner-tool-call-header");
let tool_icon = let tool_icon =
if tool_call.kind == acp::ToolKind::Edit && tool_call.locations.len() == 1 { if tool_call.kind == acp::ToolKind::Edit && tool_call.locations.len() == 1 {
FileIcons::get_icon(&tool_call.locations[0].path, cx) FileIcons::get_icon(&tool_call.locations[0].path, cx)
@ -1655,7 +1715,7 @@ impl AcpThreadView {
.unwrap_or(Icon::new(IconName::ToolPencil)) .unwrap_or(Icon::new(IconName::ToolPencil))
} else { } else {
Icon::new(match tool_call.kind { Icon::new(match tool_call.kind {
acp::ToolKind::Read => IconName::ToolRead, acp::ToolKind::Read => IconName::ToolSearch,
acp::ToolKind::Edit => IconName::ToolPencil, acp::ToolKind::Edit => IconName::ToolPencil,
acp::ToolKind::Delete => IconName::ToolDeleteFile, acp::ToolKind::Delete => IconName::ToolDeleteFile,
acp::ToolKind::Move => IconName::ArrowRightLeft, acp::ToolKind::Move => IconName::ArrowRightLeft,
@ -1669,59 +1729,6 @@ impl AcpThreadView {
.size(IconSize::Small) .size(IconSize::Small)
.color(Color::Muted); .color(Color::Muted);
let base_container = h_flex().flex_shrink_0().size_4().justify_center();
if is_collapsible {
base_container
.child(
div()
.group_hover(&group_name, |s| s.invisible().w_0())
.child(tool_icon),
)
.child(
h_flex()
.absolute()
.inset_0()
.invisible()
.justify_center()
.group_hover(&group_name, |s| s.visible())
.child(
Disclosure::new(("expand", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronRight)
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
})),
),
)
} else {
base_container.child(tool_icon)
}
}
fn render_tool_call(
&self,
entry_ix: usize,
tool_call: &ToolCall,
window: &Window,
cx: &Context<Self>,
) -> Div {
let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix));
let card_header_id = SharedString::from("inner-tool-call-header");
let in_progress = match &tool_call.status {
ToolCallStatus::InProgress => true,
_ => false,
};
let failed_or_canceled = match &tool_call.status { let failed_or_canceled = match &tool_call.status {
ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true,
_ => false, _ => false,
@ -1821,6 +1828,7 @@ impl AcpThreadView {
.child( .child(
h_flex() h_flex()
.id(header_id) .id(header_id)
.group(&card_header_id)
.relative() .relative()
.w_full() .w_full()
.max_w_full() .max_w_full()
@ -1838,19 +1846,11 @@ impl AcpThreadView {
}) })
.child( .child(
h_flex() h_flex()
.group(&card_header_id)
.relative() .relative()
.w_full() .w_full()
.h(window.line_height() - px(2.)) .h(window.line_height() - px(2.))
.text_size(self.tool_name_font_size()) .text_size(self.tool_name_font_size())
.child(self.render_tool_call_icon( .child(tool_icon)
card_header_id,
entry_ix,
is_collapsible,
is_open,
tool_call,
cx,
))
.child(if tool_call.locations.len() == 1 { .child(if tool_call.locations.len() == 1 {
let name = tool_call.locations[0] let name = tool_call.locations[0]
.path .path
@ -1878,13 +1878,13 @@ impl AcpThreadView {
}) })
.child(name) .child(name)
.tooltip(Tooltip::text("Jump to File")) .tooltip(Tooltip::text("Jump to File"))
.cursor(gpui::CursorStyle::PointingHand)
.on_click(cx.listener(move |this, _, window, cx| { .on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx); this.open_tool_call_location(entry_ix, 0, window, cx);
})) }))
.into_any_element() .into_any_element()
} else { } else {
h_flex() h_flex()
.id("non-card-label-container")
.relative() .relative()
.w_full() .w_full()
.max_w_full() .max_w_full()
@ -1895,47 +1895,39 @@ impl AcpThreadView {
default_markdown_style(false, true, window, cx), default_markdown_style(false, true, window, cx),
))) )))
.child(gradient_overlay(gradient_color)) .child(gradient_overlay(gradient_color))
.on_click(cx.listener({
let id = tool_call.id.clone();
move |this: &mut Self, _, _, cx: &mut Context<Self>| {
if is_open {
this.expanded_tool_calls.remove(&id);
} else {
this.expanded_tool_calls.insert(id.clone());
}
cx.notify();
}
}))
.into_any() .into_any()
}), }),
) )
.when(in_progress && use_card_layout && !is_open, |this| { .child(
this.child( h_flex()
div().absolute().right_2().child( .gap_px()
Icon::new(IconName::ArrowCircle) .when(is_collapsible, |this| {
.color(Color::Muted) this.child(
.size(IconSize::Small) Disclosure::new(("expand", entry_ix), is_open)
.with_animation( .opened_icon(IconName::ChevronUp)
"running", .closed_icon(IconName::ChevronDown)
Animation::new(Duration::from_secs(3)).repeat(), .visible_on_hover(&card_header_id)
|icon, delta| { .on_click(cx.listener({
icon.transform(Transformation::rotate(percentage( let id = tool_call.id.clone();
delta, move |this: &mut Self, _, _, cx: &mut Context<Self>| {
))) if is_open {
}, this.expanded_tool_calls.remove(&id);
), } else {
), this.expanded_tool_calls.insert(id.clone());
) }
}) cx.notify();
.when(failed_or_canceled, |this| { }
this.child( })),
div().absolute().right_2().child( )
Icon::new(IconName::Close) })
.color(Color::Error) .when(failed_or_canceled, |this| {
.size(IconSize::Small), this.child(
), Icon::new(IconName::Close)
) .color(Color::Error)
}), .size(IconSize::Small),
)
}),
),
) )
.children(tool_output_display) .children(tool_output_display)
} }
@ -2005,9 +1997,27 @@ impl AcpThreadView {
cx: &Context<Self>, cx: &Context<Self>,
) -> AnyElement { ) -> AnyElement {
let uri: SharedString = resource_link.uri.clone().into(); let uri: SharedString = resource_link.uri.clone().into();
let is_file = resource_link.uri.strip_prefix("file://");
let label: SharedString = if let Some(path) = resource_link.uri.strip_prefix("file://") { let label: SharedString = if let Some(abs_path) = is_file {
path.to_string().into() if let Some(project_path) = self
.project
.read(cx)
.project_path_for_absolute_path(&Path::new(abs_path), cx)
&& let Some(worktree) = self
.project
.read(cx)
.worktree_for_id(project_path.worktree_id, cx)
{
worktree
.read(cx)
.full_path(&project_path.path)
.to_string_lossy()
.to_string()
.into()
} else {
abs_path.to_string().into()
}
} else { } else {
uri.clone() uri.clone()
}; };
@ -2024,10 +2034,12 @@ impl AcpThreadView {
Button::new(button_id, label) Button::new(button_id, label)
.label_size(LabelSize::Small) .label_size(LabelSize::Small)
.color(Color::Muted) .color(Color::Muted)
.icon(IconName::ArrowUpRight)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.truncate(true) .truncate(true)
.when(is_file.is_none(), |this| {
this.icon(IconName::ArrowUpRight)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
})
.on_click(cx.listener({ .on_click(cx.listener({
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
move |_, _, window, cx: &mut Context<Self>| { move |_, _, window, cx: &mut Context<Self>| {
@ -2086,11 +2098,12 @@ impl AcpThreadView {
let tool_call_id = tool_call_id.clone(); let tool_call_id = tool_call_id.clone();
let option_id = option.id.clone(); let option_id = option.id.clone();
let option_kind = option.kind; let option_kind = option.kind;
move |this, _, _, cx| { move |this, _, window, cx| {
this.authorize_tool_call( this.authorize_tool_call(
tool_call_id.clone(), tool_call_id.clone(),
option_id.clone(), option_id.clone(),
option_kind, option_kind,
window,
cx, cx,
); );
} }
@ -2735,6 +2748,12 @@ impl AcpThreadView {
.on_click({ .on_click({
let method_id = method.id.clone(); let method_id = method.id.clone();
cx.listener(move |this, _, window, cx| { cx.listener(move |this, _, window, cx| {
telemetry::event!(
"Authenticate Agent Started",
agent = this.agent.telemetry_id(),
method = method_id
);
this.authenticate(method_id.clone(), window, cx) this.authenticate(method_id.clone(), window, cx)
}) })
}) })
@ -2763,6 +2782,8 @@ impl AcpThreadView {
.icon_color(Color::Muted) .icon_color(Color::Muted)
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.on_click(cx.listener(move |this, _, window, cx| { .on_click(cx.listener(move |this, _, window, cx| {
telemetry::event!("Agent Install CLI", agent = this.agent.telemetry_id());
let task = this let task = this
.workspace .workspace
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {
@ -2770,7 +2791,7 @@ impl AcpThreadView {
let cwd = project.first_project_directory(cx); let cwd = project.first_project_directory(cx);
let shell = project.terminal_settings(&cwd, cx).shell.clone(); let shell = project.terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal { let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId("install".to_string()), id: task::TaskId(install_command.clone()),
full_label: install_command.clone(), full_label: install_command.clone(),
label: install_command.clone(), label: install_command.clone(),
command: Some(install_command.clone()), command: Some(install_command.clone()),
@ -2820,6 +2841,8 @@ impl AcpThreadView {
.icon_color(Color::Muted) .icon_color(Color::Muted)
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.on_click(cx.listener(move |this, _, window, cx| { .on_click(cx.listener(move |this, _, window, cx| {
telemetry::event!("Agent Upgrade CLI", agent = this.agent.telemetry_id());
let task = this let task = this
.workspace .workspace
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {
@ -2827,7 +2850,7 @@ impl AcpThreadView {
let cwd = project.first_project_directory(cx); let cwd = project.first_project_directory(cx);
let shell = project.terminal_settings(&cwd, cx).shell.clone(); let shell = project.terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal { let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId("upgrade".to_string()), id: task::TaskId(upgrade_command.to_string()),
full_label: upgrade_command.clone(), full_label: upgrade_command.clone(),
label: upgrade_command.clone(), label: upgrade_command.clone(),
command: Some(upgrade_command.clone()), command: Some(upgrade_command.clone()),
@ -3643,13 +3666,53 @@ impl AcpThreadView {
} }
} }
fn is_following(&self, cx: &App) -> bool {
match self.thread().map(|thread| thread.read(cx).status()) {
Some(ThreadStatus::Generating) => self
.workspace
.read_with(cx, |workspace, _| {
workspace.is_being_followed(CollaboratorId::Agent)
})
.unwrap_or(false),
_ => self.should_be_following,
}
}
fn toggle_following(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let following = self.is_following(cx);
self.should_be_following = !following;
if self.thread().map(|thread| thread.read(cx).status()) == Some(ThreadStatus::Generating) {
self.workspace
.update(cx, |workspace, cx| {
if following {
workspace.unfollow(CollaboratorId::Agent, window, cx);
} else {
workspace.follow(CollaboratorId::Agent, window, cx);
}
})
.ok();
}
telemetry::event!("Follow Agent Selected", following = !following);
}
fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_follow_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
let following = self let following = self.is_following(cx);
.workspace
.read_with(cx, |workspace, _| { let tooltip_label = if following {
workspace.is_being_followed(CollaboratorId::Agent) if self.agent.name() == "Zed Agent" {
}) format!("Stop Following the {}", self.agent.name())
.unwrap_or(false); } else {
format!("Stop Following {}", self.agent.name())
}
} else {
if self.agent.name() == "Zed Agent" {
format!("Follow the {}", self.agent.name())
} else {
format!("Follow {}", self.agent.name())
}
};
IconButton::new("follow-agent", IconName::Crosshair) IconButton::new("follow-agent", IconName::Crosshair)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
@ -3658,10 +3721,10 @@ impl AcpThreadView {
.selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor)))
.tooltip(move |window, cx| { .tooltip(move |window, cx| {
if following { if following {
Tooltip::for_action("Stop Following Agent", &Follow, window, cx) Tooltip::for_action(tooltip_label.clone(), &Follow, window, cx)
} else { } else {
Tooltip::with_meta( Tooltip::with_meta(
"Follow Agent", tooltip_label.clone(),
Some(&Follow), Some(&Follow),
"Track the agent's location as it reads and edits files.", "Track the agent's location as it reads and edits files.",
window, window,
@ -3670,15 +3733,7 @@ impl AcpThreadView {
} }
}) })
.on_click(cx.listener(move |this, _, window, cx| { .on_click(cx.listener(move |this, _, window, cx| {
this.workspace this.toggle_following(window, cx);
.update(cx, |workspace, cx| {
if following {
workspace.unfollow(CollaboratorId::Agent, window, cx);
} else {
workspace.follow(CollaboratorId::Agent, window, cx);
}
})
.ok();
})) }))
} }
@ -4090,7 +4145,20 @@ impl AcpThreadView {
} }
} }
fn render_thread_controls(&self, cx: &Context<Self>) -> impl IntoElement { fn render_thread_controls(
&self,
thread: &Entity<AcpThread>,
cx: &Context<Self>,
) -> impl IntoElement {
let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
if is_generating {
return h_flex().id("thread-controls-container").ml_1().child(
div()
.py_2()
.px(rems_from_px(22.))
.child(SpinnerLabel::new().size(LabelSize::Small)),
);
}
let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
.shape(ui::IconButtonShape::Square) .shape(ui::IconButtonShape::Square)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
@ -4696,6 +4764,24 @@ impl AcpThreadView {
})) }))
} }
pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let agent = self.agent.clone();
let ThreadState::Ready { thread, .. } = &self.thread_state else {
return;
};
let connection = thread.read(cx).connection().clone();
let err = AuthRequired {
description: None,
provider_id: None,
};
self.clear_thread_error(cx);
let this = cx.weak_entity();
window.defer(cx, |window, cx| {
Self::handle_auth_required(this, err, agent, connection, window, cx);
})
}
fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement { fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("upgrade", "Upgrade") Button::new("upgrade", "Upgrade")
.label_size(LabelSize::Small) .label_size(LabelSize::Small)
@ -4796,45 +4882,30 @@ impl Render for AcpThreadView {
.items_center() .items_center()
.justify_end() .justify_end()
.child(self.render_load_error(e, cx)), .child(self.render_load_error(e, cx)),
ThreadState::Ready { thread, .. } => { ThreadState::Ready { .. } => v_flex().flex_1().map(|this| {
let thread_clone = thread.clone(); if has_messages {
this.child(
v_flex().flex_1().map(|this| { list(
if has_messages { self.list_state.clone(),
this.child( cx.processor(|this, index: usize, window, cx| {
list( let Some((entry, len)) = this.thread().and_then(|thread| {
self.list_state.clone(), let entries = &thread.read(cx).entries();
cx.processor(|this, index: usize, window, cx| { Some((entries.get(index)?, entries.len()))
let Some((entry, len)) = this.thread().and_then(|thread| { }) else {
let entries = &thread.read(cx).entries(); return Empty.into_any();
Some((entries.get(index)?, entries.len())) };
}) else { this.render_entry(index, len, entry, window, cx)
return Empty.into_any(); }),
};
this.render_entry(index, len, entry, window, cx)
}),
)
.with_sizing_behavior(gpui::ListSizingBehavior::Auto)
.flex_grow()
.into_any(),
) )
.child(self.render_vertical_scrollbar(cx)) .with_sizing_behavior(gpui::ListSizingBehavior::Auto)
.children( .flex_grow()
match thread_clone.read(cx).status() { .into_any(),
ThreadStatus::Idle )
| ThreadStatus::WaitingForToolConfirmation => None, .child(self.render_vertical_scrollbar(cx))
ThreadStatus::Generating => div() } else {
.py_2() this.child(self.render_recent_history(window, cx))
.px(rems_from_px(22.)) }
.child(SpinnerLabel::new().size(LabelSize::Small)) }),
.into(),
},
)
} else {
this.child(self.render_recent_history(window, cx))
}
})
}
}) })
// The activity bar is intentionally rendered outside of the ThreadState::Ready match // The activity bar is intentionally rendered outside of the ThreadState::Ready match
// above so that the scrollbar doesn't render behind it. The current setup allows // above so that the scrollbar doesn't render behind it. The current setup allows
@ -5251,6 +5322,10 @@ pub(crate) mod tests {
where where
C: 'static + AgentConnection + Send + Clone, C: 'static + AgentConnection + Send + Clone,
{ {
fn telemetry_id(&self) -> &'static str {
"test"
}
fn logo(&self) -> ui::IconName { fn logo(&self) -> ui::IconName {
ui::IconName::Ai ui::IconName::Ai
} }
@ -5299,6 +5374,12 @@ pub(crate) mod tests {
project, project,
action_log, action_log,
SessionId("test".into()), SessionId("test".into()),
watch::Receiver::constant(acp::PromptCapabilities {
image: true,
audio: true,
embedded_context: true,
}),
cx,
) )
}))) })))
} }
@ -5307,14 +5388,6 @@ pub(crate) mod tests {
&[] &[]
} }
fn prompt_capabilities(&self) -> acp::PromptCapabilities {
acp::PromptCapabilities {
image: true,
audio: true,
embedded_context: true,
}
}
fn authenticate( fn authenticate(
&self, &self,
_method_id: acp::AuthMethodId, _method_id: acp::AuthMethodId,

View file

@ -5,6 +5,7 @@ mod tool_picker;
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini};
use agent_settings::AgentSettings; use agent_settings::AgentSettings;
use assistant_tool::{ToolSource, ToolWorkingSet}; use assistant_tool::{ToolSource, ToolWorkingSet};
use cloud_llm_client::Plan; use cloud_llm_client::Plan;
@ -15,7 +16,7 @@ use extension_host::ExtensionStore;
use fs::Fs; use fs::Fs;
use gpui::{ use gpui::{
Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle,
Focusable, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage,
}; };
use language::LanguageRegistry; use language::LanguageRegistry;
use language_model::{ use language_model::{
@ -23,10 +24,11 @@ use language_model::{
}; };
use notifications::status_toast::{StatusToast, ToastIcon}; use notifications::status_toast::{StatusToast, ToastIcon};
use project::{ use project::{
Project,
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
project_settings::{ContextServerSettings, ProjectSettings}, project_settings::{ContextServerSettings, ProjectSettings},
}; };
use settings::{Settings, update_settings_file}; use settings::{Settings, SettingsStore, update_settings_file};
use ui::{ use ui::{
Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*, Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*,
@ -39,7 +41,7 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
pub(crate) use manage_profiles_modal::ManageProfilesModal; pub(crate) use manage_profiles_modal::ManageProfilesModal;
use crate::{ use crate::{
AddContextServer, AddContextServer, ExternalAgent, NewExternalAgentThread,
agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
}; };
@ -47,6 +49,7 @@ pub struct AgentConfiguration {
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>, configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
context_server_store: Entity<ContextServerStore>, context_server_store: Entity<ContextServerStore>,
@ -56,6 +59,8 @@ pub struct AgentConfiguration {
_registry_subscription: Subscription, _registry_subscription: Subscription,
scroll_handle: ScrollHandle, scroll_handle: ScrollHandle,
scrollbar_state: ScrollbarState, scrollbar_state: ScrollbarState,
gemini_is_installed: bool,
_check_for_gemini: Task<()>,
} }
impl AgentConfiguration { impl AgentConfiguration {
@ -65,6 +70,7 @@ impl AgentConfiguration {
tools: Entity<ToolWorkingSet>, tools: Entity<ToolWorkingSet>,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
@ -89,6 +95,11 @@ impl AgentConfiguration {
cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
.detach(); .detach();
cx.observe_global_in::<SettingsStore>(window, |this, _, cx| {
this.check_for_gemini(cx);
cx.notify();
})
.detach();
let scroll_handle = ScrollHandle::new(); let scroll_handle = ScrollHandle::new();
let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
@ -97,6 +108,7 @@ impl AgentConfiguration {
fs, fs,
language_registry, language_registry,
workspace, workspace,
project,
focus_handle, focus_handle,
configuration_views_by_provider: HashMap::default(), configuration_views_by_provider: HashMap::default(),
context_server_store, context_server_store,
@ -106,8 +118,11 @@ impl AgentConfiguration {
_registry_subscription: registry_subscription, _registry_subscription: registry_subscription,
scroll_handle, scroll_handle,
scrollbar_state, scrollbar_state,
gemini_is_installed: false,
_check_for_gemini: Task::ready(()),
}; };
this.build_provider_configuration_views(window, cx); this.build_provider_configuration_views(window, cx);
this.check_for_gemini(cx);
this this
} }
@ -137,6 +152,34 @@ impl AgentConfiguration {
self.configuration_views_by_provider self.configuration_views_by_provider
.insert(provider.id(), configuration_view); .insert(provider.id(), configuration_view);
} }
fn check_for_gemini(&mut self, cx: &mut Context<Self>) {
let project = self.project.clone();
let settings = AllAgentServersSettings::get_global(cx).clone();
self._check_for_gemini = cx.spawn({
async move |this, cx| {
let Some(project) = project.upgrade() else {
return;
};
let gemini_is_installed = AgentServerCommand::resolve(
Gemini::binary_name(),
&[],
// TODO expose fallback path from the Gemini/CC types so we don't have to hardcode it again here
None,
settings.gemini,
&project,
cx,
)
.await
.is_some();
this.update(cx, |this, cx| {
this.gemini_is_installed = gemini_is_installed;
cx.notify();
})
.ok();
}
});
}
} }
impl Focusable for AgentConfiguration { impl Focusable for AgentConfiguration {
@ -211,7 +254,6 @@ impl AgentConfiguration {
.child( .child(
h_flex() h_flex()
.id(provider_id_string.clone()) .id(provider_id_string.clone())
.cursor_pointer()
.px_2() .px_2()
.py_0p5() .py_0p5()
.w_full() .w_full()
@ -231,10 +273,7 @@ impl AgentConfiguration {
h_flex() h_flex()
.w_full() .w_full()
.gap_1() .gap_1()
.child( .child(Label::new(provider_name.clone()))
Label::new(provider_name.clone())
.size(LabelSize::Large),
)
.map(|this| { .map(|this| {
if is_zed_provider && is_signed_in { if is_zed_provider && is_signed_in {
this.child( this.child(
@ -279,7 +318,7 @@ impl AgentConfiguration {
"Start New Thread", "Start New Thread",
) )
.icon_position(IconPosition::Start) .icon_position(IconPosition::Start)
.icon(IconName::Plus) .icon(IconName::Thread)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.label_size(LabelSize::Small) .label_size(LabelSize::Small)
@ -378,7 +417,7 @@ impl AgentConfiguration {
), ),
) )
.child( .child(
Label::new("Add at least one provider to use AI-powered features.") Label::new("Add at least one provider to use AI-powered features with Zed's native agent.")
.color(Color::Muted), .color(Color::Muted),
), ),
), ),
@ -519,6 +558,14 @@ impl AgentConfiguration {
} }
} }
fn card_item_bg_color(&self, cx: &mut Context<Self>) -> Hsla {
cx.theme().colors().background.opacity(0.25)
}
fn card_item_border_color(&self, cx: &mut Context<Self>) -> Hsla {
cx.theme().colors().border.opacity(0.6)
}
fn render_context_servers_section( fn render_context_servers_section(
&mut self, &mut self,
window: &mut Window, window: &mut Window,
@ -536,7 +583,12 @@ impl AgentConfiguration {
v_flex() v_flex()
.gap_0p5() .gap_0p5()
.child(Headline::new("Model Context Protocol (MCP) Servers")) .child(Headline::new("Model Context Protocol (MCP) Servers"))
.child(Label::new("Connect to context servers through the Model Context Protocol, either using Zed extensions or directly.").color(Color::Muted)), .child(
Label::new(
"All context servers connected through the Model Context Protocol.",
)
.color(Color::Muted),
),
) )
.children( .children(
context_server_ids.into_iter().map(|context_server_id| { context_server_ids.into_iter().map(|context_server_id| {
@ -546,7 +598,7 @@ impl AgentConfiguration {
.child( .child(
h_flex() h_flex()
.justify_between() .justify_between()
.gap_2() .gap_1p5()
.child( .child(
h_flex().w_full().child( h_flex().w_full().child(
Button::new("add-context-server", "Add Custom Server") Button::new("add-context-server", "Add Custom Server")
@ -637,8 +689,6 @@ impl AgentConfiguration {
.map_or([].as_slice(), |tools| tools.as_slice()); .map_or([].as_slice(), |tools| tools.as_slice());
let tool_count = tools.len(); let tool_count = tools.len();
let border_color = cx.theme().colors().border.opacity(0.6);
let (source_icon, source_tooltip) = if is_from_extension { let (source_icon, source_tooltip) = if is_from_extension {
( (
IconName::ZedMcpExtension, IconName::ZedMcpExtension,
@ -781,8 +831,8 @@ impl AgentConfiguration {
.id(item_id.clone()) .id(item_id.clone())
.border_1() .border_1()
.rounded_md() .rounded_md()
.border_color(border_color) .border_color(self.card_item_border_color(cx))
.bg(cx.theme().colors().background.opacity(0.2)) .bg(self.card_item_bg_color(cx))
.overflow_hidden() .overflow_hidden()
.child( .child(
h_flex() h_flex()
@ -790,7 +840,11 @@ impl AgentConfiguration {
.justify_between() .justify_between()
.when( .when(
error.is_some() || are_tools_expanded && tool_count >= 1, error.is_some() || are_tools_expanded && tool_count >= 1,
|element| element.border_b_1().border_color(border_color), |element| {
element
.border_b_1()
.border_color(self.card_item_border_color(cx))
},
) )
.child( .child(
h_flex() h_flex()
@ -972,6 +1026,166 @@ impl AgentConfiguration {
)) ))
}) })
} }
fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let settings = AllAgentServersSettings::get_global(cx).clone();
let user_defined_agents = settings
.custom
.iter()
.map(|(name, settings)| {
self.render_agent_server(
IconName::Ai,
name.clone(),
ExternalAgent::Custom {
name: name.clone(),
settings: settings.clone(),
},
None,
cx,
)
.into_any_element()
})
.collect::<Vec<_>>();
v_flex()
.border_b_1()
.border_color(cx.theme().colors().border)
.child(
v_flex()
.p(DynamicSpacing::Base16.rems(cx))
.pr(DynamicSpacing::Base20.rems(cx))
.gap_2()
.child(
v_flex()
.gap_0p5()
.child(Headline::new("External Agents"))
.child(
Label::new(
"Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.",
)
.color(Color::Muted),
),
)
.child(self.render_agent_server(
IconName::AiGemini,
"Gemini CLI",
ExternalAgent::Gemini,
(!self.gemini_is_installed).then_some(Gemini::install_command().into()),
cx,
))
// TODO add CC
.children(user_defined_agents),
)
}
fn render_agent_server(
&self,
icon: IconName,
name: impl Into<SharedString>,
agent: ExternalAgent,
install_command: Option<SharedString>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = name.into();
h_flex()
.p_1()
.pl_2()
.gap_1p5()
.justify_between()
.border_1()
.rounded_md()
.border_color(self.card_item_border_color(cx))
.bg(self.card_item_bg_color(cx))
.overflow_hidden()
.child(
h_flex()
.gap_1p5()
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
.child(Label::new(name.clone())),
)
.map(|this| {
if let Some(install_command) = install_command {
this.child(
Button::new(
SharedString::from(format!("install_external_agent-{name}")),
"Install Agent",
)
.label_size(LabelSize::Small)
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.tooltip(Tooltip::text(install_command.clone()))
.on_click(cx.listener(
move |this, _, window, cx| {
let Some(project) = this.project.upgrade() else {
return;
};
let Some(workspace) = this.workspace.upgrade() else {
return;
};
let cwd = project.read(cx).first_project_directory(cx);
let shell =
project.read(cx).terminal_settings(&cwd, cx).shell.clone();
let spawn_in_terminal = task::SpawnInTerminal {
id: task::TaskId(install_command.to_string()),
full_label: install_command.to_string(),
label: install_command.to_string(),
command: Some(install_command.to_string()),
args: Vec::new(),
command_label: install_command.to_string(),
cwd,
env: Default::default(),
use_new_terminal: true,
allow_concurrent_runs: true,
reveal: Default::default(),
reveal_target: Default::default(),
hide: Default::default(),
shell,
show_summary: true,
show_command: true,
show_rerun: false,
};
let task = workspace.update(cx, |workspace, cx| {
workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
});
cx.spawn(async move |this, cx| {
task.await;
this.update(cx, |this, cx| {
this.check_for_gemini(cx);
})
.ok();
})
.detach();
},
)),
)
} else {
this.child(
h_flex().gap_1().child(
Button::new(
SharedString::from(format!("start_acp_thread-{name}")),
"Start New Thread",
)
.label_size(LabelSize::Small)
.icon(IconName::Thread)
.icon_position(IconPosition::Start)
.icon_size(IconSize::XSmall)
.icon_color(Color::Muted)
.on_click(move |_, window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(agent.clone()),
}
.boxed_clone(),
cx,
);
}),
),
)
}
})
}
} }
impl Render for AgentConfiguration { impl Render for AgentConfiguration {
@ -991,6 +1205,7 @@ impl Render for AgentConfiguration {
.size_full() .size_full()
.overflow_y_scroll() .overflow_y_scroll()
.child(self.render_general_settings_section(cx)) .child(self.render_general_settings_section(cx))
.child(self.render_agent_servers_section(cx))
.child(self.render_context_servers_section(window, cx)) .child(self.render_context_servers_section(window, cx))
.child(self.render_provider_configuration_section(cx)), .child(self.render_provider_configuration_section(cx)),
) )

View file

@ -1529,6 +1529,7 @@ impl AgentDiff {
| AcpThreadEvent::TokenUsageUpdated | AcpThreadEvent::TokenUsageUpdated
| AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::EntriesRemoved(_)
| AcpThreadEvent::ToolAuthorizationRequired | AcpThreadEvent::ToolAuthorizationRequired
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::Retry(_) => {} | AcpThreadEvent::Retry(_) => {}
} }
} }

View file

@ -9,6 +9,8 @@ use agent_servers::AgentServerSettings;
use agent2::{DbThreadMetadata, HistoryEntry}; use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE}; use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use zed_actions::OpenBrowser;
use zed_actions::agent::ReauthenticateAgent;
use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; use crate::acp::{AcpThreadHistory, ThreadHistoryEvent};
use crate::agent_diff::AgentDiffThread; use crate::agent_diff::AgentDiffThread;
@ -247,6 +249,7 @@ enum WhichFontSize {
None, None,
} }
// TODO unify this with ExternalAgent
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub enum AgentType { pub enum AgentType {
#[default] #[default]
@ -1031,6 +1034,8 @@ impl AgentPanel {
} }
fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
telemetry::event!("Agent Thread Started", agent = "zed-text");
let context = self let context = self
.context_store .context_store
.update(cx, |context_store, cx| context_store.create(cx)); .update(cx, |context_store, cx| context_store.create(cx));
@ -1123,6 +1128,8 @@ impl AgentPanel {
} }
}; };
telemetry::event!("Agent Thread Started", agent = ext_agent.name());
let server = ext_agent.server(fs, history); let server = ext_agent.server(fs, history);
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
@ -1480,6 +1487,7 @@ impl AgentPanel {
tools, tools,
self.language_registry.clone(), self.language_registry.clone(),
self.workspace.clone(), self.workspace.clone(),
self.project.downgrade(),
window, window,
cx, cx,
) )
@ -2211,6 +2219,8 @@ impl AgentPanel {
"Enable Full Screen" "Enable Full Screen"
}; };
let selected_agent = self.selected_agent.clone();
PopoverMenu::new("agent-options-menu") PopoverMenu::new("agent-options-menu")
.trigger_with_tooltip( .trigger_with_tooltip(
IconButton::new("agent-options-menu", IconName::Ellipsis) IconButton::new("agent-options-menu", IconName::Ellipsis)
@ -2290,6 +2300,11 @@ impl AgentPanel {
.action("Settings", Box::new(OpenSettings)) .action("Settings", Box::new(OpenSettings))
.separator() .separator()
.action(full_screen_label, Box::new(ToggleZoom)); .action(full_screen_label, Box::new(ToggleZoom));
if selected_agent == AgentType::Gemini {
menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
}
menu menu
})) }))
} }
@ -2324,6 +2339,8 @@ impl AgentPanel {
.menu({ .menu({
let menu = self.assistant_navigation_menu.clone(); let menu = self.assistant_navigation_menu.clone();
move |window, cx| { move |window, cx| {
telemetry::event!("View Thread History Clicked");
if let Some(menu) = menu.as_ref() { if let Some(menu) = menu.as_ref() {
menu.update(cx, |_, cx| { menu.update(cx, |_, cx| {
cx.defer_in(window, |menu, window, cx| { cx.defer_in(window, |menu, window, cx| {
@ -2502,6 +2519,8 @@ impl AgentPanel {
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
move |window, cx| { move |window, cx| {
telemetry::event!("New Thread Clicked");
let active_thread = active_thread.clone(); let active_thread = active_thread.clone();
Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { Some(ContextMenu::build(window, cx, |mut menu, _window, cx| {
menu = menu menu = menu
@ -2678,6 +2697,15 @@ impl AgentPanel {
} }
menu menu
})
.when(cx.has_flag::<GeminiAndNativeFeatureFlag>(), |menu| {
menu.separator().link(
"Add Your Own Agent",
OpenBrowser {
url: "https://agentclientprotocol.com/".into(),
}
.boxed_clone(),
)
}); });
menu menu
})) }))
@ -3758,6 +3786,11 @@ impl Render for AgentPanel {
} }
})) }))
.on_action(cx.listener(Self::toggle_burn_mode)) .on_action(cx.listener(Self::toggle_burn_mode))
.on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
if let Some(thread_view) = this.active_thread_view() {
thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx))
}
}))
.child(self.render_toolbar(window, cx)) .child(self.render_toolbar(window, cx))
.children(self.render_onboarding(window, cx)) .children(self.render_onboarding(window, cx))
.map(|parent| match &self.active_view { .map(|parent| match &self.active_view {

View file

@ -160,6 +160,7 @@ pub struct NewNativeAgentThreadFromSummary {
from_session_id: agent_client_protocol::SessionId, from_session_id: agent_client_protocol::SessionId,
} }
// TODO unify this with AgentType
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum ExternalAgent { enum ExternalAgent {
@ -174,6 +175,15 @@ enum ExternalAgent {
} }
impl ExternalAgent { impl ExternalAgent {
fn name(&self) -> &'static str {
match self {
Self::NativeAgent => "zed",
Self::Gemini => "gemini-cli",
Self::ClaudeCode => "claude-code",
Self::Custom { .. } => "custom",
}
}
pub fn server( pub fn server(
&self, &self,
fs: Arc<dyn fs::Fs>, fs: Arc<dyn fs::Fs>,

View file

@ -361,6 +361,7 @@ impl TextThreadEditor {
if self.sending_disabled(cx) { if self.sending_disabled(cx) {
return; return;
} }
telemetry::event!("Agent Message Sent", agent = "zed-text");
self.send_to_model(window, cx); self.send_to_model(window, cx);
} }

View file

@ -12,11 +12,11 @@ use crate::{SignInStatus, YoungAccountBanner, plan_definitions::PlanDefinitions}
#[derive(IntoElement, RegisterComponent)] #[derive(IntoElement, RegisterComponent)]
pub struct AiUpsellCard { pub struct AiUpsellCard {
pub sign_in_status: SignInStatus, sign_in_status: SignInStatus,
pub sign_in: Arc<dyn Fn(&mut Window, &mut App)>, sign_in: Arc<dyn Fn(&mut Window, &mut App)>,
pub account_too_young: bool, account_too_young: bool,
pub user_plan: Option<Plan>, user_plan: Option<Plan>,
pub tab_index: Option<isize>, tab_index: Option<isize>,
} }
impl AiUpsellCard { impl AiUpsellCard {
@ -43,6 +43,11 @@ impl AiUpsellCard {
tab_index: None, tab_index: None,
} }
} }
pub fn tab_index(mut self, tab_index: Option<isize>) -> Self {
self.tab_index = tab_index;
self
}
} }
impl RenderOnce for AiUpsellCard { impl RenderOnce for AiUpsellCard {

View file

@ -118,7 +118,7 @@ impl Tool for FetchTool {
} }
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool { fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false true
} }
fn may_perform_edits(&self) -> bool { fn may_perform_edits(&self) -> bool {

View file

@ -435,8 +435,8 @@ mod test {
assert_eq!( assert_eq!(
matches, matches,
&[ &[
PathBuf::from("root/apple/banana/carrot"), PathBuf::from(path!("root/apple/banana/carrot")),
PathBuf::from("root/apple/bandana/carbonara") PathBuf::from(path!("root/apple/bandana/carbonara"))
] ]
); );
@ -447,8 +447,8 @@ mod test {
assert_eq!( assert_eq!(
matches, matches,
&[ &[
PathBuf::from("root/apple/banana/carrot"), PathBuf::from(path!("root/apple/banana/carrot")),
PathBuf::from("root/apple/bandana/carbonara") PathBuf::from(path!("root/apple/bandana/carbonara"))
] ]
); );
} }

View file

@ -68,7 +68,7 @@ impl Tool for ReadFileTool {
} }
fn icon(&self) -> IconName { fn icon(&self) -> IconName {
IconName::ToolRead IconName::ToolSearch
} }
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> { fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {

View file

@ -1,7 +1,10 @@
use anyhow::Result; use anyhow::Result;
use db::{ use db::{
define_connection, query, query,
sqlez::{bindable::Column, statement::Statement}, sqlez::{
bindable::Column, domain::Domain, statement::Statement,
thread_safe_connection::ThreadSafeConnection,
},
sqlez_macros::sql, sqlez_macros::sql,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -50,8 +53,11 @@ impl Column for SerializedCommandInvocation {
} }
} }
define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()> = pub struct CommandPaletteDB(ThreadSafeConnection);
&[sql!(
impl Domain for CommandPaletteDB {
const NAME: &str = stringify!(CommandPaletteDB);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE IF NOT EXISTS command_invocations( CREATE TABLE IF NOT EXISTS command_invocations(
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
command_name TEXT NOT NULL, command_name TEXT NOT NULL,
@ -59,7 +65,9 @@ define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()>
last_invoked INTEGER DEFAULT (unixepoch()) NOT NULL last_invoked INTEGER DEFAULT (unixepoch()) NOT NULL
) STRICT; ) STRICT;
)]; )];
); }
db::static_connection!(COMMAND_PALETTE_HISTORY, CommandPaletteDB, []);
impl CommandPaletteDB { impl CommandPaletteDB {
pub async fn write_command_invocation( pub async fn write_command_invocation(

View file

@ -110,11 +110,14 @@ pub async fn open_test_db<M: Migrator>(db_name: &str) -> ThreadSafeConnection {
} }
/// Implements a basic DB wrapper for a given domain /// Implements a basic DB wrapper for a given domain
///
/// Arguments:
/// - static variable name for connection
/// - type of connection wrapper
/// - dependencies, whose migrations should be run prior to this domain's migrations
#[macro_export] #[macro_export]
macro_rules! define_connection { macro_rules! static_connection {
(pub static ref $id:ident: $t:ident<()> = $migrations:expr; $($global:ident)?) => { ($id:ident, $t:ident, [ $($d:ty),* ] $(, $global:ident)?) => {
pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection);
impl ::std::ops::Deref for $t { impl ::std::ops::Deref for $t {
type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection; type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection;
@ -123,16 +126,6 @@ macro_rules! define_connection {
} }
} }
impl $crate::sqlez::domain::Domain for $t {
fn name() -> &'static str {
stringify!($t)
}
fn migrations() -> &'static [&'static str] {
$migrations
}
}
impl $t { impl $t {
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub async fn open_test_db(name: &'static str) -> Self { pub async fn open_test_db(name: &'static str) -> Self {
@ -142,7 +135,8 @@ macro_rules! define_connection {
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| {
$t($crate::smol::block_on($crate::open_test_db::<$t>(stringify!($id)))) #[allow(unused_parens)]
$t($crate::smol::block_on($crate::open_test_db::<($($d,)* $t)>(stringify!($id))))
}); });
#[cfg(not(any(test, feature = "test-support")))] #[cfg(not(any(test, feature = "test-support")))]
@ -153,46 +147,10 @@ macro_rules! define_connection {
} else { } else {
$crate::RELEASE_CHANNEL.dev_name() $crate::RELEASE_CHANNEL.dev_name()
}; };
$t($crate::smol::block_on($crate::open_db::<$t>(db_dir, scope))) #[allow(unused_parens)]
$t($crate::smol::block_on($crate::open_db::<($($d,)* $t)>(db_dir, scope)))
}); });
}; }
(pub static ref $id:ident: $t:ident<$($d:ty),+> = $migrations:expr; $($global:ident)?) => {
pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection);
impl ::std::ops::Deref for $t {
type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl $crate::sqlez::domain::Domain for $t {
fn name() -> &'static str {
stringify!($t)
}
fn migrations() -> &'static [&'static str] {
$migrations
}
}
#[cfg(any(test, feature = "test-support"))]
pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| {
$t($crate::smol::block_on($crate::open_test_db::<($($d),+, $t)>(stringify!($id))))
});
#[cfg(not(any(test, feature = "test-support")))]
pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| {
let db_dir = $crate::database_dir();
let scope = if false $(|| stringify!($global) == "global")? {
"global"
} else {
$crate::RELEASE_CHANNEL.dev_name()
};
$t($crate::smol::block_on($crate::open_db::<($($d),+, $t)>(db_dir, scope)))
});
};
} }
pub fn write_and_log<F>(cx: &App, db_write: impl FnOnce() -> F + Send + 'static) pub fn write_and_log<F>(cx: &App, db_write: impl FnOnce() -> F + Send + 'static)
@ -219,17 +177,12 @@ mod tests {
enum BadDB {} enum BadDB {}
impl Domain for BadDB { impl Domain for BadDB {
fn name() -> &'static str { const NAME: &str = "db_tests";
"db_tests" const MIGRATIONS: &[&str] = &[
} sql!(CREATE TABLE test(value);),
// failure because test already exists
fn migrations() -> &'static [&'static str] { sql!(CREATE TABLE test(value);),
&[ ];
sql!(CREATE TABLE test(value);),
// failure because test already exists
sql!(CREATE TABLE test(value);),
]
}
} }
let tempdir = tempfile::Builder::new() let tempdir = tempfile::Builder::new()
@ -251,25 +204,15 @@ mod tests {
enum CorruptedDB {} enum CorruptedDB {}
impl Domain for CorruptedDB { impl Domain for CorruptedDB {
fn name() -> &'static str { const NAME: &str = "db_tests";
"db_tests" const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)];
}
fn migrations() -> &'static [&'static str] {
&[sql!(CREATE TABLE test(value);)]
}
} }
enum GoodDB {} enum GoodDB {}
impl Domain for GoodDB { impl Domain for GoodDB {
fn name() -> &'static str { const NAME: &str = "db_tests"; //Notice same name
"db_tests" //Notice same name const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)];
}
fn migrations() -> &'static [&'static str] {
&[sql!(CREATE TABLE test2(value);)] //But different migration
}
} }
let tempdir = tempfile::Builder::new() let tempdir = tempfile::Builder::new()
@ -305,25 +248,16 @@ mod tests {
enum CorruptedDB {} enum CorruptedDB {}
impl Domain for CorruptedDB { impl Domain for CorruptedDB {
fn name() -> &'static str { const NAME: &str = "db_tests";
"db_tests"
}
fn migrations() -> &'static [&'static str] { const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)];
&[sql!(CREATE TABLE test(value);)]
}
} }
enum GoodDB {} enum GoodDB {}
impl Domain for GoodDB { impl Domain for GoodDB {
fn name() -> &'static str { const NAME: &str = "db_tests"; //Notice same name
"db_tests" //Notice same name const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)]; // But different migration
}
fn migrations() -> &'static [&'static str] {
&[sql!(CREATE TABLE test2(value);)] //But different migration
}
} }
let tempdir = tempfile::Builder::new() let tempdir = tempfile::Builder::new()

View file

@ -2,16 +2,26 @@ use gpui::App;
use sqlez_macros::sql; use sqlez_macros::sql;
use util::ResultExt as _; use util::ResultExt as _;
use crate::{define_connection, query, write_and_log}; use crate::{
query,
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
write_and_log,
};
define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> = pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnection);
&[sql!(
impl Domain for KeyValueStore {
const NAME: &str = stringify!(KeyValueStore);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE IF NOT EXISTS kv_store( CREATE TABLE IF NOT EXISTS kv_store(
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
) STRICT; ) STRICT;
)]; )];
); }
crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []);
pub trait Dismissable { pub trait Dismissable {
const KEY: &'static str; const KEY: &'static str;
@ -91,15 +101,19 @@ mod tests {
} }
} }
define_connection!(pub static ref GLOBAL_KEY_VALUE_STORE: GlobalKeyValueStore<()> = pub struct GlobalKeyValueStore(ThreadSafeConnection);
&[sql!(
impl Domain for GlobalKeyValueStore {
const NAME: &str = stringify!(GlobalKeyValueStore);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE IF NOT EXISTS kv_store( CREATE TABLE IF NOT EXISTS kv_store(
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
) STRICT; ) STRICT;
)]; )];
global }
);
crate::static_connection!(GLOBAL_KEY_VALUE_STORE, GlobalKeyValueStore, [], global);
impl GlobalKeyValueStore { impl GlobalKeyValueStore {
query! { query! {

View file

@ -1404,7 +1404,7 @@ impl ProjectItem for Editor {
} }
fn for_broken_project_item( fn for_broken_project_item(
abs_path: PathBuf, abs_path: &Path,
is_local: bool, is_local: bool,
e: &anyhow::Error, e: &anyhow::Error,
window: &mut Window, window: &mut Window,

View file

@ -1,13 +1,17 @@
use anyhow::Result; use anyhow::Result;
use db::sqlez::bindable::{Bind, Column, StaticColumnCount}; use db::{
use db::sqlez::statement::Statement; query,
sqlez::{
bindable::{Bind, Column, StaticColumnCount},
domain::Domain,
statement::Statement,
},
sqlez_macros::sql,
};
use fs::MTime; use fs::MTime;
use itertools::Itertools as _; use itertools::Itertools as _;
use std::path::PathBuf; use std::path::PathBuf;
use db::sqlez_macros::sql;
use db::{define_connection, query};
use workspace::{ItemId, WorkspaceDb, WorkspaceId}; use workspace::{ItemId, WorkspaceDb, WorkspaceId};
#[derive(Clone, Debug, PartialEq, Default)] #[derive(Clone, Debug, PartialEq, Default)]
@ -83,7 +87,11 @@ impl Column for SerializedEditor {
} }
} }
define_connection!( pub struct EditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection);
impl Domain for EditorDb {
const NAME: &str = stringify!(EditorDb);
// Current schema shape using pseudo-rust syntax: // Current schema shape using pseudo-rust syntax:
// editors( // editors(
// item_id: usize, // item_id: usize,
@ -113,7 +121,8 @@ define_connection!(
// start: usize, // start: usize,
// end: usize, // end: usize,
// ) // )
pub static ref DB: EditorDb<WorkspaceDb> = &[
const MIGRATIONS: &[&str] = &[
sql! ( sql! (
CREATE TABLE editors( CREATE TABLE editors(
item_id INTEGER NOT NULL, item_id INTEGER NOT NULL,
@ -189,7 +198,9 @@ define_connection!(
) STRICT; ) STRICT;
), ),
]; ];
); }
db::static_connection!(DB, EditorDb, [WorkspaceDb]);
// https://www.sqlite.org/limits.html // https://www.sqlite.org/limits.html
// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER, // > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,

View file

@ -9,10 +9,8 @@ use parking::Parker;
use parking_lot::Mutex; use parking_lot::Mutex;
use util::ResultExt; use util::ResultExt;
use windows::{ use windows::{
Foundation::TimeSpan,
System::Threading::{ System::Threading::{
ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemOptions, ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority,
WorkItemPriority,
}, },
Win32::{ Win32::{
Foundation::{LPARAM, WPARAM}, Foundation::{LPARAM, WPARAM},
@ -56,12 +54,7 @@ impl WindowsDispatcher {
Ok(()) Ok(())
}) })
}; };
ThreadPool::RunWithPriorityAndOptionsAsync( ThreadPool::RunWithPriorityAsync(&handler, WorkItemPriority::High).log_err();
&handler,
WorkItemPriority::High,
WorkItemOptions::TimeSliced,
)
.log_err();
} }
fn dispatch_on_threadpool_after(&self, runnable: Runnable, duration: Duration) { fn dispatch_on_threadpool_after(&self, runnable: Runnable, duration: Duration) {
@ -72,12 +65,7 @@ impl WindowsDispatcher {
Ok(()) Ok(())
}) })
}; };
let delay = TimeSpan { ThreadPoolTimer::CreateTimer(&handler, duration.into()).log_err();
// A time period expressed in 100-nanosecond units.
// 10,000,000 ticks per second
Duration: (duration.as_nanos() / 100) as i64,
};
ThreadPoolTimer::CreateTimer(&handler, delay).log_err();
} }
} }

View file

@ -401,12 +401,19 @@ pub fn init(cx: &mut App) {
mod persistence { mod persistence {
use std::path::PathBuf; use std::path::PathBuf;
use db::{define_connection, query, sqlez_macros::sql}; use db::{
query,
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use workspace::{ItemId, WorkspaceDb, WorkspaceId}; use workspace::{ItemId, WorkspaceDb, WorkspaceId};
define_connection! { pub struct ImageViewerDb(ThreadSafeConnection);
pub static ref IMAGE_VIEWER: ImageViewerDb<WorkspaceDb> =
&[sql!( impl Domain for ImageViewerDb {
const NAME: &str = stringify!(ImageViewerDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE image_viewers ( CREATE TABLE image_viewers (
workspace_id INTEGER, workspace_id INTEGER,
item_id INTEGER UNIQUE, item_id INTEGER UNIQUE,
@ -417,9 +424,11 @@ mod persistence {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE ON DELETE CASCADE
) STRICT; ) STRICT;
)]; )];
} }
db::static_connection!(IMAGE_VIEWER, ImageViewerDb, [WorkspaceDb]);
impl ImageViewerDb { impl ImageViewerDb {
query! { query! {
pub async fn save_image_path( pub async fn save_image_path(

View file

@ -1569,11 +1569,21 @@ impl Buffer {
self.send_operation(op, true, cx); self.send_operation(op, true, cx);
} }
pub fn get_diagnostics(&self, server_id: LanguageServerId) -> Option<&DiagnosticSet> { pub fn buffer_diagnostics(
let Ok(idx) = self.diagnostics.binary_search_by_key(&server_id, |v| v.0) else { &self,
return None; for_server: Option<LanguageServerId>,
}; ) -> Vec<&DiagnosticEntry<Anchor>> {
Some(&self.diagnostics[idx].1) match for_server {
Some(server_id) => match self.diagnostics.binary_search_by_key(&server_id, |v| v.0) {
Ok(idx) => self.diagnostics[idx].1.iter().collect(),
Err(_) => Vec::new(),
},
None => self
.diagnostics
.iter()
.flat_map(|(_, diagnostic_set)| diagnostic_set.iter())
.collect(),
}
} }
fn request_autoindent(&mut self, cx: &mut Context<Self>) { fn request_autoindent(&mut self, cx: &mut Context<Self>) {

View file

@ -1743,6 +1743,5 @@ pub enum Event {
} }
impl EventEmitter<Event> for LogStore {} impl EventEmitter<Event> for LogStore {}
impl EventEmitter<Event> for LspLogView {}
impl EventEmitter<EditorEvent> for LspLogView {} impl EventEmitter<EditorEvent> for LspLogView {}
impl EventEmitter<SearchEvent> for LspLogView {} impl EventEmitter<SearchEvent> for LspLogView {}

View file

@ -11,6 +11,21 @@
(#set! injection.language "css")) (#set! injection.language "css"))
) )
(call_expression
function: (member_expression
object: (identifier) @_obj (#eq? @_obj "styled")
property: (property_identifier))
arguments: (template_string (string_fragment) @injection.content
(#set! injection.language "css"))
)
(call_expression
function: (call_expression
function: (identifier) @_name (#eq? @_name "styled"))
arguments: (template_string (string_fragment) @injection.content
(#set! injection.language "css"))
)
(call_expression (call_expression
function: (identifier) @_name (#eq? @_name "html") function: (identifier) @_name (#eq? @_name "html")
arguments: (template_string) @injection.content arguments: (template_string) @injection.content

View file

@ -6,9 +6,6 @@
(self) @variable.special (self) @variable.special
(field_identifier) @property (field_identifier) @property
(shorthand_field_initializer
(identifier) @property)
(trait_item name: (type_identifier) @type.interface) (trait_item name: (type_identifier) @type.interface)
(impl_item trait: (type_identifier) @type.interface) (impl_item trait: (type_identifier) @type.interface)
(abstract_type trait: (type_identifier) @type.interface) (abstract_type trait: (type_identifier) @type.interface)
@ -41,20 +38,11 @@
(identifier) @function.special (identifier) @function.special
(scoped_identifier (scoped_identifier
name: (identifier) @function.special) name: (identifier) @function.special)
] ])
"!" @function.special)
(macro_definition (macro_definition
name: (identifier) @function.special.definition) name: (identifier) @function.special.definition)
(mod_item
name: (identifier) @module)
(visibility_modifier [
(crate) @keyword
(super) @keyword
])
; Identifier conventions ; Identifier conventions
; Assume uppercase names are types/enum-constructors ; Assume uppercase names are types/enum-constructors
@ -127,7 +115,9 @@
"where" "where"
"while" "while"
"yield" "yield"
(crate)
(mutable_specifier) (mutable_specifier)
(super)
] @keyword ] @keyword
[ [
@ -199,7 +189,6 @@
operator: "/" @operator operator: "/" @operator
(lifetime) @lifetime (lifetime) @lifetime
(lifetime (identifier) @lifetime)
(parameter (identifier) @variable.parameter) (parameter (identifier) @variable.parameter)

View file

@ -11,6 +11,21 @@
(#set! injection.language "css")) (#set! injection.language "css"))
) )
(call_expression
function: (member_expression
object: (identifier) @_obj (#eq? @_obj "styled")
property: (property_identifier))
arguments: (template_string (string_fragment) @injection.content
(#set! injection.language "css"))
)
(call_expression
function: (call_expression
function: (identifier) @_name (#eq? @_name "styled"))
arguments: (template_string (string_fragment) @injection.content
(#set! injection.language "css"))
)
(call_expression (call_expression
function: (identifier) @_name (#eq? @_name "html") function: (identifier) @_name (#eq? @_name "html")
arguments: (template_string (string_fragment) @injection.content arguments: (template_string (string_fragment) @injection.content

View file

@ -15,6 +15,21 @@
(#set! injection.language "css")) (#set! injection.language "css"))
) )
(call_expression
function: (member_expression
object: (identifier) @_obj (#eq? @_obj "styled")
property: (property_identifier))
arguments: (template_string (string_fragment) @injection.content
(#set! injection.language "css"))
)
(call_expression
function: (call_expression
function: (identifier) @_name (#eq? @_name "styled"))
arguments: (template_string (string_fragment) @injection.content
(#set! injection.language "css"))
)
(call_expression (call_expression
function: (identifier) @_name (#eq? @_name "html") function: (identifier) @_name (#eq? @_name "html")
arguments: (template_string) @injection.content arguments: (template_string) @injection.content

View file

@ -283,17 +283,13 @@ pub(crate) fn render_ai_setup_page(
v_flex() v_flex()
.mt_2() .mt_2()
.gap_6() .gap_6()
.child({ .child(
let mut ai_upsell_card = AiUpsellCard::new(client, &user_store, user_store.read(cx).plan(), cx)
AiUpsellCard::new(client, &user_store, user_store.read(cx).plan(), cx); .tab_index(Some({
tab_index += 1;
ai_upsell_card.tab_index = Some({ tab_index - 1
tab_index += 1; })),
tab_index - 1 )
});
ai_upsell_card
})
.child(render_llm_provider_section( .child(render_llm_provider_section(
&mut tab_index, &mut tab_index,
workspace, workspace,

View file

@ -850,13 +850,19 @@ impl workspace::SerializableItem for Onboarding {
} }
mod persistence { mod persistence {
use db::{define_connection, query, sqlez_macros::sql}; use db::{
query,
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use workspace::WorkspaceDb; use workspace::WorkspaceDb;
define_connection! { pub struct OnboardingPagesDb(ThreadSafeConnection);
pub static ref ONBOARDING_PAGES: OnboardingPagesDb<WorkspaceDb> =
&[ impl Domain for OnboardingPagesDb {
sql!( const NAME: &str = stringify!(OnboardingPagesDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE onboarding_pages ( CREATE TABLE onboarding_pages (
workspace_id INTEGER, workspace_id INTEGER,
item_id INTEGER UNIQUE, item_id INTEGER UNIQUE,
@ -866,10 +872,11 @@ mod persistence {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE ON DELETE CASCADE
) STRICT; ) STRICT;
), )];
];
} }
db::static_connection!(ONBOARDING_PAGES, OnboardingPagesDb, [WorkspaceDb]);
impl OnboardingPagesDb { impl OnboardingPagesDb {
query! { query! {
pub async fn save_onboarding_page( pub async fn save_onboarding_page(

View file

@ -414,13 +414,19 @@ impl workspace::SerializableItem for WelcomePage {
} }
mod persistence { mod persistence {
use db::{define_connection, query, sqlez_macros::sql}; use db::{
query,
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use workspace::WorkspaceDb; use workspace::WorkspaceDb;
define_connection! { pub struct WelcomePagesDb(ThreadSafeConnection);
pub static ref WELCOME_PAGES: WelcomePagesDb<WorkspaceDb> =
&[ impl Domain for WelcomePagesDb {
sql!( const NAME: &str = stringify!(WelcomePagesDb);
const MIGRATIONS: &[&str] = (&[sql!(
CREATE TABLE welcome_pages ( CREATE TABLE welcome_pages (
workspace_id INTEGER, workspace_id INTEGER,
item_id INTEGER UNIQUE, item_id INTEGER UNIQUE,
@ -430,10 +436,11 @@ mod persistence {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE ON DELETE CASCADE
) STRICT; ) STRICT;
), )]);
];
} }
db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
impl WelcomePagesDb { impl WelcomePagesDb {
query! { query! {
pub async fn save_welcome_page( pub async fn save_welcome_page(

View file

@ -446,7 +446,6 @@ pub enum ResponseStreamResult {
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct ResponseStreamEvent { pub struct ResponseStreamEvent {
pub model: String,
pub choices: Vec<ChoiceDelta>, pub choices: Vec<ChoiceDelta>,
pub usage: Option<Usage>, pub usage: Option<Usage>,
} }

View file

@ -7588,19 +7588,16 @@ impl LspStore {
let snapshot = buffer_handle.read(cx).snapshot(); let snapshot = buffer_handle.read(cx).snapshot();
let buffer = buffer_handle.read(cx); let buffer = buffer_handle.read(cx);
let reused_diagnostics = buffer let reused_diagnostics = buffer
.get_diagnostics(server_id) .buffer_diagnostics(Some(server_id))
.into_iter() .iter()
.flat_map(|diag| { .filter(|v| merge(buffer, &v.diagnostic, cx))
diag.iter() .map(|v| {
.filter(|v| merge(buffer, &v.diagnostic, cx)) let start = Unclipped(v.range.start.to_point_utf16(&snapshot));
.map(|v| { let end = Unclipped(v.range.end.to_point_utf16(&snapshot));
let start = Unclipped(v.range.start.to_point_utf16(&snapshot)); DiagnosticEntry {
let end = Unclipped(v.range.end.to_point_utf16(&snapshot)); range: start..end,
DiagnosticEntry { diagnostic: v.diagnostic.clone(),
range: start..end, }
diagnostic: v.diagnostic.clone(),
}
})
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -11706,12 +11703,11 @@ impl LspStore {
// Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings.
} }
"workspace/symbol" => { "workspace/symbol" => {
if let Some(options) = parse_register_capabilities(reg)? { let options = parse_register_capabilities(reg)?;
server.update_capabilities(|capabilities| { server.update_capabilities(|capabilities| {
capabilities.workspace_symbol_provider = Some(options); capabilities.workspace_symbol_provider = Some(options);
}); });
notify_server_capabilities_updated(&server, cx); notify_server_capabilities_updated(&server, cx);
}
} }
"workspace/fileOperations" => { "workspace/fileOperations" => {
if let Some(options) = reg.register_options { if let Some(options) = reg.register_options {
@ -11735,12 +11731,11 @@ impl LspStore {
} }
} }
"textDocument/rangeFormatting" => { "textDocument/rangeFormatting" => {
if let Some(options) = parse_register_capabilities(reg)? { let options = parse_register_capabilities(reg)?;
server.update_capabilities(|capabilities| { server.update_capabilities(|capabilities| {
capabilities.document_range_formatting_provider = Some(options); capabilities.document_range_formatting_provider = Some(options);
}); });
notify_server_capabilities_updated(&server, cx); notify_server_capabilities_updated(&server, cx);
}
} }
"textDocument/onTypeFormatting" => { "textDocument/onTypeFormatting" => {
if let Some(options) = reg if let Some(options) = reg
@ -11755,36 +11750,32 @@ impl LspStore {
} }
} }
"textDocument/formatting" => { "textDocument/formatting" => {
if let Some(options) = parse_register_capabilities(reg)? { let options = parse_register_capabilities(reg)?;
server.update_capabilities(|capabilities| { server.update_capabilities(|capabilities| {
capabilities.document_formatting_provider = Some(options); capabilities.document_formatting_provider = Some(options);
}); });
notify_server_capabilities_updated(&server, cx); notify_server_capabilities_updated(&server, cx);
}
} }
"textDocument/rename" => { "textDocument/rename" => {
if let Some(options) = parse_register_capabilities(reg)? { let options = parse_register_capabilities(reg)?;
server.update_capabilities(|capabilities| { server.update_capabilities(|capabilities| {
capabilities.rename_provider = Some(options); capabilities.rename_provider = Some(options);
}); });
notify_server_capabilities_updated(&server, cx); notify_server_capabilities_updated(&server, cx);
}
} }
"textDocument/inlayHint" => { "textDocument/inlayHint" => {
if let Some(options) = parse_register_capabilities(reg)? { let options = parse_register_capabilities(reg)?;
server.update_capabilities(|capabilities| { server.update_capabilities(|capabilities| {
capabilities.inlay_hint_provider = Some(options); capabilities.inlay_hint_provider = Some(options);
}); });
notify_server_capabilities_updated(&server, cx); notify_server_capabilities_updated(&server, cx);
}
} }
"textDocument/documentSymbol" => { "textDocument/documentSymbol" => {
if let Some(options) = parse_register_capabilities(reg)? { let options = parse_register_capabilities(reg)?;
server.update_capabilities(|capabilities| { server.update_capabilities(|capabilities| {
capabilities.document_symbol_provider = Some(options); capabilities.document_symbol_provider = Some(options);
}); });
notify_server_capabilities_updated(&server, cx); notify_server_capabilities_updated(&server, cx);
}
} }
"textDocument/codeAction" => { "textDocument/codeAction" => {
if let Some(options) = reg if let Some(options) = reg
@ -11800,12 +11791,11 @@ impl LspStore {
} }
} }
"textDocument/definition" => { "textDocument/definition" => {
if let Some(options) = parse_register_capabilities(reg)? { let options = parse_register_capabilities(reg)?;
server.update_capabilities(|capabilities| { server.update_capabilities(|capabilities| {
capabilities.definition_provider = Some(options); capabilities.definition_provider = Some(options);
}); });
notify_server_capabilities_updated(&server, cx); notify_server_capabilities_updated(&server, cx);
}
} }
"textDocument/completion" => { "textDocument/completion" => {
if let Some(caps) = reg if let Some(caps) = reg
@ -12184,10 +12174,10 @@ impl LspStore {
// https://github.com/microsoft/vscode-languageserver-node/blob/d90a87f9557a0df9142cfb33e251cfa6fe27d970/client/src/common/client.ts#L2133 // https://github.com/microsoft/vscode-languageserver-node/blob/d90a87f9557a0df9142cfb33e251cfa6fe27d970/client/src/common/client.ts#L2133
fn parse_register_capabilities<T: serde::de::DeserializeOwned>( fn parse_register_capabilities<T: serde::de::DeserializeOwned>(
reg: lsp::Registration, reg: lsp::Registration,
) -> anyhow::Result<Option<OneOf<bool, T>>> { ) -> Result<OneOf<bool, T>> {
Ok(match reg.register_options { Ok(match reg.register_options {
Some(options) => Some(OneOf::Right(serde_json::from_value::<T>(options)?)), Some(options) => OneOf::Right(serde_json::from_value::<T>(options)?),
None => Some(OneOf::Left(true)), None => OneOf::Left(true),
}) })
} }

View file

@ -445,7 +445,7 @@ impl SshSocket {
} }
async fn platform(&self) -> Result<SshPlatform> { async fn platform(&self) -> Result<SshPlatform> {
let uname = self.run_command("sh", &["-c", "uname -sm"]).await?; let uname = self.run_command("sh", &["-lc", "uname -sm"]).await?;
let Some((os, arch)) = uname.split_once(" ") else { let Some((os, arch)) = uname.split_once(" ") else {
anyhow::bail!("unknown uname: {uname:?}") anyhow::bail!("unknown uname: {uname:?}")
}; };
@ -476,7 +476,7 @@ impl SshSocket {
} }
async fn shell(&self) -> String { async fn shell(&self) -> String {
match self.run_command("sh", &["-c", "echo $SHELL"]).await { match self.run_command("sh", &["-lc", "echo $SHELL"]).await {
Ok(shell) => shell.trim().to_owned(), Ok(shell) => shell.trim().to_owned(),
Err(e) => { Err(e) => {
log::error!("Failed to get shell: {e}"); log::error!("Failed to get shell: {e}");
@ -1533,7 +1533,7 @@ impl RemoteConnection for SshRemoteConnection {
let ssh_proxy_process = match self let ssh_proxy_process = match self
.socket .socket
.ssh_command("sh", &["-c", &start_proxy_command]) .ssh_command("sh", &["-lc", &start_proxy_command])
// IMPORTANT: we kill this process when we drop the task that uses it. // IMPORTANT: we kill this process when we drop the task that uses it.
.kill_on_drop(true) .kill_on_drop(true)
.spawn() .spawn()
@ -1910,7 +1910,7 @@ impl SshRemoteConnection {
.run_command( .run_command(
"sh", "sh",
&[ &[
"-c", "-lc",
&shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
], ],
) )
@ -1988,7 +1988,7 @@ impl SshRemoteConnection {
.run_command( .run_command(
"sh", "sh",
&[ &[
"-c", "-lc",
&shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
], ],
) )
@ -2036,7 +2036,7 @@ impl SshRemoteConnection {
dst_path = &dst_path.to_string() dst_path = &dst_path.to_string()
) )
}; };
self.socket.run_command("sh", &["-c", &script]).await?; self.socket.run_command("sh", &["-lc", &script]).await?;
Ok(()) Ok(())
} }

View file

@ -65,6 +65,7 @@ telemetry_events.workspace = true
util.workspace = true util.workspace = true
watch.workspace = true watch.workspace = true
worktree.workspace = true worktree.workspace = true
thiserror.workspace = true
[target.'cfg(not(windows))'.dependencies] [target.'cfg(not(windows))'.dependencies]
crashes.workspace = true crashes.workspace = true

View file

@ -1,6 +1,7 @@
#![cfg_attr(target_os = "windows", allow(unused, dead_code))] #![cfg_attr(target_os = "windows", allow(unused, dead_code))]
use clap::{Parser, Subcommand}; use clap::Parser;
use remote_server::Commands;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Parser)] #[derive(Parser)]
@ -21,105 +22,34 @@ struct Cli {
printenv: bool, printenv: bool,
} }
#[derive(Subcommand)]
enum Commands {
Run {
#[arg(long)]
log_file: PathBuf,
#[arg(long)]
pid_file: PathBuf,
#[arg(long)]
stdin_socket: PathBuf,
#[arg(long)]
stdout_socket: PathBuf,
#[arg(long)]
stderr_socket: PathBuf,
},
Proxy {
#[arg(long)]
reconnect: bool,
#[arg(long)]
identifier: String,
},
Version,
}
#[cfg(windows)] #[cfg(windows)]
fn main() { fn main() {
unimplemented!() unimplemented!()
} }
#[cfg(not(windows))] #[cfg(not(windows))]
fn main() { fn main() -> anyhow::Result<()> {
use release_channel::{RELEASE_CHANNEL, ReleaseChannel};
use remote::proxy::ProxyLaunchError;
use remote_server::unix::{execute_proxy, execute_run};
let cli = Cli::parse(); let cli = Cli::parse();
if let Some(socket_path) = &cli.askpass { if let Some(socket_path) = &cli.askpass {
askpass::main(socket_path); askpass::main(socket_path);
return; return Ok(());
} }
if let Some(socket) = &cli.crash_handler { if let Some(socket) = &cli.crash_handler {
crashes::crash_server(socket.as_path()); crashes::crash_server(socket.as_path());
return; return Ok(());
} }
if cli.printenv { if cli.printenv {
util::shell_env::print_env(); util::shell_env::print_env();
return; return Ok(());
} }
let result = match cli.command { if let Some(command) = cli.command {
Some(Commands::Run { remote_server::run(command)
log_file, } else {
pid_file, eprintln!("usage: remote <run|proxy|version>");
stdin_socket,
stdout_socket,
stderr_socket,
}) => execute_run(
log_file,
pid_file,
stdin_socket,
stdout_socket,
stderr_socket,
),
Some(Commands::Proxy {
identifier,
reconnect,
}) => match execute_proxy(identifier, reconnect) {
Ok(_) => Ok(()),
Err(err) => {
if let Some(err) = err.downcast_ref::<ProxyLaunchError>() {
std::process::exit(err.to_exit_code());
}
Err(err)
}
},
Some(Commands::Version) => {
let release_channel = *RELEASE_CHANNEL;
match release_channel {
ReleaseChannel::Stable | ReleaseChannel::Preview => {
println!("{}", env!("ZED_PKG_VERSION"))
}
ReleaseChannel::Nightly | ReleaseChannel::Dev => {
println!(
"{}",
option_env!("ZED_COMMIT_SHA").unwrap_or(release_channel.dev_name())
)
}
};
std::process::exit(0);
}
None => {
eprintln!("usage: remote <run|proxy|version>");
std::process::exit(1);
}
};
if let Err(error) = result {
log::error!("exiting due to error: {}", error);
std::process::exit(1); std::process::exit(1);
} }
} }

View file

@ -6,4 +6,78 @@ pub mod unix;
#[cfg(test)] #[cfg(test)]
mod remote_editing_tests; mod remote_editing_tests;
use clap::Subcommand;
use std::path::PathBuf;
pub use headless_project::{HeadlessAppState, HeadlessProject}; pub use headless_project::{HeadlessAppState, HeadlessProject};
#[derive(Subcommand)]
pub enum Commands {
Run {
#[arg(long)]
log_file: PathBuf,
#[arg(long)]
pid_file: PathBuf,
#[arg(long)]
stdin_socket: PathBuf,
#[arg(long)]
stdout_socket: PathBuf,
#[arg(long)]
stderr_socket: PathBuf,
},
Proxy {
#[arg(long)]
reconnect: bool,
#[arg(long)]
identifier: String,
},
Version,
}
#[cfg(not(windows))]
pub fn run(command: Commands) -> anyhow::Result<()> {
use anyhow::Context;
use release_channel::{RELEASE_CHANNEL, ReleaseChannel};
use unix::{ExecuteProxyError, execute_proxy, execute_run};
match command {
Commands::Run {
log_file,
pid_file,
stdin_socket,
stdout_socket,
stderr_socket,
} => execute_run(
log_file,
pid_file,
stdin_socket,
stdout_socket,
stderr_socket,
),
Commands::Proxy {
identifier,
reconnect,
} => execute_proxy(identifier, reconnect)
.inspect_err(|err| {
if let ExecuteProxyError::ServerNotRunning(err) = err {
std::process::exit(err.to_exit_code());
}
})
.context("running proxy on the remote server"),
Commands::Version => {
let release_channel = *RELEASE_CHANNEL;
match release_channel {
ReleaseChannel::Stable | ReleaseChannel::Preview => {
println!("{}", env!("ZED_PKG_VERSION"))
}
ReleaseChannel::Nightly | ReleaseChannel::Dev => {
println!(
"{}",
option_env!("ZED_COMMIT_SHA").unwrap_or(release_channel.dev_name())
)
}
};
Ok(())
}
}
}

View file

@ -36,6 +36,7 @@ use smol::Async;
use smol::{net::unix::UnixListener, stream::StreamExt as _}; use smol::{net::unix::UnixListener, stream::StreamExt as _};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::ops::ControlFlow; use std::ops::ControlFlow;
use std::process::ExitStatus;
use std::str::FromStr; use std::str::FromStr;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::{env, thread}; use std::{env, thread};
@ -46,6 +47,7 @@ use std::{
sync::Arc, sync::Arc,
}; };
use telemetry_events::LocationData; use telemetry_events::LocationData;
use thiserror::Error;
use util::ResultExt; use util::ResultExt;
pub static VERSION: LazyLock<&str> = LazyLock::new(|| match *RELEASE_CHANNEL { pub static VERSION: LazyLock<&str> = LazyLock::new(|| match *RELEASE_CHANNEL {
@ -526,7 +528,23 @@ pub fn execute_run(
Ok(()) Ok(())
} }
#[derive(Clone)] #[derive(Debug, Error)]
pub(crate) enum ServerPathError {
#[error("Failed to create server_dir `{path}`")]
CreateServerDir {
#[source]
source: std::io::Error,
path: PathBuf,
},
#[error("Failed to create logs_dir `{path}`")]
CreateLogsDir {
#[source]
source: std::io::Error,
path: PathBuf,
},
}
#[derive(Clone, Debug)]
struct ServerPaths { struct ServerPaths {
log_file: PathBuf, log_file: PathBuf,
pid_file: PathBuf, pid_file: PathBuf,
@ -536,10 +554,19 @@ struct ServerPaths {
} }
impl ServerPaths { impl ServerPaths {
fn new(identifier: &str) -> Result<Self> { fn new(identifier: &str) -> Result<Self, ServerPathError> {
let server_dir = paths::remote_server_state_dir().join(identifier); let server_dir = paths::remote_server_state_dir().join(identifier);
std::fs::create_dir_all(&server_dir)?; std::fs::create_dir_all(&server_dir).map_err(|source| {
std::fs::create_dir_all(&logs_dir())?; ServerPathError::CreateServerDir {
source,
path: server_dir.clone(),
}
})?;
let log_dir = logs_dir();
std::fs::create_dir_all(log_dir).map_err(|source| ServerPathError::CreateLogsDir {
source: source,
path: log_dir.clone(),
})?;
let pid_file = server_dir.join("server.pid"); let pid_file = server_dir.join("server.pid");
let stdin_socket = server_dir.join("stdin.sock"); let stdin_socket = server_dir.join("stdin.sock");
@ -557,7 +584,43 @@ impl ServerPaths {
} }
} }
pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> { #[derive(Debug, Error)]
pub(crate) enum ExecuteProxyError {
#[error("Failed to init server paths")]
ServerPath(#[from] ServerPathError),
#[error(transparent)]
ServerNotRunning(#[from] ProxyLaunchError),
#[error("Failed to check PidFile '{path}'")]
CheckPidFile {
#[source]
source: CheckPidError,
path: PathBuf,
},
#[error("Failed to kill existing server with pid '{pid}'")]
KillRunningServer {
#[source]
source: std::io::Error,
pid: u32,
},
#[error("failed to spawn server")]
SpawnServer(#[source] SpawnServerError),
#[error("stdin_task failed")]
StdinTask(#[source] anyhow::Error),
#[error("stdout_task failed")]
StdoutTask(#[source] anyhow::Error),
#[error("stderr_task failed")]
StderrTask(#[source] anyhow::Error),
}
pub(crate) fn execute_proxy(
identifier: String,
is_reconnecting: bool,
) -> Result<(), ExecuteProxyError> {
init_logging_proxy(); init_logging_proxy();
let server_paths = ServerPaths::new(&identifier)?; let server_paths = ServerPaths::new(&identifier)?;
@ -574,12 +637,19 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
log::info!("starting proxy process. PID: {}", std::process::id()); log::info!("starting proxy process. PID: {}", std::process::id());
let server_pid = check_pid_file(&server_paths.pid_file)?; let server_pid = check_pid_file(&server_paths.pid_file).map_err(|source| {
ExecuteProxyError::CheckPidFile {
source,
path: server_paths.pid_file.clone(),
}
})?;
let server_running = server_pid.is_some(); let server_running = server_pid.is_some();
if is_reconnecting { if is_reconnecting {
if !server_running { if !server_running {
log::error!("attempted to reconnect, but no server running"); log::error!("attempted to reconnect, but no server running");
anyhow::bail!(ProxyLaunchError::ServerNotRunning); return Err(ExecuteProxyError::ServerNotRunning(
ProxyLaunchError::ServerNotRunning,
));
} }
} else { } else {
if let Some(pid) = server_pid { if let Some(pid) = server_pid {
@ -590,7 +660,7 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
kill_running_server(pid, &server_paths)?; kill_running_server(pid, &server_paths)?;
} }
spawn_server(&server_paths)?; spawn_server(&server_paths).map_err(ExecuteProxyError::SpawnServer)?;
}; };
let stdin_task = smol::spawn(async move { let stdin_task = smol::spawn(async move {
@ -630,9 +700,9 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
if let Err(forwarding_result) = smol::block_on(async move { if let Err(forwarding_result) = smol::block_on(async move {
futures::select! { futures::select! {
result = stdin_task.fuse() => result.context("stdin_task failed"), result = stdin_task.fuse() => result.map_err(ExecuteProxyError::StdinTask),
result = stdout_task.fuse() => result.context("stdout_task failed"), result = stdout_task.fuse() => result.map_err(ExecuteProxyError::StdoutTask),
result = stderr_task.fuse() => result.context("stderr_task failed"), result = stderr_task.fuse() => result.map_err(ExecuteProxyError::StderrTask),
} }
}) { }) {
log::error!( log::error!(
@ -645,12 +715,12 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
Ok(()) Ok(())
} }
fn kill_running_server(pid: u32, paths: &ServerPaths) -> Result<()> { fn kill_running_server(pid: u32, paths: &ServerPaths) -> Result<(), ExecuteProxyError> {
log::info!("killing existing server with PID {}", pid); log::info!("killing existing server with PID {}", pid);
std::process::Command::new("kill") std::process::Command::new("kill")
.arg(pid.to_string()) .arg(pid.to_string())
.output() .output()
.context("failed to kill existing server")?; .map_err(|source| ExecuteProxyError::KillRunningServer { source, pid })?;
for file in [ for file in [
&paths.pid_file, &paths.pid_file,
@ -664,18 +734,39 @@ fn kill_running_server(pid: u32, paths: &ServerPaths) -> Result<()> {
Ok(()) Ok(())
} }
fn spawn_server(paths: &ServerPaths) -> Result<()> { #[derive(Debug, Error)]
pub(crate) enum SpawnServerError {
#[error("failed to remove stdin socket")]
RemoveStdinSocket(#[source] std::io::Error),
#[error("failed to remove stdout socket")]
RemoveStdoutSocket(#[source] std::io::Error),
#[error("failed to remove stderr socket")]
RemoveStderrSocket(#[source] std::io::Error),
#[error("failed to get current_exe")]
CurrentExe(#[source] std::io::Error),
#[error("failed to launch server process")]
ProcessStatus(#[source] std::io::Error),
#[error("failed to launch and detach server process: {status}\n{paths}")]
LaunchStatus { status: ExitStatus, paths: String },
}
fn spawn_server(paths: &ServerPaths) -> Result<(), SpawnServerError> {
if paths.stdin_socket.exists() { if paths.stdin_socket.exists() {
std::fs::remove_file(&paths.stdin_socket)?; std::fs::remove_file(&paths.stdin_socket).map_err(SpawnServerError::RemoveStdinSocket)?;
} }
if paths.stdout_socket.exists() { if paths.stdout_socket.exists() {
std::fs::remove_file(&paths.stdout_socket)?; std::fs::remove_file(&paths.stdout_socket).map_err(SpawnServerError::RemoveStdoutSocket)?;
} }
if paths.stderr_socket.exists() { if paths.stderr_socket.exists() {
std::fs::remove_file(&paths.stderr_socket)?; std::fs::remove_file(&paths.stderr_socket).map_err(SpawnServerError::RemoveStderrSocket)?;
} }
let binary_name = std::env::current_exe()?; let binary_name = std::env::current_exe().map_err(SpawnServerError::CurrentExe)?;
let mut server_process = std::process::Command::new(binary_name); let mut server_process = std::process::Command::new(binary_name);
server_process server_process
.arg("run") .arg("run")
@ -692,11 +783,17 @@ fn spawn_server(paths: &ServerPaths) -> Result<()> {
let status = server_process let status = server_process
.status() .status()
.context("failed to launch server process")?; .map_err(SpawnServerError::ProcessStatus)?;
anyhow::ensure!(
status.success(), if !status.success() {
"failed to launch and detach server process" return Err(SpawnServerError::LaunchStatus {
); status,
paths: format!(
"log file: {:?}, pid file: {:?}",
paths.log_file, paths.pid_file,
),
});
}
let mut total_time_waited = std::time::Duration::from_secs(0); let mut total_time_waited = std::time::Duration::from_secs(0);
let wait_duration = std::time::Duration::from_millis(20); let wait_duration = std::time::Duration::from_millis(20);
@ -717,7 +814,15 @@ fn spawn_server(paths: &ServerPaths) -> Result<()> {
Ok(()) Ok(())
} }
fn check_pid_file(path: &Path) -> Result<Option<u32>> { #[derive(Debug, Error)]
#[error("Failed to remove PID file for missing process (pid `{pid}`")]
pub(crate) struct CheckPidError {
#[source]
source: std::io::Error,
pid: u32,
}
fn check_pid_file(path: &Path) -> Result<Option<u32>, CheckPidError> {
let Some(pid) = std::fs::read_to_string(&path) let Some(pid) = std::fs::read_to_string(&path)
.ok() .ok()
.and_then(|contents| contents.parse::<u32>().ok()) .and_then(|contents| contents.parse::<u32>().ok())
@ -742,7 +847,7 @@ fn check_pid_file(path: &Path) -> Result<Option<u32>> {
log::debug!( log::debug!(
"Found PID file, but process with that PID does not exist. Removing PID file." "Found PID file, but process with that PID does not exist. Removing PID file."
); );
std::fs::remove_file(&path).context("Failed to remove PID file")?; std::fs::remove_file(&path).map_err(|source| CheckPidError { source, pid })?;
Ok(None) Ok(None)
} }
} }

View file

@ -3348,12 +3348,15 @@ impl SerializableItem for KeymapEditor {
} }
mod persistence { mod persistence {
use db::{define_connection, query, sqlez_macros::sql}; use db::{query, sqlez::domain::Domain, sqlez_macros::sql};
use workspace::WorkspaceDb; use workspace::WorkspaceDb;
define_connection! { pub struct KeybindingEditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection);
pub static ref KEYBINDING_EDITORS: KeybindingEditorDb<WorkspaceDb> =
&[sql!( impl Domain for KeybindingEditorDb {
const NAME: &str = stringify!(KeybindingEditorDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE keybinding_editors ( CREATE TABLE keybinding_editors (
workspace_id INTEGER, workspace_id INTEGER,
item_id INTEGER UNIQUE, item_id INTEGER UNIQUE,
@ -3362,9 +3365,11 @@ mod persistence {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE ON DELETE CASCADE
) STRICT; ) STRICT;
)]; )];
} }
db::static_connection!(KEYBINDING_EDITORS, KeybindingEditorDb, [WorkspaceDb]);
impl KeybindingEditorDb { impl KeybindingEditorDb {
query! { query! {
pub async fn save_keybinding_editor( pub async fn save_keybinding_editor(

View file

@ -1,8 +1,12 @@
use crate::connection::Connection; use crate::connection::Connection;
pub trait Domain: 'static { pub trait Domain: 'static {
fn name() -> &'static str; const NAME: &str;
fn migrations() -> &'static [&'static str]; const MIGRATIONS: &[&str];
fn should_allow_migration_change(_index: usize, _old: &str, _new: &str) -> bool {
false
}
} }
pub trait Migrator: 'static { pub trait Migrator: 'static {
@ -17,7 +21,11 @@ impl Migrator for () {
impl<D: Domain> Migrator for D { impl<D: Domain> Migrator for D {
fn migrate(connection: &Connection) -> anyhow::Result<()> { fn migrate(connection: &Connection) -> anyhow::Result<()> {
connection.migrate(Self::name(), Self::migrations()) connection.migrate(
Self::NAME,
Self::MIGRATIONS,
Self::should_allow_migration_change,
)
} }
} }

View file

@ -34,7 +34,12 @@ impl Connection {
/// Note: Unlike everything else in SQLez, migrations are run eagerly, without first /// Note: Unlike everything else in SQLez, migrations are run eagerly, without first
/// preparing the SQL statements. This makes it possible to do multi-statement schema /// preparing the SQL statements. This makes it possible to do multi-statement schema
/// updates in a single string without running into prepare errors. /// updates in a single string without running into prepare errors.
pub fn migrate(&self, domain: &'static str, migrations: &[&'static str]) -> Result<()> { pub fn migrate(
&self,
domain: &'static str,
migrations: &[&'static str],
mut should_allow_migration_change: impl FnMut(usize, &str, &str) -> bool,
) -> Result<()> {
self.with_savepoint("migrating", || { self.with_savepoint("migrating", || {
// Setup the migrations table unconditionally // Setup the migrations table unconditionally
self.exec(indoc! {" self.exec(indoc! {"
@ -65,9 +70,14 @@ impl Connection {
&sqlformat::QueryParams::None, &sqlformat::QueryParams::None,
Default::default(), Default::default(),
); );
if completed_migration == migration { if completed_migration == migration
|| migration.trim().starts_with("-- ALLOW_MIGRATION_CHANGE")
{
// Migration already run. Continue // Migration already run. Continue
continue; continue;
} else if should_allow_migration_change(index, &completed_migration, &migration)
{
continue;
} else { } else {
anyhow::bail!(formatdoc! {" anyhow::bail!(formatdoc! {"
Migration changed for {domain} at step {index} Migration changed for {domain} at step {index}
@ -108,6 +118,7 @@ mod test {
a TEXT, a TEXT,
b TEXT b TEXT
)"}], )"}],
disallow_migration_change,
) )
.unwrap(); .unwrap();
@ -136,6 +147,7 @@ mod test {
d TEXT d TEXT
)"}, )"},
], ],
disallow_migration_change,
) )
.unwrap(); .unwrap();
@ -214,7 +226,11 @@ mod test {
// Run the migration verifying that the row got dropped // Run the migration verifying that the row got dropped
connection connection
.migrate("test", &["DELETE FROM test_table"]) .migrate(
"test",
&["DELETE FROM test_table"],
disallow_migration_change,
)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
connection connection
@ -232,7 +248,11 @@ mod test {
// Run the same migration again and verify that the table was left unchanged // Run the same migration again and verify that the table was left unchanged
connection connection
.migrate("test", &["DELETE FROM test_table"]) .migrate(
"test",
&["DELETE FROM test_table"],
disallow_migration_change,
)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
connection connection
@ -252,27 +272,28 @@ mod test {
.migrate( .migrate(
"test migration", "test migration",
&[ &[
indoc! {" "CREATE TABLE test (col INTEGER)",
CREATE TABLE test ( "INSERT INTO test (col) VALUES (1)",
col INTEGER
)"},
indoc! {"
INSERT INTO test (col) VALUES (1)"},
], ],
disallow_migration_change,
) )
.unwrap(); .unwrap();
let mut migration_changed = false;
// Create another migration with the same domain but different steps // Create another migration with the same domain but different steps
let second_migration_result = connection.migrate( let second_migration_result = connection.migrate(
"test migration", "test migration",
&[ &[
indoc! {" "CREATE TABLE test (color INTEGER )",
CREATE TABLE test ( "INSERT INTO test (color) VALUES (1)",
color INTEGER
)"},
indoc! {"
INSERT INTO test (color) VALUES (1)"},
], ],
|_, old, new| {
assert_eq!(old, "CREATE TABLE test (col INTEGER)");
assert_eq!(new, "CREATE TABLE test (color INTEGER)");
migration_changed = true;
false
},
); );
// Verify new migration returns error when run // Verify new migration returns error when run
@ -284,7 +305,11 @@ mod test {
let connection = Connection::open_memory(Some("test_create_alter_drop")); let connection = Connection::open_memory(Some("test_create_alter_drop"));
connection connection
.migrate("first_migration", &["CREATE TABLE table1(a TEXT) STRICT;"]) .migrate(
"first_migration",
&["CREATE TABLE table1(a TEXT) STRICT;"],
disallow_migration_change,
)
.unwrap(); .unwrap();
connection connection
@ -305,6 +330,7 @@ mod test {
ALTER TABLE table2 RENAME TO table1; ALTER TABLE table2 RENAME TO table1;
"}], "}],
disallow_migration_change,
) )
.unwrap(); .unwrap();
@ -312,4 +338,8 @@ mod test {
assert_eq!(res, "test text"); assert_eq!(res, "test text");
} }
fn disallow_migration_change(_: usize, _: &str, _: &str) -> bool {
false
}
} }

View file

@ -278,12 +278,8 @@ mod test {
enum TestDomain {} enum TestDomain {}
impl Domain for TestDomain { impl Domain for TestDomain {
fn name() -> &'static str { const NAME: &str = "test";
"test" const MIGRATIONS: &[&str] = &["CREATE TABLE test(col1 TEXT, col2 TEXT) STRICT;"];
}
fn migrations() -> &'static [&'static str] {
&["CREATE TABLE test(col1 TEXT, col2 TEXT) STRICT;"]
}
} }
for _ in 0..100 { for _ in 0..100 {
@ -312,12 +308,9 @@ mod test {
fn wild_zed_lost_failure() { fn wild_zed_lost_failure() {
enum TestWorkspace {} enum TestWorkspace {}
impl Domain for TestWorkspace { impl Domain for TestWorkspace {
fn name() -> &'static str { const NAME: &str = "workspace";
"workspace"
}
fn migrations() -> &'static [&'static str] { const MIGRATIONS: &[&str] = &["
&["
CREATE TABLE workspaces( CREATE TABLE workspaces(
workspace_id INTEGER PRIMARY KEY, workspace_id INTEGER PRIMARY KEY,
dock_visible INTEGER, -- Boolean dock_visible INTEGER, -- Boolean
@ -336,8 +329,7 @@ mod test {
ON DELETE CASCADE ON DELETE CASCADE
ON UPDATE CASCADE ON UPDATE CASCADE
) STRICT; ) STRICT;
"] "];
}
} }
let builder = let builder =

View file

@ -2,12 +2,14 @@
mod tab_switcher_tests; mod tab_switcher_tests;
use collections::HashMap; use collections::HashMap;
use editor::items::entry_git_aware_label_color; use editor::items::{
entry_diagnostic_aware_icon_decoration_and_color, entry_git_aware_label_color,
};
use fuzzy::StringMatchCandidate; use fuzzy::StringMatchCandidate;
use gpui::{ use gpui::{
Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle,
Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Render, Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Point,
Styled, Task, WeakEntity, Window, actions, rems, Render, Styled, Task, WeakEntity, Window, actions, rems,
}; };
use picker::{Picker, PickerDelegate}; use picker::{Picker, PickerDelegate};
use project::Project; use project::Project;
@ -15,11 +17,14 @@ use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use settings::Settings; use settings::Settings;
use std::{cmp::Reverse, sync::Arc}; use std::{cmp::Reverse, sync::Arc};
use ui::{ListItem, ListItemSpacing, Tooltip, prelude::*}; use ui::{
DecoratedIcon, IconDecoration, IconDecorationKind, ListItem, ListItemSpacing, Tooltip,
prelude::*,
};
use util::ResultExt; use util::ResultExt;
use workspace::{ use workspace::{
ModalView, Pane, SaveIntent, Workspace, ModalView, Pane, SaveIntent, Workspace,
item::{ItemHandle, ItemSettings, TabContentParams}, item::{ItemHandle, ItemSettings, ShowDiagnostics, TabContentParams},
pane::{Event as PaneEvent, render_item_indicator, tab_details}, pane::{Event as PaneEvent, render_item_indicator, tab_details},
}; };
@ -233,6 +238,77 @@ pub struct TabSwitcherDelegate {
restored_items: bool, restored_items: bool,
} }
impl TabMatch {
fn icon(
&self,
project: &Entity<Project>,
selected: bool,
window: &Window,
cx: &App,
) -> Option<DecoratedIcon> {
let icon = self.item.tab_icon(window, cx)?;
let item_settings = ItemSettings::get_global(cx);
let show_diagnostics = item_settings.show_diagnostics;
let git_status_color = item_settings
.git_status
.then(|| {
let path = self.item.project_path(cx)?;
let project = project.read(cx);
let entry = project.entry_for_path(&path, cx)?;
let git_status = project
.project_path_git_status(&path, cx)
.map(|status| status.summary())
.unwrap_or_default();
Some(entry_git_aware_label_color(
git_status,
entry.is_ignored,
selected,
))
})
.flatten();
let colored_icon = icon.color(git_status_color.unwrap_or_default());
let most_sever_diagostic_level = if show_diagnostics == ShowDiagnostics::Off {
None
} else {
let buffer_store = project.read(cx).buffer_store().read(cx);
let buffer = self
.item
.project_path(cx)
.and_then(|path| buffer_store.get_by_path(&path))
.map(|buffer| buffer.read(cx));
buffer.and_then(|buffer| {
buffer
.buffer_diagnostics(None)
.iter()
.map(|diagnostic_entry| diagnostic_entry.diagnostic.severity)
.min()
})
};
let decorations =
entry_diagnostic_aware_icon_decoration_and_color(most_sever_diagostic_level)
.filter(|(d, _)| {
*d != IconDecorationKind::Triangle
|| show_diagnostics != ShowDiagnostics::Errors
})
.map(|(icon, color)| {
let knockout_item_color = if selected {
cx.theme().colors().element_selected
} else {
cx.theme().colors().element_background
};
IconDecoration::new(icon, knockout_item_color, cx)
.color(color.color(cx))
.position(Point {
x: px(-2.),
y: px(-2.),
})
});
Some(DecoratedIcon::new(colored_icon, decorations))
}
}
impl TabSwitcherDelegate { impl TabSwitcherDelegate {
#[allow(clippy::complexity)] #[allow(clippy::complexity)]
fn new( fn new(
@ -574,31 +650,7 @@ impl PickerDelegate for TabSwitcherDelegate {
}; };
let label = tab_match.item.tab_content(params, window, cx); let label = tab_match.item.tab_content(params, window, cx);
let icon = tab_match.item.tab_icon(window, cx).map(|icon| { let icon = tab_match.icon(&self.project, selected, window, cx);
let git_status_color = ItemSettings::get_global(cx)
.git_status
.then(|| {
tab_match
.item
.project_path(cx)
.as_ref()
.and_then(|path| {
let project = self.project.read(cx);
let entry = project.entry_for_path(path, cx)?;
let git_status = project
.project_path_git_status(path, cx)
.map(|status| status.summary())
.unwrap_or_default();
Some((entry, git_status))
})
.map(|(entry, git_status)| {
entry_git_aware_label_color(git_status, entry.is_ignored, selected)
})
})
.flatten();
icon.color(git_status_color.unwrap_or_default())
});
let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx); let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
let indicator_color = if let Some(ref indicator) = indicator { let indicator_color = if let Some(ref indicator) = indicator {
@ -640,7 +692,7 @@ impl PickerDelegate for TabSwitcherDelegate {
.inset(true) .inset(true)
.toggle_state(selected) .toggle_state(selected)
.child(h_flex().w_full().child(label)) .child(h_flex().w_full().child(label))
.start_slot::<Icon>(icon) .start_slot::<DecoratedIcon>(icon)
.map(|el| { .map(|el| {
if self.selected_index == ix { if self.selected_index == ix {
el.end_slot::<AnyElement>(close_button) el.end_slot::<AnyElement>(close_button)

View file

@ -9,7 +9,11 @@ use std::path::{Path, PathBuf};
use ui::{App, Context, Pixels, Window}; use ui::{App, Context, Pixels, Window};
use util::ResultExt as _; use util::ResultExt as _;
use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; use db::{
query,
sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use workspace::{ use workspace::{
ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace, ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace,
WorkspaceDb, WorkspaceId, WorkspaceDb, WorkspaceId,
@ -375,9 +379,13 @@ impl<'de> Deserialize<'de> for SerializedAxis {
} }
} }
define_connection! { pub struct TerminalDb(ThreadSafeConnection);
pub static ref TERMINAL_DB: TerminalDb<WorkspaceDb> =
&[sql!( impl Domain for TerminalDb {
const NAME: &str = stringify!(TerminalDb);
const MIGRATIONS: &[&str] = &[
sql!(
CREATE TABLE terminals ( CREATE TABLE terminals (
workspace_id INTEGER, workspace_id INTEGER,
item_id INTEGER UNIQUE, item_id INTEGER UNIQUE,
@ -414,6 +422,8 @@ define_connection! {
]; ];
} }
db::static_connection!(TERMINAL_DB, TerminalDb, [WorkspaceDb]);
impl TerminalDb { impl TerminalDb {
query! { query! {
pub async fn update_workspace_id( pub async fn update_workspace_id(

View file

@ -561,7 +561,7 @@ impl ContextMenu {
action: Some(action.boxed_clone()), action: Some(action.boxed_clone()),
handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)), handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)),
icon: Some(IconName::ArrowUpRight), icon: Some(IconName::ArrowUpRight),
icon_size: IconSize::Small, icon_size: IconSize::XSmall,
icon_position: IconPosition::End, icon_position: IconPosition::End,
icon_color: None, icon_color: None,
disabled: false, disabled: false,

View file

@ -23,6 +23,8 @@ actions!(
HelixInsert, HelixInsert,
/// Appends at the end of the selection. /// Appends at the end of the selection.
HelixAppend, HelixAppend,
/// Goes to the location of the last modification.
HelixGotoLastModification,
] ]
); );
@ -31,6 +33,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, Vim::helix_insert); Vim::action(editor, cx, Vim::helix_insert);
Vim::action(editor, cx, Vim::helix_append); Vim::action(editor, cx, Vim::helix_append);
Vim::action(editor, cx, Vim::helix_yank); Vim::action(editor, cx, Vim::helix_yank);
Vim::action(editor, cx, Vim::helix_goto_last_modification);
} }
impl Vim { impl Vim {
@ -430,6 +433,15 @@ impl Vim {
}); });
self.switch_mode(Mode::HelixNormal, true, window, cx); self.switch_mode(Mode::HelixNormal, true, window, cx);
} }
pub fn helix_goto_last_modification(
&mut self,
_: &HelixGotoLastModification,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.jump(".".into(), false, false, window, cx);
}
} }
#[cfg(test)] #[cfg(test)]
@ -441,6 +453,7 @@ mod test {
#[gpui::test] #[gpui::test]
async fn test_word_motions(cx: &mut gpui::TestAppContext) { async fn test_word_motions(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await; let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// « // «
// ˇ // ˇ
// » // »
@ -502,6 +515,7 @@ mod test {
#[gpui::test] #[gpui::test]
async fn test_delete(cx: &mut gpui::TestAppContext) { async fn test_delete(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await; let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// test delete a selection // test delete a selection
cx.set_state( cx.set_state(
@ -582,6 +596,7 @@ mod test {
#[gpui::test] #[gpui::test]
async fn test_f_and_t(cx: &mut gpui::TestAppContext) { async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await; let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
cx.set_state( cx.set_state(
indoc! {" indoc! {"
@ -635,6 +650,7 @@ mod test {
#[gpui::test] #[gpui::test]
async fn test_newline_char(cx: &mut gpui::TestAppContext) { async fn test_newline_char(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await; let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal); cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
@ -652,6 +668,7 @@ mod test {
#[gpui::test] #[gpui::test]
async fn test_insert_selected(cx: &mut gpui::TestAppContext) { async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await; let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
cx.set_state( cx.set_state(
indoc! {" indoc! {"
«The ˇ»quick brown «The ˇ»quick brown
@ -674,6 +691,7 @@ mod test {
#[gpui::test] #[gpui::test]
async fn test_append(cx: &mut gpui::TestAppContext) { async fn test_append(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await; let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// test from the end of the selection // test from the end of the selection
cx.set_state( cx.set_state(
indoc! {" indoc! {"
@ -716,6 +734,7 @@ mod test {
#[gpui::test] #[gpui::test]
async fn test_replace(cx: &mut gpui::TestAppContext) { async fn test_replace(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await; let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// No selection (single character) // No selection (single character)
cx.set_state("ˇaa", Mode::HelixNormal); cx.set_state("ˇaa", Mode::HelixNormal);
@ -763,4 +782,72 @@ mod test {
cx.shared_clipboard().assert_eq("worl"); cx.shared_clipboard().assert_eq("worl");
cx.assert_state("hello «worlˇ»d", Mode::HelixNormal); cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
} }
#[gpui::test]
async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// First copy some text to clipboard
cx.set_state("«hello worldˇ»", Mode::HelixNormal);
cx.simulate_keystrokes("y");
// Test paste with shift-r on single cursor
cx.set_state("foo ˇbar", Mode::HelixNormal);
cx.simulate_keystrokes("shift-r");
cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
// Test paste with shift-r on selection
cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
cx.simulate_keystrokes("shift-r");
cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
}
#[gpui::test]
async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// Make a modification at a specific location
cx.set_state("ˇhello", Mode::HelixNormal);
assert_eq!(cx.mode(), Mode::HelixNormal);
cx.simulate_keystrokes("i");
assert_eq!(cx.mode(), Mode::Insert);
cx.simulate_keystrokes("escape");
assert_eq!(cx.mode(), Mode::HelixNormal);
}
#[gpui::test]
async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// Make a modification at a specific location
cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
cx.simulate_keystrokes("i");
cx.simulate_keystrokes("escape");
cx.simulate_keystrokes("i");
cx.simulate_keystrokes("m o d i f i e d space");
cx.simulate_keystrokes("escape");
// TODO: this fails, because state is no longer helix
cx.assert_state(
"line one\nline modified ˇtwo\nline three",
Mode::HelixNormal,
);
// Move cursor away from the modification
cx.simulate_keystrokes("up");
// Use "g ." to go back to last modification
cx.simulate_keystrokes("g .");
// Verify we're back at the modification location and still in HelixNormal mode
cx.assert_state(
"line one\nline modifiedˇ two\nline three",
Mode::HelixNormal,
);
}
} }

View file

@ -7,8 +7,10 @@ use crate::{motion::Motion, object::Object};
use anyhow::Result; use anyhow::Result;
use collections::HashMap; use collections::HashMap;
use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor}; use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor};
use db::define_connection; use db::{
use db::sqlez_macros::sql; sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use editor::display_map::{is_invisible, replacement}; use editor::display_map::{is_invisible, replacement};
use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer, ToPoint as EditorToPoint}; use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer, ToPoint as EditorToPoint};
use gpui::{ use gpui::{
@ -1668,8 +1670,12 @@ impl MarksView {
} }
} }
define_connection! ( pub struct VimDb(ThreadSafeConnection);
pub static ref DB: VimDb<WorkspaceDb> = &[
impl Domain for VimDb {
const NAME: &str = stringify!(VimDb);
const MIGRATIONS: &[&str] = &[
sql! ( sql! (
CREATE TABLE vim_marks ( CREATE TABLE vim_marks (
workspace_id INTEGER, workspace_id INTEGER,
@ -1689,7 +1695,9 @@ define_connection! (
ON vim_global_marks_paths(workspace_id, mark_name); ON vim_global_marks_paths(workspace_id, mark_name);
), ),
]; ];
); }
db::static_connection!(DB, VimDb, [WorkspaceDb]);
struct SerializedMark { struct SerializedMark {
path: Arc<Path>, path: Arc<Path>,

View file

@ -162,6 +162,19 @@ impl<T> Receiver<T> {
pending_waker_id: None, pending_waker_id: None,
} }
} }
/// Creates a new [`Receiver`] holding an initial value that will never change.
pub fn constant(value: T) -> Self {
let state = Arc::new(RwLock::new(State {
value,
wakers: BTreeMap::new(),
next_waker_id: WakerId::default(),
version: 0,
closed: false,
}));
Self { state, version: 0 }
}
} }
impl<T: Clone> Receiver<T> { impl<T: Clone> Receiver<T> {

View file

@ -1,4 +1,4 @@
use std::{path::PathBuf, sync::Arc}; use std::{path::Path, sync::Arc};
use gpui::{EventEmitter, FocusHandle, Focusable}; use gpui::{EventEmitter, FocusHandle, Focusable};
use ui::{ use ui::{
@ -12,7 +12,7 @@ use crate::Item;
/// A view to display when a certain buffer fails to open. /// A view to display when a certain buffer fails to open.
pub struct InvalidBufferView { pub struct InvalidBufferView {
/// Which path was attempted to open. /// Which path was attempted to open.
pub abs_path: Arc<PathBuf>, pub abs_path: Arc<Path>,
/// An error message, happened when opening the buffer. /// An error message, happened when opening the buffer.
pub error: SharedString, pub error: SharedString,
is_local: bool, is_local: bool,
@ -21,7 +21,7 @@ pub struct InvalidBufferView {
impl InvalidBufferView { impl InvalidBufferView {
pub fn new( pub fn new(
abs_path: PathBuf, abs_path: &Path,
is_local: bool, is_local: bool,
e: &anyhow::Error, e: &anyhow::Error,
_: &mut Window, _: &mut Window,
@ -29,7 +29,7 @@ impl InvalidBufferView {
) -> Self { ) -> Self {
Self { Self {
is_local, is_local,
abs_path: Arc::new(abs_path), abs_path: Arc::from(abs_path),
error: format!("{e}").into(), error: format!("{e}").into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
} }
@ -43,7 +43,7 @@ impl Item for InvalidBufferView {
// Ensure we always render at least the filename. // Ensure we always render at least the filename.
detail += 1; detail += 1;
let path = self.abs_path.as_path(); let path = self.abs_path.as_ref();
let mut prefix = path; let mut prefix = path;
while detail > 0 { while detail > 0 {

View file

@ -23,7 +23,7 @@ use std::{
any::{Any, TypeId}, any::{Any, TypeId},
cell::RefCell, cell::RefCell,
ops::Range, ops::Range,
path::PathBuf, path::Path,
rc::Rc, rc::Rc,
sync::Arc, sync::Arc,
time::Duration, time::Duration,
@ -1168,7 +1168,7 @@ pub trait ProjectItem: Item {
/// with the error from that failure as an argument. /// with the error from that failure as an argument.
/// Allows to open an item that can gracefully display and handle errors. /// Allows to open an item that can gracefully display and handle errors.
fn for_broken_project_item( fn for_broken_project_item(
_abs_path: PathBuf, _abs_path: &Path,
_is_local: bool, _is_local: bool,
_e: &anyhow::Error, _e: &anyhow::Error,
_window: &mut Window, _window: &mut Window,

View file

@ -58,11 +58,7 @@ impl PathList {
let mut paths: Vec<PathBuf> = if serialized.paths.is_empty() { let mut paths: Vec<PathBuf> = if serialized.paths.is_empty() {
Vec::new() Vec::new()
} else { } else {
serde_json::from_str::<Vec<PathBuf>>(&serialized.paths) serialized.paths.split('\n').map(PathBuf::from).collect()
.unwrap_or(Vec::new())
.into_iter()
.map(|s| SanitizedPath::from(s).into())
.collect()
}; };
let mut order: Vec<usize> = serialized let mut order: Vec<usize> = serialized
@ -85,7 +81,13 @@ impl PathList {
pub fn serialize(&self) -> SerializedPathList { pub fn serialize(&self) -> SerializedPathList {
use std::fmt::Write as _; use std::fmt::Write as _;
let paths = serde_json::to_string(&self.paths).unwrap_or_default(); let mut paths = String::new();
for path in self.paths.iter() {
if !paths.is_empty() {
paths.push('\n');
}
paths.push_str(&path.to_string_lossy());
}
let mut order = String::new(); let mut order = String::new();
for ix in self.order.iter() { for ix in self.order.iter() {

View file

@ -10,7 +10,11 @@ use std::{
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use collections::HashMap; use collections::HashMap;
use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql}; use db::{
query,
sqlez::{connection::Connection, domain::Domain},
sqlez_macros::sql,
};
use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size}; use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size};
use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}; use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
@ -275,186 +279,189 @@ impl sqlez::bindable::Bind for SerializedPixels {
} }
} }
define_connection! { pub struct WorkspaceDb(ThreadSafeConnection);
pub static ref DB: WorkspaceDb<()> =
&[
sql!(
CREATE TABLE workspaces(
workspace_id INTEGER PRIMARY KEY,
workspace_location BLOB UNIQUE,
dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
left_sidebar_open INTEGER, // Boolean
timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
) STRICT;
CREATE TABLE pane_groups( impl Domain for WorkspaceDb {
group_id INTEGER PRIMARY KEY, const NAME: &str = stringify!(WorkspaceDb);
workspace_id INTEGER NOT NULL,
parent_group_id INTEGER, // NULL indicates that this is a root node
position INTEGER, // NULL indicates that this is a root node
axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal'
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE panes( const MIGRATIONS: &[&str] = &[
pane_id INTEGER PRIMARY KEY, sql!(
workspace_id INTEGER NOT NULL, CREATE TABLE workspaces(
active INTEGER NOT NULL, // Boolean workspace_id INTEGER PRIMARY KEY,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) workspace_location BLOB UNIQUE,
ON DELETE CASCADE dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
ON UPDATE CASCADE dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
) STRICT; dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
left_sidebar_open INTEGER, // Boolean
timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
) STRICT;
CREATE TABLE center_panes( CREATE TABLE pane_groups(
pane_id INTEGER PRIMARY KEY, group_id INTEGER PRIMARY KEY,
parent_group_id INTEGER, // NULL means that this is a root pane workspace_id INTEGER NOT NULL,
position INTEGER, // NULL means that this is a root pane parent_group_id INTEGER, // NULL indicates that this is a root node
FOREIGN KEY(pane_id) REFERENCES panes(pane_id) position INTEGER, // NULL indicates that this is a root node
ON DELETE CASCADE, axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal'
FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
) STRICT; ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE items( CREATE TABLE panes(
item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique pane_id INTEGER PRIMARY KEY,
workspace_id INTEGER NOT NULL, workspace_id INTEGER NOT NULL,
pane_id INTEGER NOT NULL, active INTEGER NOT NULL, // Boolean
kind TEXT NOT NULL, FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
position INTEGER NOT NULL, ON DELETE CASCADE
active INTEGER NOT NULL, ON UPDATE CASCADE
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ) STRICT;
ON DELETE CASCADE
ON UPDATE CASCADE, CREATE TABLE center_panes(
FOREIGN KEY(pane_id) REFERENCES panes(pane_id) pane_id INTEGER PRIMARY KEY,
ON DELETE CASCADE, parent_group_id INTEGER, // NULL means that this is a root pane
PRIMARY KEY(item_id, workspace_id) position INTEGER, // NULL means that this is a root pane
) STRICT; FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
), ON DELETE CASCADE,
sql!( FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
ALTER TABLE workspaces ADD COLUMN window_state TEXT; ) STRICT;
ALTER TABLE workspaces ADD COLUMN window_x REAL;
ALTER TABLE workspaces ADD COLUMN window_y REAL; CREATE TABLE items(
ALTER TABLE workspaces ADD COLUMN window_width REAL; item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique
ALTER TABLE workspaces ADD COLUMN window_height REAL; workspace_id INTEGER NOT NULL,
ALTER TABLE workspaces ADD COLUMN display BLOB; pane_id INTEGER NOT NULL,
), kind TEXT NOT NULL,
// Drop foreign key constraint from workspaces.dock_pane to panes table. position INTEGER NOT NULL,
sql!( active INTEGER NOT NULL,
CREATE TABLE workspaces_2( FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
workspace_id INTEGER PRIMARY KEY, ON DELETE CASCADE
workspace_location BLOB UNIQUE, ON UPDATE CASCADE,
dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed. FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed. ON DELETE CASCADE,
dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed. PRIMARY KEY(item_id, workspace_id)
left_sidebar_open INTEGER, // Boolean ) STRICT;
timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, ),
window_state TEXT, sql!(
window_x REAL, ALTER TABLE workspaces ADD COLUMN window_state TEXT;
window_y REAL, ALTER TABLE workspaces ADD COLUMN window_x REAL;
window_width REAL, ALTER TABLE workspaces ADD COLUMN window_y REAL;
window_height REAL, ALTER TABLE workspaces ADD COLUMN window_width REAL;
display BLOB ALTER TABLE workspaces ADD COLUMN window_height REAL;
) STRICT; ALTER TABLE workspaces ADD COLUMN display BLOB;
INSERT INTO workspaces_2 SELECT * FROM workspaces; ),
DROP TABLE workspaces; // Drop foreign key constraint from workspaces.dock_pane to panes table.
ALTER TABLE workspaces_2 RENAME TO workspaces; sql!(
), CREATE TABLE workspaces_2(
// Add panels related information workspace_id INTEGER PRIMARY KEY,
sql!( workspace_location BLOB UNIQUE,
ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT; dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT; left_sidebar_open INTEGER, // Boolean
ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT; window_state TEXT,
), window_x REAL,
// Add panel zoom persistence window_y REAL,
sql!( window_width REAL,
ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool window_height REAL,
ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool display BLOB
ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool ) STRICT;
), INSERT INTO workspaces_2 SELECT * FROM workspaces;
// Add pane group flex data DROP TABLE workspaces;
sql!( ALTER TABLE workspaces_2 RENAME TO workspaces;
ALTER TABLE pane_groups ADD COLUMN flexes TEXT; ),
), // Add panels related information
// Add fullscreen field to workspace sql!(
// Deprecated, `WindowBounds` holds the fullscreen state now. ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool
// Preserving so users can downgrade Zed. ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT;
sql!( ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool
ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT;
), ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool
// Add preview field to items ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT;
sql!( ),
ALTER TABLE items ADD COLUMN preview INTEGER; //bool // Add panel zoom persistence
), sql!(
// Add centered_layout field to workspace ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool
sql!( ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool
ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool
), ),
sql!( // Add pane group flex data
CREATE TABLE remote_projects ( sql!(
remote_project_id INTEGER NOT NULL UNIQUE, ALTER TABLE pane_groups ADD COLUMN flexes TEXT;
path TEXT, ),
dev_server_name TEXT // Add fullscreen field to workspace
); // Deprecated, `WindowBounds` holds the fullscreen state now.
ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER; // Preserving so users can downgrade Zed.
ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths; sql!(
), ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool
sql!( ),
DROP TABLE remote_projects; // Add preview field to items
CREATE TABLE dev_server_projects ( sql!(
id INTEGER NOT NULL UNIQUE, ALTER TABLE items ADD COLUMN preview INTEGER; //bool
path TEXT, ),
dev_server_name TEXT // Add centered_layout field to workspace
); sql!(
ALTER TABLE workspaces DROP COLUMN remote_project_id; ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool
ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER; ),
), sql!(
sql!( CREATE TABLE remote_projects (
ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB; remote_project_id INTEGER NOT NULL UNIQUE,
), path TEXT,
sql!( dev_server_name TEXT
ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL; );
), ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER;
sql!( ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths;
ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL; ),
), sql!(
sql!( DROP TABLE remote_projects;
ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0; CREATE TABLE dev_server_projects (
), id INTEGER NOT NULL UNIQUE,
sql!( path TEXT,
CREATE TABLE ssh_projects ( dev_server_name TEXT
id INTEGER PRIMARY KEY, );
host TEXT NOT NULL, ALTER TABLE workspaces DROP COLUMN remote_project_id;
port INTEGER, ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER;
path TEXT NOT NULL, ),
user TEXT sql!(
); ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB;
ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE; ),
), sql!(
sql!( ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL;
ALTER TABLE ssh_projects RENAME COLUMN path TO paths; ),
), sql!(
sql!( ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL;
CREATE TABLE toolchains ( ),
workspace_id INTEGER, sql!(
worktree_id INTEGER, ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0;
language_name TEXT NOT NULL, ),
name TEXT NOT NULL, sql!(
path TEXT NOT NULL, CREATE TABLE ssh_projects (
PRIMARY KEY (workspace_id, worktree_id, language_name) id INTEGER PRIMARY KEY,
); host TEXT NOT NULL,
), port INTEGER,
sql!( path TEXT NOT NULL,
ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}"; user TEXT
), );
sql!( ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE;
),
sql!(
ALTER TABLE ssh_projects RENAME COLUMN path TO paths;
),
sql!(
CREATE TABLE toolchains (
workspace_id INTEGER,
worktree_id INTEGER,
language_name TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (workspace_id, worktree_id, language_name)
);
),
sql!(
ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}";
),
sql!(
CREATE TABLE breakpoints ( CREATE TABLE breakpoints (
workspace_id INTEGER NOT NULL, workspace_id INTEGER NOT NULL,
path TEXT NOT NULL, path TEXT NOT NULL,
@ -466,141 +473,165 @@ define_connection! {
ON UPDATE CASCADE ON UPDATE CASCADE
); );
), ),
sql!( sql!(
ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT; ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT;
CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array); CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array);
ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT; ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT;
), ),
sql!( sql!(
ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL
), ),
sql!( sql!(
ALTER TABLE breakpoints DROP COLUMN kind ALTER TABLE breakpoints DROP COLUMN kind
), ),
sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL), sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL),
sql!( sql!(
ALTER TABLE breakpoints ADD COLUMN condition TEXT; ALTER TABLE breakpoints ADD COLUMN condition TEXT;
ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT; ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT;
), ),
sql!(CREATE TABLE toolchains2 ( sql!(CREATE TABLE toolchains2 (
workspace_id INTEGER, workspace_id INTEGER,
worktree_id INTEGER, worktree_id INTEGER,
language_name TEXT NOT NULL, language_name TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
path TEXT NOT NULL, path TEXT NOT NULL,
raw_json TEXT NOT NULL, raw_json TEXT NOT NULL,
relative_worktree_path TEXT NOT NULL, relative_worktree_path TEXT NOT NULL,
PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT; PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT;
INSERT INTO toolchains2 INSERT INTO toolchains2
SELECT * FROM toolchains; SELECT * FROM toolchains;
DROP TABLE toolchains; DROP TABLE toolchains;
ALTER TABLE toolchains2 RENAME TO toolchains; ALTER TABLE toolchains2 RENAME TO toolchains;
), ),
sql!( sql!(
CREATE TABLE ssh_connections ( CREATE TABLE ssh_connections (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
host TEXT NOT NULL, host TEXT NOT NULL,
port INTEGER, port INTEGER,
user TEXT user TEXT
); );
INSERT INTO ssh_connections (host, port, user) INSERT INTO ssh_connections (host, port, user)
SELECT DISTINCT host, port, user SELECT DISTINCT host, port, user
FROM ssh_projects; FROM ssh_projects;
CREATE TABLE workspaces_2( CREATE TABLE workspaces_2(
workspace_id INTEGER PRIMARY KEY, workspace_id INTEGER PRIMARY KEY,
paths TEXT, paths TEXT,
paths_order TEXT, paths_order TEXT,
ssh_connection_id INTEGER REFERENCES ssh_connections(id), ssh_connection_id INTEGER REFERENCES ssh_connections(id),
timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
window_state TEXT, window_state TEXT,
window_x REAL, window_x REAL,
window_y REAL, window_y REAL,
window_width REAL, window_width REAL,
window_height REAL, window_height REAL,
display BLOB, display BLOB,
left_dock_visible INTEGER, left_dock_visible INTEGER,
left_dock_active_panel TEXT, left_dock_active_panel TEXT,
right_dock_visible INTEGER, right_dock_visible INTEGER,
right_dock_active_panel TEXT, right_dock_active_panel TEXT,
bottom_dock_visible INTEGER, bottom_dock_visible INTEGER,
bottom_dock_active_panel TEXT, bottom_dock_active_panel TEXT,
left_dock_zoom INTEGER, left_dock_zoom INTEGER,
right_dock_zoom INTEGER, right_dock_zoom INTEGER,
bottom_dock_zoom INTEGER, bottom_dock_zoom INTEGER,
fullscreen INTEGER, fullscreen INTEGER,
centered_layout INTEGER, centered_layout INTEGER,
session_id TEXT, session_id TEXT,
window_id INTEGER window_id INTEGER
) STRICT; ) STRICT;
INSERT INSERT
INTO workspaces_2 INTO workspaces_2
SELECT SELECT
workspaces.workspace_id, workspaces.workspace_id,
CASE CASE
WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths
ELSE
CASE
WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN
NULL
ELSE
replace(workspaces.local_paths_array, ',', CHAR(10))
END
END as paths,
CASE
WHEN ssh_projects.id IS NOT NULL THEN ""
ELSE workspaces.local_paths_order_array
END as paths_order,
CASE
WHEN ssh_projects.id IS NOT NULL THEN (
SELECT ssh_connections.id
FROM ssh_connections
WHERE
ssh_connections.host IS ssh_projects.host AND
ssh_connections.port IS ssh_projects.port AND
ssh_connections.user IS ssh_projects.user
)
ELSE NULL
END as ssh_connection_id,
workspaces.timestamp,
workspaces.window_state,
workspaces.window_x,
workspaces.window_y,
workspaces.window_width,
workspaces.window_height,
workspaces.display,
workspaces.left_dock_visible,
workspaces.left_dock_active_panel,
workspaces.right_dock_visible,
workspaces.right_dock_active_panel,
workspaces.bottom_dock_visible,
workspaces.bottom_dock_active_panel,
workspaces.left_dock_zoom,
workspaces.right_dock_zoom,
workspaces.bottom_dock_zoom,
workspaces.fullscreen,
workspaces.centered_layout,
workspaces.session_id,
workspaces.window_id
FROM
workspaces LEFT JOIN
ssh_projects ON
workspaces.ssh_project_id = ssh_projects.id;
DROP TABLE ssh_projects;
DROP TABLE workspaces;
ALTER TABLE workspaces_2 RENAME TO workspaces;
CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths);
),
// Fix any data from when workspaces.paths were briefly encoded as JSON arrays
sql!(
UPDATE workspaces
SET paths = CASE
WHEN substr(paths, 1, 2) = '[' || '"' AND substr(paths, -2, 2) = '"' || ']' THEN
replace(
substr(paths, 3, length(paths) - 4),
'"' || ',' || '"',
CHAR(10)
)
ELSE ELSE
CASE replace(paths, ',', CHAR(10))
WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN END
NULL WHERE paths IS NOT NULL
ELSE ),
json('[' || '"' || replace(workspaces.local_paths_array, ',', '"' || "," || '"') || '"' || ']')
END
END as paths,
CASE
WHEN ssh_projects.id IS NOT NULL THEN ""
ELSE workspaces.local_paths_order_array
END as paths_order,
CASE
WHEN ssh_projects.id IS NOT NULL THEN (
SELECT ssh_connections.id
FROM ssh_connections
WHERE
ssh_connections.host IS ssh_projects.host AND
ssh_connections.port IS ssh_projects.port AND
ssh_connections.user IS ssh_projects.user
)
ELSE NULL
END as ssh_connection_id,
workspaces.timestamp,
workspaces.window_state,
workspaces.window_x,
workspaces.window_y,
workspaces.window_width,
workspaces.window_height,
workspaces.display,
workspaces.left_dock_visible,
workspaces.left_dock_active_panel,
workspaces.right_dock_visible,
workspaces.right_dock_active_panel,
workspaces.bottom_dock_visible,
workspaces.bottom_dock_active_panel,
workspaces.left_dock_zoom,
workspaces.right_dock_zoom,
workspaces.bottom_dock_zoom,
workspaces.fullscreen,
workspaces.centered_layout,
workspaces.session_id,
workspaces.window_id
FROM
workspaces LEFT JOIN
ssh_projects ON
workspaces.ssh_project_id = ssh_projects.id;
DROP TABLE ssh_projects;
DROP TABLE workspaces;
ALTER TABLE workspaces_2 RENAME TO workspaces;
CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths);
),
]; ];
// Allow recovering from bad migration that was initially shipped to nightly
// when introducing the ssh_connections table.
fn should_allow_migration_change(_index: usize, old: &str, new: &str) -> bool {
old.starts_with("CREATE TABLE ssh_connections")
&& new.starts_with("CREATE TABLE ssh_connections")
}
} }
db::static_connection!(DB, WorkspaceDb, []);
impl WorkspaceDb { impl WorkspaceDb {
/// Returns a serialized workspace for the given worktree_roots. If the passed array /// Returns a serialized workspace for the given worktree_roots. If the passed array
/// is empty, the most recent workspace is returned instead. If no workspace for the /// is empty, the most recent workspace is returned instead. If no workspace for the
@ -1803,6 +1834,7 @@ mod tests {
ON DELETE CASCADE ON DELETE CASCADE
) STRICT; ) STRICT;
)], )],
|_, _, _| false,
) )
.unwrap(); .unwrap();
}) })
@ -1851,6 +1883,7 @@ mod tests {
REFERENCES workspaces(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE ON DELETE CASCADE
) STRICT;)], ) STRICT;)],
|_, _, _| false,
) )
}) })
.await .await

View file

@ -613,48 +613,59 @@ impl ProjectItemRegistry {
self.build_project_item_for_path_fns self.build_project_item_for_path_fns
.push(|project, project_path, window, cx| { .push(|project, project_path, window, cx| {
let project_path = project_path.clone(); let project_path = project_path.clone();
let abs_path = project.read(cx).absolute_path(&project_path, cx); let is_file = project
.read(cx)
.entry_for_path(&project_path, cx)
.is_some_and(|entry| entry.is_file());
let entry_abs_path = project.read(cx).absolute_path(&project_path, cx);
let is_local = project.read(cx).is_local(); let is_local = project.read(cx).is_local();
let project_item = let project_item =
<T::Item as project::ProjectItem>::try_open(project, &project_path, cx)?; <T::Item as project::ProjectItem>::try_open(project, &project_path, cx)?;
let project = project.clone(); let project = project.clone();
Some(window.spawn(cx, async move |cx| match project_item.await { Some(window.spawn(cx, async move |cx| {
Ok(project_item) => { match project_item.await.with_context(|| {
let project_item = project_item; format!(
let project_entry_id: Option<ProjectEntryId> = "opening project path {:?}",
project_item.read_with(cx, project::ProjectItem::entry_id)?; entry_abs_path.as_deref().unwrap_or(&project_path.path)
let build_workspace_item = Box::new( )
|pane: &mut Pane, window: &mut Window, cx: &mut Context<Pane>| { }) {
Box::new(cx.new(|cx| { Ok(project_item) => {
T::for_project_item( let project_item = project_item;
project, let project_entry_id: Option<ProjectEntryId> =
Some(pane), project_item.read_with(cx, project::ProjectItem::entry_id)?;
project_item, let build_workspace_item = Box::new(
window, |pane: &mut Pane, window: &mut Window, cx: &mut Context<Pane>| {
cx, Box::new(cx.new(|cx| {
) T::for_project_item(
})) as Box<dyn ItemHandle> project,
}, Some(pane),
) as Box<_>; project_item,
Ok((project_entry_id, build_workspace_item)) window,
} cx,
Err(e) => match abs_path { )
Some(abs_path) => match cx.update(|window, cx| { })) as Box<dyn ItemHandle>
T::for_broken_project_item(abs_path, is_local, &e, window, cx) },
})? { ) as Box<_>;
Some(broken_project_item_view) => { Ok((project_entry_id, build_workspace_item))
let build_workspace_item = Box::new( }
Err(e) => match entry_abs_path.as_deref().filter(|_| is_file) {
Some(abs_path) => match cx.update(|window, cx| {
T::for_broken_project_item(abs_path, is_local, &e, window, cx)
})? {
Some(broken_project_item_view) => {
let build_workspace_item = Box::new(
move |_: &mut Pane, _: &mut Window, cx: &mut Context<Pane>| { move |_: &mut Pane, _: &mut Window, cx: &mut Context<Pane>| {
cx.new(|_| broken_project_item_view).boxed_clone() cx.new(|_| broken_project_item_view).boxed_clone()
}, },
) )
as Box<_>; as Box<_>;
Ok((None, build_workspace_item)) Ok((None, build_workspace_item))
} }
None => Err(e)?,
},
None => Err(e)?, None => Err(e)?,
}, },
None => Err(e)?, }
},
})) }))
}); });
} }
@ -4011,52 +4022,6 @@ impl Workspace {
maybe_pane_handle maybe_pane_handle
} }
pub fn split_pane_with_item(
&mut self,
pane_to_split: WeakEntity<Pane>,
split_direction: SplitDirection,
from: WeakEntity<Pane>,
item_id_to_move: EntityId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(pane_to_split) = pane_to_split.upgrade() else {
return;
};
let Some(from) = from.upgrade() else {
return;
};
let new_pane = self.add_pane(window, cx);
move_item(&from, &new_pane, item_id_to_move, 0, true, window, cx);
self.center
.split(&pane_to_split, &new_pane, split_direction)
.unwrap();
cx.notify();
}
pub fn split_pane_with_project_entry(
&mut self,
pane_to_split: WeakEntity<Pane>,
split_direction: SplitDirection,
project_entry: ProjectEntryId,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
let pane_to_split = pane_to_split.upgrade()?;
let new_pane = self.add_pane(window, cx);
self.center
.split(&pane_to_split, &new_pane, split_direction)
.unwrap();
let path = self.project.read(cx).path_for_entry(project_entry, cx)?;
let task = self.open_path(path, Some(new_pane.downgrade()), true, window, cx);
Some(cx.foreground_executor().spawn(async move {
task.await?;
Ok(())
}))
}
pub fn join_all_panes(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn join_all_panes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let active_item = self.active_pane.read(cx).active_item(); let active_item = self.active_pane.read(cx).active_item();
for pane in &self.panes { for pane in &self.panes {
@ -6622,15 +6587,29 @@ impl Render for Workspace {
} }
}) })
.children(self.zoomed.as_ref().and_then(|view| { .children(self.zoomed.as_ref().and_then(|view| {
Some(div() let zoomed_view = view.upgrade()?;
let div = div()
.occlude() .occlude()
.absolute() .absolute()
.overflow_hidden() .overflow_hidden()
.border_color(colors.border) .border_color(colors.border)
.bg(colors.background) .bg(colors.background)
.child(view.upgrade()?) .child(zoomed_view)
.inset_0() .inset_0()
.shadow_lg()) .shadow_lg();
if !WorkspaceSettings::get_global(cx).zoomed_padding {
return Some(div);
}
Some(match self.zoomed_position {
Some(DockPosition::Left) => div.right_2().border_r_1(),
Some(DockPosition::Right) => div.left_2().border_l_1(),
Some(DockPosition::Bottom) => div.top_2().border_t_1(),
None => {
div.top_2().bottom_2().left_2().right_2().border_1()
}
})
})) }))
.children(self.render_notifications(window, cx)), .children(self.render_notifications(window, cx)),
) )

View file

@ -29,6 +29,7 @@ pub struct WorkspaceSettings {
pub on_last_window_closed: OnLastWindowClosed, pub on_last_window_closed: OnLastWindowClosed,
pub resize_all_panels_in_dock: Vec<DockPosition>, pub resize_all_panels_in_dock: Vec<DockPosition>,
pub close_on_file_delete: bool, pub close_on_file_delete: bool,
pub zoomed_padding: bool,
} }
#[derive(Copy, Clone, Default, Serialize, Deserialize, JsonSchema)] #[derive(Copy, Clone, Default, Serialize, Deserialize, JsonSchema)]
@ -202,6 +203,12 @@ pub struct WorkspaceSettingsContent {
/// ///
/// Default: false /// Default: false
pub close_on_file_delete: Option<bool>, pub close_on_file_delete: Option<bool>,
/// Whether to show padding for zoomed panels.
/// When enabled, zoomed bottom panels will have some top padding,
/// while zoomed left/right panels will have padding to the right/left (respectively).
///
/// Default: true
pub zoomed_padding: Option<bool>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]

View file

@ -4434,7 +4434,6 @@ mod tests {
assert_eq!(actions_without_namespace, Vec::<&str>::new()); assert_eq!(actions_without_namespace, Vec::<&str>::new());
let expected_namespaces = vec![ let expected_namespaces = vec![
"acp",
"activity_indicator", "activity_indicator",
"agent", "agent",
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]

View file

@ -1,10 +1,17 @@
use anyhow::Result; use anyhow::Result;
use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; use db::{
query,
sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
use workspace::{ItemId, WorkspaceDb, WorkspaceId}; use workspace::{ItemId, WorkspaceDb, WorkspaceId};
define_connection! { pub struct ComponentPreviewDb(ThreadSafeConnection);
pub static ref COMPONENT_PREVIEW_DB: ComponentPreviewDb<WorkspaceDb> =
&[sql!( impl Domain for ComponentPreviewDb {
const NAME: &str = stringify!(ComponentPreviewDb);
const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE component_previews ( CREATE TABLE component_previews (
workspace_id INTEGER, workspace_id INTEGER,
item_id INTEGER UNIQUE, item_id INTEGER UNIQUE,
@ -13,9 +20,11 @@ define_connection! {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE ON DELETE CASCADE
) STRICT; ) STRICT;
)]; )];
} }
db::static_connection!(COMPONENT_PREVIEW_DB, ComponentPreviewDb, [WorkspaceDb]);
impl ComponentPreviewDb { impl ComponentPreviewDb {
pub async fn save_active_page( pub async fn save_active_page(
&self, &self,

View file

@ -292,7 +292,9 @@ pub mod agent {
Chat, Chat,
/// Toggles the language model selector dropdown. /// Toggles the language model selector dropdown.
#[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])] #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])]
ToggleModelSelector ToggleModelSelector,
/// Triggers re-authentication on Gemini
ReauthenticateAgent
] ]
); );
} }