Detailed changes
@@ -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
@@ -9026,7 +9026,6 @@ dependencies = [
"itertools 0.14.0",
"language",
"lsp",
- "picker",
"project",
"release_channel",
"serde_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",
@@ -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,
@@ -0,0 +1,3 @@
+[The following is an auto-generated notification; do not reply]
+
+These files have changed since the last read:
@@ -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<dyn LanguageModel>,
+ intent: CompletionIntent,
+ cx: &mut Context<Self>,
+ ) {
+ 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<dyn LanguageModel>,
+ cx: &mut App,
+ ) -> Option<PendingToolUse> {
+ 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<LanguageModelToolResult> {
+ 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);
});
}
@@ -436,7 +436,7 @@ impl AgentConfiguration {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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))
@@ -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,
)
}
}
@@ -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();
@@ -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(())
@@ -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<HttpClientWithUrl>, 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);
@@ -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<serde_json::Value> {
+ json_schema_for::<ProjectUpdatesToolInput>(format)
+ }
+
+ fn ui_text(&self, _input: &serde_json::Value) -> String {
+ "Check project notifications".into()
+ }
+
+ fn run(
+ self: Arc<Self>,
+ _input: serde_json::Value,
+ _request: Arc<LanguageModelRequest>,
+ _project: Entity<Project>,
+ action_log: Entity<ActionLog>,
+ _model: Arc<dyn LanguageModel>,
+ _window: Option<AnyWindowHandle>,
+ cx: &mut App,
+ ) -> ToolResult {
+ let 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<dyn LanguageModel> = 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);
+ });
+ }
+}
@@ -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.
@@ -0,0 +1,3 @@
+[The following is an auto-generated notification; do not reply]
+
+These files have changed since the last read:
@@ -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,
@@ -528,6 +528,7 @@ impl CopilotChat {
pub async fn stream_completion(
request: Request,
+ is_user_initiated: bool,
mut cx: AsyncApp,
) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
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<str>,
request: Request,
+ is_user_initiated: bool,
) -> Result<BoxStream<'static, Result<ResponseEvent>>> {
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 =
@@ -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,
@@ -44,7 +44,9 @@ impl DapLocator for ExtensionLocatorAdapter {
.flatten()
}
- async fn run(&self, _build_config: SpawnInTerminal) -> Result<DebugRequest> {
- Err(anyhow::anyhow!("Not implemented"))
+ async fn run(&self, build_config: SpawnInTerminal) -> Result<DebugRequest> {
+ self.extension
+ .run_dap_locator(self.locator_name.as_ref().to_owned(), build_config)
+ .await
}
}
@@ -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<String, String> =
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::<Vec<_>>()
+ .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();
@@ -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<Workspace>,
breakpoint_store: Entity<BreakpointStore>,
+ dap_store: Entity<DapStore>,
worktree_store: Entity<WorktreeStore>,
scrollbar_state: ScrollbarState,
breakpoints: Vec<BreakpointEntry>,
@@ -59,6 +62,7 @@ pub(crate) struct BreakpointList {
selected_ix: Option<usize>,
input: Entity<Editor>,
strip_mode: Option<ActiveBreakpointStripMode>,
+ serialize_exception_breakpoints_task: Option<Task<anyhow::Result<()>>>,
}
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<Self>) {
+ 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<Self>,
+ ) -> Task<anyhow::Result<()>> {
+ 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<Self>,
+ ) -> 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<Self>) {
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();
}
@@ -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.
@@ -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>,
+ ) {
+ self.manipulate_immutable_lines(window, cx, |lines| {
+ lines.sort_by_key(|&line| line.chars().count())
+ })
+ }
+
pub fn sort_lines_case_insensitive(
&mut self,
_: &SortLinesCaseInsensitive,
@@ -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]
@@ -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);
@@ -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),
@@ -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(
@@ -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 {
@@ -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(())
@@ -999,7 +999,7 @@ impl Extension {
) -> Result<Result<DebugRequest, String>> {
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?
@@ -299,15 +299,17 @@ impl From<extension::DebugScenario> for DebugScenario {
}
}
-impl From<SpawnInTerminal> for ResolvedTask {
- fn from(value: SpawnInTerminal) -> Self {
- Self {
+impl TryFrom<SpawnInTerminal> for ResolvedTask {
+ type Error = anyhow::Error;
+
+ fn try_from(value: SpawnInTerminal) -> Result<Self, Self::Error> {
+ 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()),
- }
+ })
}
}
@@ -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<usize>, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>,
>,
+ top_slot: Option<Box<dyn UniformListTopSlot>>,
decorations: Vec<Box<dyn UniformListDecoration>>,
interactivity: Interactivity,
scroll_handle: Option<UniformListScrollHandle>,
@@ -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<usize>,
+ 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<Pixels>,
+ item_height: Pixels,
+ scroll_offset: Point<Pixels>,
+ padding: crate::Edges<Pixels>,
+ 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<usize>) -> 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<Pixels>,
@@ -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
@@ -466,12 +466,7 @@ fn handle_keyup_msg(
}
fn handle_char_msg(wparam: WPARAM, state_ptr: Rc<WindowsWindowStatePtr>) -> Option<isize> {
- 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<WindowsWindowStatePtr>) -> Option<String> {
+ 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
@@ -43,6 +43,7 @@ pub struct WindowsWindowState {
pub callbacks: Callbacks,
pub input_handler: Option<PlatformInputHandler>,
+ pub pending_surrogate: Option<u16>,
pub last_reported_modifiers: Option<Modifiers>,
pub last_reported_capslock: Option<Capslock>,
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,
@@ -835,10 +835,6 @@ impl InlineCompletionButton {
cx.notify();
}
-
- pub fn toggle_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.popover_menu_handle.toggle(window, cx);
- }
}
impl StatusItemView for InlineCompletionButton {
@@ -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?;
@@ -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
@@ -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<PickerState>,
- popover_menu_handle: PopoverMenuHandle<Picker<LspPickerDelegate>>,
- lsp_picker: Option<Entity<Picker<LspPickerDelegate>>>,
+ server_state: Entity<LanguageServerState>,
+ popover_menu_handle: PopoverMenuHandle<ContextMenu>,
+ lsp_menu: Option<Entity<ContextMenu>>,
+ lsp_menu_refresh: Task<()>,
_subscriptions: Vec<Subscription>,
}
-struct PickerState {
+#[derive(Debug)]
+struct LanguageServerState {
+ items: Vec<LspItem>,
+ other_servers_start_index: Option<usize>,
workspace: WeakEntity<Workspace>,
lsp_store: WeakEntity<LspStore>,
active_editor: Option<ActiveEditor>,
language_servers: LanguageServers,
}
-#[derive(Debug)]
-pub struct LspPickerDelegate {
- state: Entity<PickerState>,
- selected_index: usize,
- items: Vec<LspItem>,
- other_servers_start_index: Option<usize>,
-}
-
struct ActiveEditor {
editor: WeakEntity<Editor>,
_editor_subscription: Subscription,
editor_buffers: HashSet<BufferId>,
}
+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<LanguageServerId, LanguageServerHealthStatus>,
@@ -104,192 +108,154 @@ impl LanguageServerHealthStatus {
}
}
-impl LspPickerDelegate {
- fn regenerate_items(&mut self, cx: &mut Context<Picker<Self>>) {
- 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<Self>) -> ContextMenu {
+ let lsp_logs = cx
+ .try_global::<GlobalLogStore>()
+ .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::<Vec<_>>();
-
- 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<ServerInfo> {
- 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<ServerInfo> {
+ 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<Picker<Self>>) {
- self.selected_index = ix;
- cx.notify();
- }
-
- fn update_matches(
- &mut self,
- _: String,
- _: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> 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<str> {
- Arc::default()
- }
-
- fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
- 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::<GlobalLogStore>().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<Picker<Self>>) {
- cx.emit(DismissEvent);
- }
-
- fn render_match(
- &self,
- ix: usize,
- selected: bool,
- _: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Option<Self::ListItem> {
- 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::<GlobalLogStore>().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<Editor>,
- _: &mut Window,
- cx: &mut Context<Picker<Self>>,
- ) -> Div {
- div().child(div().track_focus(&editor.focus_handle(cx)))
- }
-
- fn separators_after_indices(&self) -> Vec<usize> {
- 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<Picker<LspPickerDelegate>>,
+ popover_menu_handle: PopoverMenuHandle<ContextMenu>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let settings_subscription =
cx.observe_global_in::<SettingsStore>(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<Self>,
) {
- 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<PickerState>,
+ 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::<Vec<_>>();
+
+ 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<Self>,
- ) -> Entity<Picker<LspPickerDelegate>> {
- 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::<Editor>()) {
if Some(&editor)
!= self
- .state
+ .server_state
.read(cx)
.active_editor
.as_ref()
@@ -25,7 +25,7 @@
receiver: (parameter_list
"(" @context
(parameter_declaration
- name: (_) @name
+ name: (_) @context
type: (_) @context)
")" @context)
name: (field_identifier) @name
@@ -1,9 +1,8 @@
(comment) @comment.inclusive
-[
- (string)
- (template_string)
-] @string
+(string) @string
+
+(template_string (string_fragment) @string)
(jsx_element) @element
@@ -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"] }
]
@@ -14,4 +14,4 @@
(else_clause) @start.else
(except_clause) @start.except
(finally_clause) @start.finally
-(case_pattern) @start.case
+(case_clause) @start.case
@@ -1,9 +1,8 @@
(comment) @comment.inclusive
-[
- (string)
- (template_string)
-] @string
+(string) @string
+
+(template_string (string_fragment) @string)
(jsx_element) @element
@@ -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)))
@@ -171,6 +171,15 @@ impl ContextServerStore {
)
}
+ /// Returns all configured context server ids, regardless of enabled state.
+ pub fn configured_server_ids(&self) -> Vec<ContextServerId> {
+ self.context_server_settings
+ .keys()
+ .cloned()
+ .map(ContextServerId)
+ .collect()
+ }
+
#[cfg(any(test, feature = "test-support"))]
pub fn test(
registry: Entity<ContextServerDescriptorRegistry>,
@@ -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<WorktreeStore>,
sessions: BTreeMap<SessionId, Entity<Session>>,
next_session_id: u32,
+ adapter_options: BTreeMap<DebugAdapterName, Arc<PersistedAdapterOptions>>,
}
impl EventEmitter<DapStoreEvent> 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<String, PersistedExceptionBreakpoint>,
+}
+
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<Source>,
- cx: &mut Context<Self>,
- ) -> Task<Result<EvaluateResponse>> {
- 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::<Evaluate>(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<Self>,
- ) -> Task<Result<Vec<CompletionItem>>> {
- 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::<Completions>(CompletionsArguments {
- frame_id: Some(stack_frame_id),
- line: None,
- text,
- column: completion_column,
- })
- .await?
- .targets)
- })
- }
-
pub fn resolve_inline_value_locations(
&self,
session: Entity<Session>,
@@ -853,6 +807,45 @@ impl DapStore {
})
})
}
+
+ pub fn sync_adapter_options(
+ &mut self,
+ session: &Entity<Session>,
+ cx: &App,
+ ) -> Arc<PersistedAdapterOptions> {
+ 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<Arc<PersistedAdapterOptions>> {
+ self.adapter_options.get(name).cloned()
+ }
+
+ pub fn all_adapter_options(&self) -> &BTreeMap<DebugAdapterName, Arc<PersistedAdapterOptions>> {
+ &self.adapter_options
+ }
}
#[derive(Clone)]
@@ -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()
@@ -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::<Vec<_>>()
- })
- .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(());
@@ -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<_>>(),
vec![(
@@ -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)
}
}
}
@@ -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<StickyDetails>,
filename_text_color: Color,
diagnostic_severity: Option<DiagnosticSeverity>,
git_status: GitSummary,
@@ -181,6 +182,11 @@ struct EntryDetails {
canonical_path: Option<Arc<Path>>,
}
+#[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::<PathBuf>()
- .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::<ExternalPaths>(cx.listener(
- move |this, event: &DragMoveEvent<ExternalPaths>, _, 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::<ExternalPaths>(cx.listener(
+ move |this, event: &DragMoveEvent<ExternalPaths>, _, 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::<DraggedSelection>(cx.listener(
+ move |this, event: &DragMoveEvent<DraggedSelection>, 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::<DraggedSelection>(cx.listener(
- move |this, event: &DragMoveEvent<DraggedSelection>, 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<DraggedSelection>, _, _| {
+ 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<DraggedSelection>, _, _| {
- 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<DraggedSelection>, _, _| {
- 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<Arc<Path>>,
+ git_status: GitSummary,
+ sticky: Option<StickyDetails>,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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::<PathBuf>()
+ .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<Self>) -> Option<Stateful<Div>> {
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<usize>,
+ _window: &mut Window,
+ _cx: &mut Context<Self>,
+ ) -> Vec<StickyProjectPanelCandidate> {
+ 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<Self>,
+ ) -> 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::<HashMap<_, _>>()
+ } 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()
@@ -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<bool>,
+ /// Whether to stick parent directories at top of the project panel.
+ ///
+ /// Default: true
+ pub sticky_scroll: Option<bool>,
}
impl Settings for ProjectPanelSettings {
@@ -535,7 +535,7 @@ message DebugScenario {
message SpawnInTerminal {
string label = 1;
- string command = 2;
+ optional string command = 2;
repeated string args = 3;
map<string, string> env = 4;
optional string cwd = 5;
@@ -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(),
+ );
}
}
@@ -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<KeystrokeInput>,
+ context_editor: Entity<Editor>,
fs: Arc<dyn Fs>,
error: Option<String>,
}
@@ -842,17 +846,86 @@ impl KeybindingEditorModal {
pub fn new(
editing_keybind: ProcessedKeybinding,
fs: Arc<dyn Fs>,
- _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<Self>) {
+ 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::<settings::SettingsStore>().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::<settings::SettingsStore>().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<dyn Fs>,
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 {
@@ -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>) -> (String, Vec<String>) {
- 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<String>,
+ task_args: &Vec<String>,
+ ) -> (String, Vec<String>) {
+ 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)
}
@@ -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<String>,
/// Arguments to the command, potentially unsubstituted,
/// to let the shell that spawns the command to do the substitution, if needed.
pub args: Vec<String>,
@@ -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);
}
@@ -505,7 +505,7 @@ impl TerminalPanel {
let task = SpawnInTerminal {
command_label,
- command,
+ command: Some(command),
args,
..task.clone()
};
@@ -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::*;
@@ -24,6 +24,7 @@ pub enum ContextMenuItem {
entry_render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
selectable: bool,
+ documentation_aside: Option<DocumentationAside>,
},
}
@@ -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<DocumentationAside>,
) -> 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<dyn Fn(&mut App) -> AnyElement>,
}
+impl DocumentationAside {
+ pub fn new(side: DocumentationSide, render: Rc<dyn Fn(&mut App) -> 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();
@@ -105,6 +105,24 @@ impl<M: ManagedView> PopoverMenuHandle<M> {
.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<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
+ ) {
+ 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<M: ManagedView> {
@@ -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<T> {
+ compute_fn: Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<T>>,
+ render_fn: Box<dyn Fn(T, &mut Window, &mut App) -> SmallVec<[AnyElement; 8]>>,
+ last_item_is_drifting: bool,
+ anchor_index: Option<usize>,
+}
+
+pub fn sticky_items<V, T>(
+ entity: Entity<V>,
+ compute_fn: impl Fn(&mut V, Range<usize>, &mut Window, &mut Context<V>) -> Vec<T> + 'static,
+ render_fn: impl Fn(&mut V, T, &mut Window, &mut Context<V>) -> SmallVec<[AnyElement; 8]> + 'static,
+) -> StickyItems<T>
+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<usize>, window: &mut Window, cx: &mut App| -> Vec<T> {
+ 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<T> UniformListTopSlot for StickyItems<T>
+where
+ T: StickyCandidate + Clone + 'static,
+{
+ fn compute(
+ &mut self,
+ visible_range: Range<usize>,
+ 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<Pixels>,
+ item_height: Pixels,
+ scroll_offset: gpui::Point<Pixels>,
+ padding: gpui::Edges<Pixels>,
+ 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);
+ }
+ }
+}
@@ -16,8 +16,13 @@ pub fn capture(directory: &std::path::Path) -> Result<collections::HashMap<Strin
let mut command_string = String::new();
let mut command = std::process::Command::new(&shell_path);
// In some shells, file descriptors greater than 2 cannot be used in interactive mode,
- // so file descriptor 0 (stdin) is used instead. [Citation Needed]
+ // so file descriptor 0 (stdin) is used instead. This impacts zsh, old bash; perhaps others.
+ // See: https://github.com/zed-industries/zed/pull/32136#issuecomment-2999645482
const ENV_OUTPUT_FD: std::os::fd::RawFd = 0;
+ let redir = match shell_name {
+ Some("rc") => 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<collections::HashMap<Strin
}
// cd into the directory, triggering directory specific side-effects (asdf, direnv, etc)
command_string.push_str(&format!("cd '{}';", directory.display()));
- command_string.push_str(&format!(
- "sh -c \"{} --printenv >&{}\";",
- 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);
@@ -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<u32> = vec![5, 2, 3, 4, 1];
@@ -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,
@@ -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);
+ }
}
@@ -727,11 +727,10 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, 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<PathBuf> = 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,
@@ -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"
@@ -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'