diff --git a/.github/workflows/slack_notify_label_created.yml b/.github/workflows/slack_notify_label_created.yml new file mode 100644 index 00000000000000..e791cbc7ea4c37 --- /dev/null +++ b/.github/workflows/slack_notify_label_created.yml @@ -0,0 +1,83 @@ +name: New label created, notify slack + +on: + label: + types: [created] + +jobs: + notify-slack: + if: >- + github.repository_owner == 'zed-industries' + && (startsWith(github.event.label.name, 'area:') + || startsWith(github.event.label.name, 'platform:')) + runs-on: namespace-profile-2x4-ubuntu-2404 + + steps: + - name: Build Slack message payload + env: + LABEL_NAME: ${{ github.event.label.name }} + LABEL_COLOR: ${{ github.event.label.color }} + LABEL_DESCRIPTION: ${{ github.event.label.description }} + CREATED_BY: ${{ github.event.sender.login }} + REPO_URL: ${{ github.event.repository.html_url }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + LABELS_PAGE_URL="${REPO_URL}/labels" + MAPPING_FILE_URL="${REPO_URL}/blob/${DEFAULT_BRANCH}/script/community-pr-track-mapping.json" + + jq -n \ + --arg label_name "$LABEL_NAME" \ + --arg label_color "#$LABEL_COLOR" \ + --arg label_description "${LABEL_DESCRIPTION:-(none)}" \ + --arg created_by "$CREATED_BY" \ + --arg labels_url "$LABELS_PAGE_URL" \ + --arg mapping_file_url "$MAPPING_FILE_URL" \ + '{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "New label created: *\($label_name)*\nPlease choose a Track for it <\($mapping_file_url)|community-pr-track-mapping.json>." + } + }, + { + "type": "section", + "fields": [ + { "type": "mrkdwn", "text": "*Created by:*\n\($created_by)" }, + { "type": "mrkdwn", "text": "*Color:*\n\($label_color)" }, + { "type": "mrkdwn", "text": "*Description:*\n\($label_description)" }, + { "type": "mrkdwn", "text": "*Labels page:*\n<\($labels_url)|View all labels>" } + ] + } + ] + }' > payload.json + + echo "Payload built successfully:" + cat payload.json + + - name: Send Slack notification + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_LABEL_CREATED }} + run: | + if [ -z "$SLACK_WEBHOOK_URL" ]; then + echo "::error::SLACK_WEBHOOK_LABEL_CREATED secret is not set" + exit 1 + fi + + HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d @payload.json) + + HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d') + HTTP_STATUS=$(echo "$HTTP_RESPONSE" | tail -n 1) + + echo "Slack API response status: $HTTP_STATUS" + echo "Slack API response body: $HTTP_BODY" + + if [ "$HTTP_STATUS" -ne 200 ]; then + echo "::error::Slack notification failed with status $HTTP_STATUS: $HTTP_BODY" + exit 1 + fi + + echo "Slack notification sent successfully" diff --git a/README.md b/README.md index a1a7dd8f5ea08f..3402b16e937df3 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,6 @@ You modify the emojis in your `settings.json` like this in the root setting obje ## Git - add `blame > git_blame_font_family` setting to specify the font family for the git blame view because I am using a proportional font and the blame view misaligns otherwise -- add `git::DiffWithCommit` from https://github.com/zed-industries/zed/pull/44467 and based on that code, `git::DiffWithBranch` is implemented - add `({file count})` in the git panel to every directory, inspired by https://github.com/zed-industries/zed/pull/45846 (Improve Git Panel with TreeView, VSCode-style grouping, commit history, and auto-fetch) - add `split_diff_font_decrease` setting to configure font size decrease for split diff view (default is 30%) - split diff view uses 30% smaller font size than stacked view diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index b1a45e69a10097..3649c4902abf0c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1179,6 +1179,12 @@ "ctrl-shift-i": "file_finder::ToggleFilterMenu", }, }, + { + "context": "FileFinder > Picker > Editor && end_of_input", + "bindings": { + "right": "file_finder::OpenWithoutDismiss", + }, + }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 118310497bddf4..92ca298325d529 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1235,6 +1235,12 @@ "cmd-shift-i": "file_finder::ToggleFilterMenu", }, }, + { + "context": "FileFinder > Picker > Editor && end_of_input", + "bindings": { + "right": "file_finder::OpenWithoutDismiss", + }, + }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 7b600919dbe32c..102029c08cd999 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1186,6 +1186,12 @@ "ctrl-shift-i": "file_finder::ToggleFilterMenu", }, }, + { + "context": "FileFinder > Picker > Editor && end_of_input", + "bindings": { + "right": "file_finder::OpenWithoutDismiss", + }, + }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", "use_key_equivalents": true, diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 695e2beb4404bd..a2fcfe531595d0 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -767,7 +767,7 @@ impl Render for AcpTools { } else { div() .size_full() - .flex_grow() + .flex_grow_1() .child( list( connection.list_state.clone(), diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index e2b3d6cb5bccd3..64b88cd5739a54 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -580,6 +580,7 @@ mod tests { inline_assistant_model: None, inline_assistant_use_streaming_tools: false, commit_message_model: None, + commit_message_instructions: None, thread_summary_model: None, inline_alternatives: vec![], favorite_models: vec![], diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index d7b9d0ed0182f4..c5ca601f1b4751 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -150,6 +150,7 @@ pub struct AgentSettings { pub inline_assistant_model: Option, pub inline_assistant_use_streaming_tools: bool, pub commit_message_model: Option, + pub commit_message_instructions: Option, pub thread_summary_model: Option, pub inline_alternatives: Vec, pub favorite_models: Vec, @@ -649,6 +650,7 @@ impl Settings for AgentSettings { .inline_assistant_use_streaming_tools .unwrap_or(true), commit_message_model: agent.commit_message_model, + commit_message_instructions: agent.commit_message_instructions, thread_summary_model: agent.thread_summary_model, inline_alternatives: agent.inline_alternatives.unwrap_or_default(), favorite_models: agent.favorite_models, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 9ba1eb714764d9..34d2b9f0015ec2 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -4949,7 +4949,7 @@ impl AgentPanel { h_flex() .key_context("TitleEditor") .group("title_editor") - .flex_grow() + .flex_grow_1() .w_full() .min_w_0() .max_w_full() diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index c6918d869b7c7d..3f6181321cb2f2 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -650,7 +650,7 @@ impl Render for AgentRegistryPage { let scroll_handle = &self.list; this.child( uniform_list("registry-entries", count, cx.processor(Self::render_agents)) - .flex_grow() + .flex_grow_1() .pb_4() .track_scroll(scroll_handle), ) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index ac43b41f86763b..98d48e3d92c1e5 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1013,6 +1013,7 @@ mod tests { inline_assistant_model: None, inline_assistant_use_streaming_tools: false, commit_message_model: None, + commit_message_instructions: None, thread_summary_model: None, inline_alternatives: vec![], favorite_models: vec![], diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 97e40056c68b0f..c38d449694fa43 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -2629,7 +2629,7 @@ impl ThreadView { v_flex() .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) .when(max_content_width.is_none(), |this| this.w_full()) - .flex_shrink() + .flex_shrink_1() .flex_grow_0() .max_w_full() .bg(self.activity_bar_bg(cx)) @@ -3731,7 +3731,7 @@ impl ThreadView { .when_some(max_content_width, |this, max_w| this.flex_basis(max_w)) .when(max_content_width.is_none(), |this| this.w_full()) .when(fills_container, |this| this.h_full()) - .flex_shrink() + .flex_shrink_1() .flex_grow_0() .justify_between() .gap_2() @@ -4188,9 +4188,6 @@ impl ThreadView { } fn fast_mode_available(&self, cx: &Context) -> bool { - if !cx.is_staff() { - return false; - } self.as_native_thread(cx) .and_then(|thread| thread.read(cx).model()) .map(|model| model.supports_fast_mode()) @@ -5105,7 +5102,7 @@ impl ThreadView { }), ) .with_sizing_behavior(gpui::ListSizingBehavior::Auto) - .flex_grow() + .flex_grow_1() } fn render_entry( diff --git a/crates/agent_ui/src/draft_prompt_store.rs b/crates/agent_ui/src/draft_prompt_store.rs index c0b45ed75892eb..1b485ce5c888a2 100644 --- a/crates/agent_ui/src/draft_prompt_store.rs +++ b/crates/agent_ui/src/draft_prompt_store.rs @@ -8,11 +8,13 @@ //! alongside the storage so the sidebar's preview rendering can't drift from //! the format we persist. +use agent::ZED_AGENT_ID; use agent_client_protocol::schema as acp; use anyhow::Context as _; use db::kvp::KeyValueStore; use gpui::{App, AppContext as _, Entity, Task}; use itertools::Itertools; +use project::AgentId; use ui::SharedString; use util::ResultExt as _; use workspace::Workspace; @@ -164,6 +166,23 @@ pub fn display_label_for_draft( truncate_draft_label(&raw) } +pub fn empty_draft_placeholder_label( + workspace: Option<&Entity>, + agent_id: &AgentId, + cx: &App, +) -> SharedString { + let agent_name = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { + SharedString::from(ZED_AGENT_ID.to_string()) + } else { + workspace + .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()) + .and_then(|store| store.read(cx).agent_display_name(agent_id)) + .unwrap_or_else(|| SharedString::from(agent_id.to_string())) + }; + + format!("New {} Thread", agent_name).into() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 53928b5c53ab3e..39a58f83954c6f 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -2670,7 +2670,7 @@ impl Render for TextThreadEditor { .size_full() .child( div() - .flex_grow() + .flex_grow(1.) .bg(cx.theme().colors().editor_background) .child(self.editor.clone()), ) diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index d53a8d473e96f8..f8583ba5b60b47 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1580,7 +1580,7 @@ impl PickerDelegate for ProjectPickerDelegate { .child( h_flex() .gap_3() - .flex_grow() + .flex_grow_1() .child(highlighted_match.render(window, cx)), ) .tooltip(Tooltip::text(tooltip_path)) diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs index a63a332e4a0e38..b981e17c33a50d 100644 --- a/crates/breadcrumbs/src/breadcrumbs.rs +++ b/crates/breadcrumbs/src/breadcrumbs.rs @@ -49,7 +49,7 @@ impl Render for Breadcrumbs { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let element = h_flex() .id("breadcrumb-container") - .flex_grow() + .flex_grow_1() .h_8() .overflow_x_scroll() .text_ui(cx); diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index f269dfdfbbe74f..108c5a46304ffa 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -895,7 +895,8 @@ impl Room { if this.created.elapsed() > Duration::from_millis(100) { if let proto::ChannelRole::Guest = role { Audio::play_sound(Sound::GuestJoined, cx); - } else { + // Do not play join sound in large meetings + } else if this.remote_participants().len() < 10 { Audio::play_sound(Sound::Joined, cx); } } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 9c39dd4c260b95..fe66b5749af310 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -172,6 +172,7 @@ CREATE TABLE "language_servers" ( "id" INTEGER NOT NULL, "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, "name" VARCHAR NOT NULL, + "language_name" VARCHAR, "capabilities" TEXT NOT NULL, "worktree_id" BIGINT, PRIMARY KEY (project_id, id) diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index cae1f238b1d752..3cf82e8518cb14 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -586,6 +586,7 @@ impl Database { project_id: ActiveValue::set(project_id), id: ActiveValue::set(server.id as i64), name: ActiveValue::set(server.name.clone()), + language_name: ActiveValue::set(server.language_name.clone()), worktree_id: ActiveValue::set(server.worktree_id.map(|id| id as i64)), capabilities: ActiveValue::set(update.capabilities.clone()), }) @@ -596,6 +597,7 @@ impl Database { ]) .update_columns([ language_server::Column::Name, + language_server::Column::LanguageName, language_server::Column::Capabilities, language_server::Column::WorktreeId, ]) @@ -986,6 +988,7 @@ impl Database { id: language_server.id as u64, name: language_server.name, worktree_id: language_server.worktree_id.map(|id| id as u64), + language_name: language_server.language_name, }, capabilities: language_server.capabilities, }) diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 6e43b34507f839..a04cd534102d9f 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -824,6 +824,7 @@ impl Database { id: language_server.id as u64, name: language_server.name, worktree_id: language_server.worktree_id.map(|id| id as u64), + language_name: language_server.language_name, }, capabilities: language_server.capabilities, }) diff --git a/crates/collab/src/db/tables/language_server.rs b/crates/collab/src/db/tables/language_server.rs index 705aae292ba456..5eddaa84847b00 100644 --- a/crates/collab/src/db/tables/language_server.rs +++ b/crates/collab/src/db/tables/language_server.rs @@ -9,6 +9,7 @@ pub struct Model { #[sea_orm(primary_key)] pub id: i64, pub name: String, + pub language_name: Option, pub capabilities: String, pub worktree_id: Option, } diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 016ae3dd16c002..71040766db0827 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -1524,6 +1524,177 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes }); } +#[gpui::test] +async fn test_local_registration_for_new_available_server_from_remote( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let executor = cx_a.executor(); + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + client_a.language_registry().add(rust_lang()); + client_b.language_registry().add(rust_lang()); + + // Client B has an "available" adapter for "the-language-server", + // but it's not regitstered for Rust + client_b + .language_registry() + .register_fake_available_lsp_adapter( + "the-language-server", + FakeLspAdapter { + name: "the-language-server", + ..Default::default() + }, + ); + + client_a + .fs() + .insert_tree( + path!("/dir"), + json!({ + "main.rs": "const ONE: usize = 1;", + }), + ) + .await; + let (project_a, _) = client_a.build_local_project(path!("/dir"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + executor.run_until_parked(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + // Client A starts the language server. + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "the-language-server", + ..Default::default() + }, + ); + + let _buffer_a = project_a + .update(cx_a, |p, cx| { + p.open_local_buffer_with_lsp(path!("/dir/main.rs"), cx) + }) + .await + .unwrap(); + + let _fake_language_server = fake_language_servers.next().await.unwrap(); + executor.run_until_parked(); + + // Verify client B has registered the adapter for Rust locally + project_b.read_with(cx_b, |project, cx| { + let statuses = project.language_server_statuses(cx).collect::>(); + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0].1.name.0, "the-language-server"); + }); + + let rust_adapters = client_b + .language_registry() + .lsp_adapters(&language::LanguageName::new("Rust")); + assert!( + rust_adapters + .iter() + .any(|a| a.name().0 == "the-language-server") + ); +} + +#[gpui::test] +async fn test_local_registration_for_existing_available_server_from_remote( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.executor()).await; + let executor = cx_a.executor(); + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + client_a.language_registry().add(rust_lang()); + client_b.language_registry().add(rust_lang()); + + // Client B has an "available" adapter for "the-language-server", + // but it's not regitstered for Rust + client_b + .language_registry() + .register_fake_available_lsp_adapter( + "the-language-server", + FakeLspAdapter { + name: "the-language-server", + ..Default::default() + }, + ); + + client_a + .fs() + .insert_tree( + path!("/dir"), + json!({ + "main.rs": "const ONE: usize = 1;", + }), + ) + .await; + let (project_a, _) = client_a.build_local_project(path!("/dir"), cx_a).await; + + // Client A starts the language server FIRST. + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "the-language-server", + ..Default::default() + }, + ); + + let _buffer_a = project_a + .update(cx_a, |p, cx| { + p.open_local_buffer_with_lsp(path!("/dir/main.rs"), cx) + }) + .await + .unwrap(); + + let _fake_language_server = fake_language_servers.next().await.unwrap(); + executor.run_until_parked(); + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + executor.run_until_parked(); + + // Client B joins the remote project. + let project_b = client_b.join_remote_project(project_id, cx_b).await; + executor.run_until_parked(); + + // Verify client B has registered the adapter for Rust locally. + let rust_adapters = client_b + .language_registry() + .lsp_adapters(&language::LanguageName::new("Rust")); + assert!( + rust_adapters + .iter() + .any(|a| a.name().0 == "the-language-server"), + "Adapter should have been registered upon joining" + ); + + project_b.read_with(cx_b, |project, cx| { + let statuses = project.language_server_statuses(cx).collect::>(); + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0].1.name.0, "the-language-server"); + }); +} + #[gpui::test(iterations = 10)] async fn test_share_project( cx_a: &mut TestAppContext, diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index 86bd71f6eb13a5..d82971fe7a6489 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -21,7 +21,10 @@ use node_runtime::NodeRuntime; use project::{ ProjectPath, debugger::session::ThreadId, - lsp_store::{FormatTrigger, LspFormatTarget}, + lsp_store::{ + FormatTrigger, LspFormatTarget, + log_store::{self, GlobalLogStore}, + }, trusted_worktrees::{PathTrust, TrustedWorktrees}, }; use remote::RemoteClient; @@ -837,6 +840,150 @@ async fn test_ssh_collaboration_formatting_with_prettier( ); } +#[gpui::test(iterations = 10)] +async fn test_ssh_restarting_language_server_replaces_remote_status( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + cx_a.set_name("a"); + server_cx.set_name("server"); + + cx_a.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let log_store = cx_a.update(|cx| log_store::init(false, cx)); + + let (opts, server_ssh, _) = RemoteClient::fake_server(cx_a, server_cx); + let remote_fs = FakeFs::new(server_cx.executor()); + remote_fs + .insert_tree(path!("/project"), json!({ "a.rs": "fn main() {}" })) + .await; + + client_a.language_registry().add(rust_lang()); + + server_cx.update(HeadlessProject::init); + let languages = Arc::new(LanguageRegistry::new(server_cx.executor())); + languages.add(rust_lang()); + let mut fake_language_servers = languages.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "the-language-server", + ..Default::default() + }, + ); + let _headless_project = server_cx.new(|cx| { + HeadlessProject::new( + HeadlessAppState { + session: server_ssh, + fs: remote_fs.clone(), + http_client: Arc::new(BlockedHttpClient), + node_runtime: NodeRuntime::unavailable(), + languages, + extension_host_proxy: Arc::new(ExtensionHostProxy::new()), + startup_time: std::time::Instant::now(), + }, + false, + cx, + ) + }); + + let client_ssh = RemoteClient::connect_mock(opts, cx_a).await; + let (project_a, worktree_id) = client_a + .build_ssh_project(path!("/project"), client_ssh, false, cx_a) + .await; + log_store.update(cx_a, |log_store, cx| log_store.add_project(&project_a, cx)); + + let (buffer, _handle) = project_a + .update(cx_a, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("a.rs")), cx) + }) + .await + .unwrap(); + + let first_server = fake_language_servers.next().await.unwrap(); + let first_server_id = first_server.server.server_id(); + executor.run_until_parked(); + + project_a.read_with(cx_a, |project, cx| { + let statuses = project.language_server_statuses(cx).collect::>(); + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0].0, first_server_id); + assert_eq!(statuses[0].1.name.0, "the-language-server"); + }); + cx_a.read_global::(|global, cx| { + let log_store = global.0.read(cx); + let matching_server_ids = log_store + .language_servers + .iter() + .filter_map(|(server_id, state)| { + state + .name + .as_ref() + .is_some_and(|name| name.0 == "the-language-server") + .then_some(*server_id) + }) + .collect::>(); + assert_eq!(matching_server_ids, vec![first_server_id]); + }); + + project_a.update(cx_a, |project, cx| { + project.restart_language_servers_for_buffers(vec![buffer], HashSet::default(), cx); + }); + + let restarted_server = fake_language_servers.next().await.unwrap(); + let restarted_server_id = restarted_server.server.server_id(); + assert_ne!(restarted_server_id, first_server_id); + executor.run_until_parked(); + + project_a.read_with(cx_a, |project, cx| { + let statuses = project.language_server_statuses(cx).collect::>(); + assert_eq!( + statuses.len(), + 1, + "restarting a remote language server should replace the previous status entry" + ); + assert_eq!( + statuses[0].0, restarted_server_id, + "restarting a remote language server should publish the replacement server id" + ); + assert_ne!( + statuses[0].0, first_server_id, + "restarting a remote language server should remove the previous server id" + ); + assert_eq!(statuses[0].1.name.0, "the-language-server"); + }); + cx_a.read_global::(|global, cx| { + let log_store = global.0.read(cx); + let matching_server_ids = log_store + .language_servers + .iter() + .filter_map(|(server_id, state)| { + state + .name + .as_ref() + .is_some_and(|name| name.0 == "the-language-server") + .then_some(*server_id) + }) + .collect::>(); + assert_eq!( + matching_server_ids, + vec![restarted_server_id], + "restarting a remote language server should replace the old log store entry" + ); + assert!( + !log_store.language_servers.contains_key(&first_server_id), + "restarting a remote language server should remove the previous log store entry" + ); + }); +} + #[gpui::test] async fn test_remote_server_debugger( cx_a: &mut TestAppContext, diff --git a/crates/component_preview/src/component_preview.rs b/crates/component_preview/src/component_preview.rs index 1409105b12e05d..73ad50d6a5bdc1 100644 --- a/crates/component_preview/src/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -520,7 +520,7 @@ impl ComponentPreview { } }), ) - .flex_grow() + .flex_grow_1() .with_sizing_behavior(gpui::ListSizingBehavior::Auto) .into_any_element() }, diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 4f39ae49db9d16..cdb5b8122a39f8 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -1574,7 +1574,7 @@ impl Render for VariableList { .with_horizontal_sizing_behavior(gpui::ListHorizontalSizingBehavior::Unconstrained) .gap_1_5() .size_full() - .flex_grow(), + .flex_grow_1(), ) .children(self.open_context_menu.as_ref().map(|(menu, position, _)| { deferred( diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index e1068e9c3be4c6..b86b691546d737 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -40,29 +40,7 @@ impl DiagnosticRenderer { let mut markdown = Self::markdown(&entry.diagnostic); if entry.diagnostic.is_primary { let diagnostic = &primary.diagnostic; - if diagnostic.source.is_some() || diagnostic.code.is_some() { - markdown.push_str(" ("); - } - if let Some(source) = diagnostic.source.as_ref() { - markdown.push_str(&Markdown::escape(source)); - } - if diagnostic.source.is_some() && diagnostic.code.is_some() { - markdown.push(' '); - } - if let Some(code) = diagnostic.code.as_ref() { - if let Some(description) = diagnostic.code_description.as_ref() { - markdown.push('['); - markdown.push_str(&Markdown::escape(&code.to_string())); - markdown.push_str("]("); - markdown.push_str(&Markdown::escape(description.as_ref())); - markdown.push(')'); - } else { - markdown.push_str(&Markdown::escape(&code.to_string())); - } - } - if diagnostic.source.is_some() || diagnostic.code.is_some() { - markdown.push(')'); - } + append_source_and_code(&mut markdown, diagnostic); for (ix, entry) in diagnostic_group.iter().enumerate() { if entry.range.start.row.abs_diff(primary.range.start.row) >= 5 { @@ -84,6 +62,8 @@ impl DiagnosticRenderer { }), }); } else { + append_source_and_code(&mut markdown, entry.diagnostic); + if entry.range.start.row.abs_diff(primary.range.start.row) >= 5 { markdown.push_str(&format!( " ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))" @@ -116,6 +96,31 @@ impl DiagnosticRenderer { } } +fn append_source_and_code(markdown: &mut String, diagnostic: &Diagnostic) { + if diagnostic.source.is_none() && diagnostic.code.is_none() { + return; + } + markdown.push_str(" ("); + if let Some(source) = diagnostic.source.as_ref() { + markdown.push_str(&Markdown::escape(source)); + } + if diagnostic.source.is_some() && diagnostic.code.is_some() { + markdown.push(' '); + } + if let Some(code) = diagnostic.code.as_ref() { + if let Some(description) = diagnostic.code_description.as_ref() { + markdown.push('['); + markdown.push_str(&Markdown::escape(&code.to_string())); + markdown.push_str("]("); + markdown.push_str(&Markdown::escape(description.as_ref())); + markdown.push(')'); + } else { + markdown.push_str(&Markdown::escape(&code.to_string())); + } + } + markdown.push(')'); +} + impl editor::DiagnosticRenderer for DiagnosticRenderer { fn render_group( &self, diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index cd3164d2eeafd4..1d1a423cc828ad 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -712,14 +712,16 @@ impl EditPredictionButton { match language_state.clone() { Some((language, false)) => { - menu = menu.item( - entry - .disabled(true) - .documentation_aside(DocumentationSide::Left, move |_cx| { - Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name())) - .into_any_element() - }) - ); + menu = menu.item(entry.disabled(true).documentation_aside( + DocumentationSide::Left, + move |_cx| { + Label::new(format!( + "Edit predictions are disabled for {}", + language.name() + )) + .into_any_element() + }, + )); } Some(_) | None => menu = menu.item(entry), } diff --git a/crates/editor/benches/display_map.rs b/crates/editor/benches/display_map.rs index 148c7bd4ed2abf..c48f0c50b727f5 100644 --- a/crates/editor/benches/display_map.rs +++ b/crates/editor/benches/display_map.rs @@ -1,10 +1,12 @@ -use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; -use editor::MultiBuffer; -use gpui::TestDispatcher; +use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use editor::{MultiBuffer, display_map::*}; +use gpui::{AppContext as _, HighlightStyle, Hsla, TestDispatcher, font, px}; use itertools::Itertools; use multi_buffer::MultiBufferOffset; +use project::project_settings::DiagnosticSeverity; use rand::{Rng, SeedableRng, rngs::StdRng}; -use std::num::NonZeroU32; +use settings::SettingsStore; +use std::{num::NonZeroU32, time::Duration}; use text::Bias; use util::RandomCharIter; @@ -101,5 +103,112 @@ fn to_fold_point_benchmark(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, to_tab_point_benchmark, to_fold_point_benchmark); +fn create_highlight_endpoints_benchmark(c: &mut Criterion) { + const LINE_COUNT: usize = 20_000; + const LINE_VIEW_PORT_COUNT: usize = 100; + const HIGHLIGHTS_PER_LINE: usize = 4; + + let dispatcher = TestDispatcher::new(1); + let mut cx = gpui::TestAppContext::build(dispatcher, None); + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + editor::init(cx); + }); + + let mut text = String::new(); + let mut highlight_ranges = Vec::with_capacity(LINE_COUNT * HIGHLIGHTS_PER_LINE); + for line in 0..LINE_COUNT { + text.push_str("fn item_"); + text.push_str(&format!("{line:05}")); + text.push_str("() { "); + + let start = text.len(); + text.push_str("alpha_highlight"); + highlight_ranges.push(MultiBufferOffset(start)..MultiBufferOffset(text.len())); + + text.push_str(" + "); + let start = text.len(); + text.push_str("beta_highlight"); + highlight_ranges.push(MultiBufferOffset(start)..MultiBufferOffset(text.len())); + + text.push_str(" + "); + let start = text.len(); + text.push_str("gamma_highlight"); + highlight_ranges.push(MultiBufferOffset(start)..MultiBufferOffset(text.len())); + + text.push_str(" + "); + let start = text.len(); + text.push_str("delta_highlight"); + highlight_ranges.push(MultiBufferOffset(start)..MultiBufferOffset(text.len())); + + text.push_str("; }\n"); + } + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + let buffer_snapshot = cx.read(|cx| buffer.read(cx).snapshot(cx)); + let highlight_ranges = highlight_ranges + .into_iter() + .map(|range| { + buffer_snapshot.anchor_before(range.start)..buffer_snapshot.anchor_before(range.end) + }) + .collect(); + + let map = cx.new(|cx| { + DisplayMap::new( + buffer, + font("Courier"), + px(16.0), + None, + 1, + 1, + FoldPlaceholder::default(), + DiagnosticSeverity::Warning, + cx, + ) + }); + cx.update(|cx| { + map.update(cx, |map, cx| { + map.highlight_text( + HighlightKey::Editor, + highlight_ranges, + HighlightStyle { + color: Some(Hsla::blue()), + ..Default::default() + }, + false, + cx, + ); + }); + }); + let snapshot = cx.update(|cx| map.update(cx, |map, cx| map.snapshot(cx))); + + let mut group = c.benchmark_group("Create highlight endpoints"); + group.sample_size(10); + group.measurement_time(Duration::from_secs(10)); + group.bench_with_input( + BenchmarkId::new("text_highlights", LINE_VIEW_PORT_COUNT), + &snapshot, + |bench, snapshot| { + bench.iter(|| { + black_box(snapshot.chunks( + DisplayRow(400)..DisplayRow(400 + LINE_VIEW_PORT_COUNT as u32), + language::LanguageAwareStyling { + tree_sitter: false, + diagnostics: false, + }, + Default::default(), + )); + }); + }, + ); + group.finish(); +} + +criterion_group!( + benches, + to_tab_point_benchmark, + to_fold_point_benchmark, + create_highlight_endpoints_benchmark +); criterion_main!(benches); diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ea824980b1da54..bb5d804d5eadb6 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -929,7 +929,9 @@ actions!( /// Saves the current location to navigation history. SaveLocation, /// Flash navigation - highlights all occurrences of character 'a' with overlay hints. - Flash + Flash, + /// Toggles breadcrumbs display. + ToggleBreadcrumb, ] ); diff --git a/crates/editor/src/config.rs b/crates/editor/src/config.rs index 02256fe87df942..9b5df0b86713b2 100644 --- a/crates/editor/src/config.rs +++ b/crates/editor/src/config.rs @@ -138,6 +138,31 @@ impl Editor { } } + pub fn breadcrumbs_visible(&self) -> bool { + self.breadcrumbs_visibility.visible() + } + + fn set_breadcrumbs_visibility( + &mut self, + breadcrumbs_visibility: BreadcrumbsVisibility, + cx: &mut Context, + ) { + if self.breadcrumbs_visibility != breadcrumbs_visibility { + self.breadcrumbs_visibility = breadcrumbs_visibility; + cx.emit(EditorEvent::BreadcrumbsChanged); + cx.notify(); + } + } + + pub fn toggle_breadcrumb( + &mut self, + _: &ToggleBreadcrumb, + _: &mut Window, + cx: &mut Context, + ) { + self.set_breadcrumbs_visibility(self.breadcrumbs_visibility.toggle_visibility(), cx); + } + pub fn disable_scrollbars_and_minimap(&mut self, window: &mut Window, cx: &mut Context) { self.set_show_scrollbars(false, cx); self.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); diff --git a/crates/editor/src/display_map/custom_highlights.rs b/crates/editor/src/display_map/custom_highlights.rs index 6e93e562172dec..8ca43fe3cd2215 100644 --- a/crates/editor/src/display_map/custom_highlights.rs +++ b/crates/editor/src/display_map/custom_highlights.rs @@ -1,13 +1,8 @@ use collections::BTreeMap; use gpui::HighlightStyle; use language::{Chunk, LanguageAwareStyling}; -use multi_buffer::{MultiBufferChunks, MultiBufferOffset, MultiBufferSnapshot, ToOffset as _}; -use std::{ - cmp, - iter::{self, Peekable}, - ops::Range, - vec, -}; +use multi_buffer::{MultiBufferChunks, MultiBufferOffset, MultiBufferSnapshot}; +use std::{cmp, ops::Range}; use crate::display_map::{HighlightKey, SemanticTokensHighlights, TextHighlights}; @@ -17,7 +12,7 @@ pub struct CustomHighlightsChunks<'a> { offset: MultiBufferOffset, multibuffer_snapshot: &'a MultiBufferSnapshot, - highlight_endpoints: Peekable>, + highlight_endpoints: Vec, active_highlights: BTreeMap, text_highlights: Option<&'a TextHighlights>, semantic_token_highlights: Option<&'a SemanticTokensHighlights>, @@ -39,17 +34,20 @@ impl<'a> CustomHighlightsChunks<'a> { semantic_token_highlights: Option<&'a SemanticTokensHighlights>, multibuffer_snapshot: &'a MultiBufferSnapshot, ) -> Self { + let mut highlight_endpoints = Vec::new(); + create_highlight_endpoints( + &range, + text_highlights, + semantic_token_highlights, + multibuffer_snapshot, + &mut highlight_endpoints, + ); Self { buffer_chunks: multibuffer_snapshot.chunks(range.clone(), language_aware), buffer_chunk: None, offset: range.start, text_highlights, - highlight_endpoints: create_highlight_endpoints( - &range, - text_highlights, - semantic_token_highlights, - multibuffer_snapshot, - ), + highlight_endpoints, active_highlights: Default::default(), multibuffer_snapshot, semantic_token_highlights, @@ -58,11 +56,12 @@ impl<'a> CustomHighlightsChunks<'a> { #[ztracing::instrument(skip_all)] pub fn seek(&mut self, new_range: Range) { - self.highlight_endpoints = create_highlight_endpoints( + create_highlight_endpoints( &new_range, self.text_highlights, self.semantic_token_highlights, self.multibuffer_snapshot, + &mut self.highlight_endpoints, ); self.offset = new_range.start; self.buffer_chunks.seek(new_range); @@ -76,11 +75,14 @@ fn create_highlight_endpoints( text_highlights: Option<&TextHighlights>, semantic_token_highlights: Option<&SemanticTokensHighlights>, buffer: &MultiBufferSnapshot, -) -> iter::Peekable> { - let mut highlight_endpoints = Vec::new(); + highlight_endpoints: &mut Vec, +) { + highlight_endpoints.clear(); if let Some(text_highlights) = text_highlights { let start = buffer.anchor_after(range.start); let end = buffer.anchor_after(range.end); + let mut text_highlights_scratch = Vec::new(); + for (&tag, text_highlights) in text_highlights.iter() { let style = text_highlights.0; let ranges = &text_highlights.1; @@ -94,30 +96,45 @@ fn create_highlight_endpoints( }) .unwrap_or_else(|i| i); - highlight_endpoints.reserve(2 * end_ix); - - for range in &ranges[start_ix..][..end_ix] { - let start = range.start.to_offset(buffer); - let end = range.end.to_offset(buffer); - if start == end { - continue; - } - highlight_endpoints.push(HighlightEndpoint { - offset: start, - tag, - style: Some(style), - }); - highlight_endpoints.push(HighlightEndpoint { - offset: end, - tag, - style: None, - }); - } + let ranges_ = &ranges[start_ix..][..end_ix]; + text_highlights_scratch.clear(); + text_highlights_scratch.reserve(ranges_.len()); + highlight_endpoints.reserve(2 * ranges_.len()); + + let mut iter = ranges_.iter(); + buffer.summaries_for_anchors_cb( + ranges_.iter().map(|r| &r.start), + |start: MultiBufferOffset| { + text_highlights_scratch.push((start, iter.next().unwrap().end)); + }, + ); + text_highlights_scratch.sort_by(|a, b| a.1.cmp(&b.1, buffer)); + let mut iter = text_highlights_scratch.iter(); + buffer.summaries_for_anchors_cb( + text_highlights_scratch.iter().map(|(_, end)| end), + |end: MultiBufferOffset| { + let start = iter.next().unwrap().0; + if start == end { + return; + } + highlight_endpoints.push(HighlightEndpoint { + offset: start, + tag, + style: Some(style), + }); + highlight_endpoints.push(HighlightEndpoint { + offset: end, + tag, + style: None, + }); + }, + ); } } if let Some(semantic_token_highlights) = semantic_token_highlights { let start = buffer.anchor_after(range.start); let end = buffer.anchor_after(range.end); + let mut semantic_highlights_scratch = Vec::new(); for buffer_id in buffer.buffer_ids_for_range(range.clone()) { let Some((semantic_token_highlights, interner)) = semantic_token_highlights.get(&buffer_id) @@ -133,31 +150,54 @@ fn create_highlight_endpoints( .then(cmp::Ordering::Less) }) .unwrap_or_else(|i| i); - for token in &semantic_token_highlights[start_ix..] { - if token.range.start.cmp(&end, buffer).is_ge() { - break; - } + let end_ix = semantic_token_highlights[start_ix..] + .binary_search_by(|probe| { + probe + .range + .start + .cmp(&end, buffer) + .then(cmp::Ordering::Greater) + }) + .unwrap_or_else(|i| i); - let start = token.range.start.to_offset(buffer); - let end = token.range.end.to_offset(buffer); - if start == end { - continue; - } - highlight_endpoints.push(HighlightEndpoint { - offset: start, - tag: HighlightKey::SemanticToken, - style: Some(interner[token.style]), - }); - highlight_endpoints.push(HighlightEndpoint { - offset: end, - tag: HighlightKey::SemanticToken, - style: None, - }); - } + let ranges_ = &semantic_token_highlights[start_ix..][..end_ix]; + semantic_highlights_scratch.clear(); + semantic_highlights_scratch.reserve(ranges_.len()); + highlight_endpoints.reserve(2 * ranges_.len()); + + let mut iter = ranges_.iter(); + buffer.summaries_for_anchors_cb( + ranges_.iter().map(|token| &token.range.start), + |start: MultiBufferOffset| { + semantic_highlights_scratch.push((start, iter.next().unwrap())); + }, + ); + semantic_highlights_scratch.sort_by(|a, b| a.1.range.end.cmp(&b.1.range.end, buffer)); + let mut iter = semantic_highlights_scratch.iter(); + buffer.summaries_for_anchors_cb( + semantic_highlights_scratch + .iter() + .map(|(_, token)| &token.range.end), + |end: MultiBufferOffset| { + let (start, token) = iter.next().unwrap(); + if *start == end { + return; + } + highlight_endpoints.push(HighlightEndpoint { + offset: *start, + tag: HighlightKey::SemanticToken, + style: Some(interner[token.style]), + }); + highlight_endpoints.push(HighlightEndpoint { + offset: end, + tag: HighlightKey::SemanticToken, + style: None, + }); + }, + ); } } - highlight_endpoints.sort(); - highlight_endpoints.into_iter().peekable() + highlight_endpoints.sort_by(|a, b| a.cmp(b).reverse()); } impl<'a> Iterator for CustomHighlightsChunks<'a> { @@ -166,14 +206,14 @@ impl<'a> Iterator for CustomHighlightsChunks<'a> { #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { let mut next_highlight_endpoint = MultiBufferOffset(usize::MAX); - while let Some(endpoint) = self.highlight_endpoints.peek().copied() { + while let Some(endpoint) = self.highlight_endpoints.last().copied() { if endpoint.offset <= self.offset { if let Some(style) = endpoint.style { self.active_highlights.insert(endpoint.tag, style); } else { self.active_highlights.remove(&endpoint.tag); } - self.highlight_endpoints.next(); + self.highlight_endpoints.pop(); } else { next_highlight_endpoint = endpoint.offset; break; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a9ee7564d08062..6d656f45d77134 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -763,6 +763,40 @@ impl MinimapVisibility { } } +#[derive(Clone, Copy, PartialEq, Eq)] +struct BreadcrumbsVisibility { + setting_configuration: bool, + toggle_override: bool, +} + +impl BreadcrumbsVisibility { + fn from_settings(cx: &App) -> Self { + Self::new(EditorSettings::get_global(cx).toolbar.breadcrumbs) + } + + fn new(setting_configuration: bool) -> Self { + Self { + setting_configuration, + toggle_override: false, + } + } + + fn settings_visibility(&self) -> bool { + self.setting_configuration + } + + fn visible(&self) -> bool { + self.setting_configuration ^ self.toggle_override + } + + fn toggle_visibility(&self) -> Self { + Self { + setting_configuration: self.setting_configuration, + toggle_override: !self.toggle_override, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BufferSerialization { All, @@ -990,7 +1024,7 @@ pub struct Editor { hovered_cursors: HashMap>, pub show_local_selections: bool, mode: EditorMode, - show_breadcrumbs: bool, + breadcrumbs_visibility: BreadcrumbsVisibility, show_gutter: bool, show_scrollbars: ScrollbarAxes, minimap_visibility: MinimapVisibility, @@ -2200,7 +2234,7 @@ impl Editor { }, minimap_visibility: MinimapVisibility::for_mode(&mode, cx), offset_content: !matches!(mode, EditorMode::SingleLine), - show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs, + breadcrumbs_visibility: BreadcrumbsVisibility::from_settings(cx), show_gutter: full_mode, show_line_numbers: (!full_mode).then_some(false), use_relative_line_numbers: None, @@ -10835,12 +10869,17 @@ impl Editor { self.refresh_inline_values(cx); let old_cursor_shape = self.cursor_shape; - let old_show_breadcrumbs = self.show_breadcrumbs; + let old_breadcrumbs_visible = self.breadcrumbs_visible(); { let editor_settings = EditorSettings::get_global(cx); self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin; - self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs; + if self.breadcrumbs_visibility.settings_visibility() + != editor_settings.toolbar.breadcrumbs + { + self.breadcrumbs_visibility = + BreadcrumbsVisibility::new(editor_settings.toolbar.breadcrumbs); + } self.cursor_shape = editor_settings.cursor_shape.unwrap_or_default(); } @@ -10848,7 +10887,7 @@ impl Editor { cx.emit(EditorEvent::CursorShapeChanged); } - if old_show_breadcrumbs != self.show_breadcrumbs { + if old_breadcrumbs_visible != self.breadcrumbs_visible() { cx.emit(EditorEvent::BreadcrumbsChanged); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 3eca954d6de893..c90e54d0c6c2b9 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -70,7 +70,7 @@ use util::{ }; use workspace::{ CloseActiveItem, CloseAllItems, CloseOtherItems, MultiWorkspace, NavigationEntry, OpenOptions, - ViewId, + ToolbarItemLocation, ViewId, item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions}, register_project_item, }; @@ -917,6 +917,49 @@ fn test_clone(cx: &mut TestAppContext) { ); } +#[gpui::test] +fn test_toggle_breadcrumb_does_not_change_settings(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.toolbar.get_or_insert_default().breadcrumbs = Some(true); + }); + + let editor = cx.add_window(|window, cx| { + let buffer = MultiBuffer::build_simple("hello", cx); + build_editor(buffer, window, cx) + }); + + _ = editor.update(cx, |editor, window, cx| { + assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); + assert_eq!( + editor.breadcrumb_location(cx), + ToolbarItemLocation::PrimaryLeft + ); + + editor.toggle_breadcrumb(&ToggleBreadcrumb, window, cx); + assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); + assert_eq!(editor.breadcrumb_location(cx), ToolbarItemLocation::Hidden); + }); + + // Changing unrelated settings should not affect breadcrumbs visibility. + update_test_editor_settings(cx, &|settings| { + settings.vertical_scroll_margin = Some(4.0); + }); + cx.run_until_parked(); + + _ = editor.update(cx, |editor, window, cx| { + assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); + assert_eq!(editor.breadcrumb_location(cx), ToolbarItemLocation::Hidden); + + editor.toggle_breadcrumb(&ToggleBreadcrumb, window, cx); + assert!(EditorSettings::get_global(cx).toolbar.breadcrumbs); + assert_eq!( + editor.breadcrumb_location(cx), + ToolbarItemLocation::PrimaryLeft + ); + }); +} + #[gpui::test] async fn test_navigation_history(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index a778a6728f7693..0e44c8cfcf6f86 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -474,6 +474,7 @@ impl EditorElement { register_action(editor, window, Editor::open_excerpts_in_split); register_action(editor, window, Editor::toggle_soft_wrap); register_action(editor, window, Editor::toggle_tab_bar); + register_action(editor, window, Editor::toggle_breadcrumb); register_action(editor, window, Editor::toggle_line_numbers); register_action(editor, window, Editor::toggle_relative_line_numbers); register_action(editor, window, Editor::toggle_indent_guides); @@ -7012,7 +7013,7 @@ pub fn render_breadcrumb_text( ) -> gpui::AnyElement { const MAX_SEGMENTS: usize = 12; - let element = h_flex().flex_grow().text_ui(cx); + let element = h_flex().flex_grow_1().text_ui(cx); let prefix_end_ix = cmp::min(segments.len(), MAX_SEGMENTS / 2); let suffix_start_ix = cmp::max( @@ -8354,12 +8355,14 @@ impl Element for EditorElement { // Calculate how much of the editor is clipped by parent containers (e.g., List). // This allows us to only render lines that are actually visible, which is - // critical for performance when large AutoHeight editors are inside Lists. + // critical for performance when large content-sized editors are inside Lists. let visible_bounds = window.content_mask().bounds; - let clipped_top = (visible_bounds.origin.y - bounds.origin.y).max(px(0.)); + let visible_top = bounds.top().max(visible_bounds.top()); + let visible_bottom = bounds.bottom().min(visible_bounds.bottom()); + let clipped_top = (visible_top - bounds.top()).max(px(0.)); + let visible_height = (visible_bottom - visible_top).max(px(0.)); let clipped_top_in_lines = f64::from(clipped_top / line_height); - let visible_height_in_lines = - f64::from(visible_bounds.size.height / line_height); + let visible_height_in_lines = f64::from(visible_height / line_height); // The max scroll position for the top of the window let scroll_beyond_last_line = self.editor.read(cx).scroll_beyond_last_line(cx); diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 9ba5c4aa19cd66..a78c37e66d1aad 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -522,14 +522,25 @@ impl GitBlame { let id = buffer.read(cx).remote_id(); let snapshot = buffer.read(cx).snapshot(); let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); - let remote_url = project + + let repository = project .read(cx) .git_store() .read(cx) - .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) + .repository_and_path_for_buffer_id(id, cx); + + let remote_url = repository + .as_ref() .and_then(|(repo, _)| repo.read(cx).default_remote_url()); - let blame_buffer = project - .update(cx, |project, cx| project.blame_buffer(&buffer, None, cx)); + + let blame_buffer = if repository.is_some() { + project.update(cx, |project, cx| { + project.blame_buffer(&buffer, None, cx) + }) + } else { + Task::ready(Ok(None)) + }; + Ok(async move { (id, snapshot, buffer_edits, blame_buffer.await, remote_url) }) @@ -696,7 +707,7 @@ mod tests { use rand::prelude::*; use serde_json::json; use settings::SettingsStore; - use std::{cmp, env, ops::Range, path::Path}; + use std::{cmp, env, ops::Range, path::Path, sync::Mutex}; use text::BufferId; use unindent::Unindent as _; use util::{RandomCharIter, path}; @@ -811,6 +822,73 @@ mod tests { }); } + #[gpui::test] + async fn test_blame_ignores_buffers_outside_git_repositories(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/not-a-repo", + json!({ + "foo": "bar", + }), + ) + .await; + + let project = Project::test(fs, ["/not-a-repo".as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/not-a-repo/foo", cx) + }) + .await + .unwrap(); + + let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); + + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + let events = Arc::new(Mutex::new(Vec::new())); + + let _subscription = project.update(cx, |_, cx| { + cx.subscribe(&project, { + let events = events.clone(); + + move |_, _, event: &project::Event, _| { + events + .lock() + .expect("events mutex poisoned") + .push(event.clone()); + } + }) + }); + + let blame = cx.new(|cx| GitBlame::new(buffer.clone(), project.clone(), true, true, cx)); + + cx.executor().run_until_parked(); + + assert!(events.lock().expect("events mutex poisoned").is_empty()); + + blame.update(cx, |blame, cx| { + assert_eq!( + blame + .blame_for_rows( + &(0..1) + .map(|row| RowInfo { + buffer_row: Some(row), + buffer_id: Some(buffer_id), + ..Default::default() + }) + .collect::>(), + cx + ) + .collect::>(), + vec![None] + ); + }); + } + #[gpui::test] async fn test_blame_for_rows(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index ae1db2e7ad0821..2f682a6cd56d0e 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1035,7 +1035,7 @@ impl Item for Editor { } fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { - if self.show_breadcrumbs && self.buffer().read(cx).is_singleton() { + if self.breadcrumbs_visible() && self.buffer().read(cx).is_singleton() { ToolbarItemLocation::PrimaryLeft } else { ToolbarItemLocation::Hidden diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs index 0b3e150c8be472..c117aa97a307e6 100644 --- a/crates/editor/src/navigation.rs +++ b/crates/editor/src/navigation.rs @@ -2085,6 +2085,9 @@ impl Editor { })) } + let final_snapshot = multibuffer.snapshot(cx); + ranges.sort_by(|a, b| a.start.cmp(&b.start, &final_snapshot)); + multibuffer.with_title(title) }); let existing = workspace.active_pane().update(cx, |pane, cx| { diff --git a/crates/editor/src/split_editor_view.rs b/crates/editor/src/split_editor_view.rs index 02388df9a7516e..468a2bf055cce8 100644 --- a/crates/editor/src/split_editor_view.rs +++ b/crates/editor/src/split_editor_view.rs @@ -210,7 +210,7 @@ impl RenderOnce for SplitEditorView { .child( div() .id("split-editor-left") - .flex_shrink() + .flex_shrink_1() .min_w_0() .h_full() .flex_basis(DefiniteLength::Fraction(left_ratio)) @@ -221,7 +221,7 @@ impl RenderOnce for SplitEditorView { .child( div() .id("split-editor-right") - .flex_shrink() + .flex_shrink_1() .min_w_0() .h_full() .flex_basis(DefiniteLength::Fraction(right_ratio)) diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 04c6876b3603b5..a08036059dc255 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -1772,7 +1772,7 @@ impl Render for ExtensionsPage { let scroll_handle = &self.list; this.child( uniform_list("entries", count, cx.processor(Self::render_extensions)) - .flex_grow() + .flex_grow_1() .pb_4() .track_scroll(scroll_handle), ) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 3a29b8da98a68c..6c783f3f01a2ca 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -62,7 +62,10 @@ actions!( /// Toggles the file filter menu. ToggleFilterMenu, /// Toggles the split direction menu. - ToggleSplitMenu + ToggleSplitMenu, + /// Opens the selected file in the editor without dismissing the file finder, + /// so additional files can be opened in sequence. + OpenWithoutDismiss ] ); @@ -348,6 +351,17 @@ impl FileFinder { }) } + fn open_without_dismiss( + &mut self, + _: &OpenWithoutDismiss, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker.delegate.confirm_without_dismiss(window, cx); + }); + } + pub fn modal_max_width(width_setting: FileFinderWidth, window: &mut Window) -> Pixels { let window_width = window.viewport_size().width; let small_width = rems(34.).to_pixels(window.rem_size()); @@ -389,6 +403,7 @@ impl Render for FileFinder { .on_action(cx.listener(Self::go_to_file_split_right)) .on_action(cx.listener(Self::go_to_file_split_up)) .on_action(cx.listener(Self::go_to_file_split_down)) + .on_action(cx.listener(Self::open_without_dismiss)) .child(self.picker.clone()) } } @@ -1457,6 +1472,164 @@ impl FileFinderDelegate { } key_context } + + /// Shared file-opening logic for both `confirm` and `confirm_without_dismiss`. + /// + /// When `dismiss_after_open` is true this behaves like a normal confirm: the file is focused + /// and the finder is dismissed. When false the finder stays open so the user can continue + /// opening more files. + fn open_selected_file( + &mut self, + secondary: bool, + dismiss_after_open: bool, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(m) = self.matches.get(self.selected_index()).cloned() else { + return; + }; + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + // Channel matches always dismiss the finder. + if let Match::Channel { channel_id, .. } = &m { + let channel_id = channel_id.0; + let finder = self.file_finder.clone(); + window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx); + finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err(); + return; + } + + // Focus the new item only when dismissing — this avoids stealing focus from the modal. + // Always activate (make the tab current) so every opened file is visually reflected. + let focus_item = dismiss_after_open; + + let open_task = workspace.update(cx, |workspace, cx| { + let split_or_open = |workspace: &mut Workspace, + project_path, + window: &mut Window, + cx: &mut Context| { + let allow_preview = + PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder; + if secondary { + workspace.split_path_preview(project_path, allow_preview, None, window, cx) + } else { + workspace.open_path_preview( + project_path, + None, + focus_item, + allow_preview, + true, + window, + cx, + ) + } + }; + + match &m { + Match::CreateNew(project_path) => { + if secondary { + workspace.split_path_preview(project_path.clone(), false, None, window, cx) + } else { + workspace.open_path_preview( + project_path.clone(), + None, + focus_item, + false, + true, + window, + cx, + ) + } + } + Match::History { path, .. } => { + let worktree_id = path.project.worktree_id; + if workspace + .project() + .read(cx) + .worktree_for_id(worktree_id, cx) + .is_some() + { + split_or_open( + workspace, + ProjectPath { + worktree_id, + path: Arc::clone(&path.project.path), + }, + window, + cx, + ) + } else if secondary { + workspace.split_abs_path(path.absolute.clone(), false, window, cx) + } else { + workspace.open_abs_path( + path.absolute.clone(), + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ) + } + } + Match::Search(path_match) => split_or_open( + workspace, + ProjectPath { + worktree_id: WorktreeId::from_usize(path_match.0.worktree_id), + path: path_match.0.path.clone(), + }, + window, + cx, + ), + Match::Channel { .. } => unreachable!("handled above"), + } + }); + + let selection_query = self.latest_search_query.clone(); + let finder = self.file_finder.clone(); + let workspace = self.workspace.clone(); + + cx.spawn_in(window, async move |_, mut cx| { + let item = open_task + .await + .notify_workspace_async_err(workspace, &mut cx)?; + if let Some(active_editor) = item.downcast::() { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + let Some(buffer) = editor.buffer().read(cx).as_singleton() else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let Some(selection_query) = selection_query.as_ref() else { + return; + }; + let Some(selection_range) = + selection_query.selection_range(&buffer_snapshot) + else { + return; + }; + editor.go_to_singleton_buffer_range(selection_range, window, cx); + }) + .log_err(); + } + if dismiss_after_open { + finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?; + } + Some(()) + }) + .detach(); + } + + fn confirm_without_dismiss( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) { + self.open_selected_file(false, false, window, cx); + } } fn full_path_budget( @@ -1611,149 +1784,7 @@ impl PickerDelegate for FileFinderDelegate { window: &mut Window, cx: &mut Context>, ) { - if let Some(m) = self.matches.get(self.selected_index()) - && let Some(workspace) = self.workspace.upgrade() - { - // Channel matches are handled separately since they dispatch an action - // rather than directly opening a file path. - if let Match::Channel { channel_id, .. } = m { - let channel_id = channel_id.0; - let finder = self.file_finder.clone(); - window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx); - finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err(); - return; - } - - let open_task = workspace.update(cx, |workspace, cx| { - let split_or_open = - |workspace: &mut Workspace, - project_path, - window: &mut Window, - cx: &mut Context| { - let allow_preview = - PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder; - if secondary { - workspace.split_path_preview( - project_path, - allow_preview, - None, - window, - cx, - ) - } else { - workspace.open_path_preview( - project_path, - None, - true, - allow_preview, - true, - window, - cx, - ) - } - }; - match &m { - Match::CreateNew(project_path) => { - // Create a new file with the given filename - if secondary { - workspace.split_path_preview( - project_path.clone(), - false, - None, - window, - cx, - ) - } else { - workspace.open_path_preview( - project_path.clone(), - None, - true, - false, - true, - window, - cx, - ) - } - } - - Match::History { path, .. } => { - let worktree_id = path.project.worktree_id; - if workspace - .project() - .read(cx) - .worktree_for_id(worktree_id, cx) - .is_some() - { - split_or_open( - workspace, - ProjectPath { - worktree_id, - path: Arc::clone(&path.project.path), - }, - window, - cx, - ) - } else if secondary { - workspace.split_abs_path(path.absolute.clone(), false, window, cx) - } else { - workspace.open_abs_path( - path.absolute.clone(), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - } - } - Match::Search(m) => split_or_open( - workspace, - ProjectPath { - worktree_id: WorktreeId::from_usize(m.0.worktree_id), - path: m.0.path.clone(), - }, - window, - cx, - ), - Match::Channel { .. } => unreachable!("handled above"), - } - }); - - let selection_query = self.latest_search_query.clone(); - let finder = self.file_finder.clone(); - let workspace = self.workspace.clone(); - - cx.spawn_in(window, async move |_, mut cx| { - let item = open_task - .await - .notify_workspace_async_err(workspace, &mut cx)?; - if let Some(active_editor) = item.downcast::() { - active_editor - .downgrade() - .update_in(cx, |editor, window, cx| { - let Some(buffer) = editor.buffer().read(cx).as_singleton() else { - return; - }; - let buffer_snapshot = buffer.read(cx).snapshot(); - let Some(selection_query) = selection_query.as_ref() else { - return; - }; - let Some(selection_range) = - selection_query.selection_range(&buffer_snapshot) - else { - return; - }; - editor.go_to_singleton_buffer_range(selection_range, window, cx); - }) - .log_err(); - } - finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?; - - Some(()) - }) - .detach(); - } + self.open_selected_file(secondary, true, window, cx); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { @@ -1979,6 +2010,20 @@ impl PickerDelegate for FileFinderDelegate { } }), ) + .child( + Button::new("open-without-dismiss", "Keep Open") + .key_binding( + KeyBinding::for_action_in( + &OpenWithoutDismiss, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(OpenWithoutDismiss.boxed_clone(), cx) + }), + ) .child( Button::new("open-selection", "Open") .key_binding( diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 50d3d9979867c9..a6480b37bf08cc 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -3913,6 +3913,189 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_open_without_dismiss_keeps_finder_open(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "file1.txt": "content1", + "file2.txt": "content2", + "file3.txt": "content3", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (picker, workspace, cx) = build_find_picker(project, cx); + + cx.simulate_input("file"); + cx.run_until_parked(); + picker.update(cx, |picker, _| { + assert!( + picker.delegate.matches.len() >= 3, + "Expected at least 3 matches for 'file', got {}", + picker.delegate.matches.len() + ); + }); + + cx.dispatch_action(OpenWithoutDismiss); + cx.run_until_parked(); + + // Finder must still be visible after opening a file without dismiss. + workspace.update(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_some(), + "File finder should remain open after OpenWithoutDismiss" + ); + }); + + // Exactly one file was opened in the pane. + cx.read(|cx| { + let items: Vec<_> = workspace.read(cx).active_pane().read(cx).items().collect(); + assert_eq!(items.len(), 1, "One file should be open in the pane"); + }); + + // The search query and results are preserved so the user can continue browsing. + picker.update(cx, |picker, _| { + assert!( + picker.delegate.matches.len() >= 3, + "Search results should remain unchanged after OpenWithoutDismiss" + ); + }); +} + +#[gpui::test] +async fn test_open_without_dismiss_opens_multiple_files(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "alpha.txt": "alpha", + "beta.txt": "beta", + "gamma.txt": "gamma", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (_picker, workspace, cx) = build_find_picker(project, cx); + + cx.simulate_input("a"); + cx.run_until_parked(); + + // Open the first match and stay in the finder. + cx.dispatch_action(OpenWithoutDismiss); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_some(), + "Finder should remain open after first OpenWithoutDismiss" + ); + }); + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!( + pane.items().count(), + 1, + "One file open after first OpenWithoutDismiss" + ); + }); + + // Navigate to the next result and open it too. + cx.dispatch_action(SelectNext); + cx.dispatch_action(OpenWithoutDismiss); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_some(), + "Finder should remain open after second OpenWithoutDismiss" + ); + }); + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!( + pane.items().count(), + 2, + "Two files open after second OpenWithoutDismiss" + ); + // The second opened file should now be the active tab. + let active_index = pane.active_item_index(); + assert_eq!(active_index, 1, "Second file should be the active tab"); + }); +} + +#[gpui::test] +async fn test_open_without_dismiss_then_confirm_closes_finder(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "first.txt": "first", + "second.txt": "second", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (picker, workspace, cx) = build_find_picker(project, cx); + + cx.simulate_input("t"); + cx.run_until_parked(); + picker.update(cx, |picker, _| { + assert!(picker.delegate.matches.len() >= 2); + }); + + // Open first file, keep finder open. + cx.dispatch_action(OpenWithoutDismiss); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + assert!(workspace.active_modal::(cx).is_some()); + }); + + // Navigate to the next match and confirm normally — this should close the finder. + cx.dispatch_action(SelectNext); + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_none(), + "Finder should be closed after regular Confirm" + ); + }); + + // Two files were opened in total, with the confirmed one now active. + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!(pane.items().count(), 2, "Two files should be open total"); + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + let title = active_editor.read(cx).title(cx); + assert!( + title == "second.txt" || title == "first.txt", + "Active editor should be one of the opened files, got: {title}" + ); + }); +} + async fn open_close_queried_buffer( input: &str, expected_matches: usize, diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 3395c9c0b16c8f..160f7f9d90847e 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -736,14 +736,20 @@ pub enum LogSource { } impl LogSource { - fn get_arg(&self) -> Result<&str> { + fn get_args(&self) -> Result> { match self { - LogSource::All => Ok("--all"), - LogSource::Branch(branch) => Ok(branch.as_str()), - LogSource::Sha(oid) => { - str::from_utf8(oid.as_bytes()).context("Failed to build str from sha") - } - LogSource::Path(_) => Ok("--follow"), + LogSource::All => Ok(vec![ + "--ignore-missing", // needed in case of unborn HEAD + "--branches", + "--remotes", + "--tags", + "HEAD", + ]), + LogSource::Branch(branch) => Ok(vec![branch.as_str()]), + LogSource::Sha(oid) => Ok(vec![ + str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")?, + ]), + LogSource::Path(path) => Ok(vec!["--follow", "--", path.as_unix_str()]), } } } @@ -2934,6 +2940,10 @@ impl GitRepository for RealGitRepository { } } + if git.run(&["rev-parse", "main"]).await.is_ok() { + return Ok(Some("main".into())); + } + if git.run(&["rev-parse", "master"]).await.is_ok() { return Ok(Some("master".into())); } @@ -3004,17 +3014,8 @@ impl GitRepository for RealGitRepository { let git = self.git_binary(); async move { - let mut git_log_command = vec![ - "log", - GRAPH_COMMIT_FORMAT, - log_order.as_arg(), - log_source.get_arg()?, - ]; - - if let LogSource::Path(path) = &log_source { - git_log_command.extend(["--", path.as_unix_str()]); - } - + let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()]; + git_log_command.extend(log_source.get_args()?); let mut command = git.build_command(&git_log_command); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -3084,7 +3085,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary(); async move { - let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?]; + let mut args = vec!["log", SEARCH_COMMIT_FORMAT]; args.push("--fixed-strings"); @@ -3095,10 +3096,7 @@ impl GitRepository for RealGitRepository { args.push("--grep"); args.push(search_args.query.as_str()); - if let LogSource::Path(path) = &log_source { - args.extend(["--", path.as_unix_str()]); - } - + args.extend(log_source.get_args()?); let mut command = git.build_command(&args); command.stdout(Stdio::piped()); command.stderr(Stdio::null()); @@ -4687,6 +4685,67 @@ mod tests { ); } + #[gpui::test] + async fn test_initial_graph_data_ref_set(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git2::Repository::init(repo_dir.path()).unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + let git = repo.git_binary(); + + let graph_commits = async || { + let (tx, rx) = smol::channel::unbounded(); + repo.initial_graph_data(LogSource::All, LogOrder::DateOrder, tx) + .await + .unwrap(); + let mut commits = std::collections::HashSet::new(); + while let Ok(chunk) = rx.try_recv() { + for commit in chunk { + commits.insert(commit.sha); + } + } + commits + }; + + smol::fs::write(repo_dir.path().join("file1"), "1") + .await + .unwrap(); + let branch_sha = repo.checkpoint().await.unwrap().commit_sha; + repo.update_ref("refs/heads/main".into(), branch_sha.to_string()) + .await + .unwrap(); + + smol::fs::write(repo_dir.path().join("file2"), "2") + .await + .unwrap(); + let hidden_sha = repo.checkpoint().await.unwrap().commit_sha; + repo.update_ref("refs/custom/hidden".into(), hidden_sha.to_string()) + .await + .unwrap(); + + let graph = graph_commits().await; + assert!(graph.contains(&branch_sha)); + assert!(!graph.contains(&hidden_sha)); + + git.build_command(&["update-ref", "--no-deref", "HEAD", &hidden_sha.to_string()]) + .output() + .await + .unwrap(); + + let graph = graph_commits().await; + assert!(graph.contains(&branch_sha)); + assert!(graph.contains(&hidden_sha)); + } + #[test] fn test_original_repo_path_from_common_dir() { // Normal repo: common_dir is /.git diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 11e4e2a59d0f7c..eed758ab923db3 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -122,7 +122,29 @@ pub fn select_popover( }) } -pub type SelectBranchCallback = Arc; +pub fn select_modal( + workspace: WeakEntity, + repository: Option>, + selected_branch: Option, + on_select: SelectBranchCallback, + window: &mut Window, + cx: &mut Context, +) -> BranchList { + let list = BranchList::new_select( + workspace, + repository, + BranchListStyle::Modal, + rems(34.), + selected_branch, + on_select, + window, + cx, + ); + list.focus_handle(cx).focus(window, cx); + list +} + +pub type SelectBranchCallback = Arc; pub fn create_embedded( workspace: WeakEntity, @@ -1288,7 +1310,7 @@ impl PickerDelegate for BranchListDelegate { if let BranchSelectionBehavior::Select { on_select, .. } = &self.branch_selection_behavior { - on_select(branch.clone(), cx); + on_select(branch.clone(), window, cx); cx.emit(DismissEvent); return; } @@ -1526,7 +1548,7 @@ impl PickerDelegate for BranchListDelegate { h_flex() .w_full() .gap_2p5() - .flex_grow() + .flex_grow_1() .child( Icon::new(entry_icon) .color(if is_checked_branch { diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 39866f42dce7e8..dce5a89dc8902b 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -420,11 +420,11 @@ impl CommitModal { .child( h_flex() .gap_1() - .flex_shrink() + .flex_shrink_1() .overflow_x_hidden() .child( h_flex() - .flex_shrink() + .flex_shrink_1() .overflow_x_hidden() .child(branch_picker), ) diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 42e89bb7ba1c5e..5e44b24cdae55e 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -571,6 +571,8 @@ impl CommitView { time_format::TimestampFormat::MediumAbsolute, ); + let avatar_size = rems_from_px(40.); + let avatar_size_px = avatar_size.to_pixels(window.rem_size()); let gutter_width = self.editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); let style = editor.style(cx); @@ -580,6 +582,9 @@ impl CommitView { .gutter_dimensions(font_id, font_size, style, window, cx) .full_width() }); + let avatar_min_side_padding = rems_from_px(10.).to_pixels(window.rem_size()); + let avatar_container_min = avatar_size_px + avatar_min_side_padding * 2.0; + let avatar_container_width = gutter_width.max(avatar_container_min); let clipboard_has_sha = cx .read_from_clipboard() @@ -603,9 +608,13 @@ impl CommitView { .border_color(cx.theme().colors().border_variant) .child( h_flex() - .child(h_flex().w(gutter_width).justify_center().child( - self.render_commit_avatar(&commit.sha, rems_from_px(40.), window, cx), - )) + .child( + h_flex() + .flex_none() + .w(avatar_container_width) + .justify_center() + .child(self.render_commit_avatar(&commit.sha, avatar_size, window, cx)), + ) .child( v_flex().child(Label::new(author_name)).child( h_flex() @@ -1113,7 +1122,7 @@ impl Render for CommitView { .bg(cx.theme().colors().editor_background) .child(self.render_header(window, cx)) .when(!self.editor.read(cx).is_empty(cx), |this| { - this.child(div().flex_grow().child(self.editor.clone())) + this.child(div().flex_grow_1().child(self.editor.clone())) }) } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 71419c8bd64970..5de6ec2a14529d 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -697,7 +697,7 @@ pub struct GitPanel { stash_entries: GitStash, active_tab: GitPanelTab, commit_history_scroll_handle: UniformListScrollHandle, - commit_history_shas: Vec, + commit_history_shas: Option>, focused_history_entry: Option, history_keyboard_nav: bool, _repo_subscriptions: Vec, @@ -894,7 +894,7 @@ impl GitPanel { stash_entries: Default::default(), active_tab: GitPanelTab::Changes, commit_history_scroll_handle: UniformListScrollHandle::new(), - commit_history_shas: Vec::new(), + commit_history_shas: None, focused_history_entry: None, history_keyboard_nav: false, _repo_subscriptions: Vec::new(), @@ -1745,17 +1745,14 @@ impl GitPanel { cx.spawn({ async move |this, cx| { let result = this - .update(cx, |this, cx| { - let task = active_repository.update(cx, |repo, cx| { + .update(cx, |_this, cx| { + active_repository.update(cx, |repo, cx| { if stage { repo.stage_all(cx) } else { repo.unstage_all(cx) } - }); - this.update_counts(active_repository.read(cx)); - cx.notify(); - task + }) })? .await; @@ -1763,6 +1760,7 @@ impl GitPanel { if let Err(err) = result { this.show_error_toast(if stage { "add" } else { "reset" }, err, cx); } + this.update_counts(active_repository.read(cx)); cx.notify() }) } @@ -2716,6 +2714,7 @@ impl GitPanel { prompt: &str, user_agents_md: Option<&str>, rules_content: Option<&str>, + instructions: Option<&str>, subject: &str, diff_text: &str, ) -> String { @@ -2735,6 +2734,14 @@ impl GitPanel { None => String::new(), }; + let instructions_section = match instructions { + Some(instructions) if !instructions.trim().is_empty() => format!( + "\n\nThe user has provided the following instructions for writing commit messages that you should follow:\n\ + \n{instructions}\n\n" + ), + _ => String::new(), + }; + let subject_section = if subject.trim().is_empty() { String::new() } else { @@ -2742,7 +2749,7 @@ impl GitPanel { }; format!( - "{prompt}{user_agents_md_section}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}" + "{prompt}{user_agents_md_section}{rules_section}{instructions_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}" ) } @@ -2773,6 +2780,9 @@ impl GitPanel { }); let temperature = AgentSettings::temperature_for_model(&model, cx); + let instructions = AgentSettings::get_global(cx) + .commit_message_instructions + .clone(); let project = self.project.clone(); let repo_work_dir = repo.read(cx).work_directory_abs_path.clone(); @@ -2834,6 +2844,7 @@ impl GitPanel { &prompt, user_agents_md.as_deref(), rules_content.as_deref(), + instructions.as_deref(), &subject, &diff_text, ); @@ -4946,7 +4957,7 @@ impl GitPanel { .border_color(cx.theme().colors().border.opacity(0.8)) .child( div() - .flex_grow() + .flex_grow_1() .overflow_hidden() .max_w(relative(0.85)) .child( @@ -5147,7 +5158,7 @@ impl GitPanel { } fn select_next_history_entry(&mut self, cx: &mut Context) { - let count = self.commit_history_shas.len(); + let count = self.commit_history_shas.as_ref().map_or(0, Vec::len); if count == 0 { return; } @@ -5163,7 +5174,7 @@ impl GitPanel { } fn select_previous_history_entry(&mut self, cx: &mut Context) { - let count = self.commit_history_shas.len(); + let count = self.commit_history_shas.as_ref().map_or(0, Vec::len); if count == 0 { return; } @@ -5182,7 +5193,7 @@ impl GitPanel { let Some(index) = self.focused_history_entry else { return; }; - let Some(sha) = self.commit_history_shas.get(index) else { + let Some(sha) = self.commit_history_shas.as_ref().and_then(|s| s.get(index)) else { return; }; let Some(active_repository) = self.active_repository.as_ref() else { @@ -5230,7 +5241,7 @@ impl GitPanel { } GitPanelTab::Changes => { self.focus_handle.focus(window, cx); - self.commit_history_shas.clear(); + self.commit_history_shas.take(); self.focused_history_entry = None; self._repo_subscriptions.clear(); } @@ -5296,10 +5307,10 @@ impl GitPanel { let log_source = LogSource::Branch(branch_name.into()); let log_order = LogOrder::DateOrder; - self.commit_history_shas = active_repository.update(cx, |repository, cx| { + self.commit_history_shas = Some(active_repository.update(cx, |repository, cx| { let response = repository.graph_data(log_source, log_order, 0..usize::MAX, cx); response.commits.iter().map(|commit| commit.sha).collect() - }); + })); } fn git_remote(&self, cx: &mut App) -> Option { @@ -5319,14 +5330,10 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> Option { - if self.commit_history_shas.is_empty() { - return None; - } - + let shas = self.commit_history_shas.clone()?; let active_repository = self.active_repository.as_ref()?; let workspace = self.workspace.clone(); let repo_weak = active_repository.downgrade(); - let shas = self.commit_history_shas.clone(); let item_count = shas.len(); let commit_history_scroll_handle = self.commit_history_scroll_handle.clone(); let remote = self.git_remote(cx); @@ -5862,7 +5869,7 @@ impl GitPanel { }) .group("entries") .size_full() - .flex_grow() + .flex_grow_1() .with_width_from_item(self.max_width_item_index) .track_scroll(&self.scroll_handle), ) @@ -8798,18 +8805,36 @@ mod tests { "Write a commit message.", Some("Use terse commit messages."), Some("Use the git_ui prefix."), + Some("Follow the configured commit message format."), "Update generated message", "diff --git a/file b/file", ); assert!(prompt.contains("Use terse commit messages.")); assert!(prompt.contains("Use the git_ui prefix.")); + assert!(prompt.contains("Follow the configured commit message format.")); assert!(prompt.contains("Update generated message")); assert!(prompt.contains("diff --git a/file b/file")); let user_agents_md_index = prompt.find("").unwrap(); let project_rules_index = prompt.find("").unwrap(); + let instructions_index = prompt.find("").unwrap(); assert!(user_agents_md_index < project_rules_index); + assert!(project_rules_index < instructions_index); + } + + #[test] + fn test_commit_message_prompt_omits_blank_instructions() { + let prompt = GitPanel::build_commit_message_prompt( + "Write a commit message.", + None, + None, + Some(" \n "), + "", + "diff --git a/file b/file", + ); + + assert!(!prompt.contains("")); } #[gpui::test] diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 0ce5529aef208d..683e29aa5f9a57 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -66,6 +66,8 @@ actions!( /// Opens a new agent thread with the branch diff for review. ReviewDiff, LeaderAndFollower, + /// Compare with a specific branch + CompareWithBranch, ] ); @@ -98,6 +100,7 @@ impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); workspace.register_action(Self::deploy_branch_diff); + workspace.register_action(Self::compare_with_branch); workspace.register_action(|workspace, _: &Add, window, cx| { Self::deploy(workspace, &Diff, window, cx); }); @@ -121,61 +124,150 @@ impl ProjectDiff { ) { telemetry::event!("Git Branch Diff Opened"); let project = workspace.project().clone(); - let intended_repo = project.read(cx).active_repository(cx); + let Some(intended_repo) = project.read(cx).active_repository(cx) else { + let workspace = cx.entity().downgrade(); + window + .spawn(cx, async |_cx| { + let result: Result<()> = Err(anyhow!("No active repository")); + result + }) + .detach_and_notify_err(workspace, window, cx); + return; + }; - let existing = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. })); + let default_branch = intended_repo.update(cx, |repo, _| repo.default_branch(true)); + let workspace = cx.entity(); + let workspace_weak = workspace.downgrade(); + window + .spawn(cx, async move |cx| { + let base_ref = default_branch + .await?? + .context("Could not determine default branch")?; + + workspace.update_in(cx, |workspace, window, cx| { + Self::deploy_branch_diff_with_base_ref( + workspace, + project, + intended_repo, + base_ref, + window, + cx, + ); + })?; + + anyhow::Ok(()) + }) + .detach_and_notify_err(workspace_weak, window, cx); + } + + fn compare_with_branch( + workspace: &mut Workspace, + _: &CompareWithBranch, + window: &mut Window, + cx: &mut Context, + ) { + let project = workspace.project().clone(); + let Some(repository) = project.read(cx).active_repository(cx) else { + let workspace = cx.entity().downgrade(); + window + .spawn(cx, async |_cx| { + let result: Result<()> = Err(anyhow!("No active repository")); + result + }) + .detach_and_notify_err(workspace, window, cx); + return; + }; + let selected_branch = workspace.active_item_as::(cx).and_then(|item| { + match item.read(cx).diff_base(cx) { + DiffBase::Merge { base_ref } => Some(base_ref.clone()), + DiffBase::Head => None, + } + }); + let workspace_handle = workspace.weak_handle(); + let on_select = Arc::new({ + let repository = repository.clone(); + let workspace = workspace_handle.clone(); + move |branch: git::repository::Branch, window: &mut Window, cx: &mut App| { + let base_ref: SharedString = branch.name().to_owned().into(); + workspace + .update(cx, |workspace, cx| { + Self::deploy_branch_diff_with_base_ref( + workspace, + project.clone(), + repository.clone(), + base_ref, + window, + cx, + ); + }) + .ok(); + } + }); + + workspace.toggle_modal(window, cx, |window, cx| { + branch_picker::select_modal( + workspace_handle, + Some(repository), + selected_branch, + on_select, + window, + cx, + ) + }); + } + + fn deploy_branch_diff_with_base_ref( + workspace: &mut Workspace, + project: Entity, + intended_repo: Entity, + base_ref: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let existing = workspace.items_of_type::(cx).find(|item| { + let item = item.read(cx); + matches!( + item.diff_base(cx), + DiffBase::Merge { base_ref: existing_base_ref } if existing_base_ref == &base_ref + ) + }); if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); - if let Some(intended_repo) = intended_repo { - let needs_switch = existing - .read(cx) - .branch_diff - .read(cx) - .repo() - .map_or(true, |current| { - current.read(cx).id != intended_repo.read(cx).id - }); + let needs_switch = existing + .read(cx) + .branch_diff + .read(cx) + .repo() + .map_or(true, |current| { + current.read(cx).id != intended_repo.read(cx).id + }); - if needs_switch { - let default_branch = - intended_repo.update(cx, |repo, _| repo.default_branch(true)); - let existing = existing.downgrade(); - let workspace = cx.entity().downgrade(); - window - .spawn(cx, async move |cx| { - let default_branch = default_branch - .await?? - .context("Could not determine default branch")?; - - existing.update(cx, |project_diff, cx| { - project_diff.branch_diff.update(cx, |branch_diff, cx| { - branch_diff.set_repo(Some(intended_repo), cx); - branch_diff.set_diff_base( - DiffBase::Merge { - base_ref: default_branch, - }, - cx, - ); - }); - })?; - anyhow::Ok(()) - }) - .detach_and_notify_err(workspace, window, cx); - } + if needs_switch { + existing.update(cx, |project_diff, cx| { + project_diff.branch_diff.update(cx, |branch_diff, cx| { + branch_diff.set_repo(Some(intended_repo), cx); + }); + }); } return; } + let workspace = cx.entity(); let workspace_weak = workspace.downgrade(); window .spawn(cx, async move |cx| { let this = cx .update(|window, cx| { - Self::new_with_default_branch(project, workspace.clone(), window, cx) + Self::new_with_branch_base( + project, + workspace.clone(), + base_ref, + intended_repo, + window, + cx, + ) })? .await?; workspace @@ -336,6 +428,8 @@ impl ProjectDiff { }) } + #[cfg(test)] + #[allow(dead_code)] fn new_with_default_branch( project: Entity, workspace: Entity, @@ -352,14 +446,41 @@ impl ProjectDiff { .context("Could not determine default branch")?; let branch_diff = cx.new_window_entity(|window, cx| { - branch_diff::BranchDiff::new( + let mut branch_diff = branch_diff::BranchDiff::new( DiffBase::Merge { base_ref: main_branch, }, project.clone(), window, cx, - ) + ); + branch_diff.set_repo(Some(repo.clone()), cx); + branch_diff + })?; + cx.new_window_entity(|window, cx| { + Self::new_impl(branch_diff, project, workspace, window, cx) + }) + }) + } + + fn new_with_branch_base( + project: Entity, + workspace: Entity, + base_ref: SharedString, + repo: Entity, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + let branch_diff = cx.new_window_entity(|window, cx| { + let mut branch_diff = branch_diff::BranchDiff::new( + DiffBase::Merge { base_ref }, + project.clone(), + window, + cx, + ); + branch_diff.set_repo(Some(repo.clone()), cx); + branch_diff })?; cx.new_window_entity(|window, cx| { Self::new_impl(branch_diff, project, workspace, window, cx) @@ -1772,8 +1893,10 @@ impl Render for BranchDiffToolbar { PopoverMenu::new("branch-diff-base-branch-picker") .menu(move |window, cx| { let project_diff = project_diff_for_picker.clone(); - let on_select = - Arc::new(move |branch: git::repository::Branch, cx: &mut App| { + let on_select = Arc::new( + move |branch: git::repository::Branch, + _window: &mut Window, + cx: &mut App| { let base_ref: SharedString = branch.name().to_owned().into(); project_diff .update(cx, |project_diff, cx| { @@ -1785,7 +1908,8 @@ impl Render for BranchDiffToolbar { cx.notify(); }) .ok(); - }); + }, + ); Some(branch_picker::select_popover( workspace.clone(), repository.clone(), @@ -2792,6 +2916,78 @@ mod tests { ); } + #[gpui::test] + async fn test_branch_diff_action_matches_existing_item_by_base_ref(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "changed", + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let target_branch_diff = cx + .update(|window, cx| { + let Some(repository) = project.read(cx).active_repository(cx) else { + return Task::ready(Err(anyhow!("No active repository"))); + }; + ProjectDiff::new_with_branch_base( + project.clone(), + workspace.clone(), + "topic".into(), + repository, + window, + cx, + ) + }) + .await + .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new(target_branch_diff.clone()), + None, + true, + window, + cx, + ); + }); + cx.run_until_parked(); + + cx.focus(&workspace); + cx.update(|window, cx| { + window.dispatch_action(BranchDiff.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let (active_base_ref, mut base_refs) = workspace.update(cx, |workspace, cx| { + let active_item = workspace.active_item_as::(cx).unwrap(); + let active_base_ref = match active_item.read(cx).diff_base(cx) { + DiffBase::Merge { base_ref } => base_ref.to_string(), + DiffBase::Head => panic!("expected active item to be a branch diff"), + }; + let base_refs = workspace + .items_of_type::(cx) + .filter_map(|item| match item.read(cx).diff_base(cx) { + DiffBase::Merge { base_ref } => Some(base_ref.to_string()), + DiffBase::Head => None, + }) + .collect::>(); + (active_base_ref, base_refs) + }); + base_refs.sort(); + + assert_eq!(active_base_ref, "origin/main"); + assert_eq!(base_refs, vec!["origin/main", "topic"]); + } + #[gpui::test] async fn test_update_on_uncommit(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 8a6d7e3f840d05..9e32c5dc2d4520 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -336,6 +336,20 @@ impl TestAppContext { self.test_platform.simulate_new_path_selection(select_path); } + /// Simulates responding to a `prompt_for_paths` ("Open") dialog. + pub fn simulate_path_prompt_response( + &self, + select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option>, + ) { + self.test_platform + .simulate_path_prompt_response(select_paths); + } + + /// Returns true if there's a path selection dialog pending. + pub fn did_prompt_for_paths(&self) -> bool { + self.test_platform.did_prompt_for_paths() + } + /// Simulates clicking a button in an platform-level alert dialog. #[track_caller] pub fn simulate_prompt_answer(&self, button: &str) { @@ -1098,3 +1112,54 @@ impl AnyWindowHandle { .unwrap() } } + +#[cfg(test)] +mod tests { + use crate::{PathPromptOptions, TestAppContext}; + use std::path::PathBuf; + + #[gpui::test] + async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) { + assert!(!cx.did_prompt_for_paths()); + + let receiver = cx.update(|cx| { + cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: true, + prompt: None, + }) + }); + assert!(cx.did_prompt_for_paths()); + + let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")]; + cx.simulate_path_prompt_response({ + let selected = selected.clone(); + move |options| { + assert!(options.multiple); + Some(selected) + } + }); + assert!(!cx.did_prompt_for_paths()); + + let response = receiver.await.unwrap().unwrap(); + assert_eq!(response, Some(selected)); + } + + #[gpui::test] + async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) { + let receiver = cx.update(|cx| { + cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }) + }); + + cx.simulate_path_prompt_response(|_options| None); + + let response = receiver.await.unwrap().unwrap(); + assert_eq!(response, None); + } +} diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index ad2dac8371d170..5d8684869844ae 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -18,7 +18,7 @@ use crate::PinchEvent; use crate::{ Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase, - Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, + Display, Element, ElementId, Entity, EntityId, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, Overflow, @@ -3177,6 +3177,8 @@ pub(crate) fn register_tooltip_mouse_handlers( check_is_hovered_during_prepaint: Rc bool>, window: &mut Window, ) { + let current_view = window.current_view(); + window.on_mouse_event({ let active_tooltip = active_tooltip.clone(); let build_tooltip = build_tooltip.clone(); @@ -3187,6 +3189,8 @@ pub(crate) fn register_tooltip_mouse_handlers( &build_tooltip, &check_is_hovered, &check_is_hovered_during_prepaint, + tooltip_id, + current_view, phase, window, cx, @@ -3229,6 +3233,8 @@ fn handle_tooltip_mouse_move( build_tooltip: &Rc Option<(AnyView, bool)>>, check_is_hovered: &Rc bool>, check_is_hovered_during_prepaint: &Rc bool>, + tooltip_id: Option, + current_view: EntityId, phase: DispatchPhase, window: &mut Window, cx: &mut App, @@ -3239,6 +3245,7 @@ fn handle_tooltip_mouse_move( None, CancelShow, ScheduleShow, + CheckVisible, } let action = match active_tooltip.borrow().as_ref() { @@ -3258,9 +3265,26 @@ fn handle_tooltip_mouse_move( Action::CancelShow } } - // These are handled in check_visible_and_update. - Some(ActiveTooltip::Visible { .. }) | Some(ActiveTooltip::WaitingForHide { .. }) => { - Action::None + Some(ActiveTooltip::Visible { is_hoverable, .. }) => { + if phase.capture() + && !check_is_hovered(window) + && (!*is_hoverable + || !tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window))) + { + Action::CheckVisible + } else { + Action::None + } + } + Some(ActiveTooltip::WaitingForHide { .. }) => { + if phase.capture() + && (check_is_hovered(window) + || tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window))) + { + Action::CheckVisible + } else { + Action::None + } } }; @@ -3321,6 +3345,7 @@ fn handle_tooltip_mouse_move( _task: delayed_show_task, }); } + Action::CheckVisible => cx.notify(current_view), } } @@ -4037,4 +4062,36 @@ mod tests { assert!(weak_active_tooltip.upgrade().is_none()); } + + #[test] + fn tooltip_hides_after_mouse_leaves_origin() { + let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(); + + let weak_active_tooltip = captured_active_tooltip.borrow().clone().unwrap(); + let active_tooltip = weak_active_tooltip.upgrade().unwrap(); + + test_app.dispatcher.advance_clock(TOOLTIP_SHOW_DELAY); + test_app.run_until_parked(); + + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::Visible { .. }) + )); + + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + MouseMoveEvent { + position: point(px(75.), px(75.)), + modifiers: Default::default(), + pressed_button: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + + assert!(active_tooltip.borrow().is_none()); + } } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index fe93222a505c00..321f67b473dc49 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -670,6 +670,8 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { } fn set_edited(&mut self, _edited: bool) {} fn set_document_path(&self, _path: Option<&std::path::Path>) {} + #[cfg(target_os = "macos")] + fn set_traffic_light_position(&self, _position: Point) {} fn show_character_palette(&self) {} fn titlebar_double_click(&self) {} fn on_move_tab_to_new_window(&self, _callback: Box) {} diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index cc8c5749bd4696..b3bee3769e063f 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,9 +1,10 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, - PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, - Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, + DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform, + PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, + SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, + size, }; use anyhow::Result; use collections::VecDeque; @@ -85,6 +86,10 @@ struct TestPrompt { pub(crate) struct TestPrompts { multiple_choice: VecDeque, new_path: VecDeque<(PathBuf, oneshot::Sender>>)>, + paths: VecDeque<( + PathPromptOptions, + oneshot::Sender>>>, + )>, } impl TestPlatform { @@ -147,6 +152,33 @@ impl TestPlatform { tx.send(Ok(select_path(&path))).ok(); } + pub(crate) fn simulate_path_prompt_response( + &self, + select_paths: impl FnOnce(&PathPromptOptions) -> Option>, + ) { + let (options, tx) = self + .prompts + .borrow_mut() + .paths + .pop_front() + .expect("no pending paths prompt"); + let selection = select_paths(&options); + if let Some(paths) = &selection + && !options.multiple + && paths.len() > 1 + { + panic!( + "selected {} paths for a prompt that does not allow multiple selection", + paths.len() + ); + } + tx.send(Ok(selection)).ok(); + } + + pub(crate) fn did_prompt_for_paths(&self) -> bool { + !self.prompts.borrow().paths.is_empty() + } + #[track_caller] pub(crate) fn simulate_prompt_answer(&self, response: &str) { let prompt = self @@ -348,9 +380,11 @@ impl Platform for TestPlatform { fn prompt_for_paths( &self, - _options: crate::PathPromptOptions, + options: crate::PathPromptOptions, ) -> oneshot::Receiver>>> { - unimplemented!() + let (tx, rx) = oneshot::channel(); + self.prompts.borrow_mut().paths.push_back((options, tx)); + rx } fn prompt_for_new_path( diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index e090ba973fbb32..3004e157e47642 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -200,6 +200,7 @@ pub trait Styled: Sized { fn flex_none(mut self) -> Self { self.style().flex_grow = Some(0.); self.style().flex_shrink = Some(0.); + self.style().flex_basis = Some(Length::Auto); self } @@ -210,34 +211,48 @@ pub trait Styled: Sized { self } - /// Sets the element to allow a flex item to grow to fill any available space. + /// Sets the flex item's grow factor. /// [Docs](https://tailwindcss.com/docs/flex-grow) - fn flex_grow(mut self) -> Self { - self.style().flex_grow = Some(1.); + fn flex_grow(mut self, grow: f32) -> Self { + self.style().flex_grow = Some(grow); self } - /// Sets the element to prevent a flex item from growing. + /// Disables flex item growth (flex-grow: 0). /// [Docs](https://tailwindcss.com/docs/flex-grow#dont-grow) fn flex_grow_0(mut self) -> Self { self.style().flex_grow = Some(0.); self } - /// Sets the element to allow a flex item to shrink if needed. + /// Enables flex item growth (flex-grow: 1). + /// [Docs](https://tailwindcss.com/docs/flex-grow#grow-1) + fn flex_grow_1(mut self) -> Self { + self.style().flex_grow = Some(1.); + self + } + + /// Sets the flex item's shrink factor. /// [Docs](https://tailwindcss.com/docs/flex-shrink) - fn flex_shrink(mut self) -> Self { - self.style().flex_shrink = Some(1.); + fn flex_shrink(mut self, shrink: f32) -> Self { + self.style().flex_shrink = Some(shrink); self } - /// Sets the element to prevent a flex item from shrinking. + /// Disables flex item shrinking (flex-shrink: 0). /// [Docs](https://tailwindcss.com/docs/flex-shrink#dont-shrink) fn flex_shrink_0(mut self) -> Self { self.style().flex_shrink = Some(0.); self } + /// Enables flex item shrinking (flex-shrink: 1). + /// [Docs](https://tailwindcss.com/docs/flex-shrink#shrink-1) + fn flex_shrink_1(mut self) -> Self { + self.style().flex_shrink = Some(1.); + self + } + /// Sets the element to allow flex items to wrap. /// [Docs](https://tailwindcss.com/docs/flex-wrap#wrap-normally) fn flex_wrap(mut self) -> Self { diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f5292840299924..161891187a1db6 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2311,6 +2311,12 @@ impl Window { self.platform_window.set_title(title); } + /// Sets the position of the macOS traffic light buttons. + #[cfg(target_os = "macos")] + pub fn set_traffic_light_position(&self, position: Point) { + self.platform_window.set_traffic_light_position(position); + } + /// Sets the application identifier. pub fn set_app_id(&mut self, app_id: &str) { self.platform_window.set_app_id(app_id); diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 1793917861a514..16d6b03606661c 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1188,6 +1188,12 @@ impl PlatformWindow for MacWindow { } } + fn set_traffic_light_position(&self, position: Point) { + let mut state = self.0.lock(); + state.traffic_light_position = Some(position); + state.move_traffic_light(); + } + fn scale_factor(&self) -> f32 { self.0.as_ref().lock().scale_factor() } diff --git a/crates/grammars/src/python/highlights.scm b/crates/grammars/src/python/highlights.scm index 87283aaa799a15..d3230f9b62fa3f 100644 --- a/crates/grammars/src/python/highlights.scm +++ b/crates/grammars/src/python/highlights.scm @@ -114,13 +114,13 @@ ((call function: (identifier) @function.builtin) (#any-of? @function.builtin - "abs" "all" "any" "ascii" "bin" "bool" "breakpoint" "bytearray" "bytes" "callable" "chr" - "classmethod" "compile" "complex" "delattr" "dict" "dir" "divmod" "enumerate" "eval" "exec" - "filter" "float" "format" "frozenset" "getattr" "globals" "hasattr" "hash" "help" "hex" "id" - "input" "int" "isinstance" "issubclass" "iter" "len" "list" "locals" "map" "max" "memoryview" - "min" "next" "object" "oct" "open" "ord" "pow" "print" "property" "range" "repr" "reversed" - "round" "set" "setattr" "slice" "sorted" "staticmethod" "str" "sum" "super" "tuple" "type" - "vars" "zip" "__import__")) + "abs" "aiter" "all" "anext" "any" "ascii" "bin" "bool" "breakpoint" "bytearray" "bytes" + "callable" "chr" "classmethod" "compile" "complex" "delattr" "dict" "dir" "divmod" "enumerate" + "eval" "exec" "filter" "float" "format" "frozenset" "getattr" "globals" "hasattr" "hash" "help" + "hex" "id" "input" "int" "isinstance" "issubclass" "iter" "len" "list" "locals" "map" "max" + "memoryview" "min" "next" "object" "oct" "open" "ord" "pow" "print" "property" "range" "repr" + "reversed" "round" "set" "setattr" "sentinel" "slice" "sorted" "staticmethod" "str" "sum" + "super" "tuple" "type" "vars" "zip" "__import__")) ; Literals [ @@ -341,6 +341,6 @@ (binary_operator left: (identifier) @type.builtin)) (#any-of? @type.builtin - "bool" "bytearray" "bytes" "complex" "dict" "float" "frozenset" "int" "list" "memoryview" - "object" "range" "set" "slice" "str" "tuple") + "bool" "bytearray" "bytes" "complex" "dict" "float" "frozenset" "frozendict" "int" "list" + "memoryview" "object" "range" "set" "slice" "str" "tuple") ] diff --git a/crates/grammars/src/rust/injections.scm b/crates/grammars/src/rust/injections.scm index 89d839282d3388..4d6b67b9f8198d 100644 --- a/crates/grammars/src/rust/injections.scm +++ b/crates/grammars/src/rust/injections.scm @@ -10,7 +10,7 @@ (scoped_identifier (identifier) @_macro_name .) ] - (#not-any-of? @_macro_name "view" "html") + (#not-any-of? @_macro_name "view" "html" "json") (token_tree) @injection.content (#set! injection.language "rust")) diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 6a6856e4e68d6f..6677fe667dd3f6 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -2019,7 +2019,8 @@ impl Render for KeymapEditor { context.add("BufferSearchBar"); context }) - .size_full() + .flex_1() + .min_w_0() .h_8() .pl_2() .pr_1() @@ -2032,7 +2033,7 @@ impl Render for KeymapEditor { .child( h_flex() .gap_1() - .min_w_96() + .flex_none() .items_center() .child( IconButton::new( @@ -3468,7 +3469,7 @@ impl Render for ActionArgumentsEditor { .min_h_8() .min_w_48() .px_2() - .flex_grow() + .flex_grow_1() .rounded_md() .bg(cx.theme().colors().editor_background) .border_1() diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index a9d5777be01e5e..6207ba7e1bce58 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -382,6 +382,21 @@ impl LanguageRegistry { servers_rx } + #[cfg(any(feature = "test-support", test))] + pub fn register_fake_available_lsp_adapter( + &self, + name: impl Into, + adapter: crate::FakeLspAdapter, + ) { + let name = name.into(); + let adapter = Arc::new(adapter); + let mut state = self.state.write(); + state.available_lsp_adapters.insert( + name, + Arc::new(move || CachedLspAdapter::new(adapter.clone())), + ); + } + #[cfg(any(feature = "test-support", test))] pub fn has_fake_lsp_server(&self, lsp_name: &LanguageServerName) -> bool { self.state.read().fake_server_entries.contains_key(lsp_name) diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index 8bff7ce1415c00..98804037e31b8b 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -349,6 +349,56 @@ fn test_dynamic_language_injection(cx: &mut App) { assert!(!syntax_map.contains_unknown_injections()); } +#[gpui::test] +fn test_rust_json_macro_empty_string_highlighting(cx: &mut App) { + let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); + let language = rust_lang(); + registry.add(language.clone()); + + let buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + r#" + serde_json::json!({ + "email": "", + "password": "password123", + "requires2FA": false + }) + "# + .unindent(), + ); + + let mut syntax_map = SyntaxMap::new(&buffer); + syntax_map.set_language_registry(registry); + syntax_map.reparse(language, &buffer); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["string"], + r#" + serde_json::json!({ + «"email"»: «""», + «"password"»: «"password123"», + «"requires2FA"»: false + }) + "#, + ); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["boolean"], + r#" + serde_json::json!({ + "email": "", + "password": "password123", + "requires2FA": «false» + }) + "#, + ); +} + #[gpui::test] fn test_typing_multiple_new_injections(cx: &mut App) { let (buffer, syntax_map) = test_edit_sequence( diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index 159f06952ca0bb..70ddf2aa087743 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -1363,6 +1363,7 @@ impl ServerInfo { capabilities: server.capabilities(), status: LanguageServerStatus { name: server.name(), + language_name: None, server_version: server.version(), server_readable_version: server.readable_version(), pending_work: Default::default(), diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 5a3f6939830c1e..97483cf3dae775 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1495,18 +1495,23 @@ impl ToolchainLister for PythonToolchainProvider { } } + // Only inject `{manager} activate ` when we have a + // safely-quotable name. Never silently fall back to + // `activate base`: a user with miniforge installed but a + // local uv/venv project should not have their terminal + // hijacked just because we couldn't resolve a name. if let Some(name) = &toolchain.environment.name { if let Some(quoted_name) = shell.try_quote(name) { activation_script.push(format!("{manager} activate {quoted_name}")); } else { log::warn!( - "Could not safely quote environment name {:?}, falling back to base", + "Conda environment name {:?} could not be safely quoted; \ + skipping terminal activation", name ); - activation_script.push(format!("{manager} activate base")); } } else { - activation_script.push(format!("{manager} activate base")); + log::warn!("Conda toolchain has no name; skipping terminal activation"); } } Some( @@ -2793,6 +2798,143 @@ mod tests { ); } + #[gpui::test] + async fn test_conda_activation_skips_when_name_missing(cx: &mut TestAppContext) { + use language::{LanguageName, Toolchain, ToolchainLister}; + use settings::{CondaManager, VenvSettings}; + use task::ShellKind; + + use crate::python::PythonToolchainProvider; + + cx.executor().allow_parking(); + + cx.update(|cx| { + let test_settings = SettingsStore::test(cx); + cx.set_global(test_settings); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |s| { + s.terminal + .get_or_insert_with(Default::default) + .project + .detect_venv = Some(VenvSettings::On { + activate_script: None, + venv_name: None, + directories: None, + conda_manager: Some(CondaManager::Conda), + }); + }); + }); + }); + + let fs = project::FakeFs::new(cx.executor()); + let provider = PythonToolchainProvider::new(fs); + let manager_executable = std::env::current_exe().unwrap(); + + let data = serde_json::json!({ + "name": serde_json::Value::Null, + "kind": "Conda", + "executable": "/tmp/conda/bin/python", + "version": serde_json::Value::Null, + "prefix": serde_json::Value::Null, + "arch": serde_json::Value::Null, + "displayName": serde_json::Value::Null, + "project": serde_json::Value::Null, + "symlinks": serde_json::Value::Null, + "manager": { + "executable": manager_executable, + "version": serde_json::Value::Null, + "tool": "Conda", + }, + }); + + let toolchain = Toolchain { + name: "test".into(), + path: "/tmp/conda".into(), + language_name: LanguageName::new_static("Python"), + as_json: data, + }; + + let script = cx + .update(|cx| provider.activation_script(&toolchain, ShellKind::Posix, cx)) + .await; + + assert!( + script.is_empty(), + "Nameless conda toolchains must not fall back to `conda activate base`, actual: {:?}", + script + ); + } + + #[gpui::test] + async fn test_conda_activation_skips_unquotable_name(cx: &mut TestAppContext) { + use language::{LanguageName, Toolchain, ToolchainLister}; + use settings::{CondaManager, VenvSettings}; + use task::ShellKind; + + use crate::python::PythonToolchainProvider; + + cx.executor().allow_parking(); + + cx.update(|cx| { + let test_settings = SettingsStore::test(cx); + cx.set_global(test_settings); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |s| { + s.terminal + .get_or_insert_with(Default::default) + .project + .detect_venv = Some(VenvSettings::On { + activate_script: None, + venv_name: None, + directories: None, + conda_manager: Some(CondaManager::Conda), + }); + }); + }); + }); + + let fs = project::FakeFs::new(cx.executor()); + let provider = PythonToolchainProvider::new(fs); + // shlex::try_quote rejects strings containing a NUL byte, so this name + // is guaranteed to fail the Posix quoting path. + let unquotable_name = "foo\0bar"; + let manager_executable = std::env::current_exe().unwrap(); + + let data = serde_json::json!({ + "name": unquotable_name, + "kind": "Conda", + "executable": "/tmp/conda/bin/python", + "version": serde_json::Value::Null, + "prefix": serde_json::Value::Null, + "arch": serde_json::Value::Null, + "displayName": serde_json::Value::Null, + "project": serde_json::Value::Null, + "symlinks": serde_json::Value::Null, + "manager": { + "executable": manager_executable, + "version": serde_json::Value::Null, + "tool": "Conda", + }, + }); + + let toolchain = Toolchain { + name: "test".into(), + path: "/tmp/conda".into(), + language_name: LanguageName::new_static("Python"), + as_json: data, + }; + + let script = cx + .update(|cx| provider.activation_script(&toolchain, ShellKind::Posix, cx)) + .await; + + assert!( + !script.iter().any(|s| s.contains("conda activate")), + "Unquotable conda env names must not emit any `conda activate` line, actual: {:?}", + script + ); + } + #[gpui::test] async fn test_python_autoindent(cx: &mut TestAppContext) { cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX); diff --git a/crates/livekit_client/examples/test_app.rs b/crates/livekit_client/examples/test_app.rs index eb87aa6cae4530..f21bc9d3203233 100644 --- a/crates/livekit_client/examples/test_app.rs +++ b/crates/livekit_client/examples/test_app.rs @@ -350,7 +350,7 @@ impl Render for LivekitWindow { .overflow_y_scroll() .flex() .flex_col() - .flex_grow() + .flex_grow_1() .children(self.remote_participants.iter().map(|(identity, state)| { div() .h(px(1080.0)) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 7fcbf393fb405b..e92b29e8af234c 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -17,6 +17,7 @@ use mermaid::{ pub use path_range::{LineCol, PathWithRange}; use settings::Settings as _; use theme_settings::ThemeSettings; +use util::maybe; use std::borrow::Cow; use std::collections::BTreeMap; @@ -3237,6 +3238,7 @@ impl MarkdownElementBuilder { }], source_end: source_range.end, language: None, + text_align: TextAlign::Left, }); div() .absolute() @@ -3248,6 +3250,7 @@ impl MarkdownElementBuilder { } fn flush_text(&mut self) { + let text_align = self.text_style().text_align; let line = mem::take(&mut self.pending_line); if line.text.is_empty() { return; @@ -3259,6 +3262,7 @@ impl MarkdownElementBuilder { source_mappings: line.source_mappings, source_end: self.current_source_index, language: self.code_block_stack.last().cloned().flatten(), + text_align, }); self.div_stack.last_mut().unwrap().extend([text.into_any()]); } @@ -3282,6 +3286,7 @@ struct RenderedLine { source_mappings: Vec, source_end: usize, language: Option>, + text_align: TextAlign, } impl RenderedLine { @@ -3349,10 +3354,69 @@ impl RenderedLine { self.source_mappings[ix].source_index } + fn alignment_offset_for_segment( + &self, + available_width: Pixels, + segment_start_x: Pixels, + segment_end_x: Pixels, + ) -> Pixels { + let segment_width = segment_end_x - segment_start_x; + match self.text_align { + TextAlign::Left => px(0.), + TextAlign::Center => ((available_width - segment_width) / 2.).max(px(0.)), + TextAlign::Right => (available_width - segment_width).max(px(0.)), + } + } + fn source_index_for_position(&self, position: Point) -> Result { + let adjusted_position = maybe!({ + if self.text_align == TextAlign::Left { + return None; + } + + let Some(wrapped_line) = self.layout.line_layout_for_index(0) else { + return None; + }; + + let bounds = self.layout.bounds(); + let line_height = self.layout.line_height(); + let relative_y = (position.y - bounds.top()).max(px(0.)); + let wrapped_row_ix = (relative_y / line_height) as usize; + let boundaries = wrapped_line.wrap_boundaries(); + + let segment_start_x = if wrapped_row_ix == 0 { + px(0.) + } else { + boundaries + .get(wrapped_row_ix - 1) + .map(|b| { + wrapped_line.unwrapped_layout.runs[b.run_ix].glyphs[b.glyph_ix] + .position + .x + }) + .unwrap_or(px(0.)) + }; + let segment_end_x = boundaries + .get(wrapped_row_ix) + .map(|b| { + wrapped_line.unwrapped_layout.runs[b.run_ix].glyphs[b.glyph_ix] + .position + .x + }) + .unwrap_or(wrapped_line.unwrapped_layout.width); + + let alignment_offset = self.alignment_offset_for_segment( + bounds.size.width, + segment_start_x, + segment_end_x, + ); + Some(point(position.x - alignment_offset, position.y)) + }) + .unwrap_or(position); + let line_rendered_index; let out_of_bounds; - match self.layout.index_for_position(position) { + match self.layout.index_for_position(adjusted_position) { Ok(ix) => { line_rendered_index = ix; out_of_bounds = false; @@ -3475,8 +3539,14 @@ impl RenderedText { let selection_end = rendered_end.min(row_end); if selection_start < selection_end { + let alignment_offset = line.alignment_offset_for_segment( + line_bounds.size.width, + row_start_x, + row_end_x, + ); let x_for_index = |index| { line_bounds.left() + + alignment_offset + unwrapped_layout.x_for_index(index - wrapped_line_start) - row_start_x }; diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index ad027335c4c2d6..b081c4a818b3a3 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -5003,6 +5003,20 @@ impl MultiBufferSnapshot { } pub fn summaries_for_anchors<'a, MBD, I>(&'a self, anchors: I) -> Vec + where + MBD: MultiBufferDimension + + Ord + + Sub + + AddAssign, + MBD::TextDimension: Sub + Ord, + I: 'a + IntoIterator, + { + let mut summaries = Vec::new(); + self.summaries_for_anchors_cb(anchors, |summary| summaries.push(summary)); + summaries + } + + pub fn summaries_for_anchors_cb<'a, MBD, I>(&'a self, anchors: I, mut cb: impl FnMut(MBD)) where MBD: MultiBufferDimension + Ord @@ -5018,18 +5032,17 @@ impl MultiBufferSnapshot { .cursor::, OutputDimension>>(()); diff_transforms_cursor.next(); - let mut summaries = Vec::new(); while let Some(anchor) = anchors.peek() { let target = anchor.seek_target(self); let excerpt_anchor = match anchor { Anchor::Min => { - summaries.push(MBD::default()); + cb(MBD::default()); anchors.next(); continue; } Anchor::Excerpt(excerpt_anchor) => excerpt_anchor, Anchor::Max => { - summaries.push(MBD::from_summary(&self.text_summary())); + cb(MBD::from_summary(&self.text_summary())); anchors.next(); continue; } @@ -5047,7 +5060,7 @@ impl MultiBufferSnapshot { excerpt_start_position, &mut diff_transforms_cursor, ); - summaries.push(position); + cb(position); anchors.next(); continue; } @@ -5083,7 +5096,7 @@ impl MultiBufferSnapshot { diff_transforms_cursor.seek_forward(&position, Bias::Left); } - summaries.push(self.summary_for_anchor_with_excerpt_position( + cb(self.summary_for_anchor_with_excerpt_position( excerpt_anchor, position, &mut diff_transforms_cursor, @@ -5097,12 +5110,10 @@ impl MultiBufferSnapshot { excerpt_start_position, &mut diff_transforms_cursor, ); - summaries.push(position); + cb(position); anchors.next(); } } - - summaries } pub fn dimensions_from_points<'a, MBD>( diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 281330e887bd59..8c62833839bc77 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -4788,9 +4788,9 @@ impl OutlinePanel { }; v_flex() - .flex_shrink() + .flex_shrink_1() .size_full() - .child(list_contents.size_full().flex_shrink()) + .child(list_contents.size_full().flex_shrink_1()) .custom_scrollbars( Scrollbars::for_settings::() .tracked_scroll_handle(&self.scroll_handle.clone()) diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index c1c148f5e692f6..3c6eafab371725 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -891,7 +891,7 @@ impl Picker { .when_some(self.widest_item, |el, widest_item| { el.with_width_from_item(Some(widest_item)) }) - .flex_grow() + .flex_grow_1() .py(DynamicSpacing::Base04.rems(cx)) .track_scroll(&scroll_handle) .into_any_element(), @@ -902,7 +902,7 @@ impl Picker { }), ) .with_sizing_behavior(sizing_behavior) - .flex_grow() + .flex_grow_1() .py(DynamicSpacing::Base04.rems(cx)) .into_any_element(), } @@ -1150,7 +1150,7 @@ impl Render for Picker { v_flex() .id("element-container") .relative() - .flex_grow() + .flex_grow_1() .when_some(self.max_height, |div, max_h| div.max_h(max_h)) .overflow_hidden() .children(self.delegate.render_header(window, cx)) @@ -1177,7 +1177,7 @@ impl Render for Picker { el.when_some(self.delegate.no_matches_text(window, cx), |el, text| { el.child( v_flex() - .flex_grow() + .flex_grow_1() .py(DynamicSpacing::Base04.rems(cx)) .child( ListItem::new("empty_state") diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 811b9aebac6ef4..d679b531c83445 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -630,6 +630,7 @@ impl LocalLspStore { server.clone(), server_id, key, + language_name, pending_workspace_folders, cx, ); @@ -4164,6 +4165,7 @@ pub enum LspStoreEvent { #[derive(Clone, Debug, Serialize)] pub struct LanguageServerStatus { pub name: LanguageServerName, + pub language_name: Option, pub server_version: Option, pub server_readable_version: Option, pub pending_work: BTreeMap, @@ -5145,13 +5147,9 @@ impl LspStore { self.language_server_statuses .iter() .filter_map(|(server_id, server_status)| { - // Include servers that are either registered for this language OR - // available to be loaded (for SSH remote mode where adapters like - // ty/pylsp/pyright are registered via register_available_lsp_adapter - // but only loaded on the server side) - let is_relevant = registered_language_servers.contains(&server_status.name) - || self.languages.is_lsp_adapter_available(&server_status.name); - is_relevant.then_some(*server_id) + registered_language_servers + .contains(&server_status.name) + .then_some(*server_id) }) .collect() } @@ -8589,6 +8587,10 @@ impl LspStore { id: server_id.to_proto(), name: status.name.to_string(), worktree_id: status.worktree.map(|id| id.to_proto()), + language_name: status + .language_name + .as_ref() + .map(|name| name.to_proto()), }), capabilities: serde_json::to_string(&server.capabilities()) .expect("serializing server LSP capabilities"), @@ -8634,6 +8636,7 @@ impl LspStore { let name = LanguageServerName::from_proto(server.name); let worktree = server.worktree_id.map(WorktreeId::from_proto); + let language_name = server.language_name.map(LanguageName::from_proto); if let Some(lsp_logs) = &lsp_logs { lsp_logs.update(cx, |lsp_logs, cx| { @@ -8651,10 +8654,15 @@ impl LspStore { }); } + if let Some(ref lang_name) = language_name { + self.try_register_remote_adapter_locally(&name, lang_name); + } + ( server_id, LanguageServerStatus { name, + language_name: language_name, server_version: None, server_readable_version: None, pending_work: Default::default(), @@ -8671,6 +8679,38 @@ impl LspStore { .collect(); } + fn try_register_remote_adapter_locally( + &self, + server_name: &LanguageServerName, + language_name: &LanguageName, + ) { + let already_registered = self + .languages + .lsp_adapters(language_name) + .iter() + .any(|adapter| adapter.name() == *server_name); + + if already_registered { + return; + } + + if let Some(adapter) = self.languages.load_available_lsp_adapter(server_name) { + log::info!( + "Registering LSP adapter '{}' for language '{}' on local client", + server_name.0, + language_name.0 + ); + self.languages + .register_lsp_adapter(language_name.clone(), adapter.adapter.clone()); + } else { + log::warn!( + "LSP adapter '{}' for language '{}' not available locally", + server_name.0, + language_name.0 + ); + } + } + #[cfg(feature = "test-support")] pub fn update_diagnostic_entries( &mut self, @@ -9822,13 +9862,20 @@ impl LspStore { lsp_store.update(&mut cx, |lsp_store, cx| { let server_id = LanguageServerId(server.id as usize); let server_name = LanguageServerName::from_proto(server.name.clone()); + let language_name = server.language_name.map(LanguageName::from_proto); lsp_store .lsp_server_capabilities .insert(server_id, server_capabilities); + + if let Some(ref lang_name) = language_name { + lsp_store.try_register_remote_adapter_locally(&server_name, lang_name); + } + lsp_store.language_server_statuses.insert( server_id, LanguageServerStatus { name: server_name.clone(), + language_name, server_version: None, server_readable_version: None, pending_work: Default::default(), @@ -9910,6 +9957,15 @@ impl LspStore { lsp_store.disk_based_diagnostics_finished(language_server_id, cx) } + proto::update_language_server::Variant::Removed(_) => { + lsp_store + .language_server_statuses + .remove(&language_server_id); + lsp_store.cleanup_lsp_data(language_server_id); + cx.emit(LspStoreEvent::LanguageServerRemoved(language_server_id)); + cx.notify(); + } + non_lsp @ proto::update_language_server::Variant::StatusUpdate(_) | non_lsp @ proto::update_language_server::Variant::RegisteredForBuffer(_) | non_lsp @ proto::update_language_server::Variant::MetadataUpdated(_) => { @@ -11726,6 +11782,7 @@ impl LspStore { language_server: Arc, server_id: LanguageServerId, key: LanguageServerSeed, + language_name: LanguageName, workspace_folders: Arc>>, cx: &mut Context, ) { @@ -11800,6 +11857,7 @@ impl LspStore { server_id, LanguageServerStatus { name: language_server.name(), + language_name: Some(language_name.clone()), server_version: language_server.version(), server_readable_version: language_server.readable_version(), pending_work: Default::default(), @@ -11828,6 +11886,7 @@ impl LspStore { id: server_id.to_proto(), name: language_server.name().to_string(), worktree_id: Some(key.worktree_id.to_proto()), + language_name: Some(language_name.to_proto()), }), capabilities: serde_json::to_string(&server_capabilities) .expect("serializing server LSP capabilities"), diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 83b4c585f1454e..27f7e18ecc7460 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -78,6 +78,7 @@ pub enum SearchQuery { include_ignored: bool, one_match_per_line: bool, inner: SearchInputs, + escaped: bool, }, } @@ -169,6 +170,7 @@ impl SearchQuery { include_ignored, one_match_per_line, inner, + false, ) } @@ -202,6 +204,7 @@ impl SearchQuery { include_ignored, false, inner, + true, ) } @@ -212,6 +215,7 @@ impl SearchQuery { include_ignored: bool, one_match_per_line: bool, inner: SearchInputs, + escaped: bool, ) -> Result { if let Some((case_sensitive_from_pattern, new_pattern)) = Self::case_sensitive_from_pattern(&pattern) @@ -253,6 +257,7 @@ impl SearchQuery { include_ignored, inner, one_match_per_line, + escaped, }) } @@ -450,27 +455,36 @@ impl SearchQuery { /// Replaces search hits if replacement is set. `text` is assumed to be a string that matches this `SearchQuery` exactly, without any leftovers on either side. pub fn replacement_for<'a>(&self, text: &'a str) -> Option> { match self { - SearchQuery::Text { replacement, .. } => replacement.clone().map(Cow::from), + SearchQuery::Text { replacement, .. } + | SearchQuery::Regex { + replacement, + escaped: true, + .. + } => replacement.clone().map(Cow::from), + SearchQuery::Regex { - regex, replacement, .. + regex, + replacement: Some(replacement), + escaped: false, + .. } => { - if let Some(replacement) = replacement { - static TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"\\\\|\\n|\\t").unwrap()); - let replacement = TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX.replace_all( - replacement, - |c: &Captures| match c.get(0).unwrap().as_str() { - r"\\" => "\\", - r"\n" => "\n", - r"\t" => "\t", - x => unreachable!("Unexpected escape sequence: {}", x), - }, - ); - Some(regex.replace(text, replacement)) - } else { - None - } + static TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\\\\|\\n|\\t").unwrap()); + let replacement = TEXT_REPLACEMENT_SPECIAL_CHARACTERS_REGEX.replace_all( + replacement, + |c: &Captures| match c.get(0).unwrap().as_str() { + r"\\" => "\\", + r"\n" => "\n", + r"\t" => "\t", + x => unreachable!("Unexpected escape sequence: {}", x), + }, + ); + Some(regex.replace(text, replacement)) } + + SearchQuery::Regex { + replacement: None, .. + } => None, } } diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index b0fc16f3c83168..5908408f7ceaec 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -115,19 +115,23 @@ impl Project { let env_task = self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx); - let project_path_contexts = self - .active_entry() - .and_then(|entry_id| self.path_for_entry(entry_id, cx)) + // Scope the toolchain lookup to the worktree the terminal is being + // spawned in. Previously this iterated the active editor's worktree + // and then every visible worktree, so a Python toolchain persisted + // for worktree A would leak into a terminal opened in worktree B and + // inject (e.g.) `conda activate base` into a shell that has no + // business with conda. + let project_path_contexts: Vec = path + .as_ref() + .and_then(|p| self.find_worktree(p, cx)) + .map(|(worktree, relative_path)| ProjectPath { + worktree_id: worktree.read(cx).id(), + path: relative_path, + }) .into_iter() - .chain( - self.visible_worktrees(cx) - .map(|wt| wt.read(cx).id()) - .map(|worktree_id| ProjectPath { - worktree_id, - path: Arc::from(RelPath::empty()), - }), - ); + .collect(); let toolchains = project_path_contexts + .into_iter() .filter(|_| detect_venv) .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx)) .collect::>(); @@ -333,19 +337,20 @@ impl Project { let detect_venv = settings.detect_venv.as_option().is_some(); let local_path = if is_via_remote { None } else { path.clone() }; - let project_path_contexts = self - .active_entry() - .and_then(|entry_id| self.path_for_entry(entry_id, cx)) + // See create_terminal_task: scope the toolchain lookup to the + // worktree the terminal is opened in, not the active editor's + // worktree or other visible worktrees. + let project_path_contexts: Vec = path + .as_ref() + .and_then(|p| self.find_worktree(p, cx)) + .map(|(worktree, relative_path)| ProjectPath { + worktree_id: worktree.read(cx).id(), + path: relative_path, + }) .into_iter() - .chain( - self.visible_worktrees(cx) - .map(|wt| wt.read(cx).id()) - .map(|worktree_id| ProjectPath { - worktree_id, - path: RelPath::empty().into(), - }), - ); + .collect(); let toolchains = project_path_contexts + .into_iter() .filter(|_| detect_venv) .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx)) .collect::>(); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 02aa6cddf284af..8cce8ab4422915 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -6932,7 +6932,7 @@ impl Render for ProjectPanel { div() .id("project-panel-blank-area") .block_mouse_except_scroll() - .flex_grow() + .flex_grow_1() .on_scroll_wheel({ let scroll_handle = self.scroll_handle.clone(); let entity_id = cx.entity().entity_id(); diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index ff9ec4d4e64fb5..2c91b41f634a34 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -573,6 +573,7 @@ message LanguageServer { uint64 id = 1; string name = 2; optional uint64 worktree_id = 3; + optional string language_name = 4; } message StartLanguageServer { @@ -608,6 +609,7 @@ message UpdateLanguageServer { StatusUpdate status_update = 9; RegisteredForBuffer registered_for_buffer = 10; ServerMetadataUpdated metadata_updated = 11; + ServerRemoved removed = 12; } } @@ -644,6 +646,8 @@ message LspDiskBasedDiagnosticsUpdating {} message LspDiskBasedDiagnosticsUpdated {} +message ServerRemoved {} + message StatusUpdate { optional string message = 1; oneof status { diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index fe8054dc955998..169f319241b19a 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1704,7 +1704,7 @@ impl PickerDelegate for RecentProjectsDelegate { h_flex() .id("project_info_container") .gap_2p5() - .flex_grow() + .flex_grow_1() .when(self.has_any_non_local_projects, |this| { this.child(Icon::new(icon).color(Color::Muted)) }) diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 38c5e8cdb56af9..6a453b7a5c9691 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -734,6 +734,101 @@ mod tests { ); } + #[gpui::test] + async fn test_reopen_existing_remote_root_treats_root_as_directory( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let executor = cx.executor(); + + cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + + let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx); + + let remote_fs = FakeFs::new(server_cx.executor()); + let remote_home = paths::home_dir(); + let canonical_project_path = remote_home.join("remote-reopen-root-project"); + remote_fs + .insert_tree( + &canonical_project_path, + json!({ + "src": { + "main.rs": "fn main() {}", + }, + "README.md": "# Test Project", + }), + ) + .await; + + server_cx.update(HeadlessProject::init); + let http_client = Arc::new(BlockedHttpClient); + let node_runtime = NodeRuntime::unavailable(); + let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor())); + let proxy = Arc::new(ExtensionHostProxy::new()); + + let _headless = server_cx.new(|cx| { + HeadlessProject::new( + HeadlessAppState { + session: server_session, + fs: remote_fs.clone(), + http_client, + node_runtime, + languages, + extension_host_proxy: proxy, + startup_time: std::time::Instant::now(), + }, + false, + cx, + ) + }); + + drop(connect_guard); + + let mut async_cx = cx.to_async(); + let window = open_remote_project( + opts, + vec![canonical_project_path.clone()], + app_state, + workspace::OpenOptions::default(), + &mut async_cx, + ) + .await + .expect("initial open_remote_project should succeed"); + + executor.run_until_parked(); + + let open_results = window + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.open_paths( + vec![canonical_project_path.clone()], + workspace::OpenOptions { + visible: Some(workspace::OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) + }) + .unwrap() + .await; + + assert_eq!(open_results.len(), 1, "should return one open result"); + assert!( + open_results[0].is_none(), + "reopening a remote root directory should not try to open it as a file" + ); + } + #[gpui::test] async fn test_reconnect_when_server_not_running( cx: &mut TestAppContext, diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index 0b4d3722a344e5..c5d1203bf28e2a 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -369,7 +369,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { .child( h_flex() .gap_3() - .flex_grow() + .flex_grow_1() .when(self.has_any_non_local_projects, |this| { this.child(Icon::new(icon).color(Color::Muted)) }) diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index e0930fde365c11..b09a040caa9841 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -172,7 +172,7 @@ impl picker::PickerDelegate for WslPickerDelegate { .spacing(ui::ListItemSpacing::Sparse) .child( h_flex() - .flex_grow() + .flex_grow_1() .gap_3() .child(Icon::new(IconName::Linux)) .child(v_flex().child(HighlightedLabel::new( diff --git a/crates/recent_projects/src/zoxide_projects.rs b/crates/recent_projects/src/zoxide_projects.rs index eb9331a56473d5..0c635dcda1a707 100644 --- a/crates/recent_projects/src/zoxide_projects.rs +++ b/crates/recent_projects/src/zoxide_projects.rs @@ -334,7 +334,7 @@ impl PickerDelegate for RecentProjectsZoxideDelegate { .spacing(ListItemSpacing::Sparse) .child( h_flex() - .flex_grow() + .flex_grow(1.) .gap_3() .child(Icon::new(IconName::Folder).color(Color::Muted)) .child(highlighted_text.render(window, cx)), diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 098993debad82e..82ba504963b5ca 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -416,6 +416,16 @@ impl HeadlessProject { log_store.remove_language_server(*id, cx); }); } + self.session + .send(proto::UpdateLanguageServer { + project_id: REMOTE_SERVER_PROJECT_ID, + server_name: None, + language_server_id: id.to_proto(), + variant: Some(proto::update_language_server::Variant::Removed( + proto::ServerRemoved {}, + )), + }) + .log_err(); } LspStoreEvent::LanguageServerUpdate { language_server_id, diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index 32db5785884eaa..e8fc7bee363ff8 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -362,7 +362,7 @@ impl PickerDelegate for KernelPickerDelegate { .child(icon.color(Color::Default).size(IconSize::Medium)) .child( v_flex() - .flex_grow() + .flex_grow_1() .overflow_x_hidden() .gap_0p5() .child( @@ -371,7 +371,7 @@ impl PickerDelegate for KernelPickerDelegate { .child( div() .overflow_x_hidden() - .flex_shrink() + .flex_shrink_1() .text_ellipsis() .child( Label::new(spec.name()) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index ea03a1a7f9f5e7..07c91d7659de3d 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -4036,6 +4036,34 @@ mod tests { }); } + #[gpui::test] + async fn test_replace_with_non_ascii_characters(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("¥100 ¥200 ¥100", window, cx) + }); + + search_bar + .update_in(cx, |search_bar, window, cx| { + search_bar.search("¥", None, true, window, cx) + }) + .await + .unwrap(); + + search_bar.update_in(cx, |search_bar, window, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text("\\n", window, cx); + }); + search_bar.replace_all(&ReplaceAll, window, cx) + }); + + assert_eq!( + editor.read_with(cx, |this, cx| this.text(cx)), + "\\n100 \\n200 \\n100" + ); + } + fn update_search_settings(search_settings: SearchSettings, cx: &mut TestAppContext) { cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 249d91c1280ce5..98aa0b627a5853 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -2249,7 +2249,7 @@ impl Render for ProjectSearchBar { let input_base_styles = |panel: InputPanel| { input_base_styles(search.border_color_for(panel, cx), |div| match panel { InputPanel::Query | InputPanel::Replacement => div.w(input_width), - InputPanel::Include | InputPanel::Exclude => div.flex_grow(), + InputPanel::Include | InputPanel::Exclude => div.flex_grow_1(), }) }; let theme_colors = cx.theme().colors(); diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 917ca5e0530ddd..6697213602182c 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -131,6 +131,9 @@ pub struct AgentSettingsContent { pub inline_assistant_use_streaming_tools: Option, /// Model to use for generating git commit messages. Defaults to default_model when not specified. pub commit_message_model: Option, + /// Custom instructions to include in the prompt when generating git commit messages. + /// Applied in addition to any project rules files (such as `.rules` or `AGENTS.md`). + pub commit_message_instructions: Option, /// Model to use for generating thread summaries. Defaults to default_model when not specified. pub thread_summary_model: Option, /// Additional models with which to generate alternatives when performing inline assists. diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 0a83af342ffa64..bef411b5498d25 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -230,26 +230,42 @@ fn draft_display_label_for_thread_metadata( metadata: &ThreadMetadata, workspace: &ThreadEntryWorkspace, cx: &App, -) -> Option { +) -> Option<(SharedString, DraftKind)> { let workspace = match workspace { ThreadEntryWorkspace::Open(workspace) => Some(workspace), ThreadEntryWorkspace::Closed { .. } => None, }; - agent_ui::draft_prompt_store::display_label_for_draft(workspace, metadata.thread_id, cx) + + if let Some(label) = + agent_ui::draft_prompt_store::display_label_for_draft(workspace, metadata.thread_id, cx) + { + return Some((label, DraftKind::WithContent)); + } + + let placeholder = agent_ui::draft_prompt_store::empty_draft_placeholder_label( + workspace, + &metadata.agent_id, + cx, + ); + Some((placeholder, DraftKind::Empty)) } fn thread_metadata_would_render_sidebar_row( metadata: &ThreadMetadata, workspace: &ThreadEntryWorkspace, - hidden_draft_thread_ids: &HashSet, cx: &App, ) -> bool { if !metadata.is_draft() { return true; } - !hidden_draft_thread_ids.contains(&metadata.thread_id) - && draft_display_label_for_thread_metadata(metadata, workspace, cx).is_some() + draft_display_label_for_thread_metadata(metadata, workspace, cx).is_some() +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum DraftKind { + WithContent, + Empty, } #[derive(Clone)] @@ -262,7 +278,7 @@ struct ThreadEntry { is_live: bool, is_background: bool, is_title_generating: bool, - is_draft: bool, + draft: Option, highlight_positions: Vec, worktrees: Vec, diff_stats: DiffStats, @@ -1445,14 +1461,6 @@ impl Sidebar { let mut has_running_threads = false; let mut waiting_thread_count: usize = 0; let group_host = group_key.host(); - let hidden_draft_thread_ids: HashSet = group_workspaces - .iter() - .filter_map(|ws| { - ws.read(cx) - .panel::(cx) - .and_then(|panel| panel.read(cx).ephemeral_draft_thread_id(cx)) - }) - .collect(); if should_load_threads { let thread_store = ThreadMetadataStore::global(cx); @@ -1462,7 +1470,10 @@ impl Sidebar { let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); let worktrees = worktree_info_from_thread_paths(&row.worktree_paths, &branch_by_path); - let is_draft = row.is_draft(); + // Start drafts as `WithContent`; the post-processing + // pass below downgrades them to `Empty` if no draft + // label can be derived. + let draft = row.is_draft().then_some(DraftKind::WithContent); Arc::new(ThreadEntry { metadata: row, icon, @@ -1472,7 +1483,7 @@ impl Sidebar { is_live: false, is_background: false, is_title_generating: false, - is_draft, + draft, highlight_positions: Vec::new(), worktrees, diff_stats: DiffStats::default(), @@ -1562,22 +1573,38 @@ impl Sidebar { } } - if !hidden_draft_thread_ids.is_empty() { - threads.retain(|thread| { - !hidden_draft_thread_ids.contains(&thread.metadata.thread_id) - }); - } for thread in &mut threads { - if !thread.is_draft { + if thread.draft.is_none() { continue; } - Arc::make_mut(thread).metadata.title = draft_display_label_for_thread_metadata( + if let Some((label, kind)) = draft_display_label_for_thread_metadata( &thread.metadata, &thread.workspace, cx, - ); + ) { + let thread = Arc::make_mut(thread); + thread.metadata.title = Some(label); + thread.draft = Some(kind); + } } - threads.retain(|thread| !thread.is_draft || thread.metadata.title.is_some()); + threads.retain(|thread| thread.draft.is_none() || thread.metadata.title.is_some()); + + // Keep empty drafts only while their thread is active; preserve + // drafts with content because they hold user-typed state. + let pending_activation = self.pending_thread_activation; + let active_panel_thread_id = active_workspace + .as_ref() + .and_then(|ws| ws.read(cx).panel::(cx)) + .and_then(|panel| panel.read(cx).active_thread_id(cx)); + threads.retain(|thread| { + if thread.draft != Some(DraftKind::Empty) { + return true; + } + if pending_activation.is_some() { + return false; + } + Some(thread.metadata.thread_id) == active_panel_thread_id + }); // Build a lookup from live_infos and compute running/waiting // counts in a single pass. @@ -1679,23 +1706,13 @@ impl Sidebar { .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) .any(|metadata| { let workspace = resolve_workspace(metadata.folder_paths()); - thread_metadata_would_render_sidebar_row( - metadata, - &workspace, - &hidden_draft_thread_ids, - cx, - ) + thread_metadata_would_render_sidebar_row(metadata, &workspace, cx) }) || store .entries_for_path(group_key.path_list(), group_host.as_ref()) .any(|metadata| { let workspace = resolve_workspace(metadata.folder_paths()); - thread_metadata_would_render_sidebar_row( - metadata, - &workspace, - &hidden_draft_thread_ids, - cx, - ) + thread_metadata_would_render_sidebar_row(metadata, &workspace, cx) }) }; let has_threads = has_visible_rows || has_stored_thread_rows; @@ -5174,7 +5191,7 @@ impl Sidebar { } AgentThreadStatus::Completed | AgentThreadStatus::Error => {} } - if thread.is_draft { + if thread.draft.is_some() { let workspace = thread.workspace.clone(); let draft_id = thread.metadata.thread_id; self.remove_draft(draft_id, &workspace, window, cx); @@ -5236,6 +5253,9 @@ impl Sidebar { ) { fn display_time(entry: &ListEntry) -> DateTime { match entry { + ListEntry::Thread(thread) if thread.draft == Some(DraftKind::Empty) => { + DateTime::::MAX_UTC + } ListEntry::Thread(thread) => Sidebar::thread_display_time(&thread.metadata), ListEntry::Terminal(terminal) => terminal.metadata.created_at, ListEntry::ProjectHeader { .. } => unreachable!(), @@ -5297,6 +5317,9 @@ impl Sidebar { None } ListEntry::Thread(thread) => { + if thread.draft == Some(DraftKind::Empty) { + return None; + } let workspace = match &thread.workspace { ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()), ThreadEntryWorkspace::Closed { .. } => { @@ -5333,7 +5356,7 @@ impl Sidebar { }) .collect(), diff_stats: thread.diff_stats, - is_draft: thread.is_draft, + is_draft: thread.draft.is_some(), is_title_generating: thread.is_title_generating, notified, timestamp, @@ -5633,7 +5656,8 @@ impl Sidebar { let is_hovered = self.hovered_thread_index == Some(ix); let is_selected = is_active; - let is_draft = thread.is_draft; + let is_draft = thread.draft.is_some(); + let is_empty_draft = thread.draft == Some(DraftKind::Empty); let is_running = matches!( thread.status, AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation @@ -5652,7 +5676,11 @@ impl Sidebar { .title_bar_background .blend(color.panel_background.opacity(0.25)); - let timestamp = format_history_entry_timestamp(Self::thread_display_time(&thread.metadata)); + let timestamp: SharedString = if is_empty_draft { + SharedString::default() + } else { + format_history_entry_timestamp(Self::thread_display_time(&thread.metadata)).into() + }; let is_remote = thread.workspace.is_remote(cx); @@ -5750,64 +5778,72 @@ impl Sidebar { }) }); - let contextual_action = if is_running { - IconButton::new("stop-thread", IconName::Stop) - .icon_size(IconSize::Small) - .icon_color(Color::Error) - .style(ButtonStyle::Tinted(TintColor::Error)) - .tooltip(Tooltip::text("Stop Generation")) - .on_click(cx.listener(move |this, _, _window, cx| { - this.stop_thread(&thread_id_for_actions, cx); - })) - .into_any_element() - } else if is_draft { - IconButton::new("discard_thread", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Discard Draft")) - .on_click({ - let thread_workspace = thread_workspace.clone(); - cx.listener(move |this, _, window, cx| { - this.remove_draft( - thread_id_for_actions, - &thread_workspace, - window, - cx, - ); - }) - }) - .into_any_element() + let contextual_action: Option = if is_running { + Some( + IconButton::new("stop-thread", IconName::Stop) + .icon_size(IconSize::Small) + .icon_color(Color::Error) + .style(ButtonStyle::Tinted(TintColor::Error)) + .tooltip(Tooltip::text("Stop Generation")) + .on_click(cx.listener(move |this, _, _window, cx| { + this.stop_thread(&thread_id_for_actions, cx); + })) + .into_any_element(), + ) } else { - IconButton::new("archive-thread", IconName::Archive) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Archive Thread", - &ArchiveSelectedThread, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let session_id = session_id_for_delete.clone(); - cx.listener(move |this, _, window, cx| { - if let Some(ref session_id) = session_id { - this.archive_thread(session_id, window, cx); - } - }) - }) - .into_any_element() + match thread.draft { + Some(DraftKind::Empty) => None, + Some(DraftKind::WithContent) => Some( + IconButton::new("discard_thread", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Discard Draft")) + .on_click({ + let thread_workspace = thread_workspace.clone(); + cx.listener(move |this, _, window, cx| { + this.remove_draft( + thread_id_for_actions, + &thread_workspace, + window, + cx, + ); + }) + }) + .into_any_element(), + ), + None => Some( + IconButton::new("archive-thread", IconName::Archive) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Archive Thread", + &ArchiveSelectedThread, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let session_id = session_id_for_delete.clone(); + cx.listener(move |this, _, window, cx| { + if let Some(ref session_id) = session_id { + this.archive_thread(session_id, window, cx); + } + }) + }) + .into_any_element(), + ), + } }; this.action_slot( h_flex() .gap_0p5() .child(rename_button) - .child(contextual_action), + .when_some(contextual_action, |this, action| this.child(action)), ) }) .on_click({ diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index c1a060953335e6..4b50b693b52e49 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -1110,7 +1110,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { is_live: false, is_background: false, is_title_generating: false, - is_draft: false, + draft: None, highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), @@ -1137,7 +1137,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { is_live: true, is_background: false, is_title_generating: false, - is_draft: false, + draft: None, highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), @@ -1164,7 +1164,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { is_live: true, is_background: false, is_title_generating: false, - is_draft: false, + draft: None, highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), @@ -1192,7 +1192,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { is_live: false, is_background: false, is_title_generating: false, - is_draft: false, + draft: None, highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), @@ -1220,7 +1220,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { is_live: true, is_background: true, is_title_generating: false, - is_draft: false, + draft: None, highlight_positions: Vec::new(), worktrees: Vec::new(), diff_stats: DiffStats::default(), @@ -1790,14 +1790,17 @@ async fn test_closing_last_agent_panel_terminal_restores_empty_header(cx: &mut T assert!(!panel.has_terminal(terminal_id)); assert!( panel.active_view_is_new_draft(cx), - "closing the active terminal should leave the panel on a hidden empty draft" + "closing the active terminal should leave the panel on its empty draft" ); }); + // Closing the terminal drops the user back onto the panel's empty + // draft. The sidebar mirrors that with a "New {agent} Thread" + // placeholder row, so the header reports having threads. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]"] + vec!["v [my-project]", " New Zed Agent Thread"] ); - assert_project_header_has_threads(&sidebar, "my-project", false, cx); + assert_project_header_has_threads(&sidebar, "my-project", true, cx); let project_group_key = multi_workspace.read_with(cx, |multi_workspace, cx| { multi_workspace.workspace().read(cx).project_group_key(cx) @@ -1807,11 +1810,13 @@ async fn test_closing_last_agent_panel_terminal_restores_empty_header(cx: &mut T }); cx.run_until_parked(); + // Collapsed: header hides children but still reports the placeholder + // as a thread present in the group. assert_eq!( visible_entries_as_strings(&sidebar, cx), vec!["> [my-project]"] ); - assert_project_header_has_threads(&sidebar, "my-project", false, cx); + assert_project_header_has_threads(&sidebar, "my-project", true, cx); } #[gpui::test] @@ -5435,7 +5440,7 @@ async fn test_draft_title_updates_from_editor_text(cx: &mut TestAppContext) { .iter() .find_map(|entry| match entry { ListEntry::Thread(thread) - if thread.is_draft && thread.metadata.thread_id == draft_id => + if thread.draft.is_some() && thread.metadata.thread_id == draft_id => { Some(thread.metadata.display_title()) } @@ -5564,18 +5569,30 @@ async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) { first_id, second_id, "an empty draft should be reused, not replaced" ); - let draft_rows = sidebar.read_with(cx, |sidebar, _| { + // The active empty draft is surfaced in the sidebar as a single + // "New {agent} Thread" placeholder so the sidebar mirrors the panel. + let draft_rows: Vec<_> = sidebar.read_with(cx, |sidebar, _| { sidebar .contents .entries .iter() - .filter(|entry| matches!(entry, ListEntry::Thread(t) if t.is_draft)) - .count() + .filter_map(|entry| match entry { + ListEntry::Thread(t) if t.draft.is_some() => Some(t.clone()), + _ => None, + }) + .collect() }); assert_eq!( - draft_rows, 0, - "active ephemeral draft should not appear as a sidebar row" + draft_rows.len(), + 1, + "active empty draft should appear as exactly one placeholder row" + ); + assert_eq!( + draft_rows[0].draft, + Some(DraftKind::Empty), + "the row should be the empty-draft placeholder" ); + assert_eq!(draft_rows[0].metadata.thread_id, first_id); } #[gpui::test] @@ -5614,25 +5631,87 @@ async fn test_plus_button_parks_nonempty_draft(cx: &mut TestAppContext) { "non-empty draft should be parked and a fresh draft activated" ); - // The parked (now non-active) first draft shows as a sidebar row with - // its editor-derived title. - let parked_titles: Vec = sidebar.read_with(cx, |sidebar, _| { + // Both drafts now appear as sidebar rows: the parked one with its + // editor-derived title (real user state), and the newly-created empty + // draft as a "New {agent} Thread" placeholder. The placeholder mirrors + // the panel's current view; the parked row preserves typed content. + let draft_rows: Vec<_> = sidebar.read_with(cx, |sidebar, _| { sidebar .contents .entries .iter() .filter_map(|entry| match entry { - ListEntry::Thread(t) if t.is_draft => Some(t.metadata.display_title()), + ListEntry::Thread(t) if t.draft.is_some() => Some(t.clone()), _ => None, }) .collect() }); assert_eq!( - parked_titles.len(), - 1, - "expected the parked draft to be visible as a sidebar row, got {parked_titles:?}" + draft_rows.len(), + 2, + "expected two draft rows (parked + new empty placeholder), got {:?}", + draft_rows + .iter() + .map(|t| t.metadata.display_title()) + .collect::>() + ); + let parked = draft_rows + .iter() + .find(|t| t.metadata.thread_id == first_id) + .expect("parked draft should be present"); + assert_eq!( + parked.draft, + Some(DraftKind::WithContent), + "the parked draft has user content and is not an empty placeholder" + ); + let new_empty = draft_rows + .iter() + .find(|t| t.metadata.thread_id == second_id) + .expect("new empty draft should be present"); + assert_eq!( + new_empty.draft, + Some(DraftKind::Empty), + "the freshly-created draft should be an empty placeholder" + ); + assert_eq!( + parked.metadata.display_title().as_ref(), + "something the user typed" + ); + + // Reproduce the real-world inversion deterministically: parking + // re-saves the filled draft, which can leave its display time newer + // than the brand-new empty draft's. Force that here by pushing the + // parked draft's `updated_at` into the future. + cx.update(|_, cx| { + let store = ThreadMetadataStore::global(cx); + let mut parked_meta = store + .read(cx) + .entry(first_id) + .expect("parked draft metadata should exist") + .clone(); + parked_meta.interacted_at = None; + parked_meta.updated_at = Utc::now() + chrono::Duration::hours(1); + store.update(cx, |store, cx| store.save(parked_meta, cx)); + }); + cx.run_until_parked(); + + // The empty-draft placeholder must still sort ABOVE the parked draft + // despite the parked draft's newer timestamp — it's pinned to the top. + let (empty_ix, parked_ix) = sidebar.read_with(cx, |sidebar, _| { + let position = |id: ThreadId| { + sidebar.contents.entries.iter().position( + |entry| matches!(entry, ListEntry::Thread(t) if t.metadata.thread_id == id), + ) + }; + ( + position(second_id).expect("empty draft row should be present"), + position(first_id).expect("parked draft row should be present"), + ) + }); + assert!( + empty_ix < parked_ix, + "the new empty draft (ix {empty_ix}) should sort above the parked filled draft (ix {parked_ix})" ); - assert_eq!(parked_titles[0].as_ref(), "something the user typed"); } #[gpui::test] @@ -5757,8 +5836,9 @@ async fn test_sending_message_from_draft_promotes_in_place(cx: &mut TestAppConte #[gpui::test] async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { // When the user presses Cmd-N (NewThread action) while viewing a - // non-empty thread, the panel should switch to the draft thread. - // Drafts are not shown as sidebar rows. + // non-empty thread, the panel should switch to the draft thread and + // the sidebar should surface a "New {agent} Thread" placeholder row + // that mirrors the active empty draft. let project = init_test_project_with_agent_panel("/my-project", cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -5795,11 +5875,12 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { }); cx.run_until_parked(); - // Drafts are not shown as sidebar rows, so entries stay the same. + // After Cmd-N the sidebar surfaces the active empty draft as a + // placeholder row above the real thread. assert_eq!( visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"], - "After Cmd-N the sidebar should not show a Draft entry" + vec!["v [my-project]", " New stub Thread", " Hello *"], + "After Cmd-N the sidebar should show a placeholder row for the active empty draft" ); // The panel should be on the draft and active_entry should track it. @@ -5821,8 +5902,8 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { #[gpui::test] async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { // When the active workspace is an absorbed git worktree, cmd-n - // should activate the draft thread in the panel. Drafts are not - // shown as sidebar rows. + // should activate the draft thread in the panel and the sidebar + // should surface a placeholder row for the active empty draft. agent_ui::test_support::init_test(cx); cx.update(|cx| { ThreadStore::init_global(cx); @@ -5916,15 +5997,18 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp }); cx.run_until_parked(); - // Drafts are not shown as sidebar rows, so entries stay the same. + // After Cmd-N the sidebar surfaces the active empty draft as a + // placeholder row. Its worktree chip identifies which workspace it + // belongs to (the linked worktree). assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ // "v [project]", - " Hello {wt-feature-a} *" + " New stub Thread {wt-feature-a}", + " Hello {wt-feature-a} *", ], - "After Cmd-N the sidebar should not show a Draft entry" + "After Cmd-N the sidebar should show a placeholder row for the active empty draft" ); // The panel should be on the draft and active_entry should track it. @@ -5944,14 +6028,16 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp } #[gpui::test] -async fn test_all_ephemeral_drafts_in_group_are_hidden_from_sidebar(cx: &mut TestAppContext) { - // An ephemeral new-draft is surfaced through its panel's `+` button, - // never as a sidebar row. This rule must hold: - // 1. For every workspace in a project group (not just the active one). - // 2. Whether or not the draft is the active view of its panel — - // i.e. even when the user has navigated away to a real thread - // within that same panel, the draft stays in the new-draft slot - // and stays out of the sidebar. +async fn test_only_actively_viewed_empty_draft_is_visible_in_sidebar(cx: &mut TestAppContext) { + // The sidebar surfaces an empty-draft placeholder row only for the + // draft that the *active workspace's panel* is currently viewing. + // Specifically: + // 1. Empty ephemeral drafts in non-active workspaces (e.g. a + // sibling linked-worktree panel) are hidden. + // 2. An empty ephemeral that is parked in its slot while the user + // is viewing a real thread is hidden (it's not the active view). + // 3. When the active workspace switches, the placeholder follows + // the new active panel's current view. agent_ui::test_support::init_test(cx); cx.update(|cx| { ThreadStore::init_global(cx); @@ -5995,6 +6081,10 @@ async fn test_all_ephemeral_drafts_in_group_are_hidden_from_sidebar(cx: &mut Tes let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); let (sidebar, main_panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + // `mw.workspace()` returns the *currently active* workspace, so we + // capture the main one here before adding the worktree workspace + // (which would make it the active one). + let main_workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { mw.test_add_workspace(worktree_project.clone(), window, cx) }); @@ -6016,8 +6106,6 @@ async fn test_all_ephemeral_drafts_in_group_are_hidden_from_sidebar(cx: &mut Tes // Now open a fresh ephemeral draft in the main panel. agent_ui::test_support::open_draft_with_connection(&main_panel, StubAgentConnection::new(), cx); cx.run_until_parked(); - let main_draft_id = main_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap()); - assert_ne!(main_draft_id, main_real_thread_id); // And an ephemeral draft in the worktree panel as well. agent_ui::test_support::open_draft_with_connection( @@ -6026,33 +6114,61 @@ async fn test_all_ephemeral_drafts_in_group_are_hidden_from_sidebar(cx: &mut Tes cx, ); cx.run_until_parked(); - let worktree_draft_id = - worktree_panel.read_with(cx, |panel, cx| panel.active_thread_id(cx).unwrap()); - assert_ne!(main_draft_id, worktree_draft_id); - let is_draft_row_visible = - |sidebar: &Entity, cx: &mut gpui::VisualTestContext, id: ThreadId| -> bool { + // `open_draft_with_connection` focuses the panel it's called on, + // which makes that workspace active. Explicitly re-activate the main + // workspace so the baseline assertions below describe the + // "main-workspace-is-active" case independently of call order above. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate(main_workspace.clone(), None, window, cx); + }); + cx.run_until_parked(); + + // The invariant under test is: at most one empty-draft placeholder is + // visible at a time, and it corresponds to the active workspace's + // panel's currently-active draft. Counting `is_empty_draft` rows is + // more robust than tracking specific thread_ids because draft + // creation flows can leave behind orphan ephemeral metadata that's + // also hidden by the filter. + let empty_draft_rows = + |sidebar: &Entity, cx: &mut gpui::VisualTestContext| -> Vec { sidebar.read_with(cx, |sidebar, _| { - sidebar.contents.entries.iter().any( - |entry| matches!(entry, ListEntry::Thread(t) if t.metadata.thread_id == id), - ) + sidebar + .contents + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::Thread(t) if t.draft == Some(DraftKind::Empty) => { + Some(t.metadata.thread_id) + } + _ => None, + }) + .collect() + }) + }; + let active_panel_draft_id = + |panel: &Entity, cx: &mut gpui::VisualTestContext| -> Option { + panel.read_with(cx, |panel, cx| { + panel + .active_thread_id(cx) + .filter(|_| panel.active_thread_is_draft(cx)) }) }; - // Baseline: both ephemeral drafts are hidden while each is the - // active view of its own panel. - assert!( - !is_draft_row_visible(&sidebar, cx, main_draft_id), - "main panel's ephemeral draft should be hidden while it is active" - ); - assert!( - !is_draft_row_visible(&sidebar, cx, worktree_draft_id), - "worktree panel's ephemeral draft should be hidden while it is active" + // Baseline: main workspace active, main panel viewing its draft. + // Exactly one placeholder visible, matching the main panel's draft. + let main_active_draft = + active_panel_draft_id(&main_panel, cx).expect("main panel should be viewing a draft"); + let visible = empty_draft_rows(&sidebar, cx); + assert_eq!( + visible, + vec![main_active_draft], + "exactly the main panel's active empty draft should be visible" ); // Navigate the main panel AWAY from its draft to the real thread. - // The draft is no longer the active view of its panel, but it is - // still in the ephemeral slot — it must stay out of the sidebar. + // The draft is no longer the active view of its panel, so its + // placeholder must disappear from the sidebar. main_panel.update_in(cx, |panel, window, cx| { panel.load_agent_thread( agent_ui::Agent::NativeAgent, @@ -6073,36 +6189,26 @@ async fn test_all_ephemeral_drafts_in_group_are_hidden_from_sidebar(cx: &mut Tes Some(main_real_thread_id), "main panel should now be viewing the real thread" ); - assert_eq!( - panel.ephemeral_draft_thread_id(cx), - Some(main_draft_id), - "the ephemeral draft slot should still hold the parked draft" - ); }); - - assert!( - !is_draft_row_visible(&sidebar, cx, main_draft_id), - "parked ephemeral draft should stay hidden when the panel's active view is a real thread" - ); assert!( - !is_draft_row_visible(&sidebar, cx, worktree_draft_id), - "worktree panel's ephemeral draft should also stay hidden" + empty_draft_rows(&sidebar, cx).is_empty(), + "no placeholder should be visible: main panel is on a real thread and worktree workspace is inactive" ); - // Switch the active workspace to the worktree: all of the above - // assertions still hold, from the other side. + // Switch the active workspace to the worktree. Now the worktree + // panel's draft is the active view, so its placeholder appears. multi_workspace.update_in(cx, |mw, window, cx| { mw.activate(worktree_workspace.clone(), None, window, cx); }); cx.run_until_parked(); - assert!( - !is_draft_row_visible(&sidebar, cx, main_draft_id), - "main panel's parked ephemeral draft should stay hidden when worktree is active" - ); - assert!( - !is_draft_row_visible(&sidebar, cx, worktree_draft_id), - "worktree panel's ephemeral draft should stay hidden when worktree is active" + let worktree_active_draft = active_panel_draft_id(&worktree_panel, cx) + .expect("worktree panel should be viewing a draft"); + let visible = empty_draft_rows(&sidebar, cx); + assert_eq!( + visible, + vec![worktree_active_draft], + "exactly the worktree panel's active empty draft should be visible after switching workspaces" ); } diff --git a/crates/skill_creator/src/skill_creator.rs b/crates/skill_creator/src/skill_creator.rs index ffbe28801ad135..947302df9d9ac1 100644 --- a/crates/skill_creator/src/skill_creator.rs +++ b/crates/skill_creator/src/skill_creator.rs @@ -898,7 +898,7 @@ impl SkillCreator { // than squeezing the body editor below its minimum height. v_flex() .id("skill-creator-form-fields") - .flex_grow() + .flex_grow_1() .flex_shrink_0() .gap_4() .child( @@ -914,7 +914,7 @@ impl SkillCreator { .child(Divider::horizontal()) .child( v_flex() - .flex_grow() + .flex_grow_1() .flex_shrink_0() .gap_2() .child(Label::new("Skill Content")) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 48cd2768253c11..c1c0dbb1889f4d 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1427,7 +1427,7 @@ impl Item for TerminalView { v_flex() .gap_1() .child(Label::new(title.clone())) - .child(h_flex().flex_grow().child(Divider::horizontal())) + .child(h_flex().flex_grow_1().child(Divider::horizontal())) .child( Label::new(format!("Process ID (PID): {}", pid)) .color(Color::Muted) diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index 7871ea3e98ce36..6b432726fdb88b 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/crates/ui/src/components/button/split_button.rs @@ -76,7 +76,7 @@ impl RenderOnce for SplitButton { .when(self.style == SplitButtonStyle::Transparent, |this| { this.gap_px() }) - .child(div().flex_grow().child(match self.left { + .child(div().flex_grow_1().child(match self.left { SplitButtonKind::ButtonLike(button) => button.into_any_element(), SplitButtonKind::IconButton(icon) => icon.into_any_element(), })) diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 42f6a9150fb106..77061dbfa71f60 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -652,7 +652,7 @@ pub fn render_table_row( // restrict_scroll_to_axis lets vertical scroll events pass through to the list. let mut scrollable_section = div() .id(("table-row-scrollable", row_index as u64)) - .flex_grow() + .flex_grow_1() .overflow_x_scroll() .flex() .child( @@ -769,7 +769,7 @@ pub fn render_table_header( ); let mut scrollable_section = div() .id("table-header-scrollable") - .flex_grow() + .flex_grow_1() .overflow_x_scroll() .flex() .child(inner); @@ -1136,7 +1136,7 @@ impl RenderOnce for Table { }) .child({ let content = div() - .flex_grow() + .flex_grow_1() .w_full() .relative() .overflow_hidden() @@ -1180,7 +1180,7 @@ impl RenderOnce for Table { }, ) .size_full() - .flex_grow() + .flex_grow_1() .with_sizing_behavior(ListSizingBehavior::Auto) .with_horizontal_sizing_behavior(horizontal_sizing) .when_some( @@ -1208,7 +1208,7 @@ impl RenderOnce for Table { } }) .size_full() - .flex_grow() + .flex_grow_1() .with_sizing_behavior(ListSizingBehavior::Auto), ), }) @@ -1237,7 +1237,7 @@ impl RenderOnce for Table { let mut h_scroll_container = div() .id("table-h-scroll") .overflow_x_scroll() - .flex_grow() + .flex_grow_1() .h_full() .track_scroll(&state.read(cx).horizontal_scroll_handle) .child(table); diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 06be27abab2429..8f5ccadcaf2c7d 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -336,7 +336,7 @@ impl RenderOnce for ListItem { })) .child( h_flex() - .flex_grow() + .flex_grow_1() .flex_shrink_0() .flex_basis(relative(0.25)) .gap(DynamicSpacing::Base06.rems(cx)) @@ -354,16 +354,16 @@ impl RenderOnce for ListItem { .when_some(self.end_slot, |this, end_slot| { this.child(match self.end_slot_visibility { EndSlotVisibility::Always => { - h_flex().flex_shrink().overflow_hidden().child(end_slot) + h_flex().flex_shrink_1().overflow_hidden().child(end_slot) } EndSlotVisibility::OnHover => h_flex() - .flex_shrink() + .flex_shrink_1() .overflow_hidden() .visible_on_hover("list_item") .child(end_slot), EndSlotVisibility::SwapOnHover(hover_slot) => h_flex() .relative() - .flex_shrink() + .flex_shrink_1() .child(h_flex().visible_on_hover("list_item").child(hover_slot)) .child( h_flex() diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 2ce1c53496b4f3..0031ba2e200603 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -141,7 +141,7 @@ impl RenderOnce for TabBar { .child( div() .id("tabs") - .flex_grow() + .flex_grow_1() .when(self.vertical_stacking, |this| { this.flex().flex_wrap().overflow_hidden() }) diff --git a/crates/ui/src/components/tree_view_item.rs b/crates/ui/src/components/tree_view_item.rs index 9474548675dfa7..31cdfd33bdd5df 100644 --- a/crates/ui/src/components/tree_view_item.rs +++ b/crates/ui/src/components/tree_view_item.rs @@ -193,7 +193,7 @@ impl RenderOnce for TreeViewItem { h_flex() .id("nested_inner_tree_view_item") .w_full() - .flex_grow() + .flex_grow_1() .child( Label::new(label) .when(!self.selected, |this| this.color(Color::Muted)), diff --git a/crates/ui_input/src/input_field.rs b/crates/ui_input/src/input_field.rs index 8a95651950af3d..b384fd388ed33e 100644 --- a/crates/ui_input/src/input_field.rs +++ b/crates/ui_input/src/input_field.rs @@ -176,7 +176,7 @@ impl Render for InputField { .w_full() .px_2() .py_1p5() - .flex_grow() + .flex_grow_1() .text_color(style.text_color) .rounded_md() .bg(style.background_color) diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index f5a3e7854082cf..1abe0818e16bb2 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -2628,9 +2628,6 @@ fn matching( display_point: DisplayPoint, match_quotes: bool, ) -> DisplayPoint { - if !map.is_singleton() { - return display_point; - } // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1200 let display_point = map.clip_at_line_end(display_point); let point = display_point.to_point(map); @@ -2667,6 +2664,11 @@ fn matching( let is_quote_char = |ch: char| matches!(ch, '\'' | '"' | '`'); + // The filter receives buffer-local ranges, not multibuffer offsets. + let buffer_offset = snapshot + .point_to_buffer_offset(offset) + .map(|(_, buffer_offset)| buffer_offset); + let make_range_filter = |require_on_bracket: bool| { move |buffer: &language::BufferSnapshot, opening_range: Range, @@ -2683,8 +2685,9 @@ fn matching( if require_on_bracket { // Attempt to find the smallest enclosing bracket range that also contains // the offset, which only happens if the cursor is currently in a bracket. - opening_range.contains(&BufferOffset(offset.0)) - || closing_range.contains(&BufferOffset(offset.0)) + buffer_offset.is_some_and(|buffer_offset| { + opening_range.contains(&buffer_offset) || closing_range.contains(&buffer_offset) + }) } else { true } @@ -3425,7 +3428,9 @@ mod test { state::Mode, test::{NeovimBackedTestContext, VimTestContext}, }; - use editor::Inlay; + use editor::{ + Editor, EditorMode, Inlay, MultiBuffer, test::editor_test_context::EditorTestContext, + }; use gpui::KeyBinding; use indoc::indoc; use language::Point; @@ -3572,6 +3577,85 @@ mod test { cx.shared_state().await.assert_eq("func boop(ˇ) {\n}"); } + #[gpui::test] + async fn test_matching_in_multibuffer(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + let (editor, cx) = cx.add_window_view(|window, cx| { + let multi_buffer = MultiBuffer::build_multi( + [ + ( + "fn a() {\n let x = 1;\n}\n", + vec![Point::row_range(0..3)], + ), + ( + "fn b() {\n let y = 2;\n}\n", + vec![Point::row_range(0..3)], + ), + ], + cx, + ); + + let buffer_ids = multi_buffer + .read(cx) + .snapshot(cx) + .excerpts() + .map(|excerpt| excerpt.context.start.buffer_id) + .collect::>(); + + for buffer_id in buffer_ids { + if let Some(buffer) = multi_buffer.read(cx).buffer(buffer_id) { + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(language::rust_lang()), cx); + }); + } + } + + Editor::new(EditorMode::full(), multi_buffer, None, window, cx) + }); + + let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await; + + cx.simulate_keystrokes("j j j j f {"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + fn a() { + let x = 1; + } + [EXCERPT] + fn b() ˇ{ + let y = 2; + } + " + }); + + cx.simulate_keystrokes("%"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + fn a() { + let x = 1; + } + [EXCERPT] + fn b() { + let y = 2; + ˇ} + " + }); + + cx.simulate_keystrokes("%"); + cx.assert_excerpts_with_selections(indoc! {" + [EXCERPT] + fn a() { + let x = 1; + } + [EXCERPT] + fn b() ˇ{ + let y = 2; + } + " + }); + } + #[gpui::test] async fn test_matching_quotes_disabled(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index b60434eabb572b..8d8fbe7cd86283 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -3708,7 +3708,7 @@ impl Pane { .id("tab_bar_drop_target") .min_w_6() .h(Tab::container_height(cx)) - .flex_grow() + .flex_grow_1() // HACK: This empty child is currently necessary to force the drop target to appear // despite us setting a min width above. .child("") @@ -3752,7 +3752,7 @@ impl Pane { .debug_selector(|| "pinned_tabs_border".into()) .min_w_6() .h(Tab::container_height(cx)) - .flex_grow() + .flex_grow_1() .border_l_1() .border_color(cx.theme().colors().border) // HACK: This empty child is currently necessary to force the drop target to appear diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ff70949e40ea49..68c4a35f5cbb1c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3704,8 +3704,23 @@ impl Workspace { let fs = fs.clone(); let pane = pane.clone(); let task = cx.spawn(async move |cx| { - let (_worktree, project_path) = project_path?; - if fs.is_dir(&abs_path).await { + let (worktree, project_path) = project_path?; + let (entry_is_directory, worktree_is_local) = + worktree.read_with(cx, |worktree, _| { + let entry = if project_path.path.as_unix_str().is_empty() { + worktree.root_entry() + } else { + worktree.entry_for_path(&project_path.path) + }; + (entry.map(|entry| entry.is_dir()), worktree.is_local()) + }); + let is_directory = match entry_is_directory { + Some(is_directory) => is_directory, + None if worktree_is_local => fs.is_dir(&abs_path).await, + None => false, + }; + + if is_directory { // Opening a directory should not race to update the active entry. // We'll select/reveal a deterministic final entry after all paths finish opening. None diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index ce2f34bc78d52d..9e6f58e0042562 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -3340,12 +3340,16 @@ async fn is_dot_git(path: &Path, fs: &dyn Fs) -> bool { } async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result { + let parent = abs_path.parent().unwrap_or_else(|| Path::new("/")); + build_gitignore_with_root(abs_path, parent, fs).await +} + +async fn build_gitignore_with_root(abs_path: &Path, root: &Path, fs: &dyn Fs) -> Result { let contents = fs .load(abs_path) .await .with_context(|| format!("failed to load gitignore file at {}", abs_path.display()))?; - let parent = abs_path.parent().unwrap_or_else(|| Path::new("/")); - let mut builder = GitignoreBuilder::new(parent); + let mut builder = GitignoreBuilder::new(root); for line in contents.lines() { builder.add_line(Some(abs_path.into()), line)?; } @@ -5329,7 +5333,9 @@ impl BackgroundScanner { // Load gitignores asynchronously (outside the lock) let mut loaded_excludes: Vec<(Arc, Arc)> = Vec::new(); for (work_dir_abs_path, exclude_abs_path) in excludes_to_load { - if let Ok(current_exclude) = build_gitignore(&exclude_abs_path, self.fs.as_ref()).await + if let Ok(current_exclude) = + build_gitignore_with_root(&exclude_abs_path, &work_dir_abs_path, self.fs.as_ref()) + .await { loaded_excludes.push((work_dir_abs_path, Arc::new(current_exclude))); } @@ -5490,6 +5496,7 @@ impl BackgroundScanner { if SanitizedPath::new(repo.common_dir_abs_path.as_ref()) == dot_git_dir || SanitizedPath::new(repo.repository_dir_abs_path.as_ref()) == dot_git_dir + || SanitizedPath::new(repo.dot_git_abs_path.as_ref()) == dot_git_dir { Some(repo.clone()) } else { @@ -5545,10 +5552,7 @@ impl BackgroundScanner { }); if exists_in_snapshot - || matches!( - self.fs.metadata(&entry.common_dir_abs_path).await, - Ok(Some(_)) - ) + || matches!(self.fs.metadata(&entry.dot_git_abs_path).await, Ok(Some(_))) { ids_to_preserve.insert(work_directory_id); } @@ -5641,7 +5645,9 @@ async fn discover_ancestor_git_repo( let (_, common_dir_abs_path) = discover_git_paths(&dot_git_abs_path, fs.as_ref()).await; let repo_exclude_abs_path = common_dir_abs_path.join(REPO_EXCLUDE); - if let Ok(repo_exclude) = build_gitignore(&repo_exclude_abs_path, fs.as_ref()).await { + if let Ok(repo_exclude) = + build_gitignore_with_root(&repo_exclude_abs_path, ancestor, fs.as_ref()).await + { exclude = Some(Arc::new(repo_exclude)); } diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index 2ae248ad0e4053..b98f4517696aec 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -3017,6 +3017,69 @@ async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext }); } +#[gpui::test] +async fn test_repo_exclude_anchored_pattern(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor); + let project_dir = Path::new(path!("/project")); + fs.insert_tree( + project_dir, + json!({ + ".git": { + "info": { + "exclude": "vendor/cache" + } + }, + "vendor": { + "cache": { + "blob.bin": "", + }, + "keep.txt": "", + }, + "elsewhere": { + "vendor": { + "cache": { + "blob.bin": "", + }, + }, + }, + }), + ) + .await; + + let worktree = Worktree::local( + project_dir, + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + worktree + .update(cx, |worktree, _| { + worktree.as_local().unwrap().scan_complete() + }) + .await; + cx.run_until_parked(); + + // An anchored pattern (containing a `/`) is matched relative to the work + // tree root, so only the top-level `vendor/cache` is ignored. + worktree.update(cx, |worktree, _cx| { + check_worktree_entries( + worktree, + WorktreeExpectations { + ignored_paths: &["vendor/cache"], + tracked_paths: &["vendor/keep.txt", "elsewhere/vendor/cache"], + ..Default::default() + }, + ); + }); +} + #[derive(Default)] struct WorktreeExpectations { excluded_paths: &'static [&'static str], @@ -3328,7 +3391,7 @@ async fn test_invisible_worktree_does_not_track_ancestor_git_repository( } #[gpui::test] -async fn test_linked_worktree_git_file_event_does_not_panic( +async fn test_linked_worktree_gitfile_event_preserves_repo( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { @@ -3342,19 +3405,11 @@ async fn test_linked_worktree_git_file_event_does_not_panic( // and `update_git_repositories` panics because the path is outside the // worktree root. init_test(cx); - use git::repository::Worktree as GitWorktree; let fs = FakeFs::new(executor); - - fs.insert_tree( - path!("/main_repo"), - json!({ - ".git": {}, - "file.txt": "content", - }), - ) - .await; + fs.insert_tree(path!("/main_repo"), json!({ ".git": {}, "file.txt": "" })) + .await; fs.add_linked_worktree_for_repo( Path::new(path!("/main_repo/.git")), false, @@ -3367,12 +3422,9 @@ async fn test_linked_worktree_git_file_event_does_not_panic( }, ) .await; - fs.write( - path!("/linked_worktree/file.txt").as_ref(), - "content".as_bytes(), - ) - .await - .unwrap(); + fs.write(path!("/linked_worktree/file.txt").as_ref(), b"content") + .await + .unwrap(); let tree = Worktree::local( path!("/linked_worktree").as_ref(), @@ -3389,17 +3441,19 @@ async fn test_linked_worktree_git_file_event_does_not_panic( .await; cx.run_until_parked(); - // Trigger a filesystem event inside the main repo's .git directory - // (which the linked worktree scanner watches via the commondir). This - // uses the sentinel-file helper to ensure the event goes through the - // real watcher path, exactly as it would in production. - tree.flush_fs_events_in_root_git_repository(cx).await; + // Overwrite the .git gitfile with garbage to trigger an event for the + // gitfile path itself, which only matches `dot_git_abs_path`. + fs.write(path!("/linked_worktree/.git").as_ref(), b"garbage") + .await + .unwrap(); + tree.flush_fs_events(cx).await; // The worktree should still be intact. tree.read_with(cx, |tree, _| { assert_eq!( tree.snapshot().root_repo_common_dir().map(|p| p.as_ref()), Some(Path::new(path!("/main_repo/.git"))), + "linked worktree repo should survive a gitfile change event" ); }); } diff --git a/crates/zed/src/zed/quick_action_bar/repl_menu.rs b/crates/zed/src/zed/quick_action_bar/repl_menu.rs index 7b694281b99561..7d8b6ae7694dc0 100644 --- a/crates/zed/src/zed/quick_action_bar/repl_menu.rs +++ b/crates/zed/src/zed/quick_action_bar/repl_menu.rs @@ -344,7 +344,7 @@ impl QuickActionBar { .child( div() .overflow_x_hidden() - .flex_grow() + .flex_grow_1() .whitespace_nowrap() .child( Label::new(if let Some(name) = current_kernel_name { diff --git a/crates/zed/src/zed/telemetry_log.rs b/crates/zed/src/zed/telemetry_log.rs index 062fd5f0f28965..a935e29d94a6a0 100644 --- a/crates/zed/src/zed/telemetry_log.rs +++ b/crates/zed/src/zed/telemetry_log.rs @@ -518,7 +518,7 @@ impl Render for TelemetryLogView { } else { div() .size_full() - .flex_grow() + .flex_grow_1() .child( list(self.list_state.clone(), cx.processor(Self::render_entry)) .with_sizing_behavior(gpui::ListSizingBehavior::Auto) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index e657f6b6179dd1..b7d17bba887f71 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -255,7 +255,7 @@ Copying an image and pasting it is also supported. Zed surfaces how many tokens you are consuming for your currently active thread near the profile selector in the panel's message editor. Once you approach the model's context window, a banner appears above the message editor suggesting to start a new thread with the current one summarized and added as context. -You can also do this at any time with an ongoing thread via the "Agent Options" menu on the top right, where you'll see a "New from Summary" button, as well as simply @-mentioning a past thread in a new one.. +You can also do this at any time with an ongoing thread via the "Agent Options" menu on the top right, where you'll see a "New from Summary" button, as well as simply @-mentioning a past thread in a new one. ## Changing Models {#changing-models} diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index dacd149ca6289b..51de506c47fe03 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -62,6 +62,20 @@ You can assign distinct and specific models for the following AI-powered feature > If a custom model isn't set for one of these features, they automatically fall back to using the default model. +### Commit Message Instructions {#commit-message-instructions} + +You can provide custom instructions that are included in the prompt whenever a Git commit message is generated. Unlike rules files (such as `.rules` or `AGENTS.md`), these instructions apply only to commit message generation: + +```json [settings] +{ + "agent": { + "commit_message_instructions": "Use the Conventional Commits format: (): ." + } +} +``` + +These instructions are sent in addition to any project rules files that are present. + ### Alternative Models for Inline Assists {#alternative-assists} With the Inline Assistant in particular, you can send the same prompt to multiple models at once. diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index d5fc6750e83827..eb3a7875e68094 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -48,7 +48,7 @@ Zed Business consolidates your team's costs. Seat licenses and AI usage for all ### Billing dashboard {#dashboard} -Owners and admins can access billing information at [dashboard.zed.dev](https://dashboard.zed.dev). The dashboard shows the plan you're currently on and offers jumping off points to update billing details, such as the billing name and address, as well as payment information. You can also access your invoices history, accessible through the Orb billing portal. +Owners and admins can access billing information at [dashboard.zed.dev](https://dashboard.zed.dev). The dashboard shows the plan you're currently on and offers jumping off points to update billing details, such as the billing name and address, as well as payment information. You can also access your invoice history, accessible through the Orb billing portal. ### AI usage {#ai-usage} diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 1f5b3e8adcee44..fda36a39cdd6bf 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -111,7 +111,7 @@ After that, `alt-tab` remains available for accepting edit predictions, and on L To move both default accept bindings to something else, unbind them and add your replacement: -Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and delete it. Then right click on the binding for `alt-tab`, select "Edit", and record your desired keystrokes before hitting saving. +Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and delete it. Then right click on the binding for `alt-tab`, select "Edit", and record your desired keystrokes before saving. Alternatively, you can put the following in your `keymap.json`: diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index dd3f68d5b348b2..81a4ee5d3da803 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -237,7 +237,7 @@ It's also possible to customize environment variables for registry-installed age ## Debugging Agents -When using external agents in Zed, you can access the debug view via with {#action dev::OpenAcpLogs} from the Command Palette. +When using external agents in Zed, you can access the debug view via {#action dev::OpenAcpLogs} from the Command Palette. This lets you see the messages being sent and received between Zed and the agent. ![The debug view for ACP logs.](https://zed.dev/img/acp/acp-logs.webp) diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 3c08a960da8a6f..0c8645648c830a 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -515,7 +515,7 @@ One such service is [Ollama Turbo](https://ollama.com/turbo). To configure Zed t 4. Paste your API key and press enter. 5. For the API URL enter `https://ollama.com` -Zed will also use the `OLLAMA_API_KEY` environment variables if defined. +Zed will also use the `OLLAMA_API_KEY` environment variable if defined. ### OpenAI {#openai} diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index 6582508f5ca101..c3fedb94402bea 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -81,7 +81,7 @@ For example, the GitHub MCP extension requires you to add a [Personal Access Tok In the case of custom servers, make sure you check the provider documentation to determine what type of command, arguments, and environment variables need to be added to the JSON. To check if your MCP server is properly configured, go to the Agent Panel's settings view and watch the indicator dot next to its name. -If they're running correctly, the indicator will be green and its tooltip will say "Server is active". +If it's running correctly, the indicator will be green and its tooltip will say "Server is active". If not, other colors and tooltip messages will indicate what is happening. ### Agent Panel Usage @@ -162,7 +162,7 @@ For details on what configuration is shared between Zed and external agents, see ### Error Handling -When a MCP server encounters an error while processing a tool call, the agent receives the error message directly and the operation fails. +When an MCP server encounters an error while processing a tool call, the agent receives the error message directly and the operation fails. Common error scenarios include: - Invalid parameters passed to the tool diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md index d4e76534fd1b66..5113453a7ba8d1 100644 --- a/docs/src/configuring-languages.md +++ b/docs/src/configuring-languages.md @@ -249,7 +249,7 @@ Most of the servers would rely on this way of configuring only. } ``` -Apart of the LSP-related server configuration options, certain servers in Zed allow configuring the way binary is launched by Zed. +Apart from the LSP-related server configuration options, certain servers in Zed allow configuring the way binary is launched by Zed. Language servers are automatically downloaded or launched if found in your path, if you wish to specify an explicit alternate binary you can specify that in settings: diff --git a/docs/src/debugger.md b/docs/src/debugger.md index bf05de0f6ccccf..b503ff09849fc2 100644 --- a/docs/src/debugger.md +++ b/docs/src/debugger.md @@ -80,7 +80,7 @@ Which one you choose depends on what you are trying to achieve. When launching a new instance, Zed (and the underlying debug adapter) can often do a better job at picking up the debug information compared to attaching to an existing process, since it controls the lifetime of a whole program. Running unit tests or a debug build of your application is a good use case for launching. -Compared to launching, attaching to an existing process might seem inferior, but that's far from truth; there are cases where you cannot afford to restart your program, because for example, the bug is not reproducible outside of a production environment or some other circumstances. +Compared to launching, attaching to an existing process might seem inferior, but that's far from the truth; there are cases where you cannot afford to restart your program, because for example, the bug is not reproducible outside of a production environment or some other circumstances. ## Configuration diff --git a/docs/src/extensions/languages.md b/docs/src/extensions/languages.md index 121357306e7355..59f20d16de81b1 100644 --- a/docs/src/extensions/languages.md +++ b/docs/src/extensions/languages.md @@ -528,7 +528,7 @@ Each rule in the `semantic_token_rules` array is defined as follows: - `foreground_color`: The foreground color to use for the token type, in hex format (e.g., `"#ff0000"`). - `background_color`: The background color to use for the token type, in hex format (e.g., `"#ff0000"`). - `underline`: A boolean or color to underline with, in hex format. If `true`, then the token will be underlined with the text color. -- `strikethrough`: A boolean or color to strikethrough with, in hex format. If `true`, then the token have a strikethrough with the text color. +- `strikethrough`: A boolean or color to strikethrough with, in hex format. If `true`, then the token will have a strikethrough with the text color. - `font_weight`: One of `"normal"`, `"bold"`. - `font_style`: One of `"normal"`, `"italic"`. diff --git a/docs/src/git.md b/docs/src/git.md index 0d0fcc1a4e8caf..53c7227dbb3b44 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -293,6 +293,18 @@ See [Feature-specific models](./ai/agent-settings.md#feature-specific-models) fo To add custom commit instructions for the model, use the global `AGENTS.md` file located `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows. +To add custom instructions that apply only to commit message generation, use the `commit_message_instructions` field in your agent settings: + +```json [settings] +{ + "agent": { + "commit_message_instructions": "Use the Conventional Commits format: (): ." + } +} +``` + +These instructions are sent to the model in addition to any project rules files (such as `.rules` or `AGENTS.md`). To add instructions that apply to both commit messages and the agent more broadly, use the global `AGENTS.md` file located `~/.config/zed/AGENTS.md` on macOS and Linux, `%APPDATA%\Zed\AGENTS.md` on Windows. + > Before Zed v1.4.0, this was done through the Rules Library, which has been removed. > See [the "Migrating to Skills" docs](./ai/rules.md#migrating-to-skills) in the Rules page for more information. @@ -334,7 +346,7 @@ You can configure multiple custom providers if you work with several self-hosted Zed also has a Copy Permalink feature to create a permanent link to a code snippet on your Git hosting service. These links are useful for sharing a specific line or range of lines in a file at a specific commit. Trigger this action via the [Command Palette](./getting-started.md#command-palette) (search for `permalink`), -by creating a [custom key bindings](key-bindings.md#custom-key-bindings) to the +by creating [custom key bindings](key-bindings.md#custom-key-bindings) for the `editor::CopyPermalinkToLine` or `editor::OpenPermalinkToLine` actions or by simply right clicking and selecting `Copy Permalink` with line(s) selected in your editor. diff --git a/docs/src/globs.md b/docs/src/globs.md index f1fb584ee568d2..e72c7a92a31272 100644 --- a/docs/src/globs.md +++ b/docs/src/globs.md @@ -14,9 +14,9 @@ Zed uses two different rust crates for matching glob patterns: - [ignore crate](https://docs.rs/ignore/latest/ignore/) for matching glob patterns stored in `.gitignore` files - [glob crate](https://docs.rs/glob/latest/glob/) for matching file paths in Zed -While simple expressions are portable across environments (e.g. running `ls *.py` or `*.tmp` in a gitignore) there is significant divergence in the support for and syntax of more advanced features varies (character classes, exclusions, `**`, etc) across implementations. For the rest of this document we will be describing globs as supported in Zed via the `glob` crate implementation. Please see [References](#references) below for documentation links for glob pattern syntax for `.gitignore`, shells and other programming languages. +While simple expressions are portable across environments (e.g. running `ls *.py` or `*.tmp` in a gitignore) there is significant divergence in the support for and syntax of more advanced features (character classes, exclusions, `**`, etc) across implementations. For the rest of this document we will be describing globs as supported in Zed via the `glob` crate implementation. Please see [References](#references) below for documentation links for glob pattern syntax for `.gitignore`, shells and other programming languages. -The `glob` crate is implemented entirely in rust and does not rely on the `glob` / `fnmatch` interfaces provided by your platforms libc. This means that globs in Zed should behave similarly with across platforms. +The `glob` crate is implemented entirely in rust and does not rely on the `glob` / `fnmatch` interfaces provided by your platform's libc. This means that globs in Zed should behave similarly across platforms. ## Introduction @@ -71,7 +71,7 @@ If instead you wanted to restrict yourself only to [Zed Language-Specific Docume When using the "Include" / "Exclude" filters on a Project Search each glob is wrapped in implicit wildcards. For example to exclude any files with license in the path or filename from your search just type `license` in the exclude box. Behind the scenes Zed transforms `license` to `**license**`. This means that files named `license.*`, `*.license` or inside a `license` subdirectory will all be filtered out. This enables users to easily filter for `*.ts` without having to remember to type `**/*.ts` every time. -Alternatively, if in your Zed settings you wanted a [`file_types`](./reference/all-settings.md#file-types) override which only applied to a certain directory you must explicitly include the wildcard globs. For example, if you had a directory of template files with the `html` extension that you wanted to recognize as Jinja2 template you could use the following: +Alternatively, if in your Zed settings you wanted a [`file_types`](./reference/all-settings.md#file-types) override which only applied to a certain directory you must explicitly include the wildcard globs. For example, if you had a directory of template files with the `html` extension that you wanted to recognize as a Jinja2 template you could use the following: ```json [settings] { diff --git a/docs/src/installation.md b/docs/src/installation.md index 2c003da75574e5..152ba85102b81a 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -67,7 +67,7 @@ If this script is insufficient for your use case, you run into problems running ### macOS -Zed supports the follow macOS releases: +Zed supports the following macOS releases: | Version | Codename | Apple Status | Zed Status | | ------------- | -------- | -------------- | ------------------- | diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index ae64ab00b8ccd8..490293c9eba398 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -28,7 +28,7 @@ For more information, see the documentation for [Vim mode](./vim.md) and [Helix ## Keymap Editor -You can access the keymap editor through the {#kb zed::OpenKeymap} action or by running {#action zed::OpenKeymap} action from the command palette. You can easily add or change a keybind for an action with the `Change Keybinding` or `Add Keybinding` button on the command pallets left bottom corner. +You can access the keymap editor through the {#kb zed::OpenKeymap} action or by running {#action zed::OpenKeymap} action from the command palette. You can easily add or change a keybind for an action with the `Change Keybinding` or `Add Keybinding` button on the command palette's left bottom corner. In there, you can see all of the existing actions in Zed as well as the associated keybindings set to them by default. diff --git a/docs/src/languages.md b/docs/src/languages.md index b720e725cca816..7c0c618871ade6 100644 --- a/docs/src/languages.md +++ b/docs/src/languages.md @@ -6,7 +6,7 @@ description: "Overview of programming language support in Zed, including built-i # Language Support in Zed Zed supports hundreds of programming languages and text formats. -Some work out-of-the box and others rely on 3rd party extensions. +Some work out-of-the-box and others rely on 3rd party extensions. > The ones included out-of-the-box, natively built into Zed, are marked with \*. diff --git a/docs/src/languages/cpp.md b/docs/src/languages/cpp.md index 1f63460160cc1e..44025da5544315 100644 --- a/docs/src/languages/cpp.md +++ b/docs/src/languages/cpp.md @@ -80,7 +80,7 @@ You can pass any number of arguments to clangd. To see a full set of available o ## Formatting -By default Zed will use the `clangd` language server for formatting C++ code. The Clangd is the same as the `clang-format` CLI tool. To configure this you can add a `.clang-format` file. For example: +By default Zed will use the `clangd` language server for formatting C++ code. Its formatter is the same as the `clang-format` CLI tool. To configure this you can add a `.clang-format` file. For example: ```yaml # yaml-language-server: $schema=https://json.schemastore.org/clang-format-21.x.json diff --git a/docs/src/languages/lua.md b/docs/src/languages/lua.md index 27d3f613634547..861dbb710bfe94 100644 --- a/docs/src/languages/lua.md +++ b/docs/src/languages/lua.md @@ -27,7 +27,7 @@ See [LuaLS Settings Documentation](https://luals.github.io/wiki/settings/) for a ### LuaCATS Definitions -LuaLS can provide enhanced LSP autocompletion suggestions and type validation with the help of LuaCATS (Lua Comment and Type System) definitions. These definitions are available for many common Lua libraries, and local paths containing them can be specified via `workspace.library` in `luarc.json`. You can do this via relative paths if you checkout your definitions into the same partent directory of your project (`../playdate-luacats`, `../love2d`, etc). Alternatively you can create submodule(s) inside your project for each LuaCATS definition repo. +LuaLS can provide enhanced LSP autocompletion suggestions and type validation with the help of LuaCATS (Lua Comment and Type System) definitions. These definitions are available for many common Lua libraries, and local paths containing them can be specified via `workspace.library` in `luarc.json`. You can do this via relative paths if you checkout your definitions into the same parent directory of your project (`../playdate-luacats`, `../love2d`, etc). Alternatively you can create submodule(s) inside your project for each LuaCATS definition repo. ### LÖVE (Love2D) {#love2d} diff --git a/docs/src/languages/ocaml.md b/docs/src/languages/ocaml.md index b78302b77bf9bc..d6e597f255d038 100644 --- a/docs/src/languages/ocaml.md +++ b/docs/src/languages/ocaml.md @@ -12,13 +12,13 @@ OCaml support is available through the [OCaml extension](https://github.com/zed- ## Setup Instructions -If you have the development environment already setup, you can skip to [Launching Zed](#launching-zed) +If you have the development environment already set up, you can skip to [Launching Zed](#launching-zed) ### Using Opam Opam is the official package manager for OCaml and is highly recommended for getting started with OCaml. To get started using Opam, please follow the instructions provided [here](https://ocaml.org/install). -Once you install opam and setup a switch with your development environment as per the instructions, you can proceed. +Once you install opam and set up a switch with your development environment as per the instructions, you can proceed. ### Launching Zed diff --git a/docs/src/languages/php.md b/docs/src/languages/php.md index b83e75fb290c5a..8ce513ba05e4dd 100644 --- a/docs/src/languages/php.md +++ b/docs/src/languages/php.md @@ -157,7 +157,7 @@ These are common troubleshooting tips, in case you run into issues: - Ensure that you have Xdebug installed for the version of PHP you're running. - Ensure that Xdebug is configured to run in `debug` mode. - Ensure that Xdebug is actually starting a debugging session. -- Ensure that the host and port matches between Xdebug and Zed. +- Ensure that the host and port match between Xdebug and Zed. - Look at the diagnostics log by using the `xdebug_info()` function in the page you're trying to debug. ## Using the Tailwind CSS Language Server with PHP diff --git a/docs/src/languages/python.md b/docs/src/languages/python.md index 4687cf15d866d9..0dd931f5140b7d 100644 --- a/docs/src/languages/python.md +++ b/docs/src/languages/python.md @@ -106,7 +106,7 @@ See: [Working with Language Servers](https://zed.dev/docs/configuring-languages# [basedpyright](https://docs.basedpyright.com/latest/) is the primary Python language server in Zed beginning with Zed v0.204.0. It provides core language server functionality like navigation (go to definition/find all references) and type checking. Compared to Pyright, it adds support for additional language server features (like inlay hints) and checking rules. -Note that while basedpyright in isolation defaults to the `recommended` [type-checking mode](https://docs.basedpyright.com/latest/benefits-over-pyright/better-defaults/#typecheckingmode), Zed configures it to use the less-strict `standard` mode by default, which matches the behavior of Pyright. You can set the type-checking mode for your project using the `typeCheckingMode` setting in `pyrightconfig.json` or `pyproject.toml`, which will override Zed's default. Read on more for more details about how to configure basedpyright. +Note that while basedpyright in isolation defaults to the `recommended` [type-checking mode](https://docs.basedpyright.com/latest/benefits-over-pyright/better-defaults/#typecheckingmode), Zed configures it to use the less-strict `standard` mode by default, which matches the behavior of Pyright. You can set the type-checking mode for your project using the `typeCheckingMode` setting in `pyrightconfig.json` or `pyproject.toml`, which will override Zed's default. Read on for more details about how to configure basedpyright. #### Basedpyright Configuration diff --git a/docs/src/languages/r.md b/docs/src/languages/r.md index 1995bb7c4a3502..a40cda0242dd4b 100644 --- a/docs/src/languages/r.md +++ b/docs/src/languages/r.md @@ -142,7 +142,7 @@ TBD: R REPL Docs ### Ark Installation To use the Zed REPL with R you need to install [Ark](https://github.com/posit-dev/ark), an R Kernel for Jupyter applications. -You can down the latest version from the [Ark GitHub Releases](https://github.com/posit-dev/ark/releases) and then extract the `ark` binary to a directory in your `PATH`. +You can download the latest version from the [Ark GitHub Releases](https://github.com/posit-dev/ark/releases) and then extract the `ark` binary to a directory in your `PATH`. For example to install the latest non-debug build: diff --git a/docs/src/languages/ruby.md b/docs/src/languages/ruby.md index 6f8fc1c4957435..475c7e26cd08e2 100644 --- a/docs/src/languages/ruby.md +++ b/docs/src/languages/ruby.md @@ -30,7 +30,7 @@ They both have an overlapping feature set of autocomplete, diagnostics, code act In addition to these two language servers, Zed also supports: -- [rubocop](https://github.com/rubocop/rubocop) which is a static code analyzer and linter for Ruby. Under the hood, it's also used by Zed as a language server, but its functionality is complimentary to that of solargraph and ruby-lsp. +- [rubocop](https://github.com/rubocop/rubocop) which is a static code analyzer and linter for Ruby. Under the hood, it's also used by Zed as a language server, but its functionality is complementary to that of solargraph and ruby-lsp. - [sorbet](https://sorbet.org/) which is a static type checker for Ruby with a custom gradual type system. - [steep](https://github.com/soutaro/steep) which is a static type checker for Ruby that uses Ruby Signature (RBS). - [Herb](https://herb-tools.dev) which is a language server for ERB files. diff --git a/docs/src/languages/scala.md b/docs/src/languages/scala.md index 0f3b0018bb1365..d0b2b1f3e7b933 100644 --- a/docs/src/languages/scala.md +++ b/docs/src/languages/scala.md @@ -27,7 +27,7 @@ Behavior of the Metals language server can be controlled with: - `.scalafix.conf` file - See [Scalafix Configuration](https://scalacenter.github.io/scalafix/docs/users/configuration.html) - `.scalafmt.conf` file - See [Scalafmt Configuration](https://scalameta.org/scalafmt/docs/configuration.html) -You can place these files in the root of your project or specifying their location in the Metals configuration. See [Metals User Configuration](https://scalameta.org/metals/docs/editors/user-configuration) for more. +You can place these files in the root of your project or specify their location in the Metals configuration. See [Metals User Configuration](https://scalameta.org/metals/docs/editors/user-configuration) for more.