From ba807a3c46d279947f2f475391008e4f42a87492 Mon Sep 17 00:00:00 2001 From: Afief Abdurrahman <91366510+mafiefa02@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:38:29 +0700 Subject: [PATCH 01/48] languages: Initialize Tailwind's options with `includeLanguages` (#43978) Since [this PR](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1014), the `tailwindCSS.userLanguages` option has been deprecated, and it is recommended to use `tailwindCSS.includeLanguages` instead. Using `tailwindCSS.userLanguages` triggers the warning shown below in the `tailwindcss-language-server` logs. tailwindcss-language-server (kron)
Server Logs v Release Notes: - Fixed a warning indicating the deprecation of `tailwindCSS.userLanguages` by initializing the options with `tailwindCSS.includeLanguages`. --------- Co-authored-by: Smit Barmase --- crates/languages/src/tailwind.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index 6375b70c427399579d6c557879b6f357eb87bd3f..7e23c4ba5255c0413904797d1f8094e67834fa6a 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -140,13 +140,6 @@ impl LspAdapter for TailwindLspAdapter { ) -> Result> { Ok(Some(json!({ "provideFormatter": true, - "userLanguages": { - "html": "html", - "css": "css", - "javascript": "javascript", - "typescript": "typescript", - "typescriptreact": "typescriptreact", - }, }))) } @@ -167,8 +160,18 @@ impl LspAdapter for TailwindLspAdapter { tailwind_user_settings["emmetCompletions"] = Value::Bool(true); } + if tailwind_user_settings.get("includeLanguages").is_none() { + tailwind_user_settings["includeLanguages"] = json!({ + "html": "html", + "css": "css", + "javascript": "javascript", + "typescript": "typescript", + "typescriptreact": "typescriptreact", + }); + } + Ok(json!({ - "tailwindCSS": tailwind_user_settings, + "tailwindCSS": tailwind_user_settings })) } From dc5f54eaf94568ac0c77a9bddd51d1983811d1ae Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 8 Dec 2025 23:15:50 -0800 Subject: [PATCH 02/48] Backout inline assistant changes (#44454) Release Notes: - N/A --- crates/feature_flags/src/flags.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index fe11a7b5eaa162a90ae8ba3f691ca804ab64db2d..61d9a34e38de546c79a2dbb5f889e2fddad38480 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -16,4 +16,8 @@ pub struct InlineAssistantV2FeatureFlag; impl FeatureFlag for InlineAssistantV2FeatureFlag { const NAME: &'static str = "inline-assistant-v2"; + + fn enabled_for_staff() -> bool { + false + } } From 0b4f72e54926ec446f659e4af127f13180825a35 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 9 Dec 2025 09:50:10 +0200 Subject: [PATCH 03/48] Tidy up single-file worktrees' opening errors (#44455) Part of https://github.com/zed-industries/zed/issues/44370 Also log when fail to open the project item. Release Notes: - N/A --- crates/project/src/buffer_store.rs | 18 +++- crates/project/src/invalid_item_view.rs | 118 ---------------------- crates/workspace/src/invalid_item_view.rs | 1 + crates/workspace/src/workspace.rs | 1 + 4 files changed, 17 insertions(+), 121 deletions(-) delete mode 100644 crates/project/src/invalid_item_view.rs diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index daafa014b28f62b04ece0f391c714d6ea699326c..f4a0d45bc86c39be595439bfe1aebb2533b62783 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -620,9 +620,21 @@ impl LocalBufferStore { let load_file = worktree.update(cx, |worktree, cx| worktree.load_file(path.as_ref(), cx)); cx.spawn(async move |this, cx| { let path = path.clone(); - let buffer = match load_file.await.with_context(|| { - format!("Could not open path: {}", path.display(PathStyle::local())) - }) { + let single_file_path = cx.update(|cx| { + if worktree.read(cx).is_single_file() { + Some(worktree.read(cx).abs_path()) + } else { + None + } + })?; + let path_string = single_file_path + .as_ref() + .map(|path| path.to_string_lossy()) + .unwrap_or_else(|| path.display(PathStyle::local())); + let buffer = match load_file + .await + .with_context(|| format!("Opening path \"{path_string}\"")) + { Ok(loaded) => { let reservation = cx.reserve_entity::()?; let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64()); diff --git a/crates/project/src/invalid_item_view.rs b/crates/project/src/invalid_item_view.rs deleted file mode 100644 index fdcdd16a69ce73d8471f8387d55cf91576f114af..0000000000000000000000000000000000000000 --- a/crates/project/src/invalid_item_view.rs +++ /dev/null @@ -1,118 +0,0 @@ -use std::{path::Path, sync::Arc}; - -use gpui::{EventEmitter, FocusHandle, Focusable}; -use ui::{ - App, Button, ButtonCommon, ButtonStyle, Clickable, Context, FluentBuilder, InteractiveElement, - KeyBinding, Label, LabelCommon, LabelSize, ParentElement, Render, SharedString, Styled as _, - Window, h_flex, v_flex, -}; -use zed_actions::workspace::OpenWithSystem; - -use crate::Item; - -/// A view to display when a certain buffer fails to open. -#[derive(Debug)] -pub struct InvalidItemView { - /// Which path was attempted to open. - pub abs_path: Arc, - /// An error message, happened when opening the buffer. - pub error: SharedString, - is_local: bool, - focus_handle: FocusHandle, -} - -impl InvalidItemView { - pub fn new( - abs_path: &Path, - is_local: bool, - e: &anyhow::Error, - _: &mut Window, - cx: &mut App, - ) -> Self { - Self { - is_local, - abs_path: Arc::from(abs_path), - error: format!("{}", e.root_cause()).into(), - focus_handle: cx.focus_handle(), - } - } -} - -impl Item for InvalidItemView { - type Event = (); - - fn tab_content_text(&self, mut detail: usize, _: &App) -> SharedString { - // Ensure we always render at least the filename. - detail += 1; - - let path = self.abs_path.as_ref(); - - let mut prefix = path; - while detail > 0 { - if let Some(parent) = prefix.parent() { - prefix = parent; - detail -= 1; - } else { - break; - } - } - - let path = if detail > 0 { - path - } else { - path.strip_prefix(prefix).unwrap_or(path) - }; - - SharedString::new(path.to_string_lossy()) - } -} - -impl EventEmitter<()> for InvalidItemView {} - -impl Focusable for InvalidItemView { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for InvalidItemView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl gpui::IntoElement { - let abs_path = self.abs_path.clone(); - v_flex() - .size_full() - .track_focus(&self.focus_handle(cx)) - .flex_none() - .justify_center() - .overflow_hidden() - .key_context("InvalidBuffer") - .child( - h_flex().size_full().justify_center().child( - v_flex() - .justify_center() - .gap_2() - .child(h_flex().justify_center().child("Could not open file")) - .child( - h_flex() - .justify_center() - .child(Label::new(self.error.clone()).size(LabelSize::Small)), - ) - .when(self.is_local, |contents| { - contents.child( - h_flex().justify_center().child( - Button::new("open-with-system", "Open in Default App") - .on_click(move |_, _, cx| { - cx.open_with_system(&abs_path); - }) - .style(ButtonStyle::Outlined) - .key_binding(KeyBinding::for_action( - &OpenWithSystem, - window, - cx, - )), - ), - ) - }), - ), - ) - } -} diff --git a/crates/workspace/src/invalid_item_view.rs b/crates/workspace/src/invalid_item_view.rs index eb6c8f3299838c1a01777885009fa67271b924d7..08242a1ed0c86bb465c85f79a2047b89f9dc86d2 100644 --- a/crates/workspace/src/invalid_item_view.rs +++ b/crates/workspace/src/invalid_item_view.rs @@ -11,6 +11,7 @@ use zed_actions::workspace::OpenWithSystem; use crate::Item; /// A view to display when a certain buffer/image/other item fails to open. +#[derive(Debug)] pub struct InvalidItemView { /// Which path was attempted to open. pub abs_path: Arc, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 43dd4bd701fdc8c0cf394043daf35d08d6c05328..cc3ba7577ae6a0d8af889bcde174a00f185dd502 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -675,6 +675,7 @@ impl ProjectItemRegistry { Ok((project_entry_id, build_workspace_item)) } Err(e) => { + log::warn!("Failed to open a project item: {e:#}"); if e.error_code() == ErrorCode::Internal { if let Some(abs_path) = entry_abs_path.as_deref().filter(|_| is_file) From 4e75f0f3ab86a6140533fcad83595d4f5e3fcd67 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 9 Dec 2025 16:08:59 +0800 Subject: [PATCH 04/48] gpui: Implement `From` for ElementId (#44447) Release Notes: - N/A ## Before ```rs div() .id(SharedString::from(format!("process-entry-{ix}-command"))) ``` ## After ```rs div() .id(format!("process-entry-{ix}-command")) ``` --- crates/agent_ui/src/acp/thread_view.rs | 2 +- crates/agent_ui/src/agent_configuration.rs | 2 +- .../configure_context_server_tools_modal.rs | 2 +- .../src/agent_configuration/manage_profiles_modal.rs | 4 ++-- crates/agent_ui/src/profile_selector.rs | 2 +- crates/debugger_ui/src/attach_modal.rs | 4 ++-- crates/debugger_ui/src/new_process_modal.rs | 2 +- crates/debugger_ui/src/session/running.rs | 9 +++------ crates/git_ui/src/branch_picker.rs | 2 +- crates/git_ui/src/picker_prompt.rs | 2 +- crates/git_ui/src/stash_picker.rs | 2 +- crates/git_ui/src/worktree_picker.rs | 2 +- crates/gpui/examples/painting.rs | 6 +++--- crates/gpui/examples/window.rs | 6 +++--- crates/gpui/src/window.rs | 12 ++++++++++++ crates/livekit_client/examples/test_app.rs | 2 +- crates/tasks_ui/src/modal.rs | 6 +++--- crates/title_bar/src/application_menu.rs | 8 ++++---- 18 files changed, 42 insertions(+), 33 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 7b2954c2edafa6b610efe654c8c1368f04a374b6..36fb7e9097488f8070b740a63ed67ee74445602a 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -3509,7 +3509,7 @@ impl AcpThreadView { (method.id.0.clone(), method.name.clone()) }; - Button::new(SharedString::from(method_id.clone()), name) + Button::new(method_id.clone(), name) .label_size(LabelSize::Small) .map(|this| { if ix == 0 { diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 3e4654af6e7b47055611cf81886cc0a93436a264..aa4cbc8e5b261d1953a91fb090e7ecd28b4e3a31 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -838,7 +838,7 @@ impl AgentConfiguration { .min_w_0() .child( h_flex() - .id(SharedString::from(format!("tooltip-{}", item_id))) + .id(format!("tooltip-{}", item_id)) .h_full() .w_3() .mr_2() diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs index 3573c8b67ee81ef9cd1decacefb52017dabdb178..5115e2f70c0ae87cdd3ca3901a64aed09de68b0f 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs @@ -87,7 +87,7 @@ impl ConfigureContextServerToolsModal { v_flex() .child( h_flex() - .id(SharedString::from(format!("tool-header-{}", index))) + .id(format!("tool-header-{}", index)) .py_1() .pl_1() .pr_2() diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 7e03dc46b704c22b4665bbce0f3b818134b56634..2f17349c3d1da1cf68a3ab513ccad434a115087b 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -422,7 +422,7 @@ impl ManageProfilesModal { let is_focused = profile.navigation.focus_handle.contains_focused(window, cx); div() - .id(SharedString::from(format!("profile-{}", profile.id))) + .id(format!("profile-{}", profile.id)) .track_focus(&profile.navigation.focus_handle) .on_action({ let profile_id = profile.id.clone(); @@ -431,7 +431,7 @@ impl ManageProfilesModal { }) }) .child( - ListItem::new(SharedString::from(format!("profile-{}", profile.id))) + ListItem::new(format!("profile-{}", profile.id)) .toggle_state(is_focused) .inset(true) .spacing(ListItemSpacing::Sparse) diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index c1949d22e268e8744db7834a58d1a3303fa4e236..0182be0912d3b8a8a046371ce725e7d21a0ddb58 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -542,7 +542,7 @@ impl PickerDelegate for ProfilePickerDelegate { let is_active = active_id == candidate.id; Some( - ListItem::new(SharedString::from(candidate.id.0.clone())) + ListItem::new(candidate.id.0.clone()) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 64146169f53cfe44c3bdcb59b93e78d0f9223abd..6e537ae0c6e1db7418596cf48b51ca22df30be57 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -317,7 +317,7 @@ impl PickerDelegate for AttachModalDelegate { let candidate = self.candidates.get(hit.candidate_id)?; Some( - ListItem::new(SharedString::from(format!("process-entry-{ix}"))) + ListItem::new(format!("process-entry-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) @@ -327,7 +327,7 @@ impl PickerDelegate for AttachModalDelegate { .child(Label::new(format!("{} {}", candidate.name, candidate.pid))) .child( div() - .id(SharedString::from(format!("process-entry-{ix}-command"))) + .id(format!("process-entry-{ix}-command")) .tooltip(Tooltip::text( candidate .command diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 24f6cc8f341519425b33433c3324f8afe7401ab7..8aaa61aad6380752a7bdd62ee35635ebb6d160e4 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -1519,7 +1519,7 @@ impl PickerDelegate for DebugDelegate { }); Some( - ListItem::new(SharedString::from(format!("debug-scenario-selection-{ix}"))) + ListItem::new(format!("debug-scenario-selection-{ix}")) .inset(true) .start_slot::(icon) .spacing(ListItemSpacing::Sparse) diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index bc99d6ac8e42b0a706df4a09177ae2103d5939e2..66e9dd7b434e628898add7056b15c1789e32519c 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -286,10 +286,10 @@ impl Item for SubView { impl Render for SubView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .id(SharedString::from(format!( + .id(format!( "subview-container-{}", self.kind.to_shared_string() - ))) + )) .on_hover(cx.listener(|this, hovered, _, cx| { this.hovered = *hovered; cx.notify(); @@ -484,10 +484,7 @@ pub(crate) fn new_debugger_pane( let deemphasized = !pane.has_focus(window, cx); let item_ = item.boxed_clone(); div() - .id(SharedString::from(format!( - "debugger_tab_{}", - item.item_id().as_u64() - ))) + .id(format!("debugger_tab_{}", item.item_id().as_u64())) .p_1() .rounded_md() .cursor_pointer() diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index e198fa092e0cc2d0e8b77ba954fd743512915c75..90b5c4bb284112c8a13ad406da2b7424e982298a 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -911,7 +911,7 @@ impl PickerDelegate for BranchListDelegate { }); Some( - ListItem::new(SharedString::from(format!("vcs-menu-{ix}"))) + ListItem::new(format!("vcs-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 6161c62af571f3a90c3110d63cc26ea3a7e032ae..14daedda61ecc71cebe8f7778fee2f8193e65a73 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -220,7 +220,7 @@ impl PickerDelegate for PickerPromptDelegate { let shortened_option = util::truncate_and_trailoff(&hit.string, self.max_match_length); Some( - ListItem::new(SharedString::from(format!("picker-prompt-menu-{ix}"))) + ListItem::new(format!("picker-prompt-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index fd81176a127e6032ebb84f1c8afdb6f61a5aa9b8..6d0a9d291e4a8c7096c525b9b401e54e599b0b53 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -464,7 +464,7 @@ impl PickerDelegate for StashListDelegate { ); Some( - ListItem::new(SharedString::from(format!("stash-{ix}"))) + ListItem::new(format!("stash-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index d1231b51e3a37db2b3ee2316e866fcbdbe70d459..f6b3e47dec386d906e55e555600a93059d0766d0 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -665,7 +665,7 @@ impl PickerDelegate for WorktreeListDelegate { }; Some( - ListItem::new(SharedString::from(format!("worktree-menu-{ix}"))) + ListItem::new(format!("worktree-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index e7055cbdbbd781523edbc851d143bf56a551728f..9f15d12f469fa6ec5c7be52d30a63b30163ff254 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -1,7 +1,7 @@ use gpui::{ Application, Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder, - PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowOptions, canvas, - div, linear_color_stop, linear_gradient, point, prelude::*, px, quad, rgb, size, + PathStyle, Pixels, Point, Render, StrokeOptions, Window, WindowOptions, canvas, div, + linear_color_stop, linear_gradient, point, prelude::*, px, quad, rgb, size, }; struct PaintingViewer { @@ -309,7 +309,7 @@ fn button( on_click: impl Fn(&mut PaintingViewer, &mut Context) + 'static, ) -> impl IntoElement { div() - .id(SharedString::from(text.to_string())) + .id(text.to_string()) .child(text.to_string()) .bg(gpui::black()) .text_color(gpui::white()) diff --git a/crates/gpui/examples/window.rs b/crates/gpui/examples/window.rs index 4445f24e4ec0f2809109964fd34610cad1299e90..06003c4663ee5711283a85684c25b9f5d8c5b743 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, SharedString, Timer, - Window, WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, + App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, Timer, Window, + WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, }; struct SubWindow { @@ -9,7 +9,7 @@ struct SubWindow { fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> impl IntoElement { div() - .id(SharedString::from(text.to_string())) + .id(text.to_string()) .flex_none() .px_2() .bg(rgb(0xf7f7f7)) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2d525adb8f82a96c24ee3f524030782a7de3577c..69bf583057fdca4e0b3a71fc552c37c3319123ec 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -5084,6 +5084,18 @@ impl From for ElementId { } } +impl From for ElementId { + fn from(name: String) -> Self { + ElementId::Name(name.into()) + } +} + +impl From> for ElementId { + fn from(name: Arc) -> Self { + ElementId::Name(name.into()) + } +} + impl From> for ElementId { fn from(path: Arc) -> Self { ElementId::Path(path) diff --git a/crates/livekit_client/examples/test_app.rs b/crates/livekit_client/examples/test_app.rs index 7b9b0183a087d1618e1a177a5ed09fe455c4fcec..a4d815aa9be6a84df95083ae979691c109a668cb 100644 --- a/crates/livekit_client/examples/test_app.rs +++ b/crates/livekit_client/examples/test_app.rs @@ -378,7 +378,7 @@ impl Render for LivekitWindow { .when_some(state.audio_output_stream.as_ref(), |el, state| { el.child( button() - .id(SharedString::from(identity.0.clone())) + .id(identity.0.clone()) .child(if state.0.is_enabled() { "Deafen" } else { diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 0497512b762fd141e8bc727b66354f7fbcef7925..644f82285b26f02a6011d59141b94de14a0e2bbf 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -5,8 +5,8 @@ use editor::Editor; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ Action, AnyElement, App, AppContext as _, Context, DismissEvent, Entity, EventEmitter, - Focusable, InteractiveElement, ParentElement, Render, SharedString, Styled, Subscription, Task, - WeakEntity, Window, rems, + Focusable, InteractiveElement, ParentElement, Render, Styled, Subscription, Task, WeakEntity, + Window, rems, }; use itertools::Itertools; use picker::{Picker, PickerDelegate, highlighted_match_with_paths::HighlightedMatch}; @@ -526,7 +526,7 @@ impl PickerDelegate for TasksModalDelegate { }; Some( - ListItem::new(SharedString::from(format!("tasks-modal-{ix}"))) + ListItem::new(format!("tasks-modal-{ix}")) .inset(true) .start_slot::(icon) .end_slot::( diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 01a12260ad03284d77dfda19fdf2286cf6196ca8..817b73c45ecd2df4a76e9a67f425b2b459c0c026 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -151,10 +151,10 @@ impl ApplicationMenu { // Application menu must have same ids as first menu item in standard menu div() - .id(SharedString::from(format!("{}-menu-item", menu_name))) + .id(format!("{}-menu-item", menu_name)) .occlude() .child( - PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name))) + PopoverMenu::new(format!("{}-menu-popover", menu_name)) .menu(move |window, cx| { Self::build_menu_from_items(entry.clone(), window, cx).into() }) @@ -184,10 +184,10 @@ impl ApplicationMenu { .collect(); div() - .id(SharedString::from(format!("{}-menu-item", menu_name))) + .id(format!("{}-menu-item", menu_name)) .occlude() .child( - PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name))) + PopoverMenu::new(format!("{}-menu-popover", menu_name)) .menu(move |window, cx| { Self::build_menu_from_items(entry.clone(), window, cx).into() }) From 6253b1d220d6bf2033494b458985ea3ecdd9b3b0 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 9 Dec 2025 09:30:36 +0100 Subject: [PATCH 05/48] worktree: Print canonicalization error details (#44459) cc https://github.com/zed-industries/zed/issues/24714 Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/worktree/src/worktree.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 5d1baceb2cebcadb54f5b47f357470861bb5b964..4df7a93f13e3c1ff80f716141a2db727b7a5e693 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -3814,7 +3814,7 @@ impl BackgroundScanner { let root_canonical_path = match &root_canonical_path { Ok(path) => SanitizedPath::new(path), Err(err) => { - log::error!("failed to canonicalize root path {root_path:?}: {err}"); + log::error!("failed to canonicalize root path {root_path:?}: {err:#}"); return true; } }; From 9d49c1ffda05036cca0aa0a835a493e48bdb7c5f Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:24:06 +0100 Subject: [PATCH 06/48] Switch from labels to types in Top-Ranking issues (#44383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since we've run a script to replace labels with types on the open issues (e.g. label 'bug' → type 'Bug'), and those labels are deprecated, the script is updated to deal with issue types only. Other changes: - only get top-100 search results for each section since we only look at top-50 anyway: this way we don't need to deal with rate limiting, and the entire script runs way faster when it doesn't need to fetch 1000+ bugs - subtract the "-1" reactions from the "+1" reactions on a given issue to give a slightly more accurate picture in the overall ranking (this can further be improved by adding the distinct heart reactions but we'll leave that for another day) - only output the issues with a score > 0 - use Typer's built-in error handling for a missing argument - since we're only dealing with types and not labels now, remove the handling of potentially duplicate issues in the search results per section - make `Tracking` its own section since this issue type exists now - remove the `unlabeled` section with issues of no type since all the open issues have a type now and we intend to keep it that way for the sake of these and other stats (and also because GitHub's REST API has caught up with types but not with `no:type`) - replace pygithub and custom classes with requests directly to the GitHub API and plain data structures for a lighter footprint - spell out the date of the update in the resulting text to avoid the ambiguity (10/6 → October 06). The way the script is invoked has not been changed. Example run: ``` *Updated on December 08, 2025 06:57 AM (EST)* ## Features 1. https://github.com/zed-industries/zed/issues/11473 (679 :thumbsup:) 2. https://github.com/zed-industries/zed/issues/4642 (674 :thumbsup:) 3. https://github.com/zed-industries/zed/issues/10910 (638 :thumbsup:) 4. https://github.com/zed-industries/zed/issues/8279 (592 :thumbsup:) 5. https://github.com/zed-industries/zed/issues/5242 (581 :thumbsup:) 6. https://github.com/zed-industries/zed/issues/4355 (552 :thumbsup:) 7. https://github.com/zed-industries/zed/issues/15968 (453 :thumbsup:) 8. https://github.com/zed-industries/zed/issues/4930 (357 :thumbsup:) 9. https://github.com/zed-industries/zed/issues/5066 (345 :thumbsup:) 10. https://github.com/zed-industries/zed/issues/5120 (312 :thumbsup:) 11. https://github.com/zed-industries/zed/issues/7450 (310 :thumbsup:) 12. https://github.com/zed-industries/zed/issues/14801 (291 :thumbsup:) 13. https://github.com/zed-industries/zed/issues/10696 (276 :thumbsup:) 14. https://github.com/zed-industries/zed/issues/16965 (258 :thumbsup:) 15. https://github.com/zed-industries/zed/issues/4688 (231 :thumbsup:) 16. https://github.com/zed-industries/zed/issues/4943 (228 :thumbsup:) 17. https://github.com/zed-industries/zed/issues/9459 (223 :thumbsup:) 18. https://github.com/zed-industries/zed/issues/21538 (223 :thumbsup:) 19. https://github.com/zed-industries/zed/issues/11889 (194 :thumbsup:) 20. https://github.com/zed-industries/zed/issues/9721 (180 :thumbsup:) 21. https://github.com/zed-industries/zed/issues/5039 (172 :thumbsup:) 22. https://github.com/zed-industries/zed/issues/9662 (162 :thumbsup:) 23. https://github.com/zed-industries/zed/issues/4888 (160 :thumbsup:) 24. https://github.com/zed-industries/zed/issues/26823 (158 :thumbsup:) 25. https://github.com/zed-industries/zed/issues/21208 (151 :thumbsup:) 26. https://github.com/zed-industries/zed/issues/4991 (149 :thumbsup:) 27. https://github.com/zed-industries/zed/issues/6722 (144 :thumbsup:) 28. https://github.com/zed-industries/zed/issues/18490 (139 :thumbsup:) 29. https://github.com/zed-industries/zed/issues/10647 (138 :thumbsup:) 30. https://github.com/zed-industries/zed/issues/35803 (121 :thumbsup:) 31. https://github.com/zed-industries/zed/issues/4808 (118 :thumbsup:) 32. https://github.com/zed-industries/zed/issues/12406 (118 :thumbsup:) 33. https://github.com/zed-industries/zed/issues/37074 (118 :thumbsup:) 34. https://github.com/zed-industries/zed/issues/7121 (117 :thumbsup:) 35. https://github.com/zed-industries/zed/issues/15098 (112 :thumbsup:) 36. https://github.com/zed-industries/zed/issues/4867 (111 :thumbsup:) 37. https://github.com/zed-industries/zed/issues/4751 (108 :thumbsup:) 38. https://github.com/zed-industries/zed/issues/14473 (98 :thumbsup:) 39. https://github.com/zed-industries/zed/issues/6754 (97 :thumbsup:) 40. https://github.com/zed-industries/zed/issues/11138 (97 :thumbsup:) 41. https://github.com/zed-industries/zed/issues/17455 (90 :thumbsup:) 42. https://github.com/zed-industries/zed/issues/9922 (89 :thumbsup:) 43. https://github.com/zed-industries/zed/issues/4504 (87 :thumbsup:) 44. https://github.com/zed-industries/zed/issues/17353 (85 :thumbsup:) 45. https://github.com/zed-industries/zed/issues/4663 (82 :thumbsup:) 46. https://github.com/zed-industries/zed/issues/12039 (79 :thumbsup:) 47. https://github.com/zed-industries/zed/issues/11107 (75 :thumbsup:) 48. https://github.com/zed-industries/zed/issues/11565 (73 :thumbsup:) 49. https://github.com/zed-industries/zed/issues/22373 (72 :thumbsup:) 50. https://github.com/zed-industries/zed/issues/11023 (71 :thumbsup:) ## Bugs 1. https://github.com/zed-industries/zed/issues/7992 (457 :thumbsup:) 2. https://github.com/zed-industries/zed/issues/12589 (113 :thumbsup:) 3. https://github.com/zed-industries/zed/issues/12176 (105 :thumbsup:) 4. https://github.com/zed-industries/zed/issues/14053 (96 :thumbsup:) 5. https://github.com/zed-industries/zed/issues/18698 (90 :thumbsup:) 6. https://github.com/zed-industries/zed/issues/8043 (73 :thumbsup:) 7. https://github.com/zed-industries/zed/issues/7465 (65 :thumbsup:) 8. https://github.com/zed-industries/zed/issues/9403 (56 :thumbsup:) 9. https://github.com/zed-industries/zed/issues/9789 (55 :thumbsup:) 10. https://github.com/zed-industries/zed/issues/30313 (52 :thumbsup:) 11. https://github.com/zed-industries/zed/issues/13564 (47 :thumbsup:) 12. https://github.com/zed-industries/zed/issues/18673 (47 :thumbsup:) 13. https://github.com/zed-industries/zed/issues/43025 (44 :thumbsup:) 14. https://github.com/zed-industries/zed/issues/15166 (43 :thumbsup:) 15. https://github.com/zed-industries/zed/issues/14074 (41 :thumbsup:) 16. https://github.com/zed-industries/zed/issues/38109 (39 :thumbsup:) 17. https://github.com/zed-industries/zed/issues/21076 (38 :thumbsup:) 18. https://github.com/zed-industries/zed/issues/32792 (38 :thumbsup:) 19. https://github.com/zed-industries/zed/issues/26875 (36 :thumbsup:) 20. https://github.com/zed-industries/zed/issues/21146 (35 :thumbsup:) 21. https://github.com/zed-industries/zed/issues/39163 (35 :thumbsup:) 22. https://github.com/zed-industries/zed/issues/13838 (32 :thumbsup:) 23. https://github.com/zed-industries/zed/issues/16727 (32 :thumbsup:) 24. https://github.com/zed-industries/zed/issues/9057 (31 :thumbsup:) 25. https://github.com/zed-industries/zed/issues/38151 (31 :thumbsup:) 26. https://github.com/zed-industries/zed/issues/38750 (30 :thumbsup:) 27. https://github.com/zed-industries/zed/issues/8352 (29 :thumbsup:) 28. https://github.com/zed-industries/zed/issues/11744 (29 :thumbsup:) 29. https://github.com/zed-industries/zed/issues/20559 (29 :thumbsup:) 30. https://github.com/zed-industries/zed/issues/23640 (29 :thumbsup:) 31. https://github.com/zed-industries/zed/issues/11104 (27 :thumbsup:) 32. https://github.com/zed-industries/zed/issues/13461 (27 :thumbsup:) 33. https://github.com/zed-industries/zed/issues/13286 (25 :thumbsup:) 34. https://github.com/zed-industries/zed/issues/29962 (25 :thumbsup:) 35. https://github.com/zed-industries/zed/issues/14833 (23 :thumbsup:) 36. https://github.com/zed-industries/zed/issues/15409 (23 :thumbsup:) 37. https://github.com/zed-industries/zed/issues/11127 (22 :thumbsup:) 38. https://github.com/zed-industries/zed/issues/12835 (22 :thumbsup:) 39. https://github.com/zed-industries/zed/issues/31351 (22 :thumbsup:) 40. https://github.com/zed-industries/zed/issues/33942 (22 :thumbsup:) 41. https://github.com/zed-industries/zed/issues/7086 (21 :thumbsup:) 42. https://github.com/zed-industries/zed/issues/13176 (20 :thumbsup:) 43. https://github.com/zed-industries/zed/issues/14222 (20 :thumbsup:) 44. https://github.com/zed-industries/zed/issues/29757 (20 :thumbsup:) 45. https://github.com/zed-industries/zed/issues/35122 (20 :thumbsup:) 46. https://github.com/zed-industries/zed/issues/29807 (19 :thumbsup:) 47. https://github.com/zed-industries/zed/issues/4701 (18 :thumbsup:) 48. https://github.com/zed-industries/zed/issues/35770 (18 :thumbsup:) 49. https://github.com/zed-industries/zed/issues/37734 (18 :thumbsup:) 50. https://github.com/zed-industries/zed/issues/4434 (17 :thumbsup:) ## Tracking issues 1. https://github.com/zed-industries/zed/issues/7808 (298 :thumbsup:) 2. https://github.com/zed-industries/zed/issues/24878 (101 :thumbsup:) 3. https://github.com/zed-industries/zed/issues/7371 (60 :thumbsup:) 4. https://github.com/zed-industries/zed/issues/26916 (51 :thumbsup:) 5. https://github.com/zed-industries/zed/issues/31102 (41 :thumbsup:) 6. https://github.com/zed-industries/zed/issues/25469 (30 :thumbsup:) 7. https://github.com/zed-industries/zed/issues/10906 (18 :thumbsup:) 8. https://github.com/zed-industries/zed/issues/9778 (11 :thumbsup:) 9. https://github.com/zed-industries/zed/issues/23930 (10 :thumbsup:) 10. https://github.com/zed-industries/zed/issues/23914 (8 :thumbsup:) 11. https://github.com/zed-industries/zed/issues/18078 (7 :thumbsup:) 12. https://github.com/zed-industries/zed/issues/25560 (6 :thumbsup:) ## Crashes 1. https://github.com/zed-industries/zed/issues/13190 (33 :thumbsup:) 2. https://github.com/zed-industries/zed/issues/32318 (15 :thumbsup:) 3. https://github.com/zed-industries/zed/issues/39097 (14 :thumbsup:) 4. https://github.com/zed-industries/zed/issues/31149 (11 :thumbsup:) 5. https://github.com/zed-industries/zed/issues/36139 (10 :thumbsup:) 6. https://github.com/zed-industries/zed/issues/39890 (10 :thumbsup:) 7. https://github.com/zed-industries/zed/issues/16120 (9 :thumbsup:) 8. https://github.com/zed-industries/zed/issues/20970 (5 :thumbsup:) 9. https://github.com/zed-industries/zed/issues/28385 (5 :thumbsup:) 10. https://github.com/zed-industries/zed/issues/27270 (4 :thumbsup:) 11. https://github.com/zed-industries/zed/issues/30466 (4 :thumbsup:) 12. https://github.com/zed-industries/zed/issues/37593 (4 :thumbsup:) 13. https://github.com/zed-industries/zed/issues/27751 (3 :thumbsup:) 14. https://github.com/zed-industries/zed/issues/29467 (3 :thumbsup:) 15. https://github.com/zed-industries/zed/issues/39806 (3 :thumbsup:) 16. https://github.com/zed-industries/zed/issues/40998 (3 :thumbsup:) 17. https://github.com/zed-industries/zed/issues/10992 (2 :thumbsup:) 18. https://github.com/zed-industries/zed/issues/31461 (2 :thumbsup:) 19. https://github.com/zed-industries/zed/issues/37291 (2 :thumbsup:) 20. https://github.com/zed-industries/zed/issues/38275 (2 :thumbsup:) 21. https://github.com/zed-industries/zed/issues/43547 (2 :thumbsup:) 22. https://github.com/zed-industries/zed/issues/20014 (1 :thumbsup:) 23. https://github.com/zed-industries/zed/issues/30993 (1 :thumbsup:) 24. https://github.com/zed-industries/zed/issues/31498 (1 :thumbsup:) 25. https://github.com/zed-industries/zed/issues/31829 (1 :thumbsup:) 26. https://github.com/zed-industries/zed/issues/32280 (1 :thumbsup:) 27. https://github.com/zed-industries/zed/issues/36036 (1 :thumbsup:) 28. https://github.com/zed-industries/zed/issues/37918 (1 :thumbsup:) 29. https://github.com/zed-industries/zed/issues/39269 (1 :thumbsup:) 30. https://github.com/zed-industries/zed/issues/42825 (1 :thumbsup:) 31. https://github.com/zed-industries/zed/issues/43522 (1 :thumbsup:) 32. https://github.com/zed-industries/zed/issues/43774 (1 :thumbsup:) ## Windows 1. https://github.com/zed-industries/zed/issues/12288 (36 :thumbsup:) 2. https://github.com/zed-industries/zed/issues/20559 (29 :thumbsup:) 3. https://github.com/zed-industries/zed/issues/12013 (15 :thumbsup:) 4. https://github.com/zed-industries/zed/issues/38682 (8 :thumbsup:) 5. https://github.com/zed-industries/zed/issues/36241 (7 :thumbsup:) 6. https://github.com/zed-industries/zed/issues/28497 (3 :thumbsup:) 7. https://github.com/zed-industries/zed/issues/33748 (3 :thumbsup:) 8. https://github.com/zed-industries/zed/issues/38348 (3 :thumbsup:) 9. https://github.com/zed-industries/zed/issues/41649 (3 :thumbsup:) 10. https://github.com/zed-industries/zed/issues/41734 (3 :thumbsup:) 11. https://github.com/zed-industries/zed/issues/42873 (3 :thumbsup:) 12. https://github.com/zed-industries/zed/issues/36318 (2 :thumbsup:) 13. https://github.com/zed-industries/zed/issues/38886 (2 :thumbsup:) 14. https://github.com/zed-industries/zed/issues/39038 (2 :thumbsup:) 15. https://github.com/zed-industries/zed/issues/39056 (2 :thumbsup:) 16. https://github.com/zed-industries/zed/issues/39189 (2 :thumbsup:) 17. https://github.com/zed-industries/zed/issues/39473 (2 :thumbsup:) 18. https://github.com/zed-industries/zed/issues/39764 (2 :thumbsup:) 19. https://github.com/zed-industries/zed/issues/40430 (2 :thumbsup:) 20. https://github.com/zed-industries/zed/issues/43051 (2 :thumbsup:) 21. https://github.com/zed-industries/zed/issues/18765 (1 :thumbsup:) 22. https://github.com/zed-industries/zed/issues/35174 (1 :thumbsup:) 23. https://github.com/zed-industries/zed/issues/35958 (1 :thumbsup:) 24. https://github.com/zed-industries/zed/issues/36193 (1 :thumbsup:) 25. https://github.com/zed-industries/zed/issues/36849 (1 :thumbsup:) 26. https://github.com/zed-industries/zed/issues/38760 (1 :thumbsup:) 27. https://github.com/zed-industries/zed/issues/39346 (1 :thumbsup:) 28. https://github.com/zed-industries/zed/issues/39435 (1 :thumbsup:) 29. https://github.com/zed-industries/zed/issues/39453 (1 :thumbsup:) 30. https://github.com/zed-industries/zed/issues/39927 (1 :thumbsup:) 31. https://github.com/zed-industries/zed/issues/40209 (1 :thumbsup:) 32. https://github.com/zed-industries/zed/issues/40277 (1 :thumbsup:) 33. https://github.com/zed-industries/zed/issues/40370 (1 :thumbsup:) 34. https://github.com/zed-industries/zed/issues/40392 (1 :thumbsup:) 35. https://github.com/zed-industries/zed/issues/40475 (1 :thumbsup:) 36. https://github.com/zed-industries/zed/issues/40585 (1 :thumbsup:) 37. https://github.com/zed-industries/zed/issues/40647 (1 :thumbsup:) 38. https://github.com/zed-industries/zed/issues/40954 (1 :thumbsup:) 39. https://github.com/zed-industries/zed/issues/42050 (1 :thumbsup:) 40. https://github.com/zed-industries/zed/issues/42366 (1 :thumbsup:) 41. https://github.com/zed-industries/zed/issues/42731 (1 :thumbsup:) 42. https://github.com/zed-industries/zed/issues/42861 (1 :thumbsup:) 43. https://github.com/zed-industries/zed/issues/43522 (1 :thumbsup:) ## Meta issues 1. https://github.com/zed-industries/zed/issues/24804 (10 :thumbsup:) 2. https://github.com/zed-industries/zed/issues/36730 (3 :thumbsup:) ``` Release Notes: - N/A --------- Co-authored-by: Joseph T. Lyons --- script/update_top_ranking_issues/main.py | 277 +++++++--------- .../update_top_ranking_issues/pyproject.toml | 3 +- script/update_top_ranking_issues/uv.lock | 302 ++++++------------ 3 files changed, 207 insertions(+), 375 deletions(-) diff --git a/script/update_top_ranking_issues/main.py b/script/update_top_ranking_issues/main.py index c6ea1a6cde6ea7641d48dddfec3cf26c3b5175bb..a8728e94dfab67546b1d9ad3c0339323c8f0114f 100644 --- a/script/update_top_ranking_issues/main.py +++ b/script/update_top_ranking_issues/main.py @@ -1,29 +1,24 @@ import os -from datetime import datetime, timedelta -from typing import Optional +from datetime import date, datetime, timedelta +from typing import Any, Optional +import requests import typer -from github import Github -from github.Issue import Issue -from github.Repository import Repository from pytz import timezone from typer import Typer app: Typer = typer.Typer() -DATETIME_FORMAT: str = "%m/%d/%Y %I:%M %p" -ISSUES_PER_LABEL: int = 50 +AMERICA_NEW_YORK_TIMEZONE = "America/New_York" +DATETIME_FORMAT: str = "%B %d, %Y %I:%M %p" +ISSUES_PER_SECTION: int = 50 +ISSUES_TO_FETCH: int = 100 +REPO_OWNER = "zed-industries" +REPO_NAME = "zed" +GITHUB_API_BASE_URL = "https://api.github.com" -class IssueData: - def __init__(self, issue: Issue) -> None: - self.title = issue.title - self.url: str = issue.html_url - self.like_count: int = issue._rawData["reactions"]["+1"] # type: ignore [attr-defined] - self.creation_datetime: str = issue.created_at.strftime(DATETIME_FORMAT) - # TODO: Change script to support storing labels here, rather than directly in the script - self.labels: set[str] = {label["name"] for label in issue._rawData["labels"]} # type: ignore [attr-defined] - self._issue = issue +EXCLUDE_LABEL = "ignore top-ranking issues" @app.command() @@ -32,181 +27,135 @@ def main( issue_reference_number: Optional[int] = None, query_day_interval: Optional[int] = None, ) -> None: - start_time: datetime = datetime.now() - - start_date: datetime | None = None + script_start_time: datetime = datetime.now() + start_date: date | None = None if query_day_interval: - tz = timezone("america/new_york") - current_time = datetime.now(tz).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - start_date = current_time - timedelta(days=query_day_interval) + tz = timezone(AMERICA_NEW_YORK_TIMEZONE) + today = datetime.now(tz).date() + start_date = today - timedelta(days=query_day_interval) - # GitHub Workflow will pass in the token as an environment variable, + # GitHub Workflow will pass in the token as an argument, # but we can place it in our env when running the script locally, for convenience - github_token = github_token or os.getenv("GITHUB_ACCESS_TOKEN") - - with Github(github_token, per_page=100) as github: - remaining_requests_before: int = github.rate_limiting[0] - print(f"Remaining requests before: {remaining_requests_before}") - - repo_name: str = "zed-industries/zed" - repository: Repository = github.get_repo(repo_name) - - label_to_issue_data: dict[str, list[IssueData]] = get_issue_maps( - github, repository, start_date + token = github_token or os.getenv("GITHUB_ACCESS_TOKEN") + if not token: + raise typer.BadParameter( + "GitHub token is required. Pass --github-token or set GITHUB_ACCESS_TOKEN env var." ) - issue_text: str = get_issue_text(label_to_issue_data) - - if issue_reference_number: - top_ranking_issues_issue: Issue = repository.get_issue(issue_reference_number) - top_ranking_issues_issue.edit(body=issue_text) - else: - print(issue_text) - - remaining_requests_after: int = github.rate_limiting[0] - print(f"Remaining requests after: {remaining_requests_after}") - print(f"Requests used: {remaining_requests_before - remaining_requests_after}") - - run_duration: timedelta = datetime.now() - start_time - print(run_duration) - - -def get_issue_maps( - github: Github, - repository: Repository, - start_date: datetime | None = None, -) -> dict[str, list[IssueData]]: - label_to_issue_data: dict[str, list[IssueData]] = get_label_to_issue_data( - github, - repository, - start_date, - ) - - # Create a new dictionary with labels ordered by the summation the of likes on the associated issues - labels = list(label_to_issue_data.keys()) - - labels.sort( - key=lambda label: sum( - issue_data.like_count for issue_data in label_to_issue_data[label] - ), - reverse=True, - ) + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + } - label_to_issue_data = {label: label_to_issue_data[label] for label in labels} + section_to_issues = get_section_to_issues(headers, start_date) + issue_text: str = create_issue_text(section_to_issues) - return label_to_issue_data + if issue_reference_number: + update_reference_issue(headers, issue_reference_number, issue_text) + else: + print(issue_text) + run_duration: timedelta = datetime.now() - script_start_time + print(f"Ran for {run_duration}") -def get_label_to_issue_data( - github: Github, - repository: Repository, - start_date: datetime | None = None, -) -> dict[str, list[IssueData]]: - common_queries = [ - f"repo:{repository.full_name}", - "is:open", - "is:issue", - '-label:"ignore top-ranking issues"', - "sort:reactions-+1-desc", - ] - date_query: str | None = ( - f"created:>={start_date.strftime('%Y-%m-%d')}" if start_date else None - ) +def get_section_to_issues( + headers: dict[str, str], start_date: date | None = None +) -> dict[str, list[dict[str, Any]]]: + """Fetch top-ranked issues for each section from GitHub.""" - if date_query: - common_queries.append(date_query) - - common_query = " ".join(common_queries) - - # Because PyGithub doesn't seem to support logical operators `AND` and `OR` - # that GitHub issue queries can use, we use lists as values, rather than - # using `(label:bug OR type:Bug)`. This is not as efficient, as we might - # query the same issue multiple times. Issues that are potentially queried - # multiple times are deduplicated in the `label_to_issues` dictionary. If - # PyGithub ever supports logical operators, we should definitely make the - # switch. - section_queries: dict[str, list[str]] = { - "bug": ["label:bug", "type:Bug"], - "crash": ["label:crash", "type:Crash"], - "feature": ["label:feature", "type:Feature"], - "meta": ["type:Meta"], - "windows": ["label:windows"], - "unlabeled": ["no:label no:type"], + section_filters = { + "Bugs": "type:Bug", + "Crashes": "type:Crash", + "Features": "type:Feature", + "Tracking issues": "type:Tracking", + "Meta issues": "type:Meta", + "Windows": 'label:"platform:windows"', } - label_to_issue_data: dict[str, list[IssueData]] = {} - - for section, queries in section_queries.items(): - unique_issues = set() - - for query in queries: - query: str = f"{common_query} {query}" - issues = github.search_issues(query) - - for issue in issues: - unique_issues.add(issue) - - if len(unique_issues) <= 0: + section_to_issues: dict[str, list[dict[str, Any]]] = {} + for section, search_qualifier in section_filters.items(): + query_parts = [ + f"repo:{REPO_OWNER}/{REPO_NAME}", + "is:issue", + "is:open", + f'-label:"{EXCLUDE_LABEL}"', + search_qualifier, + ] + + if start_date: + query_parts.append(f"created:<={start_date.strftime('%Y-%m-%d')}") + + query = " ".join(query_parts) + url = f"{GITHUB_API_BASE_URL}/search/issues" + params = { + "q": query, + "sort": "reactions-+1", + "order": "desc", + "per_page": ISSUES_TO_FETCH, # this will work as long as it's ≤ 100 + } + + # we are only fetching one page on purpose + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + items = response.json()["items"] + + issues: list[dict[str, Any]] = [] + for item in items: + reactions = item["reactions"] + score = reactions["+1"] - reactions["-1"] + if score > 0: + issues.append({ + "url": item["html_url"], + "score": score, + "created_at": item["created_at"], + }) + + if not issues: continue - issue_data: list[IssueData] = [IssueData(issue) for issue in unique_issues] - issue_data.sort( - key=lambda issue_data: ( - -issue_data.like_count, - issue_data.creation_datetime, - ) - ) - - label_to_issue_data[section] = issue_data[0:ISSUES_PER_LABEL] - - return label_to_issue_data - + issues.sort(key=lambda x: (-x["score"], x["created_at"])) + section_to_issues[section] = issues[:ISSUES_PER_SECTION] -def get_issue_text( - label_to_issue_data: dict[str, list[IssueData]], -) -> str: - tz = timezone("america/new_york") - current_datetime: str = datetime.now(tz).strftime(f"{DATETIME_FORMAT} (%Z)") - - highest_ranking_issues_lines: list[str] = get_highest_ranking_issues_lines( - label_to_issue_data + # Sort sections by total score (highest total first) + section_to_issues = dict( + sorted( + section_to_issues.items(), + key=lambda item: sum(issue["score"] for issue in item[1]), + reverse=True, + ) ) + return section_to_issues - issue_text_lines: list[str] = [ - f"*Updated on {current_datetime}*", - *highest_ranking_issues_lines, - "\n---\n", - "*For details on how this issue is generated, [see the script](https://github.com/zed-industries/zed/blob/main/script/update_top_ranking_issues/main.py)*", - ] - return "\n".join(issue_text_lines) +def update_reference_issue( + headers: dict[str, str], issue_number: int, body: str +) -> None: + url = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}" + response = requests.patch(url, headers=headers, json={"body": body}) + response.raise_for_status() -def get_highest_ranking_issues_lines( - label_to_issue_data: dict[str, list[IssueData]], -) -> list[str]: - highest_ranking_issues_lines: list[str] = [] +def create_issue_text(section_to_issues: dict[str, list[dict[str, Any]]]) -> str: + tz = timezone(AMERICA_NEW_YORK_TIMEZONE) + current_datetime: str = datetime.now(tz).strftime(f"{DATETIME_FORMAT} (%Z)") - if label_to_issue_data: - for label, issue_data in label_to_issue_data.items(): - highest_ranking_issues_lines.append(f"\n## {label}\n") + lines: list[str] = [f"*Updated on {current_datetime}*"] - for i, issue_data in enumerate(issue_data): - markdown_bullet_point: str = ( - f"{issue_data.url} ({issue_data.like_count} :thumbsup:)" - ) + for section, issues in section_to_issues.items(): + lines.append(f"\n## {section}\n") + for i, issue in enumerate(issues): + lines.append(f"{i + 1}. {issue['url']} ({issue['score']} :thumbsup:)") - markdown_bullet_point = f"{i + 1}. {markdown_bullet_point}" - highest_ranking_issues_lines.append(markdown_bullet_point) + lines.append("\n---\n") + lines.append( + "*For details on how this issue is generated, " + "[see the script](https://github.com/zed-industries/zed/blob/main/script/update_top_ranking_issues/main.py)*" + ) - return highest_ranking_issues_lines + return "\n".join(lines) if __name__ == "__main__": app() - -# TODO: Sort label output into core and non core sections diff --git a/script/update_top_ranking_issues/pyproject.toml b/script/update_top_ranking_issues/pyproject.toml index ebd283850a1875835c74f1020904f99d96cc694d..aa3f8cc7ff55952d146dbf34c8db424de96e8e04 100644 --- a/script/update_top_ranking_issues/pyproject.toml +++ b/script/update_top_ranking_issues/pyproject.toml @@ -5,9 +5,10 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "mypy>=1.15.0", - "pygithub>=2.6.1", "pytz>=2025.1", + "requests>=2.32.0", "ruff>=0.9.7", "typer>=0.15.1", "types-pytz>=2025.1.0.20250204", + "types-requests>=2.32.0", ] diff --git a/script/update_top_ranking_issues/uv.lock b/script/update_top_ranking_issues/uv.lock index 062890b179f443130a7dd2211d55c4d94d7ad998..174f4e677fb2dcd73a31dfd2897d0b9d55a4c88e 100644 --- a/script/update_top_ranking_issues/uv.lock +++ b/script/update_top_ranking_issues/uv.lock @@ -1,60 +1,38 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.13" [[package]] name = "certifi" version = "2024.8.30" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, -] - -[[package]] -name = "cffi" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620, upload-time = "2024-10-09T07:40:20.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, - { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, - { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, - { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, - { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, - { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, - { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, - { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, - { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, - { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, - { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, - { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, - { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, - { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617, upload-time = "2024-10-09T07:39:07.317Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310, upload-time = "2024-10-09T07:39:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126, upload-time = "2024-10-09T07:39:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342, upload-time = "2024-10-09T07:39:10.322Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383, upload-time = "2024-10-09T07:39:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214, upload-time = "2024-10-09T07:39:13.059Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104, upload-time = "2024-10-09T07:39:14.815Z" }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255, upload-time = "2024-10-09T07:39:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251, upload-time = "2024-10-09T07:39:16.995Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474, upload-time = "2024-10-09T07:39:18.021Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849, upload-time = "2024-10-09T07:39:19.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781, upload-time = "2024-10-09T07:39:20.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970, upload-time = "2024-10-09T07:39:21.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973, upload-time = "2024-10-09T07:39:22.509Z" }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308, upload-time = "2024-10-09T07:39:23.524Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446, upload-time = "2024-10-09T07:40:19.383Z" }, ] [[package]] @@ -64,68 +42,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121, upload-time = "2023-08-17T17:29:11.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "cryptography" -version = "43.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/ba/0664727028b37e249e73879348cc46d45c5c1a2a2e81e8166462953c5755/cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", size = 686927 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/28/b92c98a04ba762f8cdeb54eba5c4c84e63cac037a7c5e70117d337b15ad6/cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", size = 6223222 }, - { url = "https://files.pythonhosted.org/packages/33/13/1193774705783ba364121aa2a60132fa31a668b8ababd5edfa1662354ccd/cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", size = 3794751 }, - { url = "https://files.pythonhosted.org/packages/5e/4b/39bb3c4c8cfb3e94e736b8d8859ce5c81536e91a1033b1d26770c4249000/cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", size = 3981827 }, - { url = "https://files.pythonhosted.org/packages/ce/dc/1471d4d56608e1013237af334b8a4c35d53895694fbb73882d1c4fd3f55e/cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", size = 3780034 }, - { url = "https://files.pythonhosted.org/packages/ad/43/7a9920135b0d5437cc2f8f529fa757431eb6a7736ddfadfdee1cc5890800/cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", size = 3993407 }, - { url = "https://files.pythonhosted.org/packages/cc/42/9ab8467af6c0b76f3d9b8f01d1cf25b9c9f3f2151f4acfab888d21c55a72/cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", size = 3886457 }, - { url = "https://files.pythonhosted.org/packages/a4/65/430509e31700286ec02868a2457d2111d03ccefc20349d24e58d171ae0a7/cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", size = 4081499 }, - { url = "https://files.pythonhosted.org/packages/bb/18/a04b6467e6e09df8c73b91dcee8878f4a438a43a3603dc3cd6f8003b92d8/cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", size = 2616504 }, - { url = "https://files.pythonhosted.org/packages/cc/73/0eacbdc437202edcbdc07f3576ed8fb8b0ab79d27bf2c5d822d758a72faa/cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", size = 3067456 }, - { url = "https://files.pythonhosted.org/packages/8a/b6/bc54b371f02cffd35ff8dc6baba88304d7cf8e83632566b4b42e00383e03/cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", size = 6225263 }, - { url = "https://files.pythonhosted.org/packages/00/0e/8217e348a1fa417ec4c78cd3cdf24154f5e76fd7597343a35bd403650dfd/cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", size = 3794368 }, - { url = "https://files.pythonhosted.org/packages/3d/ed/38b6be7254d8f7251fde8054af597ee8afa14f911da67a9410a45f602fc3/cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", size = 3981750 }, - { url = "https://files.pythonhosted.org/packages/64/f3/b7946c3887cf7436f002f4cbb1e6aec77b8d299b86be48eeadfefb937c4b/cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", size = 3778925 }, - { url = "https://files.pythonhosted.org/packages/ac/7e/ebda4dd4ae098a0990753efbb4b50954f1d03003846b943ea85070782da7/cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", size = 3993152 }, - { url = "https://files.pythonhosted.org/packages/43/f6/feebbd78a3e341e3913846a3bb2c29d0b09b1b3af1573c6baabc2533e147/cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", size = 3886392 }, - { url = "https://files.pythonhosted.org/packages/bd/4c/ab0b9407d5247576290b4fd8abd06b7f51bd414f04eef0f2800675512d61/cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", size = 4082606 }, - { url = "https://files.pythonhosted.org/packages/05/36/e532a671998d6fcfdb9122da16434347a58a6bae9465e527e450e0bc60a5/cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", size = 2617948 }, - { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445 }, -] - -[[package]] -name = "deprecated" -version = "1.2.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/14/1e41f504a246fc224d2ac264c227975427a85caf37c3979979edb9b1b232/Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3", size = 2974416 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/8d/778b7d51b981a96554f29136cd59ca7880bf58094338085bcf2a979a0e6a/Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c", size = 9561 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] @@ -135,18 +72,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -157,102 +94,42 @@ dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, - { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, - { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, - { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, - { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, - { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, - { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, ] [[package]] name = "mypy-extensions" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, -] - -[[package]] -name = "pycparser" -version = "2.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, -] - -[[package]] -name = "pygithub" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "pynacl" }, - { name = "requests" }, - { name = "typing-extensions" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/88/e08ab18dc74b2916f48703ed1a797d57cb64eca0e23b0a9254e13cfe3911/pygithub-2.6.1.tar.gz", hash = "sha256:b5c035392991cca63959e9453286b41b54d83bf2de2daa7d7ff7e4312cebf3bf", size = 3659473 } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/fc/a444cd19ccc8c4946a512f3827ed0b3565c88488719d800d54a75d541c0b/PyGithub-2.6.1-py3-none-any.whl", hash = "sha256:6f2fa6d076ccae475f9fc392cc6cdbd54db985d4f69b8833a28397de75ed6ca3", size = 410451 }, + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, ] [[package]] name = "pygments" version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, -] - -[[package]] -name = "pyjwt" -version = "2.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/68/ce067f09fca4abeca8771fe667d89cc347d1e99da3e093112ac329c6020e/pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c", size = 78825 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/84/0fdf9b18ba31d69877bd39c9cd6052b47f3761e9910c15de788e519f079f/PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", size = 22344 }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "pynacl" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905, upload-time = "2024-05-04T13:42:02.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920 }, - { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722 }, - { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087 }, - { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678 }, - { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660 }, - { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824 }, - { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912 }, - { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624 }, - { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141 }, + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513, upload-time = "2024-05-04T13:41:57.345Z" }, ] [[package]] name = "pytz" version = "2025.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617, upload-time = "2025-01-31T01:54:48.615Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930, upload-time = "2025-01-31T01:54:45.634Z" }, ] [[package]] @@ -265,9 +142,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, ] [[package]] @@ -278,43 +155,43 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/9e/1784d15b057b0075e5136445aaea92d23955aad2c93eaede673718a40d95/rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", size = 222843 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/9e/1784d15b057b0075e5136445aaea92d23955aad2c93eaede673718a40d95/rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", size = 222843, upload-time = "2024-10-04T11:50:31.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/91/5474b84e505a6ccc295b2d322d90ff6aa0746745717839ee0c5fb4fdcceb/rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1", size = 242117 }, + { url = "https://files.pythonhosted.org/packages/67/91/5474b84e505a6ccc295b2d322d90ff6aa0746745717839ee0c5fb4fdcceb/rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1", size = 242117, upload-time = "2024-10-04T11:50:29.123Z" }, ] [[package]] name = "ruff" version = "0.9.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/8b/a86c300359861b186f18359adf4437ac8e4c52e42daa9eedc731ef9d5b53/ruff-0.9.7.tar.gz", hash = "sha256:643757633417907510157b206e490c3aa11cab0c087c912f60e07fbafa87a4c6", size = 3669813 } +sdist = { url = "https://files.pythonhosted.org/packages/39/8b/a86c300359861b186f18359adf4437ac8e4c52e42daa9eedc731ef9d5b53/ruff-0.9.7.tar.gz", hash = "sha256:643757633417907510157b206e490c3aa11cab0c087c912f60e07fbafa87a4c6", size = 3669813, upload-time = "2025-02-20T13:26:52.111Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/f3/3a1d22973291226df4b4e2ff70196b926b6f910c488479adb0eeb42a0d7f/ruff-0.9.7-py3-none-linux_armv6l.whl", hash = "sha256:99d50def47305fe6f233eb8dabfd60047578ca87c9dcb235c9723ab1175180f4", size = 11774588 }, - { url = "https://files.pythonhosted.org/packages/8e/c9/b881f4157b9b884f2994fd08ee92ae3663fb24e34b0372ac3af999aa7fc6/ruff-0.9.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d59105ae9c44152c3d40a9c40d6331a7acd1cdf5ef404fbe31178a77b174ea66", size = 11746848 }, - { url = "https://files.pythonhosted.org/packages/14/89/2f546c133f73886ed50a3d449e6bf4af27d92d2f960a43a93d89353f0945/ruff-0.9.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f313b5800483770bd540cddac7c90fc46f895f427b7820f18fe1822697f1fec9", size = 11177525 }, - { url = "https://files.pythonhosted.org/packages/d7/93/6b98f2c12bf28ab9def59c50c9c49508519c5b5cfecca6de871cf01237f6/ruff-0.9.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042ae32b41343888f59c0a4148f103208bf6b21c90118d51dc93a68366f4e903", size = 11996580 }, - { url = "https://files.pythonhosted.org/packages/8e/3f/b3fcaf4f6d875e679ac2b71a72f6691a8128ea3cb7be07cbb249f477c061/ruff-0.9.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87862589373b33cc484b10831004e5e5ec47dc10d2b41ba770e837d4f429d721", size = 11525674 }, - { url = "https://files.pythonhosted.org/packages/f0/48/33fbf18defb74d624535d5d22adcb09a64c9bbabfa755bc666189a6b2210/ruff-0.9.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a17e1e01bee0926d351a1ee9bc15c445beae888f90069a6192a07a84af544b6b", size = 12739151 }, - { url = "https://files.pythonhosted.org/packages/63/b5/7e161080c5e19fa69495cbab7c00975ef8a90f3679caa6164921d7f52f4a/ruff-0.9.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c1f880ac5b2cbebd58b8ebde57069a374865c73f3bf41f05fe7a179c1c8ef22", size = 13416128 }, - { url = "https://files.pythonhosted.org/packages/4e/c8/b5e7d61fb1c1b26f271ac301ff6d9de5e4d9a9a63f67d732fa8f200f0c88/ruff-0.9.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e63fc20143c291cab2841dbb8260e96bafbe1ba13fd3d60d28be2c71e312da49", size = 12870858 }, - { url = "https://files.pythonhosted.org/packages/da/cb/2a1a8e4e291a54d28259f8fc6a674cd5b8833e93852c7ef5de436d6ed729/ruff-0.9.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91ff963baed3e9a6a4eba2a02f4ca8eaa6eba1cc0521aec0987da8d62f53cbef", size = 14786046 }, - { url = "https://files.pythonhosted.org/packages/ca/6c/c8f8a313be1943f333f376d79724260da5701426c0905762e3ddb389e3f4/ruff-0.9.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88362e3227c82f63eaebf0b2eff5b88990280fb1ecf7105523883ba8c3aaf6fb", size = 12550834 }, - { url = "https://files.pythonhosted.org/packages/9d/ad/f70cf5e8e7c52a25e166bdc84c082163c9c6f82a073f654c321b4dff9660/ruff-0.9.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0372c5a90349f00212270421fe91874b866fd3626eb3b397ede06cd385f6f7e0", size = 11961307 }, - { url = "https://files.pythonhosted.org/packages/52/d5/4f303ea94a5f4f454daf4d02671b1fbfe2a318b5fcd009f957466f936c50/ruff-0.9.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d76b8ab60e99e6424cd9d3d923274a1324aefce04f8ea537136b8398bbae0a62", size = 11612039 }, - { url = "https://files.pythonhosted.org/packages/eb/c8/bd12a23a75603c704ce86723be0648ba3d4ecc2af07eecd2e9fa112f7e19/ruff-0.9.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c439bdfc8983e1336577f00e09a4e7a78944fe01e4ea7fe616d00c3ec69a3d0", size = 12168177 }, - { url = "https://files.pythonhosted.org/packages/cc/57/d648d4f73400fef047d62d464d1a14591f2e6b3d4a15e93e23a53c20705d/ruff-0.9.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:115d1f15e8fdd445a7b4dc9a30abae22de3f6bcabeb503964904471691ef7606", size = 12610122 }, - { url = "https://files.pythonhosted.org/packages/49/79/acbc1edd03ac0e2a04ae2593555dbc9990b34090a9729a0c4c0cf20fb595/ruff-0.9.7-py3-none-win32.whl", hash = "sha256:e9ece95b7de5923cbf38893f066ed2872be2f2f477ba94f826c8defdd6ec6b7d", size = 9988751 }, - { url = "https://files.pythonhosted.org/packages/6d/95/67153a838c6b6ba7a2401241fd8a00cd8c627a8e4a0491b8d853dedeffe0/ruff-0.9.7-py3-none-win_amd64.whl", hash = "sha256:3770fe52b9d691a15f0b87ada29c45324b2ace8f01200fb0c14845e499eb0c2c", size = 11002987 }, - { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 }, + { url = "https://files.pythonhosted.org/packages/b1/f3/3a1d22973291226df4b4e2ff70196b926b6f910c488479adb0eeb42a0d7f/ruff-0.9.7-py3-none-linux_armv6l.whl", hash = "sha256:99d50def47305fe6f233eb8dabfd60047578ca87c9dcb235c9723ab1175180f4", size = 11774588, upload-time = "2025-02-20T13:25:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c9/b881f4157b9b884f2994fd08ee92ae3663fb24e34b0372ac3af999aa7fc6/ruff-0.9.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d59105ae9c44152c3d40a9c40d6331a7acd1cdf5ef404fbe31178a77b174ea66", size = 11746848, upload-time = "2025-02-20T13:25:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/14/89/2f546c133f73886ed50a3d449e6bf4af27d92d2f960a43a93d89353f0945/ruff-0.9.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f313b5800483770bd540cddac7c90fc46f895f427b7820f18fe1822697f1fec9", size = 11177525, upload-time = "2025-02-20T13:26:00.007Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/6b98f2c12bf28ab9def59c50c9c49508519c5b5cfecca6de871cf01237f6/ruff-0.9.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042ae32b41343888f59c0a4148f103208bf6b21c90118d51dc93a68366f4e903", size = 11996580, upload-time = "2025-02-20T13:26:03.274Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/b3fcaf4f6d875e679ac2b71a72f6691a8128ea3cb7be07cbb249f477c061/ruff-0.9.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87862589373b33cc484b10831004e5e5ec47dc10d2b41ba770e837d4f429d721", size = 11525674, upload-time = "2025-02-20T13:26:06.073Z" }, + { url = "https://files.pythonhosted.org/packages/f0/48/33fbf18defb74d624535d5d22adcb09a64c9bbabfa755bc666189a6b2210/ruff-0.9.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a17e1e01bee0926d351a1ee9bc15c445beae888f90069a6192a07a84af544b6b", size = 12739151, upload-time = "2025-02-20T13:26:08.964Z" }, + { url = "https://files.pythonhosted.org/packages/63/b5/7e161080c5e19fa69495cbab7c00975ef8a90f3679caa6164921d7f52f4a/ruff-0.9.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c1f880ac5b2cbebd58b8ebde57069a374865c73f3bf41f05fe7a179c1c8ef22", size = 13416128, upload-time = "2025-02-20T13:26:12.54Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c8/b5e7d61fb1c1b26f271ac301ff6d9de5e4d9a9a63f67d732fa8f200f0c88/ruff-0.9.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e63fc20143c291cab2841dbb8260e96bafbe1ba13fd3d60d28be2c71e312da49", size = 12870858, upload-time = "2025-02-20T13:26:16.794Z" }, + { url = "https://files.pythonhosted.org/packages/da/cb/2a1a8e4e291a54d28259f8fc6a674cd5b8833e93852c7ef5de436d6ed729/ruff-0.9.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91ff963baed3e9a6a4eba2a02f4ca8eaa6eba1cc0521aec0987da8d62f53cbef", size = 14786046, upload-time = "2025-02-20T13:26:19.85Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/c8f8a313be1943f333f376d79724260da5701426c0905762e3ddb389e3f4/ruff-0.9.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88362e3227c82f63eaebf0b2eff5b88990280fb1ecf7105523883ba8c3aaf6fb", size = 12550834, upload-time = "2025-02-20T13:26:23.082Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ad/f70cf5e8e7c52a25e166bdc84c082163c9c6f82a073f654c321b4dff9660/ruff-0.9.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0372c5a90349f00212270421fe91874b866fd3626eb3b397ede06cd385f6f7e0", size = 11961307, upload-time = "2025-02-20T13:26:26.738Z" }, + { url = "https://files.pythonhosted.org/packages/52/d5/4f303ea94a5f4f454daf4d02671b1fbfe2a318b5fcd009f957466f936c50/ruff-0.9.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d76b8ab60e99e6424cd9d3d923274a1324aefce04f8ea537136b8398bbae0a62", size = 11612039, upload-time = "2025-02-20T13:26:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/bd12a23a75603c704ce86723be0648ba3d4ecc2af07eecd2e9fa112f7e19/ruff-0.9.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c439bdfc8983e1336577f00e09a4e7a78944fe01e4ea7fe616d00c3ec69a3d0", size = 12168177, upload-time = "2025-02-20T13:26:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/57/d648d4f73400fef047d62d464d1a14591f2e6b3d4a15e93e23a53c20705d/ruff-0.9.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:115d1f15e8fdd445a7b4dc9a30abae22de3f6bcabeb503964904471691ef7606", size = 12610122, upload-time = "2025-02-20T13:26:37.365Z" }, + { url = "https://files.pythonhosted.org/packages/49/79/acbc1edd03ac0e2a04ae2593555dbc9990b34090a9729a0c4c0cf20fb595/ruff-0.9.7-py3-none-win32.whl", hash = "sha256:e9ece95b7de5923cbf38893f066ed2872be2f2f477ba94f826c8defdd6ec6b7d", size = 9988751, upload-time = "2025-02-20T13:26:40.366Z" }, + { url = "https://files.pythonhosted.org/packages/6d/95/67153a838c6b6ba7a2401241fd8a00cd8c627a8e4a0491b8d853dedeffe0/ruff-0.9.7-py3-none-win_amd64.whl", hash = "sha256:3770fe52b9d691a15f0b87ada29c45324b2ace8f01200fb0c14845e499eb0c2c", size = 11002987, upload-time = "2025-02-20T13:26:43.762Z" }, + { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763, upload-time = "2025-02-20T13:26:48.92Z" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] @@ -327,27 +204,39 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 } +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789, upload-time = "2024-12-04T17:44:58.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 }, + { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908, upload-time = "2024-12-04T17:44:57.291Z" }, ] [[package]] name = "types-pytz" version = "2025.1.0.20250204" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/d2/2190c54d53c04491ad72a1df019c5dfa692e6ab6c2dba1be7b6c9d530e30/types_pytz-2025.1.0.20250204.tar.gz", hash = "sha256:00f750132769f1c65a4f7240bc84f13985b4da774bd17dfbe5d9cd442746bd49", size = 10352 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/d2/2190c54d53c04491ad72a1df019c5dfa692e6ab6c2dba1be7b6c9d530e30/types_pytz-2025.1.0.20250204.tar.gz", hash = "sha256:00f750132769f1c65a4f7240bc84f13985b4da774bd17dfbe5d9cd442746bd49", size = 10352, upload-time = "2025-02-04T02:39:05.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/50/65ffad73746f1d8b15992c030e0fd22965fd5ae2c0206dc28873343b3230/types_pytz-2025.1.0.20250204-py3-none-any.whl", hash = "sha256:32ca4a35430e8b94f6603b35beb7f56c32260ddddd4f4bb305fdf8f92358b87e", size = 10059, upload-time = "2025-02-04T02:39:03.899Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250913" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/50/65ffad73746f1d8b15992c030e0fd22965fd5ae2c0206dc28873343b3230/types_pytz-2025.1.0.20250204-py3-none-any.whl", hash = "sha256:32ca4a35430e8b94f6603b35beb7f56c32260ddddd4f4bb305fdf8f92358b87e", size = 10059 }, + { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" }, ] [[package]] name = "typing-extensions" version = "4.12.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, ] [[package]] @@ -356,37 +245,30 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "mypy" }, - { name = "pygithub" }, { name = "pytz" }, + { name = "requests" }, { name = "ruff" }, { name = "typer" }, { name = "types-pytz" }, + { name = "types-requests" }, ] [package.metadata] requires-dist = [ { name = "mypy", specifier = ">=1.15.0" }, - { name = "pygithub", specifier = ">=2.6.1" }, { name = "pytz", specifier = ">=2025.1" }, + { name = "requests", specifier = ">=2.32.0" }, { name = "ruff", specifier = ">=0.9.7" }, { name = "typer", specifier = ">=0.15.1" }, { name = "types-pytz", specifier = ">=2025.1.0.20250204" }, + { name = "types-requests", specifier = ">=2.32.0" }, ] [[package]] name = "urllib3" version = "2.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, -] - -[[package]] -name = "wrapt" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972 } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362 }, + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" }, ] From 2b02b603174e5a8913384d1ee88a219f70955ae4 Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:44:30 +0100 Subject: [PATCH 07/48] Fix a search filter in top-ranking issues script (#44468) Release Notes: - N/A --- script/update_top_ranking_issues/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/update_top_ranking_issues/main.py b/script/update_top_ranking_issues/main.py index a8728e94dfab67546b1d9ad3c0339323c8f0114f..336c00497a4dd99e43fcea2fac1a6d763d8acded 100644 --- a/script/update_top_ranking_issues/main.py +++ b/script/update_top_ranking_issues/main.py @@ -85,7 +85,7 @@ def get_section_to_issues( ] if start_date: - query_parts.append(f"created:<={start_date.strftime('%Y-%m-%d')}") + query_parts.append(f"created:>={start_date.strftime('%Y-%m-%d')}") query = " ".join(query_parts) url = f"{GITHUB_API_BASE_URL}/search/issues" From 660234fed25802f49fe512407e655635f357cf9d Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Tue, 9 Dec 2025 12:49:33 +0100 Subject: [PATCH 08/48] docs: Improve documentation for updating an extension (#44475) Release Notes: - N/A --- docs/src/extensions/developing-extensions.md | 22 ++++++++------------ 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index 539cbe3d3044afe30b6ced4f3ceb61f537ebde75..dc8a69329176c8dbb7f9785913ae4b7aac6fb230 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -109,18 +109,6 @@ git submodule init git submodule update ``` -## Update Your Extension - -When developing/updating your extension, you will likely need to update its content from its submodule in the extensions repository. -To quickly fetch the latest code for only specific extension (and avoid updating all others), use the specific path: - -```sh -# From the root of the repository: -git submodule update --remote extensions/your-extension-name -``` - -> Note: If you need to update all submodules (e.g., if multiple extensions have changed, or for a full clean build), you can run `git submodule update` without a path, but this will take longer. - ## Extension License Requirements As of October 1st, 2025, extension repositories must include a license. @@ -177,7 +165,15 @@ To update an extension, open a PR to [the `zed-industries/extensions` repo](http In your PR do the following: -1. Update the extension's submodule to the commit of the new version. +1. Update the extension's submodule to the commit of the new version. For this, you can run + +```sh +# From the root of the repository: +git submodule update --remote extensions/your-extension-name +``` + +to update your extension to the latest commit available in your remote repository. + 2. Update the `version` field for the extension in `extensions.toml` - Make sure the `version` matches the one set in `extension.toml` at the particular commit. From b79d92d1c63eed66a102e93f2e7e376133205081 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 9 Dec 2025 13:22:57 +0100 Subject: [PATCH 09/48] language_extension: Handle prefixed WASI windows paths in extension spawning (#44477) Closes https://github.com/zed-industries/zed/issues/12013 Release Notes: - Fixed some wasm language extensions failing to spawn on windows --- .../src/extension_lsp_adapter.rs | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 64fa12074b0344702888d87e6af8975049c3adc1..269aac1f2a065815077f9e2cce6b2215ac59bb92 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -1,5 +1,5 @@ use std::ops::Range; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result}; @@ -174,7 +174,32 @@ impl DynLspInstaller for ExtensionLspAdapter { ) .await?; - let path = self.extension.path_from_extension(command.command.as_ref()); + // on windows, extensions might produce weird paths + // that start with a leading slash due to WASI + // requiring that for PWD and friends so account for + // that here and try to transform those paths back + // to windows paths + // + // if we don't do this, std will interpret the path as relative, + // which changes join behavior + let command_path: &Path = if cfg!(windows) + && let Some(command) = command.command.to_str() + { + let mut chars = command.chars(); + if chars.next().is_some_and(|c| c == '/') + && chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.next().is_some_and(|c| c == ':') + && chars.next().is_some_and(|c| c == '\\' || c == '/') + { + // looks like a windows path with a leading slash, so strip it + command.strip_prefix('/').unwrap().as_ref() + } else { + command.as_ref() + } + } else { + command.command.as_ref() + }; + let path = self.extension.path_from_extension(command_path); // TODO: This should now be done via the `zed::make_file_executable` function in // Zed extension API, but we're leaving these existing usages in place temporarily @@ -193,7 +218,32 @@ impl DynLspInstaller for ExtensionLspAdapter { Ok(LanguageServerBinary { path, - arguments: command.args.into_iter().map(|arg| arg.into()).collect(), + arguments: command + .args + .into_iter() + .map(|arg| { + // on windows, extensions might produce weird paths + // that start with a leading slash due to WASI + // requiring that for PWD and friends so account for + // that here and try to transform those paths back + // to windows paths + if cfg!(windows) { + let mut chars = arg.chars(); + if chars.next().is_some_and(|c| c == '/') + && chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.next().is_some_and(|c| c == ':') + && chars.next().is_some_and(|c| c == '\\' || c == '/') + { + // looks like a windows path with a leading slash, so strip it + arg.strip_prefix('/').unwrap().into() + } else { + arg.into() + } + } else { + arg.into() + } + }) + .collect(), env: Some(command.env.into_iter().collect()), }) }) From abf90cc274da0a40e479cef5981bbc9da4381abb Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Tue, 9 Dec 2025 18:27:23 +0530 Subject: [PATCH 10/48] language: Add auto-surround for Plain Text, JSON, and JSONC (#42631) **Summary** When users selected text and pressed opening brackets (`(`, `[`, `{`), the text was deleted instead of being wrapped. - Added bracket pairs: `()`, `[]`, `{}`, `""`, `''` with `surround = true` - Added `surround = true` to existing bracket pairs - Added `()` bracket pair **Production Build Fix** (`crates/languages/src/lib.rs`) - Fixed bug where `brackets` config was stripped in non-`load-grammars` builds - Preserved `brackets: config.brackets` in production mode Closes #41186 **Screen recording** https://github.com/user-attachments/assets/22067fe7-d5c4-4a72-a93d-8dbaae640168 Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: Smit Barmase --- crates/language/src/language.rs | 40 ++++++++++++++++++++++++++ crates/languages/src/json/config.toml | 7 +++-- crates/languages/src/jsonc/config.toml | 7 +++-- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 90a55592864a637a2692ffac6e30451827a479ea..a6a76dc70269777eb3acda79bd3fb32865c4b7ee 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -136,6 +136,46 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { path_suffixes: vec!["txt".to_owned()], first_line_pattern: None, }, + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: true, + surround: true, + newline: false, + }, + BracketPair { + start: "[".to_string(), + end: "]".to_string(), + close: true, + surround: true, + newline: false, + }, + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: true, + surround: true, + newline: false, + }, + BracketPair { + start: "\"".to_string(), + end: "\"".to_string(), + close: true, + surround: true, + newline: false, + }, + BracketPair { + start: "'".to_string(), + end: "'".to_string(), + close: true, + surround: true, + newline: false, + }, + ], + disabled_scopes_by_bracket_ix: Default::default(), + }, ..Default::default() }, None, diff --git a/crates/languages/src/json/config.toml b/crates/languages/src/json/config.toml index 8caa46c8a45076557d5f6c897fc1a5ad11ffa6ac..d21412d7bd2ed8f5ce1062728b9418ed891fa260 100644 --- a/crates/languages/src/json/config.toml +++ b/crates/languages/src/json/config.toml @@ -4,9 +4,10 @@ path_suffixes = ["json", "flake.lock"] line_comments = ["// "] autoclose_before = ",]}" brackets = [ - { start = "{", end = "}", close = true, newline = true }, - { start = "[", end = "]", close = true, newline = true }, - { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] }, + { start = "{", end = "}", close = true, surround = true, newline = true }, + { start = "[", end = "]", close = true, surround = true, newline = true }, + { start = "(", end = ")", close = true, surround = true, newline = false }, + { start = "\"", end = "\"", close = true, surround = true, newline = false, not_in = ["string"] }, ] tab_size = 2 prettier_parser_name = "json" diff --git a/crates/languages/src/jsonc/config.toml b/crates/languages/src/jsonc/config.toml index fb86cb394aaa74ffb6670e9719c0fe892f02a5c1..cb7ad38ec78fbe4a9b04816400375cefa444f055 100644 --- a/crates/languages/src/jsonc/config.toml +++ b/crates/languages/src/jsonc/config.toml @@ -4,9 +4,10 @@ path_suffixes = ["jsonc", "bun.lock", "tsconfig.json", "pyrightconfig.json"] line_comments = ["// "] autoclose_before = ",]}" brackets = [ - { start = "{", end = "}", close = true, newline = true }, - { start = "[", end = "]", close = true, newline = true }, - { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] }, + { start = "{", end = "}", close = true, surround = true, newline = true }, + { start = "[", end = "]", close = true, surround = true, newline = true }, + { start = "(", end = ")", close = true, surround = true, newline = false }, + { start = "\"", end = "\"", close = true, surround = true, newline = false, not_in = ["string"] }, ] tab_size = 2 prettier_parser_name = "jsonc" From 4d0cada8f44e934cd4ce729133373d1f4c30a013 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Tue, 9 Dec 2025 21:47:45 +0800 Subject: [PATCH 11/48] git_ui: Hide breakpoints in commit views (#44484) Release Notes: - Improved commit view to not show breakpoints on hover Signed-off-by: Xiaobo Liu --- crates/git_ui/src/commit_view.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index c637ea674f7e58954c186e1557df251d0d22d36b..238b0cbf52fdb4312178b868be4b22986ea946c3 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -152,6 +152,7 @@ impl CommitView { Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); editor.disable_inline_diagnostics(); + editor.set_show_breakpoints(false, cx); editor.set_expand_all_diff_hunks(cx); editor From f05ee8a24df5e350271b487543beaca2e4bdad2a Mon Sep 17 00:00:00 2001 From: Aaron Feickert <66188213+AaronFeickert@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:55:01 -0600 Subject: [PATCH 12/48] Fix menu capitalization (#44450) This PR fixes fixes capitalization of two menu items for consistency elsewhere in the application. Release Notes: - N/A --- crates/zed/src/zed/app_menus.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index e4355636c74e1bfd126c3b74057827f9a1fa9c0e..a7961ac6d4cb663353af1e4e0d1fe66cf43a80a3 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -169,7 +169,7 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::os_action("Paste", editor::actions::Paste, OsAction::Paste), MenuItem::separator(), MenuItem::action("Find", search::buffer_search::Deploy::find()), - MenuItem::action("Find In Project", workspace::DeploySearch::find()), + MenuItem::action("Find in Project", workspace::DeploySearch::find()), MenuItem::separator(), MenuItem::action( "Toggle Line Comment", @@ -280,7 +280,7 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::separator(), MenuItem::action("Toggle Breakpoint", editor::actions::ToggleBreakpoint), MenuItem::action("Edit Breakpoint", editor::actions::EditLogBreakpoint), - MenuItem::action("Clear all Breakpoints", debugger_ui::ClearAllBreakpoints), + MenuItem::action("Clear All Breakpoints", debugger_ui::ClearAllBreakpoints), ], }, Menu { From 1471105643c49549576d61a089ca916053d3c2b0 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:13:52 +0100 Subject: [PATCH 13/48] edit_prediction: Remove duplicate definition of interpolate_edits (#44485) Release Notes: - N/A --- Cargo.lock | 1 + crates/edit_prediction/src/prediction.rs | 53 ++----------------- crates/edit_prediction_types/Cargo.toml | 1 + .../src/edit_prediction_types.rs | 6 +-- 4 files changed, 9 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9de96dfe48ccd211c94539e541ac55da3de8ac63..5790df07111b0752321c9d29da61a16839236275 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5247,6 +5247,7 @@ dependencies = [ "client", "gpui", "language", + "text", ] [[package]] diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index d169cf26e1dc4477554bfe8821ff5eae083a6124..8aa2a8218568a99404cc9aceff36b84127700152 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -6,8 +6,9 @@ use std::{ }; use cloud_llm_client::EditPredictionRejectReason; +use edit_prediction_types::interpolate_edits; use gpui::{AsyncApp, Entity, SharedString}; -use language::{Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, TextBufferSnapshot}; +use language::{Anchor, Buffer, BufferSnapshot, EditPreview, TextBufferSnapshot}; use serde::Serialize; #[derive(Clone, Default, Debug, PartialEq, Eq, Hash)] @@ -53,7 +54,7 @@ impl EditPredictionResult { .read_with(cx, |buffer, cx| { let new_snapshot = buffer.snapshot(); let edits: Arc<[_]> = - interpolate_edits(&edited_buffer_snapshot, &new_snapshot, edits)?.into(); + interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits)?.into(); Some((edits.clone(), new_snapshot, buffer.preview_edits(edits, cx))) }) @@ -109,7 +110,7 @@ impl EditPrediction { &self, new_snapshot: &TextBufferSnapshot, ) -> Option, Arc)>> { - interpolate_edits(&self.snapshot, new_snapshot, self.edits.clone()) + interpolate_edits(&self.snapshot, new_snapshot, &self.edits) } pub fn targets_buffer(&self, buffer: &Buffer) -> bool { @@ -130,52 +131,6 @@ impl std::fmt::Debug for EditPrediction { } } -pub fn interpolate_edits( - old_snapshot: &TextBufferSnapshot, - new_snapshot: &TextBufferSnapshot, - current_edits: Arc<[(Range, Arc)]>, -) -> Option, Arc)>> { - let mut edits = Vec::new(); - - let mut model_edits = current_edits.iter().peekable(); - for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { - while let Some((model_old_range, _)) = model_edits.peek() { - let model_old_range = model_old_range.to_offset(old_snapshot); - if model_old_range.end < user_edit.old.start { - let (model_old_range, model_new_text) = model_edits.next().unwrap(); - edits.push((model_old_range.clone(), model_new_text.clone())); - } else { - break; - } - } - - if let Some((model_old_range, model_new_text)) = model_edits.peek() { - let model_old_offset_range = model_old_range.to_offset(old_snapshot); - if user_edit.old == model_old_offset_range { - let user_new_text = new_snapshot - .text_for_range(user_edit.new.clone()) - .collect::(); - - if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { - if !model_suffix.is_empty() { - let anchor = old_snapshot.anchor_after(user_edit.old.end); - edits.push((anchor..anchor, model_suffix.into())); - } - - model_edits.next(); - continue; - } - } - } - - return None; - } - - edits.extend(model_edits.cloned()); - - if edits.is_empty() { None } else { Some(edits) } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit_prediction_types/Cargo.toml b/crates/edit_prediction_types/Cargo.toml index ebc09680e1dcf99dc21e1714eca6a9db337f4a90..00a8577911af0afd012535fd324a68af8fd70391 100644 --- a/crates/edit_prediction_types/Cargo.toml +++ b/crates/edit_prediction_types/Cargo.toml @@ -15,3 +15,4 @@ path = "src/edit_prediction_types.rs" client.workspace = true gpui.workspace = true language.workspace = true +text.workspace = true diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs index 1f63b8626d15dfd3e2cba78aacb50505186da01c..fbcb3c4c00edbc5fb77f04d1fcaaf4b6129c43db 100644 --- a/crates/edit_prediction_types/src/edit_prediction_types.rs +++ b/crates/edit_prediction_types/src/edit_prediction_types.rs @@ -2,7 +2,7 @@ use std::{ops::Range, sync::Arc}; use client::EditPredictionUsage; use gpui::{App, Context, Entity, SharedString}; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt}; +use language::{Anchor, Buffer, OffsetRangeExt}; // TODO: Find a better home for `Direction`. // @@ -252,8 +252,8 @@ where /// Returns edits updated based on user edits since the old snapshot. None is returned if any user /// edit is not a prefix of a predicted insertion. pub fn interpolate_edits( - old_snapshot: &BufferSnapshot, - new_snapshot: &BufferSnapshot, + old_snapshot: &text::BufferSnapshot, + new_snapshot: &text::BufferSnapshot, current_edits: &[(Range, Arc)], ) -> Option, Arc)>> { let mut edits = Vec::new(); From a524071dd9b3b60aec6e21125d28d90347992135 Mon Sep 17 00:00:00 2001 From: Nia Date: Tue, 9 Dec 2025 17:00:13 +0100 Subject: [PATCH 14/48] gpui: Try to notify when GPU init fails (#44487) Hopefully addresses #43575. cc @cole-miller Release Notes: - GPU initialization errors are more reliably reported --------- Co-authored-by: Cole Miller --- crates/gpui/src/platform/linux/platform.rs | 44 +++++++++++++++++++ .../gpui/src/platform/linux/wayland/client.rs | 9 ++-- crates/gpui/src/platform/linux/x11/client.rs | 6 +-- crates/zed/src/main.rs | 1 + 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 21468370c499058af207a7e7b02ca1bbd7563ec1..f5056741df016cfe88c83379d7b1afd85b9900ca 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -43,6 +43,50 @@ pub(crate) const KEYRING_LABEL: &str = "zed-github-account"; const FILE_PICKER_PORTAL_MISSING: &str = "Couldn't open file picker due to missing xdg-desktop-portal implementation."; +#[cfg(any(feature = "x11", feature = "wayland"))] +pub trait ResultExt { + type Ok; + + fn notify_err(self, msg: &'static str) -> Self::Ok; +} + +#[cfg(any(feature = "x11", feature = "wayland"))] +impl ResultExt for anyhow::Result { + type Ok = T; + + fn notify_err(self, msg: &'static str) -> T { + match self { + Ok(v) => v, + Err(e) => { + use ashpd::desktop::notification::{Notification, NotificationProxy, Priority}; + use futures::executor::block_on; + + let proxy = block_on(NotificationProxy::new()).expect(msg); + + let notification_id = "dev.zed.Oops"; + block_on( + proxy.add_notification( + notification_id, + Notification::new("Zed failed to launch") + .body(Some( + format!( + "{e:?}. See https://zed.dev/docs/linux for troubleshooting steps." + ) + .as_str(), + )) + .priority(Priority::High) + .icon(ashpd::desktop::Icon::with_names(&[ + "dialog-question-symbolic", + ])), + ) + ).expect(msg); + + panic!("{msg}"); + } + } + } +} + pub trait LinuxClient { fn compositor_name(&self) -> &'static str; fn with_common(&self, f: impl FnOnce(&mut LinuxCommon) -> R) -> R; diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index 2879925495e41fd37ea075f20a0de0b19625694e..1a7011c582ab162c8ed6c7277d3dd1f5b8c60239 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -17,7 +17,7 @@ use collections::HashMap; use filedescriptor::Pipe; use http_client::Url; use smallvec::SmallVec; -use util::ResultExt; +use util::ResultExt as _; use wayland_backend::client::ObjectId; use wayland_backend::protocol::WEnum; use wayland_client::event_created_child; @@ -76,8 +76,8 @@ use crate::{ FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, LinuxCommon, LinuxKeyboardLayout, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, - PlatformInput, PlatformKeyboardLayout, Point, SCROLL_LINES, ScrollDelta, ScrollWheelEvent, - Size, TouchPhase, WindowParams, point, px, size, + PlatformInput, PlatformKeyboardLayout, Point, ResultExt as _, SCROLL_LINES, ScrollDelta, + ScrollWheelEvent, Size, TouchPhase, WindowParams, point, px, size, }; use crate::{ LinuxDispatcher, RunnableVariant, TaskTiming, @@ -531,7 +531,8 @@ impl WaylandClient { }) .unwrap(); - let gpu_context = BladeContext::new().expect("Unable to init GPU context"); + // This could be unified with the notification handling in zed/main:fail_to_open_window. + let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); let seat = seat.unwrap(); let globals = Globals::new( diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index 32f50cdf5d9d9439909c7ecaf35df0d75a9c9eae..aa16dc7ad1d9030665ace646ba2ac295df8c27b3 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1,4 +1,4 @@ -use crate::{Capslock, LinuxDispatcher, RunnableVariant, TaskTiming, xcb_flush}; +use crate::{Capslock, LinuxDispatcher, ResultExt as _, RunnableVariant, TaskTiming, xcb_flush}; use anyhow::{Context as _, anyhow}; use ashpd::WindowIdentifier; use calloop::{ @@ -18,7 +18,7 @@ use std::{ rc::{Rc, Weak}, time::{Duration, Instant}, }; -use util::ResultExt; +use util::ResultExt as _; use x11rb::{ connection::{Connection, RequestConnection}, @@ -437,7 +437,7 @@ impl X11Client { .to_string(); let keyboard_layout = LinuxKeyboardLayout::new(layout_name.into()); - let gpu_context = BladeContext::new().context("Unable to init GPU context")?; + let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); let resource_database = x11rb::resource_manager::new_from_default(&xcb_connection) .context("Failed to create resource database")?; diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 7751e6cb0118e3590488600ca2601645d6657fb7..0e82b3323b36f4845b584b33f65e440963801a9f 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -130,6 +130,7 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) { process::exit(1); } + // Maybe unify this with gpui::platform::linux::platform::ResultExt::notify_err(..)? #[cfg(any(target_os = "linux", target_os = "freebsd"))] { use ashpd::desktop::notification::{Notification, NotificationProxy, Priority}; From d5a437d22f4c7a981a5bb8baff3166d4b06846b2 Mon Sep 17 00:00:00 2001 From: Pablo Aguiar Date: Tue, 9 Dec 2025 17:15:14 +0100 Subject: [PATCH 15/48] editor: Add rotation commands for selections and lines (#41236) Introduces RotateSelectionsForward and RotateSelectionsBackward actions that rotate content in a circular fashion across multiple cursors. Behavior based on context: - With selections: rotates the selected text at each cursor position (e.g., x=1, y=2, z=3 becomes x=3, y=1, z=2) - With just cursors: rotates entire lines at cursor positions (e.g., three lines cycle to line3, line1, line2) Selections are preserved after rotation, allowing repeated cycling. Useful for quickly rearranging values, lines, or arguments. For more examples and use cases, please refer to #5315. I'm eager to read your thoughts and make any adjustments or improvements to any aspect of this change. Closes #5315 Release Notes: - Added `RotateSelectionsForward` and `RotateSelectionsBackward` actions that rotate content in a circular fashion across multiple cursors --- crates/editor/src/actions.rs | 4 + crates/editor/src/editor.rs | 162 ++++++++++++++++++++++++++++++ crates/editor/src/editor_tests.rs | 110 ++++++++++++++++++++ crates/editor/src/element.rs | 2 + 4 files changed, 278 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 7d6f486974d1ef7e792bd79997aebd332c2336f4..fb058eb8d7c5ad72a2b2656c3ce943871a623163 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -680,6 +680,10 @@ actions!( ReloadFile, /// Rewraps text to fit within the preferred line length. Rewrap, + /// Rotates selections or lines backward. + RotateSelectionsBackward, + /// Rotates selections or lines forward. + RotateSelectionsForward, /// Runs flycheck diagnostics. RunFlycheck, /// Scrolls the cursor to the bottom of the viewport. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d173c1cb4aac782283a3832b5e411a0a44cc1f23..023e4931d33be86c70b90a8cd62aa5692c25c9d9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11516,6 +11516,168 @@ impl Editor { self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut rand::rng())) } + pub fn rotate_selections_forward( + &mut self, + _: &RotateSelectionsForward, + window: &mut Window, + cx: &mut Context, + ) { + self.rotate_selections(window, cx, false) + } + + pub fn rotate_selections_backward( + &mut self, + _: &RotateSelectionsBackward, + window: &mut Window, + cx: &mut Context, + ) { + self.rotate_selections(window, cx, true) + } + + fn rotate_selections(&mut self, window: &mut Window, cx: &mut Context, reverse: bool) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + let display_snapshot = self.display_snapshot(cx); + let selections = self.selections.all::(&display_snapshot); + + if selections.len() < 2 { + return; + } + + let (edits, new_selections) = { + let buffer = self.buffer.read(cx).read(cx); + let has_selections = selections.iter().any(|s| !s.is_empty()); + if has_selections { + let mut selected_texts: Vec = selections + .iter() + .map(|selection| { + buffer + .text_for_range(selection.start..selection.end) + .collect() + }) + .collect(); + + if reverse { + selected_texts.rotate_left(1); + } else { + selected_texts.rotate_right(1); + } + + let mut offset_delta: i64 = 0; + let mut new_selections = Vec::new(); + let edits: Vec<_> = selections + .iter() + .zip(selected_texts.iter()) + .map(|(selection, new_text)| { + let old_len = (selection.end.0 - selection.start.0) as i64; + let new_len = new_text.len() as i64; + let adjusted_start = + MultiBufferOffset((selection.start.0 as i64 + offset_delta) as usize); + let adjusted_end = + MultiBufferOffset((adjusted_start.0 as i64 + new_len) as usize); + + new_selections.push(Selection { + id: selection.id, + start: adjusted_start, + end: adjusted_end, + reversed: selection.reversed, + goal: selection.goal, + }); + + offset_delta += new_len - old_len; + (selection.start..selection.end, new_text.clone()) + }) + .collect(); + (edits, new_selections) + } else { + let mut all_rows: Vec = selections + .iter() + .map(|selection| buffer.offset_to_point(selection.start).row) + .collect(); + all_rows.sort_unstable(); + all_rows.dedup(); + + if all_rows.len() < 2 { + return; + } + + let line_ranges: Vec> = all_rows + .iter() + .map(|&row| { + let start = Point::new(row, 0); + let end = Point::new(row, buffer.line_len(MultiBufferRow(row))); + buffer.point_to_offset(start)..buffer.point_to_offset(end) + }) + .collect(); + + let mut line_texts: Vec = line_ranges + .iter() + .map(|range| buffer.text_for_range(range.clone()).collect()) + .collect(); + + if reverse { + line_texts.rotate_left(1); + } else { + line_texts.rotate_right(1); + } + + let edits = line_ranges + .iter() + .zip(line_texts.iter()) + .map(|(range, new_text)| (range.clone(), new_text.clone())) + .collect(); + + let num_rows = all_rows.len(); + let row_to_index: std::collections::HashMap = all_rows + .iter() + .enumerate() + .map(|(i, &row)| (row, i)) + .collect(); + + // Compute new line start offsets after rotation (handles CRLF) + let newline_len = line_ranges[1].start.0 - line_ranges[0].end.0; + let first_line_start = line_ranges[0].start.0; + let mut new_line_starts: Vec = vec![first_line_start]; + for text in line_texts.iter().take(num_rows - 1) { + let prev_start = *new_line_starts.last().unwrap(); + new_line_starts.push(prev_start + text.len() + newline_len); + } + + let new_selections = selections + .iter() + .map(|selection| { + let point = buffer.offset_to_point(selection.start); + let old_index = row_to_index[&point.row]; + let new_index = if reverse { + (old_index + num_rows - 1) % num_rows + } else { + (old_index + 1) % num_rows + }; + let new_offset = + MultiBufferOffset(new_line_starts[new_index] + point.column as usize); + Selection { + id: selection.id, + start: new_offset, + end: new_offset, + reversed: selection.reversed, + goal: selection.goal, + } + }) + .collect(); + + (edits, new_selections) + } + }; + + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + this.change_selections(Default::default(), window, cx, |s| { + s.select(new_selections); + }); + }); + } + fn manipulate_lines( &mut self, window: &mut Window, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 3bd5e6bf8f7947dfc9ac26f8ecbe9b6554151fcb..3c33519370907d3a2f53d63d9e24403c36a5e45a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -5777,6 +5777,116 @@ fn test_duplicate_line(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_rotate_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Rotate text selections (horizontal) + cx.set_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state("x=«3ˇ», y=«1ˇ», z=«2ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + + // Rotate text selections (vertical) + cx.set_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«3ˇ» + y=«1ˇ» + z=«2ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + + // Rotate text selections (vertical, different lengths) + cx.set_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«aaˇ»\" + y=\"«ˇ»\" + z=\"«aˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + + // Rotate whole lines (cursor positions preserved) + cx.set_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + line3ˇ + ˇline123 + liˇne23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + + // Rotate whole lines, multiple cursors per line (positions preserved) + cx.set_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline3 + ˇliˇne123 + ˇline23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); +} + #[gpui::test] fn test_move_line_up_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5e5749494017479b921a2bbdb2af8fb7d62c9bf4..fab51cbef29de436e447c317849ad15aa318c45d 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -253,6 +253,8 @@ impl EditorElement { register_action(editor, window, Editor::sort_lines_case_insensitive); register_action(editor, window, Editor::reverse_lines); register_action(editor, window, Editor::shuffle_lines); + register_action(editor, window, Editor::rotate_selections_forward); + register_action(editor, window, Editor::rotate_selections_backward); register_action(editor, window, Editor::convert_indentation_to_spaces); register_action(editor, window, Editor::convert_indentation_to_tabs); register_action(editor, window, Editor::convert_to_upper_case); From dd57d97bb67f1bc30afe4e192082ee48c6c82dc6 Mon Sep 17 00:00:00 2001 From: Gaauwe Rombouts Date: Tue, 9 Dec 2025 17:50:23 +0100 Subject: [PATCH 16/48] Revert "Improve TS/TSX/JS syntax highlighting for parameters, types, and punctuation" (#44490) Reverts zed-industries/zed#43437 Internally we noticed some regression related to removed query for PascalCase identifiers. Reverting now to prevent this from going to preview, still planning to land this with the necessary fixes later. --- .../languages/src/javascript/highlights.scm | 39 +------- crates/languages/src/jsdoc/highlights.scm | 1 - crates/languages/src/tsx/highlights.scm | 93 +----------------- .../languages/src/typescript/highlights.scm | 94 +------------------ 4 files changed, 6 insertions(+), 221 deletions(-) diff --git a/crates/languages/src/javascript/highlights.scm b/crates/languages/src/javascript/highlights.scm index 378cb579f4366c4be9eb16fa87708aeb5391814a..e5b84ab68df2b32061691f469046569a6597750e 100644 --- a/crates/languages/src/javascript/highlights.scm +++ b/crates/languages/src/javascript/highlights.scm @@ -47,45 +47,10 @@ left: (identifier) @function right: [(function_expression) (arrow_function)]) -; Parameters - -(required_parameter - (identifier) @variable.parameter) - -(required_parameter - (_ - ([ - (identifier) - (shorthand_property_identifier_pattern) - ]) @variable.parameter)) - -(optional_parameter - (identifier) @variable.parameter) - -(optional_parameter - (_ - ([ - (identifier) - (shorthand_property_identifier_pattern) - ]) @variable.parameter)) - -(catch_clause - parameter: (identifier) @variable.parameter) - -(index_signature - name: (identifier) @variable.parameter) - -(arrow_function - parameter: (identifier) @variable.parameter) - ; Special identifiers -; -(class_declaration - (type_identifier) @type.class) - -(extends_clause - value: (identifier) @type.class) +((identifier) @type + (#match? @type "^[A-Z]")) (type_identifier) @type (predefined_type) @type.builtin diff --git a/crates/languages/src/jsdoc/highlights.scm b/crates/languages/src/jsdoc/highlights.scm index 581b5d8111fe25443de9951cfdddc8c277ad83ff..103d32d0bd29dae56bd456893288e86a8cf87148 100644 --- a/crates/languages/src/jsdoc/highlights.scm +++ b/crates/languages/src/jsdoc/highlights.scm @@ -1,3 +1,2 @@ (tag_name) @keyword.jsdoc (type) @type.jsdoc -(identifier) @variable.jsdoc diff --git a/crates/languages/src/tsx/highlights.scm b/crates/languages/src/tsx/highlights.scm index 816ae3c58634597495f943dfaeb4a7e7aab77228..ef12b3d7913e07109e32bb5bf41909511aa2b555 100644 --- a/crates/languages/src/tsx/highlights.scm +++ b/crates/languages/src/tsx/highlights.scm @@ -47,68 +47,13 @@ left: (identifier) @function right: [(function_expression) (arrow_function)]) -; Parameters - -(required_parameter - (identifier) @variable.parameter) - -(required_parameter - (_ - ([ - (identifier) - (shorthand_property_identifier_pattern) - ]) @variable.parameter)) - -(optional_parameter - (identifier) @variable.parameter) - -(optional_parameter - (_ - ([ - (identifier) - (shorthand_property_identifier_pattern) - ]) @variable.parameter)) - -(catch_clause - parameter: (identifier) @variable.parameter) - -(index_signature - name: (identifier) @variable.parameter) - -(arrow_function - parameter: (identifier) @variable.parameter) - -(type_predicate - name: (identifier) @variable.parameter) - ; Special identifiers -(type_annotation) @type +((identifier) @type + (#match? @type "^[A-Z]")) (type_identifier) @type (predefined_type) @type.builtin -(type_alias_declaration - (type_identifier) @type) - -(type_alias_declaration - value: (_ - (type_identifier) @type)) - -(interface_declaration - (type_identifier) @type) - -(class_declaration - (type_identifier) @type.class) - -(extends_clause - value: (identifier) @type.class) - -(extends_type_clause - type: (type_identifier) @type) - -(implements_clause - (type_identifier) @type) - ([ (identifier) (shorthand_property_identifier) @@ -286,42 +231,8 @@ "<" @punctuation.bracket ">" @punctuation.bracket) -(type_parameters - "<" @punctuation.bracket - ">" @punctuation.bracket) - (decorator "@" @punctuation.special) -(union_type - ("|") @punctuation.special) - -(intersection_type - ("&") @punctuation.special) - -(type_annotation - (":") @punctuation.special) - -(index_signature - (":") @punctuation.special) - -(type_predicate_annotation - (":") @punctuation.special) - -(public_field_definition - ("?") @punctuation.special) - -(property_signature - ("?") @punctuation.special) - -(method_signature - ("?") @punctuation.special) - -(optional_parameter - ([ - "?" - ":" - ]) @punctuation.special) - ; Keywords [ "abstract" diff --git a/crates/languages/src/typescript/highlights.scm b/crates/languages/src/typescript/highlights.scm index 94690ca30ece07d482593085755682e3e9abba70..5e8d55581e3ae86c85ca2b845e8a07caa6444c1d 100644 --- a/crates/languages/src/typescript/highlights.scm +++ b/crates/languages/src/typescript/highlights.scm @@ -4,33 +4,11 @@ ; Special identifiers -(type_annotation) @type - +((identifier) @type + (#match? @type "^[A-Z]")) (type_identifier) @type (predefined_type) @type.builtin -(type_alias_declaration - (type_identifier) @type) - -(type_alias_declaration - value: (_ - (type_identifier) @type)) - -(interface_declaration - (type_identifier) @type) - -(class_declaration - (type_identifier) @type.class) - -(extends_clause - value: (identifier) @type.class) - -(extends_type_clause - type: (type_identifier) @type) - -(implements_clause - (type_identifier) @type) - ;; Enables ts-pretty-errors ;; The Lsp returns "snippets" of typescript, which are not valid typescript in totality, ;; but should still be highlighted @@ -136,40 +114,6 @@ (arrow_function) @function -; Parameters - -(required_parameter - (identifier) @variable.parameter) - -(required_parameter - (_ - ([ - (identifier) - (shorthand_property_identifier_pattern) - ]) @variable.parameter)) - -(optional_parameter - (identifier) @variable.parameter) - -(optional_parameter - (_ - ([ - (identifier) - (shorthand_property_identifier_pattern) - ]) @variable.parameter)) - -(catch_clause - parameter: (identifier) @variable.parameter) - -(index_signature - name: (identifier) @variable.parameter) - -(arrow_function - parameter: (identifier) @variable.parameter) - -(type_predicate - name: (identifier) @variable.parameter) - ; Literals (this) @variable.special @@ -300,42 +244,8 @@ "<" @punctuation.bracket ">" @punctuation.bracket) -(type_parameters - "<" @punctuation.bracket - ">" @punctuation.bracket) - (decorator "@" @punctuation.special) -(union_type - ("|") @punctuation.special) - -(intersection_type - ("&") @punctuation.special) - -(type_annotation - (":") @punctuation.special) - -(index_signature - (":") @punctuation.special) - -(type_predicate_annotation - (":") @punctuation.special) - -(public_field_definition - ("?") @punctuation.special) - -(property_signature - ("?") @punctuation.special) - -(method_signature - ("?") @punctuation.special) - -(optional_parameter - ([ - "?" - ":" - ]) @punctuation.special) - ; Keywords [ From 20fa9983ad6e0a57efe4b530b0e8bfb57b5a7585 Mon Sep 17 00:00:00 2001 From: David Kleingeld Date: Tue, 9 Dec 2025 18:22:16 +0100 Subject: [PATCH 17/48] Revert "gpui: Update link to Ownership and data flow section" (#44492) While this fixes the link in the Readme it breaks the one in the docs which is the more important one (we should probably just duplicate the readme and not include it into gpui.rs but that is annoying). --- crates/gpui/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui/README.md b/crates/gpui/README.md index 30847c8b8cfb0ac5c662601acbe2008b41e42ee1..ad3fd37fc55857699f5fd23cbe4f4f088ee687c8 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -11,7 +11,7 @@ GPUI is still in active development as we work on the Zed code editor, and is st gpui = { version = "*" } ``` - - [Ownership and data flow](src/_ownership_and_data_flow.rs) + - [Ownership and data flow](_ownership_and_data_flow) Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. From 04d920016f2a029e7546eaa0fbf0817f9ec02b1d Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Tue, 9 Dec 2025 09:29:40 -0800 Subject: [PATCH 18/48] Remove reqwest dependency from gpui (#44424) This was pulling in tokio which is pretty unfortunate. The solution is to do the `reqwest::Form` to `http::Reqwest` conversion in the reliability crate instead of our http client wrapper. Release Notes: - N/A --- Cargo.lock | 1 - crates/http_client/Cargo.toml | 1 - crates/http_client/src/async_body.rs | 11 -------- crates/http_client/src/http_client.rs | 29 +-------------------- crates/reqwest_client/src/reqwest_client.rs | 20 -------------- crates/zed/src/reliability.rs | 15 ++++++----- 6 files changed, 10 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5790df07111b0752321c9d29da61a16839236275..49b9d6069ccdfadf2d5145808fd7b758f9b389bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7756,7 +7756,6 @@ dependencies = [ "tempfile", "url", "util", - "zed-reqwest", ] [[package]] diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index 16600627a77f6a73fa913340f29f5a2da0875de9..177f8639ca1a5d75bd0130979f4d550e3622a1b4 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -28,7 +28,6 @@ http-body.workspace = true http.workspace = true log.workspace = true parking_lot.workspace = true -reqwest.workspace = true serde.workspace = true serde_json.workspace = true serde_urlencoded.workspace = true diff --git a/crates/http_client/src/async_body.rs b/crates/http_client/src/async_body.rs index 6b99a54a7d941c290f2680bc2a599bc63251e24b..8fb49f218568ea36078d772a7225229f31a916c4 100644 --- a/crates/http_client/src/async_body.rs +++ b/crates/http_client/src/async_body.rs @@ -88,17 +88,6 @@ impl From<&'static str> for AsyncBody { } } -impl TryFrom for AsyncBody { - type Error = anyhow::Error; - - fn try_from(value: reqwest::Body) -> Result { - value - .as_bytes() - .ok_or_else(|| anyhow::anyhow!("Underlying data is a stream")) - .map(|bytes| Self::from_bytes(Bytes::copy_from_slice(bytes))) - } -} - impl> From> for AsyncBody { fn from(body: Option) -> Self { match body { diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index f357e01da062398d18134df6625d30b8129bf875..1182ef74ca3d59a2d59419e185ff5bd673c5d505 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -8,10 +8,7 @@ use derive_more::Deref; use http::HeaderValue; pub use http::{self, Method, Request, Response, StatusCode, Uri, request::Builder}; -use futures::{ - FutureExt as _, - future::{self, BoxFuture}, -}; +use futures::future::BoxFuture; use parking_lot::Mutex; use serde::Serialize; use std::sync::Arc; @@ -110,14 +107,6 @@ pub trait HttpClient: 'static + Send + Sync { fn as_fake(&self) -> &FakeHttpClient { panic!("called as_fake on {}", type_name::()) } - - fn send_multipart_form<'a>( - &'a self, - _url: &str, - _request: reqwest::multipart::Form, - ) -> BoxFuture<'a, anyhow::Result>> { - future::ready(Err(anyhow!("not implemented"))).boxed() - } } /// An [`HttpClient`] that may have a proxy. @@ -165,14 +154,6 @@ impl HttpClient for HttpClientWithProxy { fn as_fake(&self) -> &FakeHttpClient { self.client.as_fake() } - - fn send_multipart_form<'a>( - &'a self, - url: &str, - form: reqwest::multipart::Form, - ) -> BoxFuture<'a, anyhow::Result>> { - self.client.send_multipart_form(url, form) - } } /// An [`HttpClient`] that has a base URL. @@ -306,14 +287,6 @@ impl HttpClient for HttpClientWithUrl { fn as_fake(&self) -> &FakeHttpClient { self.client.as_fake() } - - fn send_multipart_form<'a>( - &'a self, - url: &str, - request: reqwest::multipart::Form, - ) -> BoxFuture<'a, anyhow::Result>> { - self.client.send_multipart_form(url, request) - } } pub fn read_proxy_from_env() -> Option { diff --git a/crates/reqwest_client/src/reqwest_client.rs b/crates/reqwest_client/src/reqwest_client.rs index 4213a239ec813f255139a97770a74608371fb73e..8a1ee45e1cc5364600342d587e6b8c084b5d195a 100644 --- a/crates/reqwest_client/src/reqwest_client.rs +++ b/crates/reqwest_client/src/reqwest_client.rs @@ -270,26 +270,6 @@ impl http_client::HttpClient for ReqwestClient { } .boxed() } - - fn send_multipart_form<'a>( - &'a self, - url: &str, - form: reqwest::multipart::Form, - ) -> futures::future::BoxFuture<'a, anyhow::Result>> - { - let response = self.client.post(url).multipart(form).send(); - self.handle - .spawn(async move { - let response = response.await?; - let mut builder = http::response::Builder::new().status(response.status()); - for (k, v) in response.headers() { - builder = builder.header(k, v) - } - Ok(builder.body(response.bytes().await?.into())?) - }) - .map(|e| e?) - .boxed() - } } #[cfg(test)] diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index 9d2f7f5da021cda38cef5a205f2d2ec77eb2b386..da8dffa85d57162a62dd6ae0a698d975d22ee374 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -1,8 +1,8 @@ use anyhow::{Context as _, Result}; use client::{Client, telemetry::MINIDUMP_ENDPOINT}; -use futures::AsyncReadExt; +use futures::{AsyncReadExt, TryStreamExt}; use gpui::{App, AppContext as _, SerializedThreadTaskTimings}; -use http_client::{self, HttpClient}; +use http_client::{self, AsyncBody, HttpClient, Request}; use log::info; use project::Project; use proto::{CrashReport, GetCrashFilesResponse}; @@ -296,11 +296,14 @@ async fn upload_minidump( // TODO: feature-flag-context, and more of device-context like screen resolution, available ram, device model, etc + let stream = form + .into_stream() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) + .into_async_read(); + let body = AsyncBody::from_reader(stream); + let req = Request::builder().uri(endpoint).body(body)?; let mut response_text = String::new(); - let mut response = client - .http_client() - .send_multipart_form(endpoint, form) - .await?; + let mut response = client.http_client().send(req).await?; response .body_mut() .read_to_string(&mut response_text) From bfab0b71e0a92cd75d309909fd247aadc3a8a7e0 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 9 Dec 2025 19:55:29 +0100 Subject: [PATCH 19/48] agent_ui: Fix panic in message editor (#44493) Release Notes: - N/A --- crates/agent_ui/src/acp/message_editor.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index a0aca0c51bd0afe1ea61f6a58c3585d68172ec24..bc293c98a84540c1e00d9895be2cc05b0bdd08a5 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -567,6 +567,14 @@ impl MessageEditor { { cx.stop_propagation(); + let insertion_target = self + .editor + .read(cx) + .selections + .newest_anchor() + .start + .text_anchor; + let project = workspace.read(cx).project().clone(); for selection in selections { if let (Some(file_path), Some(line_range)) = @@ -587,8 +595,7 @@ impl MessageEditor { let snapshot = buffer.snapshot(cx); let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap(); - let start_offset = buffer_snapshot.len(); - let text_anchor = buffer_snapshot.anchor_before(start_offset); + let text_anchor = insertion_target.bias_left(&buffer_snapshot); editor.insert(&mention_text, window, cx); editor.insert(" ", window, cx); From 5dd8561b06c8af4ee46f3aa8bcf839f208b8c7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20K=C3=B6nig?= Date: Tue, 9 Dec 2025 20:54:16 +0100 Subject: [PATCH 20/48] Fix DeepSeek Reasoner tool-call handling and add reasoning_content support (#44301) ## Closes #43887 ## Release Notes: ### Problem DeepSeek's reasoning mode API requires `reasoning_content` to be included in assistant messages that precede tool calls. Without it, the API returns a 400 error: ``` Missing `reasoning_content` field in the assistant message at message index 2 ``` ### Added/Fixed/Improved - Add `reasoning_content` field to `RequestMessage::Assistant` in `crates/deepseek/src/deepseek.rs` - Accumulate thinking content from `MessageContent::Thinking` and attach it to the next assistant/tool-call message - Wire reasoning content through the language model provider in `crates/language_models/src/provider/deepseek.rs` ### Testing - Verified with DeepSeek Reasoner model using tool calls - Confirmed reasoning content is properly included in API requests Fixes tool-call errors when using DeepSeek's reasoning mode. --------- Co-authored-by: Ben Brandt --- crates/deepseek/src/deepseek.rs | 2 ++ crates/language_models/src/provider/deepseek.rs | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/deepseek/src/deepseek.rs b/crates/deepseek/src/deepseek.rs index 64a1cbe5d96354260c2bf84a43ed70be7336aa7a..e978aa08048bfa4c7b7b203ce6b405ba8a0a7d0c 100644 --- a/crates/deepseek/src/deepseek.rs +++ b/crates/deepseek/src/deepseek.rs @@ -155,6 +155,8 @@ pub enum RequestMessage { content: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_content: Option, }, User { content: String, diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 4bc7164f421bfbaa075c72faff7f731c0defcdba..91b83bb9f1d0f08fe70f5e750ff8ce993a7afd7f 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -332,9 +332,11 @@ pub fn into_deepseek( model: &deepseek::Model, max_output_tokens: Option, ) -> deepseek::Request { - let is_reasoner = *model == deepseek::Model::Reasoner; + let is_reasoner = model == &deepseek::Model::Reasoner; let mut messages = Vec::new(); + let mut current_reasoning: Option = None; + for message in request.messages { for content in message.content { match content { @@ -343,10 +345,14 @@ pub fn into_deepseek( Role::Assistant => deepseek::RequestMessage::Assistant { content: Some(text), tool_calls: Vec::new(), + reasoning_content: current_reasoning.take(), }, Role::System => deepseek::RequestMessage::System { content: text }, }), - MessageContent::Thinking { .. } => {} + MessageContent::Thinking { text, .. } => { + // Accumulate reasoning content for next assistant message + current_reasoning.get_or_insert_default().push_str(&text); + } MessageContent::RedactedThinking(_) => {} MessageContent::Image(_) => {} MessageContent::ToolUse(tool_use) => { @@ -369,6 +375,7 @@ pub fn into_deepseek( messages.push(deepseek::RequestMessage::Assistant { content: None, tool_calls: vec![tool_call], + reasoning_content: current_reasoning.take(), }); } } From 3180f4447743426cf2227ed1ebf37cf8edced5d3 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:37:39 +0100 Subject: [PATCH 21/48] lsp: Do not drop lsp buffer handle from editor when a language change leads to buffer having a legit language (#44469) Fixes a bug that led to us unnecessarily restarting a language server when we were looking at a single file of a given language. Release Notes: - Fixed a bug that led to Zed sometimes starting an excessive amount of language servers --- crates/copilot/src/copilot.rs | 2 +- crates/editor/src/editor.rs | 6 ++++-- crates/git_ui/src/file_diff_view.rs | 2 +- crates/git_ui/src/text_diff_view.rs | 2 +- crates/language/src/buffer.rs | 13 ++++++++----- crates/multi_buffer/src/multi_buffer.rs | 6 ++++-- crates/project/src/buffer_store.rs | 2 +- crates/project/src/git_store.rs | 2 +- crates/project/src/lsp_store.rs | 2 +- crates/toolchain_selector/src/active_toolchain.rs | 2 +- 10 files changed, 23 insertions(+), 16 deletions(-) diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 6fbdeff807b65d22193ba7fdcb8e990f7184f70e..4e6520906074c1384a4e500d89be43659c162718 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -807,7 +807,7 @@ impl Copilot { .ok(); } language::BufferEvent::FileHandleChanged - | language::BufferEvent::LanguageChanged => { + | language::BufferEvent::LanguageChanged(_) => { let new_language_id = id_for_language(buffer.read(cx).language()); let Ok(new_uri) = uri_for_buffer(&buffer, cx) else { return Ok(()); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 023e4931d33be86c70b90a8cd62aa5692c25c9d9..d841bf858b8a77f502b8bfb2499118f9e714572e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -22007,8 +22007,10 @@ impl Editor { multi_buffer::Event::DiffHunksToggled => { self.tasks_update_task = Some(self.refresh_runnables(window, cx)); } - multi_buffer::Event::LanguageChanged(buffer_id) => { - self.registered_buffers.remove(&buffer_id); + multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => { + if !is_fresh_language { + self.registered_buffers.remove(&buffer_id); + } jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); cx.notify(); diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index e6ed8feb7f69493d3731d9d382cf9b955059fcc4..b020d7a9f3ac083f1a5adf15ca298b55063a3eb8 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -108,7 +108,7 @@ impl FileDiffView { for buffer in [&old_buffer, &new_buffer] { cx.subscribe(buffer, move |this, _, event, _| match event { language::BufferEvent::Edited - | language::BufferEvent::LanguageChanged + | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); } diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 5a8f0f79592f9161ae9c7ed7f0dc2814eacc2e53..56d55415ba01f893453824be00b9eb8d6bd31a90 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -170,7 +170,7 @@ impl TextDiffView { cx.subscribe(&source_buffer, move |this, _, event, _| match event { language::BufferEvent::Edited - | language::BufferEvent::LanguageChanged + | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 7166a01ef64bff9e47c70cac47910f714ae2dc39..7bf62b5aa43c60a7ecee756dd66066682ac09077 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1,8 +1,8 @@ pub mod row_chunk; use crate::{ - DebuggerTextObject, LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, - TextObject, TreeSitterOptions, + DebuggerTextObject, LanguageScope, Outline, OutlineConfig, PLAIN_TEXT, RunnableCapture, + RunnableTag, TextObject, TreeSitterOptions, diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}, language_settings::{LanguageSettings, language_settings}, outline::OutlineItem, @@ -353,7 +353,8 @@ pub enum BufferEvent { /// The buffer is in need of a reload ReloadNeeded, /// The buffer's language was changed. - LanguageChanged, + /// The boolean indicates whether this buffer did not have a language before, but does now. + LanguageChanged(bool), /// The buffer's syntax trees were updated. Reparsed, /// The buffer's diagnostics were updated. @@ -1386,10 +1387,12 @@ impl Buffer { ) { self.non_text_state_update_count += 1; self.syntax_map.lock().clear(&self.text); - self.language = language; + let old_language = std::mem::replace(&mut self.language, language); self.was_changed(); self.reparse(cx, may_block); - cx.emit(BufferEvent::LanguageChanged); + let has_fresh_language = + self.language.is_some() && old_language.is_none_or(|old| old == *PLAIN_TEXT); + cx.emit(BufferEvent::LanguageChanged(has_fresh_language)); } /// Assign a language registry to the buffer. This allows the buffer to retrieve diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index bd163557c4f6239353e7cd5ad08a6120e20e4a3d..442abe78ee65ba91ccf8e03ab3c0ad26f3679cfc 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -129,7 +129,7 @@ pub enum Event { transaction_id: TransactionId, }, Reloaded, - LanguageChanged(BufferId), + LanguageChanged(BufferId, bool), Reparsed(BufferId), Saved, FileHandleChanged, @@ -2294,7 +2294,9 @@ impl MultiBuffer { BufferEvent::Saved => Event::Saved, BufferEvent::FileHandleChanged => Event::FileHandleChanged, BufferEvent::Reloaded => Event::Reloaded, - BufferEvent::LanguageChanged => Event::LanguageChanged(buffer_id), + BufferEvent::LanguageChanged(has_language) => { + Event::LanguageChanged(buffer_id, *has_language) + } BufferEvent::Reparsed => Event::Reparsed(buffer_id), BufferEvent::DiagnosticsUpdated => Event::DiagnosticsUpdated, BufferEvent::CapabilityChanged => { diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index f4a0d45bc86c39be595439bfe1aebb2533b62783..c38b898f5d79cf34563daa9bc7563f3c869d9a70 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -1141,7 +1141,7 @@ impl BufferStore { }) .log_err(); } - BufferEvent::LanguageChanged => {} + BufferEvent::LanguageChanged(_) => {} _ => {} } } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 0b74a04e1db5c0f2b7c8934d1bbe7d38b1d1ad1b..3efbb57e0312dc7e07d0dbed69f5e096a2e52eb3 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1451,7 +1451,7 @@ impl GitStore { match event { BufferStoreEvent::BufferAdded(buffer) => { cx.subscribe(buffer, |this, buffer, event, cx| { - if let BufferEvent::LanguageChanged = event { + if let BufferEvent::LanguageChanged(_) = event { let buffer_id = buffer.read(cx).remote_id(); if let Some(diff_state) = this.diffs.get(&buffer_id) { diff_state.update(cx, |diff_state, cx| { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 1ae6d1295f37df31aac03e2019cb5510c836fb1c..6856c0ba49da63888cdd81015ca7f725ca3cb81f 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -219,7 +219,7 @@ struct UnifiedLanguageServer { project_roots: HashSet>, } -#[derive(Clone, Hash, PartialEq, Eq)] +#[derive(Clone, Debug, Hash, PartialEq, Eq)] struct LanguageServerSeed { worktree_id: WorktreeId, name: LanguageServerName, diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index 122aa9f22b74c33dd8f148f2bf3b65f04da478a9..03c152e3fd3df0c62ab2f5c7e4a4746875ac955a 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -124,7 +124,7 @@ impl ActiveToolchain { &buffer, window, |this, _, event: &BufferEvent, window, cx| { - if matches!(event, BufferEvent::LanguageChanged) { + if matches!(event, BufferEvent::LanguageChanged(_)) { this._update_toolchain_task = Self::spawn_tracker_task(window, cx); } }, From 736a71238755603e30fb8062444b830911fba704 Mon Sep 17 00:00:00 2001 From: David Kleingeld Date: Wed, 10 Dec 2025 00:30:36 +0100 Subject: [PATCH 22/48] Handle response error for ashpd fixing login edgecases (#44502) Release Notes: - Fixed login fallbacks on Linux Co-authored-by: Julia Ryan --- crates/gpui/src/platform/linux/platform.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index f5056741df016cfe88c83379d7b1afd85b9900ca..51a1d5f5849d387a3f5855c12f50fce0a95d1cf4 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -649,8 +649,9 @@ pub(super) fn open_uri_internal( .activation_token(activation_token.clone().map(ashpd::ActivationToken::from)) .send_uri(&uri) .await + .and_then(|e| e.response()) { - Ok(_) => return, + Ok(()) => return, Err(e) => log::error!("Failed to open with dbus: {}", e), } From 4353b8ecd5da2ddcc3497e99d84fe75cf95e843d Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Tue, 9 Dec 2025 16:42:19 -0800 Subject: [PATCH 23/48] Fix `--user-data-dir` (#44235) Closes #40067 Release Notes: - The `--user-data-dir` flag now works on Windows and Linux, as well as macOS if you pass `--foreground`. --------- Co-authored-by: Lukas Wirth --- crates/cli/src/main.rs | 49 +++++++++++++++++++++++++++++------------- crates/zed/src/main.rs | 2 +- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 7988f001dab37858d36f791fa8a184fe329c4be5..92c0ce2377b8c200b2367148226f3bd3b81f0008 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -32,7 +32,7 @@ struct Detect; trait InstalledApp { fn zed_version_string(&self) -> String; - fn launch(&self, ipc_url: String) -> anyhow::Result<()>; + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()>; fn run_foreground( &self, ipc_url: String, @@ -588,7 +588,7 @@ fn main() -> Result<()> { if args.foreground { app.run_foreground(url, user_data_dir.as_deref())?; } else { - app.launch(url)?; + app.launch(url, user_data_dir.as_deref())?; sender.join().unwrap()?; if let Some(handle) = stdin_pipe_handle { handle.join().unwrap()?; @@ -709,14 +709,18 @@ mod linux { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { - let sock_path = paths::data_dir().join(format!( + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { + let data_dir = user_data_dir + .map(PathBuf::from) + .unwrap_or_else(|| paths::data_dir().clone()); + + let sock_path = data_dir.join(format!( "zed-{}.sock", *release_channel::RELEASE_CHANNEL_NAME )); let sock = UnixDatagram::unbound()?; if sock.connect(&sock_path).is_err() { - self.boot_background(ipc_url)?; + self.boot_background(ipc_url, user_data_dir)?; } else { sock.send(ipc_url.as_bytes())?; } @@ -742,7 +746,11 @@ mod linux { } impl App { - fn boot_background(&self, ipc_url: String) -> anyhow::Result<()> { + fn boot_background( + &self, + ipc_url: String, + user_data_dir: Option<&str>, + ) -> anyhow::Result<()> { let path = &self.0; match fork::fork() { @@ -756,8 +764,13 @@ mod linux { if fork::close_fd().is_err() { eprintln!("failed to close_fd: {}", std::io::Error::last_os_error()); } - let error = - exec::execvp(path.clone(), &[path.as_os_str(), &OsString::from(ipc_url)]); + let mut args: Vec = + vec![path.as_os_str().to_owned(), OsString::from(ipc_url)]; + if let Some(dir) = user_data_dir { + args.push(OsString::from("--user-data-dir")); + args.push(OsString::from(dir)); + } + let error = exec::execvp(path.clone(), &args); // if exec succeeded, we never get here. eprintln!("failed to exec {:?}: {}", path, error); process::exit(1) @@ -943,11 +956,14 @@ mod windows { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { if check_single_instance() { - std::process::Command::new(self.0.clone()) - .arg(ipc_url) - .spawn()?; + let mut cmd = std::process::Command::new(self.0.clone()); + cmd.arg(ipc_url); + if let Some(dir) = user_data_dir { + cmd.arg("--user-data-dir").arg(dir); + } + cmd.spawn()?; } else { unsafe { let pipe = CreateFileW( @@ -1096,7 +1112,7 @@ mod mac_os { format!("Zed {} – {}", self.version(), self.path().display(),) } - fn launch(&self, url: String) -> anyhow::Result<()> { + fn launch(&self, url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { match self { Self::App { app_bundle, .. } => { let app_path = app_bundle; @@ -1146,8 +1162,11 @@ mod mac_os { format!("Cloning descriptor for file {subprocess_stdout_file:?}") })?; let mut command = std::process::Command::new(executable); - let command = command - .env(FORCE_CLI_MODE_ENV_VAR_NAME, "") + command.env(FORCE_CLI_MODE_ENV_VAR_NAME, ""); + if let Some(dir) = user_data_dir { + command.arg("--user-data-dir").arg(dir); + } + command .stderr(subprocess_stdout_file) .stdout(subprocess_stdin_file) .arg(url); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 0e82b3323b36f4845b584b33f65e440963801a9f..2e85da2c0622305c4244d7cc15885e0bb4111aa5 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -3,7 +3,7 @@ mod zed; use agent_ui::AgentPanel; use anyhow::{Context as _, Error, Result}; -use clap::{Parser, command}; +use clap::Parser; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; use client::{Client, ProxySettings, UserStore, parse_zed_link}; use collab_ui::channel_view::ChannelView; From 728f09f3f4398191c9c4dc9492be3dcfa9076708 Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Wed, 10 Dec 2025 10:04:24 +0530 Subject: [PATCH 24/48] vim: Fix buffer navigation with non-Editor items (#44350) Closes #44348 Release Notes: - Fixed buffer navigation in Vim mode with non-Editor items --- assets/keymaps/vim.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 5ff1dc196a82d0c3226253c4b8d892058598b4e3..b891baf2594cd400583686292d7cb648d8f3666d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -902,7 +902,11 @@ "context": "!Editor && !Terminal", "bindings": { ":": "command_palette::Toggle", - "g /": "pane::DeploySearch" + "g /": "pane::DeploySearch", + "] b": "pane::ActivateNextItem", + "[ b": "pane::ActivatePreviousItem", + "] shift-b": "pane::ActivateLastItem", + "[ shift-b": ["pane::ActivateItem", 0] } }, { From 7cbe25fda531f601a50b93e0323818b62b16f5ec Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Wed, 10 Dec 2025 10:05:28 +0530 Subject: [PATCH 25/48] vim: Fix editor paste not using clipboard in visual mode (#44347) Closes #44178 Release Notes: - Fixed editor paste not using clipboard when in Vim visual mode --- crates/vim/src/normal/paste.rs | 46 ++++++++++++++++++++++++++++++++++ crates/vim/src/vim.rs | 1 + 2 files changed, 47 insertions(+) diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 978c882f059e1f4cf40089de4a4af746d8526b54..82af828deb85e6e0ef36ea2853a251547051feed 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -773,6 +773,52 @@ mod test { "}); } + #[gpui::test] + async fn test_paste_system_clipboard_never(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |s| { + s.vim.get_or_insert_default().use_system_clipboard = Some(UseSystemClipboard::Never) + }); + }); + + cx.set_state( + indoc! {" + ˇThe quick brown + fox jumps over + the lazy dog"}, + Mode::Normal, + ); + + cx.write_to_clipboard(ClipboardItem::new_string("something else".to_string())); + + cx.simulate_keystrokes("d d"); + cx.assert_state( + indoc! {" + ˇfox jumps over + the lazy dog"}, + Mode::Normal, + ); + + cx.simulate_keystrokes("shift-v p"); + cx.assert_state( + indoc! {" + ˇThe quick brown + the lazy dog"}, + Mode::Normal, + ); + + cx.simulate_keystrokes("shift-v"); + cx.dispatch_action(editor::actions::Paste); + cx.assert_state( + indoc! {" + ˇsomething else + the lazy dog"}, + Mode::Normal, + ); + } + #[gpui::test] async fn test_numbered_registers(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 1ffcf7e2224341affc7498032fd5a181e256943d..9a9a1a001c32fcf8b22892ce5300d8d2aec3dd37 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -924,6 +924,7 @@ impl Vim { |vim, _: &editor::actions::Paste, window, cx| match vim.mode { Mode::Replace => vim.paste_replace(window, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { + vim.selected_register.replace('+'); vim.paste(&VimPaste::default(), window, cx); } _ => { From 22f1655f8f60ba4fca00a9d197d449e94454835c Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 10 Dec 2025 00:07:48 -0700 Subject: [PATCH 26/48] Add history to the command palette (#44517) Co-Authored-By: Claude Closes #ISSUE Release Notes: - Added history to the command palette (`up` will now show recently executed commands). This is particularly helpful in vim mode when you may mistype a complicated command and want to re-run a slightly different version thereof. --------- Co-authored-by: Claude --- crates/command_palette/src/command_palette.rs | 399 +++++++++++++++++- crates/command_palette/src/persistence.rs | 10 + crates/picker/src/picker.rs | 28 ++ 3 files changed, 434 insertions(+), 3 deletions(-) diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index d971bca1f01e878d7517c1d13f525dfbf8e47afa..daf97bf676e27b5dd81ce4882c102dbfdefc502a 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -2,7 +2,7 @@ mod persistence; use std::{ cmp::{self, Reverse}, - collections::HashMap, + collections::{HashMap, VecDeque}, sync::Arc, time::Duration, }; @@ -19,6 +19,7 @@ use gpui::{ ParentElement, Render, Styled, Task, WeakEntity, Window, }; use persistence::COMMAND_PALETTE_HISTORY; +use picker::Direction; use picker::{Picker, PickerDelegate}; use postage::{sink::Sink, stream::Stream}; use settings::Settings; @@ -163,6 +164,7 @@ pub struct CommandPaletteDelegate { Task<()>, postage::dispatch::Receiver<(Vec, Vec, CommandInterceptResult)>, )>, + query_history: QueryHistory, } struct Command { @@ -170,6 +172,91 @@ struct Command { action: Box, } +#[derive(Default)] +struct QueryHistory { + history: Option>, + cursor: Option, + prefix: Option, +} + +impl QueryHistory { + fn history(&mut self) -> &mut VecDeque { + self.history.get_or_insert_with(|| { + COMMAND_PALETTE_HISTORY + .list_recent_queries() + .unwrap_or_default() + .into_iter() + .collect() + }) + } + + fn add(&mut self, query: String) { + if let Some(pos) = self.history().iter().position(|h| h == &query) { + self.history().remove(pos); + } + self.history().push_back(query); + self.cursor = None; + self.prefix = None; + } + + fn validate_cursor(&mut self, current_query: &str) -> Option { + if let Some(pos) = self.cursor { + if self.history().get(pos).map(|s| s.as_str()) != Some(current_query) { + self.cursor = None; + self.prefix = None; + } + } + self.cursor + } + + fn previous(&mut self, current_query: &str) -> Option<&str> { + if self.validate_cursor(current_query).is_none() { + self.prefix = Some(current_query.to_string()); + } + + let prefix = self.prefix.clone().unwrap_or_default(); + let start_index = self.cursor.unwrap_or(self.history().len()); + + for i in (0..start_index).rev() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn next(&mut self, current_query: &str) -> Option<&str> { + let selected = self.validate_cursor(current_query)?; + let prefix = self.prefix.clone().unwrap_or_default(); + + for i in (selected + 1)..self.history().len() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn reset_cursor(&mut self) { + self.cursor = None; + self.prefix = None; + } + + fn is_navigating(&self) -> bool { + self.cursor.is_some() + } +} + impl Clone for Command { fn clone(&self) -> Self { Self { @@ -196,6 +283,7 @@ impl CommandPaletteDelegate { previous_focus_handle, latest_query: String::new(), updating_matches: None, + query_history: Default::default(), } } @@ -271,6 +359,11 @@ impl CommandPaletteDelegate { // so we need to return an Option here self.commands.get(action_ix) } + + #[cfg(any(test, feature = "test-support"))] + pub fn seed_history(&mut self, queries: &[&str]) { + self.query_history.history = Some(queries.iter().map(|s| s.to_string()).collect()); + } } impl PickerDelegate for CommandPaletteDelegate { @@ -280,6 +373,38 @@ impl PickerDelegate for CommandPaletteDelegate { "Execute a command...".into() } + fn select_history( + &mut self, + direction: Direction, + query: &str, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + match direction { + Direction::Up => { + let should_use_history = + self.selected_ix == 0 || self.query_history.is_navigating(); + if should_use_history { + if let Some(query) = self.query_history.previous(query).map(|s| s.to_string()) { + return Some(query); + } + } + } + Direction::Down => { + if self.query_history.is_navigating() { + if let Some(query) = self.query_history.next(query).map(|s| s.to_string()) { + return Some(query); + } else { + let prefix = self.query_history.prefix.take().unwrap_or_default(); + self.query_history.reset_cursor(); + return Some(prefix); + } + } + } + } + None + } + fn match_count(&self) -> usize { self.matches.len() } @@ -439,6 +564,12 @@ impl PickerDelegate for CommandPaletteDelegate { self.dismissed(window, cx); return; } + + if !self.latest_query.is_empty() { + self.query_history.add(self.latest_query.clone()); + self.query_history.reset_cursor(); + } + let action_ix = self.matches[self.selected_ix].candidate_id; let command = self.commands.swap_remove(action_ix); telemetry::event!( @@ -588,7 +719,7 @@ mod tests { use super::*; use editor::Editor; use go_to_line::GoToLine; - use gpui::TestAppContext; + use gpui::{TestAppContext, VisualTestContext}; use language::Point; use project::Project; use settings::KeymapFile; @@ -799,7 +930,9 @@ mod tests { "bindings": { "cmd-n": "workspace::NewFile", "enter": "menu::Confirm", - "cmd-shift-p": "command_palette::Toggle" + "cmd-shift-p": "command_palette::Toggle", + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" } } ]"#, @@ -808,4 +941,264 @@ mod tests { app_state }) } + + fn open_palette_with_history( + workspace: &Entity, + history: &[&str], + cx: &mut VisualTestContext, + ) -> Entity> { + cx.simulate_keystrokes("cmd-shift-p"); + cx.run_until_parked(); + + let palette = workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .unwrap() + .read(cx) + .picker + .clone() + }); + + palette.update(cx, |palette, _cx| { + palette.delegate.seed_history(history); + }); + + palette + } + + #[gpui::test] + async fn test_history_navigation_basic(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx); + + // Query should be empty initially + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + + // Press up - should load most recent query "select all" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should load "backspace" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Press down - should go back to "select all" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down again - should clear query (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } + + #[gpui::test] + async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace"], cx); + + // Press up to enter history mode + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Type something - should append to the history query + cx.simulate_input("x"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspacex"); + }); + } + + #[gpui::test] + async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx); + + // Open palette with a query that has multiple matches + cx.simulate_input("editor"); + cx.background_executor.run_until_parked(); + + // Should have multiple matches, selected_ix should be 0 + palette.read_with(cx, |palette, _| { + assert!(palette.delegate.matches.len() > 1); + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press down - should navigate to next suggestion (not history) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 1); + }); + + // Press up - should go back to first suggestion + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press up again at top - should enter history mode and show previous query + // that matches the "editor" prefix + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "editor: open"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history( + &workspace, + &["open file", "select all", "select line", "backspace"], + cx, + ); + + // Type "sel" as a prefix + cx.simulate_input("sel"); + cx.background_executor.run_until_parked(); + + // Press up - should get "select line" (most recent matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press up again - should get "select all" (next matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should stay at "select all" (no more matches for "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down - should go back to "select line" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press down again - should return to original prefix "sel" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "sel"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = + open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx); + + // Type "xyz" as a prefix that doesn't match anything + cx.simulate_input("xyz"); + cx.background_executor.run_until_parked(); + + // Press up - should stay at "xyz" (no matches) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "xyz"); + }); + } + + #[gpui::test] + async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx); + + // With empty query, press up - should get "gamma" (most recent) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press up - should get "beta" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press up - should get "alpha" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "alpha"); + }); + + // Press down - should get "beta" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press down - should get "gamma" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press down - should return to empty string (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } } diff --git a/crates/command_palette/src/persistence.rs b/crates/command_palette/src/persistence.rs index feaed72570d56f4895ff05eef891fc81c2e5e0b6..4556079b4f9c8e7a989f3e32eac6f7d084e67a4e 100644 --- a/crates/command_palette/src/persistence.rs +++ b/crates/command_palette/src/persistence.rs @@ -123,6 +123,16 @@ impl CommandPaletteDB { ORDER BY COUNT(1) DESC } } + + query! { + pub fn list_recent_queries() -> Result> { + SELECT user_query + FROM command_invocations + WHERE user_query != "" + GROUP BY user_query + ORDER BY MAX(last_invoked) ASC + } + } } #[cfg(test)] diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 8fb4941b716efa8186937ec7b49bcc3cfb26d44b..3d6ae27dfa0c6b60088995de6ccc1d85b08c9428 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -97,6 +97,18 @@ pub trait PickerDelegate: Sized + 'static { window: &mut Window, cx: &mut Context>, ); + + /// Called before the picker handles `SelectPrevious` or `SelectNext`. Return `Some(query)` to + /// set a new query and prevent the default selection behavior. + fn select_history( + &mut self, + _direction: Direction, + _query: &str, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + None + } fn can_select( &mut self, _ix: usize, @@ -448,6 +460,14 @@ impl Picker { window: &mut Window, cx: &mut Context, ) { + let query = self.query(cx); + if let Some(query) = self + .delegate + .select_history(Direction::Down, &query, window, cx) + { + self.set_query(query, window, cx); + return; + } let count = self.delegate.match_count(); if count > 0 { let index = self.delegate.selected_index(); @@ -467,6 +487,14 @@ impl Picker { window: &mut Window, cx: &mut Context, ) { + let query = self.query(cx); + if let Some(query) = self + .delegate + .select_history(Direction::Up, &query, window, cx) + { + self.set_query(query, window, cx); + return; + } let count = self.delegate.match_count(); if count > 0 { let index = self.delegate.selected_index(); From fd2094fa19828d34614f7d420adcd87bff597edc Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 9 Dec 2025 23:46:04 -0800 Subject: [PATCH 27/48] Add inline prompt rating (#44230) TODO: - [x] Add inline prompt rating buttons - [ ] Hook this into our other systems Release Notes: - N/A --- Cargo.lock | 1 + assets/keymaps/default-linux.json | 5 +- assets/keymaps/default-macos.json | 4 +- assets/keymaps/default-windows.json | 4 +- crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/agent_model_selector.rs | 4 + crates/agent_ui/src/buffer_codegen.rs | 20 ++ crates/agent_ui/src/inline_prompt_editor.rs | 263 ++++++++++++++++++-- crates/agent_ui/src/terminal_codegen.rs | 17 +- crates/zed/src/zed.rs | 1 + 10 files changed, 292 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 49b9d6069ccdfadf2d5145808fd7b758f9b389bb..7ae8d55a2484970b0ae1ad0631acadf22d106e46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,6 +401,7 @@ dependencies = [ "unindent", "url", "util", + "uuid", "watch", "workspace", "zed_actions", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 54a4f331c0b0c59eca79065fe42c1a8ecbf646b7..3838edb7a1fbea49ee0c5e1a978f9e8a9b919320 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -811,7 +811,10 @@ "context": "PromptEditor", "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" + "ctrl-]": "agent::CycleNextInlineAssist", + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-backspace": "inline_assistant::ThumbsDownResult" + } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 060151c647e42370f5aa0be5d2fa186774c2574d..9edfaa03f8d7c9609d7b642ee7ddf61973f75e76 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -878,7 +878,9 @@ "bindings": { "cmd-alt-/": "agent::ToggleModelSelector", "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" + "ctrl-]": "agent::CycleNextInlineAssist", + "cmd-shift-enter": "inline_assistant::ThumbsUpResult", + "cmd-shift-backspace": "inline_assistant::ThumbsDownResult" } }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index d749ac56886860b0e80de27f942082639df0447b..5842fe7729c74ad3f226055382cbac7f0b6d2f8f 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -816,7 +816,9 @@ "use_key_equivalents": true, "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" + "ctrl-]": "agent::CycleNextInlineAssist", + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-delete": "inline_assistant::ThumbsDownResult" } }, { diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 048ffab9b72bdecce3754320bf34f1702f021554..2af0ce6fbd2b636d19d9cb8e544851514800313c 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -95,6 +95,7 @@ ui.workspace = true ui_input.workspace = true url.workspace = true util.workspace = true +uuid.workspace = true watch.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index 3840e40cf4d22db9d52e74ef0489c06ca8a15f26..9c2634143099d2097b5c6492f81c56aa51f12491 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -63,6 +63,10 @@ impl AgentModelSelector { pub fn toggle(&self, window: &mut Window, cx: &mut Context) { self.menu_handle.toggle(window, cx); } + + pub fn active_model(&self, cx: &App) -> Option { + self.selector.read(cx).delegate.active_model(cx) + } } impl Render for AgentModelSelector { diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index f7e7884310458e97421768882df57934a19b4430..1cd7bec7b5b2c24cfbcf01a20091e8a07608e73a 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -119,6 +119,10 @@ impl BufferCodegen { .push(cx.subscribe(&codegen, |_, _, event, cx| cx.emit(*event))); } + pub fn active_completion(&self, cx: &App) -> Option { + self.active_alternative().read(cx).current_completion() + } + pub fn active_alternative(&self) -> &Entity { &self.alternatives[self.active_alternative] } @@ -241,6 +245,10 @@ impl BufferCodegen { pub fn last_equal_ranges<'a>(&self, cx: &'a App) -> &'a [Range] { self.active_alternative().read(cx).last_equal_ranges() } + + pub fn selected_text<'a>(&self, cx: &'a App) -> Option<&'a str> { + self.active_alternative().read(cx).selected_text() + } } impl EventEmitter for BufferCodegen {} @@ -264,6 +272,7 @@ pub struct CodegenAlternative { line_operations: Vec, elapsed_time: Option, completion: Option, + selected_text: Option, pub message_id: Option, pub model_explanation: Option, } @@ -323,6 +332,7 @@ impl CodegenAlternative { range, elapsed_time: None, completion: None, + selected_text: None, model_explanation: None, _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), } @@ -608,6 +618,8 @@ impl CodegenAlternative { .text_for_range(self.range.start..self.range.end) .collect::(); + self.selected_text = Some(selected_text.to_string()); + let selection_start = self.range.start.to_point(&snapshot); // Start with the indentation of the first line in the selection @@ -868,6 +880,14 @@ impl CodegenAlternative { cx.notify(); } + pub fn current_completion(&self) -> Option { + self.completion.clone() + } + + pub fn selected_text(&self) -> Option<&str> { + self.selected_text.as_deref() + } + pub fn stop(&mut self, cx: &mut Context) { self.last_equal_ranges.clear(); if self.diff.is_empty() { diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index b9852ea727c7974e3564fadc652f132076c01f09..4856d4024c94856e8dee91c048fe6ce72e79a7b8 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -8,10 +8,11 @@ use editor::{ ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, actions::{MoveDown, MoveUp}, }; +use feature_flags::{FeatureFlag, FeatureFlagAppExt}; use fs::Fs; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Subscription, - TextStyle, TextStyleRefinement, WeakEntity, Window, + AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, + Subscription, TextStyle, TextStyleRefinement, WeakEntity, Window, actions, }; use language_model::{LanguageModel, LanguageModelRegistry}; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; @@ -19,14 +20,16 @@ use parking_lot::Mutex; use project::Project; use prompt_store::PromptStore; use settings::Settings; -use std::cmp; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; +use std::{cmp, mem}; use theme::ThemeSettings; use ui::utils::WithRemSize; use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; -use workspace::Workspace; +use uuid::Uuid; +use workspace::notifications::NotificationId; +use workspace::{Toast, Workspace}; use zed_actions::agent::ToggleModelSelector; use crate::agent_model_selector::AgentModelSelector; @@ -39,6 +42,58 @@ use crate::mention_set::{MentionSet, crease_for_mention}; use crate::terminal_codegen::TerminalCodegen; use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; +actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]); + +pub struct InlineAssistRatingFeatureFlag; + +impl FeatureFlag for InlineAssistRatingFeatureFlag { + const NAME: &'static str = "inline-assist-rating"; + + fn enabled_for_staff() -> bool { + false + } +} + +enum RatingState { + Pending, + GeneratedCompletion(Option), + Rated(Uuid), +} + +impl RatingState { + fn is_pending(&self) -> bool { + matches!(self, RatingState::Pending) + } + + fn rating_id(&self) -> Option { + match self { + RatingState::Pending => None, + RatingState::GeneratedCompletion(_) => None, + RatingState::Rated(id) => Some(*id), + } + } + + fn rate(&mut self) -> (Uuid, Option) { + let id = Uuid::new_v4(); + let old_state = mem::replace(self, RatingState::Rated(id)); + let completion = match old_state { + RatingState::Pending => None, + RatingState::GeneratedCompletion(completion) => completion, + RatingState::Rated(_) => None, + }; + + (id, completion) + } + + fn reset(&mut self) { + *self = RatingState::Pending; + } + + fn generated_completion(&mut self, generated_completion: Option) { + *self = RatingState::GeneratedCompletion(generated_completion); + } +} + pub struct PromptEditor { pub editor: Entity, mode: PromptEditorMode, @@ -54,6 +109,7 @@ pub struct PromptEditor { _codegen_subscription: Subscription, editor_subscriptions: Vec, show_rate_limit_notice: bool, + rated: RatingState, _phantom: std::marker::PhantomData, } @@ -153,6 +209,8 @@ impl Render for PromptEditor { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::move_down)) + .on_action(cx.listener(Self::thumbs_up)) + .on_action(cx.listener(Self::thumbs_down)) .capture_action(cx.listener(Self::cycle_prev)) .capture_action(cx.listener(Self::cycle_next)) .child( @@ -429,6 +487,7 @@ impl PromptEditor { } self.edited_since_done = true; + self.rated.reset(); cx.notify(); } EditorEvent::Blurred => { @@ -516,6 +575,121 @@ impl PromptEditor { } } + fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context) { + if self.rated.is_pending() { + self.toast("Still generating...", None, cx); + return; + } + + if let Some(rating_id) = self.rated.rating_id() { + self.toast("Already rated this completion", Some(rating_id), cx); + return; + } + + let (rating_id, completion) = self.rated.rate(); + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let model_info = self.model_selector.read(cx).active_model(cx); + let model_id = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + + configured_model.model.telemetry_id() + }; + + let prompt = self.editor.read(cx).text(cx); + + telemetry::event!( + "Inline Assistant Rated", + rating = "positive", + model = model_id, + prompt = prompt, + completion = completion, + selected_text = selected_text, + rating_id = rating_id.to_string() + ); + + cx.notify(); + } + + fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context) { + if self.rated.is_pending() { + self.toast("Still generating...", None, cx); + return; + } + if let Some(rating_id) = self.rated.rating_id() { + self.toast("Already rated this completion", Some(rating_id), cx); + return; + } + + let (rating_id, completion) = self.rated.rate(); + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let model_info = self.model_selector.read(cx).active_model(cx); + let model_telemetry_id = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + + configured_model.model.telemetry_id() + }; + + let prompt = self.editor.read(cx).text(cx); + + telemetry::event!( + "Inline Assistant Rated", + rating = "negative", + model = model_telemetry_id, + prompt = prompt, + completion = completion, + selected_text = selected_text, + rating_id = rating_id.to_string() + ); + + cx.notify(); + } + + fn toast(&mut self, msg: &str, uuid: Option, cx: &mut Context<'_, PromptEditor>) { + self.workspace + .update(cx, |workspace, cx| { + enum InlinePromptRating {} + workspace.show_toast( + { + let mut toast = Toast::new( + NotificationId::unique::(), + msg.to_string(), + ) + .autohide(); + + if let Some(uuid) = uuid { + toast = toast.on_click("Click to copy rating ID", move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(uuid.to_string())); + }); + }; + + toast + }, + cx, + ); + }) + .ok(); + } + fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { if let Some(ix) = self.prompt_history_ix { if ix > 0 { @@ -621,6 +795,9 @@ impl PromptEditor { .into_any_element(), ] } else { + let show_rating_buttons = cx.has_flag::(); + let rated = self.rated.rating_id().is_some(); + let accept = IconButton::new("accept", IconName::Check) .icon_color(Color::Info) .shape(IconButtonShape::Square) @@ -632,25 +809,59 @@ impl PromptEditor { })) .into_any_element(); - match &self.mode { - PromptEditorMode::Terminal { .. } => vec![ - accept, - IconButton::new("confirm", IconName::PlayFilled) - .icon_color(Color::Info) + let mut buttons = Vec::new(); + + if show_rating_buttons { + buttons.push( + IconButton::new("thumbs-down", IconName::ThumbsDown) + .icon_color(if rated { Color::Muted } else { Color::Default }) .shape(IconButtonShape::Square) - .tooltip(|_window, cx| { - Tooltip::for_action( - "Execute Generated Command", - &menu::SecondaryConfirm, - cx, - ) - }) - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(PromptEditorEvent::ConfirmRequested { execute: true }); + .disabled(rated) + .tooltip(Tooltip::text("Bad result")) + .on_click(cx.listener(|this, _, window, cx| { + this.thumbs_down(&ThumbsDownResult, window, cx); })) .into_any_element(), - ], - PromptEditorMode::Buffer { .. } => vec![accept], + ); + + buttons.push( + IconButton::new("thumbs-up", IconName::ThumbsUp) + .icon_color(if rated { Color::Muted } else { Color::Default }) + .shape(IconButtonShape::Square) + .disabled(rated) + .tooltip(Tooltip::text("Good result")) + .on_click(cx.listener(|this, _, window, cx| { + this.thumbs_up(&ThumbsUpResult, window, cx); + })) + .into_any_element(), + ); + } + + buttons.push(accept); + + match &self.mode { + PromptEditorMode::Terminal { .. } => { + buttons.push( + IconButton::new("confirm", IconName::PlayFilled) + .icon_color(Color::Info) + .shape(IconButtonShape::Square) + .tooltip(|_window, cx| { + Tooltip::for_action( + "Execute Generated Command", + &menu::SecondaryConfirm, + cx, + ) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(PromptEditorEvent::ConfirmRequested { + execute: true, + }); + })) + .into_any_element(), + ); + buttons + } + PromptEditorMode::Buffer { .. } => buttons, } } } @@ -979,6 +1190,7 @@ impl PromptEditor { editor_subscriptions: Vec::new(), show_rate_limit_notice: false, mode, + rated: RatingState::Pending, _phantom: Default::default(), }; @@ -989,7 +1201,7 @@ impl PromptEditor { fn handle_codegen_changed( &mut self, - _: Entity, + codegen: Entity, cx: &mut Context>, ) { match self.codegen_status(cx) { @@ -998,10 +1210,13 @@ impl PromptEditor { .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.rated.reset(); self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done => { + let completion = codegen.read(cx).active_completion(cx); + self.rated.generated_completion(completion); self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); @@ -1122,6 +1337,7 @@ impl PromptEditor { editor_subscriptions: Vec::new(), mode, show_rate_limit_notice: false, + rated: RatingState::Pending, _phantom: Default::default(), }; this.count_lines(cx); @@ -1154,17 +1370,20 @@ impl PromptEditor { } } - fn handle_codegen_changed(&mut self, _: Entity, cx: &mut Context) { + fn handle_codegen_changed(&mut self, codegen: Entity, cx: &mut Context) { match &self.codegen().read(cx).status { CodegenStatus::Idle => { self.editor .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.rated = RatingState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done | CodegenStatus::Error(_) => { + self.rated + .generated_completion(codegen.read(cx).completion()); self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); diff --git a/crates/agent_ui/src/terminal_codegen.rs b/crates/agent_ui/src/terminal_codegen.rs index 5a4a9d560a16e858dcaedf706f2067a24bc12c5f..cc99471f7f3037cb94ff23979036bd6c2026e2f0 100644 --- a/crates/agent_ui/src/terminal_codegen.rs +++ b/crates/agent_ui/src/terminal_codegen.rs @@ -135,6 +135,12 @@ impl TerminalCodegen { cx.notify(); } + pub fn completion(&self) -> Option { + self.transaction + .as_ref() + .map(|transaction| transaction.completion.clone()) + } + pub fn stop(&mut self, cx: &mut Context) { self.status = CodegenStatus::Done; self.generation = Task::ready(()); @@ -167,27 +173,32 @@ pub const CLEAR_INPUT: &str = "\x03"; const CARRIAGE_RETURN: &str = "\x0d"; struct TerminalTransaction { + completion: String, terminal: Entity, } impl TerminalTransaction { pub fn start(terminal: Entity) -> Self { - Self { terminal } + Self { + completion: String::new(), + terminal, + } } pub fn push(&mut self, hunk: String, cx: &mut App) { // Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal let input = Self::sanitize_input(hunk); + self.completion.push_str(&input); self.terminal .update(cx, |terminal, _| terminal.input(input.into_bytes())); } - pub fn undo(&self, cx: &mut App) { + pub fn undo(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes())); } - pub fn complete(&self, cx: &mut App) { + pub fn complete(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CARRIAGE_RETURN.as_bytes())); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1361fcdba788752099c8e5b37b51e751fccf4dfd..71653124b1c4af993d9878b2b689d07f4f2acd02 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4745,6 +4745,7 @@ mod tests { "git_panel", "go_to_line", "icon_theme_selector", + "inline_assistant", "journal", "keymap_editor", "keystroke_input", From a8e2dc2f252ac996a40e82f0f3e94a14b037fda7 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 10 Dec 2025 05:19:00 -0500 Subject: [PATCH 28/48] Use agent name from extension (#44496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously this rendered `mistral-vibe` and not `Mistral Vibe`: Screenshot 2025-12-09 at 2 52 48 PM Release Notes: - Render agent display names from extension in menu --- crates/agent_ui/src/agent_configuration.rs | 37 ++++++++++++++++------ crates/agent_ui/src/agent_panel.rs | 5 ++- crates/project/src/agent_server_store.rs | 23 ++++++++++++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index aa4cbc8e5b261d1953a91fb090e7ecd28b4e3a31..327f699b4dbf5512a60637d8fce2edfba75280f0 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -977,7 +977,10 @@ impl AgentConfiguration { } else { AgentIcon::Name(IconName::Ai) }; - (name, icon) + let display_name = agent_server_store + .agent_display_name(&name) + .unwrap_or_else(|| name.0.clone()); + (name, icon, display_name) }) .collect(); @@ -1084,6 +1087,7 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiClaude), "Claude Code", + "Claude Code", false, cx, )) @@ -1091,6 +1095,7 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiOpenAi), "Codex CLI", + "Codex CLI", false, cx, )) @@ -1098,16 +1103,23 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiGemini), "Gemini CLI", + "Gemini CLI", false, cx, )) .map(|mut parent| { - for (name, icon) in user_defined_agents { + for (name, icon, display_name) in user_defined_agents { parent = parent .child( Divider::horizontal().color(DividerColor::BorderFaded), ) - .child(self.render_agent_server(icon, name, true, cx)); + .child(self.render_agent_server( + icon, + name, + display_name, + true, + cx, + )); } parent }), @@ -1118,11 +1130,13 @@ impl AgentConfiguration { fn render_agent_server( &self, icon: AgentIcon, - name: impl Into, + id: impl Into, + display_name: impl Into, external: bool, cx: &mut Context, ) -> impl IntoElement { - let name = name.into(); + let id = id.into(); + let display_name = display_name.into(); let icon = match icon { AgentIcon::Name(icon_name) => Icon::new(icon_name) .size(IconSize::Small) @@ -1132,12 +1146,15 @@ impl AgentConfiguration { .color(Color::Muted), }; - let tooltip_id = SharedString::new(format!("agent-source-{}", name)); - let tooltip_message = format!("The {} agent was installed from an extension.", name); + let tooltip_id = SharedString::new(format!("agent-source-{}", id)); + let tooltip_message = format!( + "The {} agent was installed from an extension.", + display_name + ); - let agent_server_name = ExternalAgentServerName(name.clone()); + let agent_server_name = ExternalAgentServerName(id.clone()); - let uninstall_btn_id = SharedString::from(format!("uninstall-{}", name)); + let uninstall_btn_id = SharedString::from(format!("uninstall-{}", id)); let uninstall_button = IconButton::new(uninstall_btn_id, IconName::Trash) .icon_color(Color::Muted) .icon_size(IconSize::Small) @@ -1161,7 +1178,7 @@ impl AgentConfiguration { h_flex() .gap_1p5() .child(icon) - .child(Label::new(name)) + .child(Label::new(display_name)) .when(external, |this| { this.child( div() diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 18e8f1e731defa82e865dd45e66389634992037c..d5dd0a25c818600226eb8a894f73dffcba86b797 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2083,8 +2083,11 @@ impl AgentPanel { for agent_name in agent_names { let icon_path = agent_server_store.agent_icon(&agent_name); + let display_name = agent_server_store + .agent_display_name(&agent_name) + .unwrap_or_else(|| agent_name.0.clone()); - let mut entry = ContextMenuEntry::new(agent_name.clone()); + let mut entry = ContextMenuEntry::new(display_name); if let Some(icon_path) = icon_path { entry = entry.custom_icon_svg(icon_path); diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 6524cc6d22a0bf7c5d7a5c4ad6ae0e86d795be28..a2cc57beae9702e4d5b495a135e7c357c638c17a 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -137,6 +137,7 @@ pub struct AgentServerStore { state: AgentServerStoreState, external_agents: HashMap>, agent_icons: HashMap, + agent_display_names: HashMap, } pub struct AgentServersUpdated; @@ -155,6 +156,7 @@ mod ext_agent_tests { state: AgentServerStoreState::Collab, external_agents: HashMap::default(), agent_icons: HashMap::default(), + agent_display_names: HashMap::default(), } } @@ -258,6 +260,7 @@ impl AgentServerStore { self.external_agents.retain(|name, agent| { if agent.downcast_mut::().is_some() { self.agent_icons.remove(name); + self.agent_display_names.remove(name); false } else { // Keep the hardcoded external agents that don't come from extensions @@ -275,6 +278,12 @@ impl AgentServerStore { for (ext_id, manifest) in manifests { for (agent_name, agent_entry) in &manifest.agent_servers { // Store absolute icon path if provided, resolving symlinks for dev extensions + // Store display name from manifest + self.agent_display_names.insert( + ExternalAgentServerName(agent_name.clone().into()), + SharedString::from(agent_entry.name.clone()), + ); + let icon_path = if let Some(icon) = &agent_entry.icon { let icon_path = extensions_dir.join(ext_id).join(icon); // Canonicalize to resolve symlinks (dev extensions are symlinked) @@ -310,6 +319,12 @@ impl AgentServerStore { let mut agents = vec![]; for (ext_id, manifest) in manifests { for (agent_name, agent_entry) in &manifest.agent_servers { + // Store display name from manifest + self.agent_display_names.insert( + ExternalAgentServerName(agent_name.clone().into()), + SharedString::from(agent_entry.name.clone()), + ); + // Store absolute icon path if provided, resolving symlinks for dev extensions let icon = if let Some(icon) = &agent_entry.icon { let icon_path = extensions_dir.join(ext_id).join(icon); @@ -369,6 +384,10 @@ impl AgentServerStore { self.agent_icons.get(name).cloned() } + pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option { + self.agent_display_names.get(name).cloned() + } + pub fn init_remote(session: &AnyProtoClient) { session.add_entity_message_handler(Self::handle_external_agents_updated); session.add_entity_message_handler(Self::handle_loading_status_updated); @@ -559,6 +578,7 @@ impl AgentServerStore { }, external_agents: Default::default(), agent_icons: Default::default(), + agent_display_names: Default::default(), }; if let Some(_events) = extension::ExtensionEvents::try_global(cx) {} this.agent_servers_settings_changed(cx); @@ -609,6 +629,7 @@ impl AgentServerStore { }, external_agents: external_agents.into_iter().collect(), agent_icons: HashMap::default(), + agent_display_names: HashMap::default(), } } @@ -617,6 +638,7 @@ impl AgentServerStore { state: AgentServerStoreState::Collab, external_agents: Default::default(), agent_icons: Default::default(), + agent_display_names: Default::default(), } } @@ -2040,6 +2062,7 @@ mod extension_agent_tests { state: AgentServerStoreState::Collab, external_agents: HashMap::default(), agent_icons: HashMap::default(), + agent_display_names: HashMap::default(), }; // Seed with extension agents (contain ": ") and custom agents (don't contain ": ") From 30597a0cbafdf44255e7a252f1b1b3e2e12f0668 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 10 Dec 2025 11:33:49 +0100 Subject: [PATCH 29/48] project_panel: Fix create entry with trailing dot duplicating on windows (#44524) Release Notes: - Fixed an issue where creating a file through the project panel with a trailing dot in its name would duplicate the entries with and without the dot Co-authored by: Smit Barmase --- crates/languages/src/go.rs | 4 +- crates/project_panel/src/project_panel.rs | 12 +++- .../project_panel/src/project_panel_tests.rs | 68 +++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index a8699fe9c2dc8cf99ca46a16fe75b1de6eea7ffa..130e142076b8c6ec0393e4f0d617c3a522b2ef22 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -73,7 +73,9 @@ impl LspInstaller for GoLspAdapter { delegate.show_notification(NOTIFICATION_MESSAGE, cx); })? } - anyhow::bail!("cannot install gopls"); + anyhow::bail!( + "Could not install the Go language server `gopls`, because `go` was not found." + ); } let release = diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index e53be8cd33fa265dfadb201b2bcd613c54ffb9dd..0c633bcfcda2415bae84201f7e67a91fbd5a866d 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1663,12 +1663,20 @@ impl ProjectPanel { let edit_state = self.state.edit_state.as_mut()?; let worktree_id = edit_state.worktree_id; let is_new_entry = edit_state.is_new_entry(); - let filename = self.filename_editor.read(cx).text(cx); + let mut filename = self.filename_editor.read(cx).text(cx); + let path_style = self.project.read(cx).path_style(cx); + if path_style.is_windows() { + // on windows, trailing dots are ignored in paths + // this can cause project panel to create a new entry with a trailing dot + // while the actual one without the dot gets populated by the file watcher + while let Some(trimmed) = filename.strip_suffix('.') { + filename = trimmed.to_string(); + } + } if filename.trim().is_empty() { return None; } - let path_style = self.project.read(cx).path_style(cx); let filename_indicates_dir = if path_style.is_windows() { filename.ends_with('/') || filename.ends_with('\\') } else { diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 6cf487bf9849a9252abc21504171b8c6bdf7e298..3f54e01927d67541fb3b17e88facadd1e6776bb6 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -6612,6 +6612,74 @@ async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppC ); } +#[cfg(windows)] +#[gpui::test] +async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "dir1": { + "file1.txt": "", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let panel = workspace + .update(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + .unwrap(); + cx.run_until_parked(); + + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root", + " > dir1", + ], + "Initial state with nothing selected" + ); + + panel.update_in(cx, |panel, window, cx| { + panel.new_file(&NewFile, window, cx); + }); + cx.run_until_parked(); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + panel + .update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("foo.", window, cx)); + panel.confirm_edit(true, window, cx).unwrap() + }) + .await + .unwrap(); + cx.run_until_parked(); + #[rustfmt::skip] + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root", + " > dir1", + " foo <== selected <== marked", + ], + "A new file is created under the root directory without the trailing dot" + ); +} + #[gpui::test] async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) { init_test(cx); From b1333b53ad6595e12587e8bbc58509fb2551bc43 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 10 Dec 2025 11:35:29 +0100 Subject: [PATCH 30/48] editor: Improve performance of `create_highlight_endpoints` (#44521) We reallocate quite a bunch in this codepath even though we don't need to, we already roughly know what number of elements we are working with so we can reduce the required allocations to some degree. This also reduces the amount of anchor comparisons required. Came up in profiling for https://github.com/zed-industries/zed/issues/44503 Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/editor/src/display_map/custom_highlights.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/editor/src/display_map/custom_highlights.rs b/crates/editor/src/display_map/custom_highlights.rs index c9202280bf957fac4d729bab558f686c0f62e774..1ece2493e3228536999036a32959a6228f0f7cd1 100644 --- a/crates/editor/src/display_map/custom_highlights.rs +++ b/crates/editor/src/display_map/custom_highlights.rs @@ -79,12 +79,15 @@ fn create_highlight_endpoints( let start_ix = ranges .binary_search_by(|probe| probe.end.cmp(&start, buffer).then(cmp::Ordering::Less)) .unwrap_or_else(|i| i); + let end_ix = ranges[start_ix..] + .binary_search_by(|probe| { + probe.start.cmp(&end, buffer).then(cmp::Ordering::Greater) + }) + .unwrap_or_else(|i| i); - for range in &ranges[start_ix..] { - if range.start.cmp(&end, buffer).is_ge() { - break; - } + 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 { From 0a816cbc87a4c513c429ee025caeac1082988335 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 10 Dec 2025 09:48:10 -0300 Subject: [PATCH 31/48] edit prediction: Exclude whole-module definitions from context (#44414) For qualified identifiers we end up requesting both the definition of the module and the item within it, but we only want the latter. At the moment, we can't skip the request altogether, because we can't tell them apart from the highlights query. However, we can tell from the target range length, because it should be small for individual definitions as it only covers their name, not the whole body. Release Notes: - N/A --- .../src/edit_prediction_context.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 475050fabb8b17ad76c34234094cf798e36a76ab..d3aefaa6e4ec585dc7c90fee1e95de17e018f90f 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -383,6 +383,8 @@ async fn rebuild_related_files( .await) } +const MAX_TARGET_LEN: usize = 128; + fn process_definition( location: LocationLink, project: &Entity, @@ -395,6 +397,15 @@ fn process_definition( if worktree.read(cx).is_single_file() { return None; } + + // If the target range is large, it likely means we requested the definition of an entire module. + // For individual definitions, the target range should be small as it only covers the symbol. + let buffer = location.target.buffer.read(cx); + let target_len = anchor_range.to_offset(&buffer).len(); + if target_len > MAX_TARGET_LEN { + return None; + } + Some(CachedDefinition { path: ProjectPath { worktree_id: file.worktree_id(cx), From 511e51c80eb19ae7ce46c83ea609fa65a137dbee Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 10 Dec 2025 14:01:31 +0100 Subject: [PATCH 32/48] text: Replace some more release panics with graceful fallbacks (#44542) Fixes ZED-3P7 Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/text/src/text.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index acd712f40da23af4c364649b14860e41a346389c..31eed1e926d49584e0e71a494555284c66a4e255 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -39,6 +39,7 @@ pub use subscription::*; pub use sum_tree::Bias; use sum_tree::{Dimensions, FilterCursor, SumTree, TreeMap, TreeSet}; use undo_map::UndoMap; +use util::debug_panic; #[cfg(any(test, feature = "test-support"))] use util::RandomCharIter; @@ -2439,7 +2440,7 @@ impl BufferSnapshot { if bias == Bias::Left && offset == 0 { Anchor::min_for_buffer(self.remote_id) } else if bias == Bias::Right - && ((cfg!(debug_assertions) && offset >= self.len()) || offset == self.len()) + && ((!cfg!(debug_assertions) && offset >= self.len()) || offset == self.len()) { Anchor::max_for_buffer(self.remote_id) } else { @@ -2453,7 +2454,15 @@ impl BufferSnapshot { }; } let (start, _, item) = self.fragments.find::(&None, &offset, bias); - let fragment = item.unwrap(); + let Some(fragment) = item else { + // We got a bad offset, likely out of bounds + debug_panic!( + "Failed to find fragment at offset {} (len: {})", + offset, + self.len() + ); + return Anchor::max_for_buffer(self.remote_id); + }; let overshoot = offset - start; Anchor { timestamp: fragment.timestamp, From dd431631b43ce33bd21494122ab74db691fec549 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 10 Dec 2025 15:28:19 +0100 Subject: [PATCH 33/48] editor: Ensure completion menu scrollbar does not become stale (#44536) Only by reusing the previous scroll handle, we can ensure that both the scrollbar remains usable and also that the scrollbar does not flicker. Previously, the scrollbar would hold the reference to an outdated handle. I tried invalidating the handle the scrollbar uses, but that leads to flickering, which is worse. Hence, let's just reuse the scrollbar here. Release Notes: - Fixed an issue where the scrollbar would become stale in the code completions menu after the items were updated. --- crates/editor/src/code_context_menus.rs | 13 +++++++++++-- crates/editor/src/editor.rs | 10 +++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index dcd96674207f02101b4066924b011d2b9ebd7a08..d255effdb72a003014dff0805fa34a23d11c8c81 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -206,6 +206,13 @@ impl CodeContextMenu { CodeContextMenu::CodeActions(_) => (), } } + + pub fn primary_scroll_handle(&self) -> UniformListScrollHandle { + match self { + CodeContextMenu::Completions(menu) => menu.scroll_handle.clone(), + CodeContextMenu::CodeActions(menu) => menu.scroll_handle.clone(), + } + } } pub enum ContextMenuOrigin { @@ -303,6 +310,7 @@ impl CompletionsMenu { is_incomplete: bool, buffer: Entity, completions: Box<[Completion]>, + scroll_handle: Option, display_options: CompletionDisplayOptions, snippet_sort_order: SnippetSortOrder, language_registry: Option>, @@ -332,7 +340,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: true, last_rendered_range: RefCell::new(None).into(), @@ -354,6 +362,7 @@ impl CompletionsMenu { choices: &Vec, selection: Range, buffer: Entity, + scroll_handle: Option, snippet_sort_order: SnippetSortOrder, ) -> Self { let completions = choices @@ -404,7 +413,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: false, show_completion_documentation: false, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d841bf858b8a77f502b8bfb2499118f9e714572e..aa226758648ac0d140edc6aa7b019c6271910848 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5882,6 +5882,11 @@ impl Editor { is_incomplete, buffer.clone(), completions.into(), + editor + .context_menu() + .borrow_mut() + .as_ref() + .map(|menu| menu.primary_scroll_handle()), display_options, snippet_sort_order, languages, @@ -10016,13 +10021,16 @@ impl Editor { let id = post_inc(&mut self.next_completion_id); let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; - *self.context_menu.borrow_mut() = Some(CodeContextMenu::Completions( + let mut context_menu = self.context_menu.borrow_mut(); + let old_menu = context_menu.take(); + *context_menu = Some(CodeContextMenu::Completions( CompletionsMenu::new_snippet_choices( id, true, choices, selection, buffer, + old_menu.map(|menu| menu.primary_scroll_handle()), snippet_sort_order, ), )); From 21f7e6a9e6b1e60d196cab72a2bbf402d3c69ae7 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 10 Dec 2025 12:01:49 -0300 Subject: [PATCH 34/48] commit view: Fix layout shift while loading commit (#44548) Fixes a few cases where the commit view would layout shift as the diff loaded. This was caused by: - Adding the commit message buffer after all the diff files - Using the gutter dimensions from the last frame for the avatar spacing Release Notes: - commit view: Fix layout shift while loading commit --------- Co-authored-by: MrSubidubi --- crates/editor/src/editor.rs | 326 ++++++++++-------- crates/editor/src/editor_tests.rs | 26 +- crates/editor/src/element.rs | 88 ++--- crates/editor/src/mouse_context_menu.rs | 2 +- crates/editor/src/test/editor_test_context.rs | 3 +- crates/git_ui/src/commit_view.rs | 133 +++---- crates/vim/src/normal/scroll.rs | 5 +- crates/vim/src/test.rs | 2 +- .../src/test/neovim_backed_test_context.rs | 5 +- crates/zed/src/zed/quick_action_bar.rs | 16 +- 10 files changed, 307 insertions(+), 299 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index aa226758648ac0d140edc6aa7b019c6271910848..30e040fac1fc5682cbae8f9261c6996ec48a074d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -108,7 +108,7 @@ use gpui::{ DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, Render, - ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextStyle, + ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextRun, TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, size, }; @@ -575,7 +575,7 @@ impl Default for EditorStyle { } } -pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { +pub fn make_inlay_hints_style(cx: &App) -> HighlightStyle { let show_background = language_settings::language_settings(None, None, cx) .inlay_hints .show_background; @@ -598,7 +598,7 @@ pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { style } -pub fn make_suggestion_styles(cx: &mut App) -> EditPredictionStyles { +pub fn make_suggestion_styles(cx: &App) -> EditPredictionStyles { EditPredictionStyles { insertion: HighlightStyle { color: Some(cx.theme().status().predictive), @@ -1249,6 +1249,7 @@ impl NextScrollCursorCenterTopBottom { pub struct EditorSnapshot { pub mode: EditorMode, show_gutter: bool, + offset_content: bool, show_line_numbers: Option, show_git_diff_gutter: Option, show_code_actions: Option, @@ -1825,7 +1826,11 @@ impl Editor { Editor::new_internal(mode, buffer, project, None, window, cx) } - pub fn sticky_headers(&self, cx: &App) -> Option>> { + pub fn sticky_headers( + &self, + style: &EditorStyle, + cx: &App, + ) -> Option>> { let multi_buffer = self.buffer().read(cx); let multi_buffer_snapshot = multi_buffer.snapshot(cx); let multi_buffer_visible_start = self @@ -1843,7 +1848,7 @@ impl Editor { .outline_items_containing( Point::new(start_row, 0)..Point::new(end_row, 0), true, - self.style().map(|style| style.syntax.as_ref()), + Some(style.syntax.as_ref()), ) .into_iter() .map(|outline_item| OutlineItem { @@ -2935,6 +2940,7 @@ impl Editor { EditorSnapshot { mode: self.mode.clone(), show_gutter: self.show_gutter, + offset_content: self.offset_content, show_line_numbers: self.show_line_numbers, show_git_diff_gutter: self.show_git_diff_gutter, show_code_actions: self.show_code_actions, @@ -6895,7 +6901,7 @@ impl Editor { }; let anchor = self.selections.newest_anchor().head(); - let position = self.to_pixel_point(anchor, &snapshot, window); + let position = self.to_pixel_point(anchor, &snapshot, window, cx); if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) { self.show_blame_popover( buffer, @@ -9208,7 +9214,8 @@ impl Editor { let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - let line_origin = self.display_to_pixel_point(target_line_end, editor_snapshot, window)?; + let line_origin = + self.display_to_pixel_point(target_line_end, editor_snapshot, window, cx)?; let start_point = content_origin - point(scroll_pixel_position.x.into(), Pixels::ZERO); let mut origin = start_point @@ -9950,8 +9957,7 @@ impl Editor { } pub fn render_context_menu( - &self, - style: &EditorStyle, + &mut self, max_height_in_lines: u32, window: &mut Window, cx: &mut Context, @@ -9961,7 +9967,9 @@ impl Editor { if !menu.visible() { return None; }; - Some(menu.render(style, max_height_in_lines, window, cx)) + self.style + .as_ref() + .map(|style| menu.render(style, max_height_in_lines, window, cx)) } fn render_context_menu_aside( @@ -20364,8 +20372,11 @@ impl Editor { self.style = Some(style); } - pub fn style(&self) -> Option<&EditorStyle> { - self.style.as_ref() + pub fn style(&mut self, cx: &App) -> &EditorStyle { + if self.style.is_none() { + self.style = Some(self.create_style(cx)); + } + self.style.as_ref().unwrap() } // Called by the element. This method is not designed to be called outside of the editor @@ -22989,22 +23000,24 @@ impl Editor { } pub fn to_pixel_point( - &self, + &mut self, source: multi_buffer::Anchor, editor_snapshot: &EditorSnapshot, window: &mut Window, + cx: &App, ) -> Option> { let source_point = source.to_display_point(editor_snapshot); - self.display_to_pixel_point(source_point, editor_snapshot, window) + self.display_to_pixel_point(source_point, editor_snapshot, window, cx) } pub fn display_to_pixel_point( - &self, + &mut self, source: DisplayPoint, editor_snapshot: &EditorSnapshot, window: &mut Window, + cx: &App, ) -> Option> { - let line_height = self.style()?.text.line_height_in_pixels(window.rem_size()); + let line_height = self.style(cx).text.line_height_in_pixels(window.rem_size()); let text_layout_details = self.text_layout_details(window); let scroll_top = text_layout_details .scroll_anchor @@ -23068,10 +23081,6 @@ impl Editor { } } - pub fn last_gutter_dimensions(&self) -> &GutterDimensions { - &self.gutter_dimensions - } - pub fn wait_for_diff_to_load(&self) -> Option>> { self.load_diff_task.clone() } @@ -23171,6 +23180,57 @@ impl Editor { // skip any LSP updates for it. self.active_diagnostics == ActiveDiagnostic::All || !self.mode().is_full() } + + fn create_style(&self, cx: &App) -> EditorStyle { + let settings = ThemeSettings::get_global(cx); + + let mut text_style = match self.mode { + EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), + font_size: rems(0.875).into(), + font_weight: settings.ui_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + EditorMode::Full { .. } | EditorMode::Minimap { .. } => TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + }; + if let Some(text_style_refinement) = &self.text_style_refinement { + text_style.refine(text_style_refinement) + } + + let background = match self.mode { + EditorMode::SingleLine => cx.theme().system().transparent, + EditorMode::AutoHeight { .. } => cx.theme().system().transparent, + EditorMode::Full { .. } => cx.theme().colors().editor_background, + EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7), + }; + + EditorStyle { + background, + border: cx.theme().colors().border, + local_player: cx.theme().players().local(), + text: text_style, + scrollbar_width: EditorElement::SCROLLBAR_WIDTH, + syntax: cx.theme().syntax().clone(), + status: cx.theme().status().clone(), + inlay_hints_style: make_inlay_hints_style(cx), + edit_prediction_styles: make_suggestion_styles(cx), + unnecessary_code_fade: settings.unnecessary_code_fade, + show_underlines: self.diagnostics_enabled(), + } + } } fn edit_for_markdown_paste<'a>( @@ -24698,94 +24758,98 @@ impl EditorSnapshot { self.scroll_anchor.scroll_position(&self.display_snapshot) } - fn gutter_dimensions( + pub fn gutter_dimensions( &self, font_id: FontId, font_size: Pixels, - max_line_number_width: Pixels, + style: &EditorStyle, + window: &mut Window, cx: &App, - ) -> Option { - if !self.show_gutter { - return None; - } - - let ch_width = cx.text_system().ch_width(font_id, font_size).log_err()?; - let ch_advance = cx.text_system().ch_advance(font_id, font_size).log_err()?; + ) -> GutterDimensions { + if self.show_gutter + && let Some(ch_width) = cx.text_system().ch_width(font_id, font_size).log_err() + && let Some(ch_advance) = cx.text_system().ch_advance(font_id, font_size).log_err() + { + let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { + matches!( + ProjectSettings::get_global(cx).git.git_gutter, + GitGutterSetting::TrackedFiles + ) + }); + let gutter_settings = EditorSettings::get_global(cx).gutter; + let show_line_numbers = self + .show_line_numbers + .unwrap_or(gutter_settings.line_numbers); + let line_gutter_width = if show_line_numbers { + // Avoid flicker-like gutter resizes when the line number gains another digit by + // only resizing the gutter on files with > 10**min_line_number_digits lines. + let min_width_for_number_on_gutter = + ch_advance * gutter_settings.min_line_number_digits as f32; + self.max_line_number_width(style, window) + .max(min_width_for_number_on_gutter) + } else { + 0.0.into() + }; - let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { - matches!( - ProjectSettings::get_global(cx).git.git_gutter, - GitGutterSetting::TrackedFiles - ) - }); - let gutter_settings = EditorSettings::get_global(cx).gutter; - let show_line_numbers = self - .show_line_numbers - .unwrap_or(gutter_settings.line_numbers); - let line_gutter_width = if show_line_numbers { - // Avoid flicker-like gutter resizes when the line number gains another digit by - // only resizing the gutter on files with > 10**min_line_number_digits lines. - let min_width_for_number_on_gutter = - ch_advance * gutter_settings.min_line_number_digits as f32; - max_line_number_width.max(min_width_for_number_on_gutter) - } else { - 0.0.into() - }; + let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); + let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); - let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); - let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); + let git_blame_entries_width = + self.git_blame_gutter_max_author_length + .map(|max_author_length| { + let renderer = cx.global::().0.clone(); + const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; - let git_blame_entries_width = - self.git_blame_gutter_max_author_length - .map(|max_author_length| { - let renderer = cx.global::().0.clone(); - const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; + /// The number of characters to dedicate to gaps and margins. + const SPACING_WIDTH: usize = 4; - /// The number of characters to dedicate to gaps and margins. - const SPACING_WIDTH: usize = 4; + let max_char_count = max_author_length.min(renderer.max_author_length()) + + ::git::SHORT_SHA_LENGTH + + MAX_RELATIVE_TIMESTAMP.len() + + SPACING_WIDTH; - let max_char_count = max_author_length.min(renderer.max_author_length()) - + ::git::SHORT_SHA_LENGTH - + MAX_RELATIVE_TIMESTAMP.len() - + SPACING_WIDTH; + ch_advance * max_char_count + }); - ch_advance * max_char_count - }); + let is_singleton = self.buffer_snapshot().is_singleton(); + + let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); + left_padding += if !is_singleton { + ch_width * 4.0 + } else if show_runnables || show_breakpoints { + ch_width * 3.0 + } else if show_git_gutter && show_line_numbers { + ch_width * 2.0 + } else if show_git_gutter || show_line_numbers { + ch_width + } else { + px(0.) + }; - let is_singleton = self.buffer_snapshot().is_singleton(); - - let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); - left_padding += if !is_singleton { - ch_width * 4.0 - } else if show_runnables || show_breakpoints { - ch_width * 3.0 - } else if show_git_gutter && show_line_numbers { - ch_width * 2.0 - } else if show_git_gutter || show_line_numbers { - ch_width - } else { - px(0.) - }; + let shows_folds = is_singleton && gutter_settings.folds; - let shows_folds = is_singleton && gutter_settings.folds; + let right_padding = if shows_folds && show_line_numbers { + ch_width * 4.0 + } else if shows_folds || (!is_singleton && show_line_numbers) { + ch_width * 3.0 + } else if show_line_numbers { + ch_width + } else { + px(0.) + }; - let right_padding = if shows_folds && show_line_numbers { - ch_width * 4.0 - } else if shows_folds || (!is_singleton && show_line_numbers) { - ch_width * 3.0 - } else if show_line_numbers { - ch_width + GutterDimensions { + left_padding, + right_padding, + width: line_gutter_width + left_padding + right_padding, + margin: GutterDimensions::default_gutter_margin(font_id, font_size, cx), + git_blame_entries_width, + } + } else if self.offset_content { + GutterDimensions::default_with_margin(font_id, font_size, cx) } else { - px(0.) - }; - - Some(GutterDimensions { - left_padding, - right_padding, - width: line_gutter_width + left_padding + right_padding, - margin: GutterDimensions::default_gutter_margin(font_id, font_size, cx), - git_blame_entries_width, - }) + GutterDimensions::default() + } } pub fn render_crease_toggle( @@ -24868,6 +24932,28 @@ impl EditorSnapshot { None } } + + pub fn max_line_number_width(&self, style: &EditorStyle, window: &mut Window) -> Pixels { + let digit_count = self.widest_line_number().ilog10() + 1; + column_pixels(style, digit_count as usize, window) + } +} + +pub fn column_pixels(style: &EditorStyle, column: usize, window: &Window) -> Pixels { + let font_size = style.text.font_size.to_pixels(window.rem_size()); + let layout = window.text_system().shape_line( + SharedString::from(" ".repeat(column)), + font_size, + &[TextRun { + len: column, + font: style.text.font(), + color: Hsla::default(), + ..Default::default() + }], + None, + ); + + layout.width } impl Deref for EditorSnapshot { @@ -24948,57 +25034,7 @@ impl Focusable for Editor { impl Render for Editor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - - let mut text_style = match self.mode { - EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - EditorMode::Full { .. } | EditorMode::Minimap { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_size: settings.buffer_font_size(cx).into(), - font_weight: settings.buffer_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - }; - if let Some(text_style_refinement) = &self.text_style_refinement { - text_style.refine(text_style_refinement) - } - - let background = match self.mode { - EditorMode::SingleLine => cx.theme().system().transparent, - EditorMode::AutoHeight { .. } => cx.theme().system().transparent, - EditorMode::Full { .. } => cx.theme().colors().editor_background, - EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7), - }; - - EditorElement::new( - &cx.entity(), - EditorStyle { - background, - border: cx.theme().colors().border, - local_player: cx.theme().players().local(), - text: text_style, - scrollbar_width: EditorElement::SCROLLBAR_WIDTH, - syntax: cx.theme().syntax().clone(), - status: cx.theme().status().clone(), - inlay_hints_style: make_inlay_hints_style(cx), - edit_prediction_styles: make_suggestion_styles(cx), - unnecessary_code_fade: ThemeSettings::get_global(cx).unnecessary_code_fade, - show_underlines: self.diagnostics_enabled(), - }, - ) + EditorElement::new(&cx.entity(), self.create_style(cx)) } } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 3c33519370907d3a2f53d63d9e24403c36a5e45a..c97607bb256ff4b4e3054d5de4e3057e58798e73 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -2218,10 +2218,9 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2334,10 +2333,9 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2400,8 +2398,7 @@ async fn test_autoscroll(cx: &mut TestAppContext) { let line_height = cx.update_editor(|editor, window, cx| { editor.set_vertical_scroll_margin(2, cx); editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2480,10 +2477,9 @@ async fn test_move_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _cx| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -28311,7 +28307,8 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) { let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { e.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); - EditorElement::sticky_headers(&e, &e.snapshot(window, cx), cx) + let style = e.style(cx).clone(); + EditorElement::sticky_headers(&e, &e.snapshot(window, cx), &style, cx) .into_iter() .map( |StickyHeader { @@ -28365,10 +28362,9 @@ async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { }); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _cx| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index fab51cbef29de436e447c317849ad15aa318c45d..653cf291a7ff2ea79152535392241ae94eaf05f3 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -11,6 +11,7 @@ use crate::{ SelectedTextHighlight, Selection, SelectionDragState, SelectionEffects, SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, + column_pixels, display_map::{ Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins, HighlightKey, HighlightedChunk, ToDisplayPoint, @@ -2269,7 +2270,8 @@ impl EditorElement { }; let padding = ProjectSettings::get_global(cx).diagnostics.inline.padding as f32 * em_width; - let min_x = self.column_pixels( + let min_x = column_pixels( + &self.style, ProjectSettings::get_global(cx) .diagnostics .inline @@ -2572,7 +2574,8 @@ impl EditorElement { let padded_line_end = line_end + padding; - let min_column_in_pixels = self.column_pixels( + let min_column_in_pixels = column_pixels( + &self.style, ProjectSettings::get_global(cx).git.inline_blame.min_column as usize, window, ); @@ -2796,7 +2799,7 @@ impl EditorElement { .enumerate() .filter_map(|(i, indent_guide)| { let single_indent_width = - self.column_pixels(indent_guide.tab_size as usize, window); + column_pixels(&self.style, indent_guide.tab_size as usize, window); let total_width = single_indent_width * indent_guide.depth as f32; let start_x = Pixels::from( ScrollOffset::from(content_origin.x + total_width) @@ -2853,7 +2856,7 @@ impl EditorElement { .wrap_guides(cx) .into_iter() .flat_map(|(guide, active)| { - let wrap_position = self.column_pixels(guide, window); + let wrap_position = column_pixels(&self.style, guide, window); let wrap_guide_x = wrap_position + horizontal_offset; let display_wrap_guide = wrap_guide_x >= content_origin && wrap_guide_x <= hitbox.bounds.right() - vertical_scrollbar_width; @@ -4619,6 +4622,7 @@ impl EditorElement { gutter_dimensions: &GutterDimensions, gutter_hitbox: &Hitbox, text_hitbox: &Hitbox, + style: &EditorStyle, window: &mut Window, cx: &mut App, ) -> Option { @@ -4626,7 +4630,7 @@ impl EditorElement { .show_line_numbers .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); - let rows = Self::sticky_headers(self.editor.read(cx), snapshot, cx); + let rows = Self::sticky_headers(self.editor.read(cx), snapshot, style, cx); let mut lines = Vec::::new(); @@ -4685,6 +4689,7 @@ impl EditorElement { pub(crate) fn sticky_headers( editor: &Editor, snapshot: &EditorSnapshot, + style: &EditorStyle, cx: &App, ) -> Vec { let scroll_top = snapshot.scroll_position().y; @@ -4692,7 +4697,7 @@ impl EditorElement { let mut end_rows = Vec::::new(); let mut rows = Vec::::new(); - let items = editor.sticky_headers(cx).unwrap_or_default(); + let items = editor.sticky_headers(style, cx).unwrap_or_default(); for item in items { let start_point = item.range.start.to_point(snapshot.buffer_snapshot()); @@ -5255,7 +5260,7 @@ impl EditorElement { ) -> Option { let max_height_in_lines = ((height - POPOVER_Y_PADDING) / line_height).floor() as u32; self.editor.update(cx, |editor, cx| { - editor.render_context_menu(&self.style, max_height_in_lines, window, cx) + editor.render_context_menu(max_height_in_lines, window, cx) }) } @@ -5282,16 +5287,18 @@ impl EditorElement { window: &mut Window, cx: &mut App, ) -> Option { - let position = self.editor.update(cx, |editor, _cx| { + let position = self.editor.update(cx, |editor, cx| { let visible_start_point = editor.display_to_pixel_point( DisplayPoint::new(visible_range.start, 0), editor_snapshot, window, + cx, )?; let visible_end_point = editor.display_to_pixel_point( DisplayPoint::new(visible_range.end, 0), editor_snapshot, window, + cx, )?; let mouse_context_menu = editor.mouse_context_menu.as_ref()?; @@ -5299,7 +5306,8 @@ impl EditorElement { MenuPosition::PinnedToScreen(point) => (None, point), MenuPosition::PinnedToEditor { source, offset } => { let source_display_point = source.to_display_point(editor_snapshot); - let source_point = editor.to_pixel_point(source, editor_snapshot, window)?; + let source_point = + editor.to_pixel_point(source, editor_snapshot, window, cx)?; let position = content_origin + source_point + offset; (Some(source_display_point), position) } @@ -7773,29 +7781,6 @@ impl EditorElement { }); } - fn column_pixels(&self, column: usize, window: &Window) -> Pixels { - let style = &self.style; - let font_size = style.text.font_size.to_pixels(window.rem_size()); - let layout = window.text_system().shape_line( - SharedString::from(" ".repeat(column)), - font_size, - &[TextRun { - len: column, - font: style.text.font(), - color: Hsla::default(), - ..Default::default() - }], - None, - ); - - layout.width - } - - fn max_line_number_width(&self, snapshot: &EditorSnapshot, window: &mut Window) -> Pixels { - let digit_count = snapshot.widest_line_number().ilog10() + 1; - self.column_pixels(digit_count as usize, window) - } - fn shape_line_number( &self, text: SharedString, @@ -8943,8 +8928,6 @@ impl Element for EditorElement { max_lines, } => { let editor_handle = cx.entity(); - let max_line_number_width = - self.max_line_number_width(&editor.snapshot(window, cx), window); window.request_measured_layout( Style::default(), move |known_dimensions, available_space, window, cx| { @@ -8954,7 +8937,6 @@ impl Element for EditorElement { editor, min_lines, max_lines, - max_line_number_width, known_dimensions, available_space.width, window, @@ -9041,15 +9023,10 @@ impl Element for EditorElement { .gutter_dimensions( font_id, font_size, - self.max_line_number_width(&snapshot, window), + style, + window, cx, - ) - .or_else(|| { - self.editor.read(cx).offset_content.then(|| { - GutterDimensions::default_with_margin(font_id, font_size, cx) - }) - }) - .unwrap_or_default(); + ); let text_width = bounds.size.width - gutter_dimensions.width; let settings = EditorSettings::get_global(cx); @@ -9740,6 +9717,7 @@ impl Element for EditorElement { &gutter_dimensions, &gutter_hitbox, &text_hitbox, + &style, window, cx, ) @@ -11456,7 +11434,6 @@ fn compute_auto_height_layout( editor: &mut Editor, min_lines: usize, max_lines: Option, - max_line_number_width: Pixels, known_dimensions: Size>, available_width: AvailableSpace, window: &mut Window, @@ -11480,14 +11457,7 @@ fn compute_auto_height_layout( let em_width = window.text_system().em_width(font_id, font_size).unwrap(); let mut snapshot = editor.snapshot(window, cx); - let gutter_dimensions = snapshot - .gutter_dimensions(font_id, font_size, max_line_number_width, cx) - .or_else(|| { - editor - .offset_content - .then(|| GutterDimensions::default_with_margin(font_id, font_size, cx)) - }) - .unwrap_or_default(); + let gutter_dimensions = snapshot.gutter_dimensions(font_id, font_size, style, window, cx); editor.gutter_dimensions = gutter_dimensions; let text_width = width - gutter_dimensions.width; @@ -11550,7 +11520,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); for x in 1..=100 { let (_, state) = cx.draw( @@ -11578,7 +11548,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); for x in 1..=100 { let (_, state) = cx.draw( @@ -11603,7 +11573,7 @@ mod tests { }); let editor = window.root(cx).unwrap(); - let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); let line_height = window .update(cx, |_, window, _| { style.text.line_height_in_pixels(window.rem_size()) @@ -11751,7 +11721,7 @@ mod tests { }); let editor = window.root(cx).unwrap(); - let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); let line_height = window .update(cx, |_, window, _| { style.text.line_height_in_pixels(window.rem_size()) @@ -11878,7 +11848,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); window .update(cx, |editor, window, cx| { @@ -11949,7 +11919,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); window .update(cx, |editor, window, cx| { editor.set_placeholder_text("hello", window, cx); @@ -12189,7 +12159,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); window .update(cx, |editor, _, cx| { editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index e868b105fac8a8fa87601e2d5bc8578c94bd1940..bda6f1d1d5c893c4fd3fbd4e4d2e72f6ae789361 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -59,7 +59,7 @@ impl MouseContextMenu { x: editor.gutter_dimensions.width, y: Pixels::ZERO, }; - let source_position = editor.to_pixel_point(source, &editor_snapshot, window)?; + let source_position = editor.to_pixel_point(source, &editor_snapshot, window, cx)?; let menu_position = MenuPosition::PinnedToEditor { source, offset: position - (source_position + content_origin), diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index cd45a6ec47ad7631404189194a6a0291a6240647..511629c59d8f61f1c53f5deaa406f113b9dfc3d9 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -283,8 +283,7 @@ impl EditorTestContext { .head(); let pixel_position = editor.pixel_position_of_newest_cursor.unwrap(); let line_height = editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()); let snapshot = editor.snapshot(window, cx); diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 238b0cbf52fdb4312178b868be4b22986ea946c3..30b4e3d986b12f4aba1c5487fac7500bb5cbe670 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -1,9 +1,7 @@ use anyhow::{Context as _, Result}; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; -use editor::{ - Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, multibuffer_context_lines, -}; +use editor::{Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines}; use git::repository::{CommitDetails, CommitDiff, RepoPath}; use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; use gpui::{ @@ -13,7 +11,7 @@ use gpui::{ }; use language::{ Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _, - ReplicaId, Rope, TextBuffer, + Point, ReplicaId, Rope, TextBuffer, }; use multi_buffer::PathKey; use project::{Project, WorktreeId, git_store::Repository}; @@ -70,6 +68,7 @@ struct GitBlob { display_name: Arc, } +const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0; const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; impl CommitView { @@ -147,6 +146,32 @@ impl CommitView { ) -> Self { let language_registry = project.read(cx).languages().clone(); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly)); + + let message_buffer = cx.new(|cx| { + let mut buffer = Buffer::local(commit.message.clone(), cx); + buffer.set_capability(Capability::ReadOnly, cx); + buffer + }); + + multibuffer.update(cx, |multibuffer, cx| { + let snapshot = message_buffer.read(cx).snapshot(); + let full_range = Point::zero()..snapshot.max_point(); + let range = ExcerptRange { + context: full_range.clone(), + primary: full_range, + }; + multibuffer.set_excerpt_ranges_for_path( + PathKey::with_sort_prefix( + COMMIT_MESSAGE_SORT_PREFIX, + RelPath::unix("commit message").unwrap().into(), + ), + message_buffer.clone(), + &snapshot, + vec![range], + cx, + ) + }); + let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); @@ -154,9 +179,38 @@ impl CommitView { editor.disable_inline_diagnostics(); editor.set_show_breakpoints(false, cx); editor.set_expand_all_diff_hunks(cx); + editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx); + editor.disable_indent_guides_for_buffer(message_buffer.read(cx).remote_id(), cx); + + editor.insert_blocks( + [BlockProperties { + placement: BlockPlacement::Above(editor::Anchor::min()), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }] + .into_iter() + .chain( + editor + .buffer() + .read(cx) + .buffer_anchor_to_anchor(&message_buffer, Anchor::MAX, cx) + .map(|anchor| BlockProperties { + placement: BlockPlacement::Below(anchor), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }), + ), + None, + cx, + ); editor }); + let commit_sha = Arc::::from(commit.sha.as_ref()); let first_worktree_id = project @@ -166,7 +220,6 @@ impl CommitView { .map(|worktree| worktree.read(cx).id()); let repository_clone = repository.clone(); - let commit_message = commit.message.clone(); cx.spawn(async move |this, cx| { for file in commit_diff.files { @@ -228,59 +281,6 @@ impl CommitView { })?; } - let message_buffer = cx.new(|cx| { - let mut buffer = Buffer::local(commit_message, cx); - buffer.set_capability(Capability::ReadOnly, cx); - buffer - })?; - - this.update(cx, |this, cx| { - this.multibuffer.update(cx, |multibuffer, cx| { - let range = ExcerptRange { - context: Anchor::MIN..Anchor::MAX, - primary: Anchor::MIN..Anchor::MAX, - }; - multibuffer.insert_excerpts_after( - ExcerptId::min(), - message_buffer.clone(), - [range], - cx, - ) - }); - - this.editor.update(cx, |editor, cx| { - editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx); - editor - .disable_indent_guides_for_buffer(message_buffer.read(cx).remote_id(), cx); - - editor.insert_blocks( - [BlockProperties { - placement: BlockPlacement::Above(editor::Anchor::min()), - height: Some(1), - style: BlockStyle::Sticky, - render: Arc::new(|_| gpui::Empty.into_any_element()), - priority: 0, - }] - .into_iter() - .chain( - editor - .buffer() - .read(cx) - .buffer_anchor_to_anchor(&message_buffer, Anchor::MAX, cx) - .map(|anchor| BlockProperties { - placement: BlockPlacement::Below(anchor), - height: Some(1), - style: BlockStyle::Sticky, - render: Arc::new(|_| gpui::Empty.into_any_element()), - priority: 0, - }), - ), - None, - cx, - ) - }); - })?; - anyhow::Ok(()) }) .detach(); @@ -417,12 +417,23 @@ impl CommitView { None }; + let gutter_width = self.editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let style = editor.style(cx); + let font_id = window.text_system().resolve_font(&style.text.font()); + let font_size = style.text.font_size.to_pixels(window.rem_size()); + snapshot + .gutter_dimensions(font_id, font_size, style, window, cx) + .full_width() + }); + h_flex() .border_b_1() .border_color(cx.theme().colors().border_variant) + .w_full() .child( h_flex() - .w(self.editor.read(cx).last_gutter_dimensions().full_width()) + .w(gutter_width) .justify_center() .child(self.render_commit_avatar(&commit.sha, rems_from_px(48.), window, cx)), ) @@ -1011,7 +1022,9 @@ impl Render for CommitView { .size_full() .bg(cx.theme().colors().editor_background) .child(self.render_header(window, cx)) - .child(div().flex_grow().child(self.editor.clone())) + .when(!self.editor.read(cx).is_empty(cx), |this| { + this.child(div().flex_grow().child(self.editor.clone())) + }) } } diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index ff884e3b7393b39b86114338fe2af11e384e1fa0..73209c88735a59bb2dc5c2b73bb3ba0c7d03dd56 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -294,11 +294,10 @@ mod test { async fn test_scroll(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; - let (line_height, visible_line_count) = cx.editor(|editor, window, _cx| { + let (line_height, visible_line_count) = cx.update_editor(|editor, window, cx| { ( editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()), editor.visible_line_count().unwrap(), diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 4294b5e1dbdf1a287909bd3ab5770dfcd718f98d..4c61479157268e4f0276bddf9dd1eb913284d27e 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -2399,7 +2399,7 @@ async fn test_clipping_on_mode_change(cx: &mut gpui::TestAppContext) { .end; editor.last_bounds().unwrap().origin + editor - .display_to_pixel_point(current_head, &snapshot, window) + .display_to_pixel_point(current_head, &snapshot, window, cx) .unwrap() }); pixel_position.x += px(100.); diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 21cdda111c4fdacaf0871dd087bca01de6f83957..d20464ccc4b36c8f7024db6bd63558a6292e7c68 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -304,11 +304,10 @@ impl NeovimBackedTestContext { self.neovim.set_option(&format!("scrolloff={}", 3)).await; // +2 to account for the vim command UI at the bottom. self.neovim.set_option(&format!("lines={}", rows + 2)).await; - let (line_height, visible_line_count) = self.editor(|editor, window, _cx| { + let (line_height, visible_line_count) = self.update_editor(|editor, window, cx| { ( editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()), editor.visible_line_count().unwrap(), diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index 402881680232ea636f7cb105db759f417a435145..2a52cc697249cb1f8eb280a48c89ff5aadf6fd85 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -174,17 +174,13 @@ impl Render for QuickActionBar { .as_ref() .is_some_and(|menu| matches!(menu.origin(), ContextMenuOrigin::QuickActionBar)) }; - let code_action_element = if is_deployed { - editor.update(cx, |editor, cx| { - if let Some(style) = editor.style() { - editor.render_context_menu(style, MAX_CODE_ACTION_MENU_LINES, window, cx) - } else { - None - } + let code_action_element = is_deployed + .then(|| { + editor.update(cx, |editor, cx| { + editor.render_context_menu(MAX_CODE_ACTION_MENU_LINES, window, cx) + }) }) - } else { - None - }; + .flatten(); v_flex() .child( IconButton::new("toggle_code_actions_icon", IconName::BoltOutlined) From 4106c8a1884926b86a3cc8df7ff613adac61dd6a Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 10 Dec 2025 16:12:41 +0100 Subject: [PATCH 35/48] Disable OmniSharp by default for C# files (#44427) In preparation for https://github.com/zed-extensions/csharp/pull/11. Do not merge before that PR is published. Release Notes: - Added support for Roslyn in C# files. Roslyn will now be the default language server for C# --- assets/settings/default.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/assets/settings/default.json b/assets/settings/default.json index f687778d7bd7fc0f6d66404199c34fac8d77e7a8..dd51099799abb49325e9a2747ee18f9837e4409b 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1810,6 +1810,9 @@ "allowed": false } }, + "CSharp": { + "language_servers": ["roslyn", "!omnisharp", "..."] + }, "CSS": { "prettier": { "allowed": true From f48aa252f896933206467077ab47db6d35e7556b Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 10 Dec 2025 10:28:39 -0500 Subject: [PATCH 36/48] Bump Zed to v0.218 (#44551) Release Notes: - N/A --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ae8d55a2484970b0ae1ad0631acadf22d106e46..cfe2bba0f47aa8ce8c3f69dec74c2286bd034501 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20469,7 +20469,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.217.0" +version = "0.218.0" dependencies = [ "acp_tools", "activity_indicator", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index a9a8ba87c645e99a68409865a95737e3222c87b3..92a274da9640bbe9ee3afefeacd7566c853bdd2d 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.217.0" +version = "0.218.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From f5babf96e1682c16a9dc2356d1aaa31df185fe7f Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 10 Dec 2025 17:30:10 +0100 Subject: [PATCH 37/48] agent_ui: Fix `project path not found` error when pasting code from other project (#44555) The problem with inserting the absolute paths is that the agent will try to read them. However, we don't allow the agent to read files outside the current project. For now, we will only insert the crease in case the code that is getting pasted is from the same project Release Notes: - Fixed an issue where pasting code into the agent panel from another window would show an error --- crates/agent_ui/src/acp/message_editor.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index bc293c98a84540c1e00d9895be2cc05b0bdd08a5..5e9c55cc56868ac2e7db65043d13eb46efcd89a6 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -565,8 +565,26 @@ impl MessageEditor { if let Some((workspace, selections)) = self.workspace.upgrade().zip(editor_clipboard_selections) { - cx.stop_propagation(); + let Some(first_selection) = selections.first() else { + return; + }; + if let Some(file_path) = &first_selection.file_path { + // In case someone pastes selections from another window + // with a different project, we don't want to insert the + // crease (containing the absolute path) since the agent + // cannot access files outside the project. + let is_in_project = workspace + .read(cx) + .project() + .read(cx) + .project_path_for_absolute_path(file_path, cx) + .is_some(); + if !is_in_project { + return; + } + } + cx.stop_propagation(); let insertion_target = self .editor .read(cx) From 326ebb523049b8b512c962f56630063f825482c9 Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Wed, 10 Dec 2025 22:04:49 +0530 Subject: [PATCH 38/48] git: Fix failing commits when hook command is not available (#43993) --- crates/git/src/repository.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 70cbf6e3c58b7d8f6b690a554370d34262f541e3..6d0dc8de9e2fbd5066f6abf4e4eeaddeb8cd33bf 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -2295,8 +2295,38 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let working_directory = working_directory?; - let git = GitBinary::new(git_binary_path, working_directory, executor) + let git = GitBinary::new(git_binary_path, working_directory.clone(), executor) .envs(HashMap::clone(&env)); + + let output = git.run(&["help", "-a"]).await?; + if !output.lines().any(|line| line.trim().starts_with("hook ")) { + log::warn!( + "git hook command not available, running the {} hook manually", + hook.as_str() + ); + + let hook_abs_path = working_directory + .join(".git") + .join("hooks") + .join(hook.as_str()); + if hook_abs_path.is_file() { + let output = new_smol_command(&hook_abs_path) + .envs(env.iter()) + .current_dir(&working_directory) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "{} hook failed:\n{}", + hook.as_str(), + String::from_utf8_lossy(&output.stderr) + ); + } + + return Ok(()); + } + git.run(&["hook", "run", "--ignore-missing", hook.as_str()]) .await?; Ok(()) From 5b309ef986c8efd2a53abfe4099bda901f8e263d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 10 Dec 2025 17:48:08 +0100 Subject: [PATCH 39/48] acp: Better telemetry IDs for ACP agents (#44544) We were defining these in multiple places and also weren't leveraging the ids the agents were already providing. This should make sure we use them consistently and avoid issues in the future. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 13 +- crates/acp_thread/src/connection.rs | 6 +- crates/action_log/src/action_log.rs | 2 +- crates/agent/src/agent.rs | 4 +- crates/agent/src/native_agent_server.rs | 4 - crates/agent_servers/src/acp.rs | 16 +- crates/agent_servers/src/agent_servers.rs | 1 - crates/agent_servers/src/claude.rs | 6 - crates/agent_servers/src/codex.rs | 6 - crates/agent_servers/src/custom.rs | 10 +- crates/agent_servers/src/gemini.rs | 6 - crates/agent_ui/src/acp/thread_view.rs | 194 +++++++++++----------- crates/agent_ui/src/agent_panel.rs | 6 +- crates/agent_ui/src/agent_ui.rs | 10 -- 14 files changed, 123 insertions(+), 161 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index b96ef1d898086a0b4b9336a21d1d8369fea4ad6c..53294a963d9d230c9b06372c26591ede0434ab28 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1372,7 +1372,7 @@ impl AcpThread { let path_style = self.project.read(cx).path_style(cx); let id = update.tool_call_id.clone(); - let agent = self.connection().telemetry_id(); + let agent_telemetry_id = self.connection().telemetry_id(); let session = self.session_id(); if let ToolCallStatus::Completed | ToolCallStatus::Failed = status { let status = if matches!(status, ToolCallStatus::Completed) { @@ -1380,7 +1380,12 @@ impl AcpThread { } else { "failed" }; - telemetry::event!("Agent Tool Call Completed", agent, session, status); + telemetry::event!( + "Agent Tool Call Completed", + agent_telemetry_id, + session, + status + ); } if let Some(ix) = self.index_for_tool_call(&id) { @@ -3556,8 +3561,8 @@ mod tests { } impl AgentConnection for FakeAgentConnection { - fn telemetry_id(&self) -> &'static str { - "fake" + fn telemetry_id(&self) -> SharedString { + "fake".into() } fn auth_methods(&self) -> &[acp::AuthMethod] { diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 8213786a182e1d93d1bfc1a8918a8830ecaa754b..3c8c56b2c02cd775be030cb4c4b05a9c75f0d10f 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -20,7 +20,7 @@ impl UserMessageId { } pub trait AgentConnection { - fn telemetry_id(&self) -> &'static str; + fn telemetry_id(&self) -> SharedString; fn new_thread( self: Rc, @@ -322,8 +322,8 @@ mod test_support { } impl AgentConnection for StubAgentConnection { - fn telemetry_id(&self) -> &'static str { - "stub" + fn telemetry_id(&self) -> SharedString { + "stub".into() } fn auth_methods(&self) -> &[acp::AuthMethod] { diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 80c9438bc9f8051cb58357e56a82b5307fd20b75..6eb18a4f12325f0c181928f99b4eb921265dbf9c 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -777,7 +777,7 @@ impl ActionLog { #[derive(Clone)] pub struct ActionLogTelemetry { - pub agent_telemetry_id: &'static str, + pub agent_telemetry_id: SharedString, pub session_id: Arc, } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index aec0767c25422dbfeae6fdddcf33e54f8045995c..cf98a24ac52579fc65bdbcc3444615c89625812a 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -947,8 +947,8 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector { } impl acp_thread::AgentConnection for NativeAgentConnection { - fn telemetry_id(&self) -> &'static str { - "zed" + fn telemetry_id(&self) -> SharedString { + "zed".into() } fn new_thread( diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 4c78c5a3f85b6628f9784fe7ecbadc8531b017d0..a9ade8141a678329e0dd8dad9808e55eee3c382b 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -21,10 +21,6 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { - fn telemetry_id(&self) -> &'static str { - "zed" - } - fn name(&self) -> SharedString { "Zed Agent".into() } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 153357a79afdaeeb4bf4c9e2b48bee32245ba2ef..138353592e8c0f185d14398544c226e9381e17cd 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -29,7 +29,7 @@ pub struct UnsupportedVersion; pub struct AcpConnection { server_name: SharedString, - telemetry_id: &'static str, + telemetry_id: SharedString, connection: Rc, sessions: Rc>>, auth_methods: Vec, @@ -54,7 +54,6 @@ pub struct AcpSession { pub async fn connect( server_name: SharedString, - telemetry_id: &'static str, command: AgentServerCommand, root_dir: &Path, default_mode: Option, @@ -64,7 +63,6 @@ pub async fn connect( ) -> Result> { let conn = AcpConnection::stdio( server_name, - telemetry_id, command.clone(), root_dir, default_mode, @@ -81,7 +79,6 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1 impl AcpConnection { pub async fn stdio( server_name: SharedString, - telemetry_id: &'static str, command: AgentServerCommand, root_dir: &Path, default_mode: Option, @@ -199,6 +196,13 @@ impl AcpConnection { return Err(UnsupportedVersion.into()); } + let telemetry_id = response + .agent_info + // Use the one the agent provides if we have one + .map(|info| info.name.into()) + // Otherwise, just use the name + .unwrap_or_else(|| server_name.clone()); + Ok(Self { auth_methods: response.auth_methods, root_dir: root_dir.to_owned(), @@ -233,8 +237,8 @@ impl Drop for AcpConnection { } impl AgentConnection for AcpConnection { - fn telemetry_id(&self) -> &'static str { - self.telemetry_id + fn telemetry_id(&self) -> SharedString { + self.telemetry_id.clone() } fn new_thread( diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index cf03b71a78b358d7b110c450f769f9645094baaa..46e8508e44f07e4fb3d613e30387d5afd3f38423 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -56,7 +56,6 @@ impl AgentServerDelegate { pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; fn name(&self) -> SharedString; - fn telemetry_id(&self) -> &'static str; fn default_mode(&self, _cx: &mut App) -> Option { None } diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index f49dce59c4282eb278e16ef664c75ed56652de2e..e67ddd5c0698758fdec7c7796b26a1351e9990e5 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -22,10 +22,6 @@ pub struct AgentServerLoginCommand { } impl AgentServer for ClaudeCode { - fn telemetry_id(&self) -> &'static str { - "claude-code" - } - fn name(&self) -> SharedString { "Claude Code".into() } @@ -83,7 +79,6 @@ impl AgentServer for ClaudeCode { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -108,7 +103,6 @@ impl AgentServer for ClaudeCode { .await?; let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index d14d2f0c9aeb499624943962437821d571bc0299..c2b308e48b7a984b0374272c0059286e933916b3 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -23,10 +23,6 @@ pub(crate) mod tests { } impl AgentServer for Codex { - fn telemetry_id(&self) -> &'static str { - "codex" - } - fn name(&self) -> SharedString { "Codex".into() } @@ -84,7 +80,6 @@ impl AgentServer for Codex { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -110,7 +105,6 @@ impl AgentServer for Codex { let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 634b31e90267e064f0d0df9b6014d279a44a7986..4cde4d4b9f5040f9b51365defa9a43bd0ab2b082 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,4 +1,4 @@ -use crate::{AgentServerDelegate, load_proxy_env}; +use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; @@ -20,11 +20,7 @@ impl CustomAgentServer { } } -impl crate::AgentServer for CustomAgentServer { - fn telemetry_id(&self) -> &'static str { - "custom" - } - +impl AgentServer for CustomAgentServer { fn name(&self) -> SharedString { self.name.clone() } @@ -112,7 +108,6 @@ impl crate::AgentServer for CustomAgentServer { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let default_mode = self.default_mode(cx); @@ -139,7 +134,6 @@ impl crate::AgentServer for CustomAgentServer { .await?; let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index c1b2efb081551f82752dc15a909eec64ff78d94e..5fea74746aec73f3ea7bb33562244e4a6eea5ba7 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -12,10 +12,6 @@ use project::agent_server_store::GEMINI_NAME; pub struct Gemini; impl AgentServer for Gemini { - fn telemetry_id(&self) -> &'static str { - "gemini-cli" - } - fn name(&self) -> SharedString { "Gemini CLI".into() } @@ -31,7 +27,6 @@ impl AgentServer for Gemini { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -66,7 +61,6 @@ impl AgentServer for Gemini { let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 36fb7e9097488f8070b740a63ed67ee74445602a..63ea9eb279d26ff610c12f9785ef882be61f5e26 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -170,7 +170,7 @@ impl ThreadFeedbackState { } } let session_id = thread.read(cx).session_id().clone(); - let agent = thread.read(cx).connection().telemetry_id(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); let rating = match feedback { ThreadFeedback::Positive => "positive", @@ -180,7 +180,7 @@ impl ThreadFeedbackState { let thread = task.await?; telemetry::event!( "Agent Thread Rated", - agent = agent, + agent = agent_telemetry_id, session_id = session_id, rating = rating, thread = thread @@ -207,13 +207,13 @@ impl ThreadFeedbackState { self.comments_editor.take(); let session_id = thread.read(cx).session_id().clone(); - let agent = thread.read(cx).connection().telemetry_id(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); cx.background_spawn(async move { let thread = task.await?; telemetry::event!( "Agent Thread Feedback Comments", - agent = agent, + agent = agent_telemetry_id, session_id = session_id, comments = comments, thread = thread @@ -333,6 +333,7 @@ impl AcpThreadView { project: Entity, history_store: Entity, prompt_store: Option>, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> Self { @@ -391,8 +392,9 @@ impl AcpThreadView { ), ]; - let show_codex_windows_warning = crate::ExternalAgent::parse_built_in(agent.as_ref()) - == Some(crate::ExternalAgent::Codex); + let show_codex_windows_warning = cfg!(windows) + && project.read(cx).is_local() + && agent.clone().downcast::().is_some(); Self { agent: agent.clone(), @@ -404,6 +406,7 @@ impl AcpThreadView { resume_thread.clone(), workspace.clone(), project.clone(), + track_load_event, window, cx, ), @@ -448,6 +451,7 @@ impl AcpThreadView { self.resume_thread_metadata.clone(), self.workspace.clone(), self.project.clone(), + true, window, cx, ); @@ -461,6 +465,7 @@ impl AcpThreadView { resume_thread: Option, workspace: WeakEntity, project: Entity, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> ThreadState { @@ -519,6 +524,10 @@ impl AcpThreadView { } }; + if track_load_event { + telemetry::event!("Agent Thread Started", agent = connection.telemetry_id()); + } + let result = if let Some(native_agent) = connection .clone() .downcast::() @@ -1133,8 +1142,8 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; - let agent_telemetry_id = self.agent.telemetry_id(); let session_id = thread.read(cx).session_id().clone(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let thread = thread.downgrade(); if self.should_be_following { self.workspace @@ -1512,6 +1521,7 @@ impl AcpThreadView { else { return; }; + let agent_telemetry_id = connection.telemetry_id(); // Check for the experimental "terminal-auth" _meta field let auth_method = connection.auth_methods().iter().find(|m| m.id == method); @@ -1579,19 +1589,18 @@ impl AcpThreadView { ); cx.notify(); self.auth_task = Some(cx.spawn_in(window, { - let agent = self.agent.clone(); async move |this, cx| { let result = authenticate.await; match &result { Ok(_) => telemetry::event!( "Authenticate Agent Succeeded", - agent = agent.telemetry_id() + agent = agent_telemetry_id ), Err(_) => { telemetry::event!( "Authenticate Agent Failed", - agent = agent.telemetry_id(), + agent = agent_telemetry_id, ) } } @@ -1675,6 +1684,7 @@ impl AcpThreadView { None, this.workspace.clone(), this.project.clone(), + true, window, cx, ) @@ -1730,43 +1740,38 @@ impl AcpThreadView { connection.authenticate(method, cx) }; cx.notify(); - self.auth_task = - Some(cx.spawn_in(window, { - let agent = self.agent.clone(); - async move |this, cx| { - let result = authenticate.await; - - match &result { - Ok(_) => telemetry::event!( - "Authenticate Agent Succeeded", - agent = agent.telemetry_id() - ), - Err(_) => { - telemetry::event!( - "Authenticate Agent Failed", - agent = agent.telemetry_id(), - ) - } + self.auth_task = Some(cx.spawn_in(window, { + async move |this, cx| { + let result = authenticate.await; + + match &result { + Ok(_) => telemetry::event!( + "Authenticate Agent Succeeded", + agent = agent_telemetry_id + ), + Err(_) => { + telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,) } + } - this.update_in(cx, |this, window, cx| { - if let Err(err) = result { - if let ThreadState::Unauthenticated { - pending_auth_method, - .. - } = &mut this.thread_state - { - pending_auth_method.take(); - } - this.handle_thread_error(err, cx); - } else { - this.reset(window, cx); + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + if let ThreadState::Unauthenticated { + pending_auth_method, + .. + } = &mut this.thread_state + { + pending_auth_method.take(); } - this.auth_task.take() - }) - .ok(); - } - })); + this.handle_thread_error(err, cx); + } else { + this.reset(window, cx); + } + this.auth_task.take() + }) + .ok(); + } + })); } fn spawn_external_agent_login( @@ -1896,10 +1901,11 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); telemetry::event!( "Agent Tool Call Authorized", - agent = self.agent.telemetry_id(), + agent = agent_telemetry_id, session = thread.read(cx).session_id(), option = option_kind ); @@ -3509,6 +3515,8 @@ impl AcpThreadView { (method.id.0.clone(), method.name.clone()) }; + let agent_telemetry_id = connection.telemetry_id(); + Button::new(method_id.clone(), name) .label_size(LabelSize::Small) .map(|this| { @@ -3528,7 +3536,7 @@ impl AcpThreadView { cx.listener(move |this, _, window, cx| { telemetry::event!( "Authenticate Agent Started", - agent = this.agent.telemetry_id(), + agent = agent_telemetry_id, method = method_id ); @@ -5376,47 +5384,39 @@ impl AcpThreadView { ) } - fn render_codex_windows_warning(&self, cx: &mut Context) -> Option { - if self.show_codex_windows_warning { - Some( - Callout::new() - .icon(IconName::Warning) - .severity(Severity::Warning) - .title("Codex on Windows") - .description( - "For best performance, run Codex in Windows Subsystem for Linux (WSL2)", - ) - .actions_slot( - Button::new("open-wsl-modal", "Open in WSL") - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |_, _, _window, cx| { - #[cfg(windows)] - _window.dispatch_action( - zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), - cx, - ); - cx.notify(); - } - })), - ) - .dismiss_action( - IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Dismiss Warning")) - .on_click(cx.listener({ - move |this, _, _, cx| { - this.show_codex_windows_warning = false; - cx.notify(); - } - })), - ), + fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title("Codex on Windows") + .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)") + .actions_slot( + Button::new("open-wsl-modal", "Open in WSL") + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |_, _, _window, cx| { + #[cfg(windows)] + _window.dispatch_action( + zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), + cx, + ); + cx.notify(); + } + })), + ) + .dismiss_action( + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss Warning")) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.show_codex_windows_warning = false; + cx.notify(); + } + })), ) - } else { - None - } } fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context) -> Option
{ @@ -5936,12 +5936,8 @@ impl Render for AcpThreadView { _ => this, }) .children(self.render_thread_retry_status_callout(window, cx)) - .children({ - if cfg!(windows) && self.project.read(cx).is_local() { - self.render_codex_windows_warning(cx) - } else { - None - } + .when(self.show_codex_windows_warning, |this| { + this.child(self.render_codex_windows_warning(cx)) }) .children(self.render_thread_error(window, cx)) .when_some( @@ -6398,6 +6394,7 @@ pub(crate) mod tests { project, history_store, None, + false, window, cx, ) @@ -6475,10 +6472,6 @@ pub(crate) mod tests { where C: 'static + AgentConnection + Send + Clone, { - fn telemetry_id(&self) -> &'static str { - "test" - } - fn logo(&self) -> ui::IconName { ui::IconName::Ai } @@ -6505,8 +6498,8 @@ pub(crate) mod tests { struct SaboteurAgentConnection; impl AgentConnection for SaboteurAgentConnection { - fn telemetry_id(&self) -> &'static str { - "saboteur" + fn telemetry_id(&self) -> SharedString { + "saboteur".into() } fn new_thread( @@ -6569,8 +6562,8 @@ pub(crate) mod tests { struct RefusalAgentConnection; impl AgentConnection for RefusalAgentConnection { - fn telemetry_id(&self) -> &'static str { - "refusal" + fn telemetry_id(&self) -> SharedString { + "refusal".into() } fn new_thread( @@ -6671,6 +6664,7 @@ pub(crate) mod tests { project.clone(), history_store.clone(), None, + false, window, cx, ) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d5dd0a25c818600226eb8a894f73dffcba86b797..2f6a722b471a189eafbc7aadbddb927476e4b3b9 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -305,6 +305,7 @@ impl ActiveView { project, history_store, prompt_store, + false, window, cx, ) @@ -885,10 +886,6 @@ impl AgentPanel { let server = ext_agent.server(fs, history); - if !loading { - telemetry::event!("Agent Thread Started", agent = server.telemetry_id()); - } - this.update_in(cx, |this, window, cx| { let selected_agent = ext_agent.into(); if this.selected_agent != selected_agent { @@ -905,6 +902,7 @@ impl AgentPanel { project, this.history_store.clone(), this.prompt_store.clone(), + !loading, window, cx, ) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index f7b07b7bd393b8d3efffc3757eaf6025d5c651cd..b6f7517ed934cf6cac8eefc262233b845169de9f 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -160,16 +160,6 @@ pub enum ExternalAgent { } impl ExternalAgent { - pub fn parse_built_in(server: &dyn agent_servers::AgentServer) -> Option { - match server.telemetry_id() { - "gemini-cli" => Some(Self::Gemini), - "claude-code" => Some(Self::ClaudeCode), - "codex" => Some(Self::Codex), - "zed" => Some(Self::NativeAgent), - _ => None, - } - } - pub fn server( &self, fs: Arc, From 8ee85eab3c1ac3072eb7ebda689eeccdeb88d6c0 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Wed, 10 Dec 2025 08:55:50 -0800 Subject: [PATCH 40/48] vim: Remove `ctrl-6` keybinding alias for `pane::AlternateFile` (#44560) Closes #ISSUE It seems that `ctrl-6` is used exclusively as an alias, as can be seen in the [linked section of the vim docs](https://vimhelp.org/editing.txt.html#CTRL-%5E) from the initial PR that added it. This however conflicts with the `ctrl-{n}` bindings for `pane::ActivateItem` on macOS, leading to confusing file selection when `ctrl-6` is pressed. Release Notes: - vim(BREAKING): Removed a keybinding conflict between the default macOS bindings for `pane::ActivateItem` and the `ctrl-6` alias for`pane::AlternateFile` which is primarily bound to `ctrl-^`. `ctrl-6` is no longer treated as an alias for `ctrl-^` in vim mode. If you'd like to restore `ctrl-6` as a binding for `pane::AlternateFile`, paste the following into your `keymap.json` file: ``` { "context": "VimControl && !menu", "bindings": { "ctrl-6": "pane::AlternateFile" } } ``` --- assets/keymaps/vim.json | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index b891baf2594cd400583686292d7cb648d8f3666d..7e5bc30c1ff64a637aeefbb92063d2abed7e56cd 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -180,7 +180,6 @@ "ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit", "ctrl-w space": "editor::OpenExcerptsSplit", "ctrl-w g space": "editor::OpenExcerptsSplit", - "ctrl-6": "pane::AlternateFile", "ctrl-^": "pane::AlternateFile", ".": "vim::Repeat" } From d83201256d9b77a6ce054e417fff828a59d8a93b Mon Sep 17 00:00:00 2001 From: John Tur Date: Wed, 10 Dec 2025 12:08:37 -0500 Subject: [PATCH 41/48] Use shell to launch MCP and ACP servers (#42382) `npx`, and any `npm install`-ed programs, exist as batch scripts/PowerShell scripts on the PATH. We have to use a shell to launch these programs. Fixes https://github.com/zed-industries/zed/issues/41435 Closes https://github.com/zed-industries/zed/pull/42651 Release Notes: - windows: Custom MCP and ACP servers installed through `npm` now launch correctly. --------- Co-authored-by: Lukas Wirth --- Cargo.lock | 1 + crates/agent_servers/src/acp.rs | 27 +- crates/agent_servers/src/custom.rs | 1 - crates/context_server/Cargo.toml | 1 + .../src/transport/stdio_transport.rs | 12 +- crates/languages/src/python.rs | 2 +- crates/project/src/context_server_store.rs | 2 +- crates/remote/src/transport/ssh.rs | 15 +- crates/remote/src/transport/wsl.rs | 16 +- crates/util/src/shell.rs | 313 +++++++++++++++++- crates/util/src/shell_builder.rs | 40 ++- crates/util/src/shell_env.rs | 2 +- 12 files changed, 389 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfe2bba0f47aa8ce8c3f69dec74c2286bd034501..6c75c448a50c711092a7fde296718bc6e3343379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3595,6 +3595,7 @@ dependencies = [ "settings", "smol", "tempfile", + "terminal", "url", "util", ] diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 138353592e8c0f185d14398544c226e9381e17cd..9d4973c94dc952eb421a93b14137458c4040810d 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,6 +9,10 @@ use futures::io::BufReader; use project::Project; use project::agent_server_store::AgentServerCommand; use serde::Deserialize; +use settings::Settings as _; +use task::ShellBuilder; +#[cfg(windows)] +use task::ShellKind; use util::ResultExt as _; use std::path::PathBuf; @@ -21,7 +25,7 @@ use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntit use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; use terminal::TerminalBuilder; -use terminal::terminal_settings::{AlternateScroll, CursorShape}; +use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings}; #[derive(Debug, Error)] #[error("Unsupported version")] @@ -86,9 +90,26 @@ impl AcpConnection { is_remote: bool, cx: &mut AsyncApp, ) -> Result { - let mut child = util::command::new_smol_command(&command.path); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)); + #[cfg(windows)] + let kind = builder.kind(); + let (cmd, args) = builder.build(Some(command.path.display().to_string()), &command.args); + + let mut child = util::command::new_smol_command(cmd); + #[cfg(windows)] + if kind == ShellKind::Cmd { + use smol::process::windows::CommandExt; + for arg in args { + child.raw_arg(arg); + } + } else { + child.args(args); + } + #[cfg(not(windows))] + child.args(args); + child - .args(command.args.iter().map(|arg| arg.as_str())) .envs(command.env.iter().flatten()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 4cde4d4b9f5040f9b51365defa9a43bd0ab2b082..6b981ce8b8198b275e5d9aa05b6fb66431d22e08 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -114,7 +114,6 @@ impl AgentServer for CustomAgentServer { let default_model = self.default_model(cx); let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); - cx.spawn(async move |cx| { let (command, root_dir, login) = store .update(cx, |store, cx| { diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index f73e6a9bab011c5d675040d1ee3a05dfa708dc45..cb48b7e6f7d000ed7f2db7aaf3cfe4d6317fe278 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -33,6 +33,7 @@ smol.workspace = true tempfile.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true +terminal.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 83908b46829c4cfe3b536ecca1155c909ee424dd..035a1ccb9d413d0bfab2e5c06b87ff293360f8f0 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -8,9 +8,12 @@ use futures::{ AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _, }; use gpui::AsyncApp; +use settings::Settings as _; use smol::channel; use smol::process::Child; +use terminal::terminal_settings::TerminalSettings; use util::TryFutureExt as _; +use util::shell_builder::ShellBuilder; use crate::client::ModelContextServerBinary; use crate::transport::Transport; @@ -28,9 +31,14 @@ impl StdioTransport { working_directory: &Option, cx: &AsyncApp, ) -> Result { - let mut command = util::command::new_smol_command(&binary.executable); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)); + let (command, args) = + builder.build(Some(binary.executable.display().to_string()), &binary.args); + + let mut command = util::command::new_smol_command(command); command - .args(&binary.args) + .args(args) .envs(binary.env.unwrap_or_default()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index db914ad2d062dcce77bb12c9b777da3db9a7750f..730470d17958f4db02f1ac8c570ffeb83109112c 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1344,7 +1344,7 @@ impl ToolchainLister for PythonToolchainProvider { ShellKind::Fish => Some(format!("\"{pyenv}\" shell - fish {version}")), ShellKind::Posix => Some(format!("\"{pyenv}\" shell - sh {version}")), ShellKind::Nushell => Some(format!("^\"{pyenv}\" shell - nu {version}")), - ShellKind::PowerShell => None, + ShellKind::PowerShell | ShellKind::Pwsh => None, ShellKind::Csh => None, ShellKind::Tcsh => None, ShellKind::Cmd => None, diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 59bef36f06502f11d06f76ac7819a4c9ea806176..7ba46a46872ba57c758baccf9f67b0039818ee75 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -411,11 +411,11 @@ impl ContextServerStore { ) { self.stop_server(&id, cx).log_err(); } - let task = cx.spawn({ let id = server.id(); let server = server.clone(); let configuration = configuration.clone(); + async move |this, cx| { match server.clone().start(cx).await { Ok(_) => { diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 6260653d53c2040da5e70c2c6764f9790d7abb58..5cd426d7be560c9bdb493477e6be51404836e0a8 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -31,7 +31,8 @@ use tempfile::TempDir; use util::{ paths::{PathStyle, RemotePathBuf}, rel_path::RelPath, - shell::ShellKind, + shell::{Shell, ShellKind}, + shell_builder::ShellBuilder, }; pub(crate) struct SshRemoteConnection { @@ -1362,6 +1363,8 @@ fn build_command( } else { write!(exec, "{ssh_shell} -l")?; }; + let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false) + .build(Some(exec.clone()), &[]); let mut args = Vec::new(); args.extend(ssh_args); @@ -1372,7 +1375,9 @@ fn build_command( } args.push("-t".into()); - args.push(exec); + args.push(command); + args.extend(command_args); + Ok(CommandTemplate { program: "ssh".into(), args, @@ -1411,6 +1416,9 @@ mod tests { "-p", "2222", "-t", + "/bin/fish", + "-i", + "-c", "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2" ] ); @@ -1443,6 +1451,9 @@ mod tests { "-L", "1:foo:2", "-t", + "/bin/fish", + "-i", + "-c", "cd && exec env INPUT_VA=val /bin/fish -l" ] ); diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs index 570266c8a8466265b56be11fc295ef403bdaeb80..d27648e67840681765248ae1cce12c15d7a13228 100644 --- a/crates/remote/src/transport/wsl.rs +++ b/crates/remote/src/transport/wsl.rs @@ -23,7 +23,8 @@ use std::{ use util::{ paths::{PathStyle, RemotePathBuf}, rel_path::RelPath, - shell::ShellKind, + shell::{Shell, ShellKind}, + shell_builder::ShellBuilder, }; #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)] @@ -453,8 +454,10 @@ impl RemoteConnection for WslRemoteConnection { } else { write!(&mut exec, "{} -l", self.shell)?; } + let (command, args) = + ShellBuilder::new(&Shell::Program(self.shell.clone()), false).build(Some(exec), &[]); - let wsl_args = if let Some(user) = &self.connection_options.user { + let mut wsl_args = if let Some(user) = &self.connection_options.user { vec![ "--distribution".to_string(), self.connection_options.distro_name.clone(), @@ -463,9 +466,7 @@ impl RemoteConnection for WslRemoteConnection { "--cd".to_string(), working_dir, "--".to_string(), - self.shell.clone(), - "-c".to_string(), - exec, + command, ] } else { vec![ @@ -474,11 +475,10 @@ impl RemoteConnection for WslRemoteConnection { "--cd".to_string(), working_dir, "--".to_string(), - self.shell.clone(), - "-c".to_string(), - exec, + command, ] }; + wsl_args.extend(args); Ok(CommandTemplate { program: "wsl.exe".to_string(), diff --git a/crates/util/src/shell.rs b/crates/util/src/shell.rs index d6cf5e1d380109aa4fcfc4e55a4c469ba1903add..a956a39446fbae9cdd74601c2a3c42fa6d9c408b 100644 --- a/crates/util/src/shell.rs +++ b/crates/util/src/shell.rs @@ -56,7 +56,10 @@ pub enum ShellKind { Tcsh, Rc, Fish, + /// Pre-installed "legacy" powershell for windows PowerShell, + /// PowerShell 7.x + Pwsh, Nushell, Cmd, Xonsh, @@ -238,6 +241,7 @@ impl fmt::Display for ShellKind { ShellKind::Tcsh => write!(f, "tcsh"), ShellKind::Fish => write!(f, "fish"), ShellKind::PowerShell => write!(f, "powershell"), + ShellKind::Pwsh => write!(f, "pwsh"), ShellKind::Nushell => write!(f, "nu"), ShellKind::Cmd => write!(f, "cmd"), ShellKind::Rc => write!(f, "rc"), @@ -260,7 +264,8 @@ impl ShellKind { .to_string_lossy(); match &*program { - "powershell" | "pwsh" => ShellKind::PowerShell, + "powershell" => ShellKind::PowerShell, + "pwsh" => ShellKind::Pwsh, "cmd" => ShellKind::Cmd, "nu" => ShellKind::Nushell, "fish" => ShellKind::Fish, @@ -279,7 +284,7 @@ impl ShellKind { pub fn to_shell_variable(self, input: &str) -> String { match self { - Self::PowerShell => Self::to_powershell_variable(input), + Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input), Self::Cmd => Self::to_cmd_variable(input), Self::Posix => input.to_owned(), Self::Fish => input.to_owned(), @@ -407,8 +412,12 @@ impl ShellKind { pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec { match self { - ShellKind::PowerShell => vec!["-C".to_owned(), combined_command], - ShellKind::Cmd => vec!["/C".to_owned(), combined_command], + ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command], + ShellKind::Cmd => vec![ + "/S".to_owned(), + "/C".to_owned(), + format!("\"{combined_command}\""), + ], ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish @@ -426,7 +435,7 @@ impl ShellKind { pub const fn command_prefix(&self) -> Option { match self { - ShellKind::PowerShell => Some('&'), + ShellKind::PowerShell | ShellKind::Pwsh => Some('&'), ShellKind::Nushell => Some('^'), ShellKind::Posix | ShellKind::Csh @@ -457,6 +466,7 @@ impl ShellKind { | ShellKind::Rc | ShellKind::Fish | ShellKind::PowerShell + | ShellKind::Pwsh | ShellKind::Nushell | ShellKind::Xonsh | ShellKind::Elvish => ';', @@ -471,6 +481,7 @@ impl ShellKind { | ShellKind::Tcsh | ShellKind::Rc | ShellKind::Fish + | ShellKind::Pwsh | ShellKind::PowerShell | ShellKind::Xonsh => "&&", ShellKind::Nushell | ShellKind::Elvish => ";", @@ -478,11 +489,10 @@ impl ShellKind { } pub fn try_quote<'a>(&self, arg: &'a str) -> Option> { - shlex::try_quote(arg).ok().map(|arg| match self { - // If we are running in PowerShell, we want to take extra care when escaping strings. - // In particular, we want to escape strings with a backtick (`) rather than a backslash (\). - ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")), - ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")), + match self { + ShellKind::PowerShell => Some(Self::quote_powershell(arg)), + ShellKind::Pwsh => Some(Self::quote_pwsh(arg)), + ShellKind::Cmd => Some(Self::quote_cmd(arg)), ShellKind::Posix | ShellKind::Csh | ShellKind::Tcsh @@ -490,8 +500,173 @@ impl ShellKind { | ShellKind::Fish | ShellKind::Nushell | ShellKind::Xonsh - | ShellKind::Elvish => arg, - }) + | ShellKind::Elvish => shlex::try_quote(arg).ok(), + } + } + + fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> { + if arg.is_empty() { + return Cow::Borrowed("\"\""); + } + + let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"'); + if !needs_quoting { + return Cow::Borrowed(arg); + } + + let mut result = String::with_capacity(arg.len() + 2); + + if enclose { + result.push('"'); + } + + let chars: Vec = arg.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '\\' { + let mut num_backslashes = 0; + while i < chars.len() && chars[i] == '\\' { + num_backslashes += 1; + i += 1; + } + + if i < chars.len() && chars[i] == '"' { + // Backslashes followed by quote: double the backslashes and escape the quote + for _ in 0..(num_backslashes * 2 + 1) { + result.push('\\'); + } + result.push('"'); + i += 1; + } else if i >= chars.len() { + // Trailing backslashes: double them (they precede the closing quote) + for _ in 0..(num_backslashes * 2) { + result.push('\\'); + } + } else { + // Backslashes not followed by quote: output as-is + for _ in 0..num_backslashes { + result.push('\\'); + } + } + } else if chars[i] == '"' { + // Quote not preceded by backslash: escape it + result.push('\\'); + result.push('"'); + i += 1; + } else { + result.push(chars[i]); + i += 1; + } + } + + if enclose { + result.push('"'); + } + Cow::Owned(result) + } + + fn needs_quoting_powershell(s: &str) -> bool { + s.is_empty() + || s.chars().any(|c| { + c.is_whitespace() + || matches!( + c, + '"' | '`' + | '$' + | '&' + | '|' + | '<' + | '>' + | ';' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | ',' + | '\'' + | '@' + ) + }) + } + + fn need_quotes_powershell(arg: &str) -> bool { + let mut quote_count = 0; + for c in arg.chars() { + if c == '"' { + quote_count += 1; + } else if c.is_whitespace() && (quote_count % 2 == 0) { + return true; + } + } + false + } + + fn escape_powershell_quotes(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + result.push('\''); + for c in s.chars() { + if c == '\'' { + result.push('\''); + } + result.push(c); + } + result.push('\''); + result + } + + pub fn quote_powershell(arg: &str) -> Cow<'_, str> { + let ps_will_quote = Self::need_quotes_powershell(arg); + let crt_quoted = Self::quote_windows(arg, !ps_will_quote); + + if !Self::needs_quoting_powershell(arg) { + return crt_quoted; + } + + Cow::Owned(Self::escape_powershell_quotes(&crt_quoted)) + } + + pub fn quote_pwsh(arg: &str) -> Cow<'_, str> { + if arg.is_empty() { + return Cow::Borrowed("''"); + } + + if !Self::needs_quoting_powershell(arg) { + return Cow::Borrowed(arg); + } + + Cow::Owned(Self::escape_powershell_quotes(arg)) + } + + pub fn quote_cmd(arg: &str) -> Cow<'_, str> { + let crt_quoted = Self::quote_windows(arg, true); + + let needs_cmd_escaping = crt_quoted.contains('"') + || crt_quoted.contains('%') + || crt_quoted + .chars() + .any(|c| matches!(c, '^' | '<' | '>' | '&' | '|' | '(' | ')')); + + if !needs_cmd_escaping { + return crt_quoted; + } + + let mut result = String::with_capacity(crt_quoted.len() * 2); + for c in crt_quoted.chars() { + match c { + '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => { + result.push('^'); + result.push(c); + } + '%' => { + result.push_str("%%cd:~,%"); + } + _ => result.push(c), + } + } + Cow::Owned(result) } /// Quotes the given argument if necessary, taking into account the command prefix. @@ -538,7 +713,7 @@ impl ShellKind { match self { ShellKind::Cmd => "", ShellKind::Nushell => "overlay use", - ShellKind::PowerShell => ".", + ShellKind::PowerShell | ShellKind::Pwsh => ".", ShellKind::Fish | ShellKind::Csh | ShellKind::Tcsh @@ -558,6 +733,7 @@ impl ShellKind { | ShellKind::Rc | ShellKind::Fish | ShellKind::PowerShell + | ShellKind::Pwsh | ShellKind::Nushell | ShellKind::Xonsh | ShellKind::Elvish => "clear", @@ -576,6 +752,7 @@ impl ShellKind { | ShellKind::Rc | ShellKind::Fish | ShellKind::PowerShell + | ShellKind::Pwsh | ShellKind::Nushell | ShellKind::Xonsh | ShellKind::Elvish => true, @@ -605,7 +782,7 @@ mod tests { .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"") .unwrap() .into_owned(), - "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string() + "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string() ); } @@ -617,7 +794,113 @@ mod tests { .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"") .unwrap() .into_owned(), - "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string() + "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string() + ); + } + + #[test] + fn test_try_quote_powershell_edge_cases() { + let shell_kind = ShellKind::PowerShell; + + // Empty string + assert_eq!( + shell_kind.try_quote("").unwrap().into_owned(), + "'\"\"'".to_string() + ); + + // String without special characters (no quoting needed) + assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple"); + + // String with spaces + assert_eq!( + shell_kind.try_quote("hello world").unwrap().into_owned(), + "'hello world'".to_string() + ); + + // String with dollar signs + assert_eq!( + shell_kind.try_quote("$variable").unwrap().into_owned(), + "'$variable'".to_string() + ); + + // String with backticks + assert_eq!( + shell_kind.try_quote("test`command").unwrap().into_owned(), + "'test`command'".to_string() + ); + + // String with multiple special characters + assert_eq!( + shell_kind + .try_quote("test `\"$var`\" end") + .unwrap() + .into_owned(), + "'test `\\\"$var`\\\" end'".to_string() + ); + + // String with backslashes and colon (path without spaces doesn't need quoting) + assert_eq!( + shell_kind.try_quote("C:\\path\\to\\file").unwrap(), + "C:\\path\\to\\file" + ); + } + + #[test] + fn test_try_quote_cmd_edge_cases() { + let shell_kind = ShellKind::Cmd; + + // Empty string + assert_eq!( + shell_kind.try_quote("").unwrap().into_owned(), + "^\"^\"".to_string() + ); + + // String without special characters (no quoting needed) + assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple"); + + // String with spaces + assert_eq!( + shell_kind.try_quote("hello world").unwrap().into_owned(), + "^\"hello world^\"".to_string() + ); + + // String with space and backslash (backslash not at end, so not doubled) + assert_eq!( + shell_kind.try_quote("path\\ test").unwrap().into_owned(), + "^\"path\\ test^\"".to_string() + ); + + // String ending with backslash (must be doubled before closing quote) + assert_eq!( + shell_kind.try_quote("test path\\").unwrap().into_owned(), + "^\"test path\\\\^\"".to_string() + ); + + // String ending with multiple backslashes (all doubled before closing quote) + assert_eq!( + shell_kind.try_quote("test path\\\\").unwrap().into_owned(), + "^\"test path\\\\\\\\^\"".to_string() + ); + + // String with embedded quote (quote is escaped, backslash before it is doubled) + assert_eq!( + shell_kind.try_quote("test\\\"quote").unwrap().into_owned(), + "^\"test\\\\\\^\"quote^\"".to_string() + ); + + // String with multiple backslashes before embedded quote (all doubled) + assert_eq!( + shell_kind + .try_quote("test\\\\\"quote") + .unwrap() + .into_owned(), + "^\"test\\\\\\\\\\^\"quote^\"".to_string() + ); + + // String with backslashes not before quotes (path without spaces doesn't need quoting) + assert_eq!( + shell_kind.try_quote("C:\\path\\to\\file").unwrap(), + "C:\\path\\to\\file" ); } diff --git a/crates/util/src/shell_builder.rs b/crates/util/src/shell_builder.rs index a4a0d21018447d229a6a95c4bf897804b5d6eaf9..3b9e53eb8e1aab69fc6e2115a432832325e8acb7 100644 --- a/crates/util/src/shell_builder.rs +++ b/crates/util/src/shell_builder.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use crate::shell::get_system_shell; use crate::shell::{Shell, ShellKind}; @@ -42,7 +44,7 @@ impl ShellBuilder { self.program.clone() } else { match self.kind { - ShellKind::PowerShell => { + ShellKind::PowerShell | ShellKind::Pwsh => { format!("{} -C '{}'", self.program, command_to_use_in_label) } ShellKind::Cmd => { @@ -78,11 +80,27 @@ impl ShellBuilder { task_args: &[String], ) -> (String, Vec) { if let Some(task_command) = task_command { - let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| { - command.push(' '); - command.push_str(&self.kind.to_shell_variable(arg)); - command - }); + let task_command = self.kind.prepend_command_prefix(&task_command); + let task_command = if !task_args.is_empty() { + match self.kind.try_quote_prefix_aware(&task_command) { + Some(task_command) => task_command, + None => task_command, + } + } else { + task_command + }; + let mut combined_command = + task_args + .iter() + .fold(task_command.into_owned(), |mut command, arg| { + command.push(' '); + let shell_variable = self.kind.to_shell_variable(arg); + command.push_str(&match self.kind.try_quote(&shell_variable) { + Some(shell_variable) => shell_variable, + None => Cow::Owned(shell_variable), + }); + command + }); if self.redirect_stdin { match self.kind { ShellKind::Fish => { @@ -99,7 +117,7 @@ impl ShellBuilder { combined_command.insert(0, '('); combined_command.push_str(") { + ShellKind::PowerShell | ShellKind::Pwsh => { combined_command.insert_str(0, "$null | & {"); combined_command.push_str("}"); } @@ -115,6 +133,10 @@ impl ShellBuilder { (self.program, self.args) } + + pub fn kind(&self) -> ShellKind { + self.kind + } } #[cfg(test)] @@ -144,7 +166,7 @@ mod test { vec![ "-i", "-c", - "echo $env.hello $env.world nothing --($env.something) $ ${test" + "^echo '$env.hello' '$env.world' nothing '--($env.something)' '$' '${test'" ] ); } @@ -159,7 +181,7 @@ mod test { .build(Some("echo".into()), &["nothing".to_string()]); assert_eq!(program, "nu"); - assert_eq!(args, vec!["-i", "-c", "(echo nothing) cmd.args([ + ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([ "-NonInteractive", "-NoProfile", "-Command", From a61bf33fb06e8eae8638d9296984c4ef7941fc33 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:00:11 -0300 Subject: [PATCH 42/48] Fix label copy for file history menu items (#44569) Buttons and menu items should preferably always start with an infinitive verb that describes what will happen when you trigger them. Instead of just "File History", we should say "_View_ File History". Release Notes: - N/A --- crates/editor/src/mouse_context_menu.rs | 6 +++++- crates/git_ui/src/git_panel.rs | 2 +- crates/project_panel/src/project_panel.rs | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index bda6f1d1d5c893c4fd3fbd4e4d2e72f6ae789361..36521d46a6c20223e973346b9d1e9391db3306ca 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -280,7 +280,11 @@ pub fn deploy_context_menu( "Copy Permalink", Box::new(CopyPermalinkToLine), ) - .action_disabled_when(!has_git_repo, "File History", Box::new(git::FileHistory)); + .action_disabled_when( + !has_git_repo, + "View File History", + Box::new(git::FileHistory), + ); match focus { Some(focus) => builder.context(focus), None => builder, diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index c6895f4c15d5afd3ef50ce796059956dd8653f8b..21486ba98383b06388d2dbc214dfcedc1bb350e4 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4017,7 +4017,7 @@ impl GitPanel { .action("Open Diff", Confirm.boxed_clone()) .action("Open File", SecondaryConfirm.boxed_clone()) .separator() - .action_disabled_when(is_created, "File History", Box::new(git::FileHistory)) + .action_disabled_when(is_created, "View File History", Box::new(git::FileHistory)) }); self.selected_entry = Some(ix); self.set_context_menu(context_menu, position, window, cx); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 0c633bcfcda2415bae84201f7e67a91fbd5a866d..ea667ecbb479ca347914ee11ec789a14f29cf474 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1142,7 +1142,7 @@ impl ProjectPanel { ) .when(has_git_repo, |menu| { menu.separator() - .action("File History", Box::new(git::FileHistory)) + .action("View File History", Box::new(git::FileHistory)) }) .when(!should_hide_rename, |menu| { menu.separator().action("Rename", Box::new(Rename)) From 3a84ec38acaf1f1b884570ba0b31dc6771c4f8f7 Mon Sep 17 00:00:00 2001 From: KyleBarton Date: Wed, 10 Dec 2025 12:10:43 -0800 Subject: [PATCH 43/48] Introduce MVP Dev Containers support (#44442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partially addresses #11473 MVP of dev containers with the following capabilities: - If in a project with `.devcontainer/devcontainer.json`, a pop-up notification will ask if you want to open the project in a dev container. This can be dismissed: Screenshot 2025-12-08 at 3 15
23 PM - Similarly, if a `devcontainer.json` file is in the project, you can open a devcontainer (or go the devcontainer.json file for further editing) via the `open remote` modal: https://github.com/user-attachments/assets/61f2fdaa-2808-4efc-994c-7b444a92c0b1 *Limitations* This is a first release, and comes with some limitations: - Zed extensions are not managed in `devcontainer.json` yet. They will need to be installed either on host or in the container. Host + Container sync their extensions, so there is not currently a concept of what is installed in the container vs what is installed on host: they come from the same list of manifests - This implementation uses the [devcontainer CLI](https://github.com/devcontainers/cli) for its control plane. Hence, it does not yet support the `forwardPorts` directive. A single port can be opened with `appPort`. See reference in docs [here](https://github.com/devcontainers/cli/tree/main/example-usage#how-the-tool-examples-work) - Editing devcontainer.json does not automatically cause the dev container to be rebuilt. So if you add features, change images, etc, you will need to `docker kill` the existing dev container before proceeding. - Currently takes a hard dependency on `docker` being available in the user's `PATH`. Release Notes: - Added ability to Open a project in a DevContainer, provided a `.devcontainer/devcontainer.json` is present --------- Co-authored-by: Danilo Leal Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> --- Cargo.lock | 3 + assets/icons/box.svg | 5 + crates/icons/src/icons.rs | 1 + crates/languages/src/eslint.rs | 4 +- crates/node_runtime/src/node_runtime.rs | 16 +- crates/paths/src/paths.rs | 6 + crates/recent_projects/Cargo.toml | 4 + crates/recent_projects/src/dev_container.rs | 295 +++++++ .../src/dev_container_suggest.rs | 106 +++ crates/recent_projects/src/recent_projects.rs | 96 ++- .../recent_projects/src/remote_connections.rs | 88 +- crates/recent_projects/src/remote_servers.rs | 530 +++++++++++- crates/remote/src/remote.rs | 1 + crates/remote/src/remote_client.rs | 8 + crates/remote/src/transport.rs | 11 +- crates/remote/src/transport/docker.rs | 757 ++++++++++++++++++ crates/settings/src/settings_content.rs | 16 +- crates/title_bar/src/title_bar.rs | 16 +- crates/workspace/src/persistence.rs | 98 ++- crates/workspace/src/persistence/model.rs | 3 + crates/workspace/src/workspace.rs | 6 +- crates/zed_actions/src/lib.rs | 6 + 22 files changed, 1991 insertions(+), 85 deletions(-) create mode 100644 assets/icons/box.svg create mode 100644 crates/recent_projects/src/dev_container.rs create mode 100644 crates/recent_projects/src/dev_container_suggest.rs create mode 100644 crates/remote/src/transport/docker.rs diff --git a/Cargo.lock b/Cargo.lock index 6c75c448a50c711092a7fde296718bc6e3343379..a8f602640838d3634863fc60a2399e8a9a9f5288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13157,6 +13157,7 @@ dependencies = [ "askpass", "auto_update", "dap", + "db", "editor", "extension_host", "file_finder", @@ -13168,6 +13169,7 @@ dependencies = [ "log", "markdown", "menu", + "node_runtime", "ordered-float 2.10.1", "paths", "picker", @@ -13186,6 +13188,7 @@ dependencies = [ "util", "windows-registry 0.6.1", "workspace", + "worktree", "zed_actions", ] diff --git a/assets/icons/box.svg b/assets/icons/box.svg new file mode 100644 index 0000000000000000000000000000000000000000..7e1276c629fb8bdc5a7ed48d9e2de6369d4c2bb0 --- /dev/null +++ b/assets/icons/box.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index cc84129250cfdbe968aa3d86f1d00d0789d01480..bf4c74f984ff4aa8f06d6408957eddabcf5f94ed 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -49,6 +49,7 @@ pub enum IconName { BoltOutlined, Book, BookCopy, + Box, CaseSensitive, Chat, Check, diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index 4f18149265ceac23aadd93b02e7b7309291849fa..fd4133d7ebcafc2553e25c876eb9fb1c6257ebc1 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -126,11 +126,11 @@ impl LspInstaller for EsLintLspAdapter { } self.node - .run_npm_subcommand(&repo_root, "install", &[]) + .run_npm_subcommand(Some(&repo_root), "install", &[]) .await?; self.node - .run_npm_subcommand(&repo_root, "run-script", &["compile"]) + .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"]) .await?; } diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index 1faf22dc9844f648fec53654ef3bde500cec32e2..1eb6714500446dbfd2967ed4aa2f514a5f427aba 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -206,14 +206,14 @@ impl NodeRuntime { pub async fn run_npm_subcommand( &self, - directory: &Path, + directory: Option<&Path>, subcommand: &str, args: &[&str], ) -> Result { let http = self.0.lock().await.http.clone(); self.instance() .await - .run_npm_subcommand(Some(directory), http.proxy(), subcommand, args) + .run_npm_subcommand(directory, http.proxy(), subcommand, args) .await } @@ -283,7 +283,7 @@ impl NodeRuntime { ]); // This is also wrong because the directory is wrong. - self.run_npm_subcommand(directory, "install", &arguments) + self.run_npm_subcommand(Some(directory), "install", &arguments) .await?; Ok(()) } @@ -559,7 +559,10 @@ impl NodeRuntimeTrait for ManagedNodeRuntime { command.env("PATH", env_path); command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs); command.arg(npm_file).arg(subcommand); - command.args(["--cache".into(), self.installation_path.join("cache")]); + command.arg(format!( + "--cache={}", + self.installation_path.join("cache").display() + )); command.args([ "--userconfig".into(), self.installation_path.join("blank_user_npmrc"), @@ -703,7 +706,10 @@ impl NodeRuntimeTrait for SystemNodeRuntime { .env("PATH", path) .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs) .arg(subcommand) - .args(["--cache".into(), self.scratch_dir.join("cache")]) + .arg(format!( + "--cache={}", + self.scratch_dir.join("cache").display() + )) .args(args); configure_npm_command(&mut command, directory, proxy); let output = command.output().await?; diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 7b5188b0f2b0db1c8b20876e6284209ce91fee6e..a6aa8354b4661fbdf6a3360704d0fb16e5b80614 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -408,6 +408,12 @@ pub fn remote_servers_dir() -> &'static PathBuf { REMOTE_SERVERS_DIR.get_or_init(|| data_dir().join("remote_servers")) } +/// Returns the path to the directory where the devcontainer CLI is installed. +pub fn devcontainer_dir() -> &'static PathBuf { + static DEVCONTAINER_DIR: OnceLock = OnceLock::new(); + DEVCONTAINER_DIR.get_or_init(|| data_dir().join("devcontainer")) +} + /// Returns the relative path to a `.zed` folder within a project. pub fn local_settings_folder_name() -> &'static str { ".zed" diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index abaeafa335fd48991da46268ccd59450e908528c..feaf511b81c73bbf50aae6387b3114b1d96f04c4 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -16,6 +16,7 @@ doctest = false anyhow.workspace = true askpass.workspace = true auto_update.workspace = true +db.workspace = true editor.workspace = true extension_host.workspace = true file_finder.workspace = true @@ -26,6 +27,7 @@ language.workspace = true log.workspace = true markdown.workspace = true menu.workspace = true +node_runtime.workspace = true ordered-float.workspace = true paths.workspace = true picker.workspace = true @@ -34,6 +36,7 @@ release_channel.workspace = true remote.workspace = true semver.workspace = true serde.workspace = true +serde_json.workspace = true settings.workspace = true smol.workspace = true task.workspace = true @@ -42,6 +45,7 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true +worktree.workspace = true zed_actions.workspace = true indoc.workspace = true diff --git a/crates/recent_projects/src/dev_container.rs b/crates/recent_projects/src/dev_container.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e6b8b381df32d688e062948460707a5f8cfb552 --- /dev/null +++ b/crates/recent_projects/src/dev_container.rs @@ -0,0 +1,295 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use gpui::AsyncWindowContext; +use node_runtime::NodeRuntime; +use serde::Deserialize; +use settings::DevContainerConnection; +use smol::fs; +use workspace::Workspace; + +use crate::remote_connections::Connection; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DevContainerUp { + _outcome: String, + container_id: String, + _remote_user: String, + remote_workspace_folder: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DevContainerConfiguration { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct DevContainerConfigurationOutput { + configuration: DevContainerConfiguration, +} + +#[cfg(not(target_os = "windows"))] +fn dev_container_cli() -> String { + "devcontainer".to_string() +} + +#[cfg(target_os = "windows")] +fn dev_container_cli() -> String { + "devcontainer.cmd".to_string() +} + +async fn check_for_docker() -> Result<(), DevContainerError> { + let mut command = util::command::new_smol_command("docker"); + command.arg("--version"); + + match command.output().await { + Ok(_) => Ok(()), + Err(e) => { + log::error!("Unable to find docker in $PATH: {:?}", e); + Err(DevContainerError::DockerNotAvailable) + } + } +} + +async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result { + let mut command = util::command::new_smol_command(&dev_container_cli()); + command.arg("--version"); + + if let Err(e) = command.output().await { + log::error!( + "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}", + e + ); + + let datadir_cli_path = paths::devcontainer_dir() + .join("node_modules") + .join(".bin") + .join(&dev_container_cli()); + + let mut command = + util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string()); + command.arg("--version"); + + if let Err(e) = command.output().await { + log::error!( + "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}", + e + ); + } else { + log::info!("Found devcontainer CLI in Data dir"); + return Ok(datadir_cli_path.clone()); + } + + if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await { + log::error!("Unable to create devcontainer directory. Error: {:?}", e); + return Err(DevContainerError::DevContainerCliNotAvailable); + } + + if let Err(e) = node_runtime + .npm_install_packages( + &paths::devcontainer_dir(), + &[("@devcontainers/cli", "latest")], + ) + .await + { + log::error!( + "Unable to install devcontainer CLI to data directory. Error: {:?}", + e + ); + return Err(DevContainerError::DevContainerCliNotAvailable); + }; + + let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string()); + command.arg("--version"); + if let Err(e) = command.output().await { + log::error!( + "Unable to find devcontainer cli after NPM install. Error: {:?}", + e + ); + Err(DevContainerError::DevContainerCliNotAvailable) + } else { + Ok(datadir_cli_path) + } + } else { + log::info!("Found devcontainer cli on $PATH, using it"); + Ok(PathBuf::from(&dev_container_cli())) + } +} + +async fn devcontainer_up( + path_to_cli: &PathBuf, + path: Arc, +) -> Result { + let mut command = util::command::new_smol_command(path_to_cli.display().to_string()); + command.arg("up"); + command.arg("--workspace-folder"); + command.arg(path.display().to_string()); + + match command.output().await { + Ok(output) => { + if output.status.success() { + let raw = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&raw).map_err(|e| { + log::error!( + "Unable to parse response from 'devcontainer up' command, error: {:?}", + e + ); + DevContainerError::DevContainerParseFailed + }) + } else { + log::error!( + "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Err(DevContainerError::DevContainerUpFailed) + } + } + Err(e) => { + log::error!("Error running devcontainer up: {:?}", e); + Err(DevContainerError::DevContainerUpFailed) + } + } +} + +async fn devcontainer_read_configuration( + path_to_cli: &PathBuf, + path: Arc, +) -> Result { + let mut command = util::command::new_smol_command(path_to_cli.display().to_string()); + command.arg("read-configuration"); + command.arg("--workspace-folder"); + command.arg(path.display().to_string()); + match command.output().await { + Ok(output) => { + if output.status.success() { + let raw = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&raw).map_err(|e| { + log::error!( + "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}", + e + ); + DevContainerError::DevContainerParseFailed + }) + } else { + log::error!( + "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Err(DevContainerError::DevContainerUpFailed) + } + } + Err(e) => { + log::error!("Error running devcontainer read-configuration: {:?}", e); + Err(DevContainerError::DevContainerUpFailed) + } + } +} + +// Name the project with two fallbacks +async fn get_project_name( + path_to_cli: &PathBuf, + path: Arc, + remote_workspace_folder: String, + container_id: String, +) -> Result { + if let Ok(dev_container_configuration) = + devcontainer_read_configuration(path_to_cli, path).await + && let Some(name) = dev_container_configuration.configuration.name + { + // Ideally, name the project after the name defined in devcontainer.json + Ok(name) + } else { + // Otherwise, name the project after the remote workspace folder name + Ok(Path::new(&remote_workspace_folder) + .file_name() + .and_then(|name| name.to_str()) + .map(|string| string.into()) + // Finally, name the project after the container ID as a last resort + .unwrap_or_else(|| container_id.clone())) + } +} + +fn project_directory(cx: &mut AsyncWindowContext) -> Option> { + let Some(workspace) = cx.window_handle().downcast::() else { + return None; + }; + + match workspace.update(cx, |workspace, _, cx| { + workspace.project().read(cx).active_project_directory(cx) + }) { + Ok(dir) => dir, + Err(e) => { + log::error!("Error getting project directory from workspace: {:?}", e); + None + } + } +} + +pub(crate) async fn start_dev_container( + cx: &mut AsyncWindowContext, + node_runtime: NodeRuntime, +) -> Result<(Connection, String), DevContainerError> { + check_for_docker().await?; + + let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?; + + let Some(directory) = project_directory(cx) else { + return Err(DevContainerError::DevContainerNotFound); + }; + + if let Ok(DevContainerUp { + container_id, + remote_workspace_folder, + .. + }) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await + { + let project_name = get_project_name( + &path_to_devcontainer_cli, + directory, + remote_workspace_folder.clone(), + container_id.clone(), + ) + .await?; + + let connection = Connection::DevContainer(DevContainerConnection { + name: project_name.into(), + container_id: container_id.into(), + }); + + Ok((connection, remote_workspace_folder)) + } else { + Err(DevContainerError::DevContainerUpFailed) + } +} + +#[derive(Debug)] +pub(crate) enum DevContainerError { + DockerNotAvailable, + DevContainerCliNotAvailable, + DevContainerUpFailed, + DevContainerNotFound, + DevContainerParseFailed, +} + +#[cfg(test)] +mod test { + + use crate::dev_container::DevContainerUp; + + #[test] + fn should_parse_from_devcontainer_json() { + let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#; + let up: DevContainerUp = serde_json::from_str(json).unwrap(); + assert_eq!(up._outcome, "success"); + assert_eq!( + up.container_id, + "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a" + ); + assert_eq!(up._remote_user, "vscode"); + assert_eq!(up.remote_workspace_folder, "/workspaces/zed"); + } +} diff --git a/crates/recent_projects/src/dev_container_suggest.rs b/crates/recent_projects/src/dev_container_suggest.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e50080ea15fad714d17e1648b72455b3d401a7a --- /dev/null +++ b/crates/recent_projects/src/dev_container_suggest.rs @@ -0,0 +1,106 @@ +use db::kvp::KEY_VALUE_STORE; +use gpui::{SharedString, Window}; +use project::{Project, WorktreeId}; +use std::sync::LazyLock; +use ui::prelude::*; +use util::rel_path::RelPath; +use workspace::Workspace; +use workspace::notifications::NotificationId; +use workspace::notifications::simple_message_notification::MessageNotification; +use worktree::UpdatedEntriesSet; + +const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed"; + +fn devcontainer_path() -> &'static RelPath { + static PATH: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path")); + *PATH +} + +fn project_devcontainer_key(project_path: &str) -> String { + format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path) +} + +pub fn suggest_on_worktree_updated( + worktree_id: WorktreeId, + updated_entries: &UpdatedEntriesSet, + project: &gpui::Entity, + window: &mut Window, + cx: &mut Context, +) { + let devcontainer_updated = updated_entries + .iter() + .any(|(path, _, _)| path.as_ref() == devcontainer_path()); + + if !devcontainer_updated { + return; + } + + let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else { + return; + }; + + let worktree = worktree.read(cx); + + if !worktree.is_local() { + return; + } + + let has_devcontainer = worktree + .entry_for_path(devcontainer_path()) + .is_some_and(|entry| entry.is_dir()); + + if !has_devcontainer { + return; + } + + let abs_path = worktree.abs_path(); + let project_path = abs_path.to_string_lossy().to_string(); + let key_for_dismiss = project_devcontainer_key(&project_path); + + let already_dismissed = KEY_VALUE_STORE + .read_kvp(&key_for_dismiss) + .ok() + .flatten() + .is_some(); + + if already_dismissed { + return; + } + + cx.on_next_frame(window, move |workspace, _window, cx| { + struct DevContainerSuggestionNotification; + + let notification_id = NotificationId::composite::( + SharedString::from(project_path.clone()), + ); + + workspace.show_notification(notification_id, cx, |cx| { + cx.new(move |cx| { + MessageNotification::new( + "This project contains a Dev Container configuration file. Would you like to re-open it in a container?", + cx, + ) + .primary_message("Yes, Open in Container") + .primary_icon(IconName::Check) + .primary_icon_color(Color::Success) + .primary_on_click({ + move |window, cx| { + window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx); + } + }) + .secondary_message("Don't Show Again") + .secondary_icon(IconName::Close) + .secondary_icon_color(Color::Error) + .secondary_on_click({ + move |_window, cx| { + let key = key_for_dismiss.clone(); + db::write_and_log(cx, move || { + KEY_VALUE_STORE.write_kvp(key, "dismissed".to_string()) + }); + } + }) + }) + }); + }); +} diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 7647dc1ed46cb9d87c7f889188f834dcbd3a456a..435933a880123c00d3f3fbaaea2c54f6554f0d3b 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1,8 +1,12 @@ +mod dev_container; +mod dev_container_suggest; pub mod disconnected_overlay; mod remote_connections; mod remote_servers; mod ssh_config; +use std::path::PathBuf; + #[cfg(target_os = "windows")] mod wsl_picker; @@ -31,7 +35,7 @@ use workspace::{ WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr, with_active_or_new_workspace, }; -use zed_actions::{OpenRecent, OpenRemote}; +use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote}; pub fn init(cx: &mut App) { #[cfg(target_os = "windows")] @@ -161,6 +165,95 @@ pub fn init(cx: &mut App) { }); cx.observe_new(DisconnectedOverlay::register).detach(); + + cx.on_action(|_: &OpenDevContainer, cx| { + with_active_or_new_workspace(cx, move |workspace, window, cx| { + let app_state = workspace.app_state().clone(); + let replace_window = window.window_handle().downcast::(); + + cx.spawn_in(window, async move |_, mut cx| { + let (connection, starting_dir) = match dev_container::start_dev_container( + &mut cx, + app_state.node_runtime.clone(), + ) + .await + { + Ok((c, s)) => (c, s), + Err(e) => { + log::error!("Failed to start Dev Container: {:?}", e); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to start Dev Container", + Some(&format!("{:?}", e)), + &["Ok"], + ) + .await + .ok(); + return; + } + }; + + let result = open_remote_project( + connection.into(), + vec![starting_dir].into_iter().map(PathBuf::from).collect(), + app_state, + OpenOptions { + replace_window, + ..OpenOptions::default() + }, + &mut cx, + ) + .await; + + if let Err(e) = result { + log::error!("Failed to connect: {e:#}"); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to connect", + Some(&e.to_string()), + &["Ok"], + ) + .await + .ok(); + } + }) + .detach(); + + let fs = workspace.project().read(cx).fs().clone(); + let handle = cx.entity().downgrade(); + workspace.toggle_modal(window, cx, |window, cx| { + RemoteServerProjects::new_dev_container(fs, window, handle, cx) + }); + }); + }); + + // Subscribe to worktree additions to suggest opening the project in a dev container + cx.observe_new( + |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context| { + let Some(window) = window else { + return; + }; + cx.subscribe_in( + workspace.project(), + window, + move |_, project, event, window, cx| { + if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) = + event + { + dev_container_suggest::suggest_on_worktree_updated( + *worktree_id, + updated_entries, + project, + window, + cx, + ); + } + }, + ) + .detach(); + }, + ) + .detach(); } #[cfg(target_os = "windows")] @@ -609,6 +702,7 @@ impl PickerDelegate for RecentProjectsDelegate { Icon::new(match options { RemoteConnectionOptions::Ssh { .. } => IconName::Server, RemoteConnectionOptions::Wsl { .. } => IconName::Linux, + RemoteConnectionOptions::Docker(_) => IconName::Box, }) .color(Color::Muted) .into_any_element() diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 562fcccb204212fb43e0b9457b1c08bdb15c3772..c0a655d19e513c838275d3e4f3beadaabcc8fef6 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -18,16 +18,16 @@ use language::{CursorShape, Point}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use release_channel::ReleaseChannel; use remote::{ - ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform, - SshConnectionOptions, + ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection, + RemoteConnectionOptions, RemotePlatform, SshConnectionOptions, }; use semver::Version; pub use settings::SshConnection; -use settings::{ExtendingVec, RegisterSetting, Settings, WslConnection}; +use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection}; use theme::ThemeSettings; use ui::{ - ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement, - IntoElement, Label, LabelCommon, Styled, Window, prelude::*, + ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding, + LabelCommon, ListItem, Styled, Window, prelude::*, }; use util::paths::PathWithPosition; use workspace::{AppState, ModalView, Workspace}; @@ -85,6 +85,7 @@ impl SshSettings { pub enum Connection { Ssh(SshConnection), Wsl(WslConnection), + DevContainer(DevContainerConnection), } impl From for RemoteConnectionOptions { @@ -92,6 +93,13 @@ impl From for RemoteConnectionOptions { match val { Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()), Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()), + Connection::DevContainer(conn) => { + RemoteConnectionOptions::Docker(DockerConnectionOptions { + name: conn.name.to_string(), + container_id: conn.container_id.to_string(), + upload_binary_over_docker_exec: false, + }) + } } } } @@ -123,6 +131,7 @@ pub struct RemoteConnectionPrompt { connection_string: SharedString, nickname: Option, is_wsl: bool, + is_devcontainer: bool, status_message: Option, prompt: Option<(Entity, oneshot::Sender)>, cancellation: Option>, @@ -148,6 +157,7 @@ impl RemoteConnectionPrompt { connection_string: String, nickname: Option, is_wsl: bool, + is_devcontainer: bool, window: &mut Window, cx: &mut Context, ) -> Self { @@ -155,6 +165,7 @@ impl RemoteConnectionPrompt { connection_string: connection_string.into(), nickname: nickname.map(|nickname| nickname.into()), is_wsl, + is_devcontainer, editor: cx.new(|cx| Editor::single_line(window, cx)), status_message: None, cancellation: None, @@ -244,17 +255,16 @@ impl Render for RemoteConnectionPrompt { v_flex() .key_context("PasswordPrompt") - .py_2() - .px_3() + .p_2() .size_full() .text_buffer(cx) .when_some(self.status_message.clone(), |el, status_message| { el.child( h_flex() - .gap_1() + .gap_2() .child( Icon::new(IconName::ArrowCircle) - .size(IconSize::Medium) + .color(Color::Muted) .with_rotate_animation(2), ) .child( @@ -287,15 +297,28 @@ impl RemoteConnectionModal { window: &mut Window, cx: &mut Context, ) -> Self { - let (connection_string, nickname, is_wsl) = match connection_options { - RemoteConnectionOptions::Ssh(options) => { - (options.connection_string(), options.nickname.clone(), false) + let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options { + RemoteConnectionOptions::Ssh(options) => ( + options.connection_string(), + options.nickname.clone(), + false, + false, + ), + RemoteConnectionOptions::Wsl(options) => { + (options.distro_name.clone(), None, true, false) } - RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true), + RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true), }; Self { prompt: cx.new(|cx| { - RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx) + RemoteConnectionPrompt::new( + connection_string, + nickname, + is_wsl, + is_devcontainer, + window, + cx, + ) }), finished: false, paths, @@ -328,6 +351,7 @@ pub(crate) struct SshConnectionHeader { pub(crate) paths: Vec, pub(crate) nickname: Option, pub(crate) is_wsl: bool, + pub(crate) is_devcontainer: bool, } impl RenderOnce for SshConnectionHeader { @@ -343,9 +367,12 @@ impl RenderOnce for SshConnectionHeader { (self.connection_string, None) }; - let icon = match self.is_wsl { - true => IconName::Linux, - false => IconName::Server, + let icon = if self.is_wsl { + IconName::Linux + } else if self.is_devcontainer { + IconName::Box + } else { + IconName::Server }; h_flex() @@ -388,6 +415,7 @@ impl Render for RemoteConnectionModal { let nickname = self.prompt.read(cx).nickname.clone(); let connection_string = self.prompt.read(cx).connection_string.clone(); let is_wsl = self.prompt.read(cx).is_wsl; + let is_devcontainer = self.prompt.read(cx).is_devcontainer; let theme = cx.theme().clone(); let body_color = theme.colors().editor_background; @@ -407,18 +435,34 @@ impl Render for RemoteConnectionModal { connection_string, nickname, is_wsl, + is_devcontainer, } .render(window, cx), ) .child( div() .w_full() - .rounded_b_lg() .bg(body_color) - .border_t_1() + .border_y_1() .border_color(theme.colors().border_variant) .child(self.prompt.clone()), ) + .child( + div().w_full().py_1().child( + ListItem::new("li-devcontainer-go-back") + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Close).color(Color::Muted)) + .child(Label::new("Cancel")) + .end_slot( + KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx) + .size(rems_from_px(12.)), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss(&menu::Cancel, window, cx); + })), + ), + ) } } @@ -671,6 +715,9 @@ pub async fn open_remote_project( match connection_options { RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH", RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL", + RemoteConnectionOptions::Docker(_) => { + "Failed to connect to Dev Container" + } }, Some(&format!("{e:#}")), &["Retry", "Cancel"], @@ -727,6 +774,9 @@ pub async fn open_remote_project( match connection_options { RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH", RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL", + RemoteConnectionOptions::Docker(_) => { + "Failed to connect to Dev Container" + } }, Some(&format!("{e:#}")), &["Retry", "Cancel"], diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 6dff231b30ddde741f69ba9d4e0366517d8e2751..32a4ef1a81a06a8b5968f7941edb4ab8ea0a5111 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1,4 +1,5 @@ use crate::{ + dev_container::start_dev_container, remote_connections::{ Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection, SshConnectionHeader, SshSettings, connect, determine_paths_with_positions, @@ -24,7 +25,7 @@ use remote::{ remote_client::ConnectionIdentifier, }; use settings::{ - RemoteSettingsContent, Settings as _, SettingsStore, SshProject, update_settings_file, + RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file, watch_config_file, }; use smol::stream::StreamExt as _; @@ -39,12 +40,13 @@ use std::{ }, }; use ui::{ - IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry, - Section, Tooltip, WithScrollbar, prelude::*, + CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal, + ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*, }; use util::{ ResultExt, paths::{PathStyle, RemotePathBuf}, + rel_path::RelPath, }; use workspace::{ ModalView, OpenOptions, Toast, Workspace, @@ -85,6 +87,39 @@ impl CreateRemoteServer { } } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum DevContainerCreationProgress { + Initial, + Creating, + Error(String), +} + +#[derive(Clone)] +struct CreateRemoteDevContainer { + // 3 Navigable Options + // - Create from devcontainer.json + // - Edit devcontainer.json + // - Go back + entries: [NavigableEntry; 3], + progress: DevContainerCreationProgress, +} + +impl CreateRemoteDevContainer { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let entries = std::array::from_fn(|_| NavigableEntry::focusable(cx)); + entries[0].focus_handle.focus(window); + Self { + entries, + progress: DevContainerCreationProgress::Initial, + } + } + + fn progress(&mut self, progress: DevContainerCreationProgress) -> Self { + self.progress = progress; + self.clone() + } +} + #[cfg(target_os = "windows")] struct AddWslDistro { picker: Entity>, @@ -207,6 +242,11 @@ impl ProjectPicker { RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl { distro_name: connection.distro_name.clone().into(), }, + RemoteConnectionOptions::Docker(_) => ProjectPickerData::Ssh { + // Not implemented as a project picker at this time + connection_string: "".into(), + nickname: None, + }, }; let _path_task = cx .spawn_in(window, { @@ -259,7 +299,7 @@ impl ProjectPicker { .as_mut() .and_then(|connections| connections.get_mut(index.0)) { - server.projects.insert(SshProject { paths }); + server.projects.insert(RemoteProject { paths }); }; } ServerIndex::Wsl(index) => { @@ -269,7 +309,7 @@ impl ProjectPicker { .as_mut() .and_then(|connections| connections.get_mut(index.0)) { - server.projects.insert(SshProject { paths }); + server.projects.insert(RemoteProject { paths }); }; } } @@ -349,6 +389,7 @@ impl gpui::Render for ProjectPicker { paths: Default::default(), nickname: nickname.clone(), is_wsl: false, + is_devcontainer: false, } .render(window, cx), ProjectPickerData::Wsl { distro_name } => SshConnectionHeader { @@ -356,6 +397,7 @@ impl gpui::Render for ProjectPicker { paths: Default::default(), nickname: None, is_wsl: true, + is_devcontainer: false, } .render(window, cx), }) @@ -406,7 +448,7 @@ impl From for ServerIndex { enum RemoteEntry { Project { open_folder: NavigableEntry, - projects: Vec<(NavigableEntry, SshProject)>, + projects: Vec<(NavigableEntry, RemoteProject)>, configure: NavigableEntry, connection: Connection, index: ServerIndex, @@ -440,6 +482,7 @@ impl RemoteEntry { struct DefaultState { scroll_handle: ScrollHandle, add_new_server: NavigableEntry, + add_new_devcontainer: NavigableEntry, add_new_wsl: NavigableEntry, servers: Vec, } @@ -448,6 +491,7 @@ impl DefaultState { fn new(ssh_config_servers: &BTreeSet, cx: &mut App) -> Self { let handle = ScrollHandle::new(); let add_new_server = NavigableEntry::new(&handle, cx); + let add_new_devcontainer = NavigableEntry::new(&handle, cx); let add_new_wsl = NavigableEntry::new(&handle, cx); let ssh_settings = SshSettings::get_global(cx); @@ -517,6 +561,7 @@ impl DefaultState { Self { scroll_handle: handle, add_new_server, + add_new_devcontainer, add_new_wsl, servers, } @@ -552,6 +597,7 @@ enum Mode { EditNickname(EditNicknameState), ProjectPicker(Entity), CreateRemoteServer(CreateRemoteServer), + CreateRemoteDevContainer(CreateRemoteDevContainer), #[cfg(target_os = "windows")] AddWslDistro(AddWslDistro), } @@ -598,6 +644,27 @@ impl RemoteServerProjects { ) } + /// Creates a new RemoteServerProjects modal that opens directly in dev container creation mode. + /// Used when suggesting dev container connection from toast notification. + pub fn new_dev_container( + fs: Arc, + window: &mut Window, + workspace: WeakEntity, + cx: &mut Context, + ) -> Self { + Self::new_inner( + Mode::CreateRemoteDevContainer( + CreateRemoteDevContainer::new(window, cx) + .progress(DevContainerCreationProgress::Creating), + ), + false, + fs, + window, + workspace, + cx, + ) + } + fn new_inner( mode: Mode, create_new_window: bool, @@ -703,6 +770,7 @@ impl RemoteServerProjects { connection_options.connection_string(), connection_options.nickname.clone(), false, + false, window, cx, ) @@ -778,6 +846,7 @@ impl RemoteServerProjects { connection_options.distro_name.clone(), None, true, + false, window, cx, ) @@ -862,6 +931,15 @@ impl RemoteServerProjects { cx.notify(); } + fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context) { + self.mode = Mode::CreateRemoteDevContainer( + CreateRemoteDevContainer::new(window, cx) + .progress(DevContainerCreationProgress::Creating), + ); + self.focus_handle(cx).focus(window); + cx.notify(); + } + fn create_remote_project( &mut self, index: ServerIndex, @@ -981,6 +1059,7 @@ impl RemoteServerProjects { self.create_ssh_server(state.address_editor.clone(), window, cx); } + Mode::CreateRemoteDevContainer(_) => {} Mode::EditNickname(state) => { let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty()); let index = state.index; @@ -1024,14 +1103,14 @@ impl RemoteServerProjects { } } - fn render_ssh_connection( + fn render_remote_connection( &mut self, ix: usize, - ssh_server: RemoteEntry, + remote_server: RemoteEntry, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let connection = ssh_server.connection().into_owned(); + let connection = remote_server.connection().into_owned(); let (main_label, aux_label, is_wsl) = match &connection { Connection::Ssh(connection) => { @@ -1045,6 +1124,9 @@ impl RemoteServerProjects { Connection::Wsl(wsl_connection_options) => { (wsl_connection_options.distro_name.clone(), None, true) } + Connection::DevContainer(dev_container_options) => { + (dev_container_options.name.clone(), None, false) + } }; v_flex() .w_full() @@ -1082,7 +1164,7 @@ impl RemoteServerProjects { }), ), ) - .child(match &ssh_server { + .child(match &remote_server { RemoteEntry::Project { open_folder, projects, @@ -1094,9 +1176,9 @@ impl RemoteServerProjects { List::new() .empty_message("No projects.") .children(projects.iter().enumerate().map(|(pix, p)| { - v_flex().gap_0p5().child(self.render_ssh_project( + v_flex().gap_0p5().child(self.render_remote_project( index, - ssh_server.clone(), + remote_server.clone(), pix, p, window, @@ -1222,12 +1304,12 @@ impl RemoteServerProjects { }) } - fn render_ssh_project( + fn render_remote_project( &mut self, server_ix: ServerIndex, server: RemoteEntry, ix: usize, - (navigation, project): &(NavigableEntry, SshProject), + (navigation, project): &(NavigableEntry, RemoteProject), window: &mut Window, cx: &mut Context, ) -> impl IntoElement { @@ -1372,7 +1454,7 @@ impl RemoteServerProjects { fn delete_remote_project( &mut self, server: ServerIndex, - project: &SshProject, + project: &RemoteProject, cx: &mut Context, ) { match server { @@ -1388,7 +1470,7 @@ impl RemoteServerProjects { fn delete_ssh_project( &mut self, server: SshServerIndex, - project: &SshProject, + project: &RemoteProject, cx: &mut Context, ) { let project = project.clone(); @@ -1406,7 +1488,7 @@ impl RemoteServerProjects { fn delete_wsl_project( &mut self, server: WslServerIndex, - project: &SshProject, + project: &RemoteProject, cx: &mut Context, ) { let project = project.clone(); @@ -1451,6 +1533,342 @@ impl RemoteServerProjects { }); } + fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context) { + let Some(workspace) = self.workspace.upgrade() else { + cx.emit(DismissEvent); + cx.notify(); + return; + }; + + workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + + let worktree = project + .read(cx) + .visible_worktrees(cx) + .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree)); + + if let Some(worktree) = worktree { + let tree_id = worktree.read(cx).id(); + let devcontainer_path = RelPath::unix(".devcontainer/devcontainer.json").unwrap(); + cx.spawn_in(window, async move |workspace, cx| { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (tree_id, devcontainer_path), + None, + true, + window, + cx, + ) + })? + .await + }) + .detach(); + } else { + return; + } + }); + cx.emit(DismissEvent); + cx.notify(); + } + + fn open_dev_container(&self, window: &mut Window, cx: &mut Context) { + let Some(app_state) = self + .workspace + .read_with(cx, |workspace, _| workspace.app_state().clone()) + .log_err() + else { + return; + }; + + let replace_window = window.window_handle().downcast::(); + + cx.spawn_in(window, async move |entity, cx| { + let (connection, starting_dir) = + match start_dev_container(cx, app_state.node_runtime.clone()).await { + Ok((c, s)) => (c, s), + Err(e) => { + log::error!("Failed to start dev container: {:?}", e); + entity + .update_in(cx, |remote_server_projects, window, cx| { + remote_server_projects.mode = Mode::CreateRemoteDevContainer( + CreateRemoteDevContainer::new(window, cx).progress( + DevContainerCreationProgress::Error(format!("{:?}", e)), + ), + ); + }) + .log_err(); + return; + } + }; + entity + .update(cx, |_, cx| { + cx.emit(DismissEvent); + }) + .log_err(); + + let result = open_remote_project( + connection.into(), + vec![starting_dir].into_iter().map(PathBuf::from).collect(), + app_state, + OpenOptions { + replace_window, + ..OpenOptions::default() + }, + cx, + ) + .await; + if let Err(e) = result { + log::error!("Failed to connect: {e:#}"); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to connect", + Some(&e.to_string()), + &["Ok"], + ) + .await + .ok(); + } + }) + .detach(); + } + + fn render_create_dev_container( + &self, + state: &CreateRemoteDevContainer, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + match &state.progress { + DevContainerCreationProgress::Error(message) => { + self.focus_handle(cx).focus(window); + return div() + .track_focus(&self.focus_handle(cx)) + .size_full() + .child( + v_flex() + .py_1() + .child( + ListItem::new("Error") + .inset(true) + .selectable(false) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new("Error Creating Dev Container:")) + .child(Label::new(message).buffer_font(cx)), + ) + .child(ListSeparator) + .child( + div() + .id("devcontainer-go-back") + .track_focus(&state.entries[0].focus_handle) + .on_action(cx.listener( + |this, _: &menu::Confirm, window, cx| { + this.mode = + Mode::default_mode(&this.ssh_config_servers, cx); + cx.focus_self(window); + cx.notify(); + }, + )) + .child( + ListItem::new("li-devcontainer-go-back") + .toggle_state( + state.entries[0] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::ArrowLeft).color(Color::Muted), + ) + .child(Label::new("Go Back")) + .end_slot( + KeyBinding::for_action_in( + &menu::Cancel, + &self.focus_handle, + cx, + ) + .size(rems_from_px(12.)), + ) + .on_click(cx.listener(|this, _, window, cx| { + let state = + CreateRemoteDevContainer::new(window, cx); + this.mode = Mode::CreateRemoteDevContainer(state); + + cx.notify(); + })), + ), + ), + ) + .into_any_element(); + } + _ => {} + }; + + let mut view = Navigable::new( + div() + .track_focus(&self.focus_handle(cx)) + .size_full() + .child( + v_flex() + .pb_1() + .child( + ModalHeader::new() + .child(Headline::new("Dev Containers").size(HeadlineSize::XSmall)), + ) + .child(ListSeparator) + .child( + div() + .id("confirm-create-from-devcontainer-json") + .track_focus(&state.entries[0].focus_handle) + .on_action(cx.listener({ + move |this, _: &menu::Confirm, window, cx| { + this.open_dev_container(window, cx); + this.view_in_progress_dev_container(window, cx); + } + })) + .map(|this| { + if state.progress == DevContainerCreationProgress::Creating { + this.child( + ListItem::new("creating") + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .disabled(true) + .start_slot( + Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .child( + h_flex() + .opacity(0.6) + .gap_1() + .child(Label::new("Creating From")) + .child( + Label::new("devcontainer.json") + .buffer_font(cx), + ) + .child(LoadingLabel::new("")), + ), + ) + } else { + this.child( + ListItem::new( + "li-confirm-create-from-devcontainer-json", + ) + .toggle_state( + state.entries[0] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Plus).color(Color::Muted), + ) + .child( + h_flex() + .gap_1() + .child(Label::new("Open or Create New From")) + .child( + Label::new("devcontainer.json") + .buffer_font(cx), + ), + ) + .on_click( + cx.listener({ + move |this, _, window, cx| { + this.open_dev_container(window, cx); + this.view_in_progress_dev_container( + window, cx, + ); + cx.notify(); + } + }), + ), + ) + } + }), + ) + .child( + div() + .id("edit-devcontainer-json") + .track_focus(&state.entries[1].focus_handle) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + this.edit_in_dev_container_json(window, cx); + })) + .child( + ListItem::new("li-edit-devcontainer-json") + .toggle_state( + state.entries[1] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) + .child( + h_flex().gap_1().child(Label::new("Edit")).child( + Label::new("devcontainer.json").buffer_font(cx), + ), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_in_dev_container_json(window, cx); + })), + ), + ) + .child(ListSeparator) + .child( + div() + .id("devcontainer-go-back") + .track_focus(&state.entries[2].focus_handle) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + this.mode = Mode::default_mode(&this.ssh_config_servers, cx); + cx.focus_self(window); + cx.notify(); + })) + .child( + ListItem::new("li-devcontainer-go-back") + .toggle_state( + state.entries[2] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::ArrowLeft).color(Color::Muted), + ) + .child(Label::new("Go Back")) + .end_slot( + KeyBinding::for_action_in( + &menu::Cancel, + &self.focus_handle, + cx, + ) + .size(rems_from_px(12.)), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.mode = + Mode::default_mode(&this.ssh_config_servers, cx); + cx.focus_self(window); + cx.notify() + })), + ), + ), + ) + .into_any_element(), + ); + + view = view.entry(state.entries[0].clone()); + view = view.entry(state.entries[1].clone()); + view = view.entry(state.entries[2].clone()); + + view.render(window, cx).into_any_element() + } + fn render_create_remote_server( &self, state: &CreateRemoteServer, @@ -1571,6 +1989,7 @@ impl RemoteServerProjects { paths: Default::default(), nickname: connection.nickname.clone().map(|s| s.into()), is_wsl: false, + is_devcontainer: false, } .render(window, cx) .into_any_element(), @@ -1579,6 +1998,7 @@ impl RemoteServerProjects { paths: Default::default(), nickname: None, is_wsl: true, + is_devcontainer: false, } .render(window, cx) .into_any_element(), @@ -1917,6 +2337,7 @@ impl RemoteServerProjects { paths: Default::default(), nickname, is_wsl: false, + is_devcontainer: false, } .render(window, cx), ) @@ -1998,7 +2419,7 @@ impl RemoteServerProjects { .track_focus(&state.add_new_server.focus_handle) .anchor_scroll(state.add_new_server.scroll_anchor.clone()) .child( - ListItem::new("register-remove-server-button") + ListItem::new("register-remote-server-button") .toggle_state( state .add_new_server @@ -2008,7 +2429,7 @@ impl RemoteServerProjects { .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Connect New Server")) + .child(Label::new("Connect SSH Server")) .on_click(cx.listener(|this, _, window, cx| { let state = CreateRemoteServer::new(window, cx); this.mode = Mode::CreateRemoteServer(state); @@ -2023,6 +2444,36 @@ impl RemoteServerProjects { cx.notify(); })); + let connect_dev_container_button = div() + .id("connect-new-dev-container") + .track_focus(&state.add_new_devcontainer.focus_handle) + .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone()) + .child( + ListItem::new("register-dev-container-button") + .toggle_state( + state + .add_new_devcontainer + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) + .child(Label::new("Connect Dev Container")) + .on_click(cx.listener(|this, _, window, cx| { + let state = CreateRemoteDevContainer::new(window, cx); + this.mode = Mode::CreateRemoteDevContainer(state); + + cx.notify(); + })), + ) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + let state = CreateRemoteDevContainer::new(window, cx); + this.mode = Mode::CreateRemoteDevContainer(state); + + cx.notify(); + })); + #[cfg(target_os = "windows")] let wsl_connect_button = div() .id("wsl-connect-new-server") @@ -2049,13 +2500,30 @@ impl RemoteServerProjects { cx.notify(); })); + let has_open_project = self + .workspace + .upgrade() + .map(|workspace| { + workspace + .read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + .next() + .is_some() + }) + .unwrap_or(false); + let modal_section = v_flex() .track_focus(&self.focus_handle(cx)) .id("ssh-server-list") .overflow_y_scroll() .track_scroll(&state.scroll_handle) .size_full() - .child(connect_button); + .child(connect_button) + .when(has_open_project, |this| { + this.child(connect_dev_container_button) + }); #[cfg(target_os = "windows")] let modal_section = modal_section.child(wsl_connect_button); @@ -2067,17 +2535,20 @@ impl RemoteServerProjects { .child( List::new() .empty_message( - v_flex() + h_flex() + .size_full() + .p_2() + .justify_center() + .border_t_1() + .border_color(cx.theme().colors().border_variant) .child( - div().px_3().child( - Label::new("No remote servers registered yet.") - .color(Color::Muted), - ), + Label::new("No remote servers registered yet.") + .color(Color::Muted), ) .into_any_element(), ) .children(state.servers.iter().enumerate().map(|(ix, connection)| { - self.render_ssh_connection(ix, connection.clone(), window, cx) + self.render_remote_connection(ix, connection.clone(), window, cx) .into_any_element() })), ) @@ -2085,6 +2556,10 @@ impl RemoteServerProjects { ) .entry(state.add_new_server.clone()); + if has_open_project { + modal_section = modal_section.entry(state.add_new_devcontainer.clone()); + } + if cfg!(target_os = "windows") { modal_section = modal_section.entry(state.add_new_wsl.clone()); } @@ -2297,6 +2772,9 @@ impl Render for RemoteServerProjects { Mode::CreateRemoteServer(state) => self .render_create_remote_server(state, window, cx) .into_any_element(), + Mode::CreateRemoteDevContainer(state) => self + .render_create_dev_container(state, window, cx) + .into_any_element(), Mode::EditNickname(state) => self .render_edit_nickname(state, window, cx) .into_any_element(), diff --git a/crates/remote/src/remote.rs b/crates/remote/src/remote.rs index 783dde16acb350367ed82243e138e5c58f64224b..51b71c988a6dc57e875b3baa28103bef0d8fd729 100644 --- a/crates/remote/src/remote.rs +++ b/crates/remote/src/remote.rs @@ -10,5 +10,6 @@ pub use remote_client::{ ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent, RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect, }; +pub use transport::docker::DockerConnectionOptions; pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption}; pub use transport::wsl::WslConnectionOptions; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index b0f9914c90545263a830ec034512a7e423109409..e8fa4fe4a3e727e823fc5912ddf3e940adf0f78f 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -3,6 +3,7 @@ use crate::{ protocol::MessageId, proxy::ProxyLaunchError, transport::{ + docker::{DockerConnectionOptions, DockerExecConnection}, ssh::SshRemoteConnection, wsl::{WslConnectionOptions, WslRemoteConnection}, }, @@ -1042,6 +1043,11 @@ impl ConnectionPool { .await .map(|connection| Arc::new(connection) as Arc) } + RemoteConnectionOptions::Docker(opts) => { + DockerExecConnection::new(opts, delegate, cx) + .await + .map(|connection| Arc::new(connection) as Arc) + } }; cx.update_global(|pool: &mut Self, _| { @@ -1077,6 +1083,7 @@ impl ConnectionPool { pub enum RemoteConnectionOptions { Ssh(SshConnectionOptions), Wsl(WslConnectionOptions), + Docker(DockerConnectionOptions), } impl RemoteConnectionOptions { @@ -1084,6 +1091,7 @@ impl RemoteConnectionOptions { match self { RemoteConnectionOptions::Ssh(opts) => opts.host.clone(), RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(), + RemoteConnectionOptions::Docker(opts) => opts.name.clone(), } } } diff --git a/crates/remote/src/transport.rs b/crates/remote/src/transport.rs index 1976be5656d7a227541d7adf6a36d91b5bfdcc59..4cafbf60eec338addbb43e46d156960621301ab0 100644 --- a/crates/remote/src/transport.rs +++ b/crates/remote/src/transport.rs @@ -12,6 +12,7 @@ use gpui::{AppContext as _, AsyncApp, Task}; use rpc::proto::Envelope; use smol::process::Child; +pub mod docker; pub mod ssh; pub mod wsl; @@ -64,15 +65,15 @@ fn parse_shell(output: &str, fallback_shell: &str) -> String { } fn handle_rpc_messages_over_child_process_stdio( - mut ssh_proxy_process: Child, + mut remote_proxy_process: Child, incoming_tx: UnboundedSender, mut outgoing_rx: UnboundedReceiver, mut connection_activity_tx: Sender<()>, cx: &AsyncApp, ) -> Task> { - let mut child_stderr = ssh_proxy_process.stderr.take().unwrap(); - let mut child_stdout = ssh_proxy_process.stdout.take().unwrap(); - let mut child_stdin = ssh_proxy_process.stdin.take().unwrap(); + let mut child_stderr = remote_proxy_process.stderr.take().unwrap(); + let mut child_stdout = remote_proxy_process.stdout.take().unwrap(); + let mut child_stdin = remote_proxy_process.stdin.take().unwrap(); let mut stdin_buffer = Vec::new(); let mut stdout_buffer = Vec::new(); @@ -156,7 +157,7 @@ fn handle_rpc_messages_over_child_process_stdio( result.context("stderr") } }; - let status = ssh_proxy_process.status().await?.code().unwrap_or(1); + let status = remote_proxy_process.status().await?.code().unwrap_or(1); match result { Ok(_) => Ok(status), Err(error) => Err(error), diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs new file mode 100644 index 0000000000000000000000000000000000000000..09f5935ec621260e933f11f46aa57493a31ace6d --- /dev/null +++ b/crates/remote/src/transport/docker.rs @@ -0,0 +1,757 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use async_trait::async_trait; +use collections::HashMap; +use parking_lot::Mutex; +use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; +use semver::Version as SemanticVersion; +use std::time::Instant; +use std::{ + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, +}; +use util::ResultExt; +use util::shell::ShellKind; +use util::{ + paths::{PathStyle, RemotePathBuf}, + rel_path::RelPath, +}; + +use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender}; +use gpui::{App, AppContext, AsyncApp, Task}; +use rpc::proto::Envelope; + +use crate::{ + RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemotePlatform, + remote_client::CommandTemplate, +}; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct DockerConnectionOptions { + pub name: String, + pub container_id: String, + pub upload_binary_over_docker_exec: bool, +} + +pub(crate) struct DockerExecConnection { + proxy_process: Mutex>, + remote_dir_for_server: String, + remote_binary_relpath: Option>, + connection_options: DockerConnectionOptions, + remote_platform: Option, + path_style: Option, + shell: Option, +} + +impl DockerExecConnection { + pub async fn new( + connection_options: DockerConnectionOptions, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Result { + let mut this = Self { + proxy_process: Mutex::new(None), + remote_dir_for_server: "/".to_string(), + remote_binary_relpath: None, + connection_options, + remote_platform: None, + path_style: None, + shell: None, + }; + let (release_channel, version, commit) = cx.update(|cx| { + ( + ReleaseChannel::global(cx), + AppVersion::global(cx), + AppCommitSha::try_global(cx), + ) + })?; + let remote_platform = this.check_remote_platform().await?; + + this.path_style = match remote_platform.os { + "windows" => Some(PathStyle::Windows), + _ => Some(PathStyle::Posix), + }; + + this.remote_platform = Some(remote_platform); + + this.shell = Some(this.discover_shell().await); + + this.remote_dir_for_server = this.docker_user_home_dir().await?.trim().to_string(); + + this.remote_binary_relpath = Some( + this.ensure_server_binary( + &delegate, + release_channel, + version, + &this.remote_dir_for_server, + commit, + cx, + ) + .await?, + ); + + Ok(this) + } + + async fn discover_shell(&self) -> String { + let default_shell = "sh"; + match self + .run_docker_exec("sh", None, &Default::default(), &["-c", "echo $SHELL"]) + .await + { + Ok(shell) => match shell.trim() { + "" => { + log::error!("$SHELL is not set, falling back to {default_shell}"); + default_shell.to_owned() + } + shell => shell.to_owned(), + }, + Err(e) => { + log::error!("Failed to get shell: {e}"); + default_shell.to_owned() + } + } + } + + async fn check_remote_platform(&self) -> Result { + let uname = self + .run_docker_exec("uname", None, &Default::default(), &["-sm"]) + .await?; + let Some((os, arch)) = uname.split_once(" ") else { + anyhow::bail!("unknown uname: {uname:?}") + }; + + let os = match os.trim() { + "Darwin" => "macos", + "Linux" => "linux", + _ => anyhow::bail!( + "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" + ), + }; + // exclude armv5,6,7 as they are 32-bit. + let arch = if arch.starts_with("armv8") + || arch.starts_with("armv9") + || arch.starts_with("arm64") + || arch.starts_with("aarch64") + { + "aarch64" + } else if arch.starts_with("x86") { + "x86_64" + } else { + anyhow::bail!( + "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" + ) + }; + + Ok(RemotePlatform { os, arch }) + } + + async fn ensure_server_binary( + &self, + delegate: &Arc, + release_channel: ReleaseChannel, + version: SemanticVersion, + remote_dir_for_server: &str, + commit: Option, + cx: &mut AsyncApp, + ) -> Result> { + let remote_platform = if self.remote_platform.is_some() { + self.remote_platform.unwrap() + } else { + anyhow::bail!("No remote platform defined; cannot proceed.") + }; + + let version_str = match release_channel { + ReleaseChannel::Nightly => { + let commit = commit.map(|s| s.full()).unwrap_or_default(); + format!("{}-{}", version, commit) + } + ReleaseChannel::Dev => "build".to_string(), + _ => version.to_string(), + }; + let binary_name = format!( + "zed-remote-server-{}-{}", + release_channel.dev_name(), + version_str + ); + let dst_path = + paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap()); + + #[cfg(debug_assertions)] + if let Some(remote_server_path) = + super::build_remote_server_from_source(&remote_platform, delegate.as_ref(), cx).await? + { + let tmp_path = paths::remote_server_dir_relative().join( + RelPath::unix(&format!( + "download-{}-{}", + std::process::id(), + remote_server_path.file_name().unwrap().to_string_lossy() + )) + .unwrap(), + ); + self.upload_local_server_binary( + &remote_server_path, + &tmp_path, + &remote_dir_for_server, + delegate, + cx, + ) + .await?; + self.extract_server_binary(&dst_path, &tmp_path, &remote_dir_for_server, delegate, cx) + .await?; + return Ok(dst_path); + } + + if self + .run_docker_exec( + &dst_path.display(self.path_style()), + Some(&remote_dir_for_server), + &Default::default(), + &["version"], + ) + .await + .is_ok() + { + return Ok(dst_path); + } + + let wanted_version = cx.update(|cx| match release_channel { + ReleaseChannel::Nightly => Ok(None), + ReleaseChannel::Dev => { + anyhow::bail!( + "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})", + dst_path + ) + } + _ => Ok(Some(AppVersion::global(cx))), + })??; + + let tmp_path_gz = paths::remote_server_dir_relative().join( + RelPath::unix(&format!( + "{}-download-{}.gz", + binary_name, + std::process::id() + )) + .unwrap(), + ); + if !self.connection_options.upload_binary_over_docker_exec + && let Some(url) = delegate + .get_download_url(remote_platform, release_channel, wanted_version.clone(), cx) + .await? + { + match self + .download_binary_on_server(&url, &tmp_path_gz, &remote_dir_for_server, delegate, cx) + .await + { + Ok(_) => { + self.extract_server_binary( + &dst_path, + &tmp_path_gz, + &remote_dir_for_server, + delegate, + cx, + ) + .await + .context("extracting server binary")?; + return Ok(dst_path); + } + Err(e) => { + log::error!( + "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}", + ) + } + } + } + + let src_path = delegate + .download_server_binary_locally(remote_platform, release_channel, wanted_version, cx) + .await + .context("downloading server binary locally")?; + self.upload_local_server_binary( + &src_path, + &tmp_path_gz, + &remote_dir_for_server, + delegate, + cx, + ) + .await + .context("uploading server binary")?; + self.extract_server_binary( + &dst_path, + &tmp_path_gz, + &remote_dir_for_server, + delegate, + cx, + ) + .await + .context("extracting server binary")?; + Ok(dst_path) + } + + async fn docker_user_home_dir(&self) -> Result { + let inner_program = self.shell(); + self.run_docker_exec( + &inner_program, + None, + &Default::default(), + &["-c", "echo $HOME"], + ) + .await + } + + async fn extract_server_binary( + &self, + dst_path: &RelPath, + tmp_path: &RelPath, + remote_dir_for_server: &str, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + delegate.set_status(Some("Extracting remote development server"), cx); + let server_mode = 0o755; + + let shell_kind = ShellKind::Posix; + let orig_tmp_path = tmp_path.display(self.path_style()); + let server_mode = format!("{:o}", server_mode); + let server_mode = shell_kind + .try_quote(&server_mode) + .context("shell quoting")?; + let dst_path = dst_path.display(self.path_style()); + let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?; + let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") { + let orig_tmp_path = shell_kind + .try_quote(&orig_tmp_path) + .context("shell quoting")?; + let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?; + format!( + "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}", + ) + } else { + let orig_tmp_path = shell_kind + .try_quote(&orig_tmp_path) + .context("shell quoting")?; + format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",) + }; + let args = shell_kind.args_for_shell(false, script.to_string()); + self.run_docker_exec( + "sh", + Some(&remote_dir_for_server), + &Default::default(), + &args, + ) + .await + .log_err(); + Ok(()) + } + + async fn upload_local_server_binary( + &self, + src_path: &Path, + tmp_path_gz: &RelPath, + remote_dir_for_server: &str, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.run_docker_exec( + "mkdir", + Some(remote_dir_for_server), + &Default::default(), + &["-p", parent.display(self.path_style()).as_ref()], + ) + .await?; + } + + let src_stat = smol::fs::metadata(&src_path).await?; + let size = src_stat.len(); + + let t0 = Instant::now(); + delegate.set_status(Some("Uploading remote development server"), cx); + log::info!( + "uploading remote development server to {:?} ({}kb)", + tmp_path_gz, + size / 1024 + ); + self.upload_file(src_path, tmp_path_gz, remote_dir_for_server) + .await + .context("failed to upload server binary")?; + log::info!("uploaded remote development server in {:?}", t0.elapsed()); + Ok(()) + } + + async fn upload_file( + &self, + src_path: &Path, + dest_path: &RelPath, + remote_dir_for_server: &str, + ) -> Result<()> { + log::debug!("uploading file {:?} to {:?}", src_path, dest_path); + + let src_path_display = src_path.display().to_string(); + let dest_path_str = dest_path.display(self.path_style()); + + let mut command = util::command::new_smol_command("docker"); + command.arg("cp"); + command.arg("-a"); + command.arg(&src_path_display); + command.arg(format!( + "{}:{}/{}", + &self.connection_options.container_id, remote_dir_for_server, dest_path_str + )); + + let output = command.output().await?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + log::debug!( + "failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}", + ); + anyhow::bail!( + "failed to upload file via docker cp {} -> {}: {}", + src_path_display, + dest_path_str, + stderr, + ); + } + + async fn run_docker_command( + &self, + subcommand: &str, + args: &[impl AsRef], + ) -> Result { + let mut command = util::command::new_smol_command("docker"); + command.arg(subcommand); + for arg in args { + command.arg(arg.as_ref()); + } + let output = command.output().await?; + anyhow::ensure!( + output.status.success(), + "failed to run command {command:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + async fn run_docker_exec( + &self, + inner_program: &str, + working_directory: Option<&str>, + env: &HashMap, + program_args: &[impl AsRef], + ) -> Result { + let mut args = match working_directory { + Some(dir) => vec!["-w".to_string(), dir.to_string()], + None => vec![], + }; + + for (k, v) in env.iter() { + args.push("-e".to_string()); + let env_declaration = format!("{}={}", k, v); + args.push(env_declaration); + } + + args.push(self.connection_options.container_id.clone()); + args.push(inner_program.to_string()); + + for arg in program_args { + args.push(arg.as_ref().to_owned()); + } + self.run_docker_command("exec", args.as_ref()).await + } + + async fn download_binary_on_server( + &self, + url: &str, + tmp_path_gz: &RelPath, + remote_dir_for_server: &str, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.run_docker_exec( + "mkdir", + Some(remote_dir_for_server), + &Default::default(), + &["-p", parent.display(self.path_style()).as_ref()], + ) + .await?; + } + + delegate.set_status(Some("Downloading remote development server on host"), cx); + + match self + .run_docker_exec( + "curl", + Some(remote_dir_for_server), + &Default::default(), + &[ + "-f", + "-L", + url, + "-o", + &tmp_path_gz.display(self.path_style()), + ], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self + .run_docker_exec("which", None, &Default::default(), &["curl"]) + .await + .is_ok() + { + return Err(e); + } + + log::info!("curl is not available, trying wget"); + match self + .run_docker_exec( + "wget", + Some(remote_dir_for_server), + &Default::default(), + &[url, "-O", &tmp_path_gz.display(self.path_style())], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self + .run_docker_exec("which", None, &Default::default(), &["wget"]) + .await + .is_ok() + { + return Err(e); + } else { + anyhow::bail!("Neither curl nor wget is available"); + } + } + } + } + } + Ok(()) + } + + fn kill_inner(&self) -> Result<()> { + if let Some(pid) = self.proxy_process.lock().take() { + if let Ok(_) = util::command::new_smol_command("kill") + .arg(pid.to_string()) + .spawn() + { + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to kill process")) + } + } else { + Ok(()) + } + } +} + +#[async_trait(?Send)] +impl RemoteConnection for DockerExecConnection { + fn has_wsl_interop(&self) -> bool { + false + } + fn start_proxy( + &self, + unique_identifier: String, + reconnect: bool, + incoming_tx: UnboundedSender, + outgoing_rx: UnboundedReceiver, + connection_activity_tx: Sender<()>, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + // We'll try connecting anew every time we open a devcontainer, so proactively try to kill any old connections. + if !self.has_been_killed() { + if let Err(e) = self.kill_inner() { + return Task::ready(Err(e)); + }; + } + + delegate.set_status(Some("Starting proxy"), cx); + + let Some(remote_binary_relpath) = self.remote_binary_relpath.clone() else { + return Task::ready(Err(anyhow!("Remote binary path not set"))); + }; + + let mut docker_args = vec![ + "exec".to_string(), + "-w".to_string(), + self.remote_dir_for_server.clone(), + "-i".to_string(), + self.connection_options.container_id.to_string(), + ]; + for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { + if let Some(value) = std::env::var(env_var).ok() { + docker_args.push("-e".to_string()); + docker_args.push(format!("{}='{}'", env_var, value)); + } + } + let val = remote_binary_relpath + .display(self.path_style()) + .into_owned(); + docker_args.push(val); + docker_args.push("proxy".to_string()); + docker_args.push("--identifier".to_string()); + docker_args.push(unique_identifier); + if reconnect { + docker_args.push("--reconnect".to_string()); + } + let mut command = util::command::new_smol_command("docker"); + command + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(docker_args); + + let Ok(child) = command.spawn() else { + return Task::ready(Err(anyhow::anyhow!( + "Failed to start remote server process" + ))); + }; + + let mut proxy_process = self.proxy_process.lock(); + *proxy_process = Some(child.id()); + + super::handle_rpc_messages_over_child_process_stdio( + child, + incoming_tx, + outgoing_rx, + connection_activity_tx, + cx, + ) + } + + fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task> { + let dest_path_str = dest_path.to_string(); + let src_path_display = src_path.display().to_string(); + + let mut command = util::command::new_smol_command("docker"); + command.arg("cp"); + command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user + command.arg(src_path_display); + command.arg(format!( + "{}:{}", + self.connection_options.container_id, dest_path_str + )); + + cx.background_spawn(async move { + let output = command.output().await?; + + if output.status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to upload directory")) + } + }) + } + + async fn kill(&self) -> Result<()> { + self.kill_inner() + } + + fn has_been_killed(&self) -> bool { + self.proxy_process.lock().is_none() + } + + fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + working_dir: Option, + _port_forward: Option<(u16, String, u16)>, + ) -> Result { + let mut parsed_working_dir = None; + + let path_style = self.path_style(); + + if let Some(working_dir) = working_dir { + let working_dir = RemotePathBuf::new(working_dir, path_style).to_string(); + + const TILDE_PREFIX: &'static str = "~/"; + if working_dir.starts_with(TILDE_PREFIX) { + let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/"); + parsed_working_dir = Some(format!("$HOME/{working_dir}")); + } else { + parsed_working_dir = Some(working_dir); + } + } + + let mut inner_program = Vec::new(); + + if let Some(program) = program { + inner_program.push(program); + for arg in args { + inner_program.push(arg.clone()); + } + } else { + inner_program.push(self.shell()); + inner_program.push("-l".to_string()); + }; + + let mut docker_args = vec!["exec".to_string()]; + + if let Some(parsed_working_dir) = parsed_working_dir { + docker_args.push("-w".to_string()); + docker_args.push(parsed_working_dir); + } + + for (k, v) in env.iter() { + docker_args.push("-e".to_string()); + docker_args.push(format!("{}={}", k, v)); + } + + docker_args.push("-it".to_string()); + docker_args.push(self.connection_options.container_id.to_string()); + + docker_args.append(&mut inner_program); + + Ok(CommandTemplate { + program: "docker".to_string(), + args: docker_args, + // Docker-exec pipes in environment via the "-e" argument + env: Default::default(), + }) + } + + fn build_forward_ports_command( + &self, + _forwards: Vec<(u16, String, u16)>, + ) -> Result { + Err(anyhow::anyhow!("Not currently supported for docker_exec")) + } + + fn connection_options(&self) -> RemoteConnectionOptions { + RemoteConnectionOptions::Docker(self.connection_options.clone()) + } + + fn path_style(&self) -> PathStyle { + self.path_style.unwrap_or(PathStyle::Posix) + } + + fn shell(&self) -> String { + match &self.shell { + Some(shell) => shell.clone(), + None => self.default_system_shell(), + } + } + + fn default_system_shell(&self) -> String { + String::from("/bin/sh") + } +} diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs index 230e1ffd48b9cc1d58aba59ea0af2c629e36c8e3..36c8520f9313c48408b37caabe61dd29106cacae 100644 --- a/crates/settings/src/settings_content.rs +++ b/crates/settings/src/settings_content.rs @@ -889,9 +889,19 @@ pub enum ImageFileSizeUnit { pub struct RemoteSettingsContent { pub ssh_connections: Option>, pub wsl_connections: Option>, + pub dev_container_connections: Option>, pub read_ssh_config: Option, } +#[with_fallible_options] +#[derive( + Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash, +)] +pub struct DevContainerConnection { + pub name: SharedString, + pub container_id: SharedString, +} + #[with_fallible_options] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct SshConnection { @@ -901,7 +911,7 @@ pub struct SshConnection { #[serde(default)] pub args: Vec, #[serde(default)] - pub projects: collections::BTreeSet, + pub projects: collections::BTreeSet, /// Name to use for this server in UI. pub nickname: Option, // By default Zed will download the binary to the host directly. @@ -918,14 +928,14 @@ pub struct WslConnection { pub distro_name: SharedString, pub user: Option, #[serde(default)] - pub projects: BTreeSet, + pub projects: BTreeSet, } #[with_fallible_options] #[derive( Clone, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema, )] -pub struct SshProject { +pub struct RemoteProject { pub paths: Vec, } diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 945b28a02d1e3f7d6e358c2dad0107d7404aa84b..680c455e73ab135f418f199f06415fff79100ea5 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -323,12 +323,18 @@ impl TitleBar { let options = self.project.read(cx).remote_connection_options(cx)?; let host: SharedString = options.display_name().into(); - let (nickname, icon) = match options { - RemoteConnectionOptions::Ssh(options) => { - (options.nickname.map(|nick| nick.into()), IconName::Server) + let (nickname, tooltip_title, icon) = match options { + RemoteConnectionOptions::Ssh(options) => ( + options.nickname.map(|nick| nick.into()), + "Remote Project", + IconName::Server, + ), + RemoteConnectionOptions::Wsl(_) => (None, "Remote Project", IconName::Linux), + RemoteConnectionOptions::Docker(_dev_container_connection) => { + (None, "Dev Container", IconName::Box) } - RemoteConnectionOptions::Wsl(_) => (None, IconName::Linux), }; + let nickname = nickname.unwrap_or_else(|| host.clone()); let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? { @@ -375,7 +381,7 @@ impl TitleBar { ) .tooltip(move |_window, cx| { Tooltip::with_meta( - "Remote Project", + tooltip_title, Some(&OpenRemote { from_existing_connection: false, create_new_window: false, diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 103e51d548648c18b5b2d724362228948a70930b..f1835caf8dd84e1f729e0415b5711ffa69981d9b 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -20,7 +20,9 @@ use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}; use language::{LanguageName, Toolchain, ToolchainScope}; use project::WorktreeId; -use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions}; +use remote::{ + DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, +}; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, @@ -702,6 +704,10 @@ impl Domain for WorkspaceDb { sql!( DROP TABLE ssh_connections; ), + sql!( + ALTER TABLE remote_connections ADD COLUMN name TEXT; + ALTER TABLE remote_connections ADD COLUMN container_id TEXT; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -728,9 +734,9 @@ impl WorkspaceDb { pub(crate) fn remote_workspace_for_roots>( &self, worktree_roots: &[P], - ssh_project_id: RemoteConnectionId, + remote_project_id: RemoteConnectionId, ) -> Option { - self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id)) + self.workspace_for_roots_internal(worktree_roots, Some(remote_project_id)) } pub(crate) fn workspace_for_roots_internal>( @@ -806,9 +812,20 @@ impl WorkspaceDb { order: paths_order, }); + let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id { + self.remote_connection(remote_connection_id) + .context("Get remote connection") + .log_err() + } else { + None + }; + Some(SerializedWorkspace { id: workspace_id, - location: SerializedWorkspaceLocation::Local, + location: match remote_connection_options { + Some(options) => SerializedWorkspaceLocation::Remote(options), + None => SerializedWorkspaceLocation::Local, + }, paths, center_group: self .get_center_pane_group(workspace_id) @@ -1110,10 +1127,12 @@ impl WorkspaceDb { options: RemoteConnectionOptions, ) -> Result { let kind; - let user; + let mut user = None; let mut host = None; let mut port = None; let mut distro = None; + let mut name = None; + let mut container_id = None; match options { RemoteConnectionOptions::Ssh(options) => { kind = RemoteConnectionKind::Ssh; @@ -1126,8 +1145,22 @@ impl WorkspaceDb { distro = Some(options.distro_name); user = options.user; } + RemoteConnectionOptions::Docker(options) => { + kind = RemoteConnectionKind::Docker; + container_id = Some(options.container_id); + name = Some(options.name); + } } - Self::get_or_create_remote_connection_query(this, kind, host, port, user, distro) + Self::get_or_create_remote_connection_query( + this, + kind, + host, + port, + user, + distro, + name, + container_id, + ) } fn get_or_create_remote_connection_query( @@ -1137,6 +1170,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + name: Option, + container_id: Option, ) -> Result { if let Some(id) = this.select_row_bound(sql!( SELECT id @@ -1146,7 +1181,9 @@ impl WorkspaceDb { host IS ? AND port IS ? AND user IS ? AND - distro IS ? + distro IS ? AND + name IS ? AND + container_id IS ? LIMIT 1 ))?(( kind.serialize(), @@ -1154,6 +1191,8 @@ impl WorkspaceDb { port, user.clone(), distro.clone(), + name.clone(), + container_id.clone(), ))? { Ok(RemoteConnectionId(id)) } else { @@ -1163,10 +1202,20 @@ impl WorkspaceDb { host, port, user, - distro - ) VALUES (?1, ?2, ?3, ?4, ?5) + distro, + name, + container_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id - ))?((kind.serialize(), host, port, user, distro))? + ))?(( + kind.serialize(), + host, + port, + user, + distro, + name, + container_id, + ))? .context("failed to insert remote project")?; Ok(RemoteConnectionId(id)) } @@ -1249,15 +1298,23 @@ impl WorkspaceDb { fn remote_connections(&self) -> Result> { Ok(self.select(sql!( SELECT - id, kind, host, port, user, distro + id, kind, host, port, user, distro, container_id, name FROM remote_connections ))?()? .into_iter() - .filter_map(|(id, kind, host, port, user, distro)| { + .filter_map(|(id, kind, host, port, user, distro, container_id, name)| { Some(( RemoteConnectionId(id), - Self::remote_connection_from_row(kind, host, port, user, distro)?, + Self::remote_connection_from_row( + kind, + host, + port, + user, + distro, + container_id, + name, + )?, )) }) .collect()) @@ -1267,13 +1324,13 @@ impl WorkspaceDb { &self, id: RemoteConnectionId, ) -> Result { - let (kind, host, port, user, distro) = self.select_row_bound(sql!( - SELECT kind, host, port, user, distro + let (kind, host, port, user, distro, container_id, name) = self.select_row_bound(sql!( + SELECT kind, host, port, user, distro, container_id, name FROM remote_connections WHERE id = ? ))?(id.0)? .context("no such remote connection")?; - Self::remote_connection_from_row(kind, host, port, user, distro) + Self::remote_connection_from_row(kind, host, port, user, distro, container_id, name) .context("invalid remote_connection row") } @@ -1283,6 +1340,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + container_id: Option, + name: Option, ) -> Option { match RemoteConnectionKind::deserialize(&kind)? { RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions { @@ -1295,6 +1354,13 @@ impl WorkspaceDb { username: user, ..Default::default() })), + RemoteConnectionKind::Docker => { + Some(RemoteConnectionOptions::Docker(DockerConnectionOptions { + container_id: container_id?, + name: name?, + upload_binary_over_docker_exec: false, + })) + } } } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index a37b2ebbe93efb23cad6a98f127ba1f8800a3eb3..08a3adf9ebd7fa49a5f8fb86eec65c66deb00421 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -32,6 +32,7 @@ pub(crate) struct RemoteConnectionId(pub u64); pub(crate) enum RemoteConnectionKind { Ssh, Wsl, + Docker, } #[derive(Debug, PartialEq, Clone)] @@ -75,6 +76,7 @@ impl RemoteConnectionKind { match self { RemoteConnectionKind::Ssh => "ssh", RemoteConnectionKind::Wsl => "wsl", + RemoteConnectionKind::Docker => "docker", } } @@ -82,6 +84,7 @@ impl RemoteConnectionKind { match text { "ssh" => Some(Self::Ssh), "wsl" => Some(Self::Wsl), + "docker" => Some(Self::Docker), _ => None, } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index cc3ba7577ae6a0d8af889bcde174a00f185dd502..c445ed7822428ebc140a1685c619526d0a2b0ac5 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7780,7 +7780,7 @@ pub fn open_remote_project_with_new_connection( ) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(remote_connection.connection_options(), paths.clone(), cx) + deserialize_remote_project(remote_connection.connection_options(), paths.clone(), cx) .await?; let session = match cx @@ -7834,7 +7834,7 @@ pub fn open_remote_project_with_existing_connection( ) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; + deserialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; open_remote_project_inner( project, @@ -7936,7 +7936,7 @@ async fn open_remote_project_inner( Ok(items.into_iter().map(|item| item?.ok()).collect()) } -fn serialize_remote_project( +fn deserialize_remote_project( connection_options: RemoteConnectionOptions, paths: Vec, cx: &AsyncApp, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index d4d28433d4c76dcab3df627789df82e99854fbc1..a89e943e021e79058953de46bca57713f51598bc 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -428,6 +428,12 @@ pub struct OpenRemote { pub create_new_window: bool, } +/// Opens the dev container connection modal. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = projects)] +#[serde(deny_unknown_fields)] +pub struct OpenDevContainer; + /// Where to spawn the task in the UI. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] From 9e628505f3c5aaee9cf59546af1d7c86111914d2 Mon Sep 17 00:00:00 2001 From: Xipeng Jin <56369076+xipeng-jin@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:11:36 -0500 Subject: [PATCH 44/48] git: Add tree view support to Git Panel (#44089) Closes #35803 This PR adds tree view support to the git panel UI as an additional setting and moves git entry checkboxes to the right. Tree view only supports sorting by paths behavior since sorting by status can become noisy, due to having to duplicate directories that have entries with different statuses. ### Tree vs Flat View image image #### Architecture changes Before this PR, `GitPanel::entries` represented all entries and all visible entries because both sets were equal to one another. However, this equality isn't true for tree view, because entries can be collapsed. To fix this, `TreeState` was added as a logical indices field that is used to filter out non-visible entries. A benefit of this field is that it could be used in the future to implement searching in the GitPanel. Another significant thing this PR changed was adding a HashMap field `entries_by_indices` on `GitPanel`. We did this because `entry_by_path` used binary search, which becomes overly complicated to implement for tree view. The performance of this function matters because it's a hot code path, so a linear search wasn't ideal either. The solution was using a hash map to improve time complexity from O(log n) to O(1), where n is the count of entries. #### Follow-ups In the future, we could use `ui::ListItem` to render entries in the tree view to improve UI consistency. Release Notes: - Added tree view for Git panel. Users are able to switch between Flat and Tree view in Git panel. --------- Co-authored-by: Anthony Eid Co-authored-by: Remco Smits --- assets/settings/default.json | 4 + crates/git_ui/src/git_panel.rs | 1148 ++++++++++++++++++----- crates/git_ui/src/git_panel_settings.rs | 2 + crates/git_ui/src/project_diff.rs | 5 +- crates/settings/src/settings_content.rs | 5 + crates/settings_ui/src/page_data.rs | 18 + 6 files changed, 966 insertions(+), 216 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index dd51099799abb49325e9a2747ee18f9837e4409b..cef4a79c9281541064efd5e5718cf7687f0fc451 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -870,6 +870,10 @@ // // Default: false "collapse_untracked_diff": false, + /// Whether to show entries with tree or flat view in the panel + /// + /// Default: false + "tree_view": false, "scrollbar": { // When to show the scrollbar in the git panel. // diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 21486ba98383b06388d2dbc214dfcedc1bb350e4..ba051cd26ba7c0ad30652af4a614b502e6ea4efa 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -13,6 +13,7 @@ use agent_settings::AgentSettings; use anyhow::Context as _; use askpass::AskPassDelegate; use cloud_llm_client::CompletionIntent; +use collections::{BTreeMap, HashMap, HashSet}; use db::kvp::KEY_VALUE_STORE; use editor::{ Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, @@ -33,10 +34,11 @@ use git::{ TrashUntrackedFiles, UnstageAll, }; use gpui::{ - Action, AsyncApp, AsyncWindowContext, ClickEvent, Corner, DismissEvent, Entity, EventEmitter, - FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, - MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task, - UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, + Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity, + EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, + ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, + Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, + size, uniform_list, }; use itertools::Itertools; use language::{Buffer, File}; @@ -60,12 +62,13 @@ use settings::{Settings, SettingsStore, StatusStyle}; use std::future::Future; use std::ops::Range; use std::path::Path; -use std::{collections::HashSet, sync::Arc, time::Duration, usize}; +use std::{sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; use time::OffsetDateTime; use ui::{ - ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, PopoverMenu, ScrollAxes, - Scrollbars, SplitButton, Tooltip, WithScrollbar, prelude::*, + ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors, + PopoverMenu, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar, + prelude::*, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, maybe}; @@ -92,6 +95,8 @@ actions!( ToggleFillCoAuthors, /// Toggles sorting entries by path vs status. ToggleSortByPath, + /// Toggles showing entries in tree vs flat view. + ToggleTreeView, ] ); @@ -122,6 +127,7 @@ struct GitMenuState { has_new_changes: bool, sort_by_path: bool, has_stash_items: bool, + tree_view: bool, } fn git_panel_context_menu( @@ -166,20 +172,34 @@ fn git_panel_context_menu( ) .separator() .entry( - if state.sort_by_path { - "Sort by Status" + if state.tree_view { + "Flat View" } else { - "Sort by Path" + "Tree View" }, - Some(Box::new(ToggleSortByPath)), - move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), + Some(Box::new(ToggleTreeView)), + move |window, cx| window.dispatch_action(Box::new(ToggleTreeView), cx), ) + .when(!state.tree_view, |this| { + this.entry( + if state.sort_by_path { + "Sort by Status" + } else { + "Sort by Path" + }, + Some(Box::new(ToggleSortByPath)), + move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), + ) + }) }) } const GIT_PANEL_KEY: &str = "GitPanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); +// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel +const TREE_INDENT: f32 = 12.0; +const TREE_INDENT_GUIDE_OFFSET: f32 = 16.0; pub fn register(workspace: &mut Workspace) { workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { @@ -204,7 +224,7 @@ struct SerializedGitPanel { signoff_enabled: bool, } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] enum Section { Conflict, Tracked, @@ -240,6 +260,8 @@ impl GitHeaderEntry { #[derive(Debug, PartialEq, Eq, Clone)] enum GitListEntry { Status(GitStatusEntry), + TreeStatus(GitTreeStatusEntry), + Directory(GitTreeDirEntry), Header(GitHeaderEntry), } @@ -247,11 +269,250 @@ impl GitListEntry { fn status_entry(&self) -> Option<&GitStatusEntry> { match self { GitListEntry::Status(entry) => Some(entry), + GitListEntry::TreeStatus(entry) => Some(&entry.entry), _ => None, } } } +enum GitPanelViewMode { + Flat, + Tree(TreeViewState), +} + +impl GitPanelViewMode { + fn from_settings(cx: &App) -> Self { + if GitPanelSettings::get_global(cx).tree_view { + GitPanelViewMode::Tree(TreeViewState::default()) + } else { + GitPanelViewMode::Flat + } + } + + fn tree_state(&self) -> Option<&TreeViewState> { + match self { + GitPanelViewMode::Tree(state) => Some(state), + GitPanelViewMode::Flat => None, + } + } + + fn tree_state_mut(&mut self) -> Option<&mut TreeViewState> { + match self { + GitPanelViewMode::Tree(state) => Some(state), + GitPanelViewMode::Flat => None, + } + } +} + +#[derive(Default)] +struct TreeViewState { + // Maps visible index to actual entry index. + // Length equals the number of visible entries. + // This is needed because some entries (like collapsed directories) may be hidden. + logical_indices: Vec, + expanded_dirs: HashMap, + directory_descendants: HashMap>, +} + +impl TreeViewState { + fn build_tree_entries( + &mut self, + section: Section, + mut entries: Vec, + repo: &Repository, + seen_directories: &mut HashSet, + optimistic_staging: &HashMap, + ) -> Vec<(GitListEntry, bool)> { + if entries.is_empty() { + return Vec::new(); + } + + entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); + + let mut root = TreeNode::default(); + for entry in entries { + let components: Vec<&str> = entry.repo_path.components().collect(); + if components.is_empty() { + root.files.push(entry); + continue; + } + + let mut current = &mut root; + let mut current_path = String::new(); + + for (ix, component) in components.iter().enumerate() { + if ix == components.len() - 1 { + current.files.push(entry.clone()); + } else { + if !current_path.is_empty() { + current_path.push('/'); + } + current_path.push_str(component); + let dir_path = RepoPath::new(¤t_path) + .expect("repo path from status entry component"); + + let component = SharedString::from(component.to_string()); + + current = current + .children + .entry(component.clone()) + .or_insert_with(|| TreeNode { + name: component, + path: Some(dir_path), + ..Default::default() + }); + } + } + } + + let (flattened, _) = self.flatten_tree( + &root, + section, + 0, + repo, + seen_directories, + optimistic_staging, + ); + flattened + } + + fn flatten_tree( + &mut self, + node: &TreeNode, + section: Section, + depth: usize, + repo: &Repository, + seen_directories: &mut HashSet, + optimistic_staging: &HashMap, + ) -> (Vec<(GitListEntry, bool)>, Vec) { + let mut all_statuses = Vec::new(); + let mut flattened = Vec::new(); + + for child in node.children.values() { + let (terminal, name) = Self::compact_directory_chain(child); + let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else { + continue; + }; + let (child_flattened, mut child_statuses) = self.flatten_tree( + terminal, + section, + depth + 1, + repo, + seen_directories, + optimistic_staging, + ); + let key = TreeKey { section, path }; + let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true); + self.expanded_dirs.entry(key.clone()).or_insert(true); + seen_directories.insert(key.clone()); + + let staged_count = child_statuses + .iter() + .filter(|entry| Self::is_entry_staged(entry, repo, optimistic_staging)) + .count(); + let staged_state = + GitPanel::toggle_state_for_counts(staged_count, child_statuses.len()); + + self.directory_descendants + .insert(key.clone(), child_statuses.clone()); + + flattened.push(( + GitListEntry::Directory(GitTreeDirEntry { + key, + name, + depth, + staged_state, + expanded, + }), + true, + )); + + if expanded { + flattened.extend(child_flattened); + } else { + flattened.extend(child_flattened.into_iter().map(|(child, _)| (child, false))); + } + + all_statuses.append(&mut child_statuses); + } + + for file in &node.files { + all_statuses.push(file.clone()); + flattened.push(( + GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: file.clone(), + depth, + }), + true, + )); + } + + (flattened, all_statuses) + } + + fn compact_directory_chain(mut node: &TreeNode) -> (&TreeNode, SharedString) { + let mut parts = vec![node.name.clone()]; + while node.files.is_empty() && node.children.len() == 1 { + let Some(child) = node.children.values().next() else { + continue; + }; + if child.path.is_none() { + break; + } + parts.push(child.name.clone()); + node = child; + } + let name = parts.join("/"); + (node, SharedString::from(name)) + } + + fn is_entry_staged( + entry: &GitStatusEntry, + repo: &Repository, + optimistic_staging: &HashMap, + ) -> bool { + if let Some(optimistic) = optimistic_staging.get(&entry.repo_path) { + return *optimistic; + } + repo.pending_ops_for_path(&entry.repo_path) + .map(|ops| ops.staging() || ops.staged()) + .or_else(|| { + repo.status_for_path(&entry.repo_path) + .and_then(|status| status.status.staging().as_bool()) + }) + .unwrap_or(entry.staging.has_staged()) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct GitTreeStatusEntry { + entry: GitStatusEntry, + depth: usize, +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +struct TreeKey { + section: Section, + path: RepoPath, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct GitTreeDirEntry { + key: TreeKey, + name: SharedString, + depth: usize, + staged_state: ToggleState, + expanded: bool, +} + +#[derive(Default)] +struct TreeNode { + name: SharedString, + path: Option, + children: BTreeMap, + files: Vec, +} + #[derive(Debug, PartialEq, Eq, Clone)] pub struct GitStatusEntry { pub(crate) repo_path: RepoPath, @@ -345,12 +606,15 @@ pub struct GitPanel { add_coauthors: bool, generate_commit_message_task: Option>>, entries: Vec, + view_mode: GitPanelViewMode, + entries_indices: HashMap, single_staged_entry: Option, single_tracked_entry: Option, focus_handle: FocusHandle, fs: Arc, new_count: usize, entry_count: usize, + changes_count: usize, new_staged_count: usize, pending_commit: Option>, amend_pending: bool, @@ -374,6 +638,7 @@ pub struct GitPanel { local_committer_task: Option>, bulk_staging: Option, stash_entries: GitStash, + optimistic_staging: HashMap, _settings_subscription: Subscription, } @@ -433,14 +698,19 @@ impl GitPanel { cx.on_focus(&focus_handle, window, Self::focus_in).detach(); let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; cx.observe_global_in::(window, move |this, window, cx| { - let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; - if is_sort_by_path != was_sort_by_path { - this.entries.clear(); + let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let tree_view = GitPanelSettings::get_global(cx).tree_view; + if tree_view != was_tree_view { + this.view_mode = GitPanelViewMode::from_settings(cx); + } + if sort_by_path != was_sort_by_path || tree_view != was_tree_view { this.bulk_staging.take(); this.update_visible_entries(window, cx); } - was_sort_by_path = is_sort_by_path + was_sort_by_path = sort_by_path; + was_tree_view = tree_view; }) .detach(); @@ -506,10 +776,13 @@ impl GitPanel { add_coauthors: true, generate_commit_message_task: None, entries: Vec::new(), + view_mode: GitPanelViewMode::from_settings(cx), + entries_indices: HashMap::default(), focus_handle: cx.focus_handle(), fs, new_count: 0, new_staged_count: 0, + changes_count: 0, pending_commit: None, amend_pending: false, original_commit_message: None, @@ -535,6 +808,7 @@ impl GitPanel { entry_count: 0, bulk_staging: None, stash_entries: Default::default(), + optimistic_staging: HashMap::default(), _settings_subscription, }; @@ -543,51 +817,8 @@ impl GitPanel { }) } - pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option { - if GitPanelSettings::get_global(cx).sort_by_path { - return self - .entries - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - .ok(); - } - - if self.conflicted_count > 0 { - let conflicted_start = 1; - if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(conflicted_start + ix); - } - } - if self.tracked_count > 0 { - let tracked_start = if self.conflicted_count > 0 { - 1 + self.conflicted_count - } else { - 0 - } + 1; - if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(tracked_start + ix); - } - } - if self.new_count > 0 { - let untracked_start = if self.conflicted_count > 0 { - 1 + self.conflicted_count - } else { - 0 - } + if self.tracked_count > 0 { - 1 + self.tracked_count - } else { - 0 - } + 1; - if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(untracked_start + ix); - } - } - None + pub fn entry_by_path(&self, path: &RepoPath) -> Option { + self.entries_indices.get(path).copied() } pub fn select_entry_by_path( @@ -602,7 +833,7 @@ impl GitPanel { let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else { return; }; - let Some(ix) = self.entry_by_path(&repo_path, cx) else { + let Some(ix) = self.entry_by_path(&repo_path) else { return; }; self.selected_entry = Some(ix); @@ -702,9 +933,15 @@ impl GitPanel { cx.notify(); } + fn first_status_entry_index(&self) -> Option { + self.entries + .iter() + .position(|entry| entry.status_entry().is_some()) + } + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - if !self.entries.is_empty() { - self.selected_entry = Some(1); + if let Some(first_entry) = self.first_status_entry_index() { + self.selected_entry = Some(first_entry); self.scroll_to_selected_entry(cx); } } @@ -791,7 +1028,7 @@ impl GitPanel { .as_ref() .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0); if have_entries && self.selected_entry.is_none() { - self.selected_entry = Some(1); + self.selected_entry = self.first_status_entry_index(); self.scroll_to_selected_entry(cx); cx.notify(); } @@ -1318,6 +1555,37 @@ impl GitPanel { .detach(); } + fn is_entry_staged(&self, entry: &GitStatusEntry, repo: &Repository) -> bool { + // Checking for current staged/unstaged file status is a chained operation: + // 1. first, we check for any pending operation recorded in repository + // 2. if there are no pending ops either running or finished, we then ask the repository + // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry` + // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on + // the checkbox's state (or flickering) which is undesirable. + // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded + // in `entry` arg. + if let Some(optimistic) = self.optimistic_staging.get(&entry.repo_path) { + return *optimistic; + } + repo.pending_ops_for_path(&entry.repo_path) + .map(|ops| ops.staging() || ops.staged()) + .or_else(|| { + repo.status_for_path(&entry.repo_path) + .and_then(|status| status.status.staging().as_bool()) + }) + .unwrap_or(entry.staging.has_staged()) + } + + fn toggle_state_for_counts(staged_count: usize, total: usize) -> ToggleState { + if staged_count == 0 || total == 0 { + ToggleState::Unselected + } else if staged_count == total { + ToggleState::Selected + } else { + ToggleState::Indeterminate + } + } + pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context) { self.change_all_files_stage(true, cx); } @@ -1332,50 +1600,92 @@ impl GitPanel { _window: &mut Window, cx: &mut Context, ) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.clone() else { return; }; - let repo = active_repository.read(cx); - let (stage, repo_paths) = match entry { - GitListEntry::Status(status_entry) => { - let repo_paths = vec![status_entry.clone()]; - let stage = if repo - .pending_ops_for_path(&status_entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .or_else(|| { - repo.status_for_path(&status_entry.repo_path) - .map(|status| status.status.staging().has_staged()) - }) - .unwrap_or(status_entry.staging.has_staged()) - { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.repo_path - { - self.bulk_staging = None; - } - false - } else { - self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx); - true - }; - (stage, repo_paths) - } - GitListEntry::Header(section) => { - let goal_staged_state = !self.header_state(section.header).selected(); - let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) - .filter(|status_entry| { - section.contains(status_entry, repo) - && status_entry.staging.as_bool() != Some(goal_staged_state) - }) - .cloned() - .collect::>(); + let mut set_anchor: Option = None; + let mut clear_anchor = None; + + let (stage, repo_paths) = { + let repo = active_repository.read(cx); + match entry { + GitListEntry::Status(status_entry) => { + let repo_paths = vec![status_entry.clone()]; + let stage = if self.is_entry_staged(status_entry, &repo) { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } else { + set_anchor = Some(status_entry.repo_path.clone()); + true + }; + (stage, repo_paths) + } + GitListEntry::TreeStatus(status_entry) => { + let repo_paths = vec![status_entry.entry.clone()]; + let stage = if self.is_entry_staged(&status_entry.entry, &repo) { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } else { + set_anchor = Some(status_entry.entry.repo_path.clone()); + true + }; + (stage, repo_paths) + } + GitListEntry::Header(section) => { + let goal_staged_state = !self.header_state(section.header).selected(); + let entries = self + .entries + .iter() + .filter_map(|entry| entry.status_entry()) + .filter(|status_entry| { + section.contains(status_entry, &repo) + && status_entry.staging.as_bool() != Some(goal_staged_state) + }) + .cloned() + .collect::>(); - (goal_staged_state, entries) + (goal_staged_state, entries) + } + GitListEntry::Directory(entry) => { + let goal_staged_state = entry.staged_state != ToggleState::Selected; + let entries = self + .view_mode + .tree_state() + .and_then(|state| state.directory_descendants.get(&entry.key)) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|status_entry| { + self.is_entry_staged(status_entry, &repo) != goal_staged_state + }) + .collect::>(); + (goal_staged_state, entries) + } } }; + if let Some(anchor) = clear_anchor { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == anchor + { + self.bulk_staging = None; + } + } + if let Some(anchor) = set_anchor { + self.set_bulk_staging_anchor(anchor, cx); + } + + let repo = active_repository.read(cx); + self.apply_optimistic_stage(&repo_paths, stage, &repo); + cx.notify(); + self.change_file_stage(stage, repo_paths, cx); } @@ -1420,6 +1730,81 @@ impl GitPanel { .detach(); } + fn apply_optimistic_stage( + &mut self, + entries: &[GitStatusEntry], + stage: bool, + repo: &Repository, + ) { + // This “optimistic” pass keeps all checkboxes—files, folders, and section headers—visually in sync the moment you click, + // even though `change_file_stage` is still talking to the repository in the background. + // Before, the UI would wait for Git, causing checkbox flicker or stale parent states; + // Now, users see instant feedback and accurate parent/child tri-states while the async staging operation completes. + // + // Description: + // It records the desired state in `self.optimistic_staging` (a map from path → bool), + // walks the rendered entries, and swaps their `staging` flags based on that map. + // In tree view it also recomputes every directory’s tri-state checkbox using the updated child data, + // so parent folders flip between selected/indeterminate/empty in the same frame. + let new_stage = if stage { + StageStatus::Staged + } else { + StageStatus::Unstaged + }; + + self.optimistic_staging + .extend(entries.iter().map(|entry| (entry.repo_path.clone(), stage))); + + let staged_states: HashMap = self + .view_mode + .tree_state() + .map(|state| state.directory_descendants.iter()) + .into_iter() + .flatten() + .map(|(key, descendants)| { + let staged_count = descendants + .iter() + .filter(|entry| self.is_entry_staged(entry, repo)) + .count(); + ( + key.clone(), + Self::toggle_state_for_counts(staged_count, descendants.len()), + ) + }) + .collect(); + + for list_entry in &mut self.entries { + match list_entry { + GitListEntry::Status(status) => { + if self + .optimistic_staging + .get(&status.repo_path) + .is_some_and(|s| *s == stage) + { + status.staging = new_stage; + } + } + GitListEntry::TreeStatus(status) => { + if self + .optimistic_staging + .get(&status.entry.repo_path) + .is_some_and(|s| *s == stage) + { + status.entry.staging = new_stage; + } + } + GitListEntry::Directory(dir) => { + if let Some(state) = staged_states.get(&dir.key) { + dir.staged_state = *state; + } + } + _ => {} + } + } + + self.update_counts(repo); + } + pub fn total_staged_count(&self) -> usize { self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count } @@ -2690,6 +3075,29 @@ impl GitPanel { } } + fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) { + let current_setting = GitPanelSettings::get_global(cx).tree_view; + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting); + }); + }) + } + } + + fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context) { + if let Some(state) = self.view_mode.tree_state_mut() { + let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true); + *expanded = !*expanded; + self.update_visible_entries(window, cx); + } else { + util::debug_panic!("Attempted to toggle directory in flat Git Panel state"); + } + } + fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context) { const CO_AUTHOR_PREFIX: &str = "Co-authored-by: "; @@ -2799,27 +3207,34 @@ impl GitPanel { let bulk_staging = self.bulk_staging.take(); let last_staged_path_prev_index = bulk_staging .as_ref() - .and_then(|op| self.entry_by_path(&op.anchor, cx)); + .and_then(|op| self.entry_by_path(&op.anchor)); self.entries.clear(); + self.entries_indices.clear(); self.single_staged_entry.take(); self.single_tracked_entry.take(); self.conflicted_count = 0; self.conflicted_staged_count = 0; + self.changes_count = 0; self.new_count = 0; self.tracked_count = 0; self.new_staged_count = 0; self.tracked_staged_count = 0; self.entry_count = 0; + self.max_width_item_index = None; let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); + let group_by_status = is_tree_view || !sort_by_path; let mut changed_entries = Vec::new(); let mut new_entries = Vec::new(); let mut conflict_entries = Vec::new(); let mut single_staged_entry = None; let mut staged_count = 0; - let mut max_width_item: Option<(RepoPath, usize)> = None; + let mut seen_directories = HashSet::default(); + let mut max_width_estimate = 0usize; + let mut max_width_item_index = None; let Some(repo) = self.active_repository.as_ref() else { // Just clear entries if no repository is active. @@ -2832,6 +3247,7 @@ impl GitPanel { self.stash_entries = repo.cached_stash(); for entry in repo.cached_status() { + self.changes_count += 1; let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path); let is_new = entry.status.is_created(); let staging = entry.status.staging(); @@ -2856,26 +3272,9 @@ impl GitPanel { single_staged_entry = Some(entry.clone()); } - let width_estimate = Self::item_width_estimate( - entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0), - entry.display_name(path_style).len(), - ); - - match max_width_item.as_mut() { - Some((repo_path, estimate)) => { - if width_estimate > *estimate { - *repo_path = entry.repo_path.clone(); - *estimate = width_estimate; - } - } - None => max_width_item = Some((entry.repo_path.clone(), width_estimate)), - } - - if sort_by_path { - changed_entries.push(entry); - } else if is_conflict { + if group_by_status && is_conflict { conflict_entries.push(entry); - } else if is_new { + } else if group_by_status && is_new { new_entries.push(entry); } else { changed_entries.push(entry); @@ -2910,52 +3309,126 @@ impl GitPanel { self.single_tracked_entry = changed_entries.first().cloned(); } - if !conflict_entries.is_empty() { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::Conflict, - })); - self.entries - .extend(conflict_entries.into_iter().map(GitListEntry::Status)); + let mut push_entry = + |this: &mut Self, + entry: GitListEntry, + is_visible: bool, + logical_indices: Option<&mut Vec>| { + if let Some(estimate) = + this.width_estimate_for_list_entry(is_tree_view, &entry, path_style) + { + if estimate > max_width_estimate { + max_width_estimate = estimate; + max_width_item_index = Some(this.entries.len()); + } + } + + if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone()) + { + this.entries_indices.insert(repo_path, this.entries.len()); + } + + if let (Some(indices), true) = (logical_indices, is_visible) { + indices.push(this.entries.len()); + } + + this.entries.push(entry); + }; + + macro_rules! take_section_entries { + () => { + [ + (Section::Conflict, std::mem::take(&mut conflict_entries)), + (Section::Tracked, std::mem::take(&mut changed_entries)), + (Section::New, std::mem::take(&mut new_entries)), + ] + }; } - if !changed_entries.is_empty() { - if !sort_by_path { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::Tracked, - })); + match &mut self.view_mode { + GitPanelViewMode::Tree(tree_state) => { + tree_state.logical_indices.clear(); + tree_state.directory_descendants.clear(); + + // This is just to get around the borrow checker + // because push_entry mutably borrows self + let mut tree_state = std::mem::take(tree_state); + + for (section, entries) in take_section_entries!() { + if entries.is_empty() { + continue; + } + + push_entry( + self, + GitListEntry::Header(GitHeaderEntry { header: section }), + true, + Some(&mut tree_state.logical_indices), + ); + + for (entry, is_visible) in tree_state.build_tree_entries( + section, + entries, + &repo, + &mut seen_directories, + &self.optimistic_staging, + ) { + push_entry( + self, + entry, + is_visible, + Some(&mut tree_state.logical_indices), + ); + } + } + + tree_state + .expanded_dirs + .retain(|key, _| seen_directories.contains(key)); + self.view_mode = GitPanelViewMode::Tree(tree_state); } - self.entries - .extend(changed_entries.into_iter().map(GitListEntry::Status)); - } - if !new_entries.is_empty() { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::New, - })); - self.entries - .extend(new_entries.into_iter().map(GitListEntry::Status)); - } + GitPanelViewMode::Flat => { + for (section, entries) in take_section_entries!() { + if entries.is_empty() { + continue; + } - if let Some((repo_path, _)) = max_width_item { - self.max_width_item_index = self.entries.iter().position(|entry| match entry { - GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path, - GitListEntry::Header(_) => false, - }); + if section != Section::Tracked || !sort_by_path { + push_entry( + self, + GitListEntry::Header(GitHeaderEntry { header: section }), + true, + None, + ); + } + + for entry in entries { + push_entry(self, GitListEntry::Status(entry), true, None); + } + } + } } + self.max_width_item_index = max_width_item_index; + self.update_counts(repo); + let visible_paths: HashSet = self + .entries + .iter() + .filter_map(|entry| entry.status_entry().map(|e| e.repo_path.clone())) + .collect(); + self.optimistic_staging + .retain(|path, _| visible_paths.contains(path)); let bulk_staging_anchor_new_index = bulk_staging .as_ref() .filter(|op| op.repo_id == repo.id) - .and_then(|op| self.entry_by_path(&op.anchor, cx)); + .and_then(|op| self.entry_by_path(&op.anchor)); if bulk_staging_anchor_new_index == last_staged_path_prev_index && let Some(index) = bulk_staging_anchor_new_index && let Some(entry) = self.entries.get(index) && let Some(entry) = entry.status_entry() - && repo - .pending_ops_for_path(&entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .unwrap_or(entry.staging.has_staged()) + && self.is_entry_staged(entry, &repo) { self.bulk_staging = bulk_staging; } @@ -2996,15 +3469,11 @@ impl GitPanel { self.new_staged_count = 0; self.tracked_staged_count = 0; self.entry_count = 0; - for entry in &self.entries { - let Some(status_entry) = entry.status_entry() else { - continue; - }; + + for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) { self.entry_count += 1; - let is_staging_or_staged = repo - .pending_ops_for_path(&status_entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .unwrap_or(status_entry.staging.has_staged()); + let is_staging_or_staged = self.is_entry_staged(status_entry, repo); + if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) { self.conflicted_count += 1; if is_staging_or_staged { @@ -3118,10 +3587,48 @@ impl GitPanel { self.has_staged_changes() } - // eventually we'll need to take depth into account here - // if we add a tree view - fn item_width_estimate(path: usize, file_name: usize) -> usize { - path + file_name + fn status_width_estimate( + tree_view: bool, + entry: &GitStatusEntry, + path_style: PathStyle, + depth: usize, + ) -> usize { + if tree_view { + Self::item_width_estimate(0, entry.display_name(path_style).len(), depth) + } else { + Self::item_width_estimate( + entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0), + entry.display_name(path_style).len(), + 0, + ) + } + } + + fn width_estimate_for_list_entry( + &self, + tree_view: bool, + entry: &GitListEntry, + path_style: PathStyle, + ) -> Option { + match entry { + GitListEntry::Status(status) => Some(Self::status_width_estimate( + tree_view, status, path_style, 0, + )), + GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate( + tree_view, + &status.entry, + path_style, + status.depth, + )), + GitListEntry::Directory(dir) => { + Some(Self::item_width_estimate(0, dir.name.len(), dir.depth)) + } + GitListEntry::Header(_) => None, + } + } + + fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize { + path + file_name + depth * 2 } fn render_overflow_menu(&self, id: impl Into) -> impl IntoElement { @@ -3148,6 +3655,7 @@ impl GitPanel { has_new_changes, sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, has_stash_items, + tree_view: GitPanelSettings::get_global(cx).tree_view, }, window, cx, @@ -3382,10 +3890,10 @@ impl GitPanel { ("Stage All", StageAll.boxed_clone(), true, "git add --all") }; - let change_string = match self.entry_count { + let change_string = match self.changes_count { 0 => "No Changes".to_string(), 1 => "1 Change".to_string(), - _ => format!("{} Changes", self.entry_count), + count => format!("{} Changes", count), }; Some( @@ -3807,7 +4315,7 @@ impl GitPanel { let repo = self.active_repository.as_ref()?.read(cx); let project_path = (file.worktree_id(cx), file.path().clone()).into(); let repo_path = repo.project_path_to_repo_path(&project_path, cx)?; - let ix = self.entry_by_path(&repo_path, cx)?; + let ix = self.entry_by_path(&repo_path)?; let entry = self.entries.get(ix)?; let is_staging_or_staged = repo @@ -3858,7 +4366,10 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let entry_count = self.entries.len(); + let (is_tree_view, entry_count) = match &self.view_mode { + GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()), + GitPanelViewMode::Flat => (false, self.entries.len()), + }; v_flex() .flex_1() @@ -3878,10 +4389,33 @@ impl GitPanel { cx.processor(move |this, range: Range, window, cx| { let mut items = Vec::with_capacity(range.end - range.start); - for ix in range { + for ix in range.into_iter().map(|ix| match &this.view_mode { + GitPanelViewMode::Tree(state) => state.logical_indices[ix], + GitPanelViewMode::Flat => ix, + }) { match &this.entries.get(ix) { Some(GitListEntry::Status(entry)) => { - items.push(this.render_entry( + items.push(this.render_status_entry( + ix, + entry, + 0, + has_write_access, + window, + cx, + )); + } + Some(GitListEntry::TreeStatus(entry)) => { + items.push(this.render_status_entry( + ix, + &entry.entry, + entry.depth, + has_write_access, + window, + cx, + )); + } + Some(GitListEntry::Directory(entry)) => { + items.push(this.render_directory_entry( ix, entry, has_write_access, @@ -3905,6 +4439,51 @@ impl GitPanel { items }), ) + .when(is_tree_view, |list| { + let indent_size = px(TREE_INDENT); + list.with_decoration( + ui::indent_guides(indent_size, IndentGuideColors::panel(cx)) + .with_compute_indents_fn( + cx.entity(), + |this, range, _window, _cx| { + range + .map(|ix| match this.entries.get(ix) { + Some(GitListEntry::Directory(dir)) => dir.depth, + Some(GitListEntry::TreeStatus(status)) => { + status.depth + } + _ => 0, + }) + .collect() + }, + ) + .with_render_fn(cx.entity(), |_, params, _, _| { + let left_offset = px(TREE_INDENT_GUIDE_OFFSET); + let indent_size = params.indent_size; + let item_height = params.item_height; + + params + .indent_guides + .into_iter() + .map(|layout| { + let bounds = Bounds::new( + point( + layout.offset.x * indent_size + left_offset, + layout.offset.y * item_height, + ), + size(px(1.), layout.length * item_height), + ); + RenderedIndentGuide { + bounds, + layout, + is_active: false, + hitbox: None, + } + }) + .collect() + }), + ) + }) .size_full() .flex_grow() .with_sizing_behavior(ListSizingBehavior::Auto) @@ -4038,6 +4617,7 @@ impl GitPanel { has_new_changes: self.new_count > 0, sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, has_stash_items: self.stash_entries.entries.len() > 0, + tree_view: GitPanelSettings::get_global(cx).tree_view, }, window, cx, @@ -4069,14 +4649,16 @@ impl GitPanel { cx.notify(); } - fn render_entry( + fn render_status_entry( &self, ix: usize, entry: &GitStatusEntry, + depth: usize, has_write_access: bool, window: &Window, cx: &Context, ) -> AnyElement { + let tree_view = GitPanelSettings::get_global(cx).tree_view; let path_style = self.project.read(cx).path_style(cx); let git_path_style = ProjectSettings::get_global(cx).git.path_style; let display_name = entry.display_name(path_style); @@ -4123,22 +4705,7 @@ impl GitPanel { .active_repository(cx) .expect("active repository must be set"); let repo = active_repo.read(cx); - // Checking for current staged/unstaged file status is a chained operation: - // 1. first, we check for any pending operation recorded in repository - // 2. if there are no pending ops either running or finished, we then ask the repository - // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry` - // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on - // the checkbox's state (or flickering) which is undesirable. - // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded - // in `entry` arg. - let is_staging_or_staged = repo - .pending_ops_for_path(&entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .or_else(|| { - repo.status_for_path(&entry.repo_path) - .and_then(|status| status.status.staging().as_bool()) - }) - .or_else(|| entry.staging.as_bool()); + let is_staging_or_staged = self.is_entry_staged(entry, &repo); let mut is_staged: ToggleState = is_staging_or_staged.into(); if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() { is_staged = ToggleState::Selected; @@ -4178,6 +4745,39 @@ impl GitPanel { } else { cx.theme().colors().ghost_element_active }; + + let mut name_row = h_flex() + .items_center() + .gap_1() + .flex_1() + .pl(if tree_view { + px(depth as f32 * TREE_INDENT) + } else { + px(0.) + }) + .child(git_status_icon(status)); + + name_row = if tree_view { + name_row.child( + self.entry_label(display_name, label_color) + .when(status.is_deleted(), Label::strikethrough) + .truncate(), + ) + } else { + name_row.child(h_flex().items_center().flex_1().map(|this| { + self.path_formatted( + this, + entry.parent_dir(path_style), + path_color, + display_name, + label_color, + path_style, + git_path_style, + status.is_deleted(), + ) + })) + }; + h_flex() .id(id) .h(self.list_item_height()) @@ -4223,6 +4823,7 @@ impl GitPanel { cx.stop_propagation(); }, ) + .child(name_row) .child( div() .id(checkbox_wrapper_id) @@ -4245,11 +4846,16 @@ impl GitPanel { if click.modifiers().shift { this.stage_bulk(ix, cx); } else { - this.toggle_staged_for_entry( - &GitListEntry::Status(entry.clone()), - window, - cx, - ); + let list_entry = + if GitPanelSettings::get_global(cx).tree_view { + GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: entry.clone(), + depth, + }) + } else { + GitListEntry::Status(entry.clone()) + }; + this.toggle_staged_for_entry(&list_entry, window, cx); } cx.stop_propagation(); }) @@ -4259,7 +4865,7 @@ impl GitPanel { .tooltip(move |_window, cx| { // If is_staging_or_staged is None, this implies the file was partially staged, and so // we allow the user to stage it in full by displaying `Stage` in the tooltip. - let action = if is_staging_or_staged.unwrap_or(false) { + let action = if is_staging_or_staged { "Unstage" } else { "Stage" @@ -4270,23 +4876,134 @@ impl GitPanel { }), ), ) - .child(git_status_icon(status)) + .into_any_element() + } + + fn render_directory_entry( + &self, + ix: usize, + entry: &GitTreeDirEntry, + has_write_access: bool, + window: &Window, + cx: &Context, + ) -> AnyElement { + // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that + let selected = self.selected_entry == Some(ix); + let label_color = Color::Muted; + + let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into()); + let checkbox_id: ElementId = + ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into()); + let checkbox_wrapper_id: ElementId = + ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into()); + + let selected_bg_alpha = 0.08; + let state_opacity_step = 0.04; + + let base_bg = if selected { + cx.theme().status().info.alpha(selected_bg_alpha) + } else { + cx.theme().colors().ghost_element_background + }; + + let hover_bg = if selected { + cx.theme() + .status() + .info + .alpha(selected_bg_alpha + state_opacity_step) + } else { + cx.theme().colors().ghost_element_hover + }; + + let active_bg = if selected { + cx.theme() + .status() + .info + .alpha(selected_bg_alpha + state_opacity_step * 2.0) + } else { + cx.theme().colors().ghost_element_active + }; + let folder_icon = if entry.expanded { + IconName::FolderOpen + } else { + IconName::Folder + }; + let staged_state = entry.staged_state; + + let name_row = h_flex() + .items_center() + .gap_1() + .flex_1() + .pl(px(entry.depth as f32 * TREE_INDENT)) .child( - h_flex() - .items_center() - .flex_1() - .child(h_flex().items_center().flex_1().map(|this| { - self.path_formatted( - this, - entry.parent_dir(path_style), - path_color, - display_name, - label_color, - path_style, - git_path_style, - status.is_deleted(), - ) - })), + Icon::new(folder_icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(self.entry_label(entry.name.clone(), label_color).truncate()); + + h_flex() + .id(id) + .h(self.list_item_height()) + .w_full() + .items_center() + .border_1() + .when(selected && self.focus_handle.is_focused(window), |el| { + el.border_color(cx.theme().colors().border_focused) + }) + .px(rems(0.75)) + .overflow_hidden() + .flex_none() + .gap_1p5() + .bg(base_bg) + .hover(|this| this.bg(hover_bg)) + .active(|this| this.bg(active_bg)) + .on_click({ + let key = entry.key.clone(); + cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.selected_entry = Some(ix); + this.toggle_directory(&key, window, cx); + }) + }) + .child(name_row) + .child( + div() + .id(checkbox_wrapper_id) + .flex_none() + .occlude() + .cursor_pointer() + .child( + Checkbox::new(checkbox_id, staged_state) + .disabled(!has_write_access) + .fill() + .elevation(ElevationIndex::Surface) + .on_click({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + this.toggle_staged_for_entry( + &GitListEntry::Directory(entry.clone()), + window, + cx, + ); + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| { + let action = if staged_state.selected() { + "Unstage" + } else { + "Stage" + }; + Tooltip::simple(format!("{action} folder"), cx) + }), + ), ) .into_any_element() } @@ -4433,7 +5150,7 @@ impl GitPanel { let Some(op) = self.bulk_staging.as_ref() else { return; }; - let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else { + let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else { return; }; if let Some(entry) = self.entries.get(index) @@ -4528,6 +5245,7 @@ impl Render for GitPanel { git_panel.on_action(cx.listener(Self::toggle_fill_co_authors)) }) .on_action(cx.listener(Self::toggle_sort_by_path)) + .on_action(cx.listener(Self::toggle_tree_view)) .size_full() .overflow_hidden() .bg(cx.theme().colors().panel_background) diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index 2a6c1e8882b3f9cce02060dbf8efb6a4826b6995..6b5334e55544b465864fe3afb780c4673bb5961e 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -24,6 +24,7 @@ pub struct GitPanelSettings { pub fallback_branch_name: String, pub sort_by_path: bool, pub collapse_untracked_diff: bool, + pub tree_view: bool, } impl ScrollbarVisibility for GitPanelSettings { @@ -56,6 +57,7 @@ impl Settings for GitPanelSettings { fallback_branch_name: git_panel.fallback_branch_name.unwrap(), sort_by_path: git_panel.sort_by_path.unwrap(), collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), + tree_view: git_panel.tree_view.unwrap(), } } } diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index e560bba0d36ad9901fffa9b5aad4dbd88e3108b6..f40d70da6494cf8491c1d3d7909a288e5f99023c 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -644,7 +644,10 @@ impl ProjectDiff { } fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 { - if GitPanelSettings::get_global(cx).sort_by_path { + let settings = GitPanelSettings::get_global(cx); + + // Tree view can only sort by path + if settings.sort_by_path || settings.tree_view { TRACKED_SORT_PREFIX } else if repo.had_conflict_on_last_merge_head_change(repo_path) { CONFLICT_SORT_PREFIX diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs index 36c8520f9313c48408b37caabe61dd29106cacae..743e22b04d9cf87a0d09a73aef879c781a50cca2 100644 --- a/crates/settings/src/settings_content.rs +++ b/crates/settings/src/settings_content.rs @@ -511,6 +511,11 @@ pub struct GitPanelSettingsContent { /// /// Default: false pub collapse_untracked_diff: Option, + + /// Whether to show entries with tree or flat view in the panel + /// + /// Default: false + pub tree_view: Option, } #[derive( diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0c383970c990c3ba19eab7aa5d3b7c699f8a195e..8652ccf68b48e8e858b96e4fe69edecd8ae29d25 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4314,6 +4314,24 @@ pub(crate) fn settings_data(cx: &App) -> Vec { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Tree View", + description: "Enable to show entries in tree view list, disable to show in flat view list.", + field: Box::new(SettingField { + json_path: Some("git_panel.tree_view"), + pick: |settings_content| { + settings_content.git_panel.as_ref()?.tree_view.as_ref() + }, + write: |settings_content, value| { + settings_content + .git_panel + .get_or_insert_default() + .tree_view = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Scroll Bar", description: "How and when the scrollbar should be displayed.", From d21628c349bd95e56f10a7966d8aeaa2a030e20e Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 10 Dec 2025 18:07:01 -0500 Subject: [PATCH 45/48] Revert "Increase askpass timeout for git operations (#42946)" (#44578) This reverts commit a74aac88c95738b1e1c95ac583b116a253920fbf. cc @11happy, we need to do a bit more than just running `git hook pre-push` before pushing, as described [here](https://github.com/zed-industries/zed/pull/42946#issuecomment-3550570438). Right now this is also running the pre-push hook twice. Release Notes: - N/A --- crates/git/src/git.rs | 3 --- crates/project/src/git_store.rs | 2 -- crates/proto/proto/git.proto | 2 +- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 197ce4d6fb3bb3a41dd0be67e542d91d87561736..8b8f88ef65b86ea9157e1c3217fa01bb0d6355cb 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -232,14 +232,12 @@ impl From for usize { #[derive(Copy, Clone, Debug)] pub enum RunHook { PreCommit, - PrePush, } impl RunHook { pub fn as_str(&self) -> &str { match self { Self::PreCommit => "pre-commit", - Self::PrePush => "pre-push", } } @@ -250,7 +248,6 @@ impl RunHook { pub fn from_proto(value: i32) -> Option { match value { 0 => Some(Self::PreCommit), - 1 => Some(Self::PrePush), _ => None, } } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 3efbb57e0312dc7e07d0dbed69f5e096a2e52eb3..ae39cc331c3dae44261392e1a4d1782901443795 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -4692,11 +4692,9 @@ impl Repository { }); let this = cx.weak_entity(); - let rx = self.run_hook(RunHook::PrePush, cx); self.send_job( Some(format!("git push {} {} {}", args, remote, branch).into()), move |git_repo, mut cx| async move { - rx.await??; match git_repo { RepositoryState::Local(LocalRepositoryState { backend, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 6e3573b91a690290b71e626f3bd67fc81d8d8e92..d1e56f4f8c89e655dc0e153be013903d48afc99f 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -580,7 +580,7 @@ message GitCreateWorktree { message RunGitHook { enum GitHook { PRE_COMMIT = 0; - PRE_PUSH = 1; + reserved 1; } uint64 project_id = 1; From 7c4a85f5f1e31571be35c2d61783be15224bc5a6 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Thu, 11 Dec 2025 00:35:02 +0100 Subject: [PATCH 46/48] ci: Explicitly set git committer information in protobuf check (#44582) This should hopefully fix the flakes for good. Release Notes: - N/A --- .github/workflows/run_tests.yml | 2 ++ tooling/xtask/src/tasks/workflows/run_tests.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index ad228103e33bd17dbe180d1c267c5141f5433080..9584d7a0cb70469820bf40d76beb6154f2a53b1e 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -497,6 +497,8 @@ jobs: env: GIT_AUTHOR_NAME: Protobuf Action GIT_AUTHOR_EMAIL: ci@zed.dev + GIT_COMMITTER_NAME: Protobuf Action + GIT_COMMITTER_EMAIL: ci@zed.dev steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index e4443ad91313fd4511765fb7be6a8bb092757e9d..0bb3e152fb390e044ebac456fd3347707c66f612 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -368,6 +368,8 @@ pub(crate) fn check_postgres_and_protobuf_migrations() -> NamedJob { .runs_on(runners::LINUX_DEFAULT) .add_env(("GIT_AUTHOR_NAME", "Protobuf Action")) .add_env(("GIT_AUTHOR_EMAIL", "ci@zed.dev")) + .add_env(("GIT_COMMITTER_NAME", "Protobuf Action")) + .add_env(("GIT_COMMITTER_EMAIL", "ci@zed.dev")) .add_step(steps::checkout_repo().with(("fetch-depth", 0))) // fetch full history .add_step(remove_untracked_files()) .add_step(ensure_fresh_merge()) From 37077a8ebb4c1d8f486e61a2a8bcf0b472af1ab2 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 10 Dec 2025 20:03:35 -0500 Subject: [PATCH 47/48] git: Avoid calling `git help -a` on every commit (#44586) Updates #43993 Release Notes: - N/A --- crates/git/src/repository.rs | 97 +++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 6d0dc8de9e2fbd5066f6abf4e4eeaddeb8cd33bf..6477917dcdc485a5f2d23f16f3a5b3d7f94e9597 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -652,6 +652,7 @@ pub struct RealGitRepository { pub repository: Arc>, pub system_git_binary_path: Option, pub any_git_binary_path: PathBuf, + any_git_binary_help_output: Arc>>, executor: BackgroundExecutor, } @@ -670,6 +671,7 @@ impl RealGitRepository { system_git_binary_path, any_git_binary_path, executor, + any_git_binary_help_output: Arc::new(Mutex::new(None)), }) } @@ -680,6 +682,27 @@ impl RealGitRepository { .context("failed to read git work directory") .map(Path::to_path_buf) } + + async fn any_git_binary_help_output(&self) -> SharedString { + if let Some(output) = self.any_git_binary_help_output.lock().clone() { + return output; + } + let git_binary_path = self.any_git_binary_path.clone(); + let executor = self.executor.clone(); + let working_directory = self.working_directory(); + let output: SharedString = self + .executor + .spawn(async move { + GitBinary::new(git_binary_path, working_directory?, executor) + .run(["help", "-a"]) + .await + }) + .await + .unwrap_or_default() + .into(); + *self.any_git_binary_help_output.lock() = Some(output.clone()); + output + } } #[derive(Clone, Debug)] @@ -2290,48 +2313,50 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); + let repository = self.repository.clone(); let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); - self.executor - .spawn(async move { - let working_directory = working_directory?; - let git = GitBinary::new(git_binary_path, working_directory.clone(), executor) - .envs(HashMap::clone(&env)); - - let output = git.run(&["help", "-a"]).await?; - if !output.lines().any(|line| line.trim().starts_with("hook ")) { - log::warn!( - "git hook command not available, running the {} hook manually", - hook.as_str() - ); + let help_output = self.any_git_binary_help_output(); - let hook_abs_path = working_directory - .join(".git") - .join("hooks") - .join(hook.as_str()); - if hook_abs_path.is_file() { - let output = new_smol_command(&hook_abs_path) - .envs(env.iter()) - .current_dir(&working_directory) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "{} hook failed:\n{}", - hook.as_str(), - String::from_utf8_lossy(&output.stderr) - ); - } + async move { + let working_directory = working_directory?; + if !help_output + .await + .lines() + .any(|line| line.trim().starts_with("hook ")) + { + let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str()); + if hook_abs_path.is_file() { + let output = self + .executor + .spawn( + new_smol_command(&hook_abs_path) + .envs(env.iter()) + .current_dir(&working_directory) + .output(), + ) + .await?; - return Ok(()); + if !output.status.success() { + return Err(GitBinaryCommandError { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + status: output.status, + } + .into()); + } } - git.run(&["hook", "run", "--ignore-missing", hook.as_str()]) - .await?; - Ok(()) - }) - .boxed() + return Ok(()); + } + + let git = GitBinary::new(git_binary_path, working_directory, executor) + .envs(HashMap::clone(&env)); + git.run(&["hook", "run", "--ignore-missing", hook.as_str()]) + .await?; + Ok(()) + } + .boxed() } } From 25d74480aac797ec38a60aed46d055d69767e8cc Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 10 Dec 2025 17:36:51 -0800 Subject: [PATCH 48/48] Rework edit prediction CLI (#44562) This PR restructures the commands of the Edit Prediction CLI (now called `ep`), to support some flows that are important for the training process: * generating zeta2 prompt and expected output, without running predictions * scoring outputs that are generated by a system other than the production code (to evaluate the model during training) To achieve this, we've restructured the CLI commands so that they all take as input, and produce as output, a consistent, uniform data format: a set of one or more `Example` structs, expressible either as the original markdown format, or as a JSON lines. The `Example` struct starts with the basic fields that are in human-readable eval format, but contain a number of optional fields that are filled in by different steps in the processing pipeline (`context`, `predict`, `format-prompt`, and `score`). ### To do * [x] Adjust the teacher model output parsing to use the full buffer contents * [x] Move udiff to cli * [x] Align `format-prompt` with Zeta2's production code * [x] Change score output to assume same provider * [x] Move pretty reporting to `eval` command * [x] Store cursor point in addition to cursor offset * [x] Rename `edit_prediction_cli2` -> `edit_prediction_cli` (nuke the old one) Release Notes: - N/A --------- Co-authored-by: Oleksiy Syvokon Co-authored-by: Agus Zubiaga Co-authored-by: Ben Kunkle --- Cargo.lock | 30 +- Cargo.toml | 5 +- crates/client/Cargo.toml | 2 +- crates/cloud_zeta2_prompt/Cargo.toml | 18 - .../src/cloud_zeta2_prompt.rs | 485 ----------- crates/edit_prediction/Cargo.toml | 4 +- crates/edit_prediction/src/edit_prediction.rs | 320 +++---- .../src/edit_prediction_tests.rs | 218 ++--- crates/edit_prediction/src/mercury.rs | 155 ++-- crates/edit_prediction/src/prediction.rs | 29 +- crates/edit_prediction/src/sweep_ai.rs | 96 +-- crates/edit_prediction/src/udiff.rs | 221 +---- crates/edit_prediction/src/xml_edits.rs | 637 -------------- crates/edit_prediction/src/zeta1.rs | 90 +- crates/edit_prediction/src/zeta2.rs | 303 +++---- crates/edit_prediction_cli/Cargo.toml | 16 +- .../llm_client.rs => anthropic_client.rs} | 51 +- crates/edit_prediction_cli/src/evaluate.rs | 641 -------------- crates/edit_prediction_cli/src/example.rs | 797 +++++++----------- .../edit_prediction_cli/src/format_prompt.rs | 280 ++++++ crates/edit_prediction_cli/src/headless.rs | 6 +- .../edit_prediction_cli/src/load_project.rs | 320 +++++++ crates/edit_prediction_cli/src/main.rs | 594 +++---------- crates/edit_prediction_cli/src/metrics.rs | 51 +- crates/edit_prediction_cli/src/paths.rs | 68 +- crates/edit_prediction_cli/src/predict.rs | 569 +++++-------- .../src/{util.rs => retrieve_context.rs} | 172 ++-- crates/edit_prediction_cli/src/score.rs | 119 +++ .../src/source_location.rs | 70 -- .../src/{training => }/teacher.prompt.md | 4 + .../src/training/context.rs | 89 -- .../src/training/distill.rs | 94 --- .../edit_prediction_cli/src/training/mod.rs | 4 - .../src/training/teacher.rs | 266 ------ crates/edit_prediction_context/Cargo.toml | 1 + .../src/assemble_excerpts.rs | 13 +- .../src/edit_prediction_context.rs | 161 ++-- .../src/edit_prediction_context_tests.rs | 12 +- crates/edit_prediction_ui/Cargo.toml | 2 +- .../src/edit_prediction_context_view.rs | 47 +- .../src/rate_prediction_modal.rs | 43 +- crates/zeta_prompt/Cargo.toml | 15 + .../LICENSE-GPL | 0 crates/zeta_prompt/src/zeta_prompt.rs | 165 ++++ 44 files changed, 2492 insertions(+), 4791 deletions(-) delete mode 100644 crates/cloud_zeta2_prompt/Cargo.toml delete mode 100644 crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs delete mode 100644 crates/edit_prediction/src/xml_edits.rs rename crates/edit_prediction_cli/src/{training/llm_client.rs => anthropic_client.rs} (89%) delete mode 100644 crates/edit_prediction_cli/src/evaluate.rs create mode 100644 crates/edit_prediction_cli/src/format_prompt.rs create mode 100644 crates/edit_prediction_cli/src/load_project.rs rename crates/edit_prediction_cli/src/{util.rs => retrieve_context.rs} (53%) create mode 100644 crates/edit_prediction_cli/src/score.rs delete mode 100644 crates/edit_prediction_cli/src/source_location.rs rename crates/edit_prediction_cli/src/{training => }/teacher.prompt.md (98%) delete mode 100644 crates/edit_prediction_cli/src/training/context.rs delete mode 100644 crates/edit_prediction_cli/src/training/distill.rs delete mode 100644 crates/edit_prediction_cli/src/training/mod.rs delete mode 100644 crates/edit_prediction_cli/src/training/teacher.rs create mode 100644 crates/zeta_prompt/Cargo.toml rename crates/{cloud_zeta2_prompt => zeta_prompt}/LICENSE-GPL (100%) create mode 100644 crates/zeta_prompt/src/zeta_prompt.rs diff --git a/Cargo.lock b/Cargo.lock index a8f602640838d3634863fc60a2399e8a9a9f5288..ff1041695e1f1e95bcbc05798d1a1e0f953533ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3111,16 +3111,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "cloud_zeta2_prompt" -version = "0.1.0" -dependencies = [ - "anyhow", - "cloud_llm_client", - "indoc", - "serde", -] - [[package]] name = "cmake" version = "0.1.54" @@ -5119,7 +5109,6 @@ dependencies = [ "clock", "cloud_api_types", "cloud_llm_client", - "cloud_zeta2_prompt", "collections", "copilot", "credentials_provider", @@ -5150,8 +5139,6 @@ dependencies = [ "serde", "serde_json", "settings", - "smol", - "strsim", "strum 0.27.2", "telemetry", "telemetry_events", @@ -5162,6 +5149,7 @@ dependencies = [ "workspace", "worktree", "zed_actions", + "zeta_prompt", "zlog", ] @@ -5175,11 +5163,10 @@ dependencies = [ "clap", "client", "cloud_llm_client", - "cloud_zeta2_prompt", "collections", "debug_adapter_extension", + "dirs 4.0.0", "edit_prediction", - "edit_prediction_context", "extension", "fs", "futures 0.3.31", @@ -5209,9 +5196,10 @@ dependencies = [ "sqlez", "sqlez_macros", "terminal_view", - "toml 0.8.23", "util", + "wasmtime", "watch", + "zeta_prompt", "zlog", ] @@ -5239,6 +5227,7 @@ dependencies = [ "text", "tree-sitter", "util", + "zeta_prompt", "zlog", ] @@ -5260,7 +5249,6 @@ dependencies = [ "buffer_diff", "client", "cloud_llm_client", - "cloud_zeta2_prompt", "codestral", "command_palette_hooks", "copilot", @@ -5291,6 +5279,7 @@ dependencies = [ "util", "workspace", "zed_actions", + "zeta_prompt", ] [[package]] @@ -20933,6 +20922,13 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zeta_prompt" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "zip" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index 0ad4d2b14523988aa0dd6e3bfc935f84bcd0d8d9..fcbe5c829ded21a9aaf9e6bec93b9955b1db6447 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,6 @@ members = [ "crates/cloud_api_client", "crates/cloud_api_types", "crates/cloud_llm_client", - "crates/cloud_zeta2_prompt", "crates/collab", "crates/collab_ui", "crates/collections", @@ -202,6 +201,7 @@ members = [ "crates/zed_actions", "crates/zed_env_vars", "crates/edit_prediction_cli", + "crates/zeta_prompt", "crates/zlog", "crates/zlog_settings", "crates/ztracing", @@ -266,7 +266,6 @@ clock = { path = "crates/clock" } cloud_api_client = { path = "crates/cloud_api_client" } cloud_api_types = { path = "crates/cloud_api_types" } cloud_llm_client = { path = "crates/cloud_llm_client" } -cloud_zeta2_prompt = { path = "crates/cloud_zeta2_prompt" } collab_ui = { path = "crates/collab_ui" } collections = { path = "crates/collections", version = "0.1.0" } command_palette = { path = "crates/command_palette" } @@ -425,6 +424,7 @@ zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } zed_env_vars = { path = "crates/zed_env_vars" } edit_prediction = { path = "crates/edit_prediction" } +zeta_prompt = { path = "crates/zeta_prompt" } zlog = { path = "crates/zlog" } zlog_settings = { path = "crates/zlog_settings" } ztracing = { path = "crates/ztracing" } @@ -657,6 +657,7 @@ time = { version = "0.3", features = [ tiny_http = "0.8" tokio = { version = "1" } tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } +tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io", "tokio"] } toml = "0.8" toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } tower-http = "0.4.4" diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 7149ad4f55feaae5b596a39a3dd460d71cc5daa5..50cf12b977a62d56bf9d4a036165917a5dfff2fc 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -53,7 +53,7 @@ text.workspace = true thiserror.workspace = true time.workspace = true tiny_http.workspace = true -tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io"] } +tokio-socks.workspace = true tokio.workspace = true url.workspace = true util.workspace = true diff --git a/crates/cloud_zeta2_prompt/Cargo.toml b/crates/cloud_zeta2_prompt/Cargo.toml deleted file mode 100644 index a15e3fe43c28349920433272c4040ccc58ff4cb4..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "cloud_zeta2_prompt" -version = "0.1.0" -publish.workspace = true -edition.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/cloud_zeta2_prompt.rs" - -[dependencies] -anyhow.workspace = true -cloud_llm_client.workspace = true -indoc.workspace = true -serde.workspace = true diff --git a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs b/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs deleted file mode 100644 index 62bfa45f47d0fdfefa9fbd72320c0ddee71cbc47..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs +++ /dev/null @@ -1,485 +0,0 @@ -use anyhow::Result; -use cloud_llm_client::predict_edits_v3::{ - self, DiffPathFmt, Event, Excerpt, Line, Point, PromptFormat, RelatedFile, -}; -use indoc::indoc; -use std::cmp; -use std::fmt::Write; -use std::path::Path; -use std::sync::Arc; - -pub const DEFAULT_MAX_PROMPT_BYTES: usize = 10 * 1024; - -pub const CURSOR_MARKER: &str = "<|user_cursor|>"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_START_MARKER_WITH_NEWLINE: &str = "<|editable_region_start|>\n"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_END_MARKER_WITH_NEWLINE: &str = "<|editable_region_end|>\n"; - -const STUDENT_MODEL_INSTRUCTIONS: &str = indoc! {r#" - You are a code completion assistant that analyzes edit history to identify and systematically complete incomplete refactorings or patterns across the entire codebase. - - ## Edit History - - "#}; - -const MINIMAL_PROMPT_REMINDER: &str = indoc! {" - --- - - Please analyze the edit history and the files, then provide the unified diff for your predicted edits. - Do not include the cursor marker in your output. - If you're editing multiple files, be sure to reflect filename in the hunk's header. - "}; - -const XML_TAGS_INSTRUCTIONS: &str = indoc! {r#" - # Instructions - - You are an edit prediction agent in a code editor. - - Analyze the history of edits made by the user in order to infer what they are currently trying to accomplish. - Then complete the remainder of the current change if it is incomplete, or predict the next edit the user intends to make. - Always continue along the user's current trajectory, rather than changing course. - - ## Output Format - - You should briefly explain your understanding of the user's overall goal in one sentence, then explain what the next change - along the users current trajectory will be in another, and finally specify the next edit using the following XML-like format: - - - - OLD TEXT 1 HERE - - - NEW TEXT 1 HERE - - - - OLD TEXT 1 HERE - - - NEW TEXT 1 HERE - - - - - Specify the file to edit using the `path` attribute. - - Use `` and `` tags to replace content - - `` must exactly match existing file content, including indentation - - `` cannot be empty - - Do not escape quotes, newlines, or other characters within tags - - Always close all tags properly - - Don't include the <|user_cursor|> marker in your output. - - ## Edit History - -"#}; - -const OLD_TEXT_NEW_TEXT_REMINDER: &str = indoc! {r#" - --- - - Remember that the edits in the edit history have already been applied. -"#}; - -pub fn build_prompt(request: &predict_edits_v3::PredictEditsRequest) -> Result { - let prompt_data = PromptData { - events: request.events.clone(), - cursor_point: request.cursor_point, - cursor_path: request.excerpt_path.clone(), - included_files: request.related_files.clone(), - }; - match request.prompt_format { - PromptFormat::MinimalQwen => { - return Ok(MinimalQwenPrompt.render(&prompt_data)); - } - PromptFormat::SeedCoder1120 => { - return Ok(SeedCoder1120Prompt.render(&prompt_data)); - } - _ => (), - }; - - let insertions = match request.prompt_format { - PromptFormat::Minimal | PromptFormat::OldTextNewText => { - vec![(request.cursor_point, CURSOR_MARKER)] - } - PromptFormat::OnlySnippets => vec![], - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - }; - - let mut prompt = match request.prompt_format { - PromptFormat::OldTextNewText => XML_TAGS_INSTRUCTIONS.to_string(), - PromptFormat::OnlySnippets => String::new(), - PromptFormat::Minimal => STUDENT_MODEL_INSTRUCTIONS.to_string(), - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - }; - - if request.events.is_empty() { - prompt.push_str("(No edit history)\n\n"); - } else { - let edit_preamble = if request.prompt_format == PromptFormat::Minimal { - "The following are the latest edits made by the user, from earlier to later.\n\n" - } else { - "Here are the latest edits made by the user, from earlier to later.\n\n" - }; - prompt.push_str(edit_preamble); - push_events(&mut prompt, &request.events); - } - - let excerpts_preamble = match request.prompt_format { - PromptFormat::Minimal => indoc! {" - ## Part of the file under the cursor - - (The cursor marker <|user_cursor|> indicates the current user cursor position. - The file is in current state, edits from edit history has been applied. - We only show part of the file around the cursor. - You can only edit exactly this part of the file. - We prepend line numbers (e.g., `123|`); they are not part of the file.) - "}, - PromptFormat::OldTextNewText => indoc! {" - ## Code Excerpts - - Here is some excerpts of code that you should take into account to predict the next edit. - - The cursor position is marked by `<|user_cursor|>` as it stands after the last edit in the history. - - In addition other excerpts are included to better understand what the edit will be, including the declaration - or references of symbols around the cursor, or other similar code snippets that may need to be updated - following patterns that appear in the edit history. - - Consider each of them carefully in relation to the edit history, and that the user may not have navigated - to the next place they want to edit yet. - - Lines starting with `…` indicate omitted line ranges. These may appear inside multi-line code constructs. - "}, - PromptFormat::OnlySnippets | PromptFormat::MinimalQwen | PromptFormat::SeedCoder1120 => { - indoc! {" - ## Code Excerpts - - The cursor marker <|user_cursor|> indicates the current user cursor position. - The file is in current state, edits from edit history have been applied. - "} - } - }; - - prompt.push_str(excerpts_preamble); - prompt.push('\n'); - - let include_line_numbers = matches!(request.prompt_format, PromptFormat::Minimal); - for related_file in &request.related_files { - if request.prompt_format == PromptFormat::Minimal { - write_codeblock_with_filename( - &related_file.path, - &related_file.excerpts, - if related_file.path == request.excerpt_path { - &insertions - } else { - &[] - }, - related_file.max_row, - include_line_numbers, - &mut prompt, - ); - } else { - write_codeblock( - &related_file.path, - &related_file.excerpts, - if related_file.path == request.excerpt_path { - &insertions - } else { - &[] - }, - related_file.max_row, - include_line_numbers, - &mut prompt, - ); - } - } - - match request.prompt_format { - PromptFormat::OldTextNewText => { - prompt.push_str(OLD_TEXT_NEW_TEXT_REMINDER); - } - PromptFormat::Minimal => { - prompt.push_str(MINIMAL_PROMPT_REMINDER); - } - _ => {} - } - - Ok(prompt) -} - -pub fn generation_params(prompt_format: PromptFormat) -> GenerationParams { - match prompt_format { - PromptFormat::SeedCoder1120 => SeedCoder1120Prompt::generation_params(), - _ => GenerationParams::default(), - } -} - -pub fn write_codeblock<'a>( - path: &Path, - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &'a mut String, -) { - writeln!(output, "`````{}", DiffPathFmt(path)).unwrap(); - - write_excerpts( - excerpts, - sorted_insertions, - file_line_count, - include_line_numbers, - output, - ); - write!(output, "`````\n\n").unwrap(); -} - -fn write_codeblock_with_filename<'a>( - path: &Path, - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &'a mut String, -) { - writeln!(output, "`````filename={}", DiffPathFmt(path)).unwrap(); - - write_excerpts( - excerpts, - sorted_insertions, - file_line_count, - include_line_numbers, - output, - ); - write!(output, "`````\n\n").unwrap(); -} - -pub fn write_excerpts<'a>( - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &mut String, -) { - let mut current_row = Line(0); - let mut sorted_insertions = sorted_insertions.iter().peekable(); - - for excerpt in excerpts { - if excerpt.start_line > current_row { - writeln!(output, "…").unwrap(); - } - if excerpt.text.is_empty() { - return; - } - - current_row = excerpt.start_line; - - for mut line in excerpt.text.lines() { - if include_line_numbers { - write!(output, "{}|", current_row.0 + 1).unwrap(); - } - - while let Some((insertion_location, insertion_marker)) = sorted_insertions.peek() { - match current_row.cmp(&insertion_location.line) { - cmp::Ordering::Equal => { - let (prefix, suffix) = line.split_at(insertion_location.column as usize); - output.push_str(prefix); - output.push_str(insertion_marker); - line = suffix; - sorted_insertions.next(); - } - cmp::Ordering::Less => break, - cmp::Ordering::Greater => { - sorted_insertions.next(); - break; - } - } - } - output.push_str(line); - output.push('\n'); - current_row.0 += 1; - } - } - - if current_row < file_line_count { - writeln!(output, "…").unwrap(); - } -} - -pub fn push_events(output: &mut String, events: &[Arc]) { - if events.is_empty() { - return; - }; - - writeln!(output, "`````diff").unwrap(); - for event in events { - writeln!(output, "{}", event).unwrap(); - } - writeln!(output, "`````\n").unwrap(); -} - -struct PromptData { - events: Vec>, - cursor_point: Point, - cursor_path: Arc, // TODO: make a common struct with cursor_point - included_files: Vec, -} - -#[derive(Default)] -pub struct GenerationParams { - pub temperature: Option, - pub top_p: Option, - pub stop: Option>, -} - -trait PromptFormatter { - fn render(&self, data: &PromptData) -> String; - - fn generation_params() -> GenerationParams { - return GenerationParams::default(); - } -} - -struct MinimalQwenPrompt; - -impl PromptFormatter for MinimalQwenPrompt { - fn render(&self, data: &PromptData) -> String { - let edit_history = self.fmt_edit_history(data); - let context = self.fmt_context(data); - - format!( - "{instructions}\n\n{edit_history}\n\n{context}", - instructions = MinimalQwenPrompt::INSTRUCTIONS, - edit_history = edit_history, - context = context - ) - } -} - -impl MinimalQwenPrompt { - const INSTRUCTIONS: &str = "You are a code completion assistant that analyzes edit history to identify and systematically complete incomplete refactorings or patterns across the entire codebase.\n"; - - fn fmt_edit_history(&self, data: &PromptData) -> String { - if data.events.is_empty() { - "(No edit history)\n\n".to_string() - } else { - let mut events_str = String::new(); - push_events(&mut events_str, &data.events); - format!( - "The following are the latest edits made by the user, from earlier to later.\n\n{}", - events_str - ) - } - } - - fn fmt_context(&self, data: &PromptData) -> String { - let mut context = String::new(); - let include_line_numbers = true; - - for related_file in &data.included_files { - writeln!(context, "<|file_sep|>{}", DiffPathFmt(&related_file.path)).unwrap(); - - if related_file.path == data.cursor_path { - write!(context, "<|fim_prefix|>").unwrap(); - write_excerpts( - &related_file.excerpts, - &[(data.cursor_point, "<|fim_suffix|>")], - related_file.max_row, - include_line_numbers, - &mut context, - ); - writeln!(context, "<|fim_middle|>").unwrap(); - } else { - write_excerpts( - &related_file.excerpts, - &[], - related_file.max_row, - include_line_numbers, - &mut context, - ); - } - } - context - } -} - -struct SeedCoder1120Prompt; - -impl PromptFormatter for SeedCoder1120Prompt { - fn render(&self, data: &PromptData) -> String { - let edit_history = self.fmt_edit_history(data); - let context = self.fmt_context(data); - - format!( - "# Edit History:\n{edit_history}\n\n{context}", - edit_history = edit_history, - context = context - ) - } - - fn generation_params() -> GenerationParams { - GenerationParams { - temperature: Some(0.2), - top_p: Some(0.9), - stop: Some(vec!["<[end_of_sentence]>".into()]), - } - } -} - -impl SeedCoder1120Prompt { - fn fmt_edit_history(&self, data: &PromptData) -> String { - if data.events.is_empty() { - "(No edit history)\n\n".to_string() - } else { - let mut events_str = String::new(); - push_events(&mut events_str, &data.events); - events_str - } - } - - fn fmt_context(&self, data: &PromptData) -> String { - let mut context = String::new(); - let include_line_numbers = true; - - for related_file in &data.included_files { - writeln!(context, "# Path: {}\n", DiffPathFmt(&related_file.path)).unwrap(); - - if related_file.path == data.cursor_path { - let fim_prompt = self.fmt_fim(&related_file, data.cursor_point); - context.push_str(&fim_prompt); - } else { - write_excerpts( - &related_file.excerpts, - &[], - related_file.max_row, - include_line_numbers, - &mut context, - ); - } - } - context - } - - fn fmt_fim(&self, file: &RelatedFile, cursor_point: Point) -> String { - let mut buf = String::new(); - const FIM_SUFFIX: &str = "<[fim-suffix]>"; - const FIM_PREFIX: &str = "<[fim-prefix]>"; - const FIM_MIDDLE: &str = "<[fim-middle]>"; - write!(buf, "{}", FIM_PREFIX).unwrap(); - write_excerpts( - &file.excerpts, - &[(cursor_point, FIM_SUFFIX)], - file.max_row, - true, - &mut buf, - ); - - // Swap prefix and suffix parts - let index = buf.find(FIM_SUFFIX).unwrap(); - let prefix = &buf[..index]; - let suffix = &buf[index..]; - - format!("{}{}{}", suffix, prefix, FIM_MIDDLE) - } -} diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 6e62cfa6f038671d595c5671de147cdc2125064d..c9237232e5e0bb6167fbeee8732d46ee584b080b 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -21,7 +21,6 @@ arrayvec.workspace = true brotli.workspace = true client.workspace = true cloud_llm_client.workspace = true -cloud_zeta2_prompt.workspace = true collections.workspace = true copilot.workspace = true credentials_provider.workspace = true @@ -50,8 +49,6 @@ semver.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -smol.workspace = true -strsim.workspace = true strum.workspace = true telemetry.workspace = true telemetry_events.workspace = true @@ -62,6 +59,7 @@ uuid.workspace = true workspace.workspace = true worktree.workspace = true zed_actions.workspace = true +zeta_prompt.workspace = true [dev-dependencies] clock = { workspace = true, features = ["test-support"] } diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 141fff3063b83d7e0003fddd6b4eba2d213d5fd5..b0d4a5f4d69c357fb0a153bee267a64dc0c465dd 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,14 +1,13 @@ use anyhow::Result; use arrayvec::ArrayVec; use client::{Client, EditPredictionUsage, UserStore}; -use cloud_llm_client::predict_edits_v3::{self, Event, PromptFormat}; +use cloud_llm_client::predict_edits_v3::{self, PromptFormat}; use cloud_llm_client::{ AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, EditPredictionRejectReason, EditPredictionRejection, MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST, MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsRequestTrigger, RejectEditPredictionsBodyRef, ZED_VERSION_HEADER_NAME, }; -use cloud_zeta2_prompt::DEFAULT_MAX_PROMPT_BYTES; use collections::{HashMap, HashSet}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use edit_prediction_context::EditPredictionExcerptOptions; @@ -16,10 +15,7 @@ use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, Rel use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; use futures::{ AsyncReadExt as _, FutureExt as _, StreamExt as _, - channel::{ - mpsc::{self, UnboundedReceiver}, - oneshot, - }, + channel::mpsc::{self, UnboundedReceiver}, select_biased, }; use gpui::BackgroundExecutor; @@ -58,8 +54,10 @@ mod onboarding_modal; pub mod open_ai_response; mod prediction; pub mod sweep_ai; + +#[cfg(any(test, feature = "test-support", feature = "eval-support"))] pub mod udiff; -mod xml_edits; + mod zed_edit_prediction_delegate; pub mod zeta1; pub mod zeta2; @@ -72,7 +70,6 @@ use crate::mercury::Mercury; use crate::onboarding_modal::ZedPredictModal; pub use crate::prediction::EditPrediction; pub use crate::prediction::EditPredictionId; -pub use crate::prediction::EditPredictionInputs; use crate::prediction::EditPredictionResult; pub use crate::sweep_ai::SweepAi; pub use telemetry_events::EditPredictionRating; @@ -112,7 +109,6 @@ pub const DEFAULT_OPTIONS: ZetaOptions = ZetaOptions { min_bytes: 128, target_before_cursor_over_total_bytes: 0.5, }, - max_prompt_bytes: DEFAULT_MAX_PROMPT_BYTES, prompt_format: PromptFormat::DEFAULT, }; @@ -162,7 +158,6 @@ pub struct EditPredictionStore { use_context: bool, options: ZetaOptions, update_required: bool, - debug_tx: Option>, #[cfg(feature = "eval-support")] eval_cache: Option>, edit_prediction_model: EditPredictionModel, @@ -183,10 +178,22 @@ pub enum EditPredictionModel { Mercury, } +pub struct EditPredictionModelInput { + project: Entity, + buffer: Entity, + snapshot: BufferSnapshot, + position: Anchor, + events: Vec>, + related_files: Arc<[RelatedFile]>, + recent_paths: VecDeque, + trigger: PredictEditsRequestTrigger, + diagnostic_search_range: Range, + debug_tx: Option>, +} + #[derive(Debug, Clone, PartialEq)] pub struct ZetaOptions { pub context: EditPredictionExcerptOptions, - pub max_prompt_bytes: usize, pub prompt_format: predict_edits_v3::PromptFormat, } @@ -194,7 +201,8 @@ pub struct ZetaOptions { pub enum DebugEvent { ContextRetrievalStarted(ContextRetrievalStartedDebugEvent), ContextRetrievalFinished(ContextRetrievalFinishedDebugEvent), - EditPredictionRequested(EditPredictionRequestedDebugEvent), + EditPredictionStarted(EditPredictionStartedDebugEvent), + EditPredictionFinished(EditPredictionFinishedDebugEvent), } #[derive(Debug)] @@ -212,27 +220,30 @@ pub struct ContextRetrievalFinishedDebugEvent { } #[derive(Debug)] -pub struct EditPredictionRequestedDebugEvent { - pub inputs: EditPredictionInputs, - pub retrieval_time: Duration, +pub struct EditPredictionStartedDebugEvent { pub buffer: WeakEntity, pub position: Anchor, - pub local_prompt: Result, - pub response_rx: oneshot::Receiver<(Result, Duration)>, + pub prompt: Option, +} + +#[derive(Debug)] +pub struct EditPredictionFinishedDebugEvent { + pub buffer: WeakEntity, + pub position: Anchor, + pub model_output: Option, } pub type RequestDebugInfo = predict_edits_v3::DebugInfo; struct ProjectState { - events: VecDeque>, + events: VecDeque>, last_event: Option, recent_paths: VecDeque, registered_buffers: HashMap, current_prediction: Option, next_pending_prediction_id: usize, pending_predictions: ArrayVec, - context_updates_tx: smol::channel::Sender<()>, - context_updates_rx: smol::channel::Receiver<()>, + debug_tx: Option>, last_prediction_refresh: Option<(EntityId, Instant)>, cancelled_predictions: HashSet, context: Entity, @@ -241,7 +252,7 @@ struct ProjectState { } impl ProjectState { - pub fn events(&self, cx: &App) -> Vec> { + pub fn events(&self, cx: &App) -> Vec> { self.events .iter() .cloned() @@ -376,7 +387,7 @@ impl LastEvent { &self, license_detection_watchers: &HashMap>, cx: &App, - ) -> Option> { + ) -> Option> { let path = buffer_path_with_id_fallback(&self.new_snapshot, cx); let old_path = buffer_path_with_id_fallback(&self.old_snapshot, cx); @@ -396,7 +407,7 @@ impl LastEvent { if path == old_path && diff.is_empty() { None } else { - Some(Arc::new(predict_edits_v3::Event::BufferChange { + Some(Arc::new(zeta_prompt::Event::BufferChange { old_path, path, diff, @@ -481,7 +492,6 @@ impl EditPredictionStore { }, ), update_required: false, - debug_tx: None, #[cfg(feature = "eval-support")] eval_cache: None, edit_prediction_model: EditPredictionModel::Zeta2, @@ -536,12 +546,6 @@ impl EditPredictionStore { self.eval_cache = Some(cache); } - pub fn debug_info(&mut self) -> mpsc::UnboundedReceiver { - let (debug_watch_tx, debug_watch_rx) = mpsc::unbounded(); - self.debug_tx = Some(debug_watch_tx); - debug_watch_rx - } - pub fn options(&self) -> &ZetaOptions { &self.options } @@ -560,15 +564,35 @@ impl EditPredictionStore { } } + pub fn edit_history_for_project( + &self, + project: &Entity, + ) -> Vec> { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events.iter().cloned().collect()) + .unwrap_or_default() + } + pub fn context_for_project<'a>( &'a self, project: &Entity, cx: &'a App, - ) -> &'a [RelatedFile] { + ) -> Arc<[RelatedFile]> { self.projects .get(&project.entity_id()) .map(|project| project.context.read(cx).related_files()) - .unwrap_or(&[]) + .unwrap_or_else(|| vec![].into()) + } + + pub fn context_for_project_with_buffers<'a>( + &'a self, + project: &Entity, + cx: &'a App, + ) -> Option)>> { + self.projects + .get(&project.entity_id()) + .map(|project| project.context.read(cx).related_files_with_buffers()) } pub fn usage(&self, cx: &App) -> Option { @@ -599,85 +623,21 @@ impl EditPredictionStore { cx: &mut Context, ) -> &mut ProjectState { let entity_id = project.entity_id(); - let (context_updates_tx, context_updates_rx) = smol::channel::unbounded(); self.projects .entry(entity_id) .or_insert_with(|| ProjectState { context: { let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(project, cx)); - cx.subscribe( - &related_excerpt_store, - move |this, _, event, _| match event { - RelatedExcerptStoreEvent::StartedRefresh => { - if let Some(debug_tx) = this.debug_tx.clone() { - debug_tx - .unbounded_send(DebugEvent::ContextRetrievalStarted( - ContextRetrievalStartedDebugEvent { - project_entity_id: entity_id, - timestamp: Instant::now(), - search_prompt: String::new(), - }, - )) - .ok(); - } - } - RelatedExcerptStoreEvent::FinishedRefresh { - cache_hit_count, - cache_miss_count, - mean_definition_latency, - max_definition_latency, - } => { - if let Some(debug_tx) = this.debug_tx.clone() { - debug_tx - .unbounded_send(DebugEvent::ContextRetrievalFinished( - ContextRetrievalFinishedDebugEvent { - project_entity_id: entity_id, - timestamp: Instant::now(), - metadata: vec![ - ( - "Cache Hits", - format!( - "{}/{}", - cache_hit_count, - cache_hit_count + cache_miss_count - ) - .into(), - ), - ( - "Max LSP Time", - format!( - "{} ms", - max_definition_latency.as_millis() - ) - .into(), - ), - ( - "Mean LSP Time", - format!( - "{} ms", - mean_definition_latency.as_millis() - ) - .into(), - ), - ], - }, - )) - .ok(); - } - if let Some(project_state) = this.projects.get(&entity_id) { - project_state.context_updates_tx.send_blocking(()).ok(); - } - } - }, - ) + cx.subscribe(&related_excerpt_store, move |this, _, event, _| { + this.handle_excerpt_store_event(entity_id, event); + }) .detach(); related_excerpt_store }, events: VecDeque::new(), last_event: None, recent_paths: VecDeque::new(), - context_updates_rx, - context_updates_tx, + debug_tx: None, registered_buffers: HashMap::default(), current_prediction: None, cancelled_predictions: HashSet::default(), @@ -689,12 +649,79 @@ impl EditPredictionStore { }) } - pub fn project_context_updates( - &self, + pub fn remove_project(&mut self, project: &Entity) { + self.projects.remove(&project.entity_id()); + } + + fn handle_excerpt_store_event( + &mut self, + project_entity_id: EntityId, + event: &RelatedExcerptStoreEvent, + ) { + if let Some(project_state) = self.projects.get(&project_entity_id) { + if let Some(debug_tx) = project_state.debug_tx.clone() { + match event { + RelatedExcerptStoreEvent::StartedRefresh => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalStarted( + ContextRetrievalStartedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + search_prompt: String::new(), + }, + )) + .ok(); + } + RelatedExcerptStoreEvent::FinishedRefresh { + cache_hit_count, + cache_miss_count, + mean_definition_latency, + max_definition_latency, + } => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalFinished( + ContextRetrievalFinishedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + metadata: vec![ + ( + "Cache Hits", + format!( + "{}/{}", + cache_hit_count, + cache_hit_count + cache_miss_count + ) + .into(), + ), + ( + "Max LSP Time", + format!("{} ms", max_definition_latency.as_millis()) + .into(), + ), + ( + "Mean LSP Time", + format!("{} ms", mean_definition_latency.as_millis()) + .into(), + ), + ], + }, + )) + .ok(); + } + } + } + } + } + + pub fn debug_info( + &mut self, project: &Entity, - ) -> Option> { - let project_state = self.projects.get(&project.entity_id())?; - Some(project_state.context_updates_rx.clone()) + cx: &mut Context, + ) -> mpsc::UnboundedReceiver { + let project_state = self.get_or_init_project(project, cx); + let (debug_watch_tx, debug_watch_rx) = mpsc::unbounded(); + project_state.debug_tx = Some(debug_watch_tx); + debug_watch_rx } fn handle_project_event( @@ -1348,6 +1375,7 @@ impl EditPredictionStore { let project_state = self.projects.get(&project.entity_id()).unwrap(); let events = project_state.events(cx); let has_events = !events.is_empty(); + let debug_tx = project_state.debug_tx.clone(); let snapshot = active_buffer.read(cx).snapshot(); let cursor_point = position.to_point(&snapshot); @@ -1357,55 +1385,29 @@ impl EditPredictionStore { Point::new(diagnostic_search_start, 0)..Point::new(diagnostic_search_end, 0); let related_files = if self.use_context { - self.context_for_project(&project, cx).to_vec() + self.context_for_project(&project, cx) } else { - Vec::new() + Vec::new().into() + }; + + let inputs = EditPredictionModelInput { + project: project.clone(), + buffer: active_buffer.clone(), + snapshot: snapshot.clone(), + position, + events, + related_files, + recent_paths: project_state.recent_paths.clone(), + trigger, + diagnostic_search_range: diagnostic_search_range.clone(), + debug_tx, }; let task = match self.edit_prediction_model { - EditPredictionModel::Zeta1 => zeta1::request_prediction_with_zeta1( - self, - &project, - &active_buffer, - snapshot.clone(), - position, - events, - trigger, - cx, - ), - EditPredictionModel::Zeta2 => zeta2::request_prediction_with_zeta2( - self, - &project, - &active_buffer, - snapshot.clone(), - position, - events, - related_files, - trigger, - cx, - ), - EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep( - &project, - &active_buffer, - snapshot.clone(), - position, - events, - &project_state.recent_paths, - related_files, - diagnostic_search_range.clone(), - cx, - ), - EditPredictionModel::Mercury => self.mercury.request_prediction( - &project, - &active_buffer, - snapshot.clone(), - position, - events, - &project_state.recent_paths, - related_files, - diagnostic_search_range.clone(), - cx, - ), + EditPredictionModel::Zeta1 => zeta1::request_prediction_with_zeta1(self, inputs, cx), + EditPredictionModel::Zeta2 => zeta2::request_prediction_with_zeta2(self, inputs, cx), + EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx), + EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), }; cx.spawn(async move |this, cx| { @@ -1706,6 +1708,20 @@ impl EditPredictionStore { } } + #[cfg(feature = "eval-support")] + pub fn set_context_for_buffer( + &mut self, + project: &Entity, + related_files: Vec, + cx: &mut Context, + ) { + self.get_or_init_project(project, cx) + .context + .update(cx, |store, _| { + store.set_related_files(related_files); + }); + } + fn is_file_open_source( &self, project: &Entity, @@ -1729,14 +1745,14 @@ impl EditPredictionStore { self.data_collection_choice.is_enabled() && self.is_file_open_source(project, file, cx) } - fn can_collect_events(&self, events: &[Arc]) -> bool { + fn can_collect_events(&self, events: &[Arc]) -> bool { if !self.data_collection_choice.is_enabled() { return false; } events.iter().all(|event| { matches!( event.as_ref(), - Event::BufferChange { + zeta_prompt::Event::BufferChange { in_open_source_repo: true, .. } diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 0b7e289bb32b5a10c32a4bd34f118d7cb6c7d43c..f6465b14cbd1b3357349071bc5eda399253b5328 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::zeta1::MAX_EVENT_TOKENS; +use crate::{udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS}; use client::{UserStore, test::FakeServer}; use clock::{FakeSystemClock, ReplicaId}; use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; @@ -7,7 +7,6 @@ use cloud_llm_client::{ EditPredictionRejectReason, EditPredictionRejection, PredictEditsBody, PredictEditsResponse, RejectEditPredictionsBody, }; -use edit_prediction_context::Line; use futures::{ AsyncReadExt, StreamExt, channel::{mpsc, oneshot}, @@ -28,6 +27,7 @@ use settings::SettingsStore; use std::{path::Path, sync::Arc, time::Duration}; use util::{path, rel_path::rel_path}; use uuid::Uuid; +use zeta_prompt::ZetaPromptInput; use crate::{BufferEditPrediction, EditPredictionId, EditPredictionStore, REJECT_REQUEST_DEBOUNCE}; @@ -65,18 +65,21 @@ async fn test_current_state(cx: &mut TestAppContext) { ep_store.update(cx, |ep_store, cx| { ep_store.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx) }); - let (_request, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); respond_tx - .send(model_response(indoc! {r" - --- a/root/1.txt - +++ b/root/1.txt - @@ ... @@ - Hello! - -How - +How are you? - Bye - "})) + .send(model_response( + request, + indoc! {r" + --- a/root/1.txt + +++ b/root/1.txt + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) .unwrap(); cx.run_until_parked(); @@ -120,16 +123,20 @@ async fn test_current_state(cx: &mut TestAppContext) { }); }); - let (_request, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); respond_tx - .send(model_response(indoc! {r#" - --- a/root/2.txt - +++ b/root/2.txt - Hola! - -Como - +Como estas? - Adios - "#})) + .send(model_response( + request, + indoc! {r#" + --- a/root/2.txt + +++ b/root/2.txt + @@ ... @@ + Hola! + -Como + +Como estas? + Adios + "#}, + )) .unwrap(); cx.run_until_parked(); @@ -186,7 +193,7 @@ async fn test_simple_request(cx: &mut TestAppContext) { ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); // TODO Put back when we have a structured request again // assert_eq!( @@ -202,15 +209,18 @@ async fn test_simple_request(cx: &mut TestAppContext) { // ); respond_tx - .send(model_response(indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are you? - Bye - "})) + .send(model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) .unwrap(); let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); @@ -276,15 +286,18 @@ async fn test_request_events(cx: &mut TestAppContext) { ); respond_tx - .send(model_response(indoc! {r#" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are you? - Bye - "#})) + .send(model_response( + request, + indoc! {r#" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "#}, + )) .unwrap(); let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); @@ -324,18 +337,8 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - const NO_OP_DIFF: &str = indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How - Bye - "}; - - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let response = model_response(NO_OP_DIFF); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let response = model_response(request, ""); let id = response.id.clone(); respond_tx.send(response).unwrap(); @@ -389,13 +392,13 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); buffer.update(cx, |buffer, cx| { buffer.set_text("Hello!\nHow are you?\nBye", cx); }); - let response = model_response(SIMPLE_DIFF); + let response = model_response(request, SIMPLE_DIFF); let id = response.id.clone(); respond_tx.send(response).unwrap(); @@ -459,8 +462,8 @@ async fn test_replace_current(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let first_response = model_response(SIMPLE_DIFF); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); let first_id = first_response.id.clone(); respond_tx.send(first_response).unwrap(); @@ -482,8 +485,8 @@ async fn test_replace_current(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let second_response = model_response(SIMPLE_DIFF); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let second_response = model_response(request, SIMPLE_DIFF); let second_id = second_response.id.clone(); respond_tx.send(second_response).unwrap(); @@ -541,8 +544,8 @@ async fn test_current_preferred(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let first_response = model_response(SIMPLE_DIFF); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); let first_id = first_response.id.clone(); respond_tx.send(first_response).unwrap(); @@ -564,17 +567,20 @@ async fn test_current_preferred(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); // worse than current prediction - let second_response = model_response(indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are - Bye - "}); + let second_response = model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are + Bye + "}, + ); let second_id = second_response.id.clone(); respond_tx.send(second_response).unwrap(); @@ -633,19 +639,19 @@ async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_first) = requests.predict.next().await.unwrap(); + let (request1, respond_first) = requests.predict.next().await.unwrap(); ep_store.update(cx, |ep_store, cx| { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_second) = requests.predict.next().await.unwrap(); + let (request, respond_second) = requests.predict.next().await.unwrap(); // wait for throttle cx.run_until_parked(); // second responds first - let second_response = model_response(SIMPLE_DIFF); + let second_response = model_response(request, SIMPLE_DIFF); let second_id = second_response.id.clone(); respond_second.send(second_response).unwrap(); @@ -663,7 +669,7 @@ async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { ); }); - let first_response = model_response(SIMPLE_DIFF); + let first_response = model_response(request1, SIMPLE_DIFF); let first_id = first_response.id.clone(); respond_first.send(first_response).unwrap(); @@ -724,13 +730,13 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_first) = requests.predict.next().await.unwrap(); + let (request1, respond_first) = requests.predict.next().await.unwrap(); ep_store.update(cx, |ep_store, cx| { ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_second) = requests.predict.next().await.unwrap(); + let (request2, respond_second) = requests.predict.next().await.unwrap(); // wait for throttle, so requests are sent cx.run_until_parked(); @@ -754,9 +760,9 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { // wait for throttle cx.run_until_parked(); - let (_, respond_third) = requests.predict.next().await.unwrap(); + let (request3, respond_third) = requests.predict.next().await.unwrap(); - let first_response = model_response(SIMPLE_DIFF); + let first_response = model_response(request1, SIMPLE_DIFF); let first_id = first_response.id.clone(); respond_first.send(first_response).unwrap(); @@ -774,7 +780,7 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { ); }); - let cancelled_response = model_response(SIMPLE_DIFF); + let cancelled_response = model_response(request2, SIMPLE_DIFF); let cancelled_id = cancelled_response.id.clone(); respond_second.send(cancelled_response).unwrap(); @@ -792,7 +798,7 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { ); }); - let third_response = model_response(SIMPLE_DIFF); + let third_response = model_response(request3, SIMPLE_DIFF); let third_response_id = third_response.id.clone(); respond_third.send(third_response).unwrap(); @@ -1036,7 +1042,24 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { // ); // } -fn model_response(text: &str) -> open_ai::Response { +// Generate a model response that would apply the given diff to the active file. +fn model_response(request: open_ai::Request, diff_to_apply: &str) -> open_ai::Response { + let prompt = match &request.messages[0] { + open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(content), + } => content, + _ => panic!("unexpected request {request:?}"), + }; + + let open = "\n"; + let close = ""; + let cursor = "<|user_cursor|>"; + + let start_ix = open.len() + prompt.find(open).unwrap(); + let end_ix = start_ix + &prompt[start_ix..].find(close).unwrap(); + let excerpt = prompt[start_ix..end_ix].replace(cursor, ""); + let new_excerpt = apply_diff_to_string(diff_to_apply, &excerpt).unwrap(); + open_ai::Response { id: Uuid::new_v4().to_string(), object: "response".into(), @@ -1045,7 +1068,7 @@ fn model_response(text: &str) -> open_ai::Response { choices: vec![open_ai::Choice { index: 0, message: open_ai::RequestMessage::Assistant { - content: Some(open_ai::MessageContent::Plain(text.to_string())), + content: Some(open_ai::MessageContent::Plain(new_excerpt)), tool_calls: vec![], }, finish_reason: None, @@ -1160,20 +1183,19 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { .read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx)) .await; - let completion = EditPrediction { + let prediction = EditPrediction { edits, edit_preview, buffer: buffer.clone(), snapshot: cx.read(|cx| buffer.read(cx).snapshot()), id: EditPredictionId("the-id".into()), - inputs: EditPredictionInputs { + inputs: ZetaPromptInput { events: Default::default(), - included_files: Default::default(), - cursor_point: cloud_llm_client::predict_edits_v3::Point { - line: Line(0), - column: 0, - }, + related_files: Default::default(), cursor_path: Path::new("").into(), + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, + cursor_offset_in_excerpt: 0, }, buffer_snapshotted_at: Instant::now(), response_received_at: Instant::now(), @@ -1182,7 +1204,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { cx.update(|cx| { assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1192,7 +1214,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1202,7 +1224,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.undo(cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1212,7 +1234,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1222,7 +1244,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1232,7 +1254,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1242,7 +1264,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1252,7 +1274,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); assert_eq!( from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), &buffer, cx ), @@ -1260,7 +1282,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { ); buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); - assert_eq!(completion.interpolate(&buffer.read(cx).snapshot()), None); + assert_eq!(prediction.interpolate(&buffer.read(cx).snapshot()), None); }) } diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index 40c0fdfac021f937df5172fd423d3b6bfc5f8146..f3a3afc53fc5e175fdbda2dc6b5867da6fd38feb 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -1,20 +1,17 @@ use anyhow::{Context as _, Result}; -use cloud_llm_client::predict_edits_v3::Event; use credentials_provider::CredentialsProvider; -use edit_prediction_context::RelatedFile; use futures::{AsyncReadExt as _, FutureExt, future::Shared}; use gpui::{ - App, AppContext as _, Entity, Task, + App, AppContext as _, Task, http_client::{self, AsyncBody, Method}, }; -use language::{Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToPoint as _}; -use project::{Project, ProjectPath}; -use std::{ - collections::VecDeque, fmt::Write as _, mem, ops::Range, path::Path, sync::Arc, time::Instant, -}; +use language::{OffsetRangeExt as _, ToOffset, ToPoint as _}; +use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; +use zeta_prompt::ZetaPromptInput; use crate::{ - EditPredictionId, EditPredictionInputs, open_ai_response::text_from_response, + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, open_ai_response::text_from_response, prediction::EditPredictionResult, }; @@ -38,16 +35,17 @@ impl Mercury { store_api_token_in_keychain(api_token, cx) } - pub fn request_prediction( + pub(crate) fn request_prediction( &self, - _project: &Entity, - active_buffer: &Entity, - snapshot: BufferSnapshot, - position: language::Anchor, - events: Vec>, - _recent_paths: &VecDeque, - related_files: Vec, - _diagnostic_search_range: Range, + EditPredictionModelInput { + buffer, + snapshot, + position, + events, + related_files, + debug_tx, + .. + }: EditPredictionModelInput, cx: &mut App, ) -> Task>> { let Some(api_token) = self.api_token.clone().now_or_never().flatten() else { @@ -62,6 +60,7 @@ impl Mercury { let http_client = cx.http_client(); let cursor_point = position.to_point(&snapshot); let buffer_snapshotted_at = Instant::now(); + let active_buffer = buffer.clone(); let result = cx.background_spawn(async move { let (editable_range, context_range) = @@ -72,39 +71,39 @@ impl Mercury { MAX_REWRITE_TOKENS, ); - let offset_range = editable_range.to_offset(&snapshot); - let prompt = build_prompt( - &events, - &related_files, - &snapshot, - full_path.as_ref(), - cursor_point, - editable_range, - context_range.clone(), - ); - - let inputs = EditPredictionInputs { - events: events, - included_files: vec![cloud_llm_client::predict_edits_v3::RelatedFile { - path: full_path.clone(), - max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row), - excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt { - start_line: cloud_llm_client::predict_edits_v3::Line( - context_range.start.row, - ), - text: snapshot - .text_for_range(context_range.clone()) - .collect::() - .into(), - }], - }], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - column: cursor_point.column, - line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row), - }, + let context_offset_range = context_range.to_offset(&snapshot); + + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = zeta_prompt::ZetaPromptInput { + events, + related_files, + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) + - context_range.start.to_offset(&snapshot), cursor_path: full_path.clone(), + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_offset_range.start + - context_offset_range.start) + ..(editable_offset_range.end - context_offset_range.start), }; + let prompt = build_prompt(&inputs); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: active_buffer.downgrade(), + prompt: Some(prompt.clone()), + position, + }, + )) + .ok(); + } + let request_body = open_ai::Request { model: "mercury-coder".into(), messages: vec![open_ai::RequestMessage::User { @@ -160,6 +159,18 @@ impl Mercury { let id = mem::take(&mut response.id); let response_str = text_from_response(response).unwrap_or_default(); + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: active_buffer.downgrade(), + model_output: Some(response_str.clone()), + position, + }, + )) + .ok(); + } + let response_str = response_str.strip_prefix("```\n").unwrap_or(&response_str); let response_str = response_str.strip_suffix("\n```").unwrap_or(&response_str); @@ -168,15 +179,16 @@ impl Mercury { if response_str != NO_PREDICTION_OUTPUT { let old_text = snapshot - .text_for_range(offset_range.clone()) + .text_for_range(editable_offset_range.clone()) .collect::(); edits.extend( language::text_diff(&old_text, &response_str) .into_iter() .map(|(range, text)| { ( - snapshot.anchor_after(offset_range.start + range.start) - ..snapshot.anchor_before(offset_range.start + range.end), + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot + .anchor_before(editable_offset_range.start + range.end), text, ) }), @@ -186,8 +198,6 @@ impl Mercury { anyhow::Ok((id, edits, snapshot, response_received_at, inputs)) }); - let buffer = active_buffer.clone(); - cx.spawn(async move |cx| { let (id, edits, old_snapshot, response_received_at, inputs) = result.await.context("Mercury edit prediction failed")?; @@ -208,15 +218,7 @@ impl Mercury { } } -fn build_prompt( - events: &[Arc], - related_files: &[RelatedFile], - cursor_buffer: &BufferSnapshot, - cursor_buffer_path: &Path, - cursor_point: Point, - editable_range: Range, - context_range: Range, -) -> String { +fn build_prompt(inputs: &ZetaPromptInput) -> String { const RECENTLY_VIEWED_SNIPPETS_START: &str = "<|recently_viewed_code_snippets|>\n"; const RECENTLY_VIEWED_SNIPPETS_END: &str = "<|/recently_viewed_code_snippets|>\n"; const RECENTLY_VIEWED_SNIPPET_START: &str = "<|recently_viewed_code_snippet|>\n"; @@ -237,14 +239,14 @@ fn build_prompt( &mut prompt, RECENTLY_VIEWED_SNIPPETS_START..RECENTLY_VIEWED_SNIPPETS_END, |prompt| { - for related_file in related_files { + for related_file in inputs.related_files.iter() { for related_excerpt in &related_file.excerpts { push_delimited( prompt, RECENTLY_VIEWED_SNIPPET_START..RECENTLY_VIEWED_SNIPPET_END, |prompt| { prompt.push_str(CODE_SNIPPET_FILE_PATH_PREFIX); - prompt.push_str(related_file.path.path.as_unix_str()); + prompt.push_str(related_file.path.to_string_lossy().as_ref()); prompt.push('\n'); prompt.push_str(&related_excerpt.text.to_string()); }, @@ -259,21 +261,22 @@ fn build_prompt( CURRENT_FILE_CONTENT_START..CURRENT_FILE_CONTENT_END, |prompt| { prompt.push_str(CURRENT_FILE_PATH_PREFIX); - prompt.push_str(cursor_buffer_path.as_os_str().to_string_lossy().as_ref()); + prompt.push_str(inputs.cursor_path.as_os_str().to_string_lossy().as_ref()); prompt.push('\n'); - let prefix_range = context_range.start..editable_range.start; - let suffix_range = editable_range.end..context_range.end; - - prompt.extend(cursor_buffer.text_for_range(prefix_range)); + prompt.push_str(&inputs.cursor_excerpt[0..inputs.editable_range_in_excerpt.start]); push_delimited(prompt, CODE_TO_EDIT_START..CODE_TO_EDIT_END, |prompt| { - let range_before_cursor = editable_range.start..cursor_point; - let range_after_cursor = cursor_point..editable_range.end; - prompt.extend(cursor_buffer.text_for_range(range_before_cursor)); + prompt.push_str( + &inputs.cursor_excerpt + [inputs.editable_range_in_excerpt.start..inputs.cursor_offset_in_excerpt], + ); prompt.push_str(CURSOR_TAG); - prompt.extend(cursor_buffer.text_for_range(range_after_cursor)); + prompt.push_str( + &inputs.cursor_excerpt + [inputs.cursor_offset_in_excerpt..inputs.editable_range_in_excerpt.end], + ); }); - prompt.extend(cursor_buffer.text_for_range(suffix_range)); + prompt.push_str(&inputs.cursor_excerpt[inputs.editable_range_in_excerpt.end..]); }, ); @@ -281,8 +284,8 @@ fn build_prompt( &mut prompt, EDIT_DIFF_HISTORY_START..EDIT_DIFF_HISTORY_END, |prompt| { - for event in events { - writeln!(prompt, "{event}").unwrap(); + for event in inputs.events.iter() { + zeta_prompt::write_event(prompt, &event); } }, ); diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index 8aa2a8218568a99404cc9aceff36b84127700152..c63640ccd0e1815b32f736e8a0fee8d75d124df1 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -1,6 +1,5 @@ use std::{ ops::Range, - path::Path, sync::Arc, time::{Duration, Instant}, }; @@ -9,7 +8,7 @@ use cloud_llm_client::EditPredictionRejectReason; use edit_prediction_types::interpolate_edits; use gpui::{AsyncApp, Entity, SharedString}; use language::{Anchor, Buffer, BufferSnapshot, EditPreview, TextBufferSnapshot}; -use serde::Serialize; +use zeta_prompt::ZetaPromptInput; #[derive(Clone, Default, Debug, PartialEq, Eq, Hash)] pub struct EditPredictionId(pub SharedString); @@ -40,7 +39,7 @@ impl EditPredictionResult { edits: Arc<[(Range, Arc)]>, buffer_snapshotted_at: Instant, response_received_at: Instant, - inputs: EditPredictionInputs, + inputs: ZetaPromptInput, cx: &mut AsyncApp, ) -> Self { if edits.is_empty() { @@ -94,15 +93,7 @@ pub struct EditPrediction { pub buffer: Entity, pub buffer_snapshotted_at: Instant, pub response_received_at: Instant, - pub inputs: EditPredictionInputs, -} - -#[derive(Debug, Clone, Serialize)] -pub struct EditPredictionInputs { - pub events: Vec>, - pub included_files: Vec, - pub cursor_point: cloud_llm_client::predict_edits_v3::Point, - pub cursor_path: Arc, + pub inputs: zeta_prompt::ZetaPromptInput, } impl EditPrediction { @@ -133,9 +124,12 @@ impl std::fmt::Debug for EditPrediction { #[cfg(test)] mod tests { + use std::path::Path; + use super::*; use gpui::{App, Entity, TestAppContext, prelude::*}; use language::{Buffer, ToOffset as _}; + use zeta_prompt::ZetaPromptInput; #[gpui::test] async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { @@ -154,14 +148,13 @@ mod tests { snapshot: cx.read(|cx| buffer.read(cx).snapshot()), buffer: buffer.clone(), edit_preview, - inputs: EditPredictionInputs { + inputs: ZetaPromptInput { events: vec![], - included_files: vec![], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - line: cloud_llm_client::predict_edits_v3::Line(0), - column: 0, - }, + related_files: vec![].into(), cursor_path: Path::new("path.txt").into(), + cursor_offset_in_excerpt: 0, + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, }, buffer_snapshotted_at: Instant::now(), response_received_at: Instant::now(), diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index 4bb014c640cb489db29c800835a58febf91a7270..f65749ceadf6e05fc3b56838c03234b2f83dc51e 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -1,26 +1,21 @@ use anyhow::{Context as _, Result}; -use cloud_llm_client::predict_edits_v3::Event; use credentials_provider::CredentialsProvider; -use edit_prediction_context::RelatedFile; use futures::{AsyncReadExt as _, FutureExt, future::Shared}; use gpui::{ - App, AppContext as _, Entity, Task, + App, AppContext as _, Task, http_client::{self, AsyncBody, Method}, }; -use language::{Buffer, BufferSnapshot, Point, ToOffset as _, ToPoint as _}; +use language::{Point, ToOffset as _}; use lsp::DiagnosticSeverity; -use project::{Project, ProjectPath}; use serde::{Deserialize, Serialize}; use std::{ - collections::VecDeque, fmt::{self, Write as _}, - ops::Range, path::Path, sync::Arc, time::Instant, }; -use crate::{EditPredictionId, EditPredictionInputs, prediction::EditPredictionResult}; +use crate::{EditPredictionId, EditPredictionModelInput, prediction::EditPredictionResult}; const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; @@ -44,40 +39,34 @@ impl SweepAi { pub fn request_prediction_with_sweep( &self, - project: &Entity, - active_buffer: &Entity, - snapshot: BufferSnapshot, - position: language::Anchor, - events: Vec>, - recent_paths: &VecDeque, - related_files: Vec, - diagnostic_search_range: Range, + inputs: EditPredictionModelInput, cx: &mut App, ) -> Task>> { let debug_info = self.debug_info.clone(); let Some(api_token) = self.api_token.clone().now_or_never().flatten() else { return Task::ready(Ok(None)); }; - let full_path: Arc = snapshot + let full_path: Arc = inputs + .snapshot .file() .map(|file| file.full_path(cx)) .unwrap_or_else(|| "untitled".into()) .into(); - let project_file = project::File::from_dyn(snapshot.file()); + let project_file = project::File::from_dyn(inputs.snapshot.file()); let repo_name = project_file .map(|file| file.worktree.read(cx).root_name_str()) .unwrap_or("untitled") .into(); - let offset = position.to_offset(&snapshot); + let offset = inputs.position.to_offset(&inputs.snapshot); - let recent_buffers = recent_paths.iter().cloned(); + let recent_buffers = inputs.recent_paths.iter().cloned(); let http_client = cx.http_client(); let recent_buffer_snapshots = recent_buffers .filter_map(|project_path| { - let buffer = project.read(cx).get_open_buffer(&project_path, cx)?; - if active_buffer == &buffer { + let buffer = inputs.project.read(cx).get_open_buffer(&project_path, cx)?; + if inputs.buffer == buffer { None } else { Some(buffer.read(cx).snapshot()) @@ -86,14 +75,13 @@ impl SweepAi { .take(3) .collect::>(); - let cursor_point = position.to_point(&snapshot); let buffer_snapshotted_at = Instant::now(); let result = cx.background_spawn(async move { - let text = snapshot.text(); + let text = inputs.snapshot.text(); let mut recent_changes = String::new(); - for event in &events { + for event in &inputs.events { write_event(event.as_ref(), &mut recent_changes).unwrap(); } @@ -122,20 +110,23 @@ impl SweepAi { }) .collect::>(); - let retrieval_chunks = related_files + let retrieval_chunks = inputs + .related_files .iter() .flat_map(|related_file| { related_file.excerpts.iter().map(|excerpt| FileChunk { - file_path: related_file.path.path.as_unix_str().to_string(), - start_line: excerpt.point_range.start.row as usize, - end_line: excerpt.point_range.end.row as usize, + file_path: related_file.path.to_string_lossy().to_string(), + start_line: excerpt.row_range.start as usize, + end_line: excerpt.row_range.end as usize, content: excerpt.text.to_string(), timestamp: None, }) }) .collect(); - let diagnostic_entries = snapshot.diagnostics_in_range(diagnostic_search_range, false); + let diagnostic_entries = inputs + .snapshot + .diagnostics_in_range(inputs.diagnostic_search_range, false); let mut diagnostic_content = String::new(); let mut diagnostic_count = 0; @@ -195,21 +186,14 @@ impl SweepAi { serde_json::to_writer(writer, &request_body)?; let body: AsyncBody = buf.into(); - let inputs = EditPredictionInputs { - events, - included_files: vec![cloud_llm_client::predict_edits_v3::RelatedFile { - path: full_path.clone(), - max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row), - excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt { - start_line: cloud_llm_client::predict_edits_v3::Line(0), - text: request_body.file_contents.into(), - }], - }], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - column: cursor_point.column, - line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row), - }, + let ep_inputs = zeta_prompt::ZetaPromptInput { + events: inputs.events, + related_files: inputs.related_files.clone(), cursor_path: full_path.clone(), + cursor_excerpt: request_body.file_contents.into(), + // we actually don't know + editable_range_in_excerpt: 0..inputs.snapshot.len(), + cursor_offset_in_excerpt: request_body.cursor_position, }; let request = http_client::Request::builder() @@ -237,15 +221,20 @@ impl SweepAi { let response: AutocompleteResponse = serde_json::from_slice(&body)?; - let old_text = snapshot + let old_text = inputs + .snapshot .text_for_range(response.start_index..response.end_index) .collect::(); let edits = language::text_diff(&old_text, &response.completion) .into_iter() .map(|(range, text)| { ( - snapshot.anchor_after(response.start_index + range.start) - ..snapshot.anchor_before(response.start_index + range.end), + inputs + .snapshot + .anchor_after(response.start_index + range.start) + ..inputs + .snapshot + .anchor_before(response.start_index + range.end), text, ) }) @@ -254,13 +243,13 @@ impl SweepAi { anyhow::Ok(( response.autocomplete_id, edits, - snapshot, + inputs.snapshot, response_received_at, - inputs, + ep_inputs, )) }); - let buffer = active_buffer.clone(); + let buffer = inputs.buffer.clone(); cx.spawn(async move |cx| { let (id, edits, old_snapshot, response_received_at, inputs) = result.await?; @@ -403,12 +392,9 @@ struct AdditionalCompletion { pub finish_reason: Option, } -fn write_event( - event: &cloud_llm_client::predict_edits_v3::Event, - f: &mut impl fmt::Write, -) -> fmt::Result { +fn write_event(event: &zeta_prompt::Event, f: &mut impl fmt::Write) -> fmt::Result { match event { - cloud_llm_client::predict_edits_v3::Event::BufferChange { + zeta_prompt::Event::BufferChange { old_path, path, diff, diff --git a/crates/edit_prediction/src/udiff.rs b/crates/edit_prediction/src/udiff.rs index 5ae029c6c16c2c6b6d0c2451cc961e8399a64a8f..b9cf564c16d68a98baa1986333f2bfd767c6a24b 100644 --- a/crates/edit_prediction/src/udiff.rs +++ b/crates/edit_prediction/src/udiff.rs @@ -14,68 +14,18 @@ use anyhow::anyhow; use collections::HashMap; use gpui::AsyncApp; use gpui::Entity; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, TextBufferSnapshot}; +use language::{Anchor, Buffer, OffsetRangeExt as _, TextBufferSnapshot}; use project::Project; -pub async fn parse_diff<'a>( - diff_str: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - let mut diff = DiffParser::new(diff_str); - let mut edited_buffer = None; - let mut edits = Vec::new(); - - while let Some(event) = diff.next()? { - match event { - DiffEvent::Hunk { - path: file_path, - hunk, - } => { - let (buffer, ranges) = match edited_buffer { - None => { - edited_buffer = get_buffer(&Path::new(file_path.as_ref())); - edited_buffer - .as_ref() - .context("Model tried to edit a file that wasn't included")? - } - Some(ref current) => current, - }; - - edits.extend( - resolve_hunk_edits_in_buffer(hunk, &buffer.text, ranges) - .with_context(|| format!("Diff:\n{diff_str}"))?, - ); - } - DiffEvent::FileEnd { renamed_to } => { - let (buffer, _) = edited_buffer - .take() - .context("Got a FileEnd event before an Hunk event")?; - - if renamed_to.is_some() { - anyhow::bail!("edit predictions cannot rename files"); - } - - if diff.next()?.is_some() { - anyhow::bail!("Edited more than one file"); - } - - return Ok((buffer, edits)); - } - } - } - - Err(anyhow::anyhow!("No EOF")) -} - -#[derive(Debug)] -pub struct OpenedBuffers<'a>(#[allow(unused)] HashMap, Entity>); +#[derive(Clone, Debug)] +pub struct OpenedBuffers(#[allow(unused)] HashMap>); #[must_use] -pub async fn apply_diff<'a>( - diff_str: &'a str, +pub async fn apply_diff( + diff_str: &str, project: &Entity, cx: &mut AsyncApp, -) -> Result> { +) -> Result { let mut included_files = HashMap::default(); for line in diff_str.lines() { @@ -94,7 +44,7 @@ pub async fn apply_diff<'a>( })?? .await?; - included_files.insert(path, buffer); + included_files.insert(path.to_string(), buffer); } } @@ -113,7 +63,7 @@ pub async fn apply_diff<'a>( let (buffer, ranges) = match current_file { None => { let buffer = included_files - .get_mut(&file_path) + .get_mut(file_path.as_ref()) .expect("Opened all files in diff"); current_file = Some((buffer, ranges.as_slice())); @@ -167,6 +117,29 @@ pub async fn apply_diff<'a>( Ok(OpenedBuffers(included_files)) } +pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result { + let mut diff = DiffParser::new(diff_str); + + let mut text = text.to_string(); + + while let Some(event) = diff.next()? { + match event { + DiffEvent::Hunk { hunk, .. } => { + let hunk_offset = text + .find(&hunk.context) + .ok_or_else(|| anyhow!("couldn't result hunk {:?}", hunk.context))?; + for edit in hunk.edits.iter().rev() { + let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end); + text.replace_range(range, &edit.text); + } + } + DiffEvent::FileEnd { .. } => {} + } + } + + Ok(text) +} + struct PatchFile<'a> { old_path: Cow<'a, str>, new_path: Cow<'a, str>, @@ -492,7 +465,6 @@ mod tests { use super::*; use gpui::TestAppContext; use indoc::indoc; - use language::Point; use pretty_assertions::assert_eq; use project::{FakeFs, Project}; use serde_json::json; @@ -817,137 +789,6 @@ mod tests { }); } - #[gpui::test] - async fn test_apply_diff_non_unique(cx: &mut TestAppContext) { - let fs = init_test(cx); - - let buffer_1_text = indoc! {r#" - one - two - three - four - five - one - two - three - four - five - "# }; - - fs.insert_tree( - path!("/root"), - json!({ - "file1": buffer_1_text, - }), - ) - .await; - - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/file1"), cx) - }) - .await - .unwrap(); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 - one - two - -three - +3 - four - five - "#}; - - let final_text = indoc! {r#" - one - two - three - four - five - one - two - 3 - four - five - "#}; - - apply_diff(diff, &project, &mut cx.to_async()) - .await - .expect_err("Non-unique edits should fail"); - - let ranges = [buffer_snapshot.anchor_before(Point::new(1, 0)) - ..buffer_snapshot.anchor_after(buffer_snapshot.max_point())]; - - let (edited_snapshot, edits) = parse_diff(diff, |_path| Some((&buffer_snapshot, &ranges))) - .await - .unwrap(); - - assert_eq!(edited_snapshot.remote_id(), buffer_snapshot.remote_id()); - buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - assert_eq!(buffer.text(), final_text); - }); - } - - #[gpui::test] - async fn test_parse_diff_with_edits_within_line(cx: &mut TestAppContext) { - let fs = init_test(cx); - - let buffer_1_text = indoc! {r#" - one two three four - five six seven eight - nine ten eleven twelve - "# }; - - fs.insert_tree( - path!("/root"), - json!({ - "file1": buffer_1_text, - }), - ) - .await; - - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/file1"), cx) - }) - .await - .unwrap(); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 - one two three four - -five six seven eight - +five SIX seven eight! - nine ten eleven twelve - "#}; - - let (buffer, edits) = parse_diff(diff, |_path| { - Some((&buffer_snapshot, &[(Anchor::MIN..Anchor::MAX)] as &[_])) - }) - .await - .unwrap(); - - let edits = edits - .into_iter() - .map(|(range, text)| (range.to_point(&buffer), text)) - .collect::>(); - assert_eq!( - edits, - &[ - (Point::new(1, 5)..Point::new(1, 8), "SIX".into()), - (Point::new(1, 20)..Point::new(1, 20), "!".into()) - ] - ); - } - #[gpui::test] async fn test_apply_diff_unique_via_previous_context(cx: &mut TestAppContext) { let fs = init_test(cx); diff --git a/crates/edit_prediction/src/xml_edits.rs b/crates/edit_prediction/src/xml_edits.rs deleted file mode 100644 index ee8dd47cb25ad3dcd2c3d7d172b62e724b41c22d..0000000000000000000000000000000000000000 --- a/crates/edit_prediction/src/xml_edits.rs +++ /dev/null @@ -1,637 +0,0 @@ -use anyhow::{Context as _, Result}; -use language::{Anchor, BufferSnapshot, OffsetRangeExt as _, Point}; -use std::{cmp, ops::Range, path::Path, sync::Arc}; - -const EDITS_TAG_NAME: &'static str = "edits"; -const OLD_TEXT_TAG_NAME: &'static str = "old_text"; -const NEW_TEXT_TAG_NAME: &'static str = "new_text"; -const XML_TAGS: &[&str] = &[EDITS_TAG_NAME, OLD_TEXT_TAG_NAME, NEW_TEXT_TAG_NAME]; - -pub async fn parse_xml_edits<'a>( - input: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - parse_xml_edits_inner(input, get_buffer) - .await - .with_context(|| format!("Failed to parse XML edits:\n{input}")) -} - -async fn parse_xml_edits_inner<'a>( - input: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - let xml_edits = extract_xml_replacements(input)?; - - let (buffer, context_ranges) = get_buffer(xml_edits.file_path.as_ref()) - .with_context(|| format!("no buffer for file {}", xml_edits.file_path))?; - - let mut all_edits = vec![]; - for (old_text, new_text) in xml_edits.replacements { - let match_range = fuzzy_match_in_ranges(old_text, buffer, context_ranges)?; - let matched_old_text = buffer - .text_for_range(match_range.clone()) - .collect::(); - let edits_within_hunk = language::text_diff(&matched_old_text, new_text); - all_edits.extend( - edits_within_hunk - .into_iter() - .map(move |(inner_range, inner_text)| { - ( - buffer.anchor_after(match_range.start + inner_range.start) - ..buffer.anchor_before(match_range.start + inner_range.end), - inner_text, - ) - }), - ); - } - - Ok((buffer, all_edits)) -} - -fn fuzzy_match_in_ranges( - old_text: &str, - buffer: &BufferSnapshot, - context_ranges: &[Range], -) -> Result> { - let mut state = FuzzyMatcher::new(buffer, old_text); - let mut best_match = None; - let mut tie_match_range = None; - - for range in context_ranges { - let best_match_cost = best_match.as_ref().map(|(score, _)| *score); - match (best_match_cost, state.match_range(range.to_offset(buffer))) { - (Some(lowest_cost), Some((new_cost, new_range))) => { - if new_cost == lowest_cost { - tie_match_range = Some(new_range); - } else if new_cost < lowest_cost { - tie_match_range.take(); - best_match = Some((new_cost, new_range)); - } - } - (None, Some(new_match)) => { - best_match = Some(new_match); - } - (None, None) | (Some(_), None) => {} - }; - } - - if let Some((_, best_match_range)) = best_match { - if let Some(tie_match_range) = tie_match_range { - anyhow::bail!( - "Multiple ambiguous matches:\n{:?}:\n{}\n\n{:?}:\n{}", - best_match_range.clone(), - buffer.text_for_range(best_match_range).collect::(), - tie_match_range.clone(), - buffer.text_for_range(tie_match_range).collect::() - ); - } - return Ok(best_match_range); - } - - anyhow::bail!( - "Failed to fuzzy match `old_text`:\n{}\nin:\n```\n{}\n```", - old_text, - context_ranges - .iter() - .map(|range| buffer.text_for_range(range.clone()).collect::()) - .collect::>() - .join("```\n```") - ); -} - -#[derive(Debug)] -struct XmlEdits<'a> { - file_path: &'a str, - /// Vec of (old_text, new_text) pairs - replacements: Vec<(&'a str, &'a str)>, -} - -fn extract_xml_replacements(input: &str) -> Result> { - let mut cursor = 0; - - let (edits_body_start, edits_attrs) = - find_tag_open(input, &mut cursor, EDITS_TAG_NAME)?.context("No edits tag found")?; - - let file_path = edits_attrs - .trim_start() - .strip_prefix("path") - .context("no path attribute on edits tag")? - .trim_end() - .strip_prefix('=') - .context("no value for path attribute")? - .trim() - .trim_start_matches('"') - .trim_end_matches('"'); - - cursor = edits_body_start; - let mut edits_list = Vec::new(); - - while let Some((old_body_start, _)) = find_tag_open(input, &mut cursor, OLD_TEXT_TAG_NAME)? { - let old_body_end = find_tag_close(input, &mut cursor)?; - let old_text = trim_surrounding_newlines(&input[old_body_start..old_body_end]); - - let (new_body_start, _) = find_tag_open(input, &mut cursor, NEW_TEXT_TAG_NAME)? - .context("no new_text tag following old_text")?; - let new_body_end = find_tag_close(input, &mut cursor)?; - let new_text = trim_surrounding_newlines(&input[new_body_start..new_body_end]); - - edits_list.push((old_text, new_text)); - } - - Ok(XmlEdits { - file_path, - replacements: edits_list, - }) -} - -/// Trims a single leading and trailing newline -fn trim_surrounding_newlines(input: &str) -> &str { - let start = input.strip_prefix('\n').unwrap_or(input); - let end = start.strip_suffix('\n').unwrap_or(start); - end -} - -fn find_tag_open<'a>( - input: &'a str, - cursor: &mut usize, - expected_tag: &str, -) -> Result> { - let mut search_pos = *cursor; - - while search_pos < input.len() { - let Some(tag_start) = input[search_pos..].find("<") else { - break; - }; - let tag_start = search_pos + tag_start; - if !input[tag_start + 1..].starts_with(expected_tag) { - search_pos = search_pos + tag_start + 1; - continue; - }; - - let after_tag_name = tag_start + expected_tag.len() + 1; - let close_bracket = input[after_tag_name..] - .find('>') - .with_context(|| format!("missing > after <{}", expected_tag))?; - let attrs_end = after_tag_name + close_bracket; - let body_start = attrs_end + 1; - - let attributes = input[after_tag_name..attrs_end].trim(); - *cursor = body_start; - - return Ok(Some((body_start, attributes))); - } - - Ok(None) -} - -fn find_tag_close(input: &str, cursor: &mut usize) -> Result { - let mut depth = 1; - let mut search_pos = *cursor; - - while search_pos < input.len() && depth > 0 { - let Some(bracket_offset) = input[search_pos..].find('<') else { - break; - }; - let bracket_pos = search_pos + bracket_offset; - - if input[bracket_pos..].starts_with("') - { - let close_start = bracket_pos + 2; - let tag_name = input[close_start..close_start + close_end].trim(); - - if XML_TAGS.contains(&tag_name) { - depth -= 1; - if depth == 0 { - *cursor = close_start + close_end + 1; - return Ok(bracket_pos); - } - } - search_pos = close_start + close_end + 1; - continue; - } else if let Some(close_bracket_offset) = input[bracket_pos..].find('>') { - let close_bracket_pos = bracket_pos + close_bracket_offset; - let tag_name = &input[bracket_pos + 1..close_bracket_pos].trim(); - if XML_TAGS.contains(&tag_name) { - depth += 1; - } - } - - search_pos = bracket_pos + 1; - } - - anyhow::bail!("no closing tag found") -} - -const REPLACEMENT_COST: u32 = 1; -const INSERTION_COST: u32 = 3; -const DELETION_COST: u32 = 10; - -/// A fuzzy matcher that can process text chunks incrementally -/// and return the best match found so far at each step. -struct FuzzyMatcher<'a> { - snapshot: &'a BufferSnapshot, - query_lines: Vec<&'a str>, - matrix: SearchMatrix, -} - -impl<'a> FuzzyMatcher<'a> { - fn new(snapshot: &'a BufferSnapshot, old_text: &'a str) -> Self { - let query_lines = old_text.lines().collect(); - Self { - snapshot, - query_lines, - matrix: SearchMatrix::new(0), - } - } - - fn match_range(&mut self, range: Range) -> Option<(u32, Range)> { - let point_range = range.to_point(&self.snapshot); - let buffer_line_count = (point_range.end.row - point_range.start.row + 1) as usize; - - self.matrix - .reset(self.query_lines.len() + 1, buffer_line_count + 1); - let query_line_count = self.query_lines.len(); - - for row in 0..query_line_count { - let query_line = self.query_lines[row].trim(); - let leading_deletion_cost = (row + 1) as u32 * DELETION_COST; - - self.matrix.set( - row + 1, - 0, - SearchState::new(leading_deletion_cost, SearchDirection::Up), - ); - - let mut buffer_lines = self.snapshot.text_for_range(range.clone()).lines(); - - let mut col = 0; - while let Some(buffer_line) = buffer_lines.next() { - let buffer_line = buffer_line.trim(); - let up = SearchState::new( - self.matrix - .get(row, col + 1) - .cost - .saturating_add(DELETION_COST), - SearchDirection::Up, - ); - let left = SearchState::new( - self.matrix - .get(row + 1, col) - .cost - .saturating_add(INSERTION_COST), - SearchDirection::Left, - ); - let diagonal = SearchState::new( - if query_line == buffer_line { - self.matrix.get(row, col).cost - } else if fuzzy_eq(query_line, buffer_line) { - self.matrix.get(row, col).cost + REPLACEMENT_COST - } else { - self.matrix - .get(row, col) - .cost - .saturating_add(DELETION_COST + INSERTION_COST) - }, - SearchDirection::Diagonal, - ); - self.matrix - .set(row + 1, col + 1, up.min(left).min(diagonal)); - col += 1; - } - } - - // Find all matches with the best cost - let mut best_cost = u32::MAX; - let mut matches_with_best_cost = Vec::new(); - - for col in 1..=buffer_line_count { - let cost = self.matrix.get(query_line_count, col).cost; - if cost < best_cost { - best_cost = cost; - matches_with_best_cost.clear(); - matches_with_best_cost.push(col as u32); - } else if cost == best_cost { - matches_with_best_cost.push(col as u32); - } - } - - // Find ranges for the matches - for &match_end_col in &matches_with_best_cost { - let mut matched_lines = 0; - let mut query_row = query_line_count; - let mut match_start_col = match_end_col; - while query_row > 0 && match_start_col > 0 { - let current = self.matrix.get(query_row, match_start_col as usize); - match current.direction { - SearchDirection::Diagonal => { - query_row -= 1; - match_start_col -= 1; - matched_lines += 1; - } - SearchDirection::Up => { - query_row -= 1; - } - SearchDirection::Left => { - match_start_col -= 1; - } - } - } - - let buffer_row_start = match_start_col + point_range.start.row; - let buffer_row_end = match_end_col + point_range.start.row; - - let matched_buffer_row_count = buffer_row_end - buffer_row_start; - let matched_ratio = matched_lines as f32 - / (matched_buffer_row_count as f32).max(query_line_count as f32); - if matched_ratio >= 0.8 { - let buffer_start_ix = self - .snapshot - .point_to_offset(Point::new(buffer_row_start, 0)); - let buffer_end_ix = self.snapshot.point_to_offset(Point::new( - buffer_row_end - 1, - self.snapshot.line_len(buffer_row_end - 1), - )); - return Some((best_cost, buffer_start_ix..buffer_end_ix)); - } - } - - None - } -} - -fn fuzzy_eq(left: &str, right: &str) -> bool { - const THRESHOLD: f64 = 0.8; - - let min_levenshtein = left.len().abs_diff(right.len()); - let min_normalized_levenshtein = - 1. - (min_levenshtein as f64 / cmp::max(left.len(), right.len()) as f64); - if min_normalized_levenshtein < THRESHOLD { - return false; - } - - strsim::normalized_levenshtein(left, right) >= THRESHOLD -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum SearchDirection { - Up, - Left, - Diagonal, -} - -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct SearchState { - cost: u32, - direction: SearchDirection, -} - -impl SearchState { - fn new(cost: u32, direction: SearchDirection) -> Self { - Self { cost, direction } - } -} - -struct SearchMatrix { - cols: usize, - rows: usize, - data: Vec, -} - -impl SearchMatrix { - fn new(cols: usize) -> Self { - SearchMatrix { - cols, - rows: 0, - data: Vec::new(), - } - } - - fn reset(&mut self, rows: usize, cols: usize) { - self.rows = rows; - self.cols = cols; - self.data - .fill(SearchState::new(0, SearchDirection::Diagonal)); - self.data.resize( - self.rows * self.cols, - SearchState::new(0, SearchDirection::Diagonal), - ); - } - - fn get(&self, row: usize, col: usize) -> SearchState { - debug_assert!(row < self.rows); - debug_assert!(col < self.cols); - self.data[row * self.cols + col] - } - - fn set(&mut self, row: usize, col: usize, state: SearchState) { - debug_assert!(row < self.rows && col < self.cols); - self.data[row * self.cols + col] = state; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::TestAppContext; - use indoc::indoc; - use language::Point; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - #[test] - fn test_extract_xml_edits() { - let input = indoc! {r#" - - - old content - - - new content - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "test.rs"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, "old content"); - assert_eq!(result.replacements[0].1, "new content"); - } - - #[test] - fn test_extract_xml_edits_with_wrong_closing_tags() { - let input = indoc! {r#" - - - old content - - - new content - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "test.rs"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, "old content"); - assert_eq!(result.replacements[0].1, "new content"); - } - - #[test] - fn test_extract_xml_edits_with_xml_like_content() { - let input = indoc! {r#" - - - - - - - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "component.tsx"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, ""); - assert_eq!( - result.replacements[0].1, - "" - ); - } - - #[test] - fn test_extract_xml_edits_with_conflicting_content() { - let input = indoc! {r#" - - - - - - - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "component.tsx"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, ""); - assert_eq!(result.replacements[0].1, ""); - } - - #[test] - fn test_extract_xml_edits_multiple_pairs() { - let input = indoc! {r#" - Some reasoning before edits. Lots of thinking going on here - - - - first old - - - first new - - - second old - - - second new - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "test.rs"); - assert_eq!(result.replacements.len(), 2); - assert_eq!(result.replacements[0].0, "first old"); - assert_eq!(result.replacements[0].1, "first new"); - assert_eq!(result.replacements[1].0, "second old"); - assert_eq!(result.replacements[1].1, "second new"); - } - - #[test] - fn test_extract_xml_edits_unexpected_eof() { - let input = indoc! {r#" - - - first old - - - nine ten eleven twelve - - - nine TEN eleven twelve! - - - "#}; - - let included_ranges = [(buffer_snapshot.anchor_before(Point::new(1, 0))..Anchor::MAX)]; - let (buffer, edits) = parse_xml_edits(edits, |_path| { - Some((&buffer_snapshot, included_ranges.as_slice())) - }) - .await - .unwrap(); - - let edits = edits - .into_iter() - .map(|(range, text)| (range.to_point(&buffer), text)) - .collect::>(); - assert_eq!( - edits, - &[ - (Point::new(2, 5)..Point::new(2, 8), "TEN".into()), - (Point::new(2, 22)..Point::new(2, 22), "!".into()) - ] - ); - } - - fn init_test(cx: &mut TestAppContext) -> Arc { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); - - FakeFs::new(cx.background_executor.clone()) - } -} diff --git a/crates/edit_prediction/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs index ad630484d392d75849bd33a52a55e63ea77ca23f..ed531749cb39d10d71d18947990dd1972f23a986 100644 --- a/crates/edit_prediction/src/zeta1.rs +++ b/crates/edit_prediction/src/zeta1.rs @@ -1,22 +1,23 @@ use std::{fmt::Write, ops::Range, path::Path, sync::Arc, time::Instant}; use crate::{ - EditPredictionId, EditPredictionStore, ZedUpdateRequiredError, + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, EditPredictionStore, ZedUpdateRequiredError, cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count}, - prediction::{EditPredictionInputs, EditPredictionResult}, + prediction::EditPredictionResult, }; use anyhow::{Context as _, Result}; use cloud_llm_client::{ PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse, - predict_edits_v3::Event, }; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task}; use language::{ - Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToPoint as _, text_diff, + Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset, ToPoint as _, text_diff, }; use project::{Project, ProjectPath}; use release_channel::AppVersion; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; +use zeta_prompt::{Event, ZetaPromptInput}; const CURSOR_MARKER: &str = "<|user_cursor_is_here|>"; const START_OF_FILE_MARKER: &str = "<|start_of_file|>"; @@ -29,24 +30,27 @@ pub(crate) const MAX_EVENT_TOKENS: usize = 500; pub(crate) fn request_prediction_with_zeta1( store: &mut EditPredictionStore, - project: &Entity, - buffer: &Entity, - snapshot: BufferSnapshot, - position: language::Anchor, - events: Vec>, - trigger: PredictEditsRequestTrigger, + EditPredictionModelInput { + project, + buffer, + snapshot, + position, + events, + trigger, + debug_tx, + .. + }: EditPredictionModelInput, cx: &mut Context, ) -> Task>> { - let buffer = buffer.clone(); let buffer_snapshotted_at = Instant::now(); let client = store.client.clone(); let llm_token = store.llm_token.clone(); let app_version = AppVersion::global(cx); let (git_info, can_collect_file) = if let Some(file) = snapshot.file() { - let can_collect_file = store.can_collect_file(project, file, cx); + let can_collect_file = store.can_collect_file(&project, file, cx); let git_info = if can_collect_file { - git_info_for_file(project, &ProjectPath::from_file(file.as_ref(), cx), cx) + git_info_for_file(&project, &ProjectPath::from_file(file.as_ref(), cx), cx) } else { None }; @@ -120,33 +124,33 @@ pub(crate) fn request_prediction_with_zeta1( ) .await; - let inputs = EditPredictionInputs { + let context_start_offset = context_range.start.to_offset(&snapshot); + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = ZetaPromptInput { events: included_events.into(), - included_files: vec![cloud_llm_client::predict_edits_v3::RelatedFile { - path: full_path.clone(), - max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row), - excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt { - start_line: cloud_llm_client::predict_edits_v3::Line(context_range.start.row), - text: snapshot - .text_for_range(context_range) - .collect::() - .into(), - }], - }], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - column: cursor_point.column, - line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row), - }, + related_files: vec![].into(), cursor_path: full_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset), + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - context_start_offset, }; - // let response = perform_predict_edits(PerformPredictEditsParams { - // client, - // llm_token, - // app_version, - // body, - // }) - // .await; + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(serde_json::to_string(&inputs).unwrap()), + position, + }, + )) + .ok(); + } let (response, usage) = match response { Ok(response) => response, @@ -189,6 +193,18 @@ pub(crate) fn request_prediction_with_zeta1( .ok(); } + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + model_output: Some(response.output_excerpt.clone()), + position, + }, + )) + .ok(); + } + let edit_prediction = process_completion_response( response, buffer, @@ -226,7 +242,7 @@ fn process_completion_response( buffer: Entity, snapshot: &BufferSnapshot, editable_range: Range, - inputs: EditPredictionInputs, + inputs: ZetaPromptInput, buffer_snapshotted_at: Instant, received_response_at: Instant, cx: &AsyncApp, diff --git a/crates/edit_prediction/src/zeta2.rs b/crates/edit_prediction/src/zeta2.rs index e542bc7e86e6e381766bbedac6a15f431e0693f1..034954f5760939fc31b3e5e1e8a09737c5b2e568 100644 --- a/crates/edit_prediction/src/zeta2.rs +++ b/crates/edit_prediction/src/zeta2.rs @@ -3,46 +3,39 @@ use crate::EvalCacheEntryKind; use crate::open_ai_response::text_from_response; use crate::prediction::EditPredictionResult; use crate::{ - DebugEvent, EDIT_PREDICTIONS_MODEL_ID, EditPredictionId, EditPredictionInputs, - EditPredictionRequestedDebugEvent, EditPredictionStore, + DebugEvent, EDIT_PREDICTIONS_MODEL_ID, EditPredictionFinishedDebugEvent, EditPredictionId, + EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, }; -use anyhow::{Result, anyhow, bail}; -use cloud_llm_client::predict_edits_v3::{self, Event, PromptFormat}; -use cloud_llm_client::{EditPredictionRejectReason, PredictEditsRequestTrigger}; -use cloud_zeta2_prompt::CURSOR_MARKER; -use edit_prediction_context::{EditPredictionExcerpt, Line}; -use edit_prediction_context::{RelatedExcerpt, RelatedFile}; -use futures::channel::oneshot; -use gpui::{Entity, Task, prelude::*}; -use language::{Anchor, BufferSnapshot}; -use language::{Buffer, Point, ToOffset as _, ToPoint}; -use project::{Project, ProjectItem as _}; +use anyhow::{Result, anyhow}; +use cloud_llm_client::EditPredictionRejectReason; +use gpui::{Task, prelude::*}; +use language::{OffsetRangeExt as _, ToOffset as _, ToPoint}; use release_channel::AppVersion; -use std::{ - env, - path::Path, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{path::Path, sync::Arc, time::Instant}; +use zeta_prompt::CURSOR_MARKER; +use zeta_prompt::format_zeta_prompt; + +const MAX_CONTEXT_TOKENS: usize = 150; +const MAX_REWRITE_TOKENS: usize = 350; pub fn request_prediction_with_zeta2( store: &mut EditPredictionStore, - project: &Entity, - active_buffer: &Entity, - active_snapshot: BufferSnapshot, - position: Anchor, - events: Vec>, - mut included_files: Vec, - trigger: PredictEditsRequestTrigger, + EditPredictionModelInput { + buffer, + snapshot, + position, + related_files, + events, + debug_tx, + .. + }: EditPredictionModelInput, cx: &mut Context, ) -> Task>> { - let options = store.options.clone(); let buffer_snapshotted_at = Instant::now(); - let Some((excerpt_path, active_project_path)) = active_snapshot + let Some(excerpt_path) = snapshot .file() .map(|file| -> Arc { file.full_path(cx).into() }) - .zip(active_buffer.read(cx).project_path(cx)) else { return Task::ready(Err(anyhow!("No file path for excerpt"))); }; @@ -50,148 +43,35 @@ pub fn request_prediction_with_zeta2( let client = store.client.clone(); let llm_token = store.llm_token.clone(); let app_version = AppVersion::global(cx); - let debug_tx = store.debug_tx.clone(); - - let file = active_buffer.read(cx).file(); - - let active_file_full_path = file.as_ref().map(|f| f.full_path(cx)); - - // TODO data collection - let can_collect_data = file - .as_ref() - .map_or(false, |file| store.can_collect_file(project, file, cx)); #[cfg(feature = "eval-support")] let eval_cache = store.eval_cache.clone(); let request_task = cx.background_spawn({ - let active_buffer = active_buffer.clone(); async move { - let cursor_offset = position.to_offset(&active_snapshot); - let cursor_point = cursor_offset.to_point(&active_snapshot); - - let before_retrieval = Instant::now(); - - let excerpt_options = options.context; - - let Some(excerpt) = EditPredictionExcerpt::select_from_buffer( - cursor_point, - &active_snapshot, - &excerpt_options, - ) else { - return Ok((None, None)); - }; - - let excerpt_anchor_range = active_snapshot.anchor_after(excerpt.range.start) - ..active_snapshot.anchor_before(excerpt.range.end); - let related_excerpt = RelatedExcerpt { - anchor_range: excerpt_anchor_range.clone(), - point_range: Point::new(excerpt.line_range.start.0, 0) - ..Point::new(excerpt.line_range.end.0, 0), - text: active_snapshot.as_rope().slice(excerpt.range), - }; - - if let Some(buffer_ix) = included_files - .iter() - .position(|file| file.buffer.entity_id() == active_buffer.entity_id()) - { - let file = &mut included_files[buffer_ix]; - file.excerpts.push(related_excerpt); - file.merge_excerpts(); - let last_ix = included_files.len() - 1; - included_files.swap(buffer_ix, last_ix); - } else { - let active_file = RelatedFile { - path: active_project_path, - buffer: active_buffer.downgrade(), - excerpts: vec![related_excerpt], - max_row: active_snapshot.max_point().row, - }; - included_files.push(active_file); - } - - let included_files = included_files - .iter() - .map(|related_file| predict_edits_v3::RelatedFile { - path: Arc::from(related_file.path.path.as_std_path()), - max_row: Line(related_file.max_row), - excerpts: related_file - .excerpts - .iter() - .map(|excerpt| predict_edits_v3::Excerpt { - start_line: Line(excerpt.point_range.start.row), - text: excerpt.text.to_string().into(), - }) - .collect(), - }) - .collect::>(); - - let cloud_request = predict_edits_v3::PredictEditsRequest { - excerpt_path, - excerpt: String::new(), - excerpt_line_range: Line(0)..Line(0), - excerpt_range: 0..0, - cursor_point: predict_edits_v3::Point { - line: predict_edits_v3::Line(cursor_point.row), - column: cursor_point.column, - }, - related_files: included_files, + let cursor_offset = position.to_offset(&snapshot); + let (editable_offset_range, prompt_input) = zeta2_prompt_input( + &snapshot, + related_files, events, - can_collect_data, - debug_info: debug_tx.is_some(), - prompt_max_bytes: Some(options.max_prompt_bytes), - prompt_format: options.prompt_format, - excerpt_parent: None, - git_info: None, - trigger, - }; - - let prompt_result = cloud_zeta2_prompt::build_prompt(&cloud_request); - - let inputs = EditPredictionInputs { - included_files: cloud_request.related_files, - events: cloud_request.events, - cursor_point: cloud_request.cursor_point, - cursor_path: cloud_request.excerpt_path, - }; - - let retrieval_time = Instant::now() - before_retrieval; + excerpt_path, + cursor_offset, + ); - let debug_response_tx = if let Some(debug_tx) = &debug_tx { - let (response_tx, response_rx) = oneshot::channel(); + let prompt = format_zeta_prompt(&prompt_input); + if let Some(debug_tx) = &debug_tx { debug_tx - .unbounded_send(DebugEvent::EditPredictionRequested( - EditPredictionRequestedDebugEvent { - inputs: inputs.clone(), - retrieval_time, - buffer: active_buffer.downgrade(), - local_prompt: match prompt_result.as_ref() { - Ok(prompt) => Ok(prompt.clone()), - Err(err) => Err(err.to_string()), - }, + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(prompt.clone()), position, - response_rx, }, )) .ok(); - Some(response_tx) - } else { - None - }; - - if cfg!(debug_assertions) && env::var("ZED_ZETA2_SKIP_REQUEST").is_ok() { - if let Some(debug_response_tx) = debug_response_tx { - debug_response_tx - .send((Err("Request skipped".to_string()), Duration::ZERO)) - .ok(); - } - anyhow::bail!("Skipping request because ZED_ZETA2_SKIP_REQUEST is set") } - let prompt = prompt_result?; - let generation_params = - cloud_zeta2_prompt::generation_params(cloud_request.prompt_format); let request = open_ai::Request { model: EDIT_PREDICTIONS_MODEL_ID.clone(), messages: vec![open_ai::RequestMessage::User { @@ -199,8 +79,8 @@ pub fn request_prediction_with_zeta2( }], stream: false, max_completion_tokens: None, - stop: generation_params.stop.unwrap_or_default(), - temperature: generation_params.temperature.or(Some(0.7)), + stop: Default::default(), + temperature: Default::default(), tool_choice: None, parallel_tool_calls: None, tools: vec![], @@ -210,7 +90,6 @@ pub fn request_prediction_with_zeta2( log::trace!("Sending edit prediction request"); - let before_request = Instant::now(); let response = EditPredictionStore::send_raw_llm_request( request, client, @@ -223,68 +102,53 @@ pub fn request_prediction_with_zeta2( ) .await; let received_response_at = Instant::now(); - let request_time = received_response_at - before_request; log::trace!("Got edit prediction response"); - if let Some(debug_response_tx) = debug_response_tx { - debug_response_tx - .send(( - response - .as_ref() - .map_err(|err| err.to_string()) - .map(|response| response.0.clone()), - request_time, - )) - .ok(); - } - let (res, usage) = response?; let request_id = EditPredictionId(res.id.clone().into()); let Some(mut output_text) = text_from_response(res) else { return Ok((Some((request_id, None)), usage)); }; + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + position, + model_output: Some(output_text.clone()), + }, + )) + .ok(); + } + if output_text.contains(CURSOR_MARKER) { log::trace!("Stripping out {CURSOR_MARKER} from response"); output_text = output_text.replace(CURSOR_MARKER, ""); } - let get_buffer_from_context = |path: &Path| { - if Some(path) == active_file_full_path.as_deref() { - Some(( - &active_snapshot, - std::slice::from_ref(&excerpt_anchor_range), - )) - } else { - None - } - }; - - let (_, edits) = match options.prompt_format { - PromptFormat::Minimal | PromptFormat::MinimalQwen | PromptFormat::SeedCoder1120 => { - if output_text.contains("--- a/\n+++ b/\nNo edits") { - let edits = vec![]; - (&active_snapshot, edits) - } else { - crate::udiff::parse_diff(&output_text, get_buffer_from_context).await? - } - } - PromptFormat::OldTextNewText => { - crate::xml_edits::parse_xml_edits(&output_text, get_buffer_from_context).await? - } - _ => { - bail!("unsupported prompt format {}", options.prompt_format) - } - }; + let old_text = snapshot + .text_for_range(editable_offset_range.clone()) + .collect::(); + let edits: Vec<_> = language::text_diff(&old_text, &output_text) + .into_iter() + .map(|(range, text)| { + ( + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot.anchor_before(editable_offset_range.start + range.end), + text, + ) + }) + .collect(); anyhow::Ok(( Some(( request_id, Some(( - inputs, - active_buffer, - active_snapshot.clone(), + prompt_input, + buffer, + snapshot.clone(), edits, received_response_at, )), @@ -325,3 +189,40 @@ pub fn request_prediction_with_zeta2( )) }) } + +pub fn zeta2_prompt_input( + snapshot: &language::BufferSnapshot, + related_files: Arc<[zeta_prompt::RelatedFile]>, + events: Vec>, + excerpt_path: Arc, + cursor_offset: usize, +) -> (std::ops::Range, zeta_prompt::ZetaPromptInput) { + let cursor_point = cursor_offset.to_point(snapshot); + + let (editable_range, context_range) = + crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + snapshot, + MAX_CONTEXT_TOKENS, + MAX_REWRITE_TOKENS, + ); + + let context_start_offset = context_range.start.to_offset(snapshot); + let editable_offset_range = editable_range.to_offset(snapshot); + let cursor_offset_in_excerpt = cursor_offset - context_start_offset; + let editable_range_in_excerpt = (editable_offset_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset); + + let prompt_input = zeta_prompt::ZetaPromptInput { + cursor_path: excerpt_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt, + cursor_offset_in_excerpt, + events, + related_files, + }; + (editable_offset_range, prompt_input) +} diff --git a/crates/edit_prediction_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml index 26a060994d75a2c194cc159c33d88fbc296dfa47..0e7fff8d70156c58147069f8da64035d6a80adc8 100644 --- a/crates/edit_prediction_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -9,7 +9,7 @@ license = "GPL-3.0-or-later" workspace = true [[bin]] -name = "ep_cli" +name = "ep" path = "src/main.rs" [dependencies] @@ -20,10 +20,9 @@ chrono.workspace = true clap.workspace = true client.workspace = true cloud_llm_client.workspace= true -cloud_zeta2_prompt.workspace = true collections.workspace = true debug_adapter_extension.workspace = true -edit_prediction_context.workspace = true +dirs.workspace = true extension.workspace = true fs.workspace = true futures.workspace = true @@ -51,12 +50,21 @@ smol.workspace = true sqlez.workspace = true sqlez_macros.workspace = true terminal_view.workspace = true -toml.workspace = true util.workspace = true watch.workspace = true edit_prediction = { workspace = true, features = ["eval-support"] } +wasmtime.workspace = true +zeta_prompt.workspace = true zlog.workspace = true +# Wasmtime is included as a dependency in order to enable the same +# features that are enabled in Zed. +# +# If we don't enable these features we get crashes when creating +# a Tree-sitter WasmStore. +[package.metadata.cargo-machete] +ignored = ["wasmtime"] + [dev-dependencies] indoc.workspace = true gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/edit_prediction_cli/src/training/llm_client.rs b/crates/edit_prediction_cli/src/anthropic_client.rs similarity index 89% rename from crates/edit_prediction_cli/src/training/llm_client.rs rename to crates/edit_prediction_cli/src/anthropic_client.rs index ebecbe915d36a9a456296e818e559c654370f939..8afc4d1c03f8a37ae258cc2926daf85caebe3d8a 100644 --- a/crates/edit_prediction_cli/src/training/llm_client.rs +++ b/crates/edit_prediction_cli/src/anthropic_client.rs @@ -5,11 +5,13 @@ use anthropic::{ use anyhow::Result; use http_client::HttpClient; use indoc::indoc; +use reqwest_client::ReqwestClient; use sqlez::bindable::Bind; use sqlez::bindable::StaticColumnCount; use sqlez_macros::sql; use std::hash::Hash; use std::hash::Hasher; +use std::path::Path; use std::sync::Arc; pub struct PlainLlmClient { @@ -18,7 +20,8 @@ pub struct PlainLlmClient { } impl PlainLlmClient { - fn new(http_client: Arc) -> Result { + fn new() -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); let api_key = std::env::var("ANTHROPIC_API_KEY") .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; Ok(Self { @@ -29,12 +32,12 @@ impl PlainLlmClient { async fn generate( &self, - model: String, + model: &str, max_tokens: u64, messages: Vec, ) -> Result { let request = AnthropicRequest { - model, + model: model.to_string(), max_tokens, messages, tools: Vec::new(), @@ -105,11 +108,12 @@ struct SerializableMessage { } impl BatchingLlmClient { - fn new(cache_path: &str, http_client: Arc) -> Result { + fn new(cache_path: &Path) -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); let api_key = std::env::var("ANTHROPIC_API_KEY") .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; - let connection = sqlez::connection::Connection::open_file(&cache_path); + let connection = sqlez::connection::Connection::open_file(&cache_path.to_str().unwrap()); let mut statement = sqlez::statement::Statement::prepare( &connection, indoc! {" @@ -182,16 +186,16 @@ impl BatchingLlmClient { async fn generate( &self, - model: String, + model: &str, max_tokens: u64, messages: Vec, ) -> Result> { - let response = self.lookup(&model, max_tokens, &messages)?; + let response = self.lookup(model, max_tokens, &messages)?; if let Some(response) = response { return Ok(Some(response)); } - self.mark_for_batch(&model, max_tokens, &messages)?; + self.mark_for_batch(model, max_tokens, &messages)?; Ok(None) } @@ -258,7 +262,7 @@ impl BatchingLlmClient { } } } - log::info!("Uploaded {} successful requests", success_count); + log::info!("Downloaded {} successful requests", success_count); } } @@ -363,23 +367,20 @@ fn message_content_to_string(content: &[RequestContent]) -> String { .join("\n") } -pub enum LlmClient { +pub enum AnthropicClient { // No batching Plain(PlainLlmClient), Batch(BatchingLlmClient), Dummy, } -impl LlmClient { - pub fn plain(http_client: Arc) -> Result { - Ok(Self::Plain(PlainLlmClient::new(http_client)?)) +impl AnthropicClient { + pub fn plain() -> Result { + Ok(Self::Plain(PlainLlmClient::new()?)) } - pub fn batch(cache_path: &str, http_client: Arc) -> Result { - Ok(Self::Batch(BatchingLlmClient::new( - cache_path, - http_client, - )?)) + pub fn batch(cache_path: &Path) -> Result { + Ok(Self::Batch(BatchingLlmClient::new(cache_path)?)) } #[allow(dead_code)] @@ -389,29 +390,29 @@ impl LlmClient { pub async fn generate( &self, - model: String, + model: &str, max_tokens: u64, messages: Vec, ) -> Result> { match self { - LlmClient::Plain(plain_llm_client) => plain_llm_client + AnthropicClient::Plain(plain_llm_client) => plain_llm_client .generate(model, max_tokens, messages) .await .map(Some), - LlmClient::Batch(batching_llm_client) => { + AnthropicClient::Batch(batching_llm_client) => { batching_llm_client .generate(model, max_tokens, messages) .await } - LlmClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), } } pub async fn sync_batches(&self) -> Result<()> { match self { - LlmClient::Plain(_) => Ok(()), - LlmClient::Batch(batching_llm_client) => batching_llm_client.sync_batches().await, - LlmClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + AnthropicClient::Plain(_) => Ok(()), + AnthropicClient::Batch(batching_llm_client) => batching_llm_client.sync_batches().await, + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), } } } diff --git a/crates/edit_prediction_cli/src/evaluate.rs b/crates/edit_prediction_cli/src/evaluate.rs deleted file mode 100644 index 686c8ce7e7865f265d6bf17e51ca9477194e5252..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/evaluate.rs +++ /dev/null @@ -1,641 +0,0 @@ -use crate::metrics::{self, Scores}; -use std::{ - collections::HashMap, - io::{IsTerminal, Write}, - sync::Arc, -}; - -use anyhow::Result; -use edit_prediction::{EditPredictionStore, udiff::DiffLine}; -use gpui::{AsyncApp, Entity}; -use project::Project; -use util::ResultExt as _; - -use crate::{ - EvaluateArguments, PredictionOptions, - example::{Example, NamedExample}, - headless::ZetaCliAppState, - paths::print_run_data_dir, - predict::{PredictionDetails, perform_predict, setup_store}, -}; - -#[derive(Debug)] -pub(crate) struct ExecutionData { - execution_id: String, - diff: String, - reasoning: String, -} - -pub async fn run_evaluate( - args: EvaluateArguments, - app_state: &Arc, - cx: &mut AsyncApp, -) { - if args.example_paths.is_empty() { - eprintln!("No examples provided"); - return; - } - - let all_tasks = args.example_paths.into_iter().map(|path| { - let options = args.options.clone(); - let app_state = app_state.clone(); - let example = NamedExample::load(&path).expect("Failed to load example"); - - cx.spawn(async move |cx| { - let project = example.setup_project(&app_state, cx).await.unwrap(); - - let providers = (0..args.repetitions) - .map(|_| setup_store(args.options.provider, &project, &app_state, cx).unwrap()) - .collect::>(); - - let _edited_buffers = example.apply_edit_history(&project, cx).await.unwrap(); - - let tasks = providers - .into_iter() - .enumerate() - .map(move |(repetition_ix, store)| { - let repetition_ix = (args.repetitions > 1).then(|| repetition_ix as u16); - let example = example.clone(); - let project = project.clone(); - let options = options.clone(); - - cx.spawn(async move |cx| { - let name = example.name.clone(); - run_evaluate_one( - example, - repetition_ix, - project, - store, - options, - !args.skip_prediction, - cx, - ) - .await - .map_err(|err| (err, name, repetition_ix)) - }) - }); - futures::future::join_all(tasks).await - }) - }); - let all_results = futures::future::join_all(all_tasks).await; - - write_aggregated_scores(&mut std::io::stdout(), &all_results).unwrap(); - if let Some(mut output_file) = - std::fs::File::create(crate::paths::RUN_DIR.join("aggregated_results.md")).log_err() - { - write_aggregated_scores(&mut output_file, &all_results).log_err(); - }; - - if args.repetitions > 1 { - if let Err(e) = write_bucketed_analysis(&all_results) { - eprintln!("Failed to write bucketed analysis: {:?}", e); - } - } - - print_run_data_dir(args.repetitions == 1, std::io::stdout().is_terminal()); -} - -fn write_aggregated_scores( - w: &mut impl std::io::Write, - all_results: &Vec< - Vec)>>, - >, -) -> Result<()> { - let mut successful = Vec::new(); - let mut failed_count = 0; - - for result in all_results.iter().flatten() { - match result { - Ok((eval_result, _execution_data)) => successful.push(eval_result), - Err((err, name, repetition_ix)) => { - if failed_count == 0 { - writeln!(w, "## Errors\n")?; - } - - failed_count += 1; - writeln!(w, "{}", fmt_evaluation_error(err, name, repetition_ix))?; - } - } - } - - if successful.len() > 1 { - let edit_scores = successful - .iter() - .filter_map(|r| r.edit_scores.clone()) - .collect::>(); - let has_edit_predictions = edit_scores.len() > 0; - let aggregated_result = EvaluationResult { - context_scores: Scores::aggregate(successful.iter().map(|r| &r.context_scores)), - edit_scores: has_edit_predictions.then(|| EditScores::aggregate(&edit_scores)), - prompt_len: successful.iter().map(|r| r.prompt_len).sum::() / successful.len(), - generated_len: successful.iter().map(|r| r.generated_len).sum::() - / successful.len(), - }; - - writeln!(w, "\n{}", "-".repeat(80))?; - writeln!(w, "\n## TOTAL SCORES")?; - writeln!(w, "{:#}", aggregated_result)?; - } - - if successful.len() + failed_count > 1 { - writeln!( - w, - "\nCongratulations! {}/{} ({:.2}%) of runs weren't outright failures 🎉", - successful.len(), - successful.len() + failed_count, - (successful.len() as f64 / (successful.len() + failed_count) as f64) * 100.0 - )?; - } - - Ok(()) -} - -pub async fn run_evaluate_one( - example: NamedExample, - repetition_ix: Option, - project: Entity, - store: Entity, - prediction_options: PredictionOptions, - predict: bool, - cx: &mut AsyncApp, -) -> Result<(EvaluationResult, ExecutionData)> { - let predict_result = perform_predict( - example.clone(), - project, - store, - repetition_ix, - prediction_options, - cx, - ) - .await?; - - let evaluation_result = evaluate(&example.example, &predict_result, predict); - - if repetition_ix.is_none() { - write_eval_result( - &example, - &predict_result, - &evaluation_result, - &mut std::io::stdout(), - std::io::stdout().is_terminal(), - predict, - )?; - } - - if let Some(mut results_file) = - std::fs::File::create(predict_result.run_example_dir.join("results.md")).log_err() - { - write_eval_result( - &example, - &predict_result, - &evaluation_result, - &mut results_file, - false, - predict, - ) - .log_err(); - } - - let execution_data = ExecutionData { - execution_id: if let Some(rep_ix) = repetition_ix { - format!("{:03}", rep_ix) - } else { - example.name.clone() - }, - diff: predict_result.diff.clone(), - reasoning: std::fs::read_to_string( - predict_result - .run_example_dir - .join("prediction_response.md"), - ) - .unwrap_or_default(), - }; - - anyhow::Ok((evaluation_result, execution_data)) -} - -fn write_eval_result( - example: &NamedExample, - predictions: &PredictionDetails, - evaluation_result: &EvaluationResult, - out: &mut impl Write, - use_color: bool, - predict: bool, -) -> Result<()> { - if predict { - writeln!( - out, - "## Expected edit prediction:\n\n```diff\n{}\n```\n", - compare_diffs( - &example.example.expected_patch, - &predictions.diff, - use_color - ) - )?; - writeln!( - out, - "## Actual edit prediction:\n\n```diff\n{}\n```\n", - compare_diffs( - &predictions.diff, - &example.example.expected_patch, - use_color - ) - )?; - } - - writeln!(out, "{:#}", evaluation_result)?; - - anyhow::Ok(()) -} - -#[derive(Debug, Default, Clone)] -pub struct EditScores { - pub line_match: Scores, - pub chr_f: f64, -} - -impl EditScores { - pub fn aggregate(scores: &[EditScores]) -> EditScores { - let line_match = Scores::aggregate(scores.iter().map(|s| &s.line_match)); - let chr_f = scores.iter().map(|s| s.chr_f).sum::() / scores.len() as f64; - - EditScores { line_match, chr_f } - } -} - -#[derive(Debug, Default)] -pub struct EvaluationResult { - pub edit_scores: Option, - pub context_scores: Scores, - pub prompt_len: usize, - pub generated_len: usize, -} - -impl std::fmt::Display for EvaluationResult { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if f.alternate() { - self.fmt_table(f) - } else { - self.fmt_markdown(f) - } - } -} - -impl EvaluationResult { - fn fmt_markdown(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - r#" -### Context Scores -{} -"#, - self.context_scores.to_markdown(), - )?; - if let Some(scores) = &self.edit_scores { - write!( - f, - r#" - ### Edit Prediction Scores - {}"#, - scores.line_match.to_markdown() - )?; - } - Ok(()) - } - - fn fmt_table(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "#### Prompt Statistics")?; - writeln!(f, "─────────────────────────")?; - writeln!(f, "Prompt_len Generated_len")?; - writeln!(f, "─────────────────────────")?; - writeln!(f, "{:<11} {:<14}", self.prompt_len, self.generated_len,)?; - writeln!(f)?; - writeln!(f)?; - writeln!(f, "#### Performance Scores")?; - writeln!( - f, - "──────────────────────────────────────────────────────────────────" - )?; - writeln!( - f, - " TP FP FN Precision Recall F1" - )?; - writeln!( - f, - "──────────────────────────────────────────────────────────────────" - )?; - writeln!( - f, - "Context Retrieval {:<6} {:<6} {:<6} {:>8.2} {:>7.2} {:>6.2}", - self.context_scores.true_positives, - self.context_scores.false_positives, - self.context_scores.false_negatives, - self.context_scores.precision() * 100.0, - self.context_scores.recall() * 100.0, - self.context_scores.f1_score() * 100.0 - )?; - if let Some(edit_scores) = &self.edit_scores { - let line_match = &edit_scores.line_match; - writeln!(f, "Edit Prediction")?; - writeln!( - f, - " ├─ exact lines {:<6} {:<6} {:<6} {:>8.2} {:>7.2} {:>6.2}", - line_match.true_positives, - line_match.false_positives, - line_match.false_negatives, - line_match.precision() * 100.0, - line_match.recall() * 100.0, - line_match.f1_score() * 100.0 - )?; - writeln!( - f, - " └─ diff chrF {:<6} {:<6} {:<6} {:>8} {:>8} {:>6.2}", - "-", "-", "-", "-", "-", edit_scores.chr_f - )?; - } - Ok(()) - } -} - -fn evaluate(example: &Example, preds: &PredictionDetails, predict: bool) -> EvaluationResult { - let mut eval_result = EvaluationResult { - prompt_len: preds.prompt_len, - generated_len: preds.generated_len, - ..Default::default() - }; - - if predict { - // todo: alternatives for patches - let expected_patch = example - .expected_patch - .lines() - .map(DiffLine::parse) - .collect::>(); - let actual_patch = preds.diff.lines().map(DiffLine::parse).collect::>(); - - let line_match = metrics::line_match_score(&expected_patch, &actual_patch); - let chr_f = metrics::delta_chr_f(&expected_patch, &actual_patch); - - eval_result.edit_scores = Some(EditScores { line_match, chr_f }); - } - - eval_result -} - -/// Return annotated `patch_a` so that: -/// Additions and deletions that are not present in `patch_b` will be highlighted in red. -/// Additions and deletions that are present in `patch_b` will be highlighted in green. -pub fn compare_diffs(patch_a: &str, patch_b: &str, use_color: bool) -> String { - let green = if use_color { "\x1b[32m✓ " } else { "" }; - let red = if use_color { "\x1b[31m✗ " } else { "" }; - let neutral = if use_color { " " } else { "" }; - let reset = if use_color { "\x1b[0m" } else { "" }; - let lines_a = patch_a.lines().map(DiffLine::parse); - let lines_b: Vec<_> = patch_b.lines().map(DiffLine::parse).collect(); - - let annotated = lines_a - .map(|line| match line { - DiffLine::Addition(_) | DiffLine::Deletion(_) => { - if lines_b.contains(&line) { - format!("{green}{line}{reset}") - } else { - format!("{red}{line}{reset}") - } - } - _ => format!("{neutral}{line}{reset}"), - }) - .collect::>(); - - annotated.join("\n") -} - -fn write_bucketed_analysis( - all_results: &Vec< - Vec)>>, - >, -) -> Result<()> { - #[derive(Debug)] - struct EditBucket { - diff: String, - is_correct: bool, - execution_indices: Vec, - reasoning_samples: Vec, - } - - let mut total_executions = 0; - let mut empty_predictions = Vec::new(); - let mut errors = Vec::new(); - - let mut buckets: HashMap = HashMap::new(); - - for result in all_results.iter().flatten() { - total_executions += 1; - - let (evaluation_result, execution_data) = match result { - Ok((eval_result, execution_data)) => { - if execution_data.diff.is_empty() { - empty_predictions.push(execution_data); - continue; - } - (eval_result, execution_data) - } - Err(err) => { - errors.push(err); - continue; - } - }; - - buckets - .entry(execution_data.diff.clone()) - .and_modify(|bucket| { - bucket - .execution_indices - .push(execution_data.execution_id.clone()); - bucket - .reasoning_samples - .push(execution_data.reasoning.clone()); - }) - .or_insert_with(|| EditBucket { - diff: execution_data.diff.clone(), - is_correct: { - evaluation_result - .edit_scores - .as_ref() - .map_or(false, |edit_scores| { - edit_scores.line_match.false_positives == 0 - && edit_scores.line_match.false_negatives == 0 - && edit_scores.line_match.true_positives > 0 - }) - }, - execution_indices: vec![execution_data.execution_id.clone()], - reasoning_samples: vec![execution_data.reasoning.clone()], - }); - } - - let mut sorted_buckets = buckets.into_values().collect::>(); - sorted_buckets.sort_by(|a, b| match (a.is_correct, b.is_correct) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => b.execution_indices.len().cmp(&a.execution_indices.len()), - }); - - let output_path = crate::paths::RUN_DIR.join("bucketed_analysis.md"); - let mut output = std::fs::File::create(&output_path)?; - - writeln!(output, "# Bucketed Edit Analysis\n")?; - - writeln!(output, "## Summary\n")?; - writeln!(output, "- **Total executions**: {}", total_executions)?; - - let correct_count: usize = sorted_buckets - .iter() - .filter(|b| b.is_correct) - .map(|b| b.execution_indices.len()) - .sum(); - - let incorrect_count: usize = sorted_buckets - .iter() - .filter(|b| !b.is_correct) - .map(|b| b.execution_indices.len()) - .sum(); - - writeln!( - output, - "- **Correct predictions**: {} ({:.1}%)", - correct_count, - (correct_count as f64 / total_executions as f64) * 100.0 - )?; - - writeln!( - output, - "- **Incorrect predictions**: {} ({:.1}%)", - incorrect_count, - (incorrect_count as f64 / total_executions as f64) * 100.0 - )?; - - writeln!( - output, - "- **No Predictions**: {} ({:.1}%)", - empty_predictions.len(), - (empty_predictions.len() as f64 / total_executions as f64) * 100.0 - )?; - - let unique_incorrect = sorted_buckets.iter().filter(|b| !b.is_correct).count(); - writeln!( - output, - "- **Unique incorrect edit patterns**: {}\n", - unique_incorrect - )?; - - writeln!(output, "---\n")?; - - for (idx, bucket) in sorted_buckets.iter().filter(|b| b.is_correct).enumerate() { - if idx == 0 { - writeln!( - output, - "## Correct Predictions ({} occurrences)\n", - bucket.execution_indices.len() - )?; - } - - writeln!(output, "**Predicted Edit:**\n")?; - writeln!(output, "```diff")?; - writeln!(output, "{}", bucket.diff)?; - writeln!(output, "```\n")?; - - writeln!( - output, - "**Executions:** {}\n", - bucket.execution_indices.join(", ") - )?; - writeln!(output, "---\n")?; - } - - for (idx, bucket) in sorted_buckets.iter().filter(|b| !b.is_correct).enumerate() { - writeln!( - output, - "## Incorrect Prediction #{} ({} occurrences)\n", - idx + 1, - bucket.execution_indices.len() - )?; - - writeln!(output, "**Predicted Edit:**\n")?; - writeln!(output, "```diff")?; - writeln!(output, "{}", bucket.diff)?; - writeln!(output, "```\n")?; - - writeln!( - output, - "**Executions:** {}\n", - bucket.execution_indices.join(", ") - )?; - - for (exec_id, reasoning) in bucket - .execution_indices - .iter() - .zip(bucket.reasoning_samples.iter()) - { - writeln!(output, "{}", fmt_execution(exec_id, reasoning))?; - } - - writeln!(output, "\n---\n")?; - } - - if !empty_predictions.is_empty() { - writeln!( - output, - "## No Predictions ({} occurrences)\n", - empty_predictions.len() - )?; - - for execution_data in &empty_predictions { - writeln!( - output, - "{}", - fmt_execution(&execution_data.execution_id, &execution_data.reasoning) - )?; - } - writeln!(output, "\n---\n")?; - } - - if !errors.is_empty() { - writeln!(output, "## Errors ({} occurrences)\n", errors.len())?; - - for (err, name, repetition_ix) in &errors { - writeln!(output, "{}", fmt_evaluation_error(err, name, repetition_ix))?; - } - writeln!(output, "\n---\n")?; - } - - fn fmt_execution(exec_id: &str, reasoning: &str) -> String { - let exec_content = format!( - "\n### Execution {} `{}/{}/prediction_response.md`{}", - exec_id, - crate::paths::RUN_DIR.display(), - exec_id, - indent_text(&format!("\n\n```\n{}\n```\n", reasoning,), 2) - ); - indent_text(&exec_content, 2) - } - - fn indent_text(text: &str, spaces: usize) -> String { - let indent = " ".repeat(spaces); - text.lines() - .collect::>() - .join(&format!("\n{}", indent)) - } - - Ok(()) -} - -fn fmt_evaluation_error(err: &anyhow::Error, name: &str, repetition_ix: &Option) -> String { - let err = format!("{err:?}") - .replace("", "\n```"); - format!( - "### ERROR {name}{}\n\n{err}\n", - repetition_ix - .map(|ix| format!(" [RUN {ix:03}]")) - .unwrap_or_default() - ) -} diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs index 4f8c1867cd57d7fb5dbb9c2c08b63dccf2b97d30..a13b339ae69b9584f3b47186d8b6c36f458a2b76 100644 --- a/crates/edit_prediction_cli/src/example.rs +++ b/crates/edit_prediction_cli/src/example.rs @@ -1,59 +1,103 @@ +use crate::{ + PredictionProvider, PromptFormat, + metrics::ClassificationMetrics, + paths::{REPOS_DIR, WORKTREES_DIR}, +}; +use anyhow::{Context as _, Result}; +use edit_prediction::udiff::OpenedBuffers; +use gpui::Entity; +use http_client::Url; +use language::{Anchor, Buffer}; +use project::Project; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; use std::{ borrow::Cow, - cell::RefCell, - fmt::{self, Display}, - fs, - hash::Hash, - hash::Hasher, - io::Write, + io::{Read, Write}, mem, path::{Path, PathBuf}, - sync::{Arc, OnceLock}, }; +use zeta_prompt::RelatedFile; -use crate::headless::ZetaCliAppState; -use anyhow::{Context as _, Result, anyhow}; -use clap::ValueEnum; -use cloud_zeta2_prompt::CURSOR_MARKER; -use collections::HashMap; -use edit_prediction::udiff::OpenedBuffers; -use futures::{ - AsyncWriteExt as _, - lock::{Mutex, OwnedMutexGuard}, -}; -use futures::{FutureExt as _, future::Shared}; -use gpui::{AsyncApp, Entity, Task, http_client::Url}; -use language::{Anchor, Buffer}; -use project::{Project, ProjectPath}; -use pulldown_cmark::CowStr; -use serde::{Deserialize, Serialize}; -use util::{paths::PathStyle, rel_path::RelPath}; - -use crate::paths::{REPOS_DIR, WORKTREES_DIR}; - -const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; -const EDIT_HISTORY_HEADING: &str = "Edit History"; -const CURSOR_POSITION_HEADING: &str = "Cursor Position"; -const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; -const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; -const REPOSITORY_URL_FIELD: &str = "repository_url"; -const REVISION_FIELD: &str = "revision"; - -#[derive(Debug, Clone)] -pub struct NamedExample { - pub name: String, - pub example: Example, -} - -#[derive(Clone, Debug, Hash, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Example { + #[serde(default)] + pub name: String, pub repository_url: String, pub revision: String, pub uncommitted_diff: String, - pub cursor_path: PathBuf, + pub cursor_path: Arc, pub cursor_position: String, pub edit_history: String, pub expected_patch: String, + + /// The full content of the file where an edit is being predicted, and the + /// actual cursor offset. + #[serde(skip_serializing_if = "Option::is_none")] + pub buffer: Option, + + /// The context retrieved for the prediction. This requires the worktree to + /// be loaded and the language server to be started. + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + + /// The input and expected output from the edit prediction model. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + /// The actual predictions from the model. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub predictions: Vec, + + /// The scores, for how well the actual predictions match the expected + /// predictions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub score: Vec, + + /// The application state used to process this example. + #[serde(skip)] + pub state: Option, +} + +#[derive(Clone, Debug)] +pub struct ExampleState { + pub project: Entity, + pub buffer: Entity, + pub cursor_position: Anchor, + pub _open_buffers: OpenedBuffers, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleContext { + pub files: Arc<[RelatedFile]>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleBuffer { + pub content: String, + pub cursor_row: u32, + pub cursor_column: u32, + pub cursor_offset: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrompt { + pub input: String, + pub expected_output: String, + pub format: PromptFormat, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrediction { + pub actual_patch: String, + pub actual_output: String, + pub provider: PredictionProvider, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleScore { + pub delta_chr_f: f32, + pub line_match: ClassificationMetrics, } impl Example { @@ -90,485 +134,244 @@ impl Example { } } - pub async fn setup_worktree(&self, file_name: String) -> Result { - let (repo_owner, repo_name) = self.repo_name()?; - - let repo_dir = REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref()); - let repo_lock = lock_repo(&repo_dir).await; + pub fn worktree_path(&self) -> PathBuf { + WORKTREES_DIR + .join(&self.name) + .join(self.repo_name().unwrap().1.as_ref()) + } - if !repo_dir.is_dir() { - fs::create_dir_all(&repo_dir)?; - run_git(&repo_dir, &["init"]).await?; - run_git( - &repo_dir, - &["remote", "add", "origin", &self.repository_url], - ) - .await?; - } + pub fn repo_path(&self) -> PathBuf { + let (repo_owner, repo_name) = self.repo_name().expect("failed to get repo name"); + REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref()) + } +} - // Resolve the example to a revision, fetching it if needed. - let revision = run_git( - &repo_dir, - &["rev-parse", &format!("{}^{{commit}}", self.revision)], - ) - .await; - let revision = if let Ok(revision) = revision { - revision +pub fn read_examples(inputs: &[PathBuf]) -> Vec { + let mut examples = Vec::new(); + + let stdin_path: PathBuf = PathBuf::from("-"); + + let inputs = if inputs.is_empty() { + &[stdin_path] + } else { + inputs + }; + + for path in inputs { + let is_stdin = path.as_path() == Path::new("-"); + let content = if is_stdin { + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .expect("Failed to read from stdin"); + buffer } else { - if run_git( - &repo_dir, - &["fetch", "--depth", "1", "origin", &self.revision], - ) - .await - .is_err() - { - run_git(&repo_dir, &["fetch", "origin"]).await?; - } - let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]).await?; - if revision != self.revision { - run_git(&repo_dir, &["tag", &self.revision, &revision]).await?; - } - revision + std::fs::read_to_string(path) + .unwrap_or_else(|_| panic!("Failed to read path: {:?}", &path)) }; - - // Create the worktree for this example if needed. - let worktree_path = WORKTREES_DIR.join(&file_name).join(repo_name.as_ref()); - if worktree_path.is_dir() { - run_git(&worktree_path, &["clean", "--force", "-d"]).await?; - run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; - run_git(&worktree_path, &["checkout", revision.as_str()]).await?; + let filename = path.file_stem().unwrap().to_string_lossy().to_string(); + let ext = if !is_stdin { + path.extension() + .map(|ext| ext.to_string_lossy().to_string()) + .unwrap_or_else(|| panic!("{} should have an extension", path.display())) } else { - let worktree_path_string = worktree_path.to_string_lossy(); - run_git(&repo_dir, &["branch", "-f", &file_name, revision.as_str()]).await?; - run_git( - &repo_dir, - &["worktree", "add", "-f", &worktree_path_string, &file_name], - ) - .await?; - } - drop(repo_lock); - - // Apply the uncommitted diff for this example. - if !self.uncommitted_diff.is_empty() { - let mut apply_process = smol::process::Command::new("git") - .current_dir(&worktree_path) - .args(&["apply", "-"]) - .stdin(std::process::Stdio::piped()) - .spawn()?; - - let mut stdin = apply_process.stdin.take().unwrap(); - stdin.write_all(self.uncommitted_diff.as_bytes()).await?; - stdin.close().await?; - drop(stdin); - - let apply_result = apply_process.output().await?; - if !apply_result.status.success() { - anyhow::bail!( - "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", - apply_result.status, - String::from_utf8_lossy(&apply_result.stderr), - String::from_utf8_lossy(&apply_result.stdout), - ); + "jsonl".to_string() + }; + + match ext.as_ref() { + "json" => { + let mut example = + serde_json::from_str::(&content).unwrap_or_else(|error| { + panic!("Failed to parse example file: {}\n{error}", path.display()) + }); + if example.name.is_empty() { + example.name = filename; + } + examples.push(example); + } + "jsonl" => examples.extend( + content + .lines() + .enumerate() + .map(|(line_ix, line)| { + let mut example = + serde_json::from_str::(line).unwrap_or_else(|_| { + panic!( + "Failed to parse example on {}:{}", + path.display(), + line_ix + 1 + ) + }); + if example.name.is_empty() { + example.name = format!("{filename}-{line_ix}") + } + example + }) + .collect::>(), + ), + "md" => { + examples.push(parse_markdown_example(filename, &content).unwrap()); + } + ext => { + panic!("{} has invalid example extension `{ext}`", path.display()) } } - - Ok(worktree_path) - } - - pub fn unique_name(&self) -> String { - let mut hasher = std::hash::DefaultHasher::new(); - self.hash(&mut hasher); - let disambiguator = hasher.finish(); - let hash = format!("{:04x}", disambiguator); - format!("{}_{}", &self.revision[..8], &hash[..4]) } + examples } -pub type ActualExcerpt = Excerpt; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Excerpt { - pub path: PathBuf, - pub text: String, -} - -#[derive(ValueEnum, Debug, Clone)] -pub enum ExampleFormat { - Json, - Toml, - Md, +pub fn write_examples(examples: &[Example], output_path: Option<&PathBuf>) { + let mut content = String::new(); + for example in examples { + let line = serde_json::to_string(example).unwrap(); + content.push_str(&line); + content.push('\n'); + } + if let Some(output_path) = output_path { + std::fs::write(output_path, content).expect("Failed to write examples"); + } else { + std::io::stdout().write_all(&content.as_bytes()).unwrap(); + } } -impl NamedExample { - pub fn load(path: impl AsRef) -> Result { - let path = path.as_ref(); - let content = std::fs::read_to_string(path)?; - let ext = path.extension(); - - match ext.and_then(|s| s.to_str()) { - Some("json") => Ok(Self { - name: path.file_stem().unwrap_or_default().display().to_string(), - example: serde_json::from_str(&content)?, - }), - Some("toml") => Ok(Self { - name: path.file_stem().unwrap_or_default().display().to_string(), - example: toml::from_str(&content)?, - }), - Some("md") => Self::parse_md(&content), - Some(_) => { - anyhow::bail!("Unrecognized example extension: {}", ext.unwrap().display()); - } - None => { - anyhow::bail!( - "Failed to determine example type since the file does not have an extension." - ); - } - } +fn parse_markdown_example(id: String, input: &str) -> Result { + use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd}; + + const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; + const EDIT_HISTORY_HEADING: &str = "Edit History"; + const CURSOR_POSITION_HEADING: &str = "Cursor Position"; + const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; + const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; + const REPOSITORY_URL_FIELD: &str = "repository_url"; + const REVISION_FIELD: &str = "revision"; + + let parser = Parser::new(input); + + let mut example = Example { + name: id, + repository_url: String::new(), + revision: String::new(), + uncommitted_diff: String::new(), + cursor_path: PathBuf::new().into(), + cursor_position: String::new(), + edit_history: String::new(), + expected_patch: String::new(), + buffer: None, + context: None, + prompt: None, + predictions: Vec::new(), + score: Vec::new(), + state: None, + }; + + let mut name = String::new(); + let mut text = String::new(); + let mut block_info: CowStr = "".into(); + + #[derive(PartialEq)] + enum Section { + UncommittedDiff, + EditHistory, + CursorPosition, + ExpectedExcerpts, + ExpectedPatch, + Other, } - pub fn parse_md(input: &str) -> Result { - use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd}; - - let parser = Parser::new(input); - - let mut named = NamedExample { - name: String::new(), - example: Example { - repository_url: String::new(), - revision: String::new(), - uncommitted_diff: String::new(), - cursor_path: PathBuf::new(), - cursor_position: String::new(), - edit_history: String::new(), - expected_patch: String::new(), - }, - }; + let mut current_section = Section::Other; - let mut text = String::new(); - let mut block_info: CowStr = "".into(); - - #[derive(PartialEq)] - enum Section { - UncommittedDiff, - EditHistory, - CursorPosition, - ExpectedExcerpts, - ExpectedPatch, - Other, - } + for event in parser { + match event { + Event::Text(line) => { + text.push_str(&line); - let mut current_section = Section::Other; - - for event in parser { - match event { - Event::Text(line) => { - text.push_str(&line); - - if !named.name.is_empty() - && current_section == Section::Other - // in h1 section - && let Some((field, value)) = line.split_once('=') - { - match field.trim() { - REPOSITORY_URL_FIELD => { - named.example.repository_url = value.trim().to_string(); - } - REVISION_FIELD => { - named.example.revision = value.trim().to_string(); - } - _ => {} - } - } - } - Event::End(TagEnd::Heading(HeadingLevel::H1)) => { - if !named.name.is_empty() { - anyhow::bail!( - "Found multiple H1 headings. There should only be one with the name of the example." - ); - } - named.name = mem::take(&mut text); - } - Event::End(TagEnd::Heading(HeadingLevel::H2)) => { - let title = mem::take(&mut text); - current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { - Section::UncommittedDiff - } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { - Section::EditHistory - } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { - Section::CursorPosition - } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { - Section::ExpectedPatch - } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { - Section::ExpectedExcerpts - } else { - Section::Other - }; - } - Event::End(TagEnd::Heading(HeadingLevel::H3)) => { - mem::take(&mut text); - } - Event::End(TagEnd::Heading(HeadingLevel::H4)) => { - mem::take(&mut text); - } - Event::End(TagEnd::Heading(level)) => { - anyhow::bail!("Unexpected heading level: {level}"); - } - Event::Start(Tag::CodeBlock(kind)) => { - match kind { - CodeBlockKind::Fenced(info) => { - block_info = info; - } - CodeBlockKind::Indented => { - anyhow::bail!("Unexpected indented codeblock"); - } - }; - } - Event::Start(_) => { - text.clear(); - block_info = "".into(); - } - Event::End(TagEnd::CodeBlock) => { - let block_info = block_info.trim(); - match current_section { - Section::UncommittedDiff => { - named.example.uncommitted_diff = mem::take(&mut text); - } - Section::EditHistory => { - named.example.edit_history.push_str(&mem::take(&mut text)); - } - Section::CursorPosition => { - named.example.cursor_path = block_info.into(); - named.example.cursor_position = mem::take(&mut text); - } - Section::ExpectedExcerpts => { - mem::take(&mut text); + if let Some((field, value)) = line.split_once('=') { + match field.trim() { + REPOSITORY_URL_FIELD => { + example.repository_url = value.trim().to_string(); } - Section::ExpectedPatch => { - named.example.expected_patch = mem::take(&mut text); + REVISION_FIELD => { + example.revision = value.trim().to_string(); } - Section::Other => {} + _ => {} } } - _ => {} } - } - - if named.example.cursor_path.as_path() == Path::new("") - || named.example.cursor_position.is_empty() - { - anyhow::bail!("Missing cursor position codeblock"); - } - - Ok(named) - } - - pub fn write(&self, format: ExampleFormat, mut out: impl Write) -> Result<()> { - match format { - ExampleFormat::Json => Ok(serde_json::to_writer(out, &self.example)?), - ExampleFormat::Toml => { - Ok(out.write_all(toml::to_string_pretty(&self.example)?.as_bytes())?) + Event::End(TagEnd::Heading(HeadingLevel::H1)) => { + if !name.is_empty() { + anyhow::bail!( + "Found multiple H1 headings. There should only be one with the name of the example." + ); + } + name = mem::take(&mut text); } - ExampleFormat::Md => Ok(write!(out, "{}", self)?), - } - } - - pub async fn setup_project( - &self, - app_state: &Arc, - cx: &mut AsyncApp, - ) -> Result> { - let worktree_path = self.setup_worktree().await?; - - static AUTHENTICATED: OnceLock>> = OnceLock::new(); - - AUTHENTICATED - .get_or_init(|| { - let client = app_state.client.clone(); - cx.spawn(async move |cx| { - client - .sign_in_with_optional_connect(true, cx) - .await - .unwrap(); - }) - .shared() - }) - .clone() - .await; - - let project = cx.update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - })?; - - let worktree = project - .update(cx, |project, cx| { - project.create_worktree(&worktree_path, true, cx) - })? - .await?; - worktree - .read_with(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - })? - .await; - - anyhow::Ok(project) - } - - pub async fn setup_worktree(&self) -> Result { - self.example.setup_worktree(self.file_name()).await - } - - pub fn file_name(&self) -> String { - self.name - .chars() - .map(|c| { - if c.is_whitespace() { - '-' + Event::End(TagEnd::Heading(HeadingLevel::H2)) => { + let title = mem::take(&mut text); + current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { + Section::UncommittedDiff + } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { + Section::EditHistory + } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { + Section::CursorPosition + } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { + Section::ExpectedPatch + } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { + Section::ExpectedExcerpts } else { - c.to_ascii_lowercase() + Section::Other + }; + } + Event::End(TagEnd::Heading(HeadingLevel::H3)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(HeadingLevel::H4)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(level)) => { + anyhow::bail!("Unexpected heading level: {level}"); + } + Event::Start(Tag::CodeBlock(kind)) => { + match kind { + CodeBlockKind::Fenced(info) => { + block_info = info; + } + CodeBlockKind::Indented => { + anyhow::bail!("Unexpected indented codeblock"); + } + }; + } + Event::Start(_) => { + text.clear(); + block_info = "".into(); + } + Event::End(TagEnd::CodeBlock) => { + let block_info = block_info.trim(); + match current_section { + Section::UncommittedDiff => { + example.uncommitted_diff = mem::take(&mut text); + } + Section::EditHistory => { + example.edit_history.push_str(&mem::take(&mut text)); + } + Section::CursorPosition => { + example.cursor_path = Path::new(block_info).into(); + example.cursor_position = mem::take(&mut text); + } + Section::ExpectedExcerpts => { + mem::take(&mut text); + } + Section::ExpectedPatch => { + example.expected_patch = mem::take(&mut text); + } + Section::Other => {} } - }) - .collect() - } - - pub async fn cursor_position( - &self, - project: &Entity, - cx: &mut AsyncApp, - ) -> Result<(Entity, Anchor)> { - let worktree = project.read_with(cx, |project, cx| { - project.visible_worktrees(cx).next().unwrap() - })?; - let cursor_path = RelPath::new(&self.example.cursor_path, PathStyle::Posix)?.into_arc(); - let cursor_buffer = project - .update(cx, |project, cx| { - project.open_buffer( - ProjectPath { - worktree_id: worktree.read(cx).id(), - path: cursor_path, - }, - cx, - ) - })? - .await?; - let cursor_offset_within_excerpt = self - .example - .cursor_position - .find(CURSOR_MARKER) - .ok_or_else(|| anyhow!("missing cursor marker"))?; - let mut cursor_excerpt = self.example.cursor_position.clone(); - cursor_excerpt.replace_range( - cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), - "", - ); - let excerpt_offset = cursor_buffer.read_with(cx, |buffer, _cx| { - let text = buffer.text(); - - let mut matches = text.match_indices(&cursor_excerpt); - let Some((excerpt_offset, _)) = matches.next() else { - anyhow::bail!( - "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Cursor excerpt did not exist in buffer." - ); - }; - assert!(matches.next().is_none()); - - Ok(excerpt_offset) - })??; - - let cursor_offset = excerpt_offset + cursor_offset_within_excerpt; - let cursor_anchor = - cursor_buffer.read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset))?; - Ok((cursor_buffer, cursor_anchor)) - } - - #[must_use] - pub async fn apply_edit_history( - &self, - project: &Entity, - cx: &mut AsyncApp, - ) -> Result> { - edit_prediction::udiff::apply_diff(&self.example.edit_history, project, cx).await - } -} - -async fn run_git(repo_path: &Path, args: &[&str]) -> Result { - let output = smol::process::Command::new("git") - .current_dir(repo_path) - .args(args) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}", - args.join(" "), - repo_path.display(), - output.status, - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout), - ); - Ok(String::from_utf8(output.stdout)?.trim().to_string()) -} - -impl Display for NamedExample { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "# {}\n\n", self.name)?; - write!( - f, - "{REPOSITORY_URL_FIELD} = {}\n", - self.example.repository_url - )?; - write!(f, "{REVISION_FIELD} = {}\n\n", self.example.revision)?; - - write!(f, "## {UNCOMMITTED_DIFF_HEADING}\n\n")?; - write!(f, "`````diff\n")?; - write!(f, "{}", self.example.uncommitted_diff)?; - write!(f, "`````\n")?; - - if !self.example.edit_history.is_empty() { - write!(f, "`````diff\n{}`````\n", self.example.edit_history)?; - } - - write!( - f, - "## {CURSOR_POSITION_HEADING}\n\n`````{}\n{}`````\n", - self.example.cursor_path.display(), - self.example.cursor_position - )?; - write!(f, "## {EDIT_HISTORY_HEADING}\n\n")?; - - if !self.example.expected_patch.is_empty() { - write!( - f, - "\n## {EXPECTED_PATCH_HEADING}\n\n`````diff\n{}`````\n", - self.example.expected_patch - )?; + } + _ => {} } - - Ok(()) } -} - -thread_local! { - static REPO_LOCKS: RefCell>>> = RefCell::new(HashMap::default()); -} + if example.cursor_path.as_ref() == Path::new("") || example.cursor_position.is_empty() { + anyhow::bail!("Missing cursor position codeblock"); + } -#[must_use] -pub async fn lock_repo(path: impl AsRef) -> OwnedMutexGuard<()> { - REPO_LOCKS - .with(|cell| { - cell.borrow_mut() - .entry(path.as_ref().to_path_buf()) - .or_default() - .clone() - }) - .lock_owned() - .await + Ok(example) } diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs new file mode 100644 index 0000000000000000000000000000000000000000..53ef6ebfde77dcecba9926062cdfd75c1ee3521c --- /dev/null +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -0,0 +1,280 @@ +use crate::{ + PromptFormat, + example::{Example, ExamplePrompt}, + headless::EpAppState, + retrieve_context::run_context_retrieval, +}; +use edit_prediction::{EditPredictionStore, zeta2::zeta2_prompt_input}; +use gpui::AsyncApp; +use std::sync::Arc; +use zeta_prompt::format_zeta_prompt; + +pub async fn run_format_prompt( + example: &mut Example, + prompt_format: PromptFormat, + app_state: Arc, + mut cx: AsyncApp, +) { + run_context_retrieval(example, app_state, cx.clone()).await; + + let prompt = match prompt_format { + PromptFormat::Teacher => TeacherPrompt::format(example), + PromptFormat::Zeta2 => { + let ep_store = cx + .update(|cx| EditPredictionStore::try_global(cx).unwrap()) + .unwrap(); + + let state = example.state.as_ref().unwrap(); + let snapshot = state + .buffer + .read_with(&cx, |buffer, _| buffer.snapshot()) + .unwrap(); + let project = state.project.clone(); + let (_, input) = ep_store + .update(&mut cx, |ep_store, _cx| { + zeta2_prompt_input( + &snapshot, + example.context.as_ref().unwrap().files.clone(), + ep_store.edit_history_for_project(&project), + example.cursor_path.clone(), + example.buffer.as_ref().unwrap().cursor_offset, + ) + }) + .unwrap(); + format_zeta_prompt(&input) + } + }; + + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output: example.expected_patch.clone(), // TODO + format: prompt_format, + }); +} + +pub trait PromptFormatter { + fn format(example: &Example) -> String; +} + +pub trait PromptParser { + /// Return unified diff patch of prediction given raw LLM response + fn parse(example: &Example, response: &str) -> String; +} + +pub struct TeacherPrompt; + +impl PromptFormatter for TeacherPrompt { + fn format(example: &Example) -> String { + let edit_history = Self::format_edit_history(&example.edit_history); + let context = Self::format_context(example); + let editable_region = Self::format_editable_region(example); + + let prompt = Self::PROMPT + .replace("{{context}}", &context) + .replace("{{edit_history}}", &edit_history) + .replace("{{editable_region}}", &editable_region); + + prompt + } +} + +impl TeacherPrompt { + const PROMPT: &str = include_str!("teacher.prompt.md"); + pub(crate) const EDITABLE_REGION_START: &str = "<|editable_region_start|>\n"; + pub(crate) const EDITABLE_REGION_END: &str = "<|editable_region_end|>"; + + /// Truncate edit history to this number of last lines + const MAX_HISTORY_LINES: usize = 128; + + fn format_edit_history(edit_history: &str) -> String { + // Strip comments ("garbage lines") from edit history + let lines = edit_history + .lines() + .filter(|&s| Self::is_udiff_content_line(s)) + .collect::>(); + + let history_lines = if lines.len() > Self::MAX_HISTORY_LINES { + &lines[lines.len() - Self::MAX_HISTORY_LINES..] + } else { + &lines + }; + + if history_lines.is_empty() { + return "(No edit history)".to_string(); + } + + history_lines.join("\n") + } + + fn format_context(example: &Example) -> String { + if example.context.is_none() { + panic!("Missing context retriever step"); + } + + let mut prompt = String::new(); + zeta_prompt::write_related_files(&mut prompt, &example.context.as_ref().unwrap().files); + + prompt + } + + fn format_editable_region(example: &Example) -> String { + let mut result = String::new(); + + let path_str = example.cursor_path.to_string_lossy(); + result.push_str(&format!("`````path=\"{path_str}\"\n")); + result.push_str(Self::EDITABLE_REGION_START); + + // TODO: control number of lines around cursor + result.push_str(&example.cursor_position); + if !example.cursor_position.ends_with('\n') { + result.push('\n'); + } + + result.push_str(&format!("{}\n", Self::EDITABLE_REGION_END)); + result.push_str("`````"); + + result + } + + fn extract_editable_region(text: &str) -> String { + let start = text + .find(Self::EDITABLE_REGION_START) + .map_or(0, |pos| pos + Self::EDITABLE_REGION_START.len()); + let end = text.find(Self::EDITABLE_REGION_END).unwrap_or(text.len()); + + let region = &text[start..end]; + + region.replace("<|user_cursor|>", "") + } + + fn is_udiff_content_line(s: &str) -> bool { + s.starts_with("-") + || s.starts_with("+") + || s.starts_with(" ") + || s.starts_with("---") + || s.starts_with("+++") + || s.starts_with("@@") + } +} + +impl PromptParser for TeacherPrompt { + fn parse(example: &Example, response: &str) -> String { + // Ideally, we should always be able to find cursor position in the retrieved context. + // In reality, sometimes we don't find it for these reasons: + // 1. `example.cursor_position` contains _more_ context than included in the retrieved context + // (can be fixed by getting cursor coordinates at the load_example stage) + // 2. Context retriever just didn't include cursor line. + // + // In that case, fallback to using `cursor_position` as excerpt. + let cursor_file = &example + .buffer + .as_ref() + .expect("`buffer` should be filled in in the context collection step") + .content; + + // Extract updated (new) editable region from the model response + let new_editable_region = extract_last_codeblock(response); + + // Reconstruct old editable region we sent to the model + let old_editable_region = Self::format_editable_region(example); + let old_editable_region = Self::extract_editable_region(&old_editable_region); + if !cursor_file.contains(&old_editable_region) { + panic!("Something's wrong: editable_region is not found in the cursor file") + } + + // Apply editable region to a larger context and compute diff. + // This is needed to get a better context lines around the editable region + let edited_file = cursor_file.replace(&old_editable_region, &new_editable_region); + let diff = language::unified_diff(&cursor_file, &edited_file); + + let diff = indoc::formatdoc! {" + --- a/{path} + +++ b/{path} + {diff} + ", + path = example.cursor_path.to_string_lossy(), + diff = diff, + }; + + diff + } +} + +fn extract_last_codeblock(text: &str) -> String { + let mut last_block = None; + let mut search_start = 0; + + while let Some(start) = text[search_start..].find("```") { + let start = start + search_start; + let bytes = text.as_bytes(); + let mut backtick_end = start; + + while backtick_end < bytes.len() && bytes[backtick_end] == b'`' { + backtick_end += 1; + } + + let backtick_count = backtick_end - start; + let closing_backticks = "`".repeat(backtick_count); + + while backtick_end < bytes.len() && bytes[backtick_end] != b'\n' { + backtick_end += 1; + } + + if let Some(end_pos) = text[backtick_end..].find(&closing_backticks) { + let code_block = &text[backtick_end + 1..backtick_end + end_pos - 1]; + last_block = Some(code_block.to_string()); + search_start = backtick_end + end_pos + backtick_count; + } else { + break; + } + } + + last_block.unwrap_or_else(|| text.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_last_code_block() { + let text = indoc::indoc! {" + Some thinking + + ``` + first block + ``` + + `````path='something' lines=1:2 + last block + ````` + "}; + let last_block = extract_last_codeblock(text); + assert_eq!(last_block, "last block"); + } + + #[test] + fn test_extract_editable_region() { + let text = indoc::indoc! {" + some lines + are + here + <|editable_region_start|> + one + two three + + <|editable_region_end|> + more + lines here + "}; + let parsed = TeacherPrompt::extract_editable_region(text); + assert_eq!( + parsed, + indoc::indoc! {" + one + two three + + "} + ); + } +} diff --git a/crates/edit_prediction_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs index c4d8667d63dfb3dd39fbced609e0ae0bc44974d2..fd20774168ea3c07f4efffdefe23f1b4ff5f5ef4 100644 --- a/crates/edit_prediction_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use util::ResultExt as _; /// Headless subset of `workspace::AppState`. -pub struct ZetaCliAppState { +pub struct EpAppState { pub languages: Arc, pub client: Arc, pub user_store: Entity, @@ -25,7 +25,7 @@ pub struct ZetaCliAppState { } // TODO: dedupe with crates/eval/src/eval.rs -pub fn init(cx: &mut App) -> ZetaCliAppState { +pub fn init(cx: &mut App) -> EpAppState { let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|s| AppCommitSha::new(s.to_owned())); let app_version = AppVersion::load( @@ -112,7 +112,7 @@ pub fn init(cx: &mut App) -> ZetaCliAppState { prompt_store::init(cx); terminal_view::init(cx); - ZetaCliAppState { + EpAppState { languages, client, user_store, diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs new file mode 100644 index 0000000000000000000000000000000000000000..842b63a43335454655ed41ef4d852167e8faf72a --- /dev/null +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -0,0 +1,320 @@ +use crate::{ + example::{Example, ExampleBuffer, ExampleState}, + headless::EpAppState, +}; +use anyhow::{Result, anyhow}; +use collections::HashMap; +use edit_prediction::EditPredictionStore; +use edit_prediction::udiff::OpenedBuffers; +use futures::{ + AsyncWriteExt as _, + lock::{Mutex, OwnedMutexGuard}, +}; +use gpui::{AsyncApp, Entity}; +use language::{Anchor, Buffer, ToOffset, ToPoint}; +use project::buffer_store::BufferStoreEvent; +use project::{Project, ProjectPath}; +use std::{ + cell::RefCell, + fs, + path::{Path, PathBuf}, + sync::Arc, +}; +use util::{paths::PathStyle, rel_path::RelPath}; +use zeta_prompt::CURSOR_MARKER; + +pub async fn run_load_project(example: &mut Example, app_state: Arc, mut cx: AsyncApp) { + if example.state.is_some() { + return; + } + + let project = setup_project(example, &app_state, &mut cx).await; + let buffer_store = project + .read_with(&cx, |project, _| project.buffer_store().clone()) + .unwrap(); + + let ep_store = cx + .update(|cx| EditPredictionStore::try_global(cx).unwrap()) + .unwrap(); + + cx.subscribe(&buffer_store, { + let project = project.clone(); + move |_, event, cx| match event { + BufferStoreEvent::BufferAdded(buffer) => { + ep_store.update(cx, |store, cx| store.register_buffer(&buffer, &project, cx)); + } + _ => {} + } + }) + .unwrap() + .detach(); + + let _open_buffers = apply_edit_history(example, &project, &mut cx) + .await + .unwrap(); + let (buffer, cursor_position) = cursor_position(example, &project, &mut cx).await; + example.buffer = buffer + .read_with(&cx, |buffer, _cx| { + let cursor_point = cursor_position.to_point(&buffer); + Some(ExampleBuffer { + content: buffer.text(), + cursor_row: cursor_point.row, + cursor_column: cursor_point.column, + cursor_offset: cursor_position.to_offset(&buffer), + }) + }) + .unwrap(); + example.state = Some(ExampleState { + buffer, + project, + cursor_position, + _open_buffers, + }); +} + +async fn cursor_position( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> (Entity, Anchor) { + let worktree = project + .read_with(cx, |project, cx| { + project.visible_worktrees(cx).next().unwrap() + }) + .unwrap(); + + let cursor_path = RelPath::new(&example.cursor_path, PathStyle::Posix) + .unwrap() + .into_arc(); + let cursor_buffer = project + .update(cx, |project, cx| { + project.open_buffer( + ProjectPath { + worktree_id: worktree.read(cx).id(), + path: cursor_path, + }, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + let cursor_offset_within_excerpt = example + .cursor_position + .find(CURSOR_MARKER) + .ok_or_else(|| anyhow!("missing cursor marker")) + .unwrap(); + let mut cursor_excerpt = example.cursor_position.clone(); + cursor_excerpt.replace_range( + cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), + "", + ); + let excerpt_offset = cursor_buffer.read_with(cx, |buffer, _cx| { + let text = buffer.text(); + + let mut matches = text.match_indices(&cursor_excerpt); + let (excerpt_offset, _) = matches.next().unwrap_or_else(|| { + panic!( + "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Cursor excerpt did not exist in buffer." + ); + }); + assert!(matches.next().is_none(), "More than one cursor position match found for {}", &example.name); + excerpt_offset + }).unwrap(); + + let cursor_offset = excerpt_offset + cursor_offset_within_excerpt; + let cursor_anchor = cursor_buffer + .read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset)) + .unwrap(); + + (cursor_buffer, cursor_anchor) +} + +async fn setup_project( + example: &mut Example, + app_state: &Arc, + cx: &mut AsyncApp, +) -> Entity { + setup_worktree(example).await; + + let project = cx + .update(|cx| { + Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + cx, + ) + }) + .unwrap(); + + let worktree = project + .update(cx, |project, cx| { + project.create_worktree(&example.worktree_path(), true, cx) + }) + .unwrap() + .await + .unwrap(); + worktree + .read_with(cx, |worktree, _cx| { + worktree.as_local().unwrap().scan_complete() + }) + .unwrap() + .await; + project +} + +pub async fn setup_worktree(example: &Example) { + let repo_dir = example.repo_path(); + let repo_lock = lock_repo(&repo_dir).await; + + if !repo_dir.is_dir() { + fs::create_dir_all(&repo_dir).unwrap(); + run_git(&repo_dir, &["init"]).await.unwrap(); + run_git( + &repo_dir, + &["remote", "add", "origin", &example.repository_url], + ) + .await + .unwrap(); + } + + // Resolve the example to a revision, fetching it if needed. + let revision = run_git( + &repo_dir, + &["rev-parse", &format!("{}^{{commit}}", example.revision)], + ) + .await; + let revision = if let Ok(revision) = revision { + revision + } else { + if run_git( + &repo_dir, + &["fetch", "--depth", "1", "origin", &example.revision], + ) + .await + .is_err() + { + run_git(&repo_dir, &["fetch", "origin"]).await.unwrap(); + } + let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]) + .await + .unwrap(); + if revision != example.revision { + run_git(&repo_dir, &["tag", &example.revision, &revision]) + .await + .unwrap(); + } + revision + }; + + // Create the worktree for this example if needed. + let worktree_path = example.worktree_path(); + if worktree_path.is_dir() { + run_git(&worktree_path, &["clean", "--force", "-d"]) + .await + .unwrap(); + run_git(&worktree_path, &["reset", "--hard", "HEAD"]) + .await + .unwrap(); + run_git(&worktree_path, &["checkout", revision.as_str()]) + .await + .unwrap(); + } else { + let worktree_path_string = worktree_path.to_string_lossy(); + run_git( + &repo_dir, + &["branch", "-f", &example.name, revision.as_str()], + ) + .await + .unwrap(); + run_git( + &repo_dir, + &[ + "worktree", + "add", + "-f", + &worktree_path_string, + &example.name, + ], + ) + .await + .unwrap(); + } + drop(repo_lock); + + // Apply the uncommitted diff for this example. + if !example.uncommitted_diff.is_empty() { + let mut apply_process = smol::process::Command::new("git") + .current_dir(&worktree_path) + .args(&["apply", "-"]) + .stdin(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = apply_process.stdin.take().unwrap(); + stdin + .write_all(example.uncommitted_diff.as_bytes()) + .await + .unwrap(); + stdin.close().await.unwrap(); + drop(stdin); + + let apply_result = apply_process.output().await.unwrap(); + if !apply_result.status.success() { + panic!( + "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", + apply_result.status, + String::from_utf8_lossy(&apply_result.stderr), + String::from_utf8_lossy(&apply_result.stdout), + ); + } + } +} + +async fn apply_edit_history( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> Result { + edit_prediction::udiff::apply_diff(&example.edit_history, project, cx).await +} + +thread_local! { + static REPO_LOCKS: RefCell>>> = RefCell::new(HashMap::default()); +} + +#[must_use] +pub async fn lock_repo(path: impl AsRef) -> OwnedMutexGuard<()> { + REPO_LOCKS + .with(|cell| { + cell.borrow_mut() + .entry(path.as_ref().to_path_buf()) + .or_default() + .clone() + }) + .lock_owned() + .await +} + +async fn run_git(repo_path: &Path, args: &[&str]) -> Result { + let output = smol::process::Command::new("git") + .current_dir(repo_path) + .args(args) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}", + args.join(" "), + repo_path.display(), + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 00086777f1f03112b92f11923ad2d025276699f5..51ea23649d0ec0b124c38ead2897ba16ecd96e26 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -1,522 +1,196 @@ -mod evaluate; +mod anthropic_client; mod example; +mod format_prompt; mod headless; +mod load_project; mod metrics; mod paths; mod predict; -mod source_location; -mod training; -mod util; +mod retrieve_context; +mod score; -use crate::{ - evaluate::run_evaluate, - example::{ExampleFormat, NamedExample}, - headless::ZetaCliAppState, - predict::run_predict, - source_location::SourceLocation, - training::{context::ContextType, distill::run_distill}, - util::{open_buffer, open_buffer_with_language_server}, -}; -use ::util::{ResultExt, paths::PathStyle}; -use anyhow::{Result, anyhow}; -use clap::{Args, Parser, Subcommand, ValueEnum}; -use cloud_llm_client::predict_edits_v3; -use edit_prediction::udiff::DiffLine; -use edit_prediction_context::EditPredictionExcerptOptions; -use gpui::{Application, AsyncApp, Entity, prelude::*}; -use language::{Bias, Buffer, BufferSnapshot, Point}; -use metrics::delta_chr_f; -use project::{Project, Worktree, lsp_store::OpenLspBufferHandle}; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use edit_prediction::EditPredictionStore; +use gpui::Application; use reqwest_client::ReqwestClient; -use std::io::{self}; -use std::{collections::HashSet, path::PathBuf, str::FromStr, sync::Arc}; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, sync::Arc}; + +use crate::example::{read_examples, write_examples}; +use crate::format_prompt::run_format_prompt; +use crate::load_project::run_load_project; +use crate::predict::run_prediction; +use crate::retrieve_context::run_context_retrieval; +use crate::score::run_scoring; #[derive(Parser, Debug)] -#[command(name = "zeta")] -struct ZetaCliArgs { +#[command(name = "ep")] +struct EpArgs { #[arg(long, default_value_t = false)] printenv: bool, + #[clap(long, default_value_t = 10)] + max_parallelism: usize, #[command(subcommand)] command: Option, + #[clap(global = true)] + inputs: Vec, + #[arg(long, short, global = true)] + output: Option, + #[arg(long, short, global = true)] + in_place: bool, } #[derive(Subcommand, Debug)] enum Command { - Context(ContextArgs), - Predict(PredictArguments), - Eval(EvaluateArguments), - Distill(DistillArguments), - ConvertExample { - path: PathBuf, - #[arg(long, value_enum, default_value_t = ExampleFormat::Md)] - output_format: ExampleFormat, - }, - Score { - golden_patch: PathBuf, - actual_patch: PathBuf, - }, + /// Parse markdown examples and output a combined .jsonl file + ParseExample, + /// Create git worktrees for each example and load file contents + LoadBuffer, + /// Retrieve context for input examples. + Context, + /// Generate a prompt string for a specific model + FormatPrompt(FormatPromptArgs), + /// Runs edit prediction + Predict(PredictArgs), + /// Computes a score based on actual and expected patches + Score(PredictArgs), + /// Print aggregated scores + Eval(PredictArgs), + /// Remove git repositories and worktrees Clean, } #[derive(Debug, Args)] -struct ContextArgs { - #[arg(long)] - provider: ContextProvider, - #[arg(long)] - worktree: PathBuf, - #[arg(long)] - cursor: SourceLocation, - #[arg(long)] - use_language_server: bool, - #[arg(long)] - edit_history: Option, - #[clap(flatten)] - zeta2_args: Zeta2Args, -} - -#[derive(clap::ValueEnum, Default, Debug, Clone, Copy)] -enum ContextProvider { - Zeta1, - #[default] - Zeta2, -} - -#[derive(Clone, Debug, Args)] -struct Zeta2Args { - #[arg(long, default_value_t = 8192)] - max_prompt_bytes: usize, - #[arg(long, default_value_t = 2048)] - max_excerpt_bytes: usize, - #[arg(long, default_value_t = 1024)] - min_excerpt_bytes: usize, - #[arg(long, default_value_t = 0.66)] - target_before_cursor_over_total_bytes: f32, - #[arg(long, default_value_t = 1024)] - max_diagnostic_bytes: usize, - #[arg(long, value_enum, default_value_t = PromptFormat::default())] +struct FormatPromptArgs { + #[clap(long)] prompt_format: PromptFormat, - #[arg(long, value_enum, default_value_t = Default::default())] - output_format: OutputFormat, - #[arg(long, default_value_t = 42)] - file_indexing_parallelism: usize, - #[arg(long, default_value_t = false)] - disable_imports_gathering: bool, - #[arg(long, default_value_t = u8::MAX)] - max_retrieved_definitions: u8, } -#[derive(Debug, Args)] -pub struct PredictArguments { - #[clap(long, short, value_enum, default_value_t = PredictionsOutputFormat::Md)] - format: PredictionsOutputFormat, - example_path: PathBuf, - #[clap(flatten)] - options: PredictionOptions, +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] +enum PromptFormat { + Teacher, + Zeta2, } #[derive(Debug, Args)] -pub struct DistillArguments { - split_commit_dataset: PathBuf, - #[clap(long, value_enum, default_value_t = ContextType::CurrentFile)] - context_type: ContextType, - #[clap(long)] - batch: Option, -} - -#[derive(Clone, Debug, Args)] -pub struct PredictionOptions { - #[clap(flatten)] - zeta2: Zeta2Args, +struct PredictArgs { #[clap(long)] provider: PredictionProvider, - #[clap(long, value_enum, default_value_t = CacheMode::default())] - cache: CacheMode, -} - -#[derive(Debug, ValueEnum, Default, Clone, Copy, PartialEq)] -pub enum CacheMode { - /// Use cached LLM requests and responses, except when multiple repetitions are requested - #[default] - Auto, - /// Use cached LLM requests and responses, based on the hash of the prompt and the endpoint. - #[value(alias = "request")] - Requests, - /// Ignore existing cache entries for both LLM and search. - Skip, - /// Use cached LLM responses AND search results for full determinism. Fails if they haven't been cached yet. - /// Useful for reproducing results and fixing bugs outside of search queries - Force, -} - -impl CacheMode { - fn use_cached_llm_responses(&self) -> bool { - self.assert_not_auto(); - matches!(self, CacheMode::Requests | CacheMode::Force) - } - - fn use_cached_search_results(&self) -> bool { - self.assert_not_auto(); - matches!(self, CacheMode::Force) - } - - fn assert_not_auto(&self) { - assert_ne!( - *self, - CacheMode::Auto, - "Cache mode should not be auto at this point!" - ); - } -} - -#[derive(clap::ValueEnum, Debug, Clone)] -pub enum PredictionsOutputFormat { - Json, - Md, - Diff, + #[clap(long, default_value_t = 1)] + repetitions: usize, } -#[derive(Debug, Args)] -pub struct EvaluateArguments { - example_paths: Vec, - #[clap(flatten)] - options: PredictionOptions, - #[clap(short, long, default_value_t = 1, alias = "repeat")] - repetitions: u16, - #[arg(long)] - skip_prediction: bool, -} - -#[derive(clap::ValueEnum, Default, Debug, Clone, Copy, PartialEq)] +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] enum PredictionProvider { + Sweep, + Mercury, Zeta1, - #[default] Zeta2, - Sweep, -} - -fn zeta2_args_to_options(args: &Zeta2Args) -> edit_prediction::ZetaOptions { - edit_prediction::ZetaOptions { - context: EditPredictionExcerptOptions { - max_bytes: args.max_excerpt_bytes, - min_bytes: args.min_excerpt_bytes, - target_before_cursor_over_total_bytes: args.target_before_cursor_over_total_bytes, - }, - max_prompt_bytes: args.max_prompt_bytes, - prompt_format: args.prompt_format.into(), - } -} - -#[derive(clap::ValueEnum, Default, Debug, Clone, Copy)] -enum PromptFormat { - OnlySnippets, - #[default] - OldTextNewText, - Minimal, - MinimalQwen, - SeedCoder1120, + Teacher, } -impl Into for PromptFormat { - fn into(self) -> predict_edits_v3::PromptFormat { - match self { - Self::OnlySnippets => predict_edits_v3::PromptFormat::OnlySnippets, - Self::OldTextNewText => predict_edits_v3::PromptFormat::OldTextNewText, - Self::Minimal => predict_edits_v3::PromptFormat::Minimal, - Self::MinimalQwen => predict_edits_v3::PromptFormat::MinimalQwen, - Self::SeedCoder1120 => predict_edits_v3::PromptFormat::SeedCoder1120, +impl EpArgs { + fn output_path(&self) -> Option { + if self.in_place { + if self.inputs.len() == 1 { + self.inputs.first().cloned() + } else { + panic!("--in-place requires exactly one input file") + } + } else { + self.output.clone() } } } -#[derive(clap::ValueEnum, Default, Debug, Clone)] -enum OutputFormat { - #[default] - Prompt, - Request, - Full, -} - -#[derive(Debug, Clone)] -enum FileOrStdin { - File(PathBuf), - Stdin, -} +fn main() { + zlog::init(); + zlog::init_output_stderr(); + let args = EpArgs::parse(); -impl FileOrStdin { - async fn read_to_string(&self) -> Result { - match self { - FileOrStdin::File(path) => smol::fs::read_to_string(path).await, - FileOrStdin::Stdin => smol::unblock(|| std::io::read_to_string(std::io::stdin())).await, - } + if args.printenv { + ::util::shell_env::print_env(); + return; } -} - -impl FromStr for FileOrStdin { - type Err = ::Err; - fn from_str(s: &str) -> Result { - match s { - "-" => Ok(Self::Stdin), - _ => Ok(Self::File(PathBuf::from_str(s)?)), + let output = args.output_path(); + let command = match args.command { + Some(cmd) => cmd, + None => { + EpArgs::command().print_help().unwrap(); + return; } - } -} - -struct LoadedContext { - full_path_str: String, - snapshot: BufferSnapshot, - clipped_cursor: Point, - worktree: Entity, - project: Entity, - buffer: Entity, - lsp_open_handle: Option, -} - -async fn load_context( - args: &ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let ContextArgs { - worktree: worktree_path, - cursor, - use_language_server, - .. - } = args; - - let worktree_path = worktree_path.canonicalize()?; - - let project = cx.update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - })?; - - let worktree = project - .update(cx, |project, cx| { - project.create_worktree(&worktree_path, true, cx) - })? - .await?; - - let mut ready_languages = HashSet::default(); - let (lsp_open_handle, buffer) = if *use_language_server { - let (lsp_open_handle, _, buffer) = open_buffer_with_language_server( - project.clone(), - worktree.clone(), - cursor.path.clone(), - &mut ready_languages, - cx, - ) - .await?; - (Some(lsp_open_handle), buffer) - } else { - let buffer = - open_buffer(project.clone(), worktree.clone(), cursor.path.clone(), cx).await?; - (None, buffer) }; - let full_path_str = worktree - .read_with(cx, |worktree, _| worktree.root_name().join(&cursor.path))? - .display(PathStyle::local()) - .to_string(); - - let snapshot = cx.update(|cx| buffer.read(cx).snapshot())?; - let clipped_cursor = snapshot.clip_point(cursor.point, Bias::Left); - if clipped_cursor != cursor.point { - let max_row = snapshot.max_point().row; - if cursor.point.row < max_row { - return Err(anyhow!( - "Cursor position {:?} is out of bounds (line length is {})", - cursor.point, - snapshot.line_len(cursor.point.row) - )); - } else { - return Err(anyhow!( - "Cursor position {:?} is out of bounds (max row is {})", - cursor.point, - max_row - )); + match &command { + Command::Clean => { + std::fs::remove_dir_all(&*paths::DATA_DIR).unwrap(); + return; } + _ => {} } - Ok(LoadedContext { - full_path_str, - snapshot, - clipped_cursor, - worktree, - project, - buffer, - lsp_open_handle, - }) -} - -async fn zeta2_context( - args: ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let LoadedContext { - worktree, - project, - buffer, - clipped_cursor, - lsp_open_handle: _handle, - .. - } = load_context(&args, app_state, cx).await?; - - // wait for worktree scan before starting zeta2 so that wait_for_initial_indexing waits for - // the whole worktree. - worktree - .read_with(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - })? - .await; - let output = cx - .update(|cx| { - let store = cx.new(|cx| { - edit_prediction::EditPredictionStore::new( - app_state.client.clone(), - app_state.user_store.clone(), - cx, - ) - }); - store.update(cx, |store, cx| { - store.set_options(zeta2_args_to_options(&args.zeta2_args)); - store.register_buffer(&buffer, &project, cx); - }); - cx.spawn(async move |cx| { - let updates_rx = store.update(cx, |store, cx| { - let cursor = buffer.read(cx).snapshot().anchor_before(clipped_cursor); - store.set_use_context(true); - store.refresh_context(&project, &buffer, cursor, cx); - store.project_context_updates(&project).unwrap() - })?; - - updates_rx.recv().await.ok(); - - let context = store.update(cx, |store, cx| { - store.context_for_project(&project, cx).to_vec() - })?; - - anyhow::Ok(serde_json::to_string_pretty(&context).unwrap()) - }) - })? - .await?; - - Ok(output) -} - -async fn zeta1_context( - args: ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let LoadedContext { - full_path_str, - snapshot, - clipped_cursor, - .. - } = load_context(&args, app_state, cx).await?; - - let events = match args.edit_history { - Some(events) => events.read_to_string().await?, - None => String::new(), - }; - - let prompt_for_events = move || (events, 0); - cx.update(|cx| { - edit_prediction::zeta1::gather_context( - full_path_str, - &snapshot, - clipped_cursor, - prompt_for_events, - cloud_llm_client::PredictEditsRequestTrigger::Cli, - cx, - ) - })? - .await -} - -fn main() { - zlog::init(); - zlog::init_output_stderr(); - let args = ZetaCliArgs::parse(); + let mut examples = read_examples(&args.inputs); let http_client = Arc::new(ReqwestClient::new()); let app = Application::headless().with_http_client(http_client); app.run(move |cx| { let app_state = Arc::new(headless::init(cx)); + EditPredictionStore::global(&app_state.client, &app_state.user_store, cx); + cx.spawn(async move |cx| { - match args.command { - None => { - if args.printenv { - ::util::shell_env::print_env(); - } else { - panic!("Expected a command"); - } - } - Some(Command::Context(context_args)) => { - let result = match context_args.provider { - ContextProvider::Zeta1 => { - let context = - zeta1_context(context_args, &app_state, cx).await.unwrap(); - serde_json::to_string_pretty(&context.body).unwrap() - } - ContextProvider::Zeta2 => { - zeta2_context(context_args, &app_state, cx).await.unwrap() + match &command { + Command::Predict(args) => predict::sync_batches(&args.provider).await, + _ => (), + }; + + for data in examples.chunks_mut(args.max_parallelism) { + let mut futures = Vec::new(); + for example in data.iter_mut() { + let cx = cx.clone(); + let app_state = app_state.clone(); + futures.push(async { + match &command { + Command::ParseExample => {} + Command::LoadBuffer => { + run_load_project(example, app_state.clone(), cx).await; + } + Command::Context => { + run_context_retrieval(example, app_state, cx).await; + } + Command::FormatPrompt(args) => { + run_format_prompt(example, args.prompt_format, app_state, cx).await; + } + Command::Predict(args) => { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state.clone(), + cx, + ) + .await; + } + Command::Score(args) | Command::Eval(args) => { + run_scoring(example, &args, app_state, cx).await; + } + Command::Clean => { + unreachable!() + } } - }; - println!("{}", result); - } - Some(Command::Predict(arguments)) => { - run_predict(arguments, &app_state, cx).await; - } - Some(Command::Eval(arguments)) => { - run_evaluate(arguments, &app_state, cx).await; + }); } - Some(Command::Distill(arguments)) => { - let _guard = cx - .update(|cx| gpui_tokio::Tokio::handle(cx)) - .unwrap() - .enter(); - run_distill(arguments).await.log_err(); - } - Some(Command::ConvertExample { - path, - output_format, - }) => { - let example = NamedExample::load(path).unwrap(); - example.write(output_format, io::stdout()).unwrap(); - } - Some(Command::Score { - golden_patch, - actual_patch, - }) => { - let golden_content = std::fs::read_to_string(golden_patch).unwrap(); - let actual_content = std::fs::read_to_string(actual_patch).unwrap(); - - let golden_diff: Vec = golden_content - .lines() - .map(|line| DiffLine::parse(line)) - .collect(); + futures::future::join_all(futures).await; + } - let actual_diff: Vec = actual_content - .lines() - .map(|line| DiffLine::parse(line)) - .collect(); + if args.output.is_some() || !matches!(command, Command::Eval(_)) { + write_examples(&examples, output.as_ref()); + } - let score = delta_chr_f(&golden_diff, &actual_diff); - println!("{:.2}", score); - } - Some(Command::Clean) => { - std::fs::remove_dir_all(&*crate::paths::TARGET_ZETA_DIR).unwrap() - } + match &command { + Command::Predict(args) => predict::sync_batches(&args.provider).await, + Command::Eval(_) => score::print_report(&examples), + _ => (), }; let _ = cx.update(|cx| cx.quit()); diff --git a/crates/edit_prediction_cli/src/metrics.rs b/crates/edit_prediction_cli/src/metrics.rs index 0fdb7fb535df12d00341997a64a96b97867f6f28..b3e5eb8688724c821953a56c4fe82e67c75e13b6 100644 --- a/crates/edit_prediction_cli/src/metrics.rs +++ b/crates/edit_prediction_cli/src/metrics.rs @@ -1,30 +1,34 @@ use collections::{HashMap, HashSet}; use edit_prediction::udiff::DiffLine; +use serde::{Deserialize, Serialize}; type Counts = HashMap; type CountsDelta = HashMap; -#[derive(Default, Debug, Clone)] -pub struct Scores { +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct ClassificationMetrics { pub true_positives: usize, pub false_positives: usize, pub false_negatives: usize, } -impl Scores { - pub fn from_sets(expected: &HashSet, actual: &HashSet) -> Scores { +impl ClassificationMetrics { + pub fn from_sets( + expected: &HashSet, + actual: &HashSet, + ) -> ClassificationMetrics { let true_positives = expected.intersection(actual).count(); let false_positives = actual.difference(expected).count(); let false_negatives = expected.difference(actual).count(); - Scores { + ClassificationMetrics { true_positives, false_positives, false_negatives, } } - pub fn from_counts(expected: &Counts, actual: &Counts) -> Scores { + pub fn from_counts(expected: &Counts, actual: &Counts) -> ClassificationMetrics { let mut true_positives = 0; let mut false_positives = 0; let mut false_negatives = 0; @@ -45,32 +49,16 @@ impl Scores { } } - Scores { + ClassificationMetrics { true_positives, false_positives, false_negatives, } } - pub fn to_markdown(&self) -> String { - format!( - " -Precision : {:.4} -Recall : {:.4} -F1 Score : {:.4} -True Positives : {} -False Positives : {} -False Negatives : {}", - self.precision(), - self.recall(), - self.f1_score(), - self.true_positives, - self.false_positives, - self.false_negatives - ) - } - - pub fn aggregate<'a>(scores: impl Iterator) -> Scores { + pub fn aggregate<'a>( + scores: impl Iterator, + ) -> ClassificationMetrics { let mut true_positives = 0; let mut false_positives = 0; let mut false_negatives = 0; @@ -81,7 +69,7 @@ False Negatives : {}", false_negatives += score.false_negatives; } - Scores { + ClassificationMetrics { true_positives, false_positives, false_negatives, @@ -115,7 +103,10 @@ False Negatives : {}", } } -pub fn line_match_score(expected_patch: &[DiffLine], actual_patch: &[DiffLine]) -> Scores { +pub fn line_match_score( + expected_patch: &[DiffLine], + actual_patch: &[DiffLine], +) -> ClassificationMetrics { let expected_change_lines = expected_patch .iter() .filter(|line| matches!(line, DiffLine::Addition(_) | DiffLine::Deletion(_))) @@ -128,7 +119,7 @@ pub fn line_match_score(expected_patch: &[DiffLine], actual_patch: &[DiffLine]) .map(|line| line.to_string()) .collect(); - Scores::from_sets(&expected_change_lines, &actual_change_lines) + ClassificationMetrics::from_sets(&expected_change_lines, &actual_change_lines) } enum ChrfWhitespace { @@ -204,7 +195,7 @@ pub fn delta_chr_f(expected: &[DiffLine], actual: &[DiffLine]) -> f64 { let expected_counts = ngram_delta_to_counts(&expected_delta); let actual_counts = ngram_delta_to_counts(&actual_delta); - let score = Scores::from_counts(&expected_counts, &actual_counts); + let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); total_precision += score.precision(); total_recall += score.recall(); } diff --git a/crates/edit_prediction_cli/src/paths.rs b/crates/edit_prediction_cli/src/paths.rs index 3cc2beec5bd50380b9eef8b502dcba0ccba32772..0f470fae556b6d61739ab77083d7edbedf77ef89 100644 --- a/crates/edit_prediction_cli/src/paths.rs +++ b/crates/edit_prediction_cli/src/paths.rs @@ -1,57 +1,25 @@ -use std::{env, path::PathBuf, sync::LazyLock}; +use std::{ + path::{Path, PathBuf}, + sync::LazyLock, +}; -pub static TARGET_ZETA_DIR: LazyLock = - LazyLock::new(|| env::current_dir().unwrap().join("target/zeta")); -pub static CACHE_DIR: LazyLock = LazyLock::new(|| TARGET_ZETA_DIR.join("cache")); -pub static REPOS_DIR: LazyLock = LazyLock::new(|| TARGET_ZETA_DIR.join("repos")); -pub static WORKTREES_DIR: LazyLock = LazyLock::new(|| TARGET_ZETA_DIR.join("worktrees")); +pub static DATA_DIR: LazyLock = LazyLock::new(|| { + let dir = dirs::home_dir().unwrap().join(".zed_ep"); + ensure_dir(&dir) +}); +pub static CACHE_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("cache"))); +pub static REPOS_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("repos"))); +pub static WORKTREES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&DATA_DIR.join("worktrees"))); pub static RUN_DIR: LazyLock = LazyLock::new(|| { - TARGET_ZETA_DIR + DATA_DIR .join("runs") .join(chrono::Local::now().format("%d-%m-%y-%H_%M_%S").to_string()) }); -pub static LATEST_EXAMPLE_RUN_DIR: LazyLock = - LazyLock::new(|| TARGET_ZETA_DIR.join("latest")); - -pub fn print_run_data_dir(deep: bool, use_color: bool) { - println!("\n## Run Data\n"); - let mut files = Vec::new(); - - let current_dir = std::env::current_dir().unwrap(); - for file in std::fs::read_dir(&*RUN_DIR).unwrap() { - let file = file.unwrap(); - if file.file_type().unwrap().is_dir() && deep { - for file in std::fs::read_dir(file.path()).unwrap() { - let path = file.unwrap().path(); - let path = path.strip_prefix(¤t_dir).unwrap_or(&path); - files.push(format!( - "- {}/{}{}{}", - path.parent().unwrap().display(), - if use_color { "\x1b[34m" } else { "" }, - path.file_name().unwrap().display(), - if use_color { "\x1b[0m" } else { "" }, - )); - } - } else { - let path = file.path(); - let path = path.strip_prefix(¤t_dir).unwrap_or(&path); - files.push(format!( - "- {}/{}{}{}", - path.parent().unwrap().display(), - if use_color { "\x1b[34m" } else { "" }, - path.file_name().unwrap().display(), - if use_color { "\x1b[0m" } else { "" } - )); - } - } - files.sort(); - - for file in files { - println!("{}", file); - } +pub static LATEST_EXAMPLE_RUN_DIR: LazyLock = LazyLock::new(|| DATA_DIR.join("latest")); +pub static LLM_CACHE_DB: LazyLock = LazyLock::new(|| CACHE_DIR.join("llm_cache.sqlite")); - println!( - "\n💡 Tip of the day: {} always points to the latest run\n", - LATEST_EXAMPLE_RUN_DIR.display() - ); +fn ensure_dir(path: &Path) -> PathBuf { + std::fs::create_dir_all(path).expect("Failed to create directory"); + path.to_path_buf() } diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 74e939b887ce15790993ec15f5973c7f5fd01866..11ed0e3bab0551d1e9d3e87cc98ef91ee015ac13 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -1,374 +1,271 @@ -use crate::example::{ActualExcerpt, NamedExample}; -use crate::headless::ZetaCliAppState; -use crate::paths::{CACHE_DIR, LATEST_EXAMPLE_RUN_DIR, RUN_DIR, print_run_data_dir}; use crate::{ - CacheMode, PredictArguments, PredictionOptions, PredictionProvider, PredictionsOutputFormat, + PredictionProvider, PromptFormat, + anthropic_client::AnthropicClient, + example::{Example, ExamplePrediction}, + format_prompt::{PromptParser, TeacherPrompt, run_format_prompt}, + headless::EpAppState, + load_project::run_load_project, + paths::{LATEST_EXAMPLE_RUN_DIR, RUN_DIR}, + retrieve_context::run_context_retrieval, +}; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, future::Shared}; +use gpui::{AppContext as _, AsyncApp, Task}; +use std::{ + fs, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicUsize, Ordering::SeqCst}, + }, }; -use ::serde::Serialize; -use anyhow::{Context, Result, anyhow}; -use cloud_zeta2_prompt::{CURSOR_MARKER, write_codeblock}; -use edit_prediction::{EditPredictionStore, EvalCache, EvalCacheEntryKind, EvalCacheKey}; -use futures::StreamExt as _; -use gpui::{AppContext, AsyncApp, Entity}; -use project::Project; -use project::buffer_store::BufferStoreEvent; -use serde::Deserialize; -use std::fs; -use std::io::{IsTerminal, Write}; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::{Duration, Instant}; -pub async fn run_predict( - args: PredictArguments, - app_state: &Arc, - cx: &mut AsyncApp, +pub async fn run_prediction( + example: &mut Example, + provider: Option, + repetition_count: usize, + app_state: Arc, + mut cx: AsyncApp, ) { - let example = NamedExample::load(args.example_path).unwrap(); - let project = example.setup_project(app_state, cx).await.unwrap(); - let store = setup_store(args.options.provider, &project, app_state, cx).unwrap(); - let _edited_buffers = example.apply_edit_history(&project, cx).await.unwrap(); - let result = perform_predict(example, project, store, None, args.options, cx) - .await - .unwrap(); - result.write(args.format, std::io::stdout()).unwrap(); - - print_run_data_dir(true, std::io::stdout().is_terminal()); -} - -pub fn setup_store( - provider: PredictionProvider, - project: &Entity, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result> { - let store = cx.new(|cx| { - edit_prediction::EditPredictionStore::new( - app_state.client.clone(), - app_state.user_store.clone(), - cx, - ) - })?; + if !example.predictions.is_empty() { + return; + } - store.update(cx, |store, _cx| { - let model = match provider { - PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta1, - PredictionProvider::Zeta2 => edit_prediction::EditPredictionModel::Zeta2, - PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, - }; - store.set_edit_prediction_model(model); - })?; + run_load_project(example, app_state.clone(), cx.clone()).await; + run_context_retrieval(example, app_state.clone(), cx.clone()).await; - let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + let provider = provider.unwrap(); - cx.subscribe(&buffer_store, { - let project = project.clone(); - let store = store.clone(); - move |_, event, cx| match event { - BufferStoreEvent::BufferAdded(buffer) => { - store.update(cx, |store, cx| store.register_buffer(&buffer, &project, cx)); - } - _ => {} + if matches!(provider, PredictionProvider::Teacher) { + if example.prompt.is_none() { + run_format_prompt(example, PromptFormat::Teacher, app_state.clone(), cx).await; } - })? - .detach(); - anyhow::Ok(store) -} - -pub async fn perform_predict( - example: NamedExample, - project: Entity, - store: Entity, - repetition_ix: Option, - options: PredictionOptions, - cx: &mut AsyncApp, -) -> Result { - let mut cache_mode = options.cache; - if repetition_ix.is_some() { - if cache_mode != CacheMode::Auto && cache_mode != CacheMode::Skip { - panic!("Repetitions are not supported in Auto cache mode"); - } else { - cache_mode = CacheMode::Skip; - } - } else if cache_mode == CacheMode::Auto { - cache_mode = CacheMode::Requests; + let batched = true; + return predict_anthropic(example, repetition_count, batched).await; } - let mut example_run_dir = RUN_DIR.join(&example.file_name()); - if let Some(repetition_ix) = repetition_ix { - example_run_dir = example_run_dir.join(format!("{:03}", repetition_ix)); - } - fs::create_dir_all(&example_run_dir)?; - if LATEST_EXAMPLE_RUN_DIR.is_symlink() { - fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR)?; + if matches!( + provider, + PredictionProvider::Zeta1 | PredictionProvider::Zeta2 + ) { + static AUTHENTICATED: OnceLock>> = OnceLock::new(); + AUTHENTICATED + .get_or_init(|| { + let client = app_state.client.clone(); + cx.spawn(async move |cx| { + client + .sign_in_with_optional_connect(true, cx) + .await + .unwrap(); + }) + .shared() + }) + .clone() + .await; } - #[cfg(unix)] - std::os::unix::fs::symlink(&example_run_dir, &*LATEST_EXAMPLE_RUN_DIR) - .context("creating latest link")?; - - #[cfg(windows)] - std::os::windows::fs::symlink_dir(&example_run_dir, &*LATEST_EXAMPLE_RUN_DIR) - .context("creating latest link")?; - - store.update(cx, |store, _cx| { - store.with_eval_cache(Arc::new(RunCache { - example_run_dir: example_run_dir.clone(), - cache_mode, - })); - })?; - - let (cursor_buffer, cursor_anchor) = example.cursor_position(&project, cx).await?; - - let result = Arc::new(Mutex::new(PredictionDetails::new(example_run_dir.clone()))); - - let prompt_format = options.zeta2.prompt_format; - - store.update(cx, |store, _cx| { - let mut options = store.options().clone(); - options.prompt_format = prompt_format.into(); - store.set_options(options); - })?; + let ep_store = cx + .update(|cx| EditPredictionStore::try_global(cx).unwrap()) + .unwrap(); - let mut debug_task = gpui::Task::ready(Ok(())); + ep_store + .update(&mut cx, |store, _cx| { + let model = match provider { + PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta1, + PredictionProvider::Zeta2 => edit_prediction::EditPredictionModel::Zeta2, + PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, + PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, + PredictionProvider::Teacher => unreachable!(), + }; + store.set_edit_prediction_model(model); + }) + .unwrap(); + let state = example.state.as_ref().unwrap(); + let run_dir = RUN_DIR.join(&example.name); - if options.provider == crate::PredictionProvider::Zeta2 { - let mut debug_rx = store.update(cx, |store, _| store.debug_info())?; + let updated_example = Arc::new(Mutex::new(example.clone())); + let current_run_ix = Arc::new(AtomicUsize::new(0)); - debug_task = cx.background_spawn({ - let result = result.clone(); - async move { - let mut start_time = None; - let mut retrieval_finished_at = None; - while let Some(event) = debug_rx.next().await { - match event { - edit_prediction::DebugEvent::ContextRetrievalStarted(info) => { - start_time = Some(info.timestamp); - fs::write( - example_run_dir.join("search_prompt.md"), - &info.search_prompt, - )?; + let mut debug_rx = ep_store + .update(&mut cx, |store, cx| store.debug_info(&state.project, cx)) + .unwrap(); + let debug_task = cx.background_spawn({ + let updated_example = updated_example.clone(); + let current_run_ix = current_run_ix.clone(); + let run_dir = run_dir.clone(); + async move { + while let Some(event) = debug_rx.next().await { + let run_ix = current_run_ix.load(SeqCst); + let mut updated_example = updated_example.lock().unwrap(); + + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", run_ix)) + } else { + run_dir.clone() + }; + + match event { + DebugEvent::EditPredictionStarted(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(prompt) = request.prompt { + fs::write(run_dir.join("prediction_prompt.md"), &prompt)?; } - edit_prediction::DebugEvent::ContextRetrievalFinished(info) => { - retrieval_finished_at = Some(info.timestamp); - for (key, value) in &info.metadata { - if *key == "search_queries" { - fs::write( - example_run_dir.join("search_queries.json"), - value.as_bytes(), - )?; - } - } + } + DebugEvent::EditPredictionFinished(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(output) = request.model_output { + fs::write(run_dir.join("prediction_response.md"), &output)?; + updated_example + .predictions + .last_mut() + .unwrap() + .actual_output = output; } - edit_prediction::DebugEvent::EditPredictionRequested(request) => { - let prediction_started_at = Instant::now(); - start_time.get_or_insert(prediction_started_at); - let prompt = request.local_prompt.unwrap_or_default(); - fs::write(example_run_dir.join("prediction_prompt.md"), &prompt)?; - - { - let mut result = result.lock().unwrap(); - result.prompt_len = prompt.chars().count(); - - for included_file in request.inputs.included_files { - let insertions = - vec![(request.inputs.cursor_point, CURSOR_MARKER)]; - result.excerpts.extend(included_file.excerpts.iter().map( - |excerpt| ActualExcerpt { - path: included_file.path.components().skip(1).collect(), - text: String::from(excerpt.text.as_ref()), - }, - )); - write_codeblock( - &included_file.path, - included_file.excerpts.iter(), - if included_file.path == request.inputs.cursor_path { - &insertions - } else { - &[] - }, - included_file.max_row, - false, - &mut result.excerpts_text, - ); - } - } - - let response = - request.response_rx.await?.0.map_err(|err| anyhow!(err))?; - let response = - edit_prediction::open_ai_response::text_from_response(response) - .unwrap_or_default(); - let prediction_finished_at = Instant::now(); - fs::write(example_run_dir.join("prediction_response.md"), &response)?; - - let mut result = result.lock().unwrap(); - result.generated_len = response.chars().count(); - result.retrieval_time = - retrieval_finished_at.unwrap() - start_time.unwrap(); - result.prediction_time = prediction_finished_at - prediction_started_at; - result.total_time = prediction_finished_at - start_time.unwrap(); - + if run_ix >= repetition_count { break; } } + _ => {} } - anyhow::Ok(()) } - }); - - store.update(cx, |store, cx| { - store.refresh_context(&project, &cursor_buffer, cursor_anchor, cx) - })?; - } - - let prediction = store - .update(cx, |store, cx| { - store.request_prediction( - &project, - &cursor_buffer, - cursor_anchor, - cloud_llm_client::PredictEditsRequestTrigger::Cli, - cx, - ) - })? - .await?; - - debug_task.await?; - - let mut result = Arc::into_inner(result).unwrap().into_inner().unwrap(); - - result.diff = prediction - .and_then(|prediction| { - let prediction = prediction.prediction.ok()?; - prediction.edit_preview.as_unified_diff(&prediction.edits) - }) - .unwrap_or_default(); - - anyhow::Ok(result) -} - -struct RunCache { - cache_mode: CacheMode, - example_run_dir: PathBuf, -} + anyhow::Ok(()) + } + }); -impl RunCache { - fn output_cache_path((kind, key): &EvalCacheKey) -> PathBuf { - CACHE_DIR.join(format!("{kind}_out_{key:x}.json",)) - } + for ix in 0..repetition_count { + current_run_ix.store(ix, SeqCst); + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", ix)) + } else { + run_dir.clone() + }; - fn input_cache_path((kind, key): &EvalCacheKey) -> PathBuf { - CACHE_DIR.join(format!("{kind}_in_{key:x}.json",)) + fs::create_dir_all(&run_dir).unwrap(); + if LATEST_EXAMPLE_RUN_DIR.is_symlink() { + fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR).unwrap(); + } + #[cfg(unix)] + std::os::unix::fs::symlink(&run_dir, &*LATEST_EXAMPLE_RUN_DIR).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_dir(&run_dir, &*LATEST_EXAMPLE_RUN_DIR).unwrap(); + + updated_example + .lock() + .unwrap() + .predictions + .push(ExamplePrediction { + actual_patch: String::new(), + actual_output: String::new(), + provider, + }); + + let prediction = ep_store + .update(&mut cx, |store, cx| { + store.request_prediction( + &state.project, + &state.buffer, + state.cursor_position, + cloud_llm_client::PredictEditsRequestTrigger::Cli, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + + updated_example + .lock() + .unwrap() + .predictions + .last_mut() + .unwrap() + .actual_patch = prediction + .and_then(|prediction| { + let prediction = prediction.prediction.ok()?; + prediction.edit_preview.as_unified_diff(&prediction.edits) + }) + .unwrap_or_default(); } - fn link_to_run(&self, key: &EvalCacheKey) { - let output_link_path = self.example_run_dir.join(format!("{}_out.json", key.0)); - fs::hard_link(Self::output_cache_path(key), &output_link_path).unwrap(); + ep_store + .update(&mut cx, |store, _| { + store.remove_project(&state.project); + }) + .unwrap(); + debug_task.await.unwrap(); - let input_link_path = self.example_run_dir.join(format!("{}_in.json", key.0)); - fs::hard_link(Self::input_cache_path(key), &input_link_path).unwrap(); - } + *example = Arc::into_inner(updated_example) + .unwrap() + .into_inner() + .unwrap(); } -impl EvalCache for RunCache { - fn read(&self, key: EvalCacheKey) -> Option { - let path = RunCache::output_cache_path(&key); - - if path.exists() { - let use_cache = match key.0 { - EvalCacheEntryKind::Search => self.cache_mode.use_cached_search_results(), - EvalCacheEntryKind::Context | EvalCacheEntryKind::Prediction => { - self.cache_mode.use_cached_llm_responses() - } - }; - if use_cache { - log::info!("Using cache entry: {}", path.display()); - self.link_to_run(&key); - Some(fs::read_to_string(path).unwrap()) - } else { - log::trace!("Skipping cached entry: {}", path.display()); - None - } - } else if matches!(self.cache_mode, CacheMode::Force) { - panic!( - "No cached entry found for {:?}. Run without `--cache force` at least once.", - key.0 - ); - } else { - None - } - } - - fn write(&self, key: EvalCacheKey, input: &str, output: &str) { - fs::create_dir_all(&*CACHE_DIR).unwrap(); +async fn predict_anthropic(example: &mut Example, _repetition_count: usize, batched: bool) { + let llm_model_name = "claude-sonnet-4-5"; + let max_tokens = 16384; + let llm_client = if batched { + AnthropicClient::batch(&crate::paths::LLM_CACHE_DB.as_ref()) + } else { + AnthropicClient::plain() + }; + let llm_client = llm_client.expect("Failed to create LLM client"); + + let prompt = example + .prompt + .as_ref() + .unwrap_or_else(|| panic!("Prompt is required for an example {}", &example.name)); + + let messages = vec![anthropic::Message { + role: anthropic::Role::User, + content: vec![anthropic::RequestContent::Text { + text: prompt.input.clone(), + cache_control: None, + }], + }]; + + let Some(response) = llm_client + .generate(llm_model_name, max_tokens, messages) + .await + .unwrap() + else { + // Request stashed for batched processing + return; + }; + + let actual_output = response + .content + .into_iter() + .filter_map(|content| match content { + anthropic::ResponseContent::Text { text } => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); - let input_path = RunCache::input_cache_path(&key); - fs::write(&input_path, input).unwrap(); + let actual_patch = TeacherPrompt::parse(example, &actual_output); - let output_path = RunCache::output_cache_path(&key); - log::trace!("Writing cache entry: {}", output_path.display()); - fs::write(&output_path, output).unwrap(); + let prediction = ExamplePrediction { + actual_patch, + actual_output, + provider: PredictionProvider::Teacher, + }; - self.link_to_run(&key); - } + example.predictions.push(prediction); } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PredictionDetails { - pub diff: String, - pub excerpts: Vec, - pub excerpts_text: String, // TODO: contains the worktree root path. Drop this field and compute it on the fly - pub retrieval_time: Duration, - pub prediction_time: Duration, - pub total_time: Duration, - pub run_example_dir: PathBuf, - pub prompt_len: usize, - pub generated_len: usize, -} - -impl PredictionDetails { - pub fn new(run_example_dir: PathBuf) -> Self { - Self { - diff: Default::default(), - excerpts: Default::default(), - excerpts_text: Default::default(), - retrieval_time: Default::default(), - prediction_time: Default::default(), - total_time: Default::default(), - run_example_dir, - prompt_len: 0, - generated_len: 0, +pub async fn sync_batches(provider: &PredictionProvider) { + match provider { + PredictionProvider::Teacher => { + let cache_path = crate::paths::LLM_CACHE_DB.as_ref(); + let llm_client = + AnthropicClient::batch(cache_path).expect("Failed to create LLM client"); + llm_client + .sync_batches() + .await + .expect("Failed to sync batches"); } - } - - pub fn write(&self, format: PredictionsOutputFormat, mut out: impl Write) -> Result<()> { - let formatted = match format { - PredictionsOutputFormat::Md => self.to_markdown(), - PredictionsOutputFormat::Json => serde_json::to_string_pretty(self)?, - PredictionsOutputFormat::Diff => self.diff.clone(), - }; - - Ok(out.write_all(formatted.as_bytes())?) - } - - pub fn to_markdown(&self) -> String { - format!( - "## Excerpts\n\n\ - {}\n\n\ - ## Prediction\n\n\ - {}\n\n\ - ## Time\n\n\ - Retrieval: {}ms\n\ - Prediction: {}ms\n\n\ - Total: {}ms\n", - self.excerpts_text, - self.diff, - self.retrieval_time.as_millis(), - self.prediction_time.as_millis(), - self.total_time.as_millis(), - ) + _ => (), } } diff --git a/crates/edit_prediction_cli/src/util.rs b/crates/edit_prediction_cli/src/retrieve_context.rs similarity index 53% rename from crates/edit_prediction_cli/src/util.rs rename to crates/edit_prediction_cli/src/retrieve_context.rs index f4a51d94585f82da008ac832dc62392c365738fd..2344b4250e2dd0d3a94928b05689377dcabba84a 100644 --- a/crates/edit_prediction_cli/src/util.rs +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -1,106 +1,136 @@ -use anyhow::{Result, anyhow}; -use futures::channel::mpsc; -use futures::{FutureExt as _, StreamExt as _}; +use crate::{ + example::{Example, ExampleContext}, + headless::EpAppState, + load_project::run_load_project, +}; +use anyhow::Result; +use collections::HashSet; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, channel::mpsc}; use gpui::{AsyncApp, Entity, Task}; -use language::{Buffer, LanguageId, LanguageNotFound, LanguageServerId, ParseStatus}; -use project::lsp_store::OpenLspBufferHandle; -use project::{Project, ProjectPath, Worktree}; -use std::collections::HashSet; -use std::sync::Arc; -use std::time::Duration; -use util::rel_path::RelPath; - -pub fn open_buffer( - project: Entity, - worktree: Entity, - path: Arc, - cx: &AsyncApp, -) -> Task>> { - cx.spawn(async move |cx| { - let project_path = worktree.read_with(cx, |worktree, _cx| ProjectPath { - worktree_id: worktree.id(), - path, - })?; - - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx))? - .await?; - - let mut parse_status = buffer.read_with(cx, |buffer, _cx| buffer.parse_status())?; - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; +use language::{Buffer, LanguageNotFound}; +use project::Project; +use std::{sync::Arc, time::Duration}; + +pub async fn run_context_retrieval( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) { + if example.context.is_some() { + return; + } + + run_load_project(example, app_state.clone(), cx.clone()).await; + + let state = example.state.as_ref().unwrap(); + let project = state.project.clone(); + + let _lsp_handle = project + .update(&mut cx, |project, cx| { + project.register_buffer_with_language_servers(&state.buffer, cx) + }) + .unwrap(); + + wait_for_language_server_to_start(example, &project, &state.buffer, &mut cx).await; + + let ep_store = cx + .update(|cx| EditPredictionStore::try_global(cx).unwrap()) + .unwrap(); + + let mut events = ep_store + .update(&mut cx, |store, cx| { + store.register_buffer(&state.buffer, &project, cx); + store.set_use_context(true); + store.refresh_context(&project, &state.buffer, state.cursor_position, cx); + store.debug_info(&project, cx) + }) + .unwrap(); + + while let Some(event) = events.next().await { + match event { + DebugEvent::ContextRetrievalFinished(_) => { + break; + } + _ => {} } + } - Ok(buffer) - }) + let context_files = ep_store + .update(&mut cx, |store, cx| store.context_for_project(&project, cx)) + .unwrap(); + + example.context = Some(ExampleContext { + files: context_files, + }); } -pub async fn open_buffer_with_language_server( - project: Entity, - worktree: Entity, - path: Arc, - ready_languages: &mut HashSet, +async fn wait_for_language_server_to_start( + example: &Example, + project: &Entity, + buffer: &Entity, cx: &mut AsyncApp, -) -> Result<(OpenLspBufferHandle, LanguageServerId, Entity)> { - let buffer = open_buffer(project.clone(), worktree, path.clone(), cx).await?; - - let (lsp_open_handle, path_style) = project.update(cx, |project, cx| { - ( - project.register_buffer_with_language_servers(&buffer, cx), - project.path_style(cx), - ) - })?; - - let language_registry = project.read_with(cx, |project, _| project.languages().clone())?; +) { + let language_registry = project + .read_with(cx, |project, _| project.languages().clone()) + .unwrap(); let result = language_registry - .load_language_for_file_path(path.as_std_path()) + .load_language_for_file_path(&example.cursor_path) .await; if let Err(error) = result && !error.is::() { - anyhow::bail!(error); + panic!("Failed to load language for file path: {}", error); } - let Some(language_id) = buffer.read_with(cx, |buffer, _cx| { - buffer.language().map(|language| language.id()) - })? + let Some(language_id) = buffer + .read_with(cx, |buffer, _cx| { + buffer.language().map(|language| language.id()) + }) + .unwrap() else { - return Err(anyhow!("No language for {}", path.display(path_style))); + panic!("No language for {:?}", example.cursor_path); }; - let log_prefix = format!("{} | ", path.display(path_style)); + let mut ready_languages = HashSet::default(); + let log_prefix = format!("{} | ", example.name); if !ready_languages.contains(&language_id) { - wait_for_lang_server(&project, &buffer, log_prefix, cx).await?; + wait_for_lang_server(&project, &buffer, log_prefix, cx) + .await + .unwrap(); ready_languages.insert(language_id); } - let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store())?; + let lsp_store = project + .read_with(cx, |project, _cx| project.lsp_store()) + .unwrap(); // hacky wait for buffer to be registered with the language server for _ in 0..100 { - let Some(language_server_id) = lsp_store.update(cx, |lsp_store, cx| { - buffer.update(cx, |buffer, cx| { - lsp_store - .language_servers_for_local_buffer(&buffer, cx) - .next() - .map(|(_, language_server)| language_server.server_id()) + if lsp_store + .update(cx, |lsp_store, cx| { + buffer.update(cx, |buffer, cx| { + lsp_store + .language_servers_for_local_buffer(&buffer, cx) + .next() + .map(|(_, language_server)| language_server.server_id()) + }) }) - })? - else { + .unwrap() + .is_some() + { + return; + } else { cx.background_executor() .timer(Duration::from_millis(10)) .await; - continue; - }; - - return Ok((lsp_open_handle, language_server_id, buffer)); + } } - return Err(anyhow!("No language server found for buffer")); + panic!("No language server found for buffer"); } -// TODO: Dedupe with similar function in crates/eval/src/instance.rs pub fn wait_for_lang_server( project: &Entity, buffer: &Entity, diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs new file mode 100644 index 0000000000000000000000000000000000000000..88ec5d5831c763b604c53d762a1ea9722e7279cb --- /dev/null +++ b/crates/edit_prediction_cli/src/score.rs @@ -0,0 +1,119 @@ +use crate::{ + PredictArgs, + example::{Example, ExampleScore}, + headless::EpAppState, + metrics::{self, ClassificationMetrics}, + predict::run_prediction, +}; +use edit_prediction::udiff::DiffLine; +use gpui::AsyncApp; +use std::sync::Arc; + +pub async fn run_scoring( + example: &mut Example, + args: &PredictArgs, + app_state: Arc, + cx: AsyncApp, +) { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state, + cx, + ) + .await; + + let expected_patch = parse_patch(&example.expected_patch); + + let mut scores = vec![]; + + for pred in &example.predictions { + let actual_patch = parse_patch(&pred.actual_patch); + let line_match = metrics::line_match_score(&expected_patch, &actual_patch); + let delta_chr_f = metrics::delta_chr_f(&expected_patch, &actual_patch) as f32; + + scores.push(ExampleScore { + delta_chr_f, + line_match, + }); + } + + example.score = scores; +} + +fn parse_patch(patch: &str) -> Vec> { + patch.lines().map(DiffLine::parse).collect() +} + +pub fn print_report(examples: &[Example]) { + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>10} {:>8} {:>8} {:>10}", + "Example name", "TP", "FP", "FN", "Precision", "Recall", "F1", "DeltaChrF" + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + let mut all_line_match_scores = Vec::new(); + let mut all_delta_chr_f_scores = Vec::new(); + + for example in examples { + for score in example.score.iter() { + let line_match = &score.line_match; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + truncate_name(&example.name, 30), + line_match.true_positives, + line_match.false_positives, + line_match.false_negatives, + line_match.precision() * 100.0, + line_match.recall() * 100.0, + line_match.f1_score() * 100.0, + score.delta_chr_f + ); + + all_line_match_scores.push(line_match.clone()); + all_delta_chr_f_scores.push(score.delta_chr_f); + } + } + + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + if !all_line_match_scores.is_empty() { + let total_line_match = ClassificationMetrics::aggregate(all_line_match_scores.iter()); + let avg_delta_chr_f: f32 = + all_delta_chr_f_scores.iter().sum::() / all_delta_chr_f_scores.len() as f32; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + "TOTAL", + total_line_match.true_positives, + total_line_match.false_positives, + total_line_match.false_negatives, + total_line_match.precision() * 100.0, + total_line_match.recall() * 100.0, + total_line_match.f1_score() * 100.0, + avg_delta_chr_f + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + } + + eprintln!("\n"); +} + +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() <= max_len { + name.to_string() + } else { + format!("{}...", &name[..max_len - 3]) + } +} diff --git a/crates/edit_prediction_cli/src/source_location.rs b/crates/edit_prediction_cli/src/source_location.rs deleted file mode 100644 index 3438675e78ac4d8bba6f58f7ce8a9016aed6c0c7..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/source_location.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::{fmt, fmt::Display, path::Path, str::FromStr, sync::Arc}; - -use ::util::{paths::PathStyle, rel_path::RelPath}; -use anyhow::{Result, anyhow}; -use language::Point; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub struct SourceLocation { - pub path: Arc, - pub point: Point, -} - -impl Serialize for SourceLocation { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.to_string()) - } -} - -impl<'de> Deserialize<'de> for SourceLocation { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - s.parse().map_err(serde::de::Error::custom) - } -} - -impl Display for SourceLocation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}:{}:{}", - self.path.display(PathStyle::Posix), - self.point.row + 1, - self.point.column + 1 - ) - } -} - -impl FromStr for SourceLocation { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() != 3 { - return Err(anyhow!( - "Invalid source location. Expected 'file.rs:line:column', got '{}'", - s - )); - } - - let path = RelPath::new(Path::new(&parts[0]), PathStyle::local())?.into_arc(); - let line: u32 = parts[1] - .parse() - .map_err(|_| anyhow!("Invalid line number: '{}'", parts[1]))?; - let column: u32 = parts[2] - .parse() - .map_err(|_| anyhow!("Invalid column number: '{}'", parts[2]))?; - - // Convert from 1-based to 0-based indexing - let point = Point::new(line.saturating_sub(1), column.saturating_sub(1)); - - Ok(SourceLocation { path, point }) - } -} diff --git a/crates/edit_prediction_cli/src/training/teacher.prompt.md b/crates/edit_prediction_cli/src/teacher.prompt.md similarity index 98% rename from crates/edit_prediction_cli/src/training/teacher.prompt.md rename to crates/edit_prediction_cli/src/teacher.prompt.md index af67c871ef31a21a8744bf71375a50128d9699b6..238d3b7ac1297583727f562f1755d084ff5a3ceb 100644 --- a/crates/edit_prediction_cli/src/training/teacher.prompt.md +++ b/crates/edit_prediction_cli/src/teacher.prompt.md @@ -46,3 +46,7 @@ Output example: ## Code Context {{context}} + +## Editable region + +{{editable_region}} diff --git a/crates/edit_prediction_cli/src/training/context.rs b/crates/edit_prediction_cli/src/training/context.rs deleted file mode 100644 index 7b6d9cc19c1c3750bbf03158ceec5c79a9df0340..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/training/context.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::path::Path; - -use crate::{source_location::SourceLocation, training::teacher::TeacherModel}; - -#[derive(Debug, Clone, Default, clap::ValueEnum)] -pub enum ContextType { - #[default] - CurrentFile, -} - -const MAX_CONTEXT_SIZE: usize = 32768; - -pub fn collect_context( - context_type: &ContextType, - worktree_dir: &Path, - cursor: SourceLocation, -) -> String { - let context = match context_type { - ContextType::CurrentFile => { - let file_path = worktree_dir.join(cursor.path.as_std_path()); - let context = std::fs::read_to_string(&file_path).unwrap_or_default(); - - let context = add_special_tags(&context, worktree_dir, cursor); - context - } - }; - - let region_end_offset = context.find(TeacherModel::REGION_END); - - if context.len() <= MAX_CONTEXT_SIZE { - return context; - } - - if let Some(region_end_offset) = region_end_offset - && region_end_offset + TeacherModel::REGION_END.len() > MAX_CONTEXT_SIZE - { - let to_truncate = context.len() - MAX_CONTEXT_SIZE; - format!( - "[...{} bytes truncated]\n{}\n", - to_truncate, - &context[to_truncate..] - ) - } else { - format!( - "{}\n[...{} bytes truncated]\n", - &context[..MAX_CONTEXT_SIZE], - context.len() - MAX_CONTEXT_SIZE - ) - } -} - -/// Add <|editable_region_start/end|> tags -fn add_special_tags(context: &str, worktree_dir: &Path, cursor: SourceLocation) -> String { - let path = worktree_dir.join(cursor.path.as_std_path()); - let file = std::fs::read_to_string(&path).unwrap_or_default(); - let lines = file.lines().collect::>(); - let cursor_row = cursor.point.row as usize; - let start_line = cursor_row.saturating_sub(TeacherModel::LEFT_CONTEXT_SIZE); - let end_line = (cursor_row + TeacherModel::RIGHT_CONTEXT_SIZE).min(lines.len()); - - let snippet = lines[start_line..end_line].join("\n"); - - if context.contains(&snippet) { - let mut cursor_line = lines[cursor_row].to_string(); - cursor_line.insert_str(cursor.point.column as usize, TeacherModel::USER_CURSOR); - - let mut snippet_with_tags_lines = vec![]; - snippet_with_tags_lines.push(TeacherModel::REGION_START); - snippet_with_tags_lines.extend(&lines[start_line..cursor_row]); - snippet_with_tags_lines.push(&cursor_line); - snippet_with_tags_lines.extend(&lines[cursor_row + 1..end_line]); - snippet_with_tags_lines.push(TeacherModel::REGION_END); - let snippet_with_tags = snippet_with_tags_lines.join("\n"); - - context.replace(&snippet, &snippet_with_tags) - } else { - log::warn!( - "Can't find area around the cursor in the context; proceeding without special tags" - ); - context.to_string() - } -} - -pub fn strip_special_tags(context: &str) -> String { - context - .replace(TeacherModel::REGION_START, "") - .replace(TeacherModel::REGION_END, "") - .replace(TeacherModel::USER_CURSOR, "") -} diff --git a/crates/edit_prediction_cli/src/training/distill.rs b/crates/edit_prediction_cli/src/training/distill.rs deleted file mode 100644 index 277e35551a9fbce43982de832de5ccecf8d6e92e..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/training/distill.rs +++ /dev/null @@ -1,94 +0,0 @@ -use serde::Deserialize; -use std::sync::Arc; - -use crate::{ - DistillArguments, - example::Example, - source_location::SourceLocation, - training::{ - context::ContextType, - llm_client::LlmClient, - teacher::{TeacherModel, TeacherOutput}, - }, -}; -use anyhow::Result; -use reqwest_client::ReqwestClient; - -#[derive(Debug, Deserialize)] -pub struct SplitCommit { - repo_url: String, - commit_sha: String, - edit_history: String, - expected_patch: String, - cursor_position: String, -} - -pub async fn run_distill(arguments: DistillArguments) -> Result<()> { - let split_commits: Vec = std::fs::read_to_string(&arguments.split_commit_dataset) - .expect("Failed to read split commit dataset") - .lines() - .map(|line| serde_json::from_str(line).expect("Failed to parse JSON line")) - .collect(); - - let http_client: Arc = Arc::new(ReqwestClient::new()); - - let llm_client = if let Some(cache_path) = arguments.batch { - LlmClient::batch(&cache_path, http_client)? - } else { - LlmClient::plain(http_client)? - }; - - let mut teacher = TeacherModel::new( - "claude-sonnet-4-5".to_string(), - ContextType::CurrentFile, - llm_client, - ); - - let mut num_marked_for_batching = 0; - - for commit in split_commits { - if let Some(distilled) = distill_one(&mut teacher, commit).await? { - println!("{}", serde_json::to_string(&distilled)?); - } else { - if num_marked_for_batching == 0 { - log::warn!("Marked for batching"); - } - num_marked_for_batching += 1; - } - } - - eprintln!( - "{} requests are marked for batching", - num_marked_for_batching - ); - let llm_client = teacher.client; - llm_client.sync_batches().await?; - - Ok(()) -} - -pub async fn distill_one( - teacher: &mut TeacherModel, - commit: SplitCommit, -) -> Result> { - let cursor: SourceLocation = commit - .cursor_position - .parse() - .expect("Failed to parse cursor position"); - - let path = cursor.path.to_rel_path_buf(); - - let example = Example { - repository_url: commit.repo_url, - revision: commit.commit_sha, - uncommitted_diff: commit.edit_history.clone(), - cursor_path: path.as_std_path().to_path_buf(), - cursor_position: commit.cursor_position, - edit_history: commit.edit_history, // todo: trim - expected_patch: commit.expected_patch, - }; - - let prediction = teacher.predict(example).await; - - prediction -} diff --git a/crates/edit_prediction_cli/src/training/mod.rs b/crates/edit_prediction_cli/src/training/mod.rs deleted file mode 100644 index dc564c4dc86c8e095e8e93ccbdfb29d3313e922a..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/training/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod context; -pub mod distill; -pub mod llm_client; -pub mod teacher; diff --git a/crates/edit_prediction_cli/src/training/teacher.rs b/crates/edit_prediction_cli/src/training/teacher.rs deleted file mode 100644 index 99672db8f99a87b99a43c8876db2fd0c2f307b21..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_cli/src/training/teacher.rs +++ /dev/null @@ -1,266 +0,0 @@ -use crate::{ - example::Example, - source_location::SourceLocation, - training::{ - context::{ContextType, collect_context, strip_special_tags}, - llm_client::LlmClient, - }, -}; -use anthropic::{Message, RequestContent, ResponseContent, Role}; -use anyhow::Result; - -pub struct TeacherModel { - pub llm_name: String, - pub context: ContextType, - pub client: LlmClient, -} - -#[derive(Debug, serde::Serialize)] -pub struct TeacherOutput { - parsed_output: String, - prompt: String, - raw_llm_response: String, - context: String, - diff: String, -} - -impl TeacherModel { - const PROMPT: &str = include_str!("teacher.prompt.md"); - pub(crate) const REGION_START: &str = "<|editable_region_start|>\n"; - pub(crate) const REGION_END: &str = "<|editable_region_end|>"; - pub(crate) const USER_CURSOR: &str = "<|user_cursor|>"; - - /// Number of lines to include before the cursor position - pub(crate) const LEFT_CONTEXT_SIZE: usize = 5; - - /// Number of lines to include after the cursor position - pub(crate) const RIGHT_CONTEXT_SIZE: usize = 5; - - /// Truncate edit history to this number of last lines - const MAX_HISTORY_LINES: usize = 128; - - pub fn new(llm_name: String, context: ContextType, client: LlmClient) -> Self { - TeacherModel { - llm_name, - context, - client, - } - } - - pub async fn predict(&self, input: Example) -> Result> { - let name = input.unique_name(); - let worktree_dir = input.setup_worktree(name).await?; - let cursor: SourceLocation = input - .cursor_position - .parse() - .expect("Failed to parse cursor position"); - - let context = collect_context(&self.context, &worktree_dir, cursor.clone()); - let edit_history = Self::format_edit_history(&input.edit_history); - - let prompt = Self::PROMPT - .replace("{{context}}", &context) - .replace("{{edit_history}}", &edit_history); - - let messages = vec![Message { - role: Role::User, - content: vec![RequestContent::Text { - text: prompt.clone(), - cache_control: None, - }], - }]; - - let Some(response) = self - .client - .generate(self.llm_name.clone(), 16384, messages) - .await? - else { - return Ok(None); - }; - - let response_text = response - .content - .into_iter() - .filter_map(|content| match content { - ResponseContent::Text { text } => Some(text), - _ => None, - }) - .collect::>() - .join("\n"); - - let parsed_output = self.parse_response(&response_text); - - let original_editable_region = Self::extract_editable_region(&context); - let context_after_edit = context.replace(&original_editable_region, &parsed_output); - let context_after_edit = strip_special_tags(&context_after_edit); - let context_before_edit = strip_special_tags(&context); - let diff = language::unified_diff(&context_before_edit, &context_after_edit); - - // zeta distill --batch batch_results.txt - // zeta distill - // 1. Run `zeta distill <2000 examples <- all examples>` for the first time - // - store LLM requests in a batch, don't actual send the request - // - send the batch (2000 requests) after all inputs are processed - // 2. `zeta send-batches` - // - upload the batch to Anthropic - - // https://platform.claude.com/docs/en/build-with-claude/batch-processing - // https://crates.io/crates/anthropic-sdk-rust - - // - poll for results - // - when ready, store results in cache (a database) - // 3. `zeta distill` again - // - use the cached results this time - - Ok(Some(TeacherOutput { - parsed_output, - prompt, - raw_llm_response: response_text, - context, - diff, - })) - } - - fn parse_response(&self, content: &str) -> String { - let codeblock = Self::extract_last_codeblock(content); - let editable_region = Self::extract_editable_region(&codeblock); - - editable_region - } - - /// Extract content from the last code-fenced block if any, or else return content as is - fn extract_last_codeblock(text: &str) -> String { - let mut last_block = None; - let mut search_start = 0; - - while let Some(start) = text[search_start..].find("```") { - let start = start + search_start; - let bytes = text.as_bytes(); - let mut backtick_end = start; - - while backtick_end < bytes.len() && bytes[backtick_end] == b'`' { - backtick_end += 1; - } - - let backtick_count = backtick_end - start; - let closing_backticks = "`".repeat(backtick_count); - - if let Some(end_pos) = text[backtick_end..].find(&closing_backticks) { - let code_block = &text[backtick_end + 1..backtick_end + end_pos - 1]; - last_block = Some(code_block.to_string()); - search_start = backtick_end + end_pos + backtick_count; - } else { - break; - } - } - - last_block.unwrap_or_else(|| text.to_string()) - } - - fn extract_editable_region(text: &str) -> String { - let start = text - .find(Self::REGION_START) - .map_or(0, |pos| pos + Self::REGION_START.len()); - let end = text.find(Self::REGION_END).unwrap_or(text.len()); - - text[start..end].to_string() - } - - /// Truncates edit history to a maximum length and removes comments (unified diff garbage lines) - fn format_edit_history(edit_history: &str) -> String { - let lines = edit_history - .lines() - .filter(|&s| Self::is_content_line(s)) - .collect::>(); - - let history_lines = if lines.len() > Self::MAX_HISTORY_LINES { - &lines[lines.len() - Self::MAX_HISTORY_LINES..] - } else { - &lines - }; - history_lines.join("\n") - } - - fn is_content_line(s: &str) -> bool { - s.starts_with("-") - || s.starts_with("+") - || s.starts_with(" ") - || s.starts_with("---") - || s.starts_with("+++") - || s.starts_with("@@") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_response() { - let teacher = TeacherModel::new( - "test".to_string(), - ContextType::CurrentFile, - LlmClient::dummy(), - ); - let response = "This is a test response."; - let parsed = teacher.parse_response(response); - assert_eq!(parsed, response.to_string()); - - let response = indoc::indoc! {" - Some thinking - - ````` - actual response - ````` - "}; - let parsed = teacher.parse_response(response); - assert_eq!(parsed, "actual response"); - } - - #[test] - fn test_extract_last_code_block() { - let text = indoc::indoc! {" - Some thinking - - ``` - first block - ``` - - ````` - last block - ````` - "}; - let last_block = TeacherModel::extract_last_codeblock(text); - assert_eq!(last_block, "last block"); - } - - #[test] - fn test_extract_editable_region() { - let teacher = TeacherModel::new( - "test".to_string(), - ContextType::CurrentFile, - LlmClient::dummy(), - ); - let response = indoc::indoc! {" - some lines - are - here - <|editable_region_start|> - one - two three - - <|editable_region_end|> - more - lines here - "}; - let parsed = teacher.parse_response(response); - assert_eq!( - parsed, - indoc::indoc! {" - one - two three - - "} - ); - } -} diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml index f113c3c46075ca70e61d8d07947d37502e8528e8..731ffc85d159e285ad497c29fba2f74179d4149b 100644 --- a/crates/edit_prediction_context/Cargo.toml +++ b/crates/edit_prediction_context/Cargo.toml @@ -26,6 +26,7 @@ serde.workspace = true smallvec.workspace = true tree-sitter.workspace = true util.workspace = true +zeta_prompt.workspace = true [dev-dependencies] env_logger.workspace = true diff --git a/crates/edit_prediction_context/src/assemble_excerpts.rs b/crates/edit_prediction_context/src/assemble_excerpts.rs index 15f4c03d653429af671c22d6b5abc652d282a38e..e337211cf90f0e4fbcb481f836e512b1ceb6477f 100644 --- a/crates/edit_prediction_context/src/assemble_excerpts.rs +++ b/crates/edit_prediction_context/src/assemble_excerpts.rs @@ -1,6 +1,6 @@ -use crate::RelatedExcerpt; use language::{BufferSnapshot, OffsetRangeExt as _, Point}; use std::ops::Range; +use zeta_prompt::RelatedExcerpt; #[cfg(not(test))] const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 512; @@ -76,14 +76,9 @@ pub fn assemble_excerpts( input_ranges .into_iter() - .map(|range| { - let offset_range = range.to_offset(buffer); - RelatedExcerpt { - point_range: range, - anchor_range: buffer.anchor_before(offset_range.start) - ..buffer.anchor_after(offset_range.end), - text: buffer.as_rope().slice(offset_range), - } + .map(|range| RelatedExcerpt { + row_range: range.start.row..range.end.row, + text: buffer.text_for_range(range).collect(), }) .collect() } diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index d3aefaa6e4ec585dc7c90fee1e95de17e018f90f..15576a835d9b4b0781b1e3979edbed443fa40f62 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -3,13 +3,13 @@ use anyhow::Result; use collections::HashMap; use futures::{FutureExt, StreamExt as _, channel::mpsc, future}; use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity}; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, Rope, ToOffset as _}; +use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _}; use project::{LocationLink, Project, ProjectPath}; -use serde::{Serialize, Serializer}; use smallvec::SmallVec; use std::{ collections::hash_map, ops::Range, + path::Path, sync::Arc, time::{Duration, Instant}, }; @@ -24,12 +24,14 @@ mod fake_definition_lsp; pub use cloud_llm_client::predict_edits_v3::Line; pub use excerpt::{EditPredictionExcerpt, EditPredictionExcerptOptions, EditPredictionExcerptText}; +pub use zeta_prompt::{RelatedExcerpt, RelatedFile}; const IDENTIFIER_LINE_COUNT: u32 = 3; pub struct RelatedExcerptStore { project: WeakEntity, - related_files: Vec, + related_files: Arc<[RelatedFile]>, + related_file_buffers: Vec>, cache: HashMap>, update_tx: mpsc::UnboundedSender<(Entity, Anchor)>, identifier_line_count: u32, @@ -68,82 +70,6 @@ struct CachedDefinition { anchor_range: Range, } -#[derive(Clone, Debug, Serialize)] -pub struct RelatedFile { - #[serde(serialize_with = "serialize_project_path")] - pub path: ProjectPath, - #[serde(skip)] - pub buffer: WeakEntity, - pub excerpts: Vec, - pub max_row: u32, -} - -impl RelatedFile { - pub fn merge_excerpts(&mut self) { - self.excerpts.sort_unstable_by(|a, b| { - a.point_range - .start - .cmp(&b.point_range.start) - .then(b.point_range.end.cmp(&a.point_range.end)) - }); - - let mut index = 1; - while index < self.excerpts.len() { - if self.excerpts[index - 1] - .point_range - .end - .cmp(&self.excerpts[index].point_range.start) - .is_ge() - { - let removed = self.excerpts.remove(index); - if removed - .point_range - .end - .cmp(&self.excerpts[index - 1].point_range.end) - .is_gt() - { - self.excerpts[index - 1].point_range.end = removed.point_range.end; - self.excerpts[index - 1].anchor_range.end = removed.anchor_range.end; - } - } else { - index += 1; - } - } - } -} - -#[derive(Clone, Debug, Serialize)] -pub struct RelatedExcerpt { - #[serde(skip)] - pub anchor_range: Range, - #[serde(serialize_with = "serialize_point_range")] - pub point_range: Range, - #[serde(serialize_with = "serialize_rope")] - pub text: Rope, -} - -fn serialize_project_path( - project_path: &ProjectPath, - serializer: S, -) -> Result { - project_path.path.serialize(serializer) -} - -fn serialize_rope(rope: &Rope, serializer: S) -> Result { - rope.to_string().serialize(serializer) -} - -fn serialize_point_range( - range: &Range, - serializer: S, -) -> Result { - [ - [range.start.row, range.start.column], - [range.end.row, range.end.column], - ] - .serialize(serializer) -} - const DEBOUNCE_DURATION: Duration = Duration::from_millis(100); impl EventEmitter for RelatedExcerptStore {} @@ -179,7 +105,8 @@ impl RelatedExcerptStore { RelatedExcerptStore { project: project.downgrade(), update_tx, - related_files: Vec::new(), + related_files: Vec::new().into(), + related_file_buffers: Vec::new(), cache: Default::default(), identifier_line_count: IDENTIFIER_LINE_COUNT, } @@ -193,8 +120,21 @@ impl RelatedExcerptStore { self.update_tx.unbounded_send((buffer, position)).ok(); } - pub fn related_files(&self) -> &[RelatedFile] { - &self.related_files + pub fn related_files(&self) -> Arc<[RelatedFile]> { + self.related_files.clone() + } + + pub fn related_files_with_buffers( + &self, + ) -> impl Iterator)> { + self.related_files + .iter() + .cloned() + .zip(self.related_file_buffers.iter().cloned()) + } + + pub fn set_related_files(&mut self, files: Vec) { + self.related_files = files.into(); } async fn fetch_excerpts( @@ -297,7 +237,8 @@ impl RelatedExcerptStore { } mean_definition_latency /= cache_miss_count.max(1) as u32; - let (new_cache, related_files) = rebuild_related_files(new_cache, cx).await?; + let (new_cache, related_files, related_file_buffers) = + rebuild_related_files(&project, new_cache, cx).await?; if let Some(file) = &file { log::debug!( @@ -309,7 +250,8 @@ impl RelatedExcerptStore { this.update(cx, |this, cx| { this.cache = new_cache; - this.related_files = related_files; + this.related_files = related_files.into(); + this.related_file_buffers = related_file_buffers; cx.emit(RelatedExcerptStoreEvent::FinishedRefresh { cache_hit_count, cache_miss_count, @@ -323,10 +265,16 @@ impl RelatedExcerptStore { } async fn rebuild_related_files( + project: &Entity, new_entries: HashMap>, cx: &mut AsyncApp, -) -> Result<(HashMap>, Vec)> { +) -> Result<( + HashMap>, + Vec, + Vec>, +)> { let mut snapshots = HashMap::default(); + let mut worktree_root_names = HashMap::default(); for entry in new_entries.values() { for definition in &entry.definitions { if let hash_map::Entry::Vacant(e) = snapshots.entry(definition.buffer.entity_id()) { @@ -340,12 +288,22 @@ async fn rebuild_related_files( .read_with(cx, |buffer, _| buffer.snapshot())?, ); } + let worktree_id = definition.path.worktree_id; + if let hash_map::Entry::Vacant(e) = + worktree_root_names.entry(definition.path.worktree_id) + { + project.read_with(cx, |project, cx| { + if let Some(worktree) = project.worktree_for_id(worktree_id, cx) { + e.insert(worktree.read(cx).root_name().as_unix_str().to_string()); + } + })?; + } } } Ok(cx .background_spawn(async move { - let mut files = Vec::::new(); + let mut files = Vec::new(); let mut ranges_by_buffer = HashMap::<_, Vec>>::default(); let mut paths_by_buffer = HashMap::default(); for entry in new_entries.values() { @@ -369,16 +327,31 @@ async fn rebuild_related_files( continue; }; let excerpts = assemble_excerpts(snapshot, ranges); - files.push(RelatedFile { - path: project_path.clone(), - buffer: buffer.downgrade(), - excerpts, - max_row: snapshot.max_point().row, - }); + let Some(root_name) = worktree_root_names.get(&project_path.worktree_id) else { + continue; + }; + + let path = Path::new(&format!( + "{}/{}", + root_name, + project_path.path.as_unix_str() + )) + .into(); + + files.push(( + buffer, + RelatedFile { + path, + excerpts, + max_row: snapshot.max_point().row, + }, + )); } - files.sort_by_key(|file| file.path.clone()); - (new_entries, files) + files.sort_by_key(|(_, file)| file.path.clone()); + let (related_buffers, related_files) = files.into_iter().unzip(); + + (new_entries, related_files, related_buffers) }) .await) } diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs index dba8d89e593ccb60e7eae5d091708e82debef0f5..d93a66081164a3fc70f7e1072d91a02bd9adbd37 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -48,7 +48,7 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { &excerpts, &[ ( - "src/company.rs", + "root/src/company.rs", &[indoc! {" pub struct Company { owner: Arc, @@ -56,7 +56,7 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { }"}], ), ( - "src/main.rs", + "root/src/main.rs", &[ indoc! {" pub struct Session { @@ -71,7 +71,7 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { ], ), ( - "src/person.rs", + "root/src/person.rs", &[ indoc! {" impl Person { @@ -446,7 +446,7 @@ fn assert_related_files(actual_files: &[RelatedFile], expected_files: &[(&str, & .iter() .map(|excerpt| excerpt.text.to_string()) .collect::>(); - (file.path.path.as_unix_str(), excerpts) + (file.path.to_str().unwrap(), excerpts) }) .collect::>(); let expected_excerpts = expected_files @@ -492,10 +492,10 @@ fn format_excerpts(buffer: &Buffer, excerpts: &[RelatedExcerpt]) -> String { if excerpt.text.is_empty() { continue; } - if current_row < excerpt.point_range.start.row { + if current_row < excerpt.row_range.start { writeln!(&mut output, "…").unwrap(); } - current_row = excerpt.point_range.start.row; + current_row = excerpt.row_range.start; for line in excerpt.text.to_string().lines() { output.push_str(line); diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index fb846f35d76ae2f6478ef675f246e4d06fe5f469..d6fc45512132197a3b9e7bd200c3005efa52ae10 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -17,7 +17,6 @@ anyhow.workspace = true buffer_diff.workspace = true client.workspace = true cloud_llm_client.workspace = true -cloud_zeta2_prompt.workspace = true codestral.workspace = true command_palette_hooks.workspace = true copilot.workspace = true @@ -46,6 +45,7 @@ ui_input.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true +zeta_prompt.workspace = true [dev-dependencies] copilot = { workspace = true, features = ["test-support"] } diff --git a/crates/edit_prediction_ui/src/edit_prediction_context_view.rs b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs index 0e343fe3fcb8ed7bb6bf3e8481927344d63133ee..92d66d2bec3a7a3b35678f1d4da92fae6b071633 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_context_view.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs @@ -17,7 +17,7 @@ use gpui::{ }; use multi_buffer::MultiBuffer; use project::Project; -use text::OffsetRangeExt; +use text::Point; use ui::{ ButtonCommon, Clickable, Disableable, FluentBuilder as _, IconButton, IconName, StyledTypography as _, h_flex, v_flex, @@ -66,7 +66,7 @@ impl EditPredictionContextView { ) -> Self { let store = EditPredictionStore::global(client, user_store, cx); - let mut debug_rx = store.update(cx, |store, _| store.debug_info()); + let mut debug_rx = store.update(cx, |store, cx| store.debug_info(&project, cx)); let _update_task = cx.spawn_in(window, async move |this, cx| { while let Some(event) = debug_rx.next().await { this.update_in(cx, |this, window, cx| { @@ -103,7 +103,8 @@ impl EditPredictionContextView { self.handle_context_retrieval_finished(info, window, cx); } } - DebugEvent::EditPredictionRequested(_) => {} + DebugEvent::EditPredictionStarted(_) => {} + DebugEvent::EditPredictionFinished(_) => {} } } @@ -152,12 +153,11 @@ impl EditPredictionContextView { run.finished_at = Some(info.timestamp); run.metadata = info.metadata; - let project = self.project.clone(); let related_files = self .store .read(cx) - .context_for_project(&self.project, cx) - .to_vec(); + .context_for_project_with_buffers(&self.project, cx) + .map_or(Vec::new(), |files| files.collect()); let editor = run.editor.clone(); let multibuffer = run.editor.read(cx).buffer().clone(); @@ -168,33 +168,14 @@ impl EditPredictionContextView { cx.spawn_in(window, async move |this, cx| { let mut paths = Vec::new(); - for related_file in related_files { - let (buffer, point_ranges): (_, Vec<_>) = - if let Some(buffer) = related_file.buffer.upgrade() { - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - - ( - buffer, - related_file - .excerpts - .iter() - .map(|excerpt| excerpt.anchor_range.to_point(&snapshot)) - .collect(), - ) - } else { - ( - project - .update(cx, |project, cx| { - project.open_buffer(related_file.path.clone(), cx) - })? - .await?, - related_file - .excerpts - .iter() - .map(|excerpt| excerpt.point_range.clone()) - .collect(), - ) - }; + for (related_file, buffer) in related_files { + let point_ranges = related_file + .excerpts + .iter() + .map(|excerpt| { + Point::new(excerpt.row_range.start, 0)..Point::new(excerpt.row_range.end, 0) + }) + .collect::>(); cx.update(|_, cx| { let path = PathKey::for_buffer(&buffer, cx); paths.push((path, buffer, point_ranges)); diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 8e754b33dc18c5be60bc052c33aa08cdcb980acb..54933fbf904f8fc7146dcce9a6bd3340884cc8bf 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -1,5 +1,4 @@ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use cloud_zeta2_prompt::write_codeblock; use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore}; use editor::{Editor, ExcerptRange, MultiBuffer}; use feature_flags::FeatureFlag; @@ -362,14 +361,14 @@ impl RatePredictionsModal { write!(&mut formatted_inputs, "## Events\n\n").unwrap(); for event in &prediction.inputs.events { - write!(&mut formatted_inputs, "```diff\n{event}```\n\n").unwrap(); + formatted_inputs.push_str("```diff\n"); + zeta_prompt::write_event(&mut formatted_inputs, event.as_ref()); + formatted_inputs.push_str("```\n\n"); } - write!(&mut formatted_inputs, "## Included files\n\n").unwrap(); - - for included_file in &prediction.inputs.included_files { - let cursor_insertions = &[(prediction.inputs.cursor_point, "<|CURSOR|>")]; + write!(&mut formatted_inputs, "## Related files\n\n").unwrap(); + for included_file in prediction.inputs.related_files.as_ref() { write!( &mut formatted_inputs, "### {}\n\n", @@ -377,20 +376,28 @@ impl RatePredictionsModal { ) .unwrap(); - write_codeblock( - &included_file.path, - &included_file.excerpts, - if included_file.path == prediction.inputs.cursor_path { - cursor_insertions.as_slice() - } else { - &[] - }, - included_file.max_row, - false, - &mut formatted_inputs, - ); + for excerpt in included_file.excerpts.iter() { + write!( + &mut formatted_inputs, + "```{}\n{}\n```\n", + included_file.path.display(), + excerpt.text + ) + .unwrap(); + } } + write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap(); + + writeln!( + &mut formatted_inputs, + "```{}\n{}{}\n```\n", + prediction.inputs.cursor_path.display(), + &prediction.inputs.cursor_excerpt[..prediction.inputs.cursor_offset_in_excerpt], + &prediction.inputs.cursor_excerpt[prediction.inputs.cursor_offset_in_excerpt..], + ) + .unwrap(); + self.active_prediction = Some(ActivePrediction { prediction, feedback_editor: cx.new(|cx| { diff --git a/crates/zeta_prompt/Cargo.toml b/crates/zeta_prompt/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..c9b1e2d784d10ea2fd278f70ffdae2ef0981fce0 --- /dev/null +++ b/crates/zeta_prompt/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zeta_prompt" +version = "0.1.0" +publish.workspace = true +edition.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/zeta_prompt.rs" + +[dependencies] +serde.workspace = true \ No newline at end of file diff --git a/crates/cloud_zeta2_prompt/LICENSE-GPL b/crates/zeta_prompt/LICENSE-GPL similarity index 100% rename from crates/cloud_zeta2_prompt/LICENSE-GPL rename to crates/zeta_prompt/LICENSE-GPL diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs new file mode 100644 index 0000000000000000000000000000000000000000..21fbca1ae10b715d0c11a31dc9390aada03fa157 --- /dev/null +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use std::fmt::Write; +use std::ops::Range; +use std::path::Path; +use std::sync::Arc; + +pub const CURSOR_MARKER: &str = "<|user_cursor|>"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ZetaPromptInput { + pub cursor_path: Arc, + pub cursor_excerpt: Arc, + pub editable_range_in_excerpt: Range, + pub cursor_offset_in_excerpt: usize, + pub events: Vec>, + pub related_files: Arc<[RelatedFile]>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum Event { + BufferChange { + path: Arc, + old_path: Arc, + diff: String, + predicted: bool, + in_open_source_repo: bool, + }, +} + +pub fn write_event(prompt: &mut String, event: &Event) { + fn write_path_as_unix_str(prompt: &mut String, path: &Path) { + for component in path.components() { + prompt.push('/'); + write!(prompt, "{}", component.as_os_str().display()).ok(); + } + } + match event { + Event::BufferChange { + path, + old_path, + diff, + predicted, + in_open_source_repo: _, + } => { + if *predicted { + prompt.push_str("// User accepted prediction:\n"); + } + prompt.push_str("--- a"); + write_path_as_unix_str(prompt, old_path.as_ref()); + prompt.push_str("\n+++ b"); + write_path_as_unix_str(prompt, path.as_ref()); + prompt.push('\n'); + prompt.push_str(diff); + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RelatedFile { + pub path: Arc, + pub max_row: u32, + pub excerpts: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RelatedExcerpt { + pub row_range: Range, + pub text: String, +} + +pub fn format_zeta_prompt(input: &ZetaPromptInput) -> String { + let mut prompt = String::new(); + write_related_files(&mut prompt, &input.related_files); + write_edit_history_section(&mut prompt, input); + write_cursor_excerpt_section(&mut prompt, input); + prompt +} + +pub fn write_related_files(prompt: &mut String, related_files: &[RelatedFile]) { + push_delimited(prompt, "related_files", &[], |prompt| { + for file in related_files { + let path_str = file.path.to_string_lossy(); + push_delimited(prompt, "related_file", &[("path", &path_str)], |prompt| { + for excerpt in &file.excerpts { + push_delimited( + prompt, + "related_excerpt", + &[( + "lines", + &format!( + "{}-{}", + excerpt.row_range.start + 1, + excerpt.row_range.end + 1 + ), + )], + |prompt| { + prompt.push_str(&excerpt.text); + prompt.push('\n'); + }, + ); + } + }); + } + }); +} + +fn write_edit_history_section(prompt: &mut String, input: &ZetaPromptInput) { + push_delimited(prompt, "edit_history", &[], |prompt| { + if input.events.is_empty() { + prompt.push_str("(No edit history)"); + } else { + for event in &input.events { + write_event(prompt, event); + } + } + }); +} + +fn write_cursor_excerpt_section(prompt: &mut String, input: &ZetaPromptInput) { + push_delimited(prompt, "cursor_excerpt", &[], |prompt| { + let path_str = input.cursor_path.to_string_lossy(); + push_delimited(prompt, "file", &[("path", &path_str)], |prompt| { + prompt.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + push_delimited(prompt, "editable_region", &[], |prompt| { + prompt.push_str( + &input.cursor_excerpt + [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], + ); + prompt.push_str(CURSOR_MARKER); + prompt.push_str( + &input.cursor_excerpt + [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], + ); + }); + prompt.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + }); + }); +} + +fn push_delimited( + prompt: &mut String, + tag: &'static str, + arguments: &[(&str, &str)], + cb: impl FnOnce(&mut String), +) { + if !prompt.ends_with("\n") { + prompt.push('\n'); + } + prompt.push('<'); + prompt.push_str(tag); + for (arg_name, arg_value) in arguments { + write!(prompt, " {}=\"{}\"", arg_name, arg_value).ok(); + } + prompt.push_str(">\n"); + + cb(prompt); + + if !prompt.ends_with('\n') { + prompt.push('\n'); + } + prompt.push_str("\n"); +}