diff --git a/Cargo.lock b/Cargo.lock index 7d945d75201f27a2c69951da92db64629d0d2471..ea9e6fc49fdf0d0bf886ba61fd6e309e8e561241 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,7 +150,9 @@ dependencies = [ "indoc", "itertools 0.14.0", "language", + "libc", "log", + "nix 0.29.0", "paths", "project", "schemars", @@ -163,6 +165,7 @@ dependencies = [ "tempfile", "ui", "util", + "uuid", "watch", "which 6.0.3", "workspace-hack", @@ -9166,7 +9169,6 @@ dependencies = [ "collections", "copilot", "editor", - "feature_flags", "futures 0.3.31", "gpui", "itertools 0.14.0", diff --git a/assets/icons/ai_claude.svg b/assets/icons/ai_claude.svg index 423a963eba9b9492a9807082922a4c072786d843..a3e3e1f4cd7bcc4924ed3f8164c35c5c8e2a9c4c 100644 --- a/assets/icons/ai_claude.svg +++ b/assets/icons/ai_claude.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/ai_gemini.svg b/assets/icons/ai_gemini.svg index 60197dc4adcf912128756b32ead43b8b1da61222..bdde44ed2475313f0dfd418a496f372ca61db22d 100644 --- a/assets/icons/ai_gemini.svg +++ b/assets/icons/ai_gemini.svg @@ -1 +1,3 @@ -Google Gemini + + + diff --git a/assets/icons/new_from_summary.svg b/assets/icons/new_from_summary.svg new file mode 100644 index 0000000000000000000000000000000000000000..3b61ca51a08ca8901333d8beb172147b1f1cfcd0 --- /dev/null +++ b/assets/icons/new_from_summary.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/new_text_thread.svg b/assets/icons/new_text_thread.svg new file mode 100644 index 0000000000000000000000000000000000000000..75afa934a028f1bddd104effe536db70ad4f241c --- /dev/null +++ b/assets/icons/new_text_thread.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/new_thread.svg b/assets/icons/new_thread.svg new file mode 100644 index 0000000000000000000000000000000000000000..8c2596a4c9fca9f75a122dc85225f33696320030 --- /dev/null +++ b/assets/icons/new_thread.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 6aba27fec8fee9ed601e6bae43d575a5d050f95b..4918e654fc50e7282cf5ee99228c77381d6997ee 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -277,7 +277,7 @@ } }, { - "context": "MessageEditor > Editor && !use_modifier_to_send", + "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", "bindings": { "enter": "agent::Chat", "ctrl-enter": "agent::ChatWithFollow", @@ -288,7 +288,7 @@ } }, { - "context": "MessageEditor > Editor && use_modifier_to_send", + "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", "bindings": { "ctrl-enter": "agent::Chat", "enter": "editor::Newline", @@ -483,9 +483,8 @@ "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "ctrl-k ctrl-i": "editor::Hover", + "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], - "ctrl-u": "editor::UndoSelection", - "ctrl-shift-u": "editor::RedoSelection", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -663,6 +662,8 @@ { "context": "Editor", "bindings": { + "ctrl-u": "editor::UndoSelection", + "ctrl-shift-u": "editor::RedoSelection", "ctrl-shift-j": "editor::JoinLines", "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", "ctrl-alt-h": "editor::DeleteToPreviousSubwordStart", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ba903c07821fec6fad805e3a4b1cac81831216ed..60f29b1da148e26d72744f42252f6086894cd5db 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -318,7 +318,7 @@ } }, { - "context": "MessageEditor > Editor && !use_modifier_to_send", + "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", "use_key_equivalents": true, "bindings": { "enter": "agent::Chat", @@ -330,7 +330,7 @@ } }, { - "context": "MessageEditor > Editor && use_modifier_to_send", + "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", "use_key_equivalents": true, "bindings": { "cmd-enter": "agent::Chat", @@ -537,9 +537,8 @@ "ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch "cmd-k ctrl-cmd-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch "cmd-k cmd-i": "editor::Hover", + "cmd-k cmd-b": "editor::BlameHover", "cmd-/": ["editor::ToggleComments", { "advance_downwards": false }], - "cmd-u": "editor::UndoSelection", - "cmd-shift-u": "editor::RedoSelection", "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -726,6 +725,8 @@ "context": "Editor", "use_key_equivalents": true, "bindings": { + "cmd-u": "editor::UndoSelection", + "cmd-shift-u": "editor::RedoSelection", "ctrl-j": "editor::JoinLines", "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", "ctrl-alt-h": "editor::DeleteToPreviousSubwordStart", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 2ef282c21edc87fe5f062f8083296b43cc0a571d..d0cf4621a59d8614022f2a4ba176a79b40f8d331 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -124,6 +124,7 @@ "g r a": "editor::ToggleCodeActions", "g g": "vim::StartOfDocument", "g h": "editor::Hover", + "g B": "editor::BlameHover", "g t": "pane::ActivateNextItem", "g shift-t": "pane::ActivatePreviousItem", "g d": "editor::GoToDefinition", @@ -858,6 +859,14 @@ "shift-n": null } }, + { + "context": "Picker > Editor", + "bindings": { + "ctrl-h": "editor::Backspace", + "ctrl-u": "editor::DeleteToBeginningOfLine", + "ctrl-w": "editor::DeleteToPreviousWordStart" + } + }, { "context": "GitCommit > Editor && VimControl && vim_mode == normal", "bindings": { diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index aee25fc9e39d533409b980782fa8f0cac3977935..f8ea7173d8afecb14a8e8ed9e1de6a87660ddc1a 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -231,7 +231,6 @@ impl ActivityIndicator { status, } => { let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx)); - let project = project.clone(); let status = status.clone(); let server_name = server_name.clone(); cx.spawn_in(window, async move |workspace, cx| { @@ -247,8 +246,7 @@ impl ActivityIndicator { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane( Box::new(cx.new(|cx| { - let mut editor = - Editor::for_buffer(buffer, Some(project.clone()), window, cx); + let mut editor = Editor::for_buffer(buffer, None, window, cx); editor.set_read_only(true); editor })), diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 180cc88390eb21d96c5109fac5802f42e425f83a..e50763535a461bef6769b4f7c1aadeb2f219b904 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -51,7 +51,7 @@ use util::{ResultExt as _, debug_panic, post_inc}; use uuid::Uuid; use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; -const MAX_RETRY_ATTEMPTS: u8 = 3; +const MAX_RETRY_ATTEMPTS: u8 = 4; const BASE_RETRY_DELAY: Duration = Duration::from_secs(5); #[derive(Debug, Clone)] @@ -2182,8 +2182,8 @@ impl Thread { // General strategy here: // - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all. - // - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), try multiple times with exponential backoff. - // - If it's an issue that *might* be fixed by retrying (e.g. internal server error), just retry once. + // - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), retry up to 4 times with exponential backoff. + // - If it's an issue that *might* be fixed by retrying (e.g. internal server error), retry up to 3 times. match error { HttpResponseError { status_code: StatusCode::TOO_MANY_REQUESTS, @@ -2211,8 +2211,8 @@ impl Thread { } StatusCode::INTERNAL_SERVER_ERROR => Some(RetryStrategy::Fixed { delay: retry_after.unwrap_or(BASE_RETRY_DELAY), - // Internal Server Error could be anything, so only retry once. - max_attempts: 1, + // Internal Server Error could be anything, retry up to 3 times. + max_attempts: 3, }), status => { // There is no StatusCode variant for the unofficial HTTP 529 ("The service is overloaded"), @@ -2223,20 +2223,23 @@ impl Thread { max_attempts: MAX_RETRY_ATTEMPTS, }) } else { - None + Some(RetryStrategy::Fixed { + delay: retry_after.unwrap_or(BASE_RETRY_DELAY), + max_attempts: 2, + }) } } }, ApiInternalServerError { .. } => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, - max_attempts: 1, + max_attempts: 3, }), ApiReadResponseError { .. } | HttpSend { .. } | DeserializeResponse { .. } | BadRequestFormat { .. } => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, - max_attempts: 1, + max_attempts: 3, }), // Retrying these errors definitely shouldn't help. HttpResponseError { @@ -2244,24 +2247,31 @@ impl Thread { StatusCode::PAYLOAD_TOO_LARGE | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED, .. } - | SerializeRequest { .. } + | AuthenticationError { .. } + | PermissionError { .. } => None, + // These errors might be transient, so retry them + SerializeRequest { .. } | BuildRequestBody { .. } | PromptTooLarge { .. } - | AuthenticationError { .. } - | PermissionError { .. } | ApiEndpointNotFound { .. } - | NoApiKey { .. } => None, + | NoApiKey { .. } => Some(RetryStrategy::Fixed { + delay: BASE_RETRY_DELAY, + max_attempts: 2, + }), // Retry all other 4xx and 5xx errors once. HttpResponseError { status_code, .. } if status_code.is_client_error() || status_code.is_server_error() => { Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, - max_attempts: 1, + max_attempts: 3, }) } // Conservatively assume that any other errors are non-retryable - HttpResponseError { .. } | Other(..) => None, + HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed { + delay: BASE_RETRY_DELAY, + max_attempts: 2, + }), } } @@ -4352,7 +4362,7 @@ fn main() {{ let retry_state = thread.retry_state.as_ref().unwrap(); assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); assert_eq!( - retry_state.max_attempts, 1, + retry_state.max_attempts, 3, "Should have correct max attempts" ); }); @@ -4368,8 +4378,9 @@ fn main() {{ if let MessageSegment::Text(text) = seg { text.contains("internal") && text.contains("Fake") - && text.contains("Retrying in") - && !text.contains("attempt") + && text.contains("Retrying") + && text.contains("attempt 1 of 3") + && text.contains("seconds") } else { false } @@ -4464,8 +4475,8 @@ fn main() {{ let retry_state = thread.retry_state.as_ref().unwrap(); assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); assert_eq!( - retry_state.max_attempts, 1, - "Internal server errors should only retry once" + retry_state.max_attempts, 3, + "Internal server errors should retry up to 3 times" ); }); @@ -4473,7 +4484,15 @@ fn main() {{ cx.executor().advance_clock(BASE_RETRY_DELAY); cx.run_until_parked(); - // Should have scheduled second retry - count retry messages + // Advance clock for second retry + cx.executor().advance_clock(BASE_RETRY_DELAY); + cx.run_until_parked(); + + // Advance clock for third retry + cx.executor().advance_clock(BASE_RETRY_DELAY); + cx.run_until_parked(); + + // Should have completed all retries - count retry messages let retry_count = thread.update(cx, |thread, _| { thread .messages @@ -4491,24 +4510,24 @@ fn main() {{ .count() }); assert_eq!( - retry_count, 1, - "Should have only one retry for internal server errors" + retry_count, 3, + "Should have 3 retries for internal server errors" ); - // For internal server errors, we only retry once and then give up - // Check that retry_state is cleared after the single retry + // For internal server errors, we retry 3 times and then give up + // Check that retry_state is cleared after all retries thread.read_with(cx, |thread, _| { assert!( thread.retry_state.is_none(), - "Retry state should be cleared after single retry" + "Retry state should be cleared after all retries" ); }); - // Verify total attempts (1 initial + 1 retry) + // Verify total attempts (1 initial + 3 retries) assert_eq!( *completion_count.lock(), - 2, - "Should have attempted once plus 1 retry" + 4, + "Should have attempted once plus 3 retries" ); } diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index af7854aac48a2a2804a7f78c2f21356032f9f484..0552677a27e61462b14d733bce93004d291fc723 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -37,11 +37,16 @@ strum.workspace = true tempfile.workspace = true ui.workspace = true util.workspace = true +uuid.workspace = true watch.workspace = true which.workspace = true shlex.workspace = true workspace-hack.workspace = true +[target.'cfg(unix)'.dependencies] +libc.workspace = true +nix.workspace = true + [dev-dependencies] env_logger.workspace = true language.workspace = true diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 6891b1b3c18ec50e9ce95a34818fcfea0eb6c451..5c7deb489ba319c49569620b85d15af586ae84e9 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -3,10 +3,13 @@ pub mod tools; use collections::HashMap; use project::Project; use settings::SettingsStore; +use smol::process::Child; use std::cell::RefCell; use std::fmt::Display; use std::path::Path; +use std::pin::pin; use std::rc::Rc; +use uuid::Uuid; use agentic_coding_protocol::{ self as acp, AnyAgentRequest, AnyAgentResult, Client, ProtocolVersion, @@ -15,7 +18,7 @@ use agentic_coding_protocol::{ use anyhow::{Result, anyhow}; use futures::channel::oneshot; use futures::future::LocalBoxFuture; -use futures::{AsyncBufReadExt, AsyncWriteExt}; +use futures::{AsyncBufReadExt, AsyncWriteExt, SinkExt}; use futures::{ AsyncRead, AsyncWrite, FutureExt, StreamExt, channel::mpsc::{self, UnboundedReceiver, UnboundedSender}, @@ -27,7 +30,7 @@ use serde::{Deserialize, Serialize}; use util::ResultExt; use crate::claude::tools::ClaudeTool; -use crate::mcp_server::{McpConfig, ZedMcpServer}; +use crate::mcp_server::{self, McpConfig, ZedMcpServer}; use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings}; use acp_thread::{AcpClientDelegate, AcpThread, AgentConnection}; @@ -97,50 +100,58 @@ impl AgentServer for ClaudeCode { anyhow::bail!("Failed to find claude binary"); }; - let mut child = util::command::new_smol_command(&command.path) - .args( - [ - "--input-format", - "stream-json", - "--output-format", - "stream-json", - "--print", - "--verbose", - "--mcp-config", - mcp_config_path.to_string_lossy().as_ref(), - "--permission-prompt-tool", - &format!( - "mcp__{}__{}", - crate::mcp_server::SERVER_NAME, - crate::mcp_server::PERMISSION_TOOL - ), - "--allowedTools", - "mcp__zed__Read,mcp__zed__Edit", - "--disallowedTools", - "Read,Edit", - ] - .into_iter() - .chain(command.args.iter().map(|arg| arg.as_str())), - ) - .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(); - let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded(); let (outgoing_tx, outgoing_rx) = mpsc::unbounded(); + let (cancel_tx, mut cancel_rx) = mpsc::unbounded::>>(); + + let session_id = Uuid::new_v4(); + + log::trace!("Starting session with id: {}", session_id); - let io_task = - ClaudeAgentConnection::handle_io(outgoing_rx, incoming_message_tx, stdin, stdout); cx.background_spawn(async move { - io_task.await.log_err(); + let mut outgoing_rx = Some(outgoing_rx); + let mut mode = ClaudeSessionMode::Start; + + loop { + let mut child = + spawn_claude(&command, mode, session_id, &mcp_config_path, &root_dir) + .await?; + mode = ClaudeSessionMode::Resume; + + let pid = child.id(); + log::trace!("Spawned (pid: {})", pid); + + let mut io_fut = pin!( + ClaudeAgentConnection::handle_io( + outgoing_rx.take().unwrap(), + incoming_message_tx.clone(), + child.stdin.take().unwrap(), + child.stdout.take().unwrap(), + ) + .fuse() + ); + + select_biased! { + done_tx = cancel_rx.next() => { + if let Some(done_tx) = done_tx { + log::trace!("Interrupted (pid: {})", pid); + let result = send_interrupt(pid as i32); + outgoing_rx.replace(io_fut.await?); + done_tx.send(result).log_err(); + continue; + } + } + result = io_fut => { + result?; + } + } + + log::trace!("Stopped (pid: {})", pid); + break; + } + drop(mcp_config_path); - drop(child); + anyhow::Ok(()) }) .detach(); @@ -170,6 +181,8 @@ impl AgentServer for ClaudeCode { delegate, outgoing_tx, end_turn_tx, + cancel_tx, + session_id, _handler_task: handler_task, _mcp_server: None, }; @@ -181,6 +194,19 @@ impl AgentServer for ClaudeCode { } } +#[cfg(unix)] +fn send_interrupt(pid: libc::pid_t) -> anyhow::Result<()> { + let pid = nix::unistd::Pid::from_raw(pid); + + nix::sys::signal::kill(pid, nix::sys::signal::SIGINT) + .map_err(|e| anyhow!("Failed to interrupt process: {}", e)) +} + +#[cfg(windows)] +fn send_interrupt(_pid: i32) -> anyhow::Result<()> { + panic!("Cancel not implemented on Windows") +} + impl AgentConnection for ClaudeAgentConnection { /// Send a request to the agent and wait for a response. fn request_any( @@ -190,6 +216,8 @@ impl AgentConnection for ClaudeAgentConnection { let delegate = self.delegate.clone(); let end_turn_tx = self.end_turn_tx.clone(); let outgoing_tx = self.outgoing_tx.clone(); + let mut cancel_tx = self.cancel_tx.clone(); + let session_id = self.session_id; async move { match params { // todo: consider sending an empty request so we get the init response? @@ -228,26 +256,83 @@ impl AgentConnection for ClaudeAgentConnection { stop_sequence: None, usage: None, }, - session_id: None, + session_id: Some(session_id), })?; rx.await??; Ok(AnyAgentResult::SendUserMessageResponse( acp::SendUserMessageResponse, )) } - AnyAgentRequest::CancelSendMessageParams(_) => Ok( - AnyAgentResult::CancelSendMessageResponse(acp::CancelSendMessageResponse), - ), + AnyAgentRequest::CancelSendMessageParams(_) => { + let (done_tx, done_rx) = oneshot::channel(); + cancel_tx.send(done_tx).await?; + done_rx.await??; + + Ok(AnyAgentResult::CancelSendMessageResponse( + acp::CancelSendMessageResponse, + )) + } } } .boxed_local() } } +#[derive(Clone, Copy)] +enum ClaudeSessionMode { + Start, + Resume, +} + +async fn spawn_claude( + command: &AgentServerCommand, + mode: ClaudeSessionMode, + session_id: Uuid, + mcp_config_path: &Path, + root_dir: &Path, +) -> Result { + let child = util::command::new_smol_command(&command.path) + .args([ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--print", + "--verbose", + "--mcp-config", + mcp_config_path.to_string_lossy().as_ref(), + "--permission-prompt-tool", + &format!( + "mcp__{}__{}", + mcp_server::SERVER_NAME, + mcp_server::PERMISSION_TOOL + ), + "--allowedTools", + "mcp__zed__Read,mcp__zed__Edit", + "--disallowedTools", + "Read,Edit", + ]) + .args(match mode { + ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()], + ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()], + }) + .args(command.args.iter().map(|arg| arg.as_str())) + .current_dir(root_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .spawn()?; + + Ok(child) +} + struct ClaudeAgentConnection { delegate: AcpClientDelegate, + session_id: Uuid, outgoing_tx: UnboundedSender, end_turn_tx: Rc>>>>, + cancel_tx: UnboundedSender>>, _mcp_server: Option, _handler_task: Task<()>, } @@ -349,7 +434,7 @@ impl ClaudeAgentConnection { incoming_tx: UnboundedSender, mut outgoing_bytes: impl Unpin + AsyncWrite, incoming_bytes: impl Unpin + AsyncRead, - ) -> Result<()> { + ) -> Result> { let mut output_reader = BufReader::new(incoming_bytes); let mut outgoing_line = Vec::new(); let mut incoming_line = String::new(); @@ -383,7 +468,8 @@ impl ClaudeAgentConnection { } } } - Ok(()) + + Ok(outgoing_rx) } } @@ -506,14 +592,14 @@ enum SdkMessage { Assistant { message: Message, // from Anthropic SDK #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, + session_id: Option, }, // A user message User { message: Message, // from Anthropic SDK #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, + session_id: Option, }, // Emitted as the last message in a conversation diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index b5bf9056f664446eb6747235e291b1c8d5f2d1f2..0045ecc58497913ef33bb1d029b809063bc71850 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -94,8 +94,7 @@ impl AgentServer for Codex { let codex_mcp_client: Arc = ContextServer::stdio( ContextServerId("codex-mcp-server".into()), ContextServerCommand { - // todo! should we change ContextServerCommand to take a PathBuf? - path: command.path.to_string_lossy().to_string(), + path: command.path, args: command.args, env: command.env, }, diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 12f74cb13e41fea5c313729edf857173af94f74e..95c4d3e3a0d2d7bf83bca8532c13c953619789f8 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -350,6 +350,8 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { claude: Some(AgentServerSettings { command: crate::claude::tests::local_command(), }), + // todo! + codex: None, gemini: Some(AgentServerSettings { command: crate::gemini::tests::local_command(), }), diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index 14e7cf05b51b8f302d58ee928c7c0bbde0d4fc31..bfed81f5b7e07baf7b2f4db489742ce0944cf538 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -3724,8 +3724,11 @@ pub(crate) fn open_context( AgentContextHandle::Thread(thread_context) => workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.open_thread(thread_context.thread.clone(), window, cx); + let thread = thread_context.thread.clone(); + window.defer(cx, move |window, cx| { + panel.update(cx, |panel, cx| { + panel.open_thread(thread, window, cx); + }); }); } }), @@ -3733,8 +3736,11 @@ pub(crate) fn open_context( AgentContextHandle::TextThread(text_thread_context) => { workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.open_prompt_editor(text_thread_context.context.clone(), window, cx) + let context = text_thread_context.context.clone(); + window.defer(cx, move |window, cx| { + panel.update(cx, |panel, cx| { + panel.open_prompt_editor(context, window, cx) + }); }); } }) diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 9e5f6e09c82489dd4ccdc89f188e962ceeec596d..06d035d836853068c8ed402ee0e85ff85d9af6b2 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -1,4 +1,5 @@ use std::{ + path::PathBuf, sync::{Arc, Mutex}, time::Duration, }; @@ -188,7 +189,7 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) } None => ( "some-mcp-server".to_string(), - "".to_string(), + PathBuf::new(), "[]".to_string(), "{}".to_string(), ), @@ -199,13 +200,14 @@ fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand) /// The name of your MCP server "{name}": {{ /// The command which runs the MCP server - "command": "{command}", + "command": "{}", /// The arguments to pass to the MCP server "args": {args}, /// The environment variables to set "env": {env} }} -}}"# +}}"#, + command.display() ) } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index a731a7c1bea6f446abc2be3ddf4f124fd34fee95..e115957d26d7b39bd231e460b29ec97b0ca04767 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::NewExternalAgentThread; use crate::agent_diff::AgentDiffThread; use crate::message_editor::{MAX_EDITOR_LINES, MIN_EDITOR_LINES}; +use crate::ui::NewThreadButton; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, @@ -66,8 +67,8 @@ use theme::ThemeSettings; use time::UtcOffset; use ui::utils::WithRemSize; use ui::{ - Banner, Callout, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu, PopoverMenuHandle, - ProgressBar, Tab, Tooltip, prelude::*, + Banner, Callout, ContextMenu, ContextMenuEntry, ElevationIndex, KeyBinding, PopoverMenu, + PopoverMenuHandle, ProgressBar, Tab, Tooltip, prelude::*, }; use util::ResultExt as _; use workspace::{ @@ -1906,16 +1907,39 @@ impl AgentPanel { .when(cx.has_flag::(), |this| { this.header("Zed Agent") }) - .action("New Thread", NewThread::default().boxed_clone()) - .action("New Text Thread", NewTextThread.boxed_clone()) + .item( + ContextMenuEntry::new("New Thread") + .icon(IconName::NewThread) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action(NewThread::default().boxed_clone(), cx); + }), + ) + .item( + ContextMenuEntry::new("New Text Thread") + .icon(IconName::NewTextThread) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action(NewTextThread.boxed_clone(), cx); + }), + ) .when_some(active_thread, |this, active_thread| { let thread = active_thread.read(cx); + if !thread.is_empty() { - this.action( - "New From Summary", - Box::new(NewThread { - from_thread_id: Some(thread.id().clone()), - }), + let thread_id = thread.id().clone(); + this.item( + ContextMenuEntry::new("New From Summary") + .icon(IconName::NewFromSummary) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + Box::new(NewThread { + from_thread_id: Some(thread_id.clone()), + }), + cx, + ); + }), ) } else { this @@ -1924,19 +1948,33 @@ impl AgentPanel { .when(cx.has_flag::(), |this| { this.separator() .header("External Agents") - .action( - "New Gemini Thread", - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::Gemini), - } - .boxed_clone(), + .item( + ContextMenuEntry::new("New Gemini Thread") + .icon(IconName::AiGemini) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(crate::ExternalAgent::Gemini), + } + .boxed_clone(), + cx, + ); + }), ) - .action( - "New Claude Code Thread", - NewExternalAgentThread { - agent: Some(crate::ExternalAgent::ClaudeCode), - } - .boxed_clone(), + .item( + ContextMenuEntry::new("New Claude Code Thread") + .icon(IconName::AiClaude) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(crate::ExternalAgent::ClaudeCode), + } + .boxed_clone(), + cx, + ); + }), ) .action( "New Codex Thread", @@ -2269,7 +2307,20 @@ impl AgentPanel { return None; } - Some(div().size_full().child(self.onboarding.clone())) + let thread_view = matches!(&self.active_view, ActiveView::Thread { .. }); + let text_thread_view = matches!(&self.active_view, ActiveView::TextThread { .. }); + + Some( + div() + .size_full() + .when(thread_view, |this| { + this.bg(cx.theme().colors().panel_background) + }) + .when(text_thread_view, |this| { + this.bg(cx.theme().colors().editor_background) + }) + .child(self.onboarding.clone()), + ) } fn render_trial_end_upsell( @@ -2292,6 +2343,28 @@ impl AgentPanel { }))) } + fn render_empty_state_section_header( + &self, + label: impl Into, + action_slot: Option, + cx: &mut Context, + ) -> impl IntoElement { + h_flex() + .mt_2() + .pl_1p5() + .pb_1() + .w_full() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new(label.into()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .children(action_slot) + } + fn render_thread_empty_state( &self, window: &mut Window, @@ -2414,19 +2487,9 @@ impl AgentPanel { .justify_end() .gap_1() .child( - h_flex() - .pl_1p5() - .pb_1() - .w_full() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new("Recent") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( + self.render_empty_state_section_header( + "Recent", + Some( Button::new("view-history", "View All") .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) @@ -2441,8 +2504,11 @@ impl AgentPanel { ) .on_click(move |_event, window, cx| { window.dispatch_action(OpenHistory.boxed_clone(), cx); - }), + }) + .into_any_element(), ), + cx, + ), ) .child( v_flex() @@ -2470,6 +2536,113 @@ impl AgentPanel { }, )), ) + .child(self.render_empty_state_section_header("Start", None, cx)) + .child( + v_flex() + .p_1() + .gap_2() + .child( + h_flex() + .w_full() + .gap_2() + .child( + NewThreadButton::new( + "new-thread-btn", + "New Thread", + IconName::NewThread, + ) + .keybinding(KeyBinding::for_action_in( + &NewThread::default(), + &self.focus_handle(cx), + window, + cx, + )) + .on_click( + |window, cx| { + window.dispatch_action( + NewThread::default().boxed_clone(), + cx, + ) + }, + ), + ) + .child( + NewThreadButton::new( + "new-text-thread-btn", + "New Text Thread", + IconName::NewTextThread, + ) + .keybinding(KeyBinding::for_action_in( + &NewTextThread, + &self.focus_handle(cx), + window, + cx, + )) + .on_click( + |window, cx| { + window.dispatch_action(Box::new(NewTextThread), cx) + }, + ), + ), + ) + .when(cx.has_flag::(), |this| { + this.child( + h_flex() + .w_full() + .gap_2() + .child( + NewThreadButton::new( + "new-gemini-thread-btn", + "New Gemini Thread", + IconName::AiGemini, + ) + // .keybinding(KeyBinding::for_action_in( + // &OpenHistory, + // &self.focus_handle(cx), + // window, + // cx, + // )) + .on_click( + |window, cx| { + window.dispatch_action( + Box::new(NewExternalAgentThread { + agent: Some( + crate::ExternalAgent::Gemini, + ), + }), + cx, + ) + }, + ), + ) + .child( + NewThreadButton::new( + "new-claude-thread-btn", + "New Claude Code Thread", + IconName::AiClaude, + ) + // .keybinding(KeyBinding::for_action_in( + // &OpenHistory, + // &self.focus_handle(cx), + // window, + // cx, + // )) + .on_click( + |window, cx| { + window.dispatch_action( + Box::new(NewExternalAgentThread { + agent: Some( + crate::ExternalAgent::ClaudeCode, + ), + }), + cx, + ) + }, + ), + ), + ) + }), + ) .when_some(configuration_error.as_ref(), |this, err| { this.child(self.render_configuration_error(err, &focus_handle, window, cx)) }) @@ -3084,7 +3257,20 @@ impl Render for AgentPanel { .into_any(), ) }) - .child(h_flex().child(message_editor.clone())) + .child(h_flex().relative().child(message_editor.clone()).when( + !LanguageModelRegistry::read_global(cx).has_authenticated_provider(cx), + |this| { + this.child( + div() + .size_full() + .absolute() + .inset_0() + .bg(cx.theme().colors().panel_background) + .opacity(0.8) + .block_mouse_except_scroll(), + ) + }, + )) .child(self.render_drag_target(cx)), ActiveView::ExternalAgentThread { thread_view, .. } => parent .relative() diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index a2cf4aac48a0eb6e368596bc7458615ea1f008a1..78037532925d8214b3f6fe8c780039e3e590a7f7 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -14,6 +14,7 @@ use agent::{ context_store::ContextStoreEvent, }; use agent_settings::{AgentSettings, CompletionMode}; +use ai_onboarding::ApiKeysWithProviders; use buffer_diff::BufferDiff; use client::UserStore; use collections::{HashMap, HashSet}; @@ -33,7 +34,8 @@ use gpui::{ }; use language::{Buffer, Language, Point}; use language_model::{ - ConfiguredModel, LanguageModelRequestMessage, MessageContent, ZED_CLOUD_PROVIDER_ID, + ConfiguredModel, LanguageModelRegistry, LanguageModelRequestMessage, MessageContent, + ZED_CLOUD_PROVIDER_ID, }; use multi_buffer; use project::Project; @@ -1655,9 +1657,34 @@ impl Render for MessageEditor { let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5; + let in_pro_trial = matches!( + self.user_store.read(cx).current_plan(), + Some(proto::Plan::ZedProTrial) + ); + + let pro_user = matches!( + self.user_store.read(cx).current_plan(), + Some(proto::Plan::ZedPro) + ); + + let configured_providers: Vec<(IconName, SharedString)> = + LanguageModelRegistry::read_global(cx) + .providers() + .iter() + .filter(|provider| { + provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID + }) + .map(|provider| (provider.icon(), provider.name().0.clone())) + .collect(); + let has_existing_providers = configured_providers.len() > 0; + v_flex() .size_full() .bg(cx.theme().colors().panel_background) + .when( + has_existing_providers && !in_pro_trial && !pro_user, + |this| this.child(cx.new(ApiKeysWithProviders::new)), + ) .when(changed_buffers.len() > 0, |parent| { parent.child(self.render_edits_bar(&changed_buffers, window, cx)) }) diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index 6398f64abb65bb6c9639c71c59e31e1d1a214bba..15f2e28e5824242c3a6da258c15810263c0d9b83 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -2,6 +2,7 @@ mod agent_notification; mod burn_mode_tooltip; mod context_pill; mod end_trial_upsell; +mod new_thread_button; mod onboarding_modal; pub mod preview; mod upsell; @@ -10,4 +11,5 @@ pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use context_pill::*; pub use end_trial_upsell::*; +pub use new_thread_button::*; pub use onboarding_modal::*; diff --git a/crates/agent_ui/src/ui/new_thread_button.rs b/crates/agent_ui/src/ui/new_thread_button.rs new file mode 100644 index 0000000000000000000000000000000000000000..7764144150762f9b828ea98f1c917332759bd5ad --- /dev/null +++ b/crates/agent_ui/src/ui/new_thread_button.rs @@ -0,0 +1,75 @@ +use gpui::{ClickEvent, ElementId, IntoElement, ParentElement, Styled}; +use ui::prelude::*; + +#[derive(IntoElement)] +pub struct NewThreadButton { + id: ElementId, + label: SharedString, + icon: IconName, + keybinding: Option, + on_click: Option>, +} + +impl NewThreadButton { + pub fn new(id: impl Into, label: impl Into, icon: IconName) -> Self { + Self { + id: id.into(), + label: label.into(), + icon, + keybinding: None, + on_click: None, + } + } + + pub fn keybinding(mut self, keybinding: Option) -> Self { + self.keybinding = keybinding; + self + } + + pub fn on_click(mut self, handler: F) -> Self + where + F: Fn(&mut Window, &mut App) + 'static, + { + self.on_click = Some(Box::new( + move |_: &ClickEvent, window: &mut Window, cx: &mut App| handler(window, cx), + )); + self + } +} + +impl RenderOnce for NewThreadButton { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .id(self.id) + .w_full() + .py_1p5() + .px_2() + .gap_1() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border.opacity(0.4)) + .bg(cx.theme().colors().element_active.opacity(0.2)) + .hover(|style| { + style + .bg(cx.theme().colors().element_hover) + .border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(self.icon) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(Label::new(self.label).size(LabelSize::Small)), + ) + .when_some(self.keybinding, |this, keybinding| { + this.child(keybinding.size(rems_from_px(10.))) + }) + .when_some(self.on_click, |this, on_click| { + this.on_click(move |event, window, cx| on_click(event, window, cx)) + }) + } +} diff --git a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs new file mode 100644 index 0000000000000000000000000000000000000000..4f9e20cf77ed2685241cd72e5971df26cd918563 --- /dev/null +++ b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs @@ -0,0 +1,135 @@ +use gpui::{Action, IntoElement, ParentElement, RenderOnce, point}; +use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; +use ui::{Divider, List, prelude::*}; + +use crate::BulletItem; + +pub struct ApiKeysWithProviders { + configured_providers: Vec<(IconName, SharedString)>, +} + +impl ApiKeysWithProviders { + pub fn new(cx: &mut Context) -> Self { + cx.subscribe( + &LanguageModelRegistry::global(cx), + |this: &mut Self, _registry, event: &language_model::Event, cx| match event { + language_model::Event::ProviderStateChanged + | language_model::Event::AddedProvider(_) + | language_model::Event::RemovedProvider(_) => { + this.configured_providers = Self::compute_configured_providers(cx) + } + _ => {} + }, + ) + .detach(); + + Self { + configured_providers: Self::compute_configured_providers(cx), + } + } + + fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> { + LanguageModelRegistry::read_global(cx) + .providers() + .iter() + .filter(|provider| { + provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID + }) + .map(|provider| (provider.icon(), provider.name().0.clone())) + .collect() + } + + pub fn has_providers(&self) -> bool { + !self.configured_providers.is_empty() + } +} + +impl Render for ApiKeysWithProviders { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let configured_providers_list = + self.configured_providers + .iter() + .cloned() + .map(|(icon, name)| { + h_flex() + .gap_1p5() + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) + .child(Label::new(name)) + }); + + h_flex() + .mx_2p5() + .p_1() + .pb_0() + .gap_2() + .rounded_t_lg() + .border_t_1() + .border_x_1() + .border_color(cx.theme().colors().border.opacity(0.5)) + .bg(cx.theme().colors().background.alpha(0.5)) + .shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(0.15), + offset: point(px(1.), px(-1.)), + blur_radius: px(3.), + spread_radius: px(0.), + }]) + .child( + h_flex() + .px_2p5() + .py_1p5() + .gap_2() + .flex_wrap() + .rounded_t(px(5.)) + .overflow_hidden() + .border_t_1() + .border_x_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().panel_background) + .child(Icon::new(IconName::Info).size(IconSize::XSmall).color(Color::Muted)) + .child(Label::new("Or start now using API keys from your environment for the following providers:").color(Color::Muted)) + .children(configured_providers_list) + ) + } +} + +#[derive(IntoElement)] +pub struct ApiKeysWithoutProviders; + +impl ApiKeysWithoutProviders { + pub fn new() -> Self { + Self + } +} + +impl RenderOnce for ApiKeysWithoutProviders { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + v_flex() + .mt_2() + .gap_1() + .child( + h_flex() + .gap_2() + .child( + Label::new("API Keys") + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child(List::new().child(BulletItem::new( + "You can also use AI in Zed by bringing your own API keys", + ))) + .child( + Button::new("configure-providers", "Configure Providers") + .full_width() + .style(ButtonStyle::Outlined) + .on_click(move |_, window, cx| { + window.dispatch_action( + zed_actions::agent::OpenConfiguration.boxed_clone(), + cx, + ); + }), + ) + } +} diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_card.rs b/crates/ai_onboarding/src/agent_panel_onboarding_card.rs index 8ec9ccfe2230cedd921d3a18d0cb6236a043c716..c63c5926428ab47f80afd2e157f90f8852dbf4ee 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_card.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_card.rs @@ -24,7 +24,7 @@ impl ParentElement for AgentPanelOnboardingCard { impl RenderOnce for AgentPanelOnboardingCard { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { div() - .m_4() + .m_2p5() .p(px(3.)) .elevation_2(cx) .rounded_lg() @@ -49,6 +49,7 @@ impl RenderOnce for AgentPanelOnboardingCard { .right_0() .w(px(400.)) .h(px(92.)) + .rounded_md() .child( Vector::new( VectorName::AiGrid, @@ -61,11 +62,12 @@ impl RenderOnce for AgentPanelOnboardingCard { .child( div() .absolute() - .top_0() - .right_0() + .top_0p5() + .right_0p5() .w(px(660.)) .h(px(401.)) .overflow_hidden() + .rounded_md() .bg(linear_gradient( 75., linear_color_stop( diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs index f3f7d6c3d7e152ee8e46c6cf28b1d0bc0322c057..771482abf3f5ba871f2955d8579514013c6704f0 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -1,12 +1,11 @@ use std::sync::Arc; use client::{Client, UserStore}; -use gpui::{Action, ClickEvent, Entity, IntoElement, ParentElement}; +use gpui::{Entity, IntoElement, ParentElement}; use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; -use ui::{Divider, List, prelude::*}; -use zed_actions::agent::{OpenConfiguration, ToggleModelSelector}; +use ui::prelude::*; -use crate::{AgentPanelOnboardingCard, BulletItem, ZedAiOnboarding}; +use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding}; pub struct AgentPanelOnboarding { user_store: Entity, @@ -53,93 +52,34 @@ impl AgentPanelOnboarding { .map(|provider| (provider.icon(), provider.name().0.clone())) .collect() } - - fn configure_providers(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - window.dispatch_action(OpenConfiguration.boxed_clone(), cx); - cx.notify(); - } - - fn render_api_keys_section(&mut self, cx: &mut Context) -> impl IntoElement { - let has_existing_providers = self.configured_providers.len() > 0; - let configure_provider_label = if has_existing_providers { - "Configure Other Provider" - } else { - "Configure Providers" - }; - - let content = if has_existing_providers { - List::new() - .child(BulletItem::new( - "Or start now using API keys from your environment for the following providers:" - )) - .child( - h_flex() - .px_5() - .gap_2() - .flex_wrap() - .children(self.configured_providers.iter().cloned().map(|(icon, name)| - h_flex() - .gap_1p5() - .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) - .child(Label::new(name)) - )) - ) - .child(BulletItem::new( - "No need for any of the plans or even to sign in", - )) - } else { - List::new() - .child(BulletItem::new( - "You can also use AI in Zed by bringing your own API keys", - )) - .child(BulletItem::new( - "No need for any of the plans or even to sign in", - )) - }; - - v_flex() - .mt_2() - .gap_1() - .child( - h_flex() - .gap_2() - .child( - Label::new("API Keys") - .size(LabelSize::Small) - .color(Color::Muted) - .buffer_font(cx), - ) - .child(Divider::horizontal()), - ) - .child(content) - .when(has_existing_providers, |this| { - this.child( - Button::new("pick-model", "Choose Model") - .full_width() - .style(ButtonStyle::Outlined) - .on_click(|_event, window, cx| { - window.dispatch_action(ToggleModelSelector.boxed_clone(), cx) - }), - ) - }) - .child( - Button::new("configure-providers", configure_provider_label) - .full_width() - .style(ButtonStyle::Outlined) - .on_click(cx.listener(Self::configure_providers)), - ) - } } impl Render for AgentPanelOnboarding { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let enrolled_in_trial = matches!( + self.user_store.read(cx).current_plan(), + Some(proto::Plan::ZedProTrial) + ); + AgentPanelOnboardingCard::new() - .child(ZedAiOnboarding::new( - self.client.clone(), - &self.user_store, - self.continue_with_zed_ai.clone(), - cx, - )) - .child(self.render_api_keys_section(cx)) + .child( + ZedAiOnboarding::new( + self.client.clone(), + &self.user_store, + self.continue_with_zed_ai.clone(), + cx, + ) + .with_dismiss({ + let callback = self.continue_with_zed_ai.clone(); + move |window, cx| callback(window, cx) + }), + ) + .map(|this| { + if enrolled_in_trial || self.configured_providers.len() >= 1 { + this + } else { + this.child(ApiKeysWithoutProviders::new()) + } + }) } } diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index f19b8821fa2cbcda063bc9a47f9b7736ef639d8e..88c962c1ba5271c8bf713af72a7aa2492d10c84d 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -1,8 +1,10 @@ +mod agent_api_keys_onboarding; mod agent_panel_onboarding_card; mod agent_panel_onboarding_content; mod edit_prediction_onboarding_content; mod young_account_banner; +pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProviders}; pub use agent_panel_onboarding_card::AgentPanelOnboardingCard; pub use agent_panel_onboarding_content::AgentPanelOnboarding; pub use edit_prediction_onboarding_content::EditPredictionOnboarding; @@ -12,7 +14,7 @@ use std::sync::Arc; use client::{Client, UserStore, zed_urls}; use gpui::{AnyElement, Entity, IntoElement, ParentElement, SharedString}; -use ui::{Divider, List, ListItem, RegisterComponent, TintColor, prelude::*}; +use ui::{Divider, List, ListItem, RegisterComponent, TintColor, Tooltip, prelude::*}; pub struct BulletItem { label: SharedString, @@ -69,6 +71,7 @@ pub struct ZedAiOnboarding { pub continue_with_zed_ai: Arc, pub sign_in: Arc, pub accept_terms_of_service: Arc, + pub dismiss_onboarding: Option>, } impl ZedAiOnboarding { @@ -80,6 +83,7 @@ impl ZedAiOnboarding { ) -> Self { let store = user_store.read(cx); let status = *client.status().borrow(); + Self { sign_in_status: status.into(), has_accepted_terms_of_service: store.current_user_has_accepted_terms().unwrap_or(false), @@ -102,14 +106,22 @@ impl ZedAiOnboarding { }) .detach(); }), + dismiss_onboarding: None, } } - fn render_free_plan_section(&self, cx: &mut App) -> impl IntoElement { + pub fn with_dismiss( + mut self, + dismiss_callback: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + self.dismiss_onboarding = Some(Arc::new(dismiss_callback)); + self + } + + fn free_plan_definition(&self, cx: &mut App) -> impl IntoElement { v_flex() .mt_2() .gap_1() - .when(self.account_too_young, |this| this.opacity(0.4)) .child( h_flex() .gap_2() @@ -119,6 +131,12 @@ impl ZedAiOnboarding { .color(Color::Muted) .buffer_font(cx), ) + .child( + Label::new("(Current Plan)") + .size(LabelSize::Small) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6))) + .buffer_font(cx), + ) .child(Divider::horizontal()), ) .child( @@ -130,65 +148,89 @@ impl ZedAiOnboarding { "2000 accepted edit predictions using our open-source Zeta model", )), ) - .child( - Button::new("continue", "Continue Free") - .disabled(self.account_too_young) - .full_width() - .style(ButtonStyle::Outlined) - .on_click({ - let callback = self.continue_with_zed_ai.clone(); - move |_, window, cx| callback(window, cx) - }), - ) } - fn render_pro_plan_section(&self, cx: &mut App) -> impl IntoElement { - let (button_label, button_url) = if self.account_too_young { - ("Start with Pro", zed_urls::upgrade_to_zed_pro_url(cx)) - } else { - ("Start Pro Trial", zed_urls::account_url(cx)) - }; + fn pro_trial_definition(&self) -> impl IntoElement { + List::new() + .child(BulletItem::new( + "150 prompts per month with the Claude models", + )) + .child(BulletItem::new( + "Unlimited accepted edit predictions using our open-source Zeta model", + )) + } - v_flex() - .mt_2() - .gap_1() - .child( - h_flex() - .gap_2() - .child( - Label::new("Pro") - .size(LabelSize::Small) - .color(Color::Accent) - .buffer_font(cx), - ) - .child(Divider::horizontal()), - ) - .child( - List::new() - .child(BulletItem::new("500 prompts per month with Claude models")) - .child(BulletItem::new("Unlimited edit predictions")) - .when(!self.account_too_young, |this| { - this.child(BulletItem::new( - "Try it out for 14 days with no charge, no credit card required", + fn pro_plan_definition(&self, cx: &mut App) -> impl IntoElement { + v_flex().mt_2().gap_1().map(|this| { + if self.account_too_young { + this.child( + h_flex() + .gap_2() + .child( + Label::new("Pro") + .size(LabelSize::Small) + .color(Color::Accent) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child( + List::new() + .child(BulletItem::new("500 prompts per month with Claude models")) + .child(BulletItem::new( + "Unlimited accepted edit predictions using our open-source Zeta model", )) - }), - ) - .child( - Button::new("pro", button_label) - .full_width() - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .on_click(move |_, _window, cx| cx.open_url(&button_url)), - ) + .child(BulletItem::new("USD $20 per month")), + ) + .child( + Button::new("pro", "Start with Pro") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click(move |_, _window, cx| { + cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)) + }), + ) + } else { + this.child( + h_flex() + .gap_2() + .child( + Label::new("Pro Trial") + .size(LabelSize::Small) + .color(Color::Accent) + .buffer_font(cx), + ) + .child(Divider::horizontal()), + ) + .child( + List::new() + .child(self.pro_trial_definition()) + .child(BulletItem::new( + "Try it out for 14 days with no charge and no credit card required", + )), + ) + .child( + Button::new("pro", "Start Pro Trial") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .on_click(move |_, _window, cx| { + cx.open_url(&zed_urls::start_trial_url(cx)) + }), + ) + } + }) } - fn render_accept_terms_of_service(&self) -> Div { + fn render_accept_terms_of_service(&self) -> AnyElement { v_flex() - .w_full() .gap_1() + .w_full() .child(Headline::new("Before starting…")) - .child(Label::new( - "Make sure you have read and accepted Zed AI's terms of service.", - )) + .child( + Label::new("Make sure you have read and accepted Zed AI's terms of service.") + .color(Color::Muted) + .mb_2(), + ) .child( Button::new("terms_of_service", "View and Read the Terms of Service") .full_width() @@ -196,9 +238,7 @@ impl ZedAiOnboarding { .icon(IconName::ArrowUpRight) .icon_color(Color::Muted) .icon_size(IconSize::XSmall) - .on_click(move |_, _window, cx| { - cx.open_url("https://zed.dev/terms-of-service") - }), + .on_click(move |_, _window, cx| cx.open_url(&zed_urls::terms_of_service(cx))), ) .child( Button::new("accept_terms", "I've read it and accept it") @@ -209,23 +249,23 @@ impl ZedAiOnboarding { move |_, window, cx| (callback)(window, cx) }), ) + .into_any_element() } - fn render_sign_in_disclaimer(&self, _cx: &mut App) -> Div { - const SIGN_IN_DISCLAIMER: &str = - "To start using AI in Zed with our hosted models, sign in and subscribe to a plan."; + fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement { let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn); v_flex() - .gap_2() + .gap_1() .child(Headline::new("Welcome to Zed AI")) - .child(div().w_full().child(Label::new(SIGN_IN_DISCLAIMER))) .child( - Button::new("sign_in", "Sign In with GitHub") - .icon(IconName::Github) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + Label::new("Sign in to start using AI in Zed with a free trial of the Pro plan, which includes:") + .color(Color::Muted) + .mb_2(), + ) + .child(self.pro_trial_definition()) + .child( + Button::new("sign_in", "Sign in to Start Trial") .disabled(signing_in) .full_width() .style(ButtonStyle::Tinted(ui::TintColor::Accent)) @@ -234,36 +274,55 @@ impl ZedAiOnboarding { move |_, window, cx| callback(window, cx) }), ) + .into_any_element() } - fn render_free_plan_onboarding(&self, cx: &mut App) -> Div { - const PLANS_DESCRIPTION: &str = "Choose how you want to start."; + fn render_free_plan_state(&self, cx: &mut App) -> AnyElement { let young_account_banner = YoungAccountBanner; v_flex() + .relative() + .gap_1() .child(Headline::new("Welcome to Zed AI")) .child( - Label::new(PLANS_DESCRIPTION) - .size(LabelSize::Small) + Label::new("Choose how you want to start.") .color(Color::Muted) - .mt_1() - .mb_3(), + .mb_2(), ) - .when(self.account_too_young, |this| { - this.child(young_account_banner) + .map(|this| { + if self.account_too_young { + this.child(young_account_banner) + } else { + this.child(self.free_plan_definition(cx)).when_some( + self.dismiss_onboarding.as_ref(), + |this, dismiss_callback| { + let callback = dismiss_callback.clone(); + + this.child( + h_flex().absolute().top_0().right_0().child( + IconButton::new("dismiss_onboarding", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(move |_, window, cx| callback(window, cx)), + ), + ) + }, + ) + } }) - .child(self.render_free_plan_section(cx)) - .child(self.render_pro_plan_section(cx)) + .child(self.pro_plan_definition(cx)) + .into_any_element() } - fn render_trial_onboarding(&self, _cx: &mut App) -> Div { + fn render_trial_state(&self, _cx: &mut App) -> AnyElement { v_flex() - .child(Headline::new("Welcome to the trial of Zed Pro")) + .relative() + .gap_1() + .child(Headline::new("Welcome to the Zed Pro free trial")) .child( Label::new("Here's what you get for the next 14 days:") - .size(LabelSize::Small) .color(Color::Muted) - .mt_1(), + .mb_2(), ) .child( List::new() @@ -272,25 +331,31 @@ impl ZedAiOnboarding { "Unlimited edit predictions with Zeta, our open-source model", )), ) - .child( - Button::new("trial", "Start Trial") - .full_width() - .style(ButtonStyle::Outlined) - .on_click({ - let callback = self.continue_with_zed_ai.clone(); - move |_, window, cx| callback(window, cx) - }), + .when_some( + self.dismiss_onboarding.as_ref(), + |this, dismiss_callback| { + let callback = dismiss_callback.clone(); + this.child( + h_flex().absolute().top_0().right_0().child( + IconButton::new("dismiss_onboarding", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(move |_, window, cx| callback(window, cx)), + ), + ) + }, ) + .into_any_element() } - fn render_pro_plan_onboarding(&self, _cx: &mut App) -> Div { + fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement { v_flex() + .gap_1() .child(Headline::new("Welcome to Zed Pro")) .child( Label::new("Here's what you get:") - .size(LabelSize::Small) .color(Color::Muted) - .mt_1(), + .mb_2(), ) .child( List::new() @@ -306,6 +371,7 @@ impl ZedAiOnboarding { move |_, window, cx| callback(window, cx) }), ) + .into_any_element() } } @@ -314,9 +380,9 @@ impl RenderOnce for ZedAiOnboarding { if matches!(self.sign_in_status, SignInStatus::SignedIn) { if self.has_accepted_terms_of_service { match self.plan { - None | Some(proto::Plan::Free) => self.render_free_plan_onboarding(cx), - Some(proto::Plan::ZedProTrial) => self.render_trial_onboarding(cx), - Some(proto::Plan::ZedPro) => self.render_pro_plan_onboarding(cx), + None | Some(proto::Plan::Free) => self.render_free_plan_state(cx), + Some(proto::Plan::ZedProTrial) => self.render_trial_state(cx), + Some(proto::Plan::ZedPro) => self.render_pro_plan_state(cx), } } else { self.render_accept_terms_of_service() @@ -339,18 +405,17 @@ impl Component for ZedAiOnboarding { plan: Option, account_too_young: bool, ) -> AnyElement { - div() - .w(px(800.)) - .child(ZedAiOnboarding { - sign_in_status, - has_accepted_terms_of_service, - plan, - account_too_young, - continue_with_zed_ai: Arc::new(|_, _| {}), - sign_in: Arc::new(|_, _| {}), - accept_terms_of_service: Arc::new(|_, _| {}), - }) - .into_any_element() + ZedAiOnboarding { + sign_in_status, + has_accepted_terms_of_service, + plan, + account_too_young, + continue_with_zed_ai: Arc::new(|_, _| {}), + sign_in: Arc::new(|_, _| {}), + accept_terms_of_service: Arc::new(|_, _| {}), + dismiss_onboarding: None, + } + .into_any_element() } Some( @@ -368,7 +433,7 @@ impl Component for ZedAiOnboarding { ), single_example( "Account too young", - onboarding(SignInStatus::SignedIn, true, None, true), + onboarding(SignInStatus::SignedIn, false, None, true), ), single_example( "Free Plan", diff --git a/crates/ai_onboarding/src/young_account_banner.rs b/crates/ai_onboarding/src/young_account_banner.rs index f6e1446fd05cc719e8a6674ae9246084185162c7..1e1ed3a8653d0cb39955fb54b10dd1dc3937ceb3 100644 --- a/crates/ai_onboarding/src/young_account_banner.rs +++ b/crates/ai_onboarding/src/young_account_banner.rs @@ -6,7 +6,7 @@ pub struct YoungAccountBanner; impl RenderOnce for YoungAccountBanner { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - const YOUNG_ACCOUNT_DISCLAIMER: &str = "Given your GitHub account was created less than 30 days ago, we cannot put you in the Free plan or offer you a free trial of the Pro plan. We hope you'll understand, as this is unfortunately required to prevent abuse of our service. To continue, upgrade to Pro or use your own API keys for other providers."; + const YOUNG_ACCOUNT_DISCLAIMER: &str = "To prevent abuse of our service, we cannot offer plans to GitHub accounts created fewer than 30 days ago. To request an exception, reach out to billing@zed.dev."; let label = div() .w_full() diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 442875b45132c1d7990f82ac93248ebd0477362c..693c7bf836330fc8c6cd36ca72ee862a9e2b865b 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -18,7 +18,20 @@ pub fn account_url(cx: &App) -> String { format!("{server_url}/account", server_url = server_url(cx)) } +/// Returns the URL to the start trial page on zed.dev. +pub fn start_trial_url(cx: &App) -> String { + format!( + "{server_url}/account/start-trial", + server_url = server_url(cx) + ) +} + /// Returns the URL to the upgrade page on zed.dev. pub fn upgrade_to_zed_pro_url(cx: &App) -> String { format!("{server_url}/account/upgrade", server_url = server_url(cx)) } + +/// Returns the URL to Zed's terms of service. +pub fn terms_of_service(cx: &App) -> String { + format!("{server_url}/terms-of-service", server_url = server_url(cx)) +} diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7a454e11cfced2fa7f9f1dc8c0263934830c7cad..924784109b1de0a56abca60c4866ab137d14e7c3 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -4167,6 +4167,13 @@ async fn accept_terms_of_service( response.send(proto::AcceptTermsOfServiceResponse { accepted_tos_at: accepted_tos_at.timestamp() as u64, })?; + + // When the user accepts the terms of service, we want to refresh their LLM + // token to grant access. + session + .peer + .send(session.connection_id, proto::RefreshLlmToken {})?; + Ok(()) } diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 807b17f1ca64fcc253084d553b8ec700c60fb74e..f2517feb27e9ceab2187e0f86bc752e14de5d63f 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -6,9 +6,9 @@ pub mod test; pub mod transport; pub mod types; -use std::fmt::Display; use std::path::Path; use std::sync::Arc; +use std::{fmt::Display, path::PathBuf}; use anyhow::Result; use client::Client; @@ -31,7 +31,7 @@ impl Display for ContextServerId { #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] pub struct ContextServerCommand { #[serde(rename = "command")] - pub path: String, + pub path: PathBuf, pub args: Vec, pub env: Option>, } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 87463d246d2aba96485247467b15025c58f9d5d5..8557b57f4602e35174cc711aa62e2bebf8c8a140 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -322,6 +322,8 @@ actions!( ApplyDiffHunk, /// Deletes the character before the cursor. Backspace, + /// Shows git blame information for the current line. + BlameHover, /// Cancels the current operation. Cancel, /// Cancels the running flycheck operation. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b8dcdd35e07102afd613e99c22a60df6f4699604..1f985eeb7c225be0778cf13de925276583063e16 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -950,6 +950,7 @@ struct InlineBlamePopover { hide_task: Option>, popover_bounds: Option>, popover_state: InlineBlamePopoverState, + keyboard_grace: bool, } enum SelectionDragState { @@ -6517,21 +6518,55 @@ impl Editor { } } + pub fn blame_hover(&mut self, _: &BlameHover, window: &mut Window, cx: &mut Context) { + let snapshot = self.snapshot(window, cx); + let cursor = self.selections.newest::(cx).head(); + let Some((buffer, point, _)) = snapshot.buffer_snapshot.point_to_buffer_point(cursor) + else { + return; + }; + + let Some(blame) = self.blame.as_ref() else { + return; + }; + + let row_info = RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(point.row), + ..Default::default() + }; + let Some(blame_entry) = blame + .update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next()) + .flatten() + else { + return; + }; + + let anchor = self.selections.newest_anchor().head(); + let position = self.to_pixel_point(anchor, &snapshot, window); + if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) { + self.show_blame_popover(&blame_entry, position + last_bounds.origin, true, cx); + }; + } + fn show_blame_popover( &mut self, blame_entry: &BlameEntry, position: gpui::Point, + ignore_timeout: bool, cx: &mut Context, ) { if let Some(state) = &mut self.inline_blame_popover { state.hide_task.take(); } else { - let delay = EditorSettings::get_global(cx).hover_popover_delay; + let blame_popover_delay = EditorSettings::get_global(cx).hover_popover_delay; let blame_entry = blame_entry.clone(); let show_task = cx.spawn(async move |editor, cx| { - cx.background_executor() - .timer(std::time::Duration::from_millis(delay)) - .await; + if !ignore_timeout { + cx.background_executor() + .timer(std::time::Duration::from_millis(blame_popover_delay)) + .await; + } editor .update(cx, |editor, cx| { editor.inline_blame_popover_show_task.take(); @@ -6560,6 +6595,7 @@ impl Editor { commit_message: details, markdown, }, + keyboard_grace: ignore_timeout, }); cx.notify(); }) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index fef185bb156085655c8a144cb3d06c70d8558f2c..cbff544c7e2e4159ae290e37b3fbe8b631696184 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -216,6 +216,7 @@ impl EditorElement { register_action(editor, window, Editor::newline_above); register_action(editor, window, Editor::newline_below); register_action(editor, window, Editor::backspace); + register_action(editor, window, Editor::blame_hover); register_action(editor, window, Editor::delete); register_action(editor, window, Editor::tab); register_action(editor, window, Editor::backtab); @@ -1143,10 +1144,14 @@ impl EditorElement { .as_ref() .and_then(|state| state.popover_bounds) .map_or(false, |bounds| bounds.contains(&event.position)); + let keyboard_grace = editor + .inline_blame_popover + .as_ref() + .map_or(false, |state| state.keyboard_grace); if mouse_over_inline_blame || mouse_over_popover { - editor.show_blame_popover(&blame_entry, event.position, cx); - } else { + editor.show_blame_popover(&blame_entry, event.position, false, cx); + } else if !keyboard_grace { editor.hide_blame_popover(cx); } } else { diff --git a/crates/extension/src/types.rs b/crates/extension/src/types.rs index cb24e5077b839a0c5ded24c084fbdd7c1cbeab7c..ed9eb2ec2fb96a3b19125355be90e6ba7a5a6e90 100644 --- a/crates/extension/src/types.rs +++ b/crates/extension/src/types.rs @@ -3,7 +3,7 @@ mod dap; mod lsp; mod slash_command; -use std::ops::Range; +use std::{ops::Range, path::PathBuf}; use util::redact::should_redact; @@ -18,7 +18,7 @@ pub type EnvVars = Vec<(String, String)>; /// A command. pub struct Command { /// The command to execute. - pub command: String, + pub command: PathBuf, /// The arguments to pass to the command. pub args: Vec, /// The environment variables to set for the command. diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index ced2ea9c677022e95f106ac6ba0543303fe5a372..d25328ee7f6744528e606e5043ff51fbc0896aee 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -75,7 +75,7 @@ impl From for std::ops::Range { impl From for extension::Command { fn from(value: Command) -> Self { Self { - command: value.command, + command: value.command.into(), args: value.args, env: value.env, } @@ -958,7 +958,7 @@ impl ExtensionImports for WasmState { command, } => Ok(serde_json::to_string(&settings::ContextServerSettings { command: Some(settings::CommandSettings { - path: Some(command.path), + path: command.path.to_str().map(|path| path.to_string()), arguments: Some(command.args), env: command.env.map(|env| env.into_iter().collect()), }), diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index ed1666c53060dfdf3ed4c10a85a730d69f87986d..4655c92409d3f21fd8a2a919154368a56da9567e 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1664,6 +1664,11 @@ impl Interactivity { window: &mut Window, _cx: &mut App, ) -> Point { + fn round_to_two_decimals(pixels: Pixels) -> Pixels { + const ROUNDING_FACTOR: f32 = 100.0; + (pixels * ROUNDING_FACTOR).round() / ROUNDING_FACTOR + } + if let Some(scroll_offset) = self.scroll_offset.as_ref() { let mut scroll_to_bottom = false; let mut tracked_scroll_handle = self @@ -1678,8 +1683,16 @@ impl Interactivity { let rem_size = window.rem_size(); let padding = style.padding.to_pixels(bounds.size.into(), rem_size); let padding_size = size(padding.left + padding.right, padding.top + padding.bottom); + // The floating point values produced by Taffy and ours often vary + // slightly after ~5 decimal places. This can lead to cases where after + // subtracting these, the container becomes scrollable for less than + // 0.00000x pixels. As we generally don't benefit from a precision that + // high for the maximum scroll, we round the scroll max to 2 decimal + // places here. let padded_content_size = self.content_size + padding_size; - let scroll_max = (padded_content_size - bounds.size).max(&Size::default()); + let scroll_max = (padded_content_size - bounds.size) + .map(round_to_two_decimals) + .max(&Default::default()); // Clamp scroll offset in case scroll max is smaller now (e.g., if children // were removed or the bounds became larger). let mut scroll_offset = scroll_offset.borrow_mut(); @@ -1692,7 +1705,7 @@ impl Interactivity { } if let Some(mut scroll_handle_state) = tracked_scroll_handle { - scroll_handle_state.padded_content_size = padded_content_size; + scroll_handle_state.max_offset = scroll_max; } *scroll_offset @@ -2936,7 +2949,7 @@ impl ScrollAnchor { struct ScrollHandleState { offset: Rc>>, bounds: Bounds, - padded_content_size: Size, + max_offset: Size, child_bounds: Vec>, scroll_to_bottom: bool, overflow: Point, @@ -2965,6 +2978,11 @@ impl ScrollHandle { *self.0.borrow().offset.borrow() } + /// Get the maximum scroll offset. + pub fn max_offset(&self) -> Size { + self.0.borrow().max_offset + } + /// Get the top child that's scrolled into view. pub fn top_item(&self) -> usize { let state = self.0.borrow(); @@ -2999,11 +3017,6 @@ impl ScrollHandle { self.0.borrow().child_bounds.get(ix).cloned() } - /// Get the size of the content with padding of the container. - pub fn padded_content_size(&self) -> Size { - self.0.borrow().padded_content_size - } - /// scroll_to_item scrolls the minimal amount to ensure that the child is /// fully visible pub fn scroll_to_item(&self, ix: usize) { diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 35a3b622b2e53028218ce0c42ab0a5ad7f1a4ec3..f24d38794f7611ee173dbe913ed51a53bdef73ed 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -411,9 +411,9 @@ impl ListState { self.0.borrow_mut().set_offset_from_scrollbar(point); } - /// Returns the size of items we have measured. + /// Returns the maximum scroll offset according to the items we have measured. /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. - pub fn content_size_for_scrollbar(&self) -> Size { + pub fn max_offset_for_scrollbar(&self) -> Size { let state = self.0.borrow(); let bounds = state.last_layout_bounds.unwrap_or_default(); @@ -421,7 +421,7 @@ impl ListState { .scrollbar_drag_start_height .unwrap_or_else(|| state.items.summary().height); - Size::new(bounds.size.width, height) + Size::new(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) } /// Returns the current scroll offset adjusted for the scrollbar diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index a52841e1afe4b0a396c68ef72587777edd5eb14e..d65118e994e4cd488c23436bea8d3666495881ec 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -828,6 +828,13 @@ impl crate::Keystroke { Keysym::Delete => "delete".to_owned(), Keysym::Escape => "escape".to_owned(), + Keysym::Left => "left".to_owned(), + Keysym::Right => "right".to_owned(), + Keysym::Up => "up".to_owned(), + Keysym::Down => "down".to_owned(), + Keysym::Home => "home".to_owned(), + Keysym::End => "end".to_owned(), + _ => { let name = xkb::keysym_get_name(key_sym).to_lowercase(); if key_sym.is_keypad_key() { diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 631ccc1af3123defdc07c3e5dfb9756c0f235ec1..b85e5b517d6587ffdc39abb2295a2bcf6381fc19 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -181,6 +181,9 @@ pub enum IconName { MicMute, Microscope, Minimize, + NewFromSummary, + NewTextThread, + NewThread, Option, PageDown, PageUp, diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index 840fda38dec4714a32f3397a28dd2d116bb67f5d..6e8e8e91088bf3197c523c75209c56f9edda82be 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -206,8 +206,8 @@ impl LanguageModelRegistry { None } - /// Check that we have at least one provider that is authenticated. - fn has_authenticated_provider(&self, cx: &App) -> bool { + /// Returns `true` if at least one provider that is authenticated. + pub fn has_authenticated_provider(&self, cx: &App) -> bool { self.providers.values().any(|p| p.is_authenticated(cx)) } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 736107570b395c3014e25dce1cbe21737de9e96b..fac88107143919437c1851b8417210343bb44b0c 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1140,19 +1140,19 @@ impl RenderOnce for ZedAiConfiguration { let is_pro = self.plan == Some(proto::Plan::ZedPro); let subscription_text = match (self.plan, self.subscription_period) { (Some(proto::Plan::ZedPro), Some(_)) => { - "You have access to Zed's hosted LLMs through your Pro subscription." + "You have access to Zed's hosted models through your Pro subscription." } (Some(proto::Plan::ZedProTrial), Some(_)) => { - "You have access to Zed's hosted LLMs through your Pro trial." + "You have access to Zed's hosted models through your Pro trial." } (Some(proto::Plan::Free), Some(_)) => { - "You have basic access to Zed's hosted LLMs through the Free plan." + "You have basic access to Zed's hosted models through the Free plan." } _ => { if self.eligible_for_trial { - "Subscribe for access to Zed's hosted LLMs. Start with a 14 day free trial." + "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." } else { - "Subscribe for access to Zed's hosted LLMs." + "Subscribe for access to Zed's hosted models." } } }; @@ -1166,7 +1166,7 @@ impl RenderOnce for ZedAiConfiguration { Button::new("start_trial", "Start 14-day Free Pro Trial") .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .full_width() - .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) + .on_click(|_, _, cx| cx.open_url(&zed_urls::start_trial_url(cx))) .into_any_element() } else { Button::new("upgrade", "Upgrade to Pro") diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index 45af7518d589166e26788203c919d2267b544756..5aa914311a6eccc1cb68efa37e878ad12249d6fd 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -18,7 +18,6 @@ client.workspace = true collections.workspace = true copilot.workspace = true editor.workspace = true -feature_flags.workspace = true futures.workspace = true gpui.workspace = true itertools.workspace = true diff --git a/crates/language_tools/src/lsp_tool.rs b/crates/language_tools/src/lsp_tool.rs index fd843916800a552692f53404a5165b85f48d172e..9e95ed46734940f3f8de429ad6a581dc092b4614 100644 --- a/crates/language_tools/src/lsp_tool.rs +++ b/crates/language_tools/src/lsp_tool.rs @@ -1,13 +1,17 @@ -use std::{collections::hash_map, path::PathBuf, rc::Rc, time::Duration}; +use std::{ + collections::{BTreeMap, HashMap}, + path::{Path, PathBuf}, + rc::Rc, + time::Duration, +}; use client::proto; -use collections::{HashMap, HashSet}; +use collections::HashSet; use editor::{Editor, EditorEvent}; -use feature_flags::FeatureFlagAppExt as _; use gpui::{Corner, Entity, Subscription, Task, WeakEntity, actions}; -use language::{BinaryStatus, BufferId, LocalFile, ServerHealth}; +use language::{BinaryStatus, BufferId, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; -use project::{LspStore, LspStoreEvent, project_settings::ProjectSettings}; +use project::{LspStore, LspStoreEvent, Worktree, project_settings::ProjectSettings}; use settings::{Settings as _, SettingsStore}; use ui::{ Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide, @@ -36,8 +40,7 @@ pub struct LspTool { #[derive(Debug)] struct LanguageServerState { - items: Vec, - other_servers_start_index: Option, + items: Vec, workspace: WeakEntity, lsp_store: WeakEntity, active_editor: Option, @@ -63,8 +66,13 @@ impl std::fmt::Debug for ActiveEditor { struct LanguageServers { health_statuses: HashMap, binary_statuses: HashMap, - servers_per_buffer_abs_path: - HashMap>>, + servers_per_buffer_abs_path: HashMap, +} + +#[derive(Debug, Clone)] +struct ServersForPath { + servers: HashMap>, + worktree: Option>, } #[derive(Debug, Clone)] @@ -120,8 +128,8 @@ impl LanguageServerState { }; let mut first_button_encountered = false; - for (i, item) in self.items.iter().enumerate() { - if let LspItem::ToggleServersButton { restart } = item { + for item in &self.items { + if let LspMenuItem::ToggleServersButton { restart } = item { let label = if *restart { "Restart All Servers" } else { @@ -140,22 +148,19 @@ impl LanguageServerState { }; let project = workspace.read(cx).project().clone(); let buffer_store = project.read(cx).buffer_store().clone(); - let worktree_store = project.read(cx).worktree_store(); - let buffers = state .read(cx) .language_servers .servers_per_buffer_abs_path - .keys() - .filter_map(|abs_path| { - worktree_store.read(cx).find_worktree(abs_path, cx) - }) - .filter_map(|(worktree, relative_path)| { - let entry = - worktree.read(cx).entry_for_path(&relative_path)?; - project.read(cx).path_for_entry(entry.id, cx) - }) - .filter_map(|project_path| { + .iter() + .filter_map(|(abs_path, servers)| { + let worktree = + servers.worktree.as_ref()?.upgrade()?.read(cx); + let relative_path = + abs_path.strip_prefix(&worktree.abs_path()).ok()?; + let entry = worktree.entry_for_path(&relative_path)?; + let project_path = + project.read(cx).path_for_entry(entry.id, cx)?; buffer_store.read(cx).get_by_path(&project_path) }) .collect(); @@ -165,13 +170,16 @@ impl LanguageServerState { .iter() // Do not try to use IDs as we have stopped all servers already, when allowing to restart them all .flat_map(|item| match item { - LspItem::ToggleServersButton { .. } => None, - LspItem::WithHealthCheck(_, status, ..) => Some( - LanguageServerSelector::Name(status.name.clone()), - ), - LspItem::WithBinaryStatus(_, server_name, ..) => Some( - LanguageServerSelector::Name(server_name.clone()), + LspMenuItem::Header { .. } => None, + LspMenuItem::ToggleServersButton { .. } => None, + LspMenuItem::WithHealthCheck { health, .. } => Some( + LanguageServerSelector::Name(health.name.clone()), ), + LspMenuItem::WithBinaryStatus { + server_name, .. + } => Some(LanguageServerSelector::Name( + server_name.clone(), + )), }) .collect(); lsp_store.restart_language_servers_for_buffers( @@ -190,13 +198,17 @@ impl LanguageServerState { } menu = menu.item(button); continue; - }; + } else if let LspMenuItem::Header { header, separator } = item { + menu = menu + .when(*separator, |menu| menu.separator()) + .when_some(header.as_ref(), |menu, header| menu.header(header)); + continue; + } let Some(server_info) = item.server_info() else { continue; }; - let workspace = self.workspace.clone(); let server_selector = server_info.server_selector(); // TODO currently, Zed remote does not work well with the LSP logs // https://github.com/zed-industries/zed/issues/28557 @@ -205,6 +217,7 @@ impl LanguageServerState { let status_color = server_info .binary_status + .as_ref() .and_then(|binary_status| match binary_status.status { BinaryStatus::None => None, BinaryStatus::CheckingForUpdate @@ -223,17 +236,20 @@ impl LanguageServerState { }) .unwrap_or(Color::Success); - if self - .other_servers_start_index - .is_some_and(|index| index == i) - { - menu = menu.separator().header("Other Buffers"); - } - - if i == 0 && self.other_servers_start_index.is_some() { - menu = menu.header("Current Buffer"); - } + let message = server_info + .message + .as_ref() + .or_else(|| server_info.binary_status.as_ref()?.message.as_ref()) + .cloned(); + let hover_label = if has_logs { + Some("View Logs") + } else if message.is_some() { + Some("View Message") + } else { + None + }; + let server_name = server_info.name.clone(); menu = menu.item(ContextMenuItem::custom_entry( move |_, _| { h_flex() @@ -245,42 +261,99 @@ impl LanguageServerState { h_flex() .gap_2() .child(Indicator::dot().color(status_color)) - .child(Label::new(server_info.name.0.clone())), - ) - .child( - h_flex() - .visible_on_hover("menu_item") - .child( - Label::new("View Logs") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Icon::new(IconName::ChevronRight) - .size(IconSize::Small) - .color(Color::Muted), - ), + .child(Label::new(server_name.0.clone())), ) + .when_some(hover_label, |div, hover_label| { + div.child( + h_flex() + .visible_on_hover("menu_item") + .child( + Label::new(hover_label) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Icon::new(IconName::ChevronRight) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + }) .into_any_element() }, { let lsp_logs = lsp_logs.clone(); + let message = message.clone(); + let server_selector = server_selector.clone(); + let server_name = server_info.name.clone(); + let workspace = self.workspace.clone(); move |window, cx| { - if !has_logs { + if has_logs { + lsp_logs.update(cx, |lsp_logs, cx| { + lsp_logs.open_server_trace( + workspace.clone(), + server_selector.clone(), + window, + cx, + ); + }); + } else if let Some(message) = &message { + let Some(create_buffer) = workspace + .update(cx, |workspace, cx| { + workspace + .project() + .update(cx, |project, cx| project.create_buffer(cx)) + }) + .ok() + else { + return; + }; + + let window = window.window_handle(); + let workspace = workspace.clone(); + let message = message.clone(); + let server_name = server_name.clone(); + cx.spawn(async move |cx| { + let buffer = create_buffer.await?; + buffer.update(cx, |buffer, cx| { + buffer.edit( + [( + 0..0, + format!("Language server {server_name}:\n\n{message}"), + )], + None, + cx, + ); + buffer.set_capability(language::Capability::ReadOnly, cx); + })?; + + workspace.update(cx, |workspace, cx| { + window.update(cx, |_, window, cx| { + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_buffer(buffer, None, window, cx); + editor.set_read_only(true); + editor + })), + None, + true, + window, + cx, + ); + }) + })??; + + anyhow::Ok(()) + }) + .detach(); + } else { cx.propagate(); return; } - lsp_logs.update(cx, |lsp_logs, cx| { - lsp_logs.open_server_trace( - workspace.clone(), - server_selector.clone(), - window, - cx, - ); - }); } }, - server_info.message.map(|server_message| { + message.map(|server_message| { DocumentationAside::new( DocumentationSide::Right, Rc::new(move |_| Label::new(server_message.clone()).into_any_element()), @@ -345,81 +418,95 @@ impl LanguageServers { #[derive(Debug)] enum ServerData<'a> { - WithHealthCheck( - LanguageServerId, - &'a LanguageServerHealthStatus, - Option<&'a LanguageServerBinaryStatus>, - ), - WithBinaryStatus( - Option, - &'a LanguageServerName, - &'a LanguageServerBinaryStatus, - ), + WithHealthCheck { + server_id: LanguageServerId, + health: &'a LanguageServerHealthStatus, + binary_status: Option<&'a LanguageServerBinaryStatus>, + }, + WithBinaryStatus { + server_id: Option, + server_name: &'a LanguageServerName, + binary_status: &'a LanguageServerBinaryStatus, + }, } #[derive(Debug)] -enum LspItem { - WithHealthCheck( - LanguageServerId, - LanguageServerHealthStatus, - Option, - ), - WithBinaryStatus( - Option, - LanguageServerName, - LanguageServerBinaryStatus, - ), +enum LspMenuItem { + WithHealthCheck { + server_id: LanguageServerId, + health: LanguageServerHealthStatus, + binary_status: Option, + }, + WithBinaryStatus { + server_id: Option, + server_name: LanguageServerName, + binary_status: LanguageServerBinaryStatus, + }, ToggleServersButton { restart: bool, }, + Header { + header: Option, + separator: bool, + }, } -impl LspItem { +impl LspMenuItem { fn server_info(&self) -> Option { match self { - LspItem::ToggleServersButton { .. } => None, - LspItem::WithHealthCheck( - language_server_id, - language_server_health_status, - language_server_binary_status, - ) => Some(ServerInfo { - name: language_server_health_status.name.clone(), - id: Some(*language_server_id), - health: language_server_health_status.health(), - binary_status: language_server_binary_status.clone(), - message: language_server_health_status.message(), + Self::Header { .. } => None, + Self::ToggleServersButton { .. } => None, + Self::WithHealthCheck { + server_id, + health, + binary_status, + .. + } => Some(ServerInfo { + name: health.name.clone(), + id: Some(*server_id), + health: health.health(), + binary_status: binary_status.clone(), + message: health.message(), }), - LspItem::WithBinaryStatus( + Self::WithBinaryStatus { server_id, - language_server_name, - language_server_binary_status, - ) => Some(ServerInfo { - name: language_server_name.clone(), + server_name, + binary_status, + .. + } => Some(ServerInfo { + name: server_name.clone(), id: *server_id, health: None, - binary_status: Some(language_server_binary_status.clone()), - message: language_server_binary_status.message.clone(), + binary_status: Some(binary_status.clone()), + message: binary_status.message.clone(), }), } } } impl ServerData<'_> { - fn name(&self) -> &LanguageServerName { - match self { - Self::WithHealthCheck(_, state, _) => &state.name, - Self::WithBinaryStatus(_, name, ..) => name, - } - } - - fn into_lsp_item(self) -> LspItem { + fn into_lsp_item(self) -> LspMenuItem { match self { - Self::WithHealthCheck(id, name, status) => { - LspItem::WithHealthCheck(id, name.clone(), status.cloned()) - } - Self::WithBinaryStatus(server_id, name, status) => { - LspItem::WithBinaryStatus(server_id, name.clone(), status.clone()) - } + Self::WithHealthCheck { + server_id, + health, + binary_status, + .. + } => LspMenuItem::WithHealthCheck { + server_id, + health: health.clone(), + binary_status: binary_status.cloned(), + }, + Self::WithBinaryStatus { + server_id, + server_name, + binary_status, + .. + } => LspMenuItem::WithBinaryStatus { + server_id, + server_name: server_name.clone(), + binary_status: binary_status.clone(), + }, } } } @@ -452,7 +539,6 @@ impl LspTool { let state = cx.new(|_| LanguageServerState { workspace: workspace.weak_handle(), items: Vec::new(), - other_servers_start_index: None, lsp_store: lsp_store.downgrade(), active_editor: None, language_servers: LanguageServers::default(), @@ -542,13 +628,28 @@ impl LspTool { message: proto::update_language_server::Variant::RegisteredForBuffer(update), .. } => { - self.server_state.update(cx, |state, _| { - state + self.server_state.update(cx, |state, cx| { + let Ok(worktree) = state.workspace.update(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .find_worktree(Path::new(&update.buffer_abs_path), cx) + .map(|(worktree, _)| worktree.downgrade()) + }) else { + return; + }; + let entry = state .language_servers .servers_per_buffer_abs_path .entry(PathBuf::from(&update.buffer_abs_path)) - .or_default() - .insert(*language_server_id, name.clone()); + .or_insert_with(|| ServersForPath { + servers: HashMap::default(), + worktree: worktree.clone(), + }); + entry.servers.insert(*language_server_id, name.clone()); + if worktree.is_some() { + entry.worktree = worktree; + } }); updated = true; } @@ -562,94 +663,95 @@ impl LspTool { fn regenerate_items(&mut self, cx: &mut App) { self.server_state.update(cx, |state, cx| { - let editor_buffers = state + let active_worktrees = state .active_editor .as_ref() - .map(|active_editor| active_editor.editor_buffers.clone()) - .unwrap_or_default(); - let editor_buffer_paths = editor_buffers - .iter() - .filter_map(|buffer_id| { - let buffer_path = state - .lsp_store - .update(cx, |lsp_store, cx| { - Some( - project::File::from_dyn( - lsp_store - .buffer_store() - .read(cx) - .get(*buffer_id)? - .read(cx) - .file(), - )? - .abs_path(cx), - ) + .into_iter() + .flat_map(|active_editor| { + active_editor + .editor + .upgrade() + .into_iter() + .flat_map(|active_editor| { + active_editor + .read(cx) + .buffer() + .read(cx) + .all_buffers() + .into_iter() + .filter_map(|buffer| { + project::File::from_dyn(buffer.read(cx).file()) + }) + .map(|buffer_file| buffer_file.worktree.clone()) }) - .ok()??; - Some(buffer_path) }) - .collect::>(); + .collect::>(); - let mut servers_with_health_checks = HashSet::default(); - let mut server_ids_with_health_checks = HashSet::default(); - let mut buffer_servers = - Vec::with_capacity(state.language_servers.health_statuses.len()); - let mut other_servers = - Vec::with_capacity(state.language_servers.health_statuses.len()); - let buffer_server_ids = editor_buffer_paths - .iter() - .filter_map(|buffer_path| { - state - .language_servers - .servers_per_buffer_abs_path - .get(buffer_path) - }) - .flatten() - .fold(HashMap::default(), |mut acc, (server_id, name)| { - match acc.entry(*server_id) { - hash_map::Entry::Occupied(mut o) => { - let old_name: &mut Option<&LanguageServerName> = o.get_mut(); - if old_name.is_none() { - *old_name = name.as_ref(); - } - } - hash_map::Entry::Vacant(v) => { - v.insert(name.as_ref()); + let mut server_ids_to_worktrees = + HashMap::>::default(); + let mut server_names_to_worktrees = HashMap::< + LanguageServerName, + HashSet<(Entity, LanguageServerId)>, + >::default(); + for servers_for_path in state.language_servers.servers_per_buffer_abs_path.values() { + if let Some(worktree) = servers_for_path + .worktree + .as_ref() + .and_then(|worktree| worktree.upgrade()) + { + for (server_id, server_name) in &servers_for_path.servers { + server_ids_to_worktrees.insert(*server_id, worktree.clone()); + if let Some(server_name) = server_name { + server_names_to_worktrees + .entry(server_name.clone()) + .or_default() + .insert((worktree.clone(), *server_id)); } } - acc + } + } + + let mut servers_per_worktree = BTreeMap::>::new(); + let mut servers_without_worktree = Vec::::new(); + let mut servers_with_health_checks = HashSet::default(); + + for (server_id, health) in &state.language_servers.health_statuses { + let worktree = server_ids_to_worktrees.get(server_id).or_else(|| { + let worktrees = server_names_to_worktrees.get(&health.name)?; + worktrees + .iter() + .find(|(worktree, _)| active_worktrees.contains(worktree)) + .or_else(|| worktrees.iter().next()) + .map(|(worktree, _)| worktree) }); - for (server_id, server_state) in &state.language_servers.health_statuses { - let binary_status = state - .language_servers - .binary_statuses - .get(&server_state.name); - servers_with_health_checks.insert(&server_state.name); - server_ids_with_health_checks.insert(*server_id); - if buffer_server_ids.contains_key(server_id) { - buffer_servers.push(ServerData::WithHealthCheck( - *server_id, - server_state, - binary_status, - )); - } else { - other_servers.push(ServerData::WithHealthCheck( - *server_id, - server_state, - binary_status, - )); + servers_with_health_checks.insert(&health.name); + let worktree_name = + worktree.map(|worktree| SharedString::new(worktree.read(cx).root_name())); + + let binary_status = state.language_servers.binary_statuses.get(&health.name); + let server_data = ServerData::WithHealthCheck { + server_id: *server_id, + health, + binary_status, + }; + match worktree_name { + Some(worktree_name) => servers_per_worktree + .entry(worktree_name.clone()) + .or_default() + .push(server_data), + None => servers_without_worktree.push(server_data), } } let mut can_stop_all = !state.language_servers.health_statuses.is_empty(); let mut can_restart_all = state.language_servers.health_statuses.is_empty(); - for (server_name, status) in state + for (server_name, binary_status) in state .language_servers .binary_statuses .iter() .filter(|(name, _)| !servers_with_health_checks.contains(name)) { - match status.status { + match binary_status.status { BinaryStatus::None => { can_restart_all = false; can_stop_all |= true; @@ -674,52 +776,73 @@ impl LspTool { BinaryStatus::Failed { .. } => {} } - let matching_server_id = state - .language_servers - .servers_per_buffer_abs_path - .iter() - .filter(|(path, _)| editor_buffer_paths.contains(path)) - .flat_map(|(_, server_associations)| server_associations.iter()) - .find_map(|(id, name)| { - if name.as_ref() == Some(server_name) { - Some(*id) - } else { - None + match server_names_to_worktrees.get(server_name) { + Some(worktrees_for_name) => { + match worktrees_for_name + .iter() + .find(|(worktree, _)| active_worktrees.contains(worktree)) + .or_else(|| worktrees_for_name.iter().next()) + { + Some((worktree, server_id)) => { + let worktree_name = + SharedString::new(worktree.read(cx).root_name()); + servers_per_worktree + .entry(worktree_name.clone()) + .or_default() + .push(ServerData::WithBinaryStatus { + server_name, + binary_status, + server_id: Some(*server_id), + }); + } + None => servers_without_worktree.push(ServerData::WithBinaryStatus { + server_name, + binary_status, + server_id: None, + }), } - }); - if let Some(server_id) = matching_server_id { - buffer_servers.push(ServerData::WithBinaryStatus( - Some(server_id), + } + None => servers_without_worktree.push(ServerData::WithBinaryStatus { server_name, - status, - )); - } else { - other_servers.push(ServerData::WithBinaryStatus(None, server_name, status)); + binary_status, + server_id: None, + }), } } - buffer_servers.sort_by_key(|data| data.name().clone()); - other_servers.sort_by_key(|data| data.name().clone()); - - let mut other_servers_start_index = None; let mut new_lsp_items = - Vec::with_capacity(buffer_servers.len() + other_servers.len() + 1); - new_lsp_items.extend(buffer_servers.into_iter().map(ServerData::into_lsp_item)); - if !new_lsp_items.is_empty() { - other_servers_start_index = Some(new_lsp_items.len()); + Vec::with_capacity(servers_per_worktree.len() + servers_without_worktree.len() + 2); + for (worktree_name, worktree_servers) in servers_per_worktree { + if worktree_servers.is_empty() { + continue; + } + new_lsp_items.push(LspMenuItem::Header { + header: Some(worktree_name), + separator: false, + }); + new_lsp_items.extend(worktree_servers.into_iter().map(ServerData::into_lsp_item)); + } + if !servers_without_worktree.is_empty() { + new_lsp_items.push(LspMenuItem::Header { + header: Some(SharedString::from("Unknown worktree")), + separator: false, + }); + new_lsp_items.extend( + servers_without_worktree + .into_iter() + .map(ServerData::into_lsp_item), + ); } - new_lsp_items.extend(other_servers.into_iter().map(ServerData::into_lsp_item)); if !new_lsp_items.is_empty() { if can_stop_all { - new_lsp_items.push(LspItem::ToggleServersButton { restart: true }); - new_lsp_items.push(LspItem::ToggleServersButton { restart: false }); + new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: true }); + new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: false }); } else if can_restart_all { - new_lsp_items.push(LspItem::ToggleServersButton { restart: true }); + new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: true }); } } state.items = new_lsp_items; - state.other_servers_start_index = other_servers_start_index; }); } @@ -841,10 +964,7 @@ impl StatusItemView for LspTool { impl Render for LspTool { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl ui::IntoElement { - if !cx.is_staff() - || self.server_state.read(cx).language_servers.is_empty() - || self.lsp_menu.is_none() - { + if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() { return div(); } @@ -852,12 +972,12 @@ impl Render for LspTool { let mut has_warnings = false; let mut has_other_notifications = false; let state = self.server_state.read(cx); - for server in state.language_servers.health_statuses.values() { - if let Some(binary_status) = &state.language_servers.binary_statuses.get(&server.name) { - has_errors |= matches!(binary_status.status, BinaryStatus::Failed { .. }); - has_other_notifications |= binary_status.message.is_some(); - } + for binary_status in state.language_servers.binary_statuses.values() { + has_errors |= matches!(binary_status.status, BinaryStatus::Failed { .. }); + has_other_notifications |= binary_status.message.is_some(); + } + for server in state.language_servers.health_statuses.values() { if let Some((message, health)) = &server.health { has_other_notifications |= message.is_some(); match health { diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index fd31e638d4bf7774af83d430dca232d1ade74f01..ceec0c0a52b70cb68f0fb41d8c415a13e39e8b85 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -610,7 +610,7 @@ mod tests { use context_server::test::create_fake_transport; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use serde_json::json; - use std::{cell::RefCell, rc::Rc}; + use std::{cell::RefCell, path::PathBuf, rc::Rc}; use util::path; #[gpui::test] @@ -931,7 +931,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -971,7 +971,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["anotherArg".to_string()], env: None, }, @@ -1053,7 +1053,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -1104,7 +1104,7 @@ mod tests { ContextServerSettings::Custom { enabled: false, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -1132,7 +1132,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -1184,7 +1184,7 @@ mod tests { ContextServerSettings::Custom { enabled: true, command: ContextServerCommand { - path: "somebinary".to_string(), + path: "somebinary".into(), args: vec!["arg".to_string()], env: None, }, @@ -1256,11 +1256,11 @@ mod tests { } struct FakeContextServerDescriptor { - path: String, + path: PathBuf, } impl FakeContextServerDescriptor { - fn new(path: impl Into) -> Self { + fn new(path: impl Into) -> Self { Self { path: path.into() } } } diff --git a/crates/project/src/context_server_store/extension.rs b/crates/project/src/context_server_store/extension.rs index 1eaecd987dd51158fc2f505c1ae9b0c8fcc076a3..1eb0fe7da129ba9dbd3ee640cb6e02474a3990b6 100644 --- a/crates/project/src/context_server_store/extension.rs +++ b/crates/project/src/context_server_store/extension.rs @@ -61,10 +61,7 @@ impl registry::ContextServerDescriptor for ContextServerDescriptor { let mut command = extension .context_server_command(id.clone(), extension_project.clone()) .await?; - command.command = extension - .path_from_extension(command.command.as_ref()) - .to_string_lossy() - .to_string(); + command.command = extension.path_from_extension(&command.command); log::info!("loaded command for context server {id}: {command:?}"); diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index a85d90fe33575ecd15fd7f55166b35e2489e222b..20be7fef85c79910904fe577f0691fba57424d45 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -581,7 +581,7 @@ impl Settings for ProjectSettings { #[derive(Deserialize)] struct VsCodeContextServerCommand { - command: String, + command: PathBuf, args: Option>, env: Option>, // note: we don't support envFile and type diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 3d62b4156b7b96caade454d5d05a3c02c44dae8c..8cfbdff31183cc6763455e4cbe5acf635440343e 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -662,7 +662,7 @@ pub fn wrap_for_ssh( format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}") } else { - format!("cd {path}; {env_changes} {to_run}") + format!("cd \"{path}\"; {env_changes} {to_run}") } } else { format!("cd; {env_changes} {to_run}") diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b6fdcd6fa5bac837df5cab8aad3b9c69cd1613d8..44f4e8985ad90462f3c68b21e7f12274725e3673 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -384,12 +384,20 @@ struct ItemColors { focused: Hsla, } -fn get_item_color(cx: &App) -> ItemColors { +fn get_item_color(is_sticky: bool, cx: &App) -> ItemColors { let colors = cx.theme().colors(); ItemColors { - default: colors.panel_background, - hover: colors.element_hover, + default: if is_sticky { + colors.panel_overlay_background + } else { + colors.panel_background + }, + hover: if is_sticky { + colors.panel_overlay_hover + } else { + colors.element_hover + }, marked: colors.element_selected, focused: colors.panel_focused_border, drag_over: colors.drop_target_background, @@ -3903,7 +3911,7 @@ impl ProjectPanel { let filename_text_color = details.filename_text_color; let diagnostic_severity = details.diagnostic_severity; - let item_colors = get_item_color(cx); + let item_colors = get_item_color(is_sticky, cx); let canonical_path = details .canonical_path diff --git a/crates/terminal_view/src/terminal_scrollbar.rs b/crates/terminal_view/src/terminal_scrollbar.rs index 18e135be2eef3b8e7ec71c070f2a60a46792a271..c8565a42bee0858e0928e557b9fae590dba319fb 100644 --- a/crates/terminal_view/src/terminal_scrollbar.rs +++ b/crates/terminal_view/src/terminal_scrollbar.rs @@ -46,9 +46,16 @@ impl TerminalScrollHandle { } impl ScrollableHandle for TerminalScrollHandle { - fn content_size(&self) -> Size { + fn max_offset(&self) -> Size { let state = self.state.borrow(); - size(Pixels::ZERO, state.total_lines as f32 * state.line_height) + size( + Pixels::ZERO, + state + .total_lines + .checked_sub(state.viewport_lines) + .unwrap_or(0) as f32 + * state.line_height, + ) } fn offset(&self) -> Point { diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 3424e0fe04cdbc11544fa81018edba4ff2b357c1..1c3f48b548d3fdd4a2a554b476afaa08dcbae150 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -83,6 +83,8 @@ impl ThemeColors { panel_indent_guide: neutral().light_alpha().step_5(), panel_indent_guide_hover: neutral().light_alpha().step_6(), panel_indent_guide_active: neutral().light_alpha().step_6(), + panel_overlay_background: neutral().light().step_2(), + panel_overlay_hover: neutral().light_alpha().step_4(), pane_focused_border: blue().light().step_5(), pane_group_border: neutral().light().step_6(), scrollbar_thumb_background: neutral().light_alpha().step_3(), @@ -206,6 +208,8 @@ impl ThemeColors { panel_indent_guide: neutral().dark_alpha().step_4(), panel_indent_guide_hover: neutral().dark_alpha().step_6(), panel_indent_guide_active: neutral().dark_alpha().step_6(), + panel_overlay_background: neutral().dark().step_2(), + panel_overlay_hover: neutral().dark_alpha().step_4(), pane_focused_border: blue().dark().step_5(), pane_group_border: neutral().dark().step_6(), scrollbar_thumb_background: neutral().dark_alpha().step_3(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 5e9967d4603a5bac8c9f1a7e461c7319f52f82d7..4d77dd5d81dfc45427bda4034ff7a2085dbcb489 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -59,6 +59,7 @@ pub(crate) fn zed_default_dark() -> Theme { let bg = hsla(215. / 360., 12. / 100., 15. / 100., 1.); let editor = hsla(220. / 360., 12. / 100., 18. / 100., 1.); let elevated_surface = hsla(225. / 360., 12. / 100., 17. / 100., 1.); + let hover = hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0); let blue = hsla(207.8 / 360., 81. / 100., 66. / 100., 1.0); let gray = hsla(218.8 / 360., 10. / 100., 40. / 100., 1.0); @@ -108,14 +109,14 @@ pub(crate) fn zed_default_dark() -> Theme { surface_background: bg, background: bg, element_background: hsla(223.0 / 360., 13. / 100., 21. / 100., 1.0), - element_hover: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0), + element_hover: hover, element_active: hsla(220.0 / 360., 11.8 / 100., 20.0 / 100., 1.0), element_selected: hsla(224.0 / 360., 11.3 / 100., 26.1 / 100., 1.0), element_disabled: SystemColors::default().transparent, element_selection_background: player.local().selection.alpha(0.25), drop_target_background: hsla(220.0 / 360., 8.3 / 100., 21.4 / 100., 1.0), ghost_element_background: SystemColors::default().transparent, - ghost_element_hover: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0), + ghost_element_hover: hover, ghost_element_active: hsla(220.0 / 360., 11.8 / 100., 20.0 / 100., 1.0), ghost_element_selected: hsla(224.0 / 360., 11.3 / 100., 26.1 / 100., 1.0), ghost_element_disabled: SystemColors::default().transparent, @@ -202,10 +203,12 @@ pub(crate) fn zed_default_dark() -> Theme { panel_indent_guide: hsla(228. / 360., 8. / 100., 25. / 100., 1.), panel_indent_guide_hover: hsla(225. / 360., 13. / 100., 12. / 100., 1.), panel_indent_guide_active: hsla(225. / 360., 13. / 100., 12. / 100., 1.), + panel_overlay_background: bg, + panel_overlay_hover: hover, pane_focused_border: blue, pane_group_border: hsla(225. / 360., 13. / 100., 12. / 100., 1.), scrollbar_thumb_background: gpui::transparent_black(), - scrollbar_thumb_hover_background: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0), + scrollbar_thumb_hover_background: hover, scrollbar_thumb_active_background: hsla( 225.0 / 360., 11.8 / 100., diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index bed25d0c054fc4e1767cc852597db13dc2cb434c..bfa2adcedf73ec9d51c25d30785b1e81cd83173e 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -351,6 +351,12 @@ pub struct ThemeColorsContent { #[serde(rename = "panel.indent_guide_active")] pub panel_indent_guide_active: Option, + #[serde(rename = "panel.overlay_background")] + pub panel_overlay_background: Option, + + #[serde(rename = "panel.overlay_hover")] + pub panel_overlay_hover: Option, + #[serde(rename = "pane.focused_border")] pub pane_focused_border: Option, @@ -674,6 +680,14 @@ impl ThemeColorsContent { .scrollbar_thumb_border .as_ref() .and_then(|color| try_parse_color(color).ok()); + let element_hover = self + .element_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let panel_background = self + .panel_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); ThemeColorsRefinement { border, border_variant: self @@ -712,10 +726,7 @@ impl ThemeColorsContent { .element_background .as_ref() .and_then(|color| try_parse_color(color).ok()), - element_hover: self - .element_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), + element_hover, element_active: self .element_active .as_ref() @@ -832,10 +843,7 @@ impl ThemeColorsContent { .search_match_background .as_ref() .and_then(|color| try_parse_color(color).ok()), - panel_background: self - .panel_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), + panel_background, panel_focused_border: self .panel_focused_border .as_ref() @@ -852,6 +860,16 @@ impl ThemeColorsContent { .panel_indent_guide_active .as_ref() .and_then(|color| try_parse_color(color).ok()), + panel_overlay_background: self + .panel_overlay_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(panel_background), + panel_overlay_hover: self + .panel_overlay_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(element_hover), pane_focused_border: self .pane_focused_border .as_ref() diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 7c5270e3612dfbe1fb6b1ec45dc4787dac0e9463..aab11803f4d810453f5bfc286624ea8e4efb4a61 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -131,6 +131,12 @@ pub struct ThemeColors { pub panel_indent_guide: Hsla, pub panel_indent_guide_hover: Hsla, pub panel_indent_guide_active: Hsla, + + /// The color of the overlay surface on top of panel. + pub panel_overlay_background: Hsla, + /// The color of the overlay surface on top of panel when hovered over. + pub panel_overlay_hover: Hsla, + pub pane_focused_border: Hsla, pub pane_group_border: Hsla, /// The color of the scrollbar thumb. @@ -326,6 +332,8 @@ pub enum ThemeColorField { PanelIndentGuide, PanelIndentGuideHover, PanelIndentGuideActive, + PanelOverlayBackground, + PanelOverlayHover, PaneFocusedBorder, PaneGroupBorder, ScrollbarThumbBackground, @@ -438,6 +446,8 @@ impl ThemeColors { ThemeColorField::PanelIndentGuide => self.panel_indent_guide, ThemeColorField::PanelIndentGuideHover => self.panel_indent_guide_hover, ThemeColorField::PanelIndentGuideActive => self.panel_indent_guide_active, + ThemeColorField::PanelOverlayBackground => self.panel_overlay_background, + ThemeColorField::PanelOverlayHover => self.panel_overlay_hover, ThemeColorField::PaneFocusedBorder => self.pane_focused_border, ThemeColorField::PaneGroupBorder => self.pane_group_border, ThemeColorField::ScrollbarThumbBackground => self.scrollbar_thumb_background, diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 2a8c4885acff5f3b5e75c7e2f6ae62335f9b8ebe..17ab2e788f3d7ef37fe99136b99397f200512124 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -29,8 +29,8 @@ impl ThumbState { } impl ScrollableHandle for UniformListScrollHandle { - fn content_size(&self) -> Size { - self.0.borrow().base_handle.content_size() + fn max_offset(&self) -> Size { + self.0.borrow().base_handle.max_offset() } fn set_offset(&self, point: Point) { @@ -47,8 +47,8 @@ impl ScrollableHandle for UniformListScrollHandle { } impl ScrollableHandle for ListState { - fn content_size(&self) -> Size { - self.content_size_for_scrollbar() + fn max_offset(&self) -> Size { + self.max_offset_for_scrollbar() } fn set_offset(&self, point: Point) { @@ -73,8 +73,8 @@ impl ScrollableHandle for ListState { } impl ScrollableHandle for ScrollHandle { - fn content_size(&self) -> Size { - self.padded_content_size() + fn max_offset(&self) -> Size { + self.max_offset() } fn set_offset(&self, point: Point) { @@ -91,7 +91,10 @@ impl ScrollableHandle for ScrollHandle { } pub trait ScrollableHandle: Any + Debug { - fn content_size(&self) -> Size; + fn content_size(&self) -> Size { + self.viewport().size + self.max_offset() + } + fn max_offset(&self) -> Size; fn set_offset(&self, point: Point); fn offset(&self) -> Point; fn viewport(&self) -> Bounds; @@ -149,17 +152,17 @@ impl ScrollbarState { fn thumb_range(&self, axis: ScrollbarAxis) -> Option> { const MINIMUM_THUMB_SIZE: Pixels = px(25.); - let content_size = self.scroll_handle.content_size().along(axis); + let max_offset = self.scroll_handle.max_offset().along(axis); let viewport_size = self.scroll_handle.viewport().size.along(axis); - if content_size.is_zero() || viewport_size.is_zero() || content_size <= viewport_size { + if max_offset.is_zero() || viewport_size.is_zero() { return None; } + let content_size = viewport_size + max_offset; let visible_percentage = viewport_size / content_size; let thumb_size = MINIMUM_THUMB_SIZE.max(viewport_size * visible_percentage); if thumb_size > viewport_size { return None; } - let max_offset = content_size - viewport_size; let current_offset = self .scroll_handle .offset() @@ -307,7 +310,7 @@ impl Element for Scrollbar { let compute_click_offset = move |event_position: Point, - item_size: Size, + max_offset: Size, event_type: ScrollbarMouseEvent| { let viewport_size = padded_bounds.size.along(axis); @@ -323,7 +326,7 @@ impl Element for Scrollbar { - thumb_offset) .clamp(px(0.), viewport_size - thumb_size); - let max_offset = (item_size.along(axis) - viewport_size).max(px(0.)); + let max_offset = max_offset.along(axis); let percentage = if viewport_size > thumb_size { thumb_start / (viewport_size - thumb_size) } else { @@ -347,7 +350,7 @@ impl Element for Scrollbar { } else { let click_offset = compute_click_offset( event.position, - scroll.content_size(), + scroll.max_offset(), ScrollbarMouseEvent::GutterClick, ); scroll.set_offset(scroll.offset().apply_along(axis, |_| click_offset)); @@ -373,7 +376,7 @@ impl Element for Scrollbar { ThumbState::Dragging(drag_state) if event.dragging() => { let drag_offset = compute_click_offset( event.position, - scroll.content_size(), + scroll.max_offset(), ScrollbarMouseEvent::ThumbDrag(drag_state), ); scroll.set_offset(scroll.offset().apply_along(axis, |_| drag_offset)); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 7cc10c27f714bec6480c44cb241d8012eda138d6..e57b103c61988c4a48f2078cdeb600cc3bd34978 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -18,7 +18,7 @@ use futures::{StreamExt, stream::FuturesUnordered}; use gpui::{ Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div, DragMoveEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusOutEvent, - Focusable, KeyContext, MouseButton, MouseDownEvent, NavigationDirection, Pixels, Point, + Focusable, IsZero, KeyContext, MouseButton, MouseDownEvent, NavigationDirection, Pixels, Point, PromptLevel, Render, ScrollHandle, Subscription, Task, WeakEntity, WeakFocusHandle, Window, actions, anchored, deferred, prelude::*, }; @@ -46,8 +46,8 @@ use theme::ThemeSettings; use ui::{ ButtonSize, Color, ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButton, IconButtonShape, IconDecoration, IconDecorationKind, IconName, IconSize, Indicator, Label, - PopoverMenu, PopoverMenuHandle, ScrollableHandle, Tab, TabBar, TabPosition, Tooltip, - prelude::*, right_click_menu, + PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, prelude::*, + right_click_menu, }; use util::{ResultExt, debug_panic, maybe, truncate_and_remove_front}; @@ -2865,10 +2865,9 @@ impl Pane { } }) .children(pinned_tabs.len().ne(&0).then(|| { - let content_width = self.tab_bar_scroll_handle.content_size().width; - let viewport_width = self.tab_bar_scroll_handle.viewport().size.width; + let max_scroll = self.tab_bar_scroll_handle.max_offset().width; // We need to check both because offset returns delta values even when the scroll handle is not scrollable - let is_scrollable = content_width > viewport_width; + let is_scrollable = !max_scroll.is_zero(); let is_scrolled = self.tab_bar_scroll_handle.offset().x < px(0.); let has_active_unpinned_tab = self.active_item_index >= self.pinned_tab_count; h_flex() diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 9d85923ca2f8e837c2e1f280c145947e43404594..c9b8eebff6a9001c2350a2e1bc2e56ad6708c5ee 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1,6 +1,7 @@ mod reliability; mod zed; +use agent_ui::AgentPanel; use anyhow::{Context as _, Result}; use clap::{Parser, command}; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; @@ -14,7 +15,7 @@ use extension_host::ExtensionStore; use fs::{Fs, RealFs}; use futures::{StreamExt, channel::oneshot, future}; use git::GitHostingProviderRegistry; -use gpui::{App, AppContext as _, Application, AsyncApp, UpdateGlobal as _}; +use gpui::{App, AppContext as _, Application, AsyncApp, Focusable as _, UpdateGlobal as _}; use gpui_tokio::Tokio; use http_client::{Url, read_proxy_from_env}; @@ -24,7 +25,6 @@ use reqwest_client::ReqwestClient; use assets::Assets; use node_runtime::{NodeBinaryOptions, NodeRuntime}; -use onboarding::show_onboarding_view; use parking_lot::Mutex; use project::project_settings::ProjectSettings; use recent_projects::{SshSettings, open_ssh_project}; @@ -56,6 +56,8 @@ use zed::{ inline_completion_registry, open_paths_with_positions, }; +use crate::zed::OpenRequestKind; + #[cfg(feature = "mimalloc")] #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -747,32 +749,46 @@ pub fn main() { } fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut App) { - if let Some(connection) = request.cli_connection { - let app_state = app_state.clone(); - cx.spawn(async move |cx| handle_cli_connection(connection, app_state, cx).await) - .detach(); - return; - } - - if let Some(action_index) = request.dock_menu_action { - cx.perform_dock_menu_action(action_index); - return; - } + if let Some(kind) = request.kind { + match kind { + OpenRequestKind::CliConnection(connection) => { + let app_state = app_state.clone(); + cx.spawn(async move |cx| handle_cli_connection(connection, app_state, cx).await) + .detach(); + } + OpenRequestKind::Extension { extension_id } => { + cx.spawn(async move |cx| { + let workspace = + workspace::get_any_active_workspace(app_state, cx.clone()).await?; + workspace.update(cx, |_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::Extensions { + category_filter: None, + id: Some(extension_id), + }), + cx, + ); + }) + }) + .detach_and_log_err(cx); + } + OpenRequestKind::AgentPanel => { + cx.spawn(async move |cx| { + let workspace = + workspace::get_any_active_workspace(app_state, cx.clone()).await?; + workspace.update(cx, |workspace, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.focus_handle(cx).focus(window); + } + }) + }) + .detach_and_log_err(cx); + } + OpenRequestKind::DockMenuAction { index } => { + cx.perform_dock_menu_action(index); + } + } - if let Some(extension) = request.extension_id { - cx.spawn(async move |cx| { - let workspace = workspace::get_any_active_workspace(app_state, cx.clone()).await?; - workspace.update(cx, |_, window, cx| { - window.dispatch_action( - Box::new(zed_actions::Extensions { - category_filter: None, - id: Some(extension), - }), - cx, - ); - }) - }) - .detach_and_log_err(cx); return; } @@ -1041,10 +1057,6 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp ); } } - } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { - let state = app_state.clone(); - cx.update(|cx| show_onboarding_view(state, cx))?.await?; - // cx.update(|cx| show_welcome_view(app_state, cx))?.await?; } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { cx.update(|cx| show_welcome_view(app_state, cx))?.await?; } else { diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 42eb8198a4c091d0ce6dd4ecbae3f0ced7bdf7d3..b6feb0073e3d46fb43c45e0f17069eef730f2481 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -30,14 +30,20 @@ use workspace::{AppState, OpenOptions, SerializedWorkspaceLocation, Workspace}; #[derive(Default, Debug)] pub struct OpenRequest { - pub cli_connection: Option<(mpsc::Receiver, IpcSender)>, + pub kind: Option, pub open_paths: Vec, pub diff_paths: Vec<[String; 2]>, pub open_channel_notes: Vec<(u64, Option)>, pub join_channel: Option, pub ssh_connection: Option, - pub dock_menu_action: Option, - pub extension_id: Option, +} + +#[derive(Debug)] +pub enum OpenRequestKind { + CliConnection((mpsc::Receiver, IpcSender)), + Extension { extension_id: String }, + AgentPanel, + DockMenuAction { index: usize }, } impl OpenRequest { @@ -45,9 +51,11 @@ impl OpenRequest { let mut this = Self::default(); for url in request.urls { if let Some(server_name) = url.strip_prefix("zed-cli://") { - this.cli_connection = Some(connect_to_cli(server_name)?); + this.kind = Some(OpenRequestKind::CliConnection(connect_to_cli(server_name)?)); } else if let Some(action_index) = url.strip_prefix("zed-dock-action://") { - this.dock_menu_action = Some(action_index.parse()?); + this.kind = Some(OpenRequestKind::DockMenuAction { + index: action_index.parse()?, + }); } else if let Some(file) = url.strip_prefix("file://") { this.parse_file_path(file) } else if let Some(file) = url.strip_prefix("zed://file") { @@ -55,8 +63,12 @@ impl OpenRequest { } else if let Some(file) = url.strip_prefix("zed://ssh") { let ssh_url = "ssh:/".to_string() + file; this.parse_ssh_file_path(&ssh_url, cx)? - } else if let Some(file) = url.strip_prefix("zed://extension/") { - this.extension_id = Some(file.to_string()) + } else if let Some(extension_id) = url.strip_prefix("zed://extension/") { + this.kind = Some(OpenRequestKind::Extension { + extension_id: extension_id.to_string(), + }); + } else if url == "zed://agent" { + this.kind = Some(OpenRequestKind::AgentPanel); } else if url.starts_with("ssh://") { this.parse_ssh_file_path(&url, cx)? } else if let Some(request_path) = parse_zed_link(&url, cx) { diff --git a/docs/src/languages/php.md b/docs/src/languages/php.md index 9cb7c40762e7af0f0680cbbcf564d4d989e7f0e9..4e94c134467c5a3484ede7a2146f2f09c172e859 100644 --- a/docs/src/languages/php.md +++ b/docs/src/languages/php.md @@ -15,7 +15,7 @@ The PHP extension offers both `phpactor` and `intelephense` language server supp ## Phpactor -The Zed PHP Extension can install `phpactor` automatically but requires `php` to installed and available in your path: +The Zed PHP Extension can install `phpactor` automatically but requires `php` to be installed and available in your path: ```sh # brew install php # macOS