diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39036ef5649e699ffda1636f304629fce6184371..84c7a9682819408c95fb8dfaa1f424bd3f847a9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: else echo "run_docs=false" >> $GITHUB_OUTPUT fi - if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^Cargo.lock') ]]; then + if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P '^(Cargo.lock|script/.*licenses)') ]]; then echo "run_license=true" >> $GITHUB_OUTPUT else echo "run_license=false" >> $GITHUB_OUTPUT diff --git a/Cargo.lock b/Cargo.lock index 5d4c29ca9952cea6fa0d3af8fd018e6c5ff84fb1..19ef428b3f48e274f9b0202fe0922dea8d0911d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9026,7 +9026,6 @@ dependencies = [ "itertools 0.14.0", "language", "lsp", - "picker", "project", "release_channel", "serde_json", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 639f1cefade18ee46771d22e08eda4a24f8696c0..ba3012cc54f7cc0af464357b6fb05c041130262d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -327,6 +327,7 @@ "g shift-r": ["vim::Paste", { "preserve_clipboard": true }], "g c": "vim::ToggleComments", "g q": "vim::Rewrap", + "g w": "vim::Rewrap", "g ?": "vim::ConvertToRot13", // "g ?": "vim::ConvertToRot47", "\"": "vim::PushRegister", diff --git a/assets/settings/default.json b/assets/settings/default.json index 9d858b42a8867b9968f6e6d8113e5d0c7fe357ff..48cdd665e1745fdcacb15b6317ade6ec2dd4480b 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -617,6 +617,8 @@ // 3. Mark files with errors and warnings: // "all" "show_diagnostics": "all", + // Whether to stick parent directories at top of the project panel. + "sticky_scroll": true, // Settings related to indent guides in the project panel. "indent_guides": { // When to show indent guides in the project panel. @@ -808,6 +810,7 @@ "edit_file": true, "fetch": true, "list_directory": true, + "project_notifications": true, "move_path": true, "now": true, "find_path": true, @@ -827,6 +830,7 @@ "diagnostics": true, "fetch": true, "list_directory": true, + "project_notifications": true, "now": true, "find_path": true, "read_file": true, diff --git a/crates/agent/src/prompts/stale_files_prompt_header.txt b/crates/agent/src/prompts/stale_files_prompt_header.txt new file mode 100644 index 0000000000000000000000000000000000000000..f743e239c883c7456f7bdc6e089185c6b994cb44 --- /dev/null +++ b/crates/agent/src/prompts/stale_files_prompt_header.txt @@ -0,0 +1,3 @@ +[The following is an auto-generated notification; do not reply] + +These files have changed since the last read: diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 815b9e86ea8a7c4c0879e81028c4ee42e3a84ca8..50d2a4d77383e4336bed6ba3fd6fc4a9e3e64ac1 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -25,8 +25,8 @@ use language_model::{ ConfiguredModel, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolResultContent, - LanguageModelToolUseId, MessageContent, ModelRequestLimitReachedError, PaymentRequiredError, - Role, SelectedModel, StopReason, TokenUsage, + LanguageModelToolUse, LanguageModelToolUseId, MessageContent, ModelRequestLimitReachedError, + PaymentRequiredError, Role, SelectedModel, StopReason, TokenUsage, }; use postage::stream::Stream as _; use project::{ @@ -45,7 +45,7 @@ use std::{ time::{Duration, Instant}, }; use thiserror::Error; -use util::{ResultExt as _, post_inc}; +use util::{ResultExt as _, debug_panic, post_inc}; use uuid::Uuid; use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; @@ -1248,6 +1248,8 @@ impl Thread { self.remaining_turns -= 1; + self.flush_notifications(model.clone(), intent, cx); + let request = self.to_completion_request(model.clone(), intent, cx); self.stream_completion(request, model, intent, window, cx); @@ -1481,6 +1483,110 @@ impl Thread { request } + /// Insert auto-generated notifications (if any) to the thread + fn flush_notifications( + &mut self, + model: Arc, + intent: CompletionIntent, + cx: &mut Context, + ) { + match intent { + CompletionIntent::UserPrompt | CompletionIntent::ToolResults => { + if let Some(pending_tool_use) = self.attach_tracked_files_state(model, cx) { + cx.emit(ThreadEvent::ToolFinished { + tool_use_id: pending_tool_use.id.clone(), + pending_tool_use: Some(pending_tool_use), + }); + } + } + CompletionIntent::ThreadSummarization + | CompletionIntent::ThreadContextSummarization + | CompletionIntent::CreateFile + | CompletionIntent::EditFile + | CompletionIntent::InlineAssist + | CompletionIntent::TerminalInlineAssist + | CompletionIntent::GenerateGitCommitMessage => {} + }; + } + + fn attach_tracked_files_state( + &mut self, + model: Arc, + cx: &mut App, + ) -> Option { + let action_log = self.action_log.read(cx); + + action_log.stale_buffers(cx).next()?; + + // Represent notification as a simulated `project_notifications` tool call + let tool_name = Arc::from("project_notifications"); + let Some(tool) = self.tools.read(cx).tool(&tool_name, cx) else { + debug_panic!("`project_notifications` tool not found"); + return None; + }; + + if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) { + return None; + } + + let input = serde_json::json!({}); + let request = Arc::new(LanguageModelRequest::default()); // unused + let window = None; + let tool_result = tool.run( + input, + request, + self.project.clone(), + self.action_log.clone(), + model.clone(), + window, + cx, + ); + + let tool_use_id = + LanguageModelToolUseId::from(format!("project_notifications_{}", self.messages.len())); + + let tool_use = LanguageModelToolUse { + id: tool_use_id.clone(), + name: tool_name.clone(), + raw_input: "{}".to_string(), + input: serde_json::json!({}), + is_input_complete: true, + }; + + let tool_output = cx.background_executor().block(tool_result.output); + + // Attach a project_notification tool call to the latest existing + // Assistant message. We cannot create a new Assistant message + // because thinking models require a `thinking` block that we + // cannot mock. We cannot send a notification as a normal + // (non-tool-use) User message because this distracts Agent + // too much. + let tool_message_id = self + .messages + .iter() + .enumerate() + .rfind(|(_, message)| message.role == Role::Assistant) + .map(|(_, message)| message.id)?; + + let tool_use_metadata = ToolUseMetadata { + model: model.clone(), + thread_id: self.id.clone(), + prompt_id: self.last_prompt_id.clone(), + }; + + self.tool_use + .request_tool_use(tool_message_id, tool_use, tool_use_metadata.clone(), cx); + + let pending_tool_use = self.tool_use.insert_tool_output( + tool_use_id.clone(), + tool_name, + tool_output, + self.configured_model.as_ref(), + ); + + pending_tool_use + } + pub fn stream_completion( &mut self, request: LanguageModelRequest, @@ -3156,10 +3262,13 @@ mod tests { const TEST_RATE_LIMIT_RETRY_SECS: u64 = 30; use agent_settings::{AgentProfileId, AgentSettings, LanguageModelParameters}; use assistant_tool::ToolRegistry; + use assistant_tools; use futures::StreamExt; use futures::future::BoxFuture; use futures::stream::BoxStream; use gpui::TestAppContext; + use http_client; + use indoc::indoc; use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; use language_model::{ LanguageModelCompletionError, LanguageModelName, LanguageModelProviderId, @@ -3487,6 +3596,105 @@ fn main() {{ ); } + #[gpui::test] + async fn test_stale_buffer_notification(cx: &mut TestAppContext) { + init_test_settings(cx); + + let project = create_test_project( + cx, + json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), + ) + .await; + + let (_workspace, _thread_store, thread, context_store, model) = + setup_test_environment(cx, project.clone()).await; + + // Add a buffer to the context. This will be a tracked buffer + let buffer = add_file_to_context(&project, &context_store, "test/code.rs", cx) + .await + .unwrap(); + + let context = context_store + .read_with(cx, |store, _| store.context().next().cloned()) + .unwrap(); + let loaded_context = cx + .update(|cx| load_context(vec![context], &project, &None, cx)) + .await; + + // Insert user message and assistant response + thread.update(cx, |thread, cx| { + thread.insert_user_message("Explain this code", loaded_context, None, Vec::new(), cx); + thread.insert_assistant_message( + vec![MessageSegment::Text("This code prints 42.".into())], + cx, + ); + }); + + // We shouldn't have a stale buffer notification yet + let notification = thread.read_with(cx, |thread, _| { + find_tool_use(thread, "project_notifications") + }); + assert!( + notification.is_none(), + "Should not have stale buffer notification before buffer is modified" + ); + + // Modify the buffer + buffer.update(cx, |buffer, cx| { + buffer.edit( + [(1..1, "\n println!(\"Added a new line\");\n")], + None, + cx, + ); + }); + + // Insert another user message + thread.update(cx, |thread, cx| { + thread.insert_user_message( + "What does the code do now?", + ContextLoadResult::default(), + None, + Vec::new(), + cx, + ) + }); + + // Check for the stale buffer warning + thread.update(cx, |thread, cx| { + thread.flush_notifications(model.clone(), CompletionIntent::UserPrompt, cx) + }); + + let Some(notification_result) = thread.read_with(cx, |thread, _cx| { + find_tool_use(thread, "project_notifications") + }) else { + panic!("Should have a `project_notifications` tool use"); + }; + + let Some(notification_content) = notification_result.content.to_str() else { + panic!("`project_notifications` should return text"); + }; + + let expected_content = indoc! {"[The following is an auto-generated notification; do not reply] + + These files have changed since the last read: + - code.rs + "}; + assert_eq!(notification_content, expected_content); + } + + fn find_tool_use(thread: &Thread, tool_name: &str) -> Option { + thread + .messages() + .filter_map(|message| { + thread + .tool_results_for_message(message.id) + .into_iter() + .find(|result| result.tool_name == tool_name.into()) + }) + .next() + .cloned() + } + #[gpui::test] async fn test_storing_profile_setting_per_thread(cx: &mut TestAppContext) { init_test_settings(cx); @@ -5052,6 +5260,14 @@ fn main() {{ language_model::init_settings(cx); ThemeSettings::register(cx); ToolRegistry::default_global(cx); + assistant_tool::init(cx); + + let http_client = Arc::new(http_client::HttpClientWithUrl::new( + http_client::FakeHttpClient::with_200_response(), + "http://localhost".to_string(), + None, + )); + assistant_tools::init(http_client, cx); }); } diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index d91aa5fb2200629703f2f789f293686eb4c3ad73..8bfdd507611112b2930fd07270667050796533e3 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -436,7 +436,7 @@ impl AgentConfiguration { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let context_server_ids = self.context_server_store.read(cx).all_server_ids().clone(); + let context_server_ids = self.context_server_store.read(cx).configured_server_ids(); v_flex() .p(DynamicSpacing::Base16.rems(cx)) diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index f303f34a52856a068f1d2da33cf1f0a4fb5813a5..73fc0b36ce33853abd7b8689ef251855e5aca6ac 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -426,6 +426,7 @@ impl ContextPicker { this.add_recent_file(project_path.clone(), window, cx); }) }, + None, ) } RecentEntry::Thread(thread) => { @@ -443,6 +444,7 @@ impl ContextPicker { .detach_and_log_err(cx); }) }, + None, ) } } diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs index ab91ded2c8e45ffd8c840f9dacaa413137dacd51..b377e40b193d090a61b88232098fd45645a2ab4f 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -686,6 +686,7 @@ impl ContextPickerCompletionProvider { let mut label = CodeLabel::plain(symbol.name.clone(), None); label.push_str(" ", None); label.push_str(&file_name, comment_id); + label.push_str(&format!(" L{}", symbol.range.start.0.row + 1), comment_id); let new_text = format!("{} ", MentionLink::for_symbol(&symbol.name, &full_path)); let new_text_len = new_text.len(); diff --git a/crates/assistant_tool/src/tool_schema.rs b/crates/assistant_tool/src/tool_schema.rs index 001b16ac87f02d3783d606ec3bc8d69a0cefd5a0..7b48f93ba6d23bcc1a6e2cf051737efaf69fa595 100644 --- a/crates/assistant_tool/src/tool_schema.rs +++ b/crates/assistant_tool/src/tool_schema.rs @@ -25,10 +25,15 @@ fn preprocess_json_schema(json: &mut Value) -> Result<()> { // `additionalProperties` defaults to `false` unless explicitly specified. // This prevents models from hallucinating tool parameters. if let Value::Object(obj) = json { - if let Some(Value::String(type_str)) = obj.get("type") { - if type_str == "object" && !obj.contains_key("additionalProperties") { + if matches!(obj.get("type"), Some(Value::String(s)) if s == "object") { + if !obj.contains_key("additionalProperties") { obj.insert("additionalProperties".to_string(), Value::Bool(false)); } + + // OpenAI API requires non-missing `properties` + if !obj.contains_key("properties") { + obj.insert("properties".to_string(), Value::Object(Default::default())); + } } } Ok(()) diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index 83312a07b625404085694194b92ee7c732a67998..eef792f526fb684e83752241194d293064a9f4f7 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -11,6 +11,7 @@ mod list_directory_tool; mod move_path_tool; mod now_tool; mod open_tool; +mod project_notifications_tool; mod read_file_tool; mod schema; mod templates; @@ -45,6 +46,7 @@ pub use edit_file_tool::{EditFileMode, EditFileToolInput}; pub use find_path_tool::FindPathToolInput; pub use grep_tool::{GrepTool, GrepToolInput}; pub use open_tool::OpenTool; +pub use project_notifications_tool::ProjectNotificationsTool; pub use read_file_tool::{ReadFileTool, ReadFileToolInput}; pub use terminal_tool::TerminalTool; @@ -61,6 +63,7 @@ pub fn init(http_client: Arc, cx: &mut App) { registry.register_tool(ListDirectoryTool); registry.register_tool(NowTool); registry.register_tool(OpenTool); + registry.register_tool(ProjectNotificationsTool); registry.register_tool(FindPathTool); registry.register_tool(ReadFileTool); registry.register_tool(GrepTool); diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..552ebb3d53e7453dbcaa4f363bd6e6ae5d2709b6 --- /dev/null +++ b/crates/assistant_tools/src/project_notifications_tool.rs @@ -0,0 +1,193 @@ +use crate::schema::json_schema_for; +use anyhow::Result; +use assistant_tool::{ActionLog, Tool, ToolResult}; +use gpui::{AnyWindowHandle, App, Entity, Task}; +use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::fmt::Write as _; +use std::sync::Arc; +use ui::IconName; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct ProjectUpdatesToolInput {} + +pub struct ProjectNotificationsTool; + +impl Tool for ProjectNotificationsTool { + fn name(&self) -> String { + "project_notifications".to_string() + } + + fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + false + } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { + include_str!("./project_notifications_tool/description.md").to_string() + } + + fn icon(&self) -> IconName { + IconName::Envelope + } + + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { + json_schema_for::(format) + } + + fn ui_text(&self, _input: &serde_json::Value) -> String { + "Check project notifications".into() + } + + fn run( + self: Arc, + _input: serde_json::Value, + _request: Arc, + _project: Entity, + action_log: Entity, + _model: Arc, + _window: Option, + cx: &mut App, + ) -> ToolResult { + let mut stale_files = String::new(); + + let action_log = action_log.read(cx); + + for stale_file in action_log.stale_buffers(cx) { + if let Some(file) = stale_file.read(cx).file() { + writeln!(&mut stale_files, "- {}", file.path().display()).ok(); + } + } + + let response = if stale_files.is_empty() { + "No new notifications".to_string() + } else { + // NOTE: Changes to this prompt require a symmetric update in the LLM Worker + const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt"); + format!("{HEADER}{stale_files}").replace("\r\n", "\n") + }; + + Task::ready(Ok(response.into())).into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use assistant_tool::ToolResultContent; + use gpui::{AppContext, TestAppContext}; + use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModelProvider}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use std::sync::Arc; + use util::path; + + #[gpui::test] + async fn test_stale_buffer_notification(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), + ) + .await; + + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let buffer_path = project + .read_with(cx, |project, cx| { + project.find_project_path("test/code.rs", cx) + }) + .unwrap(); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(buffer_path.clone(), cx) + }) + .await + .unwrap(); + + // Start tracking the buffer + action_log.update(cx, |log, cx| { + log.buffer_read(buffer.clone(), cx); + }); + + // Run the tool before any changes + let tool = Arc::new(ProjectNotificationsTool); + let provider = Arc::new(FakeLanguageModelProvider); + let model: Arc = Arc::new(provider.test_model()); + let request = Arc::new(LanguageModelRequest::default()); + let tool_input = json!({}); + + let result = cx.update(|cx| { + tool.clone().run( + tool_input.clone(), + request.clone(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }); + + let response = result.output.await.unwrap(); + let response_text = match &response.content { + ToolResultContent::Text(text) => text.clone(), + _ => panic!("Expected text response"), + }; + assert_eq!( + response_text.as_str(), + "No new notifications", + "Tool should return 'No new notifications' when no stale buffers" + ); + + // Modify the buffer (makes it stale) + buffer.update(cx, |buffer, cx| { + buffer.edit([(1..1, "\nChange!\n")], None, cx); + }); + + // Run the tool again + let result = cx.update(|cx| { + tool.run( + tool_input.clone(), + request.clone(), + project.clone(), + action_log, + model.clone(), + None, + cx, + ) + }); + + // This time the buffer is stale, so the tool should return a notification + let response = result.output.await.unwrap(); + let response_text = match &response.content { + ToolResultContent::Text(text) => text.clone(), + _ => panic!("Expected text response"), + }; + + let expected_content = "[The following is an auto-generated notification; do not reply]\n\nThese files have changed since the last read:\n- code.rs\n"; + assert_eq!( + response_text.as_str(), + expected_content, + "Tool should return the stale buffer notification" + ); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + assistant_tool::init(cx); + }); + } +} diff --git a/crates/assistant_tools/src/project_notifications_tool/description.md b/crates/assistant_tools/src/project_notifications_tool/description.md new file mode 100644 index 0000000000000000000000000000000000000000..24ff678f5e7fd728b94ad4ebce06f2a1dcc6a658 --- /dev/null +++ b/crates/assistant_tools/src/project_notifications_tool/description.md @@ -0,0 +1,3 @@ +This tool reports which files have been modified by the user since the agent last accessed them. + +It serves as a notification mechanism to inform the agent of recent changes. No immediate action is required in response to these updates. diff --git a/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt b/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt new file mode 100644 index 0000000000000000000000000000000000000000..f743e239c883c7456f7bdc6e089185c6b994cb44 --- /dev/null +++ b/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt @@ -0,0 +1,3 @@ +[The following is an auto-generated notification; do not reply] + +These files have changed since the last read: diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 2c582a531069eb9a81340af7eb07731e8df8a96e..9a3eac907cbdd6848df32eeed9481058bc368840 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -218,7 +218,7 @@ impl Tool for TerminalTool { .update(cx, |project, cx| { project.create_terminal( TerminalKind::Task(task::SpawnInTerminal { - command: program, + command: Some(program), args, cwd, env, diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index b1fa1565f30ed79fdff763964708fe01c62d023f..4c91b4fedb790ab3500273ff21aba767cacd28e0 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -528,6 +528,7 @@ impl CopilotChat { pub async fn stream_completion( request: Request, + is_user_initiated: bool, mut cx: AsyncApp, ) -> Result>> { let this = cx @@ -562,7 +563,14 @@ impl CopilotChat { }; let api_url = configuration.api_url_from_endpoint(&token.api_endpoint); - stream_completion(client.clone(), token.api_key, api_url.into(), request).await + stream_completion( + client.clone(), + token.api_key, + api_url.into(), + request, + is_user_initiated, + ) + .await } pub fn set_configuration( @@ -697,6 +705,7 @@ async fn stream_completion( api_key: String, completion_url: Arc, request: Request, + is_user_initiated: bool, ) -> Result>> { let is_vision_request = request.messages.iter().any(|message| match message { ChatMessage::User { content } @@ -707,6 +716,8 @@ async fn stream_completion( _ => false, }); + let request_initiator = if is_user_initiated { "user" } else { "agent" }; + let mut request_builder = HttpRequest::builder() .method(Method::POST) .uri(completion_url.as_ref()) @@ -719,7 +730,8 @@ async fn stream_completion( ) .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") - .header("Copilot-Integration-Id", "vscode-chat"); + .header("Copilot-Integration-Id", "vscode-chat") + .header("X-Initiator", request_initiator); if is_vision_request { request_builder = diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs index 4ffa6e51db76063def22e647382c06e61638c728..02acd97ee83b5bad3eeb4683b70826a44c75ce70 100644 --- a/crates/dap_adapters/src/javascript.rs +++ b/crates/dap_adapters/src/javascript.rs @@ -1,11 +1,11 @@ use adapters::latest_github_release; use anyhow::Context as _; +use collections::HashMap; use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition}; use gpui::AsyncApp; use serde_json::Value; use std::{ borrow::Cow, - collections::HashMap, path::PathBuf, sync::{LazyLock, OnceLock}, }; @@ -75,6 +75,8 @@ impl JsDebugAdapter { let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default(); let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?; + let mut envs = HashMap::default(); + let mut configuration = task_definition.config.clone(); if let Some(configuration) = configuration.as_object_mut() { maybe!({ @@ -115,6 +117,12 @@ impl JsDebugAdapter { } } + if let Some(env) = configuration.get("env").cloned() { + if let Ok(env) = serde_json::from_value(env) { + envs = env; + } + } + configuration .entry("cwd") .or_insert(delegate.worktree_root_path().to_string_lossy().into()); @@ -163,7 +171,7 @@ impl JsDebugAdapter { ), arguments, cwd: Some(delegate.worktree_root_path().to_path_buf()), - envs: HashMap::default(), + envs, connection: Some(adapters::TcpArguments { host, port, diff --git a/crates/debug_adapter_extension/src/extension_locator_adapter.rs b/crates/debug_adapter_extension/src/extension_locator_adapter.rs index 54c03b1eafa1cda8495c29f419f1588c570d78c3..55094ea7de02385ad3a5a75ea8ac0042c50a8600 100644 --- a/crates/debug_adapter_extension/src/extension_locator_adapter.rs +++ b/crates/debug_adapter_extension/src/extension_locator_adapter.rs @@ -44,7 +44,9 @@ impl DapLocator for ExtensionLocatorAdapter { .flatten() } - async fn run(&self, _build_config: SpawnInTerminal) -> Result { - Err(anyhow::anyhow!("Not implemented")) + async fn run(&self, build_config: SpawnInTerminal) -> Result { + self.extension + .run_dap_locator(self.locator_name.as_ref().to_owned(), build_config) + .await } } diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index b9f373daa4b6afc96e63817d64b686840a2d0738..af8c14aef77d0886071dfd899d8de5adff0d3ed6 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -973,7 +973,7 @@ impl RunningState { let task_with_shell = SpawnInTerminal { command_label, - command, + command: Some(command), args, ..task.resolved.clone() }; @@ -1085,19 +1085,6 @@ impl RunningState { .map(PathBuf::from) .or_else(|| session.binary().unwrap().cwd.clone()); - let mut args = request.args.clone(); - - // Handle special case for NodeJS debug adapter - // If only the Node binary path is provided, we set the command to None - // This prevents the NodeJS REPL from appearing, which is not the desired behavior - // The expected usage is for users to provide their own Node command, e.g., `node test.js` - // This allows the NodeJS debug client to attach correctly - let command = if args.len() > 1 { - Some(args.remove(0)) - } else { - None - }; - let mut envs: HashMap = self.session.read(cx).task_context().project_env.clone(); if let Some(Value::Object(env)) = &request.env { @@ -1111,32 +1098,58 @@ impl RunningState { } } - let shell = project.read(cx).terminal_settings(&cwd, cx).shell.clone(); - let kind = if let Some(command) = command { - let title = request.title.clone().unwrap_or(command.clone()); - TerminalKind::Task(task::SpawnInTerminal { - id: task::TaskId("debug".to_string()), - full_label: title.clone(), - label: title.clone(), - command: command.clone(), - args, - command_label: title.clone(), - cwd, - env: envs, - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: task::RevealStrategy::NoFocus, - reveal_target: task::RevealTarget::Dock, - hide: task::HideStrategy::Never, - shell, - show_summary: false, - show_command: false, - show_rerun: false, - }) + let mut args = request.args.clone(); + let command = if envs.contains_key("VSCODE_INSPECTOR_OPTIONS") { + // Handle special case for NodeJS debug adapter + // If the Node binary path is provided (possibly with arguments like --experimental-network-inspection), + // we set the command to None + // This prevents the NodeJS REPL from appearing, which is not the desired behavior + // The expected usage is for users to provide their own Node command, e.g., `node test.js` + // This allows the NodeJS debug client to attach correctly + if args + .iter() + .filter(|arg| !arg.starts_with("--")) + .collect::>() + .len() + > 1 + { + Some(args.remove(0)) + } else { + None + } + } else if args.len() > 0 { + Some(args.remove(0)) } else { - TerminalKind::Shell(cwd.map(|c| c.to_path_buf())) + None }; + let shell = project.read(cx).terminal_settings(&cwd, cx).shell.clone(); + let title = request + .title + .clone() + .filter(|title| !title.is_empty()) + .or_else(|| command.clone()) + .unwrap_or_else(|| "Debug terminal".to_string()); + let kind = TerminalKind::Task(task::SpawnInTerminal { + id: task::TaskId("debug".to_string()), + full_label: title.clone(), + label: title.clone(), + command: command.clone(), + args, + command_label: title.clone(), + cwd, + env: envs, + use_new_terminal: true, + allow_concurrent_runs: true, + reveal: task::RevealStrategy::NoFocus, + reveal_target: task::RevealTarget::Dock, + hide: task::HideStrategy::Never, + shell, + show_summary: false, + show_command: false, + show_rerun: false, + }); + let workspace = self.workspace.clone(); let weak_project = project.downgrade(); diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index 2ec20c9877ab38642fa12aa6cc2a61256dd6ab26..78c87db2e6f2a1f9d54368b875d1e86b3ac5789f 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -5,7 +5,8 @@ use std::{ time::Duration, }; -use dap::{Capabilities, ExceptionBreakpointsFilter}; +use dap::{Capabilities, ExceptionBreakpointsFilter, adapters::DebugAdapterName}; +use db::kvp::KEY_VALUE_STORE; use editor::Editor; use gpui::{ Action, AppContext, ClickEvent, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy, @@ -16,6 +17,7 @@ use project::{ Project, debugger::{ breakpoint_store::{BreakpointEditAction, BreakpointStore, SourceBreakpoint}, + dap_store::{DapStore, PersistedAdapterOptions}, session::Session, }, worktree_store::WorktreeStore, @@ -48,6 +50,7 @@ pub(crate) enum SelectedBreakpointKind { pub(crate) struct BreakpointList { workspace: WeakEntity, breakpoint_store: Entity, + dap_store: Entity, worktree_store: Entity, scrollbar_state: ScrollbarState, breakpoints: Vec, @@ -59,6 +62,7 @@ pub(crate) struct BreakpointList { selected_ix: Option, input: Entity, strip_mode: Option, + serialize_exception_breakpoints_task: Option>>, } impl Focusable for BreakpointList { @@ -85,24 +89,34 @@ impl BreakpointList { let project = project.read(cx); let breakpoint_store = project.breakpoint_store(); let worktree_store = project.worktree_store(); + let dap_store = project.dap_store(); let focus_handle = cx.focus_handle(); let scroll_handle = UniformListScrollHandle::new(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); - cx.new(|cx| Self { - breakpoint_store, - worktree_store, - scrollbar_state, - breakpoints: Default::default(), - hide_scrollbar_task: None, - show_scrollbar: false, - workspace, - session, - focus_handle, - scroll_handle, - selected_ix: None, - input: cx.new(|cx| Editor::single_line(window, cx)), - strip_mode: None, + let adapter_name = session.as_ref().map(|session| session.read(cx).adapter()); + cx.new(|cx| { + let this = Self { + breakpoint_store, + dap_store, + worktree_store, + scrollbar_state, + breakpoints: Default::default(), + hide_scrollbar_task: None, + show_scrollbar: false, + workspace, + session, + focus_handle, + scroll_handle, + selected_ix: None, + input: cx.new(|cx| Editor::single_line(window, cx)), + strip_mode: None, + serialize_exception_breakpoints_task: None, + }; + if let Some(name) = adapter_name { + _ = this.deserialize_exception_breakpoints(name, cx); + } + this }) } @@ -404,12 +418,8 @@ impl BreakpointList { self.edit_line_breakpoint(path, row, BreakpointEditAction::InvertState, cx); } BreakpointEntryKind::ExceptionBreakpoint(exception_breakpoint) => { - if let Some(session) = &self.session { - let id = exception_breakpoint.id.clone(); - session.update(cx, |session, cx| { - session.toggle_exception_breakpoint(&id, cx); - }); - } + let id = exception_breakpoint.id.clone(); + self.toggle_exception_breakpoint(&id, cx); } } cx.notify(); @@ -480,6 +490,64 @@ impl BreakpointList { cx.notify(); } + fn toggle_exception_breakpoint(&mut self, id: &str, cx: &mut Context) { + if let Some(session) = &self.session { + session.update(cx, |this, cx| { + this.toggle_exception_breakpoint(&id, cx); + }); + cx.notify(); + const EXCEPTION_SERIALIZATION_INTERVAL: Duration = Duration::from_secs(1); + self.serialize_exception_breakpoints_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(EXCEPTION_SERIALIZATION_INTERVAL) + .await; + this.update(cx, |this, cx| this.serialize_exception_breakpoints(cx))? + .await?; + Ok(()) + })); + } + } + + fn kvp_key(adapter_name: &str) -> String { + format!("debug_adapter_`{adapter_name}`_persistence") + } + fn serialize_exception_breakpoints( + &mut self, + cx: &mut Context, + ) -> Task> { + if let Some(session) = self.session.as_ref() { + let key = { + let session = session.read(cx); + let name = session.adapter().0; + Self::kvp_key(&name) + }; + let settings = self.dap_store.update(cx, |this, cx| { + this.sync_adapter_options(session, cx); + }); + let value = serde_json::to_string(&settings); + + cx.background_executor() + .spawn(async move { KEY_VALUE_STORE.write_kvp(key, value?).await }) + } else { + return Task::ready(Result::Ok(())); + } + } + + fn deserialize_exception_breakpoints( + &self, + adapter_name: DebugAdapterName, + cx: &mut Context, + ) -> anyhow::Result<()> { + let Some(val) = KEY_VALUE_STORE.read_kvp(&Self::kvp_key(&adapter_name))? else { + return Ok(()); + }; + let value: PersistedAdapterOptions = serde_json::from_str(&val)?; + self.dap_store + .update(cx, |this, _| this.set_adapter_options(adapter_name, value)); + + Ok(()) + } + fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context) { const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| { @@ -988,12 +1056,7 @@ impl ExceptionBreakpoint { let list = list.clone(); move |_, _, cx| { list.update(cx, |this, cx| { - if let Some(session) = &this.session { - session.update(cx, |this, cx| { - this.toggle_exception_breakpoint(&id, cx); - }); - cx.notify(); - } + this.toggle_exception_breakpoint(&id, cx); }) .ok(); } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index def2a616a8aa0f29e330b85c75cb8ae2d285542a..70ec8ea00f52dccbab9e2a3ad4856599a8a94acf 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -635,6 +635,8 @@ actions!( SignatureHelpNext, /// Navigates to the previous signature in the signature help popup. SignatureHelpPrevious, + /// Sorts selected lines by length. + SortLinesByLength, /// Sorts selected lines case-insensitively. SortLinesCaseInsensitive, /// Sorts selected lines case-sensitively. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 47223aa59a86eef27494f28dccae144c14a59f85..6d529287a778e1b56994f80084dfb52f33d1e893 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10204,6 +10204,17 @@ impl Editor { self.manipulate_immutable_lines(window, cx, |lines| lines.sort()) } + pub fn sort_lines_by_length( + &mut self, + _: &SortLinesByLength, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_immutable_lines(window, cx, |lines| { + lines.sort_by_key(|&line| line.chars().count()) + }) + } + pub fn sort_lines_case_insensitive( &mut self, _: &SortLinesCaseInsensitive, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index ade9a9322bcdbe38ad33fe9611820c43e2ea5809..aea84de9b022d742080ca9187a6a835092da67af 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -4075,6 +4075,29 @@ async fn test_manipulate_immutable_lines_with_single_selection(cx: &mut TestAppC Zˇ» "}); + // Test sort_lines_by_length() + // + // Demonstrates: + // - ∞ is 3 bytes UTF-8, but sorted by its char count (1) + // - sort is stable + cx.set_state(indoc! {" + «123 + æ + 12 + ∞ + 1 + æˇ» + "}); + cx.update_editor(|e, window, cx| e.sort_lines_by_length(&SortLinesByLength, window, cx)); + cx.assert_editor_state(indoc! {" + «æ + ∞ + 1 + æ + 12 + 123ˇ» + "}); + // Test reverse_lines() cx.set_state(indoc! {" «5 @@ -22325,6 +22348,19 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { def f() -> list[str]: aˇ "}); + + // test does not outdent on typing : after case keyword + cx.set_state(indoc! {" + match 1: + caseˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input(":", window, cx); + }); + cx.assert_editor_state(indoc! {" + match 1: + case:ˇ + "}); } #[gpui::test] diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 3fa8697c193f80e2f974c57b74947e32a689a506..49f4fc52ac725bcef2707f2bf508a1fae811f434 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -225,6 +225,7 @@ impl EditorElement { register_action(editor, window, Editor::autoindent); register_action(editor, window, Editor::delete_line); register_action(editor, window, Editor::join_lines); + register_action(editor, window, Editor::sort_lines_by_length); register_action(editor, window, Editor::sort_lines_case_sensitive); register_action(editor, window, Editor::sort_lines_case_insensitive); register_action(editor, window, Editor::reverse_lines); diff --git a/crates/eval/src/examples/file_change_notification.rs b/crates/eval/src/examples/file_change_notification.rs index 0e4f770a6757a216061e28efb227a339f1094084..7879ad6f2ebb782bd4a5620f0fdf562c9aad1360 100644 --- a/crates/eval/src/examples/file_change_notification.rs +++ b/crates/eval/src/examples/file_change_notification.rs @@ -14,7 +14,7 @@ impl Example for FileChangeNotificationExample { url: "https://github.com/octocat/hello-world".to_string(), revision: "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d".to_string(), language_server: None, - max_assertions: Some(1), + max_assertions: None, profile_id: AgentProfileId::default(), existing_thread_json: None, max_turns: Some(3), diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index 4e3f8a3dc214e7b6f8970c72562b85838a1660aa..0a14923c0c1a4ccfb153d9fa7f602d36805799fe 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -130,6 +130,12 @@ impl ExtensionManifest { Ok(()) } + + pub fn allow_remote_load(&self) -> bool { + !self.language_servers.is_empty() + || !self.debug_adapters.is_empty() + || !self.debug_locators.is_empty() + } } pub fn build_debug_adapter_schema_path( diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index eb6fb52eb82acec5b628aac05fd9131568a6c919..7c58fac1e0d363a4536fd0c7ea0035609c90330e 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -1670,7 +1670,7 @@ impl ExtensionStore { .extensions .iter() .filter_map(|(id, entry)| { - if entry.manifest.language_servers.is_empty() { + if !entry.manifest.allow_remote_load() { return None; } Some(proto::Extension { diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index ad3931ce838043c7644fd3e0c3d0eb249db1dd9b..8feaec89de5c0c607bffe87c3be9b4700169e190 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -125,7 +125,7 @@ impl HeadlessExtensionStore { let manifest = Arc::new(ExtensionManifest::load(fs.clone(), &extension_dir).await?); - debug_assert!(!manifest.languages.is_empty() || !manifest.language_servers.is_empty()); + debug_assert!(!manifest.languages.is_empty() || manifest.allow_remote_load()); if manifest.version.as_ref() != extension.version.as_str() { anyhow::bail!( @@ -165,7 +165,7 @@ impl HeadlessExtensionStore { })?; } - if manifest.language_servers.is_empty() { + if !manifest.allow_remote_load() { return Ok(()); } @@ -187,24 +187,28 @@ impl HeadlessExtensionStore { ); })?; } - for (debug_adapter, meta) in &manifest.debug_adapters { - let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, meta); + log::info!("Loaded language server: {}", language_server_id); + } - this.update(cx, |this, _cx| { - this.proxy.register_debug_adapter( - wasm_extension.clone(), - debug_adapter.clone(), - &extension_dir.join(schema_path), - ); - })?; - } + for (debug_adapter, meta) in &manifest.debug_adapters { + let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, meta); - for debug_adapter in manifest.debug_locators.keys() { - this.update(cx, |this, _cx| { - this.proxy - .register_debug_locator(wasm_extension.clone(), debug_adapter.clone()); - })?; - } + this.update(cx, |this, _cx| { + this.proxy.register_debug_adapter( + wasm_extension.clone(), + debug_adapter.clone(), + &extension_dir.join(schema_path), + ); + })?; + log::info!("Loaded debug adapter: {}", debug_adapter); + } + + for debug_locator in manifest.debug_locators.keys() { + this.update(cx, |this, _cx| { + this.proxy + .register_debug_locator(wasm_extension.clone(), debug_locator.clone()); + })?; + log::info!("Loaded debug locator: {}", debug_locator); } Ok(()) diff --git a/crates/extension_host/src/wasm_host/wit.rs b/crates/extension_host/src/wasm_host/wit.rs index b2b7726a1566dd202f25f52c3ceb8023ff216371..1f1fa49bd535ad19f4981eeed9fcdca1ba9421a9 100644 --- a/crates/extension_host/src/wasm_host/wit.rs +++ b/crates/extension_host/src/wasm_host/wit.rs @@ -999,7 +999,7 @@ impl Extension { ) -> Result> { match self { Extension::V0_6_0(ext) => { - let build_config_template = resolved_build_task.into(); + let build_config_template = resolved_build_task.try_into()?; let dap_request = ext .call_run_dap_locator(store, &locator_name, &build_config_template) .await? 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 f8f9ae1977687296790a562711c286e2fce026e4..ced2ea9c677022e95f106ac6ba0543303fe5a372 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 @@ -299,15 +299,17 @@ impl From for DebugScenario { } } -impl From for ResolvedTask { - fn from(value: SpawnInTerminal) -> Self { - Self { +impl TryFrom for ResolvedTask { + type Error = anyhow::Error; + + fn try_from(value: SpawnInTerminal) -> Result { + Ok(Self { label: value.label, - command: value.command, + command: value.command.context("missing command")?, args: value.args, env: value.env.into_iter().collect(), cwd: value.cwd.map(|s| s.to_string_lossy().into_owned()), - } + }) } } diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index c85f71eae8954e8afd72812e313bfd1ebfbea2c1..f32ecfc20cb0d1a488705f9e48e596f9a05ef98c 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -7,8 +7,8 @@ use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId, - ListSizingBehavior, Overflow, Pixels, ScrollHandle, Size, StyleRefinement, Styled, Window, - point, size, + ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, StyleRefinement, Styled, + Window, point, size, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; @@ -42,6 +42,7 @@ where item_count, item_to_measure_index: 0, render_items: Box::new(render_range), + top_slot: None, decorations: Vec::new(), interactivity: Interactivity { element_id: Some(id), @@ -61,6 +62,7 @@ pub struct UniformList { render_items: Box< dyn for<'a> Fn(Range, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>, >, + top_slot: Option>, decorations: Vec>, interactivity: Interactivity, scroll_handle: Option, @@ -71,6 +73,7 @@ pub struct UniformList { /// Frame state used by the [UniformList]. pub struct UniformListFrameState { items: SmallVec<[AnyElement; 32]>, + top_slot_items: SmallVec<[AnyElement; 8]>, decorations: SmallVec<[AnyElement; 1]>, } @@ -88,6 +91,8 @@ pub enum ScrollStrategy { /// May not be possible if there's not enough list items above the item scrolled to: /// in this case, the element will be placed at the closest possible position. Center, + /// Scrolls the element to be at the given item index from the top of the viewport. + ToPosition(usize), } #[derive(Clone, Debug, Default)] @@ -212,6 +217,7 @@ impl Element for UniformList { UniformListFrameState { items: SmallVec::new(), decorations: SmallVec::new(), + top_slot_items: SmallVec::new(), }, ) } @@ -345,6 +351,15 @@ impl Element for UniformList { } } } + ScrollStrategy::ToPosition(sticky_index) => { + let target_y_in_viewport = item_height * sticky_index; + let target_scroll_top = item_top - target_y_in_viewport; + let max_scroll_top = + (content_height - list_height).max(Pixels::ZERO); + let new_scroll_top = + target_scroll_top.clamp(Pixels::ZERO, max_scroll_top); + updated_scroll_offset.y = -new_scroll_top; + } } scroll_offset = *updated_scroll_offset } @@ -354,7 +369,17 @@ impl Element for UniformList { let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height) / item_height) .ceil() as usize; - let visible_range = first_visible_element_ix + let initial_range = first_visible_element_ix + ..cmp::min(last_visible_element_ix, self.item_count); + + let mut top_slot_elements = if let Some(ref mut top_slot) = self.top_slot { + top_slot.compute(initial_range, window, cx) + } else { + SmallVec::new() + }; + let top_slot_offset = top_slot_elements.len(); + + let visible_range = (top_slot_offset + first_visible_element_ix) ..cmp::min(last_visible_element_ix, self.item_count); let items = if y_flipped { @@ -393,6 +418,20 @@ impl Element for UniformList { frame_state.items.push(item); } + if let Some(ref top_slot) = self.top_slot { + top_slot.prepaint( + &mut top_slot_elements, + padded_bounds, + item_height, + scroll_offset, + padding, + can_scroll_horizontally, + window, + cx, + ); + } + frame_state.top_slot_items = top_slot_elements; + let bounds = Bounds::new( padded_bounds.origin + point( @@ -454,6 +493,9 @@ impl Element for UniformList { for decoration in &mut request_layout.decorations { decoration.paint(window, cx); } + if let Some(ref top_slot) = self.top_slot { + top_slot.paint(&mut request_layout.top_slot_items, window, cx); + } }, ) } @@ -483,6 +525,35 @@ pub trait UniformListDecoration { ) -> AnyElement; } +/// A trait for implementing top slots in a [`UniformList`]. +/// Top slots are elements that appear at the top of the list and can adjust +/// the visible range of list items. +pub trait UniformListTopSlot { + /// Returns elements to render at the top slot for the given visible range. + fn compute( + &mut self, + visible_range: Range, + window: &mut Window, + cx: &mut App, + ) -> SmallVec<[AnyElement; 8]>; + + /// Layout and prepaint the top slot elements. + fn prepaint( + &self, + elements: &mut SmallVec<[AnyElement; 8]>, + bounds: Bounds, + item_height: Pixels, + scroll_offset: Point, + padding: crate::Edges, + can_scroll_horizontally: bool, + window: &mut Window, + cx: &mut App, + ); + + /// Paint the top slot elements. + fn paint(&self, elements: &mut SmallVec<[AnyElement; 8]>, window: &mut Window, cx: &mut App); +} + impl UniformList { /// Selects a specific list item for measurement. pub fn with_width_from_item(mut self, item_index: Option) -> Self { @@ -521,6 +592,12 @@ impl UniformList { self } + /// Sets a top slot for the list. + pub fn with_top_slot(mut self, top_slot: impl UniformListTopSlot + 'static) -> Self { + self.top_slot = Some(Box::new(top_slot)); + self + } + fn measure_item( &self, list_width: Option, diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 18adc1af10073001b82e0a72f8e372fafe4395b0..40387a820230cfc0f73f90643c082619ceaa595a 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -55,7 +55,7 @@ impl Keystroke { /// /// This method assumes that `self` was typed and `target' is in the keymap, and checks /// both possibilities for self against the target. - pub(crate) fn should_match(&self, target: &Keystroke) -> bool { + pub fn should_match(&self, target: &Keystroke) -> bool { #[cfg(not(target_os = "windows"))] if let Some(key_char) = self .key_char diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index a43fdc097f24a5c30544817f60c992829dbc831a..8b8964b2dfa827784ad5bfc2281835d64f4d7fbc 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -466,12 +466,7 @@ fn handle_keyup_msg( } fn handle_char_msg(wparam: WPARAM, state_ptr: Rc) -> Option { - let Some(input) = char::from_u32(wparam.0 as u32) - .filter(|c| !c.is_control()) - .map(String::from) - else { - return Some(1); - }; + let input = parse_char_message(wparam, &state_ptr)?; with_input_handler(&state_ptr, |input_handler| { input_handler.replace_text_in_range(None, &input); }); @@ -1228,6 +1223,36 @@ fn handle_input_language_changed( Some(0) } +#[inline] +fn parse_char_message(wparam: WPARAM, state_ptr: &Rc) -> Option { + let code_point = wparam.loword(); + let mut lock = state_ptr.state.borrow_mut(); + // https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G2630 + match code_point { + 0xD800..=0xDBFF => { + // High surrogate, wait for low surrogate + lock.pending_surrogate = Some(code_point); + None + } + 0xDC00..=0xDFFF => { + if let Some(high_surrogate) = lock.pending_surrogate.take() { + // Low surrogate, combine with pending high surrogate + String::from_utf16(&[high_surrogate, code_point]).ok() + } else { + // Invalid low surrogate without a preceding high surrogate + log::warn!( + "Received low surrogate without a preceding high surrogate: {code_point:x}" + ); + None + } + } + _ => { + lock.pending_surrogate = None; + String::from_utf16(&[code_point]).ok() + } + } +} + #[inline] fn translate_message(handle: HWND, wparam: WPARAM, lparam: LPARAM) { let msg = MSG { @@ -1270,6 +1295,10 @@ where capslock: current_capslock(), })) } + VK_PACKET => { + translate_message(handle, wparam, lparam); + None + } VK_CAPITAL => { let capslock = current_capslock(); if state diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 5c7dd07c4857c8d99dd8dcde294942cd33bf0573..5703a82815eb0679ca3668a13c08f3e9affa3696 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -43,6 +43,7 @@ pub struct WindowsWindowState { pub callbacks: Callbacks, pub input_handler: Option, + pub pending_surrogate: Option, pub last_reported_modifiers: Option, pub last_reported_capslock: Option, pub system_key_handled: bool, @@ -105,6 +106,7 @@ impl WindowsWindowState { let renderer = windows_renderer::init(gpu_context, hwnd, transparent)?; let callbacks = Callbacks::default(); let input_handler = None; + let pending_surrogate = None; let last_reported_modifiers = None; let last_reported_capslock = None; let system_key_handled = false; @@ -126,6 +128,7 @@ impl WindowsWindowState { min_size, callbacks, input_handler, + pending_surrogate, last_reported_modifiers, last_reported_capslock, system_key_handled, diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index f8123d676a001427b8b0350d53cdd7ab8b1041ab..7e6b77b93deafbb971980d8b2d19f33f2fa348b4 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -835,10 +835,6 @@ impl InlineCompletionButton { cx.notify(); } - - pub fn toggle_menu(&mut self, window: &mut Window, cx: &mut Context) { - self.popover_menu_handle.toggle(window, cx); - } } impl StatusItemView for InlineCompletionButton { diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 5411fbc63c10d84d96e2d85bf77c453bf66b5411..d9a84f1eb74465a0d5e72591d450802d5708cb20 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -30,6 +30,7 @@ use settings::SettingsStore; use std::time::Duration; use ui::prelude::*; use util::debug_panic; +use zed_llm_client::CompletionIntent; use super::anthropic::count_anthropic_tokens; use super::google::count_google_tokens; @@ -268,6 +269,19 @@ impl LanguageModel for CopilotChatLanguageModel { LanguageModelCompletionError, >, > { + let is_user_initiated = request.intent.is_none_or(|intent| match intent { + CompletionIntent::UserPrompt + | CompletionIntent::ThreadContextSummarization + | CompletionIntent::InlineAssist + | CompletionIntent::TerminalInlineAssist + | CompletionIntent::GenerateGitCommitMessage => true, + + CompletionIntent::ToolResults + | CompletionIntent::ThreadSummarization + | CompletionIntent::CreateFile + | CompletionIntent::EditFile => false, + }); + let copilot_request = match into_copilot_chat(&self.model, request) { Ok(request) => request, Err(err) => return futures::future::ready(Err(err.into())).boxed(), @@ -276,7 +290,8 @@ impl LanguageModel for CopilotChatLanguageModel { let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { - let request = CopilotChat::stream_completion(copilot_request, cx.clone()); + let request = + CopilotChat::stream_completion(copilot_request, is_user_initiated, cx.clone()); request_limiter .stream(async move { let response = request.await?; diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index ffdc939809145b319d5421adf5b8a923604e74fe..45af7518d589166e26788203c919d2267b544756 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -24,7 +24,6 @@ gpui.workspace = true itertools.workspace = true language.workspace = true lsp.workspace = true -picker.workspace = true project.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/language_tools/src/lsp_tool.rs b/crates/language_tools/src/lsp_tool.rs index 24a53ae2529b23b45e2478227109506839c12a89..6cd2f83184d946fdbf133f5d7d17d188d294ee13 100644 --- a/crates/language_tools/src/lsp_tool.rs +++ b/crates/language_tools/src/lsp_tool.rs @@ -1,19 +1,18 @@ -use std::{collections::hash_map, path::PathBuf, sync::Arc, time::Duration}; +use std::{collections::hash_map, path::PathBuf, rc::Rc, time::Duration}; use client::proto; use collections::{HashMap, HashSet}; use editor::{Editor, EditorEvent}; use feature_flags::FeatureFlagAppExt as _; -use gpui::{ - Corner, DismissEvent, Entity, Focusable as _, MouseButton, Subscription, Task, WeakEntity, - actions, -}; +use gpui::{Corner, Entity, Subscription, Task, WeakEntity, actions}; use language::{BinaryStatus, BufferId, LocalFile, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; -use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu}; use project::{LspStore, LspStoreEvent, project_settings::ProjectSettings}; use settings::{Settings as _, SettingsStore}; -use ui::{Context, Indicator, PopoverMenuHandle, Tooltip, Window, prelude::*}; +use ui::{ + Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide, + Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, Window, prelude::*, +}; use workspace::{StatusItemView, Workspace}; @@ -28,33 +27,38 @@ actions!( ); pub struct LspTool { - state: Entity, - popover_menu_handle: PopoverMenuHandle>, - lsp_picker: Option>>, + server_state: Entity, + popover_menu_handle: PopoverMenuHandle, + lsp_menu: Option>, + lsp_menu_refresh: Task<()>, _subscriptions: Vec, } -struct PickerState { +#[derive(Debug)] +struct LanguageServerState { + items: Vec, + other_servers_start_index: Option, workspace: WeakEntity, lsp_store: WeakEntity, active_editor: Option, language_servers: LanguageServers, } -#[derive(Debug)] -pub struct LspPickerDelegate { - state: Entity, - selected_index: usize, - items: Vec, - other_servers_start_index: Option, -} - struct ActiveEditor { editor: WeakEntity, _editor_subscription: Subscription, editor_buffers: HashSet, } +impl std::fmt::Debug for ActiveEditor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActiveEditor") + .field("editor", &self.editor) + .field("editor_buffers", &self.editor_buffers) + .finish_non_exhaustive() + } +} + #[derive(Debug, Default, Clone)] struct LanguageServers { health_statuses: HashMap, @@ -104,192 +108,154 @@ impl LanguageServerHealthStatus { } } -impl LspPickerDelegate { - fn regenerate_items(&mut self, cx: &mut Context>) { - self.state.update(cx, |state, cx| { - let editor_buffers = 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() +impl LanguageServerState { + fn fill_menu(&self, mut menu: ContextMenu, cx: &mut Context) -> ContextMenu { + let lsp_logs = cx + .try_global::() + .and_then(|lsp_logs| lsp_logs.0.upgrade()); + let lsp_store = self.lsp_store.upgrade(); + let Some((lsp_logs, lsp_store)) = lsp_logs.zip(lsp_store) else { + return menu; + }; + + for (i, item) in self.items.iter().enumerate() { + if let LspItem::ToggleServersButton { restart } = item { + let label = if *restart { + "Restart All Servers" + } else { + "Stop All Servers" + }; + let restart = *restart; + let button = ContextMenuEntry::new(label).handler({ + let state = cx.entity(); + move |_, cx| { + let lsp_store = state.read(cx).lsp_store.clone(); + lsp_store + .update(cx, |lsp_store, cx| { + if restart { + let Some(workspace) = state.read(cx).workspace.upgrade() else { + return; + }; + 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) - .get(*buffer_id)? + .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| { + buffer_store.read(cx).get_by_path(&project_path) + }) + .collect(); + let selectors = state .read(cx) - .file(), - )? - .abs_path(cx), - ) - }) - .ok()??; - Some(buffer_path) - }) - .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()); - } + .items + .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()), + ), + }) + .collect(); + lsp_store.restart_language_servers_for_buffers( + buffers, selectors, cx, + ); + } else { + lsp_store.stop_all_language_servers(cx); + } + }) + .ok(); } - acc }); - 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, - )); - } - } - - let mut can_stop_all = false; - let mut can_restart_all = true; + menu = menu.separator().item(button); + 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 + let has_logs = lsp_store.read(cx).as_local().is_some() + && lsp_logs.read(cx).has_server_logs(&server_selector); + let status_color = server_info + .binary_status + .and_then(|binary_status| match binary_status.status { + BinaryStatus::None => None, + BinaryStatus::CheckingForUpdate + | BinaryStatus::Downloading + | BinaryStatus::Starting => Some(Color::Modified), + BinaryStatus::Stopping => Some(Color::Disabled), + BinaryStatus::Stopped => Some(Color::Disabled), + BinaryStatus::Failed { .. } => Some(Color::Error), + }) + .or_else(|| { + Some(match server_info.health? { + ServerHealth::Ok => Color::Success, + ServerHealth::Warning => Color::Warning, + ServerHealth::Error => Color::Error, + }) + }) + .unwrap_or(Color::Success); - for (server_name, status) in state - .language_servers - .binary_statuses - .iter() - .filter(|(name, _)| !servers_with_health_checks.contains(name)) + if self + .other_servers_start_index + .is_some_and(|index| index == i) { - match status.status { - BinaryStatus::None => { - can_restart_all = false; - can_stop_all = true; - } - BinaryStatus::CheckingForUpdate => { - can_restart_all = false; - } - BinaryStatus::Downloading => { - can_restart_all = false; - } - BinaryStatus::Starting => { - can_restart_all = false; - } - BinaryStatus::Stopping => { - can_restart_all = false; - } - BinaryStatus::Stopped => {} - 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 - } - }); - if let Some(server_id) = matching_server_id { - buffer_servers.push(ServerData::WithBinaryStatus( - Some(server_id), - server_name, - status, - )); - } else { - other_servers.push(ServerData::WithBinaryStatus(None, server_name, status)); - } + menu = menu.separator(); } - - 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()); - } - 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: false }); - } else if can_restart_all { - new_lsp_items.push(LspItem::ToggleServersButton { restart: true }); - } - } - - self.items = new_lsp_items; - self.other_servers_start_index = other_servers_start_index; - }); - } - - fn server_info(&self, ix: usize) -> Option { - match self.items.get(ix)? { - 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(), - }), - LspItem::WithBinaryStatus( - server_id, - language_server_name, - language_server_binary_status, - ) => Some(ServerInfo { - name: language_server_name.clone(), - id: *server_id, - health: None, - binary_status: Some(language_server_binary_status.clone()), - message: language_server_binary_status.message.clone(), - }), + menu = menu.item(ContextMenuItem::custom_entry( + move |_, _| { + h_flex() + .gap_1() + .w_full() + .child(Indicator::dot().color(status_color)) + .child(Label::new(server_info.name.0.clone())) + .when(!has_logs, |div| div.cursor_default()) + .into_any_element() + }, + { + let lsp_logs = lsp_logs.clone(); + move |window, cx| { + if !has_logs { + 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| { + DocumentationAside::new( + DocumentationSide::Right, + Rc::new(move |_| Label::new(server_message.clone()).into_any_element()), + ) + }), + )); } + menu } } @@ -375,6 +341,36 @@ enum LspItem { }, } +impl LspItem { + 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(), + }), + LspItem::WithBinaryStatus( + server_id, + language_server_name, + language_server_binary_status, + ) => Some(ServerInfo { + name: language_server_name.clone(), + id: *server_id, + health: None, + binary_status: Some(language_server_binary_status.clone()), + message: language_server_binary_status.message.clone(), + }), + } + } +} + impl ServerData<'_> { fn name(&self) -> &LanguageServerName { match self { @@ -395,267 +391,21 @@ impl ServerData<'_> { } } -impl PickerDelegate for LspPickerDelegate { - type ListItem = AnyElement; - - fn match_count(&self) -> usize { - self.items.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { - self.selected_index = ix; - cx.notify(); - } - - fn update_matches( - &mut self, - _: String, - _: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - cx.spawn(async move |lsp_picker, cx| { - cx.background_executor() - .timer(Duration::from_millis(30)) - .await; - lsp_picker - .update(cx, |lsp_picker, cx| { - lsp_picker.delegate.regenerate_items(cx); - }) - .ok(); - }) - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - Arc::default() - } - - fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { - if let Some(LspItem::ToggleServersButton { restart }) = self.items.get(self.selected_index) - { - let lsp_store = self.state.read(cx).lsp_store.clone(); - lsp_store - .update(cx, |lsp_store, cx| { - if *restart { - let Some(workspace) = self.state.read(cx).workspace.upgrade() else { - return; - }; - 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 = self - .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| { - buffer_store.read(cx).get_by_path(&project_path) - }) - .collect(); - let selectors = self - .items - .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())) - } - }) - .collect(); - lsp_store.restart_language_servers_for_buffers(buffers, selectors, cx); - } else { - lsp_store.stop_all_language_servers(cx); - } - }) - .ok(); - } - - let Some(server_selector) = self - .server_info(self.selected_index) - .map(|info| info.server_selector()) - else { - return; - }; - let lsp_logs = cx.global::().0.clone(); - let lsp_store = self.state.read(cx).lsp_store.clone(); - let workspace = self.state.read(cx).workspace.clone(); - lsp_logs - .update(cx, |lsp_logs, cx| { - let has_logs = lsp_store - .update(cx, |lsp_store, _| { - lsp_store.as_local().is_some() && lsp_logs.has_server_logs(&server_selector) - }) - .unwrap_or(false); - if has_logs { - lsp_logs.open_server_trace(workspace, server_selector, window, cx); - } - }) - .ok(); - } - - fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - cx.emit(DismissEvent); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - let rendered_match = h_flex().px_1().gap_1(); - let rendered_match_contents = h_flex() - .id(("lsp-item", ix)) - .w_full() - .px_2() - .gap_2() - .when(selected, |server_entry| { - server_entry.bg(cx.theme().colors().element_hover) - }) - .hover(|s| s.bg(cx.theme().colors().element_hover)); - - if let Some(LspItem::ToggleServersButton { restart }) = self.items.get(ix) { - let label = Label::new(if *restart { - "Restart All Servers" - } else { - "Stop All Servers" - }); - return Some( - rendered_match - .child(rendered_match_contents.child(label)) - .into_any_element(), - ); - } - - let server_info = self.server_info(ix)?; - let workspace = self.state.read(cx).workspace.clone(); - let lsp_logs = cx.global::().0.upgrade()?; - let lsp_store = self.state.read(cx).lsp_store.upgrade()?; - 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 - let has_logs = lsp_store.read(cx).as_local().is_some() - && lsp_logs.read(cx).has_server_logs(&server_selector); - - let status_color = server_info - .binary_status - .and_then(|binary_status| match binary_status.status { - BinaryStatus::None => None, - BinaryStatus::CheckingForUpdate - | BinaryStatus::Downloading - | BinaryStatus::Starting => Some(Color::Modified), - BinaryStatus::Stopping => Some(Color::Disabled), - BinaryStatus::Stopped => Some(Color::Disabled), - BinaryStatus::Failed { .. } => Some(Color::Error), - }) - .or_else(|| { - Some(match server_info.health? { - ServerHealth::Ok => Color::Success, - ServerHealth::Warning => Color::Warning, - ServerHealth::Error => Color::Error, - }) - }) - .unwrap_or(Color::Success); - - Some( - rendered_match - .child( - rendered_match_contents - .child(Indicator::dot().color(status_color)) - .child(Label::new(server_info.name.0.clone())) - .when_some( - server_info.message.clone(), - |server_entry, server_message| { - server_entry.tooltip(Tooltip::text(server_message.clone())) - }, - ), - ) - .when_else( - has_logs, - |server_entry| { - server_entry.on_mouse_down(MouseButton::Left, { - let workspace = workspace.clone(); - let lsp_logs = lsp_logs.downgrade(); - let server_selector = server_selector.clone(); - move |_, window, cx| { - lsp_logs - .update(cx, |lsp_logs, cx| { - lsp_logs.open_server_trace( - workspace.clone(), - server_selector.clone(), - window, - cx, - ); - }) - .ok(); - } - }) - }, - |div| div.cursor_default(), - ) - .into_any_element(), - ) - } - - fn render_editor( - &self, - editor: &Entity, - _: &mut Window, - cx: &mut Context>, - ) -> Div { - div().child(div().track_focus(&editor.focus_handle(cx))) - } - - fn separators_after_indices(&self) -> Vec { - if self.items.is_empty() { - return Vec::new(); - } - let mut indices = vec![self.items.len().saturating_sub(2)]; - if let Some(other_servers_start_index) = self.other_servers_start_index { - if other_servers_start_index > 0 { - indices.insert(0, other_servers_start_index - 1); - indices.dedup(); - } - } - indices - } -} - impl LspTool { pub fn new( workspace: &Workspace, - popover_menu_handle: PopoverMenuHandle>, + popover_menu_handle: PopoverMenuHandle, window: &mut Window, cx: &mut Context, ) -> Self { let settings_subscription = cx.observe_global_in::(window, move |lsp_tool, window, cx| { if ProjectSettings::get_global(cx).global_lsp_settings.button { - if lsp_tool.lsp_picker.is_none() { - lsp_tool.lsp_picker = - Some(Self::new_lsp_picker(lsp_tool.state.clone(), window, cx)); - cx.notify(); + if lsp_tool.lsp_menu.is_none() { + lsp_tool.refresh_lsp_menu(true, window, cx); return; } - } else if lsp_tool.lsp_picker.take().is_some() { + } else if lsp_tool.lsp_menu.take().is_some() { cx.notify(); } }); @@ -666,17 +416,20 @@ impl LspTool { lsp_tool.on_lsp_store_event(e, window, cx) }); - let state = cx.new(|_| PickerState { + 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(), }); Self { - state, + server_state: state, popover_menu_handle, - lsp_picker: None, + lsp_menu: None, + lsp_menu_refresh: Task::ready(()), _subscriptions: vec![settings_subscription, lsp_store_subscription], } } @@ -687,7 +440,7 @@ impl LspTool { window: &mut Window, cx: &mut Context, ) { - let Some(lsp_picker) = self.lsp_picker.clone() else { + if self.lsp_menu.is_none() { return; }; let mut updated = false; @@ -720,7 +473,7 @@ impl LspTool { BinaryStatus::Failed { error } } }; - self.state.update(cx, |state, _| { + self.server_state.update(cx, |state, _| { state.language_servers.update_binary_status( binary_status, status_update.message.as_deref(), @@ -737,7 +490,7 @@ impl LspTool { proto::ServerHealth::Warning => ServerHealth::Warning, proto::ServerHealth::Error => ServerHealth::Error, }; - self.state.update(cx, |state, _| { + self.server_state.update(cx, |state, _| { state.language_servers.update_server_health( *language_server_id, health, @@ -756,7 +509,7 @@ impl LspTool { message: proto::update_language_server::Variant::RegisteredForBuffer(update), .. } => { - self.state.update(cx, |state, _| { + self.server_state.update(cx, |state, _| { state .language_servers .servers_per_buffer_abs_path @@ -770,27 +523,203 @@ impl LspTool { }; if updated { - lsp_picker.update(cx, |lsp_picker, cx| { - lsp_picker.refresh(window, cx); - }); + self.refresh_lsp_menu(false, window, cx); } } - fn new_lsp_picker( - state: Entity, + fn regenerate_items(&mut self, cx: &mut App) { + self.server_state.update(cx, |state, cx| { + let editor_buffers = 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), + ) + }) + .ok()??; + Some(buffer_path) + }) + .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()); + } + } + acc + }); + 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, + )); + } + } + + 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 + .language_servers + .binary_statuses + .iter() + .filter(|(name, _)| !servers_with_health_checks.contains(name)) + { + match status.status { + BinaryStatus::None => { + can_restart_all = false; + can_stop_all |= true; + } + BinaryStatus::CheckingForUpdate => { + can_restart_all = false; + can_stop_all = false; + } + BinaryStatus::Downloading => { + can_restart_all = false; + can_stop_all = false; + } + BinaryStatus::Starting => { + can_restart_all = false; + can_stop_all = false; + } + BinaryStatus::Stopping => { + can_restart_all = false; + can_stop_all = false; + } + BinaryStatus::Stopped => {} + 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 + } + }); + if let Some(server_id) = matching_server_id { + buffer_servers.push(ServerData::WithBinaryStatus( + Some(server_id), + server_name, + status, + )); + } else { + other_servers.push(ServerData::WithBinaryStatus(None, server_name, status)); + } + } + + 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()); + } + 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: false }); + } else if can_restart_all { + new_lsp_items.push(LspItem::ToggleServersButton { restart: true }); + } + } + + state.items = new_lsp_items; + state.other_servers_start_index = other_servers_start_index; + }); + } + + fn refresh_lsp_menu( + &mut self, + create_if_empty: bool, window: &mut Window, cx: &mut Context, - ) -> Entity> { - cx.new(|cx| { - let mut delegate = LspPickerDelegate { - selected_index: 0, - other_servers_start_index: None, - items: Vec::new(), - state, - }; - delegate.regenerate_items(cx); - Picker::list(delegate, window, cx) - }) + ) { + if create_if_empty || self.lsp_menu.is_some() { + let state = self.server_state.clone(); + self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_tool, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; + lsp_tool + .update_in(cx, |lsp_tool, window, cx| { + lsp_tool.regenerate_items(cx); + let menu = ContextMenu::build(window, cx, |menu, _, cx| { + state.update(cx, |state, cx| state.fill_menu(menu, cx)) + }); + lsp_tool.lsp_menu = Some(menu.clone()); + // TODO kb will this work? + // what about the selections? + lsp_tool.popover_menu_handle.refresh_menu( + window, + cx, + Rc::new(move |_, _| Some(menu.clone())), + ); + cx.notify(); + }) + .ok(); + }); + } } } @@ -805,7 +734,7 @@ impl StatusItemView for LspTool { if let Some(editor) = active_pane_item.and_then(|item| item.downcast::()) { if Some(&editor) != self - .state + .server_state .read(cx) .active_editor .as_ref() @@ -819,25 +748,24 @@ impl StatusItemView for LspTool { window, |lsp_tool, _, e: &EditorEvent, window, cx| match e { EditorEvent::ExcerptsAdded { buffer, .. } => { - lsp_tool.state.update(cx, |state, cx| { + let updated = lsp_tool.server_state.update(cx, |state, cx| { if let Some(active_editor) = state.active_editor.as_mut() { let buffer_id = buffer.read(cx).remote_id(); - if active_editor.editor_buffers.insert(buffer_id) { - if let Some(picker) = &lsp_tool.lsp_picker { - picker.update(cx, |picker, cx| { - picker.refresh(window, cx) - }); - } - } + active_editor.editor_buffers.insert(buffer_id) + } else { + false } }); + if updated { + lsp_tool.refresh_lsp_menu(false, window, cx); + } } EditorEvent::ExcerptsRemoved { removed_buffer_ids, .. } => { - lsp_tool.state.update(cx, |state, cx| { + let removed = lsp_tool.server_state.update(cx, |state, _| { + let mut removed = false; if let Some(active_editor) = state.active_editor.as_mut() { - let mut removed = false; for id in removed_buffer_ids { active_editor.editor_buffers.retain(|buffer_id| { let retain = buffer_id != id; @@ -845,68 +773,53 @@ impl StatusItemView for LspTool { retain }); } - if removed { - if let Some(picker) = &lsp_tool.lsp_picker { - picker.update(cx, |picker, cx| { - picker.refresh(window, cx) - }); - } - } } + removed }); + if removed { + lsp_tool.refresh_lsp_menu(false, window, cx); + } } _ => {} }, ); - self.state.update(cx, |state, _| { + self.server_state.update(cx, |state, _| { state.active_editor = Some(ActiveEditor { editor: editor.downgrade(), _editor_subscription, editor_buffers, }); }); - - let lsp_picker = Self::new_lsp_picker(self.state.clone(), window, cx); - self.lsp_picker = Some(lsp_picker.clone()); - lsp_picker.update(cx, |lsp_picker, cx| lsp_picker.refresh(window, cx)); + self.refresh_lsp_menu(true, window, cx); } - } else if self.state.read(cx).active_editor.is_some() { - self.state.update(cx, |state, _| { + } else if self.server_state.read(cx).active_editor.is_some() { + self.server_state.update(cx, |state, _| { state.active_editor = None; }); - if let Some(lsp_picker) = self.lsp_picker.as_ref() { - lsp_picker.update(cx, |lsp_picker, cx| { - lsp_picker.refresh(window, cx); - }); - }; + self.refresh_lsp_menu(false, window, cx); } - } else if self.state.read(cx).active_editor.is_some() { - self.state.update(cx, |state, _| { + } else if self.server_state.read(cx).active_editor.is_some() { + self.server_state.update(cx, |state, _| { state.active_editor = None; }); - if let Some(lsp_picker) = self.lsp_picker.as_ref() { - lsp_picker.update(cx, |lsp_picker, cx| { - lsp_picker.refresh(window, cx); - }); - } + self.refresh_lsp_menu(false, window, cx); } } } impl Render for LspTool { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { - if !cx.is_staff() || self.state.read(cx).language_servers.is_empty() { + 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() + { return div(); } - let Some(lsp_picker) = self.lsp_picker.clone() else { - return div(); - }; - let mut has_errors = false; let mut has_warnings = false; let mut has_other_notifications = false; - let state = self.state.read(cx); + 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 { .. }); @@ -933,19 +846,21 @@ impl Render for LspTool { None }; + let lsp_tool = cx.entity().clone(); div().child( - PickerPopoverMenu::new( - lsp_picker.clone(), - IconButton::new("zed-lsp-tool-button", IconName::BoltFilledAlt) - .when_some(indicator, IconButton::indicator) - .icon_size(IconSize::Small) - .indicator_border_color(Some(cx.theme().colors().status_bar_background)), - move |window, cx| Tooltip::for_action("Language Servers", &ToggleMenu, window, cx), - Corner::BottomLeft, - cx, - ) - .with_handle(self.popover_menu_handle.clone()) - .render(window, cx), + PopoverMenu::new("lsp-tool") + .menu(move |_, cx| lsp_tool.read(cx).lsp_menu.clone()) + .anchor(Corner::BottomLeft) + .with_handle(self.popover_menu_handle.clone()) + .trigger_with_tooltip( + IconButton::new("zed-lsp-tool-button", IconName::BoltFilledAlt) + .when_some(indicator, IconButton::indicator) + .icon_size(IconSize::Small) + .indicator_border_color(Some(cx.theme().colors().status_bar_background)), + move |window, cx| { + Tooltip::for_action("Language Servers", &ToggleMenu, window, cx) + }, + ), ) } } diff --git a/crates/languages/src/go/outline.scm b/crates/languages/src/go/outline.scm index 0e4d6a52f3b5d48538c30d658f98ec3692b966c6..e37ae7e5723027af3227f947898b301c0305a4f5 100644 --- a/crates/languages/src/go/outline.scm +++ b/crates/languages/src/go/outline.scm @@ -25,7 +25,7 @@ receiver: (parameter_list "(" @context (parameter_declaration - name: (_) @name + name: (_) @context type: (_) @context) ")" @context) name: (field_identifier) @name diff --git a/crates/languages/src/javascript/overrides.scm b/crates/languages/src/javascript/overrides.scm index d93c8b5aea27b1fced6d021c68403348a97bb9e9..6dbbc88ef924c2cac65aaf9ff7e7dba87b99a359 100644 --- a/crates/languages/src/javascript/overrides.scm +++ b/crates/languages/src/javascript/overrides.scm @@ -1,9 +1,8 @@ (comment) @comment.inclusive -[ - (string) - (template_string) -] @string +(string) @string + +(template_string (string_fragment) @string) (jsx_element) @element diff --git a/crates/languages/src/python/config.toml b/crates/languages/src/python/config.toml index 6d83d3f3dec6ba44e87e1d361fb5e61198767874..8728dfeaf138a97a7d9d7e9e2e3ca4b6b6db1820 100644 --- a/crates/languages/src/python/config.toml +++ b/crates/languages/src/python/config.toml @@ -34,5 +34,4 @@ decrease_indent_patterns = [ { pattern = "^\\s*else\\b.*:", valid_after = ["if", "elif", "for", "while", "except"] }, { pattern = "^\\s*except\\b.*:", valid_after = ["try", "except"] }, { pattern = "^\\s*finally\\b.*:", valid_after = ["try", "except", "else"] }, - { pattern = "^\\s*case\\b.*:", valid_after = ["match", "case"] } ] diff --git a/crates/languages/src/python/indents.scm b/crates/languages/src/python/indents.scm index 617aa706d3177c368f334c409989a27d09655b1e..3d4c1cc9c4260d4e925cc373662ae5ca3b82e124 100644 --- a/crates/languages/src/python/indents.scm +++ b/crates/languages/src/python/indents.scm @@ -14,4 +14,4 @@ (else_clause) @start.else (except_clause) @start.except (finally_clause) @start.finally -(case_pattern) @start.case +(case_clause) @start.case diff --git a/crates/languages/src/tsx/overrides.scm b/crates/languages/src/tsx/overrides.scm index b26d010ce34b58cac34e516075c8c010525ed5fe..f5a51af33fee340762d6b689e78d2e94e9c84901 100644 --- a/crates/languages/src/tsx/overrides.scm +++ b/crates/languages/src/tsx/overrides.scm @@ -1,9 +1,8 @@ (comment) @comment.inclusive -[ - (string) - (template_string) -] @string +(string) @string + +(template_string (string_fragment) @string) (jsx_element) @element diff --git a/crates/languages/src/typescript/overrides.scm b/crates/languages/src/typescript/overrides.scm index 17ad7be339ccb2e670ebcf225b1ab9d2b9af40ae..8f437a1424af06aa4855aac67511926181977936 100644 --- a/crates/languages/src/typescript/overrides.scm +++ b/crates/languages/src/typescript/overrides.scm @@ -1,6 +1,9 @@ (comment) @comment.inclusive + (string) @string +(template_string (string_fragment) @string) + (_ value: (call_expression function: (identifier) @function_name_before_type_arguments type_arguments: (type_arguments))) diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index d2541e1b31fe6a798edc92bb070a17b631f61f98..fd31e638d4bf7774af83d430dca232d1ade74f01 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -171,6 +171,15 @@ impl ContextServerStore { ) } + /// Returns all configured context server ids, regardless of enabled state. + pub fn configured_server_ids(&self) -> Vec { + self.context_server_settings + .keys() + .cloned() + .map(ContextServerId) + .collect() + } + #[cfg(any(test, feature = "test-support"))] pub fn test( registry: Entity, diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index be4964bbee2688c0025900c552eec3fbbc9af492..29555d0179a41448131aecad8ebea610f2321c1d 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -14,15 +14,13 @@ use anyhow::{Context as _, Result, anyhow}; use async_trait::async_trait; use collections::HashMap; use dap::{ - Capabilities, CompletionItem, CompletionsArguments, DapRegistry, DebugRequest, - EvaluateArguments, EvaluateArgumentsContext, EvaluateResponse, Source, StackFrameId, + Capabilities, DapRegistry, DebugRequest, EvaluateArgumentsContext, StackFrameId, adapters::{ DapDelegate, DebugAdapterBinary, DebugAdapterName, DebugTaskDefinition, TcpArguments, }, client::SessionId, inline_value::VariableLookupKind, messages::Message, - requests::{Completions, Evaluate}, }; use fs::Fs; use futures::{ @@ -40,6 +38,7 @@ use rpc::{ AnyProtoClient, TypedEnvelope, proto::{self}, }; +use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsLocation, WorktreeId}; use std::{ borrow::Borrow, @@ -93,10 +92,23 @@ pub struct DapStore { worktree_store: Entity, sessions: BTreeMap>, next_session_id: u32, + adapter_options: BTreeMap>, } impl EventEmitter for DapStore {} +#[derive(Clone, Serialize, Deserialize)] +pub struct PersistedExceptionBreakpoint { + pub enabled: bool, +} + +/// Represents best-effort serialization of adapter state during last session (e.g. watches) +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct PersistedAdapterOptions { + /// Which exception breakpoints were enabled during the last session with this adapter? + pub exception_breakpoints: BTreeMap, +} + impl DapStore { pub fn init(client: &AnyProtoClient, cx: &mut App) { static ADD_LOCATORS: Once = Once::new(); @@ -173,6 +185,7 @@ impl DapStore { breakpoint_store, worktree_store, sessions: Default::default(), + adapter_options: Default::default(), } } @@ -520,65 +533,6 @@ impl DapStore { )) } - pub fn evaluate( - &self, - session_id: &SessionId, - stack_frame_id: u64, - expression: String, - context: EvaluateArgumentsContext, - source: Option, - cx: &mut Context, - ) -> Task> { - let Some(client) = self - .session_by_id(session_id) - .and_then(|client| client.read(cx).adapter_client()) - else { - return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); - }; - - cx.background_executor().spawn(async move { - client - .request::(EvaluateArguments { - expression: expression.clone(), - frame_id: Some(stack_frame_id), - context: Some(context), - format: None, - line: None, - column: None, - source, - }) - .await - }) - } - - pub fn completions( - &self, - session_id: &SessionId, - stack_frame_id: u64, - text: String, - completion_column: u64, - cx: &mut Context, - ) -> Task>> { - let Some(client) = self - .session_by_id(session_id) - .and_then(|client| client.read(cx).adapter_client()) - else { - return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); - }; - - cx.background_executor().spawn(async move { - Ok(client - .request::(CompletionsArguments { - frame_id: Some(stack_frame_id), - line: None, - text, - column: completion_column, - }) - .await? - .targets) - }) - } - pub fn resolve_inline_value_locations( &self, session: Entity, @@ -853,6 +807,45 @@ impl DapStore { }) }) } + + pub fn sync_adapter_options( + &mut self, + session: &Entity, + cx: &App, + ) -> Arc { + let session = session.read(cx); + let adapter = session.adapter(); + let exceptions = session.exception_breakpoints(); + let exception_breakpoints = exceptions + .map(|(exception, enabled)| { + ( + exception.filter.clone(), + PersistedExceptionBreakpoint { enabled: *enabled }, + ) + }) + .collect(); + let options = Arc::new(PersistedAdapterOptions { + exception_breakpoints, + }); + self.adapter_options.insert(adapter, options.clone()); + options + } + + pub fn set_adapter_options( + &mut self, + adapter: DebugAdapterName, + options: PersistedAdapterOptions, + ) { + self.adapter_options.insert(adapter, Arc::new(options)); + } + + pub fn adapter_options(&self, name: &str) -> Option> { + self.adapter_options.get(name).cloned() + } + + pub fn all_adapter_options(&self) -> &BTreeMap> { + &self.adapter_options + } } #[derive(Clone)] diff --git a/crates/project/src/debugger/locators/cargo.rs b/crates/project/src/debugger/locators/cargo.rs index bad7dfe9f8947810346c06493d47d5f0b4c89c22..7d70371380192c99e1ace9676b02088f86ed9e5f 100644 --- a/crates/project/src/debugger/locators/cargo.rs +++ b/crates/project/src/debugger/locators/cargo.rs @@ -119,7 +119,7 @@ impl DapLocator for CargoLocator { .context("Couldn't get cwd from debug config which is needed for locators")?; let builder = ShellBuilder::new(true, &build_config.shell).non_interactive(); let (program, args) = builder.build( - "cargo".into(), + Some("cargo".into()), &build_config .args .iter() diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index bd52c0f6fa6f7c77baaa7fa052cd7499b44f0858..9ab83610f02cdeb0661062d04dc1b3d6fa3013be 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -409,17 +409,6 @@ impl RunningMode { }; let configuration_done_supported = ConfigurationDone::is_supported(capabilities); - let exception_filters = capabilities - .exception_breakpoint_filters - .as_ref() - .map(|exception_filters| { - exception_filters - .iter() - .filter(|filter| filter.default == Some(true)) - .cloned() - .collect::>() - }) - .unwrap_or_default(); // From spec (on initialization sequence): // client sends a setExceptionBreakpoints request if one or more exceptionBreakpointFilters have been defined (or if supportsConfigurationDoneRequest is not true) // @@ -434,10 +423,20 @@ impl RunningMode { .unwrap_or_default(); let this = self.clone(); let worktree = self.worktree().clone(); + let mut filters = capabilities + .exception_breakpoint_filters + .clone() + .unwrap_or_default(); let configuration_sequence = cx.spawn({ - async move |_, cx| { - let breakpoint_store = - dap_store.read_with(cx, |dap_store, _| dap_store.breakpoint_store().clone())?; + async move |session, cx| { + let adapter_name = session.read_with(cx, |this, _| this.adapter())?; + let (breakpoint_store, adapter_defaults) = + dap_store.read_with(cx, |dap_store, _| { + ( + dap_store.breakpoint_store().clone(), + dap_store.adapter_options(&adapter_name), + ) + })?; initialized_rx.await?; let errors_by_path = cx .update(|cx| this.send_source_breakpoints(false, &breakpoint_store, cx))? @@ -471,7 +470,25 @@ impl RunningMode { })?; if should_send_exception_breakpoints { - this.send_exception_breakpoints(exception_filters, supports_exception_filters) + _ = session.update(cx, |this, _| { + filters.retain(|filter| { + let is_enabled = if let Some(defaults) = adapter_defaults.as_ref() { + defaults + .exception_breakpoints + .get(&filter.filter) + .map(|options| options.enabled) + .unwrap_or_else(|| filter.default.unwrap_or_default()) + } else { + filter.default.unwrap_or_default() + }; + this.exception_breakpoints + .entry(filter.filter.clone()) + .or_insert_with(|| (filter.clone(), is_enabled)); + is_enabled + }); + }); + + this.send_exception_breakpoints(filters, supports_exception_filters) .await .ok(); } @@ -1233,18 +1250,7 @@ impl Session { Ok(capabilities) => { this.update(cx, |session, cx| { session.capabilities = capabilities; - let filters = session - .capabilities - .exception_breakpoint_filters - .clone() - .unwrap_or_default(); - for filter in filters { - let default = filter.default.unwrap_or_default(); - session - .exception_breakpoints - .entry(filter.filter.clone()) - .or_insert_with(|| (filter, default)); - } + cx.emit(SessionEvent::CapabilitiesLoaded); })?; return Ok(()); diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index b5eb62c4eb75e2165deaab9044d7b96f4c5e1f57..779cf95add9ad5547e13d85d87c0dcc3935ab326 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -568,7 +568,7 @@ async fn test_fallback_to_single_worktree_tasks(cx: &mut gpui::TestAppContext) { .into_iter() .map(|(source_kind, task)| { let resolved = task.resolved; - (source_kind, resolved.command) + (source_kind, resolved.command.unwrap()) }) .collect::>(), vec![( diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index b4e1093293b6275b9da68075425dd3b75b5bb335..b067396881d3b1bc0c20d8b0f21cb5ea80b675f9 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -149,7 +149,7 @@ impl Project { let settings = self.terminal_settings(&path, cx).clone(); let builder = ShellBuilder::new(ssh_details.is_none(), &settings.shell).non_interactive(); - let (command, args) = builder.build(command, &Vec::new()); + let (command, args) = builder.build(Some(command), &Vec::new()); let mut env = self .environment @@ -297,7 +297,10 @@ impl Project { .or_insert_with(|| "xterm-256color".to_string()); let (program, args) = wrap_for_ssh( &ssh_command, - Some((&spawn_task.command, &spawn_task.args)), + spawn_task + .command + .as_ref() + .map(|command| (command, &spawn_task.args)), path.as_deref(), env, python_venv_directory.as_deref(), @@ -317,14 +320,16 @@ impl Project { add_environment_path(&mut env, &venv_path.join("bin")).log_err(); } - ( - task_state, + let shell = if let Some(program) = spawn_task.command { Shell::WithArguments { - program: spawn_task.command, + program, args: spawn_task.args, title_override: None, - }, - ) + } + } else { + Shell::System + }; + (task_state, shell) } } } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ded6e0e3f48f0650f97f265a1b5aed1b9b1b443a..ca791869d9db9a70090583f21b06b8099e9d74f1 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -56,7 +56,7 @@ use theme::ThemeSettings; use ui::{ Color, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors, IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing, Scrollbar, - ScrollbarState, Tooltip, prelude::*, v_flex, + ScrollbarState, StickyCandidate, Tooltip, prelude::*, v_flex, }; use util::{ResultExt, TakeUntilExt, TryFutureExt, maybe, paths::compare_paths}; use workspace::{ @@ -173,6 +173,7 @@ struct EntryDetails { is_editing: bool, is_processing: bool, is_cut: bool, + sticky: Option, filename_text_color: Color, diagnostic_severity: Option, git_status: GitSummary, @@ -181,6 +182,11 @@ struct EntryDetails { canonical_path: Option>, } +#[derive(Debug, PartialEq, Eq, Clone)] +struct StickyDetails { + sticky_index: usize, +} + /// Permanently deletes the selected file or directory. #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] #[action(namespace = project_panel)] @@ -3366,22 +3372,13 @@ impl ProjectPanel { } let end_ix = range.end.min(ix + visible_worktree_entries.len()); - let (git_status_setting, show_file_icons, show_folder_icons) = { + let git_status_setting = { let settings = ProjectPanelSettings::get_global(cx); - ( - settings.git_status, - settings.file_icons, - settings.folder_icons, - ) + settings.git_status }; if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) { let snapshot = worktree.read(cx).snapshot(); let root_name = OsStr::new(snapshot.root_name()); - let expanded_entry_ids = self - .expanded_dir_ids - .get(&snapshot.id()) - .map(Vec::as_slice) - .unwrap_or(&[]); let entry_range = range.start.saturating_sub(ix)..end_ix - ix; let entries = entries_paths.get_or_init(|| { @@ -3394,80 +3391,17 @@ impl ProjectPanel { let status = git_status_setting .then_some(entry.git_summary) .unwrap_or_default(); - let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok(); - let icon = match entry.kind { - EntryKind::File => { - if show_file_icons { - FileIcons::get_icon(&entry.path, cx) - } else { - None - } - } - _ => { - if show_folder_icons { - FileIcons::get_folder_icon(is_expanded, cx) - } else { - FileIcons::get_chevron_icon(is_expanded, cx) - } - } - }; - - let (depth, difference) = - ProjectPanel::calculate_depth_and_difference(&entry, entries); - - let filename = match difference { - diff if diff > 1 => entry - .path - .iter() - .skip(entry.path.components().count() - diff) - .collect::() - .to_str() - .unwrap_or_default() - .to_string(), - _ => entry - .path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| root_name.to_string_lossy().to_string()), - }; - let selection = SelectedEntry { - worktree_id: snapshot.id(), - entry_id: entry.id, - }; - let is_marked = self.marked_entries.contains(&selection); - - let diagnostic_severity = self - .diagnostics - .get(&(*worktree_id, entry.path.to_path_buf())) - .cloned(); - - let filename_text_color = - entry_git_aware_label_color(status, entry.is_ignored, is_marked); - - let mut details = EntryDetails { - filename, - icon, - path: entry.path.clone(), - depth, - kind: entry.kind, - is_ignored: entry.is_ignored, - is_expanded, - is_selected: self.selection == Some(selection), - is_marked, - is_editing: false, - is_processing: false, - is_cut: self - .clipboard - .as_ref() - .map_or(false, |e| e.is_cut() && e.items().contains(&selection)), - filename_text_color, - diagnostic_severity, - git_status: status, - is_private: entry.is_private, - worktree_id: *worktree_id, - canonical_path: entry.canonical_path.clone(), - }; + let mut details = self.details_for_entry( + entry, + *worktree_id, + root_name, + entries, + status, + None, + window, + cx, + ); if let Some(edit_state) = &self.edit_state { let is_edited_entry = if edit_state.is_new_entry() { @@ -3879,6 +3813,8 @@ impl ProjectPanel { const GROUP_NAME: &str = "project_entry"; let kind = details.kind; + let is_sticky = details.sticky.is_some(); + let sticky_index = details.sticky.as_ref().map(|this| this.sticky_index); let settings = ProjectPanelSettings::get_global(cx); let show_editor = details.is_editing && !details.is_processing; @@ -4002,141 +3938,144 @@ impl ProjectPanel { .border_r_2() .border_color(border_color) .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color)) - .on_drag_move::(cx.listener( - move |this, event: &DragMoveEvent, _, cx| { - let is_current_target = this.drag_target_entry.as_ref() - .map(|entry| entry.entry_id) == Some(entry_id); - - if !event.bounds.contains(&event.event.position) { - // Entry responsible for setting drag target is also responsible to - // clear it up after drag is out of bounds + .when(!is_sticky, |this| { + this + .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over)) + .on_drag_move::(cx.listener( + move |this, event: &DragMoveEvent, _, cx| { + let is_current_target = this.drag_target_entry.as_ref() + .map(|entry| entry.entry_id) == Some(entry_id); + + if !event.bounds.contains(&event.event.position) { + // Entry responsible for setting drag target is also responsible to + // clear it up after drag is out of bounds + if is_current_target { + this.drag_target_entry = None; + } + return; + } + if is_current_target { - this.drag_target_entry = None; + return; } - return; - } - if is_current_target { - return; - } + let Some((entry_id, highlight_entry_id)) = maybe!({ + let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); + let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?; + let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree); + Some((target_entry.id, highlight_entry_id)) + }) else { + return; + }; - let Some((entry_id, highlight_entry_id)) = maybe!({ - let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); - let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?; - let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree); - Some((target_entry.id, highlight_entry_id)) - }) else { - return; - }; + this.drag_target_entry = Some(DragTargetEntry { + entry_id, + highlight_entry_id, + }); + this.marked_entries.clear(); + }, + )) + .on_drop(cx.listener( + move |this, external_paths: &ExternalPaths, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + this.drop_external_files(external_paths.paths(), entry_id, window, cx); + cx.stop_propagation(); + }, + )) + .on_drag_move::(cx.listener( + move |this, event: &DragMoveEvent, window, cx| { + let is_current_target = this.drag_target_entry.as_ref() + .map(|entry| entry.entry_id) == Some(entry_id); + + if !event.bounds.contains(&event.event.position) { + // Entry responsible for setting drag target is also responsible to + // clear it up after drag is out of bounds + if is_current_target { + this.drag_target_entry = None; + } + return; + } - this.drag_target_entry = Some(DragTargetEntry { - entry_id, - highlight_entry_id, - }); - this.marked_entries.clear(); - }, - )) - .on_drop(cx.listener( - move |this, external_paths: &ExternalPaths, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); - this.drop_external_files(external_paths.paths(), entry_id, window, cx); - cx.stop_propagation(); - }, - )) - .on_drag_move::(cx.listener( - move |this, event: &DragMoveEvent, window, cx| { - let is_current_target = this.drag_target_entry.as_ref() - .map(|entry| entry.entry_id) == Some(entry_id); - - if !event.bounds.contains(&event.event.position) { - // Entry responsible for setting drag target is also responsible to - // clear it up after drag is out of bounds if is_current_target { - this.drag_target_entry = None; + return; } - return; - } - if is_current_target { - return; - } - - let drag_state = event.drag(cx); - let Some((entry_id, highlight_entry_id)) = maybe!({ - let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); - let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?; - let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx); - Some((target_entry.id, highlight_entry_id)) - }) else { - return; - }; + let drag_state = event.drag(cx); + let Some((entry_id, highlight_entry_id)) = maybe!({ + let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); + let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?; + let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx); + Some((target_entry.id, highlight_entry_id)) + }) else { + return; + }; - this.drag_target_entry = Some(DragTargetEntry { - entry_id, - highlight_entry_id, - }); - if drag_state.items().count() == 1 { - this.marked_entries.clear(); - this.marked_entries.insert(drag_state.active_selection); - } - this.hover_expand_task.take(); + this.drag_target_entry = Some(DragTargetEntry { + entry_id, + highlight_entry_id, + }); + if drag_state.items().count() == 1 { + this.marked_entries.clear(); + this.marked_entries.insert(drag_state.active_selection); + } + this.hover_expand_task.take(); - if !kind.is_dir() - || this - .expanded_dir_ids - .get(&details.worktree_id) - .map_or(false, |ids| ids.binary_search(&entry_id).is_ok()) - { - return; - } + if !kind.is_dir() + || this + .expanded_dir_ids + .get(&details.worktree_id) + .map_or(false, |ids| ids.binary_search(&entry_id).is_ok()) + { + return; + } - let bounds = event.bounds; - this.hover_expand_task = - Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(500)) - .await; - this.update_in(cx, |this, window, cx| { - this.hover_expand_task.take(); - if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id) - && bounds.contains(&window.mouse_position()) - { - this.expand_entry(worktree_id, entry_id, cx); - this.update_visible_entries( - Some((worktree_id, entry_id)), - cx, - ); - cx.notify(); - } - }) - .ok(); - })); - }, - )) - .on_drag( - dragged_selection, - move |selection, click_offset, _window, cx| { - cx.new(|_| DraggedProjectEntryView { - details: details.clone(), - click_offset, - selection: selection.active_selection, - selections: selection.marked_selections.clone(), - }) - }, - ) - .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over)) - .on_drop( - cx.listener(move |this, selections: &DraggedSelection, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); - this.hover_expand_task.take(); - if folded_directory_drag_target.is_some() { - return; - } - this.drag_onto(selections, entry_id, kind.is_file(), window, cx); - }), - ) + let bounds = event.bounds; + this.hover_expand_task = + Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + this.update_in(cx, |this, window, cx| { + this.hover_expand_task.take(); + if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id) + && bounds.contains(&window.mouse_position()) + { + this.expand_entry(worktree_id, entry_id, cx); + this.update_visible_entries( + Some((worktree_id, entry_id)), + cx, + ); + cx.notify(); + } + }) + .ok(); + })); + }, + )) + .on_drag( + dragged_selection, + move |selection, click_offset, _window, cx| { + cx.new(|_| DraggedProjectEntryView { + details: details.clone(), + click_offset, + selection: selection.active_selection, + selections: selection.marked_selections.clone(), + }) + }, + ) + .on_drop( + cx.listener(move |this, selections: &DraggedSelection, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + this.hover_expand_task.take(); + if folded_directory_drag_target.is_some() { + return; + } + this.drag_onto(selections, entry_id, kind.is_file(), window, cx); + }), + ) + }) .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, _, cx| { @@ -4168,7 +4107,7 @@ impl ProjectPanel { current_selection.zip(target_selection) { let range_start = source_index.min(target_index); - let range_end = source_index.max(target_index) + 1; // Make the range inclusive. + let range_end = source_index.max(target_index) + 1; let mut new_selections = BTreeSet::new(); this.for_each_visible_entry( range_start..range_end, @@ -4214,6 +4153,16 @@ impl ProjectPanel { let allow_preview = preview_tabs_enabled && click_count == 1; this.open_entry(entry_id, focus_opened_item, allow_preview, cx); } + + if is_sticky { + if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) { + let strategy = sticky_index + .map(ScrollStrategy::ToPosition) + .unwrap_or(ScrollStrategy::Top); + this.scroll_handle.scroll_to_item(index, strategy); + cx.notify(); + } + } }), ) .child( @@ -4328,51 +4277,99 @@ impl ProjectPanel { let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned(); this = this.child( div() - .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| { - this.hover_scroll_task.take(); - this.drag_target_entry = None; - this.folded_directory_drag_target = None; - if let Some(target_entry_id) = target_entry_id { - this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); - } - })) + .when(!is_sticky, |div| { + div + .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| { + this.hover_scroll_task.take(); + this.drag_target_entry = None; + this.folded_directory_drag_target = None; + if let Some(target_entry_id) = target_entry_id { + this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); + } + })) + .on_drag_move(cx.listener( + move |this, event: &DragMoveEvent, _, _| { + if event.bounds.contains(&event.event.position) { + this.folded_directory_drag_target = Some( + FoldedDirectoryDragTarget { + entry_id, + index: delimiter_target_index, + is_delimiter_target: true, + } + ); + } else { + let is_current_target = this.folded_directory_drag_target + .map_or(false, |target| + target.entry_id == entry_id && + target.index == delimiter_target_index && + target.is_delimiter_target + ); + if is_current_target { + this.folded_directory_drag_target = None; + } + } + + }, + )) + }) + .child( + Label::new(DELIMITER.clone()) + .single_line() + .color(filename_text_color) + ) + ); + } + let id = SharedString::from(format!( + "project_panel_path_component_{}_{index}", + entry_id.to_usize() + )); + let label = div() + .id(id) + .when(!is_sticky,| div| { + div + .when(index != components_len - 1, |div|{ + let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned(); + div .on_drag_move(cx.listener( move |this, event: &DragMoveEvent, _, _| { - if event.bounds.contains(&event.event.position) { + if event.bounds.contains(&event.event.position) { this.folded_directory_drag_target = Some( FoldedDirectoryDragTarget { entry_id, - index: delimiter_target_index, - is_delimiter_target: true, + index, + is_delimiter_target: false, } ); } else { let is_current_target = this.folded_directory_drag_target + .as_ref() .map_or(false, |target| target.entry_id == entry_id && - target.index == delimiter_target_index && - target.is_delimiter_target + target.index == index && + !target.is_delimiter_target ); if is_current_target { this.folded_directory_drag_target = None; } } - }, )) - .child( - Label::new(DELIMITER.clone()) - .single_line() - .color(filename_text_color) - ) - ); - } - let id = SharedString::from(format!( - "project_panel_path_component_{}_{index}", - entry_id.to_usize() - )); - let label = div() - .id(id) + .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| { + this.hover_scroll_task.take(); + this.drag_target_entry = None; + this.folded_directory_drag_target = None; + if let Some(target_entry_id) = target_entry_id { + this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); + } + })) + .when(folded_directory_drag_target.map_or(false, |target| + target.entry_id == entry_id && + target.index == index + ), |this| { + this.bg(item_colors.drag_over) + }) + }) + }) .on_click(cx.listener(move |this, _, _, cx| { if index != active_index { if let Some(folds) = @@ -4384,48 +4381,6 @@ impl ProjectPanel { } } })) - .when(index != components_len - 1, |div|{ - let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned(); - div - .on_drag_move(cx.listener( - move |this, event: &DragMoveEvent, _, _| { - if event.bounds.contains(&event.event.position) { - this.folded_directory_drag_target = Some( - FoldedDirectoryDragTarget { - entry_id, - index, - is_delimiter_target: false, - } - ); - } else { - let is_current_target = this.folded_directory_drag_target - .as_ref() - .map_or(false, |target| - target.entry_id == entry_id && - target.index == index && - !target.is_delimiter_target - ); - if is_current_target { - this.folded_directory_drag_target = None; - } - } - }, - )) - .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| { - this.hover_scroll_task.take(); - this.drag_target_entry = None; - this.folded_directory_drag_target = None; - if let Some(target_entry_id) = target_entry_id { - this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); - } - })) - .when(folded_directory_drag_target.map_or(false, |target| - target.entry_id == entry_id && - target.index == index - ), |this| { - this.bg(item_colors.drag_over) - }) - }) .child( Label::new(component) .single_line() @@ -4497,6 +4452,108 @@ impl ProjectPanel { ) } + fn details_for_entry( + &self, + entry: &Entry, + worktree_id: WorktreeId, + root_name: &OsStr, + entries_paths: &HashSet>, + git_status: GitSummary, + sticky: Option, + _window: &mut Window, + cx: &mut Context, + ) -> EntryDetails { + let (show_file_icons, show_folder_icons) = { + let settings = ProjectPanelSettings::get_global(cx); + (settings.file_icons, settings.folder_icons) + }; + + let expanded_entry_ids = self + .expanded_dir_ids + .get(&worktree_id) + .map(Vec::as_slice) + .unwrap_or(&[]); + let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok(); + + let icon = match entry.kind { + EntryKind::File => { + if show_file_icons { + FileIcons::get_icon(&entry.path, cx) + } else { + None + } + } + _ => { + if show_folder_icons { + FileIcons::get_folder_icon(is_expanded, cx) + } else { + FileIcons::get_chevron_icon(is_expanded, cx) + } + } + }; + + let (depth, difference) = + ProjectPanel::calculate_depth_and_difference(&entry, entries_paths); + + let filename = match difference { + diff if diff > 1 => entry + .path + .iter() + .skip(entry.path.components().count() - diff) + .collect::() + .to_str() + .unwrap_or_default() + .to_string(), + _ => entry + .path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| root_name.to_string_lossy().to_string()), + }; + + let selection = SelectedEntry { + worktree_id, + entry_id: entry.id, + }; + let is_marked = self.marked_entries.contains(&selection); + let is_selected = self.selection == Some(selection); + + let diagnostic_severity = self + .diagnostics + .get(&(worktree_id, entry.path.to_path_buf())) + .cloned(); + + let filename_text_color = + entry_git_aware_label_color(git_status, entry.is_ignored, is_marked); + + let is_cut = self + .clipboard + .as_ref() + .map_or(false, |e| e.is_cut() && e.items().contains(&selection)); + + EntryDetails { + filename, + icon, + path: entry.path.clone(), + depth, + kind: entry.kind, + is_ignored: entry.is_ignored, + is_expanded, + is_selected, + is_marked, + is_editing: false, + is_processing: false, + is_cut, + sticky, + filename_text_color, + diagnostic_severity, + git_status, + is_private: entry.is_private, + worktree_id, + canonical_path: entry.canonical_path.clone(), + } + } + fn render_vertical_scrollbar(&self, cx: &mut Context) -> Option> { if !Self::should_show_scrollbar(cx) || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging()) @@ -4751,6 +4808,156 @@ impl ProjectPanel { } None } + + fn candidate_entries_in_range_for_sticky( + &self, + range: Range, + _window: &mut Window, + _cx: &mut Context, + ) -> Vec { + let mut result = Vec::new(); + let mut current_offset = 0; + + for (_, visible_worktree_entries, entries_paths) in &self.visible_entries { + let worktree_len = visible_worktree_entries.len(); + let worktree_end_offset = current_offset + worktree_len; + + if current_offset >= range.end { + break; + } + + if worktree_end_offset > range.start { + let local_start = range.start.saturating_sub(current_offset); + let local_end = range.end.saturating_sub(current_offset).min(worktree_len); + + let paths = entries_paths.get_or_init(|| { + visible_worktree_entries + .iter() + .map(|e| e.path.clone()) + .collect() + }); + + let entries_from_this_worktree = visible_worktree_entries[local_start..local_end] + .iter() + .enumerate() + .map(|(i, entry)| { + let (depth, _) = Self::calculate_depth_and_difference(&entry.entry, paths); + StickyProjectPanelCandidate { + index: current_offset + local_start + i, + depth, + } + }); + + result.extend(entries_from_this_worktree); + } + + current_offset = worktree_end_offset; + } + + result + } + + fn render_sticky_entries( + &self, + child: StickyProjectPanelCandidate, + window: &mut Window, + cx: &mut Context, + ) -> SmallVec<[AnyElement; 8]> { + let project = self.project.read(cx); + + let Some((worktree_id, entry_ref)) = self.entry_at_index(child.index) else { + return SmallVec::new(); + }; + + let Some((_, visible_worktree_entries, entries_paths)) = self + .visible_entries + .iter() + .find(|(id, _, _)| *id == worktree_id) + else { + return SmallVec::new(); + }; + + let Some(worktree) = project.worktree_for_id(worktree_id, cx) else { + return SmallVec::new(); + }; + let worktree = worktree.read(cx).snapshot(); + + let paths = entries_paths.get_or_init(|| { + visible_worktree_entries + .iter() + .map(|e| e.path.clone()) + .collect() + }); + + let mut sticky_parents = Vec::new(); + let mut current_path = entry_ref.path.clone(); + + 'outer: loop { + if let Some(parent_path) = current_path.parent() { + for ancestor_path in parent_path.ancestors() { + if paths.contains(ancestor_path) { + if let Some(parent_entry) = worktree.entry_for_path(ancestor_path) { + sticky_parents.push(parent_entry.clone()); + current_path = parent_entry.path.clone(); + continue 'outer; + } + } + } + } + break 'outer; + } + + sticky_parents.reverse(); + + let git_status_enabled = ProjectPanelSettings::get_global(cx).git_status; + let root_name = OsStr::new(worktree.root_name()); + + let git_summaries_by_id = if git_status_enabled { + visible_worktree_entries + .iter() + .map(|e| (e.id, e.git_summary)) + .collect::>() + } else { + Default::default() + }; + + sticky_parents + .iter() + .enumerate() + .map(|(index, entry)| { + let git_status = git_summaries_by_id + .get(&entry.id) + .copied() + .unwrap_or_default(); + let sticky_details = Some(StickyDetails { + sticky_index: index, + }); + let details = self.details_for_entry( + entry, + worktree_id, + root_name, + paths, + git_status, + sticky_details, + window, + cx, + ); + self.render_entry(entry.id, details, window, cx).into_any() + }) + .collect() + } +} + +#[derive(Clone)] +struct StickyProjectPanelCandidate { + index: usize, + depth: usize, +} + +impl StickyCandidate for StickyProjectPanelCandidate { + fn depth(&self) -> usize { + self.depth + } } fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize { @@ -4769,6 +4976,7 @@ impl Render for ProjectPanel { let indent_size = ProjectPanelSettings::get_global(cx).indent_size; let show_indent_guides = ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always; + let show_sticky_scroll = ProjectPanelSettings::get_global(cx).sticky_scroll; let is_local = project.is_local(); if has_worktree { @@ -4963,6 +5171,17 @@ impl Render for ProjectPanel { items }) }) + .when(show_sticky_scroll, |list| { + list.with_top_slot(ui::sticky_items( + cx.entity().clone(), + |this, range, window, cx| { + this.candidate_entries_in_range_for_sticky(range, window, cx) + }, + |this, marker_entry, window, cx| { + this.render_sticky_entries(marker_entry, window, cx) + }, + )) + }) .when(show_indent_guides, |list| { list.with_decoration( ui::indent_guides( @@ -5079,7 +5298,7 @@ impl Render for ProjectPanel { .anchor(gpui::Corner::TopLeft) .child(menu.clone()), ) - .with_priority(1) + .with_priority(3) })) } else { v_flex() diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 31f4a21b0973c430bbddff168bafc3c40c69aa3c..9057480972a07b25ad30917a03ccf871b0bb6e3f 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -40,6 +40,7 @@ pub struct ProjectPanelSettings { pub git_status: bool, pub indent_size: f32, pub indent_guides: IndentGuidesSettings, + pub sticky_scroll: bool, pub auto_reveal_entries: bool, pub auto_fold_dirs: bool, pub scrollbar: ScrollbarSettings, @@ -150,6 +151,10 @@ pub struct ProjectPanelSettingsContent { /// /// Default: false pub hide_root: Option, + /// Whether to stick parent directories at top of the project panel. + /// + /// Default: true + pub sticky_scroll: Option, } impl Settings for ProjectPanelSettings { diff --git a/crates/proto/proto/debugger.proto b/crates/proto/proto/debugger.proto index 3979265accaa07040373174a4e7984d181a1da33..09abd4bf1c1aa73e89d77c55ade1bce21f0027d4 100644 --- a/crates/proto/proto/debugger.proto +++ b/crates/proto/proto/debugger.proto @@ -535,7 +535,7 @@ message DebugScenario { message SpawnInTerminal { string label = 1; - string command = 2; + optional string command = 2; repeated string args = 3; map env = 4; optional string cwd = 5; diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 4c4ceee49bcd0a90ac43329e6ecd6211a423ae65..ca54b6a877361af15a634ec7ce3c247ffeaff49f 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -604,7 +604,7 @@ impl KeymapFile { // if trying to replace a keybinding that is not user-defined, treat it as an add operation match operation { KeybindUpdateOperation::Replace { - target_source, + target_keybind_source: target_source, source, .. } if target_source != KeybindSource::User => { @@ -643,7 +643,12 @@ impl KeymapFile { else { continue; }; - if keystrokes != target.keystrokes { + if keystrokes.len() != target.keystrokes.len() + || !keystrokes + .iter() + .zip(target.keystrokes) + .all(|(a, b)| a.should_match(b)) + { continue; } if action.0 != target_action_value { @@ -655,18 +660,75 @@ impl KeymapFile { } if let Some(index) = found_index { - let (replace_range, replace_value) = replace_top_level_array_value_in_json_text( - &keymap_contents, - &["bindings", &target.keystrokes_unparsed()], - Some(&source_action_value), - Some(&source.keystrokes_unparsed()), - index, - tab_size, - ) - .context("Failed to replace keybinding")?; - keymap_contents.replace_range(replace_range, &replace_value); - - return Ok(keymap_contents); + if target.context == source.context { + // if we are only changing the keybinding (common case) + // not the context, etc. Then just update the binding in place + + let (replace_range, replace_value) = + replace_top_level_array_value_in_json_text( + &keymap_contents, + &["bindings", &target.keystrokes_unparsed()], + Some(&source_action_value), + Some(&source.keystrokes_unparsed()), + index, + tab_size, + ) + .context("Failed to replace keybinding")?; + keymap_contents.replace_range(replace_range, &replace_value); + + return Ok(keymap_contents); + } else if keymap.0[index] + .bindings + .as_ref() + .map_or(true, |bindings| bindings.len() == 1) + { + // if we are replacing the only binding in the section, + // just update the section in place, updating the context + // and the binding + + let (replace_range, replace_value) = + replace_top_level_array_value_in_json_text( + &keymap_contents, + &["bindings", &target.keystrokes_unparsed()], + Some(&source_action_value), + Some(&source.keystrokes_unparsed()), + index, + tab_size, + ) + .context("Failed to replace keybinding")?; + keymap_contents.replace_range(replace_range, &replace_value); + + let (replace_range, replace_value) = + replace_top_level_array_value_in_json_text( + &keymap_contents, + &["context"], + source.context.map(Into::into).as_ref(), + None, + index, + tab_size, + ) + .context("Failed to replace keybinding")?; + keymap_contents.replace_range(replace_range, &replace_value); + return Ok(keymap_contents); + } else { + // if we are replacing one of multiple bindings in a section + // with a context change, remove the existing binding from the + // section, then treat this operation as an add operation of the + // new binding with the updated context. + + let (replace_range, replace_value) = + replace_top_level_array_value_in_json_text( + &keymap_contents, + &["bindings", &target.keystrokes_unparsed()], + None, + None, + index, + tab_size, + ) + .context("Failed to replace keybinding")?; + keymap_contents.replace_range(replace_range, &replace_value); + operation = KeybindUpdateOperation::Add(source); + } } else { log::warn!( "Failed to find keybinding to update `{:?} -> {}` creating new binding for `{:?} -> {}` instead", @@ -712,7 +774,7 @@ pub enum KeybindUpdateOperation<'a> { source: KeybindUpdateTarget<'a>, /// Describes the keybind to remove target: KeybindUpdateTarget<'a>, - target_source: KeybindSource, + target_keybind_source: KeybindSource, }, Add(KeybindUpdateTarget<'a>), } @@ -1001,7 +1063,7 @@ mod tests { use_key_equivalents: false, input: Some(r#"{"foo": "bar"}"#), }, - target_source: KeybindSource::Base, + target_keybind_source: KeybindSource::Base, }, r#"[ { @@ -1027,14 +1089,14 @@ mod tests { r#"[ { "bindings": { - "ctrl-a": "zed::SomeAction" + "a": "zed::SomeAction" } } ]"# .unindent(), KeybindUpdateOperation::Replace { target: KeybindUpdateTarget { - keystrokes: &parse_keystrokes("ctrl-a"), + keystrokes: &parse_keystrokes("a"), action_name: "zed::SomeAction", context: None, use_key_equivalents: false, @@ -1047,7 +1109,7 @@ mod tests { use_key_equivalents: false, input: Some(r#"{"foo": "bar"}"#), }, - target_source: KeybindSource::User, + target_keybind_source: KeybindSource::User, }, r#"[ { @@ -1088,7 +1150,7 @@ mod tests { use_key_equivalents: false, input: None, }, - target_source: KeybindSource::User, + target_keybind_source: KeybindSource::User, }, r#"[ { @@ -1131,7 +1193,7 @@ mod tests { use_key_equivalents: false, input: Some(r#"{"foo": "bar"}"#), }, - target_source: KeybindSource::User, + target_keybind_source: KeybindSource::User, }, r#"[ { @@ -1149,5 +1211,88 @@ mod tests { ]"# .unindent(), ); + + check_keymap_update( + r#"[ + { + "context": "SomeContext", + "bindings": { + "a": "foo::bar", + "b": "baz::qux", + } + } + ]"# + .unindent(), + KeybindUpdateOperation::Replace { + target: KeybindUpdateTarget { + keystrokes: &parse_keystrokes("a"), + action_name: "foo::bar", + context: Some("SomeContext"), + use_key_equivalents: false, + input: None, + }, + source: KeybindUpdateTarget { + keystrokes: &parse_keystrokes("c"), + action_name: "foo::baz", + context: Some("SomeOtherContext"), + use_key_equivalents: false, + input: None, + }, + target_keybind_source: KeybindSource::User, + }, + r#"[ + { + "context": "SomeContext", + "bindings": { + "b": "baz::qux", + } + }, + { + "context": "SomeOtherContext", + "bindings": { + "c": "foo::baz" + } + } + ]"# + .unindent(), + ); + + check_keymap_update( + r#"[ + { + "context": "SomeContext", + "bindings": { + "a": "foo::bar", + } + } + ]"# + .unindent(), + KeybindUpdateOperation::Replace { + target: KeybindUpdateTarget { + keystrokes: &parse_keystrokes("a"), + action_name: "foo::bar", + context: Some("SomeContext"), + use_key_equivalents: false, + input: None, + }, + source: KeybindUpdateTarget { + keystrokes: &parse_keystrokes("c"), + action_name: "foo::baz", + context: Some("SomeOtherContext"), + use_key_equivalents: false, + input: None, + }, + target_keybind_source: KeybindSource::User, + }, + r#"[ + { + "context": "SomeOtherContext", + "bindings": { + "c": "foo::baz", + } + } + ]"# + .unindent(), + ); } } diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 1f5f4b1b7e18c7720227d6c04d7f8680e469c94b..34d4b8585256d12b62b725d360679225b7360a82 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -1,4 +1,7 @@ -use std::{ops::Range, sync::Arc}; +use std::{ + ops::{Not, Range}, + sync::Arc, +}; use anyhow::{Context as _, anyhow}; use collections::HashSet; @@ -824,6 +827,7 @@ impl RenderOnce for SyntaxHighlightedText { struct KeybindingEditorModal { editing_keybind: ProcessedKeybinding, keybind_editor: Entity, + context_editor: Entity, fs: Arc, error: Option, } @@ -842,17 +846,86 @@ impl KeybindingEditorModal { pub fn new( editing_keybind: ProcessedKeybinding, fs: Arc, - _window: &mut Window, + window: &mut Window, cx: &mut App, ) -> Self { let keybind_editor = cx.new(KeystrokeInput::new); + let context_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(context) = editing_keybind + .context + .as_ref() + .and_then(KeybindContextString::local) + { + editor.set_text(context.clone(), window, cx); + } else { + editor.set_placeholder_text("Keybinding context", cx); + } + + editor + }); Self { editing_keybind, fs, keybind_editor, + context_editor, error: None, } } + + fn save(&mut self, cx: &mut Context) { + let existing_keybind = self.editing_keybind.clone(); + let fs = self.fs.clone(); + let new_keystrokes = self + .keybind_editor + .read_with(cx, |editor, _| editor.keystrokes().to_vec()); + if new_keystrokes.is_empty() { + self.error = Some("Keystrokes cannot be empty".to_string()); + cx.notify(); + return; + } + let tab_size = cx.global::().json_tab_size(); + let new_context = self + .context_editor + .read_with(cx, |editor, cx| editor.text(cx)); + let new_context = new_context.is_empty().not().then_some(new_context); + let new_context_err = new_context.as_deref().and_then(|context| { + gpui::KeyBindingContextPredicate::parse(context) + .context("Failed to parse key context") + .err() + }); + if let Some(err) = new_context_err { + // TODO: store and display as separate error + // TODO: also, should be validating on keystroke + self.error = Some(err.to_string()); + cx.notify(); + return; + } + + cx.spawn(async move |this, cx| { + if let Err(err) = save_keybinding_update( + existing_keybind, + &new_keystrokes, + new_context.as_deref(), + &fs, + tab_size, + ) + .await + { + this.update(cx, |this, cx| { + this.error = Some(err.to_string()); + cx.notify(); + }) + .log_err(); + } else { + this.update(cx, |_this, cx| { + cx.emit(DismissEvent); + }) + .ok(); + } + }) + .detach(); + } } impl Render for KeybindingEditorModal { @@ -868,14 +941,35 @@ impl Render for KeybindingEditorModal { .gap_2() .child( v_flex().child(Label::new("Edit Keystroke")).child( - Label::new( - "Input the desired keystroke for the selected action and hit save.", - ) - .color(Color::Muted), + Label::new("Input the desired keystroke for the selected action.") + .color(Color::Muted), ), ) .child(self.keybind_editor.clone()), ) + .child( + v_flex() + .p_3() + .gap_3() + .child( + v_flex().child(Label::new("Edit Keystroke")).child( + Label::new("Input the desired keystroke for the selected action.") + .color(Color::Muted), + ), + ) + .child( + div() + .w_full() + .border_color(cx.theme().colors().border_variant) + .border_1() + .py_2() + .px_3() + .min_h_8() + .rounded_md() + .bg(theme.editor_background) + .child(self.context_editor.clone()), + ), + ) .child( h_flex() .p_2() @@ -888,38 +982,11 @@ impl Render for KeybindingEditorModal { Button::new("cancel", "Cancel") .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) - .child(Button::new("save-btn", "Save").on_click(cx.listener( - |this, _event, _window, cx| { - let existing_keybind = this.editing_keybind.clone(); - let fs = this.fs.clone(); - let new_keystrokes = this - .keybind_editor - .read_with(cx, |editor, _| editor.keystrokes.clone()); - if new_keystrokes.is_empty() { - this.error = Some("Keystrokes cannot be empty".to_string()); - cx.notify(); - return; - } - let tab_size = cx.global::().json_tab_size(); - cx.spawn(async move |this, cx| { - if let Err(err) = save_keybinding_update( - existing_keybind, - &new_keystrokes, - &fs, - tab_size, - ) - .await - { - this.update(cx, |this, cx| { - this.error = Some(err.to_string()); - cx.notify(); - }) - .log_err(); - } - }) - .detach(); - }, - ))), + .child( + Button::new("save-btn", "Save").on_click( + cx.listener(|this, _event, _window, cx| Self::save(this, cx)), + ), + ), ) .when_some(self.error.clone(), |this, error| { this.child( @@ -937,6 +1004,7 @@ impl Render for KeybindingEditorModal { async fn save_keybinding_update( existing: ProcessedKeybinding, new_keystrokes: &[Keystroke], + new_context: Option<&str>, fs: &Arc, tab_size: usize, ) -> anyhow::Result<()> { @@ -950,7 +1018,7 @@ async fn save_keybinding_update( .map(|keybinding| keybinding.keystrokes.as_slice()) .unwrap_or_default(); - let context = existing + let existing_context = existing .context .as_ref() .and_then(KeybindContextString::local_str); @@ -963,18 +1031,18 @@ async fn save_keybinding_update( let operation = if existing.ui_key_binding.is_some() { settings::KeybindUpdateOperation::Replace { target: settings::KeybindUpdateTarget { - context, + context: existing_context, keystrokes: existing_keystrokes, action_name: &existing.action, use_key_equivalents: false, input, }, - target_source: existing + target_keybind_source: existing .source .map(|(source, _name)| source) .unwrap_or(KeybindSource::User), source: settings::KeybindUpdateTarget { - context, + context: new_context, keystrokes: new_keystrokes, action_name: &existing.action, use_key_equivalents: false, @@ -1071,6 +1139,17 @@ impl KeystrokeInput { cx.stop_propagation(); cx.notify(); } + + fn keystrokes(&self) -> &[Keystroke] { + if self + .keystrokes + .last() + .map_or(false, |last| last.key.is_empty()) + { + return &self.keystrokes[..self.keystrokes.len() - 1]; + } + return &self.keystrokes; + } } impl Focusable for KeystrokeInput { diff --git a/crates/task/src/shell_builder.rs b/crates/task/src/shell_builder.rs index c75aa059f0e4571093ce97fec31860af3c8c4652..544663713933dd967f71c9330268f46688b11d93 100644 --- a/crates/task/src/shell_builder.rs +++ b/crates/task/src/shell_builder.rs @@ -149,17 +149,23 @@ impl ShellBuilder { } } /// Returns the program and arguments to run this task in a shell. - pub fn build(mut self, task_command: String, task_args: &Vec) -> (String, Vec) { - let combined_command = task_args - .into_iter() - .fold(task_command, |mut command, arg| { - command.push(' '); - command.push_str(&self.kind.to_shell_variable(arg)); - command - }); + pub fn build( + mut self, + task_command: Option, + task_args: &Vec, + ) -> (String, Vec) { + if let Some(task_command) = task_command { + let combined_command = task_args + .into_iter() + .fold(task_command, |mut command, arg| { + command.push(' '); + command.push_str(&self.kind.to_shell_variable(arg)); + command + }); - self.args - .extend(self.kind.args_for_shell(self.interactive, combined_command)); + self.args + .extend(self.kind.args_for_shell(self.interactive, combined_command)); + } (self.program, self.args) } diff --git a/crates/task/src/task.rs b/crates/task/src/task.rs index 4586b0cacc83863d27562383cb3e3fb70c78e247..8bcdb5d27fbf693ebc4451530e88188007f88180 100644 --- a/crates/task/src/task.rs +++ b/crates/task/src/task.rs @@ -44,7 +44,7 @@ pub struct SpawnInTerminal { /// Human readable name of the terminal tab. pub label: String, /// Executable command to spawn. - pub command: String, + pub command: Option, /// Arguments to the command, potentially unsubstituted, /// to let the shell that spawns the command to do the substitution, if needed. pub args: Vec, diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 29eaca14939baeb6d7b4b4c92028e9c9a88dc1eb..008b1357e1dee77d4c70ffa62469e272e219f14a 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -255,7 +255,7 @@ impl TaskTemplate { command_label }, ), - command, + command: Some(command), args: self.args.clone(), env, use_new_terminal: self.use_new_terminal, @@ -635,7 +635,7 @@ mod tests { "Human-readable label should have long substitutions trimmed" ); assert_eq!( - spawn_in_terminal.command, + spawn_in_terminal.command.clone().unwrap(), format!("echo test_file {long_value}"), "Command should be substituted with variables and those should not be shortened" ); @@ -652,7 +652,7 @@ mod tests { spawn_in_terminal.command_label, format!( "{} arg1 test_selected_text arg2 5678 arg3 {long_value}", - spawn_in_terminal.command + spawn_in_terminal.command.clone().unwrap() ), "Command label args should be substituted with variables and those should not be shortened" ); @@ -711,7 +711,7 @@ mod tests { assert_substituted_variables(&resolved_task, Vec::new()); let resolved = resolved_task.resolved; assert_eq!(resolved.label, task.label); - assert_eq!(resolved.command, task.command); + assert_eq!(resolved.command, Some(task.command)); assert_eq!(resolved.args, task.args); } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 8c55fed2a60127db4dd6fd0845f219507a8f4f78..f6eee3065ca974449315ab2ac519de1acb5da11e 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -505,7 +505,7 @@ impl TerminalPanel { let task = SpawnInTerminal { command_label, - command, + command: Some(command), args, ..task.clone() }; diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 237403d4ba053646108e88546242df1f07cdc8ab..88676e8a2bbe383538e91499a71ca908b2057203 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -30,6 +30,7 @@ mod scrollbar; mod settings_container; mod settings_group; mod stack; +mod sticky_items; mod tab; mod tab_bar; mod toggle; @@ -70,6 +71,7 @@ pub use scrollbar::*; pub use settings_container::*; pub use settings_group::*; pub use stack::*; +pub use sticky_items::*; pub use tab::*; pub use tab_bar::*; pub use toggle::*; diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index d7080f21f4f374777fab03104dad339d250f2d2a..075cf7a7d7a881fc308b0d2a7dcee3c9bdabcd57 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -24,6 +24,7 @@ pub enum ContextMenuItem { entry_render: Box AnyElement>, handler: Rc, &mut Window, &mut App)>, selectable: bool, + documentation_aside: Option, }, } @@ -31,11 +32,13 @@ impl ContextMenuItem { pub fn custom_entry( entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, handler: impl Fn(&mut Window, &mut App) + 'static, + documentation_aside: Option, ) -> Self { Self::CustomEntry { entry_render: Box::new(entry_render), handler: Rc::new(move |_, window, cx| handler(window, cx)), selectable: true, + documentation_aside, } } } @@ -170,6 +173,12 @@ pub struct DocumentationAside { render: Rc AnyElement>, } +impl DocumentationAside { + pub fn new(side: DocumentationSide, render: Rc AnyElement>) -> Self { + Self { side, render } + } +} + impl Focusable for ContextMenu { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() @@ -456,6 +465,7 @@ impl ContextMenu { entry_render: Box::new(entry_render), handler: Rc::new(|_, _, _| {}), selectable: false, + documentation_aside: None, }); self } @@ -469,6 +479,7 @@ impl ContextMenu { entry_render: Box::new(entry_render), handler: Rc::new(move |_, window, cx| handler(window, cx)), selectable: true, + documentation_aside: None, }); self } @@ -705,10 +716,19 @@ impl ContextMenu { let item = self.items.get(ix)?; if item.is_selectable() { self.selected_index = Some(ix); - if let ContextMenuItem::Entry(entry) = item { - if let Some(callback) = &entry.documentation_aside { + match item { + ContextMenuItem::Entry(entry) => { + if let Some(callback) = &entry.documentation_aside { + self.documentation_aside = Some((ix, callback.clone())); + } + } + ContextMenuItem::CustomEntry { + documentation_aside: Some(callback), + .. + } => { self.documentation_aside = Some((ix, callback.clone())); } + _ => (), } } Some(ix) @@ -806,6 +826,7 @@ impl ContextMenu { entry_render, handler, selectable, + .. } => { let handler = handler.clone(); let menu = cx.entity().downgrade(); diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index 077c18f69e5476d8d47a85f5dd9664b3284c6681..55ce0218c75d4450067a5c09c2ea523f7d86ca3c 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -105,6 +105,24 @@ impl PopoverMenuHandle { .map_or(false, |model| model.focus_handle(cx).is_focused(window)) }) } + + pub fn refresh_menu( + &self, + window: &mut Window, + cx: &mut App, + new_menu_builder: Rc Option>>, + ) { + let show_menu = if let Some(state) = self.0.borrow_mut().as_mut() { + state.menu_builder = new_menu_builder; + state.menu.borrow().is_some() + } else { + false + }; + + if show_menu { + self.show(window, cx); + } + } } pub struct PopoverMenu { diff --git a/crates/ui/src/components/sticky_items.rs b/crates/ui/src/components/sticky_items.rs new file mode 100644 index 0000000000000000000000000000000000000000..e5ef0cdf27daae5ccbd9fc1eeceac631e8ce757b --- /dev/null +++ b/crates/ui/src/components/sticky_items.rs @@ -0,0 +1,150 @@ +use std::ops::Range; + +use gpui::{ + AnyElement, App, AvailableSpace, Bounds, Context, Entity, Pixels, Render, UniformListTopSlot, + Window, point, size, +}; +use smallvec::SmallVec; + +pub trait StickyCandidate { + fn depth(&self) -> usize; +} + +pub struct StickyItems { + compute_fn: Box, &mut Window, &mut App) -> Vec>, + render_fn: Box SmallVec<[AnyElement; 8]>>, + last_item_is_drifting: bool, + anchor_index: Option, +} + +pub fn sticky_items( + entity: Entity, + compute_fn: impl Fn(&mut V, Range, &mut Window, &mut Context) -> Vec + 'static, + render_fn: impl Fn(&mut V, T, &mut Window, &mut Context) -> SmallVec<[AnyElement; 8]> + 'static, +) -> StickyItems +where + V: Render, + T: StickyCandidate + Clone + 'static, +{ + let entity_compute = entity.clone(); + let entity_render = entity.clone(); + + let compute_fn = Box::new( + move |range: Range, window: &mut Window, cx: &mut App| -> Vec { + entity_compute.update(cx, |view, cx| compute_fn(view, range, window, cx)) + }, + ); + let render_fn = Box::new( + move |entry: T, window: &mut Window, cx: &mut App| -> SmallVec<[AnyElement; 8]> { + entity_render.update(cx, |view, cx| render_fn(view, entry, window, cx)) + }, + ); + StickyItems { + compute_fn, + render_fn, + last_item_is_drifting: false, + anchor_index: None, + } +} + +impl UniformListTopSlot for StickyItems +where + T: StickyCandidate + Clone + 'static, +{ + fn compute( + &mut self, + visible_range: Range, + window: &mut Window, + cx: &mut App, + ) -> SmallVec<[AnyElement; 8]> { + let entries = (self.compute_fn)(visible_range.clone(), window, cx); + + let mut anchor_entry = None; + + let mut iter = entries.iter().enumerate().peekable(); + while let Some((ix, current_entry)) = iter.next() { + let current_depth = current_entry.depth(); + let index_in_range = ix; + + if current_depth < index_in_range { + anchor_entry = Some(current_entry.clone()); + break; + } + + if let Some(&(_next_ix, next_entry)) = iter.peek() { + let next_depth = next_entry.depth(); + + if next_depth < current_depth && next_depth < index_in_range { + self.last_item_is_drifting = true; + self.anchor_index = Some(visible_range.start + ix); + anchor_entry = Some(current_entry.clone()); + break; + } + } + } + + if let Some(anchor_entry) = anchor_entry { + (self.render_fn)(anchor_entry, window, cx) + } else { + SmallVec::new() + } + } + + fn prepaint( + &self, + items: &mut SmallVec<[AnyElement; 8]>, + bounds: Bounds, + item_height: Pixels, + scroll_offset: gpui::Point, + padding: gpui::Edges, + can_scroll_horizontally: bool, + window: &mut Window, + cx: &mut App, + ) { + let items_count = items.len(); + + for (ix, item) in items.iter_mut().enumerate() { + let mut item_y_offset = None; + if ix == items_count - 1 && self.last_item_is_drifting { + if let Some(anchor_index) = self.anchor_index { + let scroll_top = -scroll_offset.y; + let anchor_top = item_height * anchor_index; + let sticky_area_height = item_height * items_count; + item_y_offset = + Some((anchor_top - scroll_top - sticky_area_height).min(Pixels::ZERO)); + }; + } + + let sticky_origin = bounds.origin + + point( + if can_scroll_horizontally { + scroll_offset.x + padding.left + } else { + scroll_offset.x + }, + item_height * ix + padding.top + item_y_offset.unwrap_or(Pixels::ZERO), + ); + + let available_width = if can_scroll_horizontally { + bounds.size.width + scroll_offset.x.abs() + } else { + bounds.size.width + }; + + let available_space = size( + AvailableSpace::Definite(available_width), + AvailableSpace::Definite(item_height), + ); + + item.layout_as_root(available_space, window, cx); + item.prepaint_at(sticky_origin, window, cx); + } + } + + fn paint(&self, items: &mut SmallVec<[AnyElement; 8]>, window: &mut Window, cx: &mut App) { + // reverse so that last item is bottom most among sticky items + for item in items.iter_mut().rev() { + item.paint(window, cx); + } + } +} diff --git a/crates/util/src/shell_env.rs b/crates/util/src/shell_env.rs index 9e42ebe500ba372806731b799b15afd4b44429b4..21f6096f19fa0c89bf4516b122878be04361ddcd 100644 --- a/crates/util/src/shell_env.rs +++ b/crates/util/src/shell_env.rs @@ -16,8 +16,13 @@ pub fn capture(directory: &std::path::Path) -> Result format!(">[1={}]", ENV_OUTPUT_FD), // `[1=0]` + _ => format!(">&{}", ENV_OUTPUT_FD), // `>&0` + }; command.stdin(Stdio::null()); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -38,10 +43,7 @@ pub fn capture(directory: &std::path::Path) -> Result&{}\";", - zed_path, ENV_OUTPUT_FD - )); + command_string.push_str(&format!("{} --printenv {}", zed_path, redir)); command.args(["-i", "-c", &command_string]); super::set_pre_exec_to_start_new_session(&mut command); diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 86bee7ffd14c1782f806ebfe4dbe0537b675a5bc..932b519b18d4c555a2ee6189eef5744b0f85829e 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -1097,52 +1097,6 @@ mod tests { assert_eq!(vec, &[1000, 101, 21, 19, 17, 13, 9, 8]); } - #[test] - fn test_get_shell_safe_zed_path_with_spaces() { - // Test that shlex::try_quote handles paths with spaces correctly - let path_with_spaces = "/Applications/Zed Nightly.app/Contents/MacOS/zed"; - let quoted = shlex::try_quote(path_with_spaces).unwrap(); - - // The quoted path should be properly escaped for shell use - assert!(quoted.contains(path_with_spaces)); - - // When used in a shell command, it should not be split at spaces - let command = format!("sh -c '{} --printenv'", quoted); - println!("Command would be: {}", command); - - // Test that shlex can parse it back correctly - let parsed = shlex::split(&format!("{} --printenv", quoted)).unwrap(); - assert_eq!(parsed.len(), 2); - assert_eq!(parsed[0], path_with_spaces); - assert_eq!(parsed[1], "--printenv"); - } - - #[test] - fn test_shell_command_construction_with_quoted_path() { - // Test the specific pattern used in shell_env.rs to ensure proper quoting - let path_with_spaces = "/Applications/Zed Nightly.app/Contents/MacOS/zed"; - let quoted_path = shlex::try_quote(path_with_spaces).unwrap(); - - // This should be: '/Applications/Zed Nightly.app/Contents/MacOS/zed' - assert_eq!( - quoted_path, - "'/Applications/Zed Nightly.app/Contents/MacOS/zed'" - ); - - // Test the command construction pattern from shell_env.rs - // The fixed version should use double quotes around the entire sh -c argument - let env_fd = 0; - let command = format!("sh -c \"{} --printenv >&{}\";", quoted_path, env_fd); - - // This should produce: sh -c "'/Applications/Zed Nightly.app/Contents/MacOS/zed' --printenv >&0"; - let expected = - "sh -c \"'/Applications/Zed Nightly.app/Contents/MacOS/zed' --printenv >&0\";"; - assert_eq!(command, expected); - - // The command should not contain the problematic double single-quote pattern - assert!(!command.contains("''")); - } - #[test] fn test_truncate_to_bottom_n_sorted_by() { let mut vec: Vec = vec![5, 2, 3, 4, 1]; diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 729e1a7b3c008c957f3f018f79bdcccf78a8b698..b24ca75e8bc1f922a86b011c9dcfc27a92b57e47 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1688,7 +1688,7 @@ impl ShellExec { id: TaskId("vim".to_string()), full_label: command.clone(), label: command.clone(), - command: command.clone(), + command: Some(command.clone()), args: Vec::new(), command_label: command.clone(), cwd, diff --git a/crates/vim/src/normal/convert.rs b/crates/vim/src/normal/convert.rs index 25b425e847d67eb5bc3d58b1d0a2201581a1e03f..cf9498bec9d7ee796bd2d4d6830085eda9970ea5 100644 --- a/crates/vim/src/normal/convert.rs +++ b/crates/vim/src/normal/convert.rs @@ -212,7 +212,19 @@ impl Vim { } } - Mode::HelixNormal => {} + Mode::HelixNormal => { + if selection.is_empty() { + // Handle empty selection by operating on the whole word + let (word_range, _) = snapshot.surrounding_word(selection.start, false); + let word_start = snapshot.offset_to_point(word_range.start); + let word_end = snapshot.offset_to_point(word_range.end); + ranges.push(word_start..word_end); + cursor_positions.push(selection.start..selection.start); + } else { + ranges.push(selection.start..selection.end); + cursor_positions.push(selection.start..selection.end); + } + } Mode::Insert | Mode::Normal | Mode::Replace => { let start = selection.start; let mut end = start; @@ -245,12 +257,16 @@ impl Vim { }) }); }); - self.switch_mode(Mode::Normal, true, window, cx) + if self.mode != Mode::HelixNormal { + self.switch_mode(Mode::Normal, true, window, cx) + } } } #[cfg(test)] mod test { + use crate::test::VimTestContext; + use crate::{state::Mode, test::NeovimBackedTestContext}; #[gpui::test] @@ -419,4 +435,25 @@ mod test { .await .assert_eq("ˇnopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM"); } + + #[gpui::test] + async fn test_change_case_helix_mode(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Explicit selection + cx.set_state("«hello worldˇ»", Mode::HelixNormal); + cx.simulate_keystrokes("~"); + cx.assert_state("«HELLO WORLDˇ»", Mode::HelixNormal); + + // Cursor-only (empty) selection + cx.set_state("The ˇquick brown", Mode::HelixNormal); + cx.simulate_keystrokes("~"); + cx.assert_state("The ˇQUICK brown", Mode::HelixNormal); + + // With `e` motion (which extends selection to end of word in Helix) + cx.set_state("The ˇquick brown fox", Mode::HelixNormal); + cx.simulate_keystrokes("e"); + cx.simulate_keystrokes("~"); + cx.assert_state("The «QUICKˇ» brown fox", Mode::HelixNormal); + } } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 89d9c2edf127ff4b75f3100be3b8600dbaa89c06..e04e9c38c15b7ed1bc95bffc0d702b013150b3a5 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -727,11 +727,10 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut if let Some(connection_options) = request.ssh_connection { cx.spawn(async move |mut cx| { - let paths_with_position = - derive_paths_with_position(app_state.fs.as_ref(), request.open_paths).await; + let paths: Vec = request.open_paths.into_iter().map(PathBuf::from).collect(); open_ssh_project( connection_options, - paths_with_position.into_iter().map(|p| p.path).collect(), + paths, app_state, workspace::OpenOptions::default(), &mut cx, diff --git a/script/generate-licenses b/script/generate-licenses index 9fcb2bd5133e10007f3335abd59fa7c4da2e3176..7ae0f1c3f60b93eb500ffa5e127a96c82d954b72 100755 --- a/script/generate-licenses +++ b/script/generate-licenses @@ -6,6 +6,15 @@ CARGO_ABOUT_VERSION="0.7" OUTPUT_FILE="${1:-$(pwd)/assets/licenses.md}" TEMPLATE_FILE="script/licenses/template.md.hbs" +fail_on_stderr() { + local tmpfile=$(mktemp) + "$@" 2> >(tee "$tmpfile" >&2) + local rc=$? + [ -s "$tmpfile" ] && rc=1 + rm "$tmpfile" + return $rc +} + echo -n "" >"$OUTPUT_FILE" { @@ -28,7 +37,7 @@ fi echo "Generating cargo licenses" if [ -z "${ALLOW_MISSING_LICENSES-}" ]; then FAIL_FLAG=--fail; else FAIL_FLAG=""; fi set -x -cargo about generate \ +fail_on_stderr cargo about generate \ $FAIL_FLAG \ -c script/licenses/zed-licenses.toml \ "$TEMPLATE_FILE" >>"$OUTPUT_FILE" diff --git a/script/licenses/zed-licenses.toml b/script/licenses/zed-licenses.toml index 4df1f5989ab3aa0c70b887d0a9cfc07719f3e7c3..9d13087ece08404e2cae1a44733e382521b8fdd0 100644 --- a/script/licenses/zed-licenses.toml +++ b/script/licenses/zed-licenses.toml @@ -177,9 +177,3 @@ license = "MIT" [[pet-windows-store.clarify.files]] path = '../../LICENSE' checksum = 'c2cfccb812fe482101a8f04597dfc5a9991a6b2748266c47ac91b6a5aae15383' - -[ring.clarify] -license = "ISC AND OpenSSL" -[[ring.clarify.files]] -path = 'LICENSE' -checksum = '76b39f9b371688eac9d8323f96ee80b3aef5ecbc2217f25377bd4e4a615296a9'