diff --git a/Cargo.lock b/Cargo.lock
index 94aca307210e19bc97c14002ba5f136edfd76778..78a209af2af33ab8d4e99e2fb3b5f200c6194b34 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -629,13 +629,17 @@ version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
+ "collections",
"futures 0.3.32",
"http_client",
+ "language_model_core",
+ "log",
"schemars",
"serde",
"serde_json",
"strum 0.27.2",
"thiserror 2.0.17",
+ "tiktoken-rs",
]
[[package]]
@@ -2903,7 +2907,6 @@ dependencies = [
"http_client",
"http_client_tls",
"httparse",
- "language_model",
"log",
"objc2-foundation",
"parking_lot",
@@ -2959,6 +2962,7 @@ dependencies = [
"http_client",
"parking_lot",
"serde_json",
+ "smol",
"thiserror 2.0.17",
"yawc",
]
@@ -3204,7 +3208,6 @@ dependencies = [
"anyhow",
"call",
"channel",
- "chrono",
"client",
"collections",
"db",
@@ -3213,7 +3216,6 @@ dependencies = [
"fuzzy",
"gpui",
"livekit_client",
- "log",
"menu",
"notifications",
"picker",
@@ -3228,7 +3230,6 @@ dependencies = [
"theme",
"theme_settings",
"time",
- "time_format",
"title_bar",
"ui",
"util",
@@ -5161,6 +5162,7 @@ dependencies = [
"buffer_diff",
"client",
"clock",
+ "cloud_api_client",
"cloud_api_types",
"cloud_llm_client",
"collections",
@@ -5640,7 +5642,7 @@ dependencies = [
name = "env_var"
version = "0.1.0"
dependencies = [
- "gpui",
+ "gpui_shared_string",
]
[[package]]
@@ -6182,6 +6184,7 @@ dependencies = [
"file_icons",
"futures 0.3.32",
"fuzzy",
+ "fuzzy_nucleo",
"gpui",
"menu",
"open_path_prompt",
@@ -6739,6 +6742,15 @@ dependencies = [
"thread_local",
]
+[[package]]
+name = "fuzzy_nucleo"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "nucleo",
+ "util",
+]
+
[[package]]
name = "gaoya"
version = "0.2.0"
@@ -7457,11 +7469,13 @@ dependencies = [
"anyhow",
"futures 0.3.32",
"http_client",
+ "language_model_core",
+ "log",
"schemars",
"serde",
"serde_json",
- "settings",
"strum 0.27.2",
+ "tiktoken-rs",
]
[[package]]
@@ -7530,6 +7544,7 @@ dependencies = [
"getrandom 0.3.4",
"gpui_macros",
"gpui_platform",
+ "gpui_shared_string",
"gpui_util",
"gpui_web",
"http_client",
@@ -7699,6 +7714,16 @@ dependencies = [
"gpui_windows",
]
+[[package]]
+name = "gpui_shared_string"
+version = "0.1.0"
+dependencies = [
+ "derive_more",
+ "gpui_util",
+ "schemars",
+ "serde",
+]
+
[[package]]
name = "gpui_tokio"
version = "0.1.0"
@@ -9348,7 +9373,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"collections",
- "gpui",
+ "gpui_shared_string",
"log",
"lsp",
"parking_lot",
@@ -9387,12 +9412,8 @@ dependencies = [
name = "language_model"
version = "0.1.0"
dependencies = [
- "anthropic",
"anyhow",
"base64 0.22.1",
- "cloud_api_client",
- "cloud_api_types",
- "cloud_llm_client",
"collections",
"credentials_provider",
"env_var",
@@ -9401,16 +9422,31 @@ dependencies = [
"http_client",
"icons",
"image",
+ "language_model_core",
"log",
- "open_ai",
- "open_router",
"parking_lot",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.17",
+ "util",
+]
+
+[[package]]
+name = "language_model_core"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "cloud_llm_client",
+ "futures 0.3.32",
+ "gpui_shared_string",
+ "http_client",
+ "partial-json-fixer",
"schemars",
"serde",
"serde_json",
"smol",
+ "strum 0.27.2",
"thiserror 2.0.17",
- "util",
]
[[package]]
@@ -9426,8 +9462,8 @@ dependencies = [
"base64 0.22.1",
"bedrock",
"client",
+ "cloud_api_client",
"cloud_api_types",
- "cloud_llm_client",
"collections",
"component",
"convert_case 0.8.0",
@@ -9446,6 +9482,7 @@ dependencies = [
"http_client",
"language",
"language_model",
+ "language_models_cloud",
"lmstudio",
"log",
"menu",
@@ -9455,19 +9492,16 @@ dependencies = [
"open_router",
"opencode",
"parking_lot",
- "partial-json-fixer",
"pretty_assertions",
"rand 0.9.2",
"release_channel",
"schemars",
- "semver",
"serde",
"serde_json",
"settings",
"sha2",
"smol",
"strum 0.27.2",
- "thiserror 2.0.17",
"tiktoken-rs",
"tokio",
"ui",
@@ -9478,6 +9512,28 @@ dependencies = [
"x_ai",
]
+[[package]]
+name = "language_models_cloud"
+version = "0.1.0"
+dependencies = [
+ "anthropic",
+ "anyhow",
+ "cloud_llm_client",
+ "futures 0.3.32",
+ "google_ai",
+ "gpui",
+ "http_client",
+ "language_model",
+ "open_ai",
+ "schemars",
+ "semver",
+ "serde",
+ "serde_json",
+ "smol",
+ "thiserror 2.0.17",
+ "x_ai",
+]
+
[[package]]
name = "language_onboarding"
version = "0.1.0"
@@ -11067,6 +11123,27 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "nucleo"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4"
+dependencies = [
+ "nucleo-matcher",
+ "parking_lot",
+ "rayon",
+]
+
+[[package]]
+name = "nucleo-matcher"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
+dependencies = [
+ "memchr",
+ "unicode-segmentation",
+]
+
[[package]]
name = "num"
version = "0.4.3"
@@ -11604,16 +11681,19 @@ name = "open_ai"
version = "0.1.0"
dependencies = [
"anyhow",
+ "collections",
"futures 0.3.32",
"http_client",
+ "language_model_core",
"log",
+ "pretty_assertions",
"rand 0.9.2",
"schemars",
"serde",
"serde_json",
- "settings",
"strum 0.27.2",
"thiserror 2.0.17",
+ "tiktoken-rs",
]
[[package]]
@@ -11645,6 +11725,7 @@ dependencies = [
"anyhow",
"futures 0.3.32",
"http_client",
+ "language_model_core",
"schemars",
"serde",
"serde_json",
@@ -13207,6 +13288,7 @@ dependencies = [
"fs",
"futures 0.3.32",
"fuzzy",
+ "fuzzy_nucleo",
"git",
"git2",
"git_hosting_providers",
@@ -15773,6 +15855,7 @@ dependencies = [
"collections",
"derive_more",
"gpui",
+ "language_model_core",
"log",
"schemars",
"serde",
@@ -20152,6 +20235,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"client",
+ "cloud_api_client",
"cloud_api_types",
"cloud_llm_client",
"futures 0.3.32",
@@ -21755,9 +21839,11 @@ name = "x_ai"
version = "0.1.0"
dependencies = [
"anyhow",
+ "language_model_core",
"schemars",
"serde",
"strum 0.27.2",
+ "tiktoken-rs",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 5cb5b991b645ec1b78b16f48493c7c8dc1426344..5a7fc9caaf982953168855671bebbcf4f010df03 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,6 +78,7 @@ members = [
"crates/fs",
"crates/fs_benchmarks",
"crates/fuzzy",
+ "crates/fuzzy_nucleo",
"crates/git",
"crates/git_graph",
"crates/git_hosting_providers",
@@ -86,6 +87,7 @@ members = [
"crates/google_ai",
"crates/grammars",
"crates/gpui",
+ "crates/gpui_shared_string",
"crates/gpui_linux",
"crates/gpui_macos",
"crates/gpui_macros",
@@ -109,7 +111,9 @@ members = [
"crates/language_core",
"crates/language_extension",
"crates/language_model",
+ "crates/language_model_core",
"crates/language_models",
+ "crates/language_models_cloud",
"crates/language_onboarding",
"crates/language_selector",
"crates/language_tools",
@@ -325,6 +329,7 @@ file_finder = { path = "crates/file_finder" }
file_icons = { path = "crates/file_icons" }
fs = { path = "crates/fs" }
fuzzy = { path = "crates/fuzzy" }
+fuzzy_nucleo = { path = "crates/fuzzy_nucleo" }
git = { path = "crates/git" }
git_graph = { path = "crates/git_graph" }
git_hosting_providers = { path = "crates/git_hosting_providers" }
@@ -333,6 +338,7 @@ go_to_line = { path = "crates/go_to_line" }
google_ai = { path = "crates/google_ai" }
grammars = { path = "crates/grammars" }
gpui = { path = "crates/gpui", default-features = false }
+gpui_shared_string = { path = "crates/gpui_shared_string" }
gpui_linux = { path = "crates/gpui_linux", default-features = false }
gpui_macos = { path = "crates/gpui_macos", default-features = false }
gpui_macros = { path = "crates/gpui_macros" }
@@ -359,7 +365,9 @@ language = { path = "crates/language" }
language_core = { path = "crates/language_core" }
language_extension = { path = "crates/language_extension" }
language_model = { path = "crates/language_model" }
+language_model_core = { path = "crates/language_model_core" }
language_models = { path = "crates/language_models" }
+language_models_cloud = { path = "crates/language_models_cloud" }
language_onboarding = { path = "crates/language_onboarding" }
language_selector = { path = "crates/language_selector" }
language_tools = { path = "crates/language_tools" }
@@ -609,6 +617,7 @@ naga = { version = "29.0", features = ["wgsl-in"] }
nanoid = "0.4"
nbformat = "1.2.0"
nix = "0.29"
+nucleo = "0.5"
num-format = "0.4.4"
objc = "0.2"
objc2-app-kit = { version = "0.3", default-features = false, features = [ "NSGraphics" ] }
diff --git a/assets/icons/folder_open_add.svg b/assets/icons/folder_open_add.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d5ebbdaa8b080037a2faee0ee0fc3606eec9c6ca
--- /dev/null
+++ b/assets/icons/folder_open_add.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/folder_plus.svg b/assets/icons/folder_plus.svg
deleted file mode 100644
index a543448ed6197043291369bee640e23b6ad729b9..0000000000000000000000000000000000000000
--- a/assets/icons/folder_plus.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/assets/icons/open_new_window.svg b/assets/icons/open_new_window.svg
new file mode 100644
index 0000000000000000000000000000000000000000..c81d49f9ff9edfbc965055568efc72e0214efb41
--- /dev/null
+++ b/assets/icons/open_new_window.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/settings/default.json b/assets/settings/default.json
index 63e906e3b11206fc458f8d7353f3ecba0abeb825..97fbcd546e09beefa9ff7a67e33806f3faf561d1 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -936,16 +936,6 @@
// For example: typing `:wave:` gets replaced with `đź‘‹`.
"auto_replace_emoji_shortcode": true,
},
- "notification_panel": {
- // Whether to show the notification panel button in the status bar.
- "button": true,
- // Where to dock the notification panel. Can be 'left' or 'right'.
- "dock": "right",
- // Default width of the notification panel.
- "default_width": 380,
- // Whether to show a badge on the notification panel icon with the count of unread notifications.
- "show_count_badge": false,
- },
"agent": {
// Whether the inline assistant should use streaming tools, when available
"inline_assistant_use_streaming_tools": true,
@@ -965,6 +955,9 @@
"default_width": 640,
// Default height when the agent panel is docked to the bottom.
"default_height": 320,
+ // Maximum content width when the agent panel is wider than this value.
+ // Content will be centered within the panel.
+ "max_content_width": 850,
// The default model to use when creating new threads.
"default_model": {
// The provider to use.
diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs
index 58e779da59aef176464839ed6f2d6a5c16e4bc12..ff9e735b6c4181588ed5cddbd6dada7fbae5f18f 100644
--- a/crates/agent/src/tool_permissions.rs
+++ b/crates/agent/src/tool_permissions.rs
@@ -574,6 +574,7 @@ mod tests {
flexible: true,
default_width: px(300.),
default_height: px(600.),
+ max_content_width: px(850.),
default_model: None,
inline_assistant_model: None,
inline_assistant_use_streaming_tools: false,
diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs
index 0086a82f4e79c9924502202873ceb2b25d2e66fb..9b013f111e7eaa981652d8868dfcf3c098d9dc7e 100644
--- a/crates/agent/src/tools/read_file_tool.rs
+++ b/crates/agent/src/tools/read_file_tool.rs
@@ -5,7 +5,7 @@ use futures::FutureExt as _;
use gpui::{App, Entity, SharedString, Task};
use indoc::formatdoc;
use language::Point;
-use language_model::{LanguageModelImage, LanguageModelToolResultContent};
+use language_model::{LanguageModelImage, LanguageModelImageExt, LanguageModelToolResultContent};
use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs
index 5f452bc9c0e2e9c2322042583295894a5866b053..e56db9df927ab3cdf838587f1cb4f9514eb5a758 100644
--- a/crates/agent_servers/src/acp.rs
+++ b/crates/agent_servers/src/acp.rs
@@ -325,7 +325,7 @@ impl AcpConnection {
// Use the one the agent provides if we have one
.map(|info| info.name.into())
// Otherwise, just use the name
- .unwrap_or_else(|| agent_id.0.to_string().into());
+ .unwrap_or_else(|| agent_id.0.clone());
let session_list = if response
.agent_capabilities
diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs
index 0c68d2f25d54f966d1cc0a93476457bbba79c959..a04de2ed3be69d3f5791419a32e427fa0c26791e 100644
--- a/crates/agent_settings/src/agent_settings.rs
+++ b/crates/agent_settings/src/agent_settings.rs
@@ -31,7 +31,6 @@ pub struct PanelLayout {
pub(crate) outline_panel_dock: Option,
pub(crate) collaboration_panel_dock: Option,
pub(crate) git_panel_dock: Option,
- pub(crate) notification_panel_button: Option,
}
impl PanelLayout {
@@ -41,7 +40,6 @@ impl PanelLayout {
outline_panel_dock: Some(DockSide::Right),
collaboration_panel_dock: Some(DockPosition::Right),
git_panel_dock: Some(DockPosition::Right),
- notification_panel_button: Some(false),
};
const EDITOR: Self = Self {
@@ -50,7 +48,6 @@ impl PanelLayout {
outline_panel_dock: Some(DockSide::Left),
collaboration_panel_dock: Some(DockPosition::Left),
git_panel_dock: Some(DockPosition::Left),
- notification_panel_button: Some(true),
};
pub fn is_agent_layout(&self) -> bool {
@@ -68,7 +65,6 @@ impl PanelLayout {
outline_panel_dock: content.outline_panel.as_ref().and_then(|p| p.dock),
collaboration_panel_dock: content.collaboration_panel.as_ref().and_then(|p| p.dock),
git_panel_dock: content.git_panel.as_ref().and_then(|p| p.dock),
- notification_panel_button: content.notification_panel.as_ref().and_then(|p| p.button),
}
}
@@ -78,7 +74,6 @@ impl PanelLayout {
settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
settings.collaboration_panel.get_or_insert_default().dock = self.collaboration_panel_dock;
settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
- settings.notification_panel.get_or_insert_default().button = self.notification_panel_button;
}
fn write_diff_to(&self, current_merged: &PanelLayout, settings: &mut SettingsContent) {
@@ -98,10 +93,6 @@ impl PanelLayout {
if self.git_panel_dock != current_merged.git_panel_dock {
settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
}
- if self.notification_panel_button != current_merged.notification_panel_button {
- settings.notification_panel.get_or_insert_default().button =
- self.notification_panel_button;
- }
}
fn backfill_to(&self, user_layout: &PanelLayout, settings: &mut SettingsContent) {
@@ -121,10 +112,6 @@ impl PanelLayout {
if user_layout.git_panel_dock.is_none() {
settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
}
- if user_layout.notification_panel_button.is_none() {
- settings.notification_panel.get_or_insert_default().button =
- self.notification_panel_button;
- }
}
}
@@ -154,6 +141,7 @@ pub struct AgentSettings {
pub sidebar_side: SidebarDockPosition,
pub default_width: Pixels,
pub default_height: Pixels,
+ pub max_content_width: Pixels,
pub default_model: Option,
pub inline_assistant_model: Option,
pub inline_assistant_use_streaming_tools: bool,
@@ -600,6 +588,7 @@ impl Settings for AgentSettings {
sidebar_side: agent.sidebar_side.unwrap(),
default_width: px(agent.default_width.unwrap()),
default_height: px(agent.default_height.unwrap()),
+ max_content_width: px(agent.max_content_width.unwrap()),
flexible: agent.flexible.unwrap(),
default_model: Some(agent.default_model.unwrap()),
inline_assistant_model: agent.inline_assistant_model,
@@ -1255,7 +1244,6 @@ mod tests {
assert_eq!(user_layout.outline_panel_dock, None);
assert_eq!(user_layout.collaboration_panel_dock, None);
assert_eq!(user_layout.git_panel_dock, None);
- assert_eq!(user_layout.notification_panel_button, None);
// User sets a combination that doesn't match either preset:
// agent on the left but project panel also on the left.
@@ -1478,7 +1466,6 @@ mod tests {
Some(DockPosition::Left)
);
assert_eq!(user_layout.git_panel_dock, Some(DockPosition::Left));
- assert_eq!(user_layout.notification_panel_button, Some(true));
// Now switch defaults to agent V2.
set_agent_v2_defaults(cx);
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 41900e71e5d3ad7e5327ee7e04f73cb05eed5a5b..01b897fc63da76247b5624f8316ea06b2c1f85e5 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -28,21 +28,20 @@ use zed_actions::agent::{
use crate::thread_metadata_store::ThreadMetadataStore;
use crate::{
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
- Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown,
- OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn,
- ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
+ Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
+ OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
+ StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
conversation_view::{AcpThreadViewEvent, ThreadView},
+ thread_branch_picker::ThreadBranchPicker,
+ thread_worktree_picker::ThreadWorktreePicker,
ui::EndTrialUpsell,
};
use crate::{
Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
NewNativeAgentThreadFromSummary,
};
-use crate::{
- DEFAULT_THREAD_TITLE,
- ui::{AcpOnboardingModal, HoldForDefault},
-};
+use crate::{DEFAULT_THREAD_TITLE, ui::AcpOnboardingModal};
use crate::{ExpandMessageEditor, ThreadHistoryView};
use crate::{ManageProfiles, ThreadHistoryViewEvent};
use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
@@ -73,8 +72,8 @@ use terminal::terminal_settings::TerminalSettings;
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use theme_settings::ThemeSettings;
use ui::{
- Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide,
- PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
+ Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
+ PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
};
use util::{ResultExt as _, debug_panic};
use workspace::{
@@ -620,7 +619,31 @@ impl StartThreadIn {
fn label(&self) -> SharedString {
match self {
Self::LocalProject => "Current Worktree".into(),
- Self::NewWorktree => "New Git Worktree".into(),
+ Self::NewWorktree {
+ worktree_name: Some(worktree_name),
+ ..
+ } => format!("New: {worktree_name}").into(),
+ Self::NewWorktree { .. } => "New Git Worktree".into(),
+ Self::LinkedWorktree { display_name, .. } => format!("From: {}", &display_name).into(),
+ }
+ }
+
+ fn worktree_branch_label(&self, default_branch_label: SharedString) -> Option {
+ match self {
+ Self::NewWorktree { branch_target, .. } => match branch_target {
+ NewWorktreeBranchTarget::CurrentBranch => Some(default_branch_label),
+ NewWorktreeBranchTarget::ExistingBranch { name } => {
+ Some(format!("From: {name}").into())
+ }
+ NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
+ if let Some(from_ref) = from_ref {
+ Some(format!("From: {from_ref}").into())
+ } else {
+ Some(format!("From: {name}").into())
+ }
+ }
+ },
+ _ => None,
}
}
}
@@ -632,6 +655,17 @@ pub enum WorktreeCreationStatus {
Error(SharedString),
}
+#[derive(Clone, Debug)]
+enum WorktreeCreationArgs {
+ New {
+ worktree_name: Option,
+ branch_target: NewWorktreeBranchTarget,
+ },
+ Linked {
+ worktree_path: PathBuf,
+ },
+}
+
impl ActiveView {
pub fn which_font_size_used(&self) -> WhichFontSize {
match self {
@@ -662,7 +696,8 @@ pub struct AgentPanel {
previous_view: Option,
background_threads: HashMap>,
new_thread_menu_handle: PopoverMenuHandle,
- start_thread_in_menu_handle: PopoverMenuHandle,
+ start_thread_in_menu_handle: PopoverMenuHandle,
+ thread_branch_menu_handle: PopoverMenuHandle,
agent_panel_menu_handle: PopoverMenuHandle,
agent_navigation_menu_handle: PopoverMenuHandle,
agent_navigation_menu: Option>,
@@ -689,7 +724,7 @@ impl AgentPanel {
};
let selected_agent = self.selected_agent.clone();
- let start_thread_in = Some(self.start_thread_in);
+ let start_thread_in = Some(self.start_thread_in.clone());
let last_active_thread = self.active_agent_thread(cx).map(|thread| {
let thread = thread.read(cx);
@@ -794,18 +829,21 @@ impl AgentPanel {
} else if let Some(agent) = global_fallback {
panel.selected_agent = agent;
}
- if let Some(start_thread_in) = serialized_panel.start_thread_in {
+ if let Some(ref start_thread_in) = serialized_panel.start_thread_in {
let is_worktree_flag_enabled =
cx.has_flag::();
let is_valid = match &start_thread_in {
StartThreadIn::LocalProject => true,
- StartThreadIn::NewWorktree => {
+ StartThreadIn::NewWorktree { .. } => {
let project = panel.project.read(cx);
is_worktree_flag_enabled && !project.is_via_collab()
}
+ StartThreadIn::LinkedWorktree { path, .. } => {
+ is_worktree_flag_enabled && path.exists()
+ }
};
if is_valid {
- panel.start_thread_in = start_thread_in;
+ panel.start_thread_in = start_thread_in.clone();
} else {
log::info!(
"deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
@@ -979,6 +1017,7 @@ impl AgentPanel {
background_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
start_thread_in_menu_handle: PopoverMenuHandle::default(),
+ thread_branch_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu: None,
@@ -1948,24 +1987,43 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context,
) {
- if matches!(action, StartThreadIn::NewWorktree) && !cx.has_flag::() {
- return;
- }
-
- let new_target = match *action {
+ let new_target = match action {
StartThreadIn::LocalProject => StartThreadIn::LocalProject,
- StartThreadIn::NewWorktree => {
+ StartThreadIn::NewWorktree { .. } => {
+ if !cx.has_flag::() {
+ return;
+ }
if !self.project_has_git_repository(cx) {
log::error!(
- "set_start_thread_in: cannot use NewWorktree without a git repository"
+ "set_start_thread_in: cannot use worktree mode without a git repository"
);
return;
}
if self.project.read(cx).is_via_collab() {
- log::error!("set_start_thread_in: cannot use NewWorktree in a collab project");
+ log::error!(
+ "set_start_thread_in: cannot use worktree mode in a collab project"
+ );
return;
}
- StartThreadIn::NewWorktree
+ action.clone()
+ }
+ StartThreadIn::LinkedWorktree { .. } => {
+ if !cx.has_flag::() {
+ return;
+ }
+ if !self.project_has_git_repository(cx) {
+ log::error!(
+ "set_start_thread_in: cannot use LinkedWorktree without a git repository"
+ );
+ return;
+ }
+ if self.project.read(cx).is_via_collab() {
+ log::error!(
+ "set_start_thread_in: cannot use LinkedWorktree in a collab project"
+ );
+ return;
+ }
+ action.clone()
}
};
self.start_thread_in = new_target;
@@ -1977,9 +2035,14 @@ impl AgentPanel {
}
fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context) {
- let next = match self.start_thread_in {
- StartThreadIn::LocalProject => StartThreadIn::NewWorktree,
- StartThreadIn::NewWorktree => StartThreadIn::LocalProject,
+ let next = match &self.start_thread_in {
+ StartThreadIn::LocalProject => StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ StartThreadIn::NewWorktree { .. } | StartThreadIn::LinkedWorktree { .. } => {
+ StartThreadIn::LocalProject
+ }
};
self.set_start_thread_in(&next, window, cx);
}
@@ -1991,7 +2054,10 @@ impl AgentPanel {
NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
NewThreadLocation::NewWorktree => {
if self.project_has_git_repository(cx) {
- StartThreadIn::NewWorktree
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ }
} else {
StartThreadIn::LocalProject
}
@@ -2219,15 +2285,39 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context,
) {
- if self.start_thread_in == StartThreadIn::NewWorktree {
- self.handle_worktree_creation_requested(content, window, cx);
- } else {
- cx.defer_in(window, move |_this, window, cx| {
- thread_view.update(cx, |thread_view, cx| {
- let editor = thread_view.message_editor.clone();
- thread_view.send_impl(editor, window, cx);
+ match &self.start_thread_in {
+ StartThreadIn::NewWorktree {
+ worktree_name,
+ branch_target,
+ } => {
+ self.handle_worktree_requested(
+ content,
+ WorktreeCreationArgs::New {
+ worktree_name: worktree_name.clone(),
+ branch_target: branch_target.clone(),
+ },
+ window,
+ cx,
+ );
+ }
+ StartThreadIn::LinkedWorktree { path, .. } => {
+ self.handle_worktree_requested(
+ content,
+ WorktreeCreationArgs::Linked {
+ worktree_path: path.clone(),
+ },
+ window,
+ cx,
+ );
+ }
+ StartThreadIn::LocalProject => {
+ cx.defer_in(window, move |_this, window, cx| {
+ thread_view.update(cx, |thread_view, cx| {
+ let editor = thread_view.message_editor.clone();
+ thread_view.send_impl(editor, window, cx);
+ });
});
- });
+ }
}
}
@@ -2289,6 +2379,33 @@ impl AgentPanel {
(git_repos, non_git_paths)
}
+ fn resolve_worktree_branch_target(
+ branch_target: &NewWorktreeBranchTarget,
+ existing_branches: &HashSet,
+ occupied_branches: &HashSet,
+ ) -> Result<(String, bool, Option)> {
+ let generate_branch_name = || -> Result {
+ let refs: Vec<&str> = existing_branches.iter().map(|s| s.as_str()).collect();
+ let mut rng = rand::rng();
+ crate::branch_names::generate_branch_name(&refs, &mut rng)
+ .ok_or_else(|| anyhow!("Failed to generate a unique branch name"))
+ };
+
+ match branch_target {
+ NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
+ Ok((name.clone(), false, from_ref.clone()))
+ }
+ NewWorktreeBranchTarget::ExistingBranch { name } => {
+ if occupied_branches.contains(name) {
+ Ok((generate_branch_name()?, false, Some(name.clone())))
+ } else {
+ Ok((name.clone(), true, None))
+ }
+ }
+ NewWorktreeBranchTarget::CurrentBranch => Ok((generate_branch_name()?, false, None)),
+ }
+ }
+
/// Kicks off an async git-worktree creation for each repository. Returns:
///
/// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
@@ -2297,7 +2414,10 @@ impl AgentPanel {
/// later to remap open editor tabs into the new workspace.
fn start_worktree_creations(
git_repos: &[Entity],
+ worktree_name: Option,
branch_name: &str,
+ use_existing_branch: bool,
+ start_point: Option,
worktree_directory_setting: &str,
cx: &mut Context,
) -> Result<(
@@ -2311,12 +2431,27 @@ impl AgentPanel {
let mut creation_infos = Vec::new();
let mut path_remapping = Vec::new();
+ let worktree_name = worktree_name.unwrap_or_else(|| branch_name.to_string());
+
for repo in git_repos {
let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
let new_path =
- repo.path_for_new_linked_worktree(branch_name, worktree_directory_setting)?;
- let receiver =
- repo.create_worktree(branch_name.to_string(), new_path.clone(), None);
+ repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
+ let target = if use_existing_branch {
+ debug_assert!(
+ git_repos.len() == 1,
+ "use_existing_branch should only be true for a single repo"
+ );
+ git::repository::CreateWorktreeTarget::ExistingBranch {
+ branch_name: branch_name.to_string(),
+ }
+ } else {
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: branch_name.to_string(),
+ base_sha: start_point.clone(),
+ }
+ };
+ let receiver = repo.create_worktree(target, new_path.clone());
let work_dir = repo.work_directory_abs_path.clone();
anyhow::Ok((work_dir, new_path, receiver))
})?;
@@ -2419,9 +2554,10 @@ impl AgentPanel {
cx.notify();
}
- fn handle_worktree_creation_requested(
+ fn handle_worktree_requested(
&mut self,
content: Vec,
+ args: WorktreeCreationArgs,
window: &mut Window,
cx: &mut Context,
) {
@@ -2437,7 +2573,7 @@ impl AgentPanel {
let (git_repos, non_git_paths) = self.classify_worktrees(cx);
- if git_repos.is_empty() {
+ if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
self.set_worktree_creation_error(
"No git repositories found in the project".into(),
window,
@@ -2446,17 +2582,31 @@ impl AgentPanel {
return;
}
- // Kick off branch listing as early as possible so it can run
- // concurrently with the remaining synchronous setup work.
- let branch_receivers: Vec<_> = git_repos
- .iter()
- .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
- .collect();
-
- let worktree_directory_setting = ProjectSettings::get_global(cx)
- .git
- .worktree_directory
- .clone();
+ let (branch_receivers, worktree_receivers, worktree_directory_setting) =
+ if matches!(args, WorktreeCreationArgs::New { .. }) {
+ (
+ Some(
+ git_repos
+ .iter()
+ .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
+ .collect::>(),
+ ),
+ Some(
+ git_repos
+ .iter()
+ .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
+ .collect::>(),
+ ),
+ Some(
+ ProjectSettings::get_global(cx)
+ .git
+ .worktree_directory
+ .clone(),
+ ),
+ )
+ } else {
+ (None, None, None)
+ };
let active_file_path = self.workspace.upgrade().and_then(|workspace| {
let workspace = workspace.read(cx);
@@ -2476,77 +2626,124 @@ impl AgentPanel {
let selected_agent = self.selected_agent();
let task = cx.spawn_in(window, async move |this, cx| {
- // Await the branch listings we kicked off earlier.
- let mut existing_branches = Vec::new();
- for result in futures::future::join_all(branch_receivers).await {
- match result {
- Ok(Ok(branches)) => {
- for branch in branches {
- existing_branches.push(branch.name().to_string());
+ let (all_paths, path_remapping, has_non_git) = match args {
+ WorktreeCreationArgs::New {
+ worktree_name,
+ branch_target,
+ } => {
+ let branch_receivers = branch_receivers
+ .expect("branch receivers must be prepared for new worktree creation");
+ let worktree_receivers = worktree_receivers
+ .expect("worktree receivers must be prepared for new worktree creation");
+ let worktree_directory_setting = worktree_directory_setting
+ .expect("worktree directory must be prepared for new worktree creation");
+
+ let mut existing_branches = HashSet::default();
+ for result in futures::future::join_all(branch_receivers).await {
+ match result {
+ Ok(Ok(branches)) => {
+ for branch in branches {
+ existing_branches.insert(branch.name().to_string());
+ }
+ }
+ Ok(Err(err)) => {
+ Err::<(), _>(err).log_err();
+ }
+ Err(_) => {}
}
}
- Ok(Err(err)) => {
- Err::<(), _>(err).log_err();
+
+ let mut occupied_branches = HashSet::default();
+ for result in futures::future::join_all(worktree_receivers).await {
+ match result {
+ Ok(Ok(worktrees)) => {
+ for worktree in worktrees {
+ if let Some(branch_name) = worktree.branch_name() {
+ occupied_branches.insert(branch_name.to_string());
+ }
+ }
+ }
+ Ok(Err(err)) => {
+ Err::<(), _>(err).log_err();
+ }
+ Err(_) => {}
+ }
}
- Err(_) => {}
- }
- }
- let existing_branch_refs: Vec<&str> =
- existing_branches.iter().map(|s| s.as_str()).collect();
- let mut rng = rand::rng();
- let branch_name =
- match crate::branch_names::generate_branch_name(&existing_branch_refs, &mut rng) {
- Some(name) => name,
- None => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- "Failed to generate a unique branch name".into(),
- window,
+ let (branch_name, use_existing_branch, start_point) =
+ match Self::resolve_worktree_branch_target(
+ &branch_target,
+ &existing_branches,
+ &occupied_branches,
+ ) {
+ Ok(target) => target,
+ Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ err.to_string().into(),
+ window,
+ cx,
+ );
+ })?;
+ return anyhow::Ok(());
+ }
+ };
+
+ let (creation_infos, path_remapping) =
+ match this.update_in(cx, |_this, _window, cx| {
+ Self::start_worktree_creations(
+ &git_repos,
+ worktree_name,
+ &branch_name,
+ use_existing_branch,
+ start_point,
+ &worktree_directory_setting,
cx,
- );
- })?;
- return anyhow::Ok(());
- }
- };
+ )
+ }) {
+ Ok(Ok(result)) => result,
+ Ok(Err(err)) | Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ format!("Failed to validate worktree directory: {err}")
+ .into(),
+ window,
+ cx,
+ );
+ })
+ .log_err();
+ return anyhow::Ok(());
+ }
+ };
- let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| {
- Self::start_worktree_creations(
- &git_repos,
- &branch_name,
- &worktree_directory_setting,
- cx,
- )
- }) {
- Ok(Ok(result)) => result,
- Ok(Err(err)) | Err(err) => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- format!("Failed to validate worktree directory: {err}").into(),
- window,
- cx,
- );
- })
- .log_err();
- return anyhow::Ok(());
- }
- };
+ let created_paths =
+ match Self::await_and_rollback_on_failure(creation_infos, cx).await {
+ Ok(paths) => paths,
+ Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ format!("{err}").into(),
+ window,
+ cx,
+ );
+ })?;
+ return anyhow::Ok(());
+ }
+ };
- let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
- {
- Ok(paths) => paths,
- Err(err) => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(format!("{err}").into(), window, cx);
- })?;
- return anyhow::Ok(());
+ let mut all_paths = created_paths;
+ let has_non_git = !non_git_paths.is_empty();
+ all_paths.extend(non_git_paths.iter().cloned());
+ (all_paths, path_remapping, has_non_git)
+ }
+ WorktreeCreationArgs::Linked { worktree_path } => {
+ let mut all_paths = vec![worktree_path];
+ let has_non_git = !non_git_paths.is_empty();
+ all_paths.extend(non_git_paths.iter().cloned());
+ (all_paths, Vec::new(), has_non_git)
}
};
- let mut all_paths = created_paths;
- let has_non_git = !non_git_paths.is_empty();
- all_paths.extend(non_git_paths.iter().cloned());
-
let app_state = match workspace.upgrade() {
Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
None => {
@@ -2562,7 +2759,7 @@ impl AgentPanel {
};
let this_for_error = this.clone();
- if let Err(err) = Self::setup_new_workspace(
+ if let Err(err) = Self::open_worktree_workspace_and_start_thread(
this,
all_paths,
app_state,
@@ -2595,7 +2792,7 @@ impl AgentPanel {
}));
}
- async fn setup_new_workspace(
+ async fn open_worktree_workspace_and_start_thread(
this: WeakEntity,
all_paths: Vec,
app_state: Arc,
@@ -2989,17 +3186,11 @@ impl AgentPanel {
fn render_panel_options_menu(
&self,
- window: &mut Window,
+ _window: &mut Window,
cx: &mut Context,
) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
- let full_screen_label = if self.is_zoomed(window, cx) {
- "Disable Full Screen"
- } else {
- "Enable Full Screen"
- };
-
let conversation_view = match &self.active_view {
ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()),
_ => None,
@@ -3075,8 +3266,7 @@ impl AgentPanel {
.action("Profiles", Box::new(ManageProfiles::default()))
.action("Settings", Box::new(OpenSettings))
.separator()
- .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar))
- .action(full_screen_label, Box::new(ToggleZoom));
+ .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar));
if has_auth_methods {
menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent))
@@ -3149,25 +3339,15 @@ impl AgentPanel {
}
fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement {
- use settings::{NewThreadLocation, Settings};
-
let focus_handle = self.focus_handle(cx);
- let has_git_repo = self.project_has_git_repository(cx);
- let is_via_collab = self.project.read(cx).is_via_collab();
- let fs = self.fs.clone();
let is_creating = matches!(
self.worktree_creation_status,
Some(WorktreeCreationStatus::Creating)
);
- let current_target = self.start_thread_in;
let trigger_label = self.start_thread_in.label();
- let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
- let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
- let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
-
let icon = if self.start_thread_in_menu_handle.is_deployed() {
IconName::ChevronUp
} else {
@@ -3178,13 +3358,9 @@ impl AgentPanel {
.end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
.disabled(is_creating);
- let dock_position = AgentSettings::get_global(cx).dock;
- let documentation_side = match dock_position {
- settings::DockPosition::Left => DocumentationSide::Right,
- settings::DockPosition::Bottom | settings::DockPosition::Right => {
- DocumentationSide::Left
- }
- };
+ let project = self.project.clone();
+ let current_target = self.start_thread_in.clone();
+ let fs = self.fs.clone();
PopoverMenu::new("thread-target-selector")
.trigger_with_tooltip(trigger_button, {
@@ -3198,89 +3374,66 @@ impl AgentPanel {
}
})
.menu(move |window, cx| {
- let is_local_selected = current_target == StartThreadIn::LocalProject;
- let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree;
let fs = fs.clone();
+ Some(cx.new(|cx| {
+ ThreadWorktreePicker::new(project.clone(), ¤t_target, fs, window, cx)
+ }))
+ })
+ .with_handle(self.start_thread_in_menu_handle.clone())
+ .anchor(Corner::TopLeft)
+ .offset(gpui::Point {
+ x: px(1.0),
+ y: px(1.0),
+ })
+ }
- Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
- let new_worktree_disabled = !has_git_repo || is_via_collab;
+ fn render_new_worktree_branch_selector(&self, cx: &mut Context) -> impl IntoElement {
+ let is_creating = matches!(
+ self.worktree_creation_status,
+ Some(WorktreeCreationStatus::Creating)
+ );
+ let default_branch_label = if self.project.read(cx).repositories(cx).len() > 1 {
+ SharedString::from("From: current branches")
+ } else {
+ self.project
+ .read(cx)
+ .active_repository(cx)
+ .and_then(|repo| {
+ repo.read(cx)
+ .branch
+ .as_ref()
+ .map(|branch| SharedString::from(format!("From: {}", branch.name())))
+ })
+ .unwrap_or_else(|| SharedString::from("From: HEAD"))
+ };
+ let trigger_label = self
+ .start_thread_in
+ .worktree_branch_label(default_branch_label)
+ .unwrap_or_else(|| SharedString::from("From: HEAD"));
+ let icon = if self.thread_branch_menu_handle.is_deployed() {
+ IconName::ChevronUp
+ } else {
+ IconName::ChevronDown
+ };
+ let trigger_button = Button::new("thread-branch-trigger", trigger_label)
+ .start_icon(
+ Icon::new(IconName::GitBranch)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
+ .disabled(is_creating);
+ let project = self.project.clone();
+ let current_target = self.start_thread_in.clone();
- menu.header("Start Thread In…")
- .item(
- ContextMenuEntry::new("Current Worktree")
- .toggleable(IconPosition::End, is_local_selected)
- .documentation_aside(documentation_side, move |_| {
- HoldForDefault::new(is_local_default)
- .more_content(false)
- .into_any_element()
- })
- .handler({
- let fs = fs.clone();
- move |window, cx| {
- if window.modifiers().secondary() {
- update_settings_file(fs.clone(), cx, |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_new_thread_location(
- NewThreadLocation::LocalProject,
- );
- });
- }
- window.dispatch_action(
- Box::new(StartThreadIn::LocalProject),
- cx,
- );
- }
- }),
- )
- .item({
- let entry = ContextMenuEntry::new("New Git Worktree")
- .toggleable(IconPosition::End, is_new_worktree_selected)
- .disabled(new_worktree_disabled)
- .handler({
- let fs = fs.clone();
- move |window, cx| {
- if window.modifiers().secondary() {
- update_settings_file(fs.clone(), cx, |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_new_thread_location(
- NewThreadLocation::NewWorktree,
- );
- });
- }
- window.dispatch_action(
- Box::new(StartThreadIn::NewWorktree),
- cx,
- );
- }
- });
-
- if new_worktree_disabled {
- entry.documentation_aside(documentation_side, move |_| {
- let reason = if !has_git_repo {
- "No git repository found in this project."
- } else {
- "Not available for remote/collab projects yet."
- };
- Label::new(reason)
- .color(Color::Muted)
- .size(LabelSize::Small)
- .into_any_element()
- })
- } else {
- entry.documentation_aside(documentation_side, move |_| {
- HoldForDefault::new(is_new_worktree_default)
- .more_content(false)
- .into_any_element()
- })
- }
- })
+ PopoverMenu::new("thread-branch-selector")
+ .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
+ .menu(move |window, cx| {
+ Some(cx.new(|cx| {
+ ThreadBranchPicker::new(project.clone(), ¤t_target, window, cx)
}))
})
- .with_handle(self.start_thread_in_menu_handle.clone())
+ .with_handle(self.thread_branch_menu_handle.clone())
.anchor(Corner::TopLeft)
.offset(gpui::Point {
x: px(1.0),
@@ -3549,21 +3702,37 @@ impl AgentPanel {
);
let is_full_screen = self.is_zoomed(window, cx);
+ let full_screen_button = if is_full_screen {
+ IconButton::new("disable-full-screen", IconName::Minimize)
+ .icon_size(IconSize::Small)
+ .tooltip(move |_, cx| Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.toggle_zoom(&ToggleZoom, window, cx);
+ }))
+ } else {
+ IconButton::new("enable-full-screen", IconName::Maximize)
+ .icon_size(IconSize::Small)
+ .tooltip(move |_, cx| Tooltip::for_action("Enable Full Screen", &ToggleZoom, cx))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.toggle_zoom(&ToggleZoom, window, cx);
+ }))
+ };
let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config;
+ let max_content_width = AgentSettings::get_global(cx).max_content_width;
+
let base_container = h_flex()
- .id("agent-panel-toolbar")
- .h(Tab::container_height(cx))
- .max_w_full()
+ .size_full()
+ // TODO: This is only until we remove Agent settings from the panel.
+ .when(!is_in_history_or_config, |this| {
+ this.max_w(max_content_width).mx_auto()
+ })
.flex_none()
.justify_between()
- .gap_2()
- .bg(cx.theme().colors().tab_bar_background)
- .border_b_1()
- .border_color(cx.theme().colors().border);
+ .gap_2();
- if use_v2_empty_toolbar {
+ let toolbar_content = if use_v2_empty_toolbar {
let (chevron_icon, icon_color, label_color) =
if self.new_thread_menu_handle.is_deployed() {
(IconName::ChevronUp, Color::Accent, Color::Accent)
@@ -3621,6 +3790,14 @@ impl AgentPanel {
.when(
has_visible_worktrees && self.project_has_git_repository(cx),
|this| this.child(self.render_start_thread_in_selector(cx)),
+ )
+ .when(
+ has_v2_flag
+ && matches!(
+ self.start_thread_in,
+ StartThreadIn::NewWorktree { .. }
+ ),
+ |this| this.child(self.render_new_worktree_branch_selector(cx)),
),
)
.child(
@@ -3637,20 +3814,7 @@ impl AgentPanel {
cx,
))
})
- .when(is_full_screen, |this| {
- this.child(
- IconButton::new("disable-full-screen", IconName::Minimize)
- .icon_size(IconSize::Small)
- .tooltip(move |_, cx| {
- Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx)
- })
- .on_click({
- cx.listener(move |_, _, window, cx| {
- window.dispatch_action(ToggleZoom.boxed_clone(), cx);
- })
- }),
- )
- })
+ .child(full_screen_button)
.child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
@@ -3703,24 +3867,21 @@ impl AgentPanel {
cx,
))
})
- .when(is_full_screen, |this| {
- this.child(
- IconButton::new("disable-full-screen", IconName::Minimize)
- .icon_size(IconSize::Small)
- .tooltip(move |_, cx| {
- Tooltip::for_action("Disable Full Screen", &ToggleZoom, cx)
- })
- .on_click({
- cx.listener(move |_, _, window, cx| {
- window.dispatch_action(ToggleZoom.boxed_clone(), cx);
- })
- }),
- )
- })
+ .child(full_screen_button)
.child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
- }
+ };
+
+ h_flex()
+ .id("agent-panel-toolbar")
+ .h(Tab::container_height(cx))
+ .flex_shrink_0()
+ .max_w_full()
+ .bg(cx.theme().colors().tab_bar_background)
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(toolbar_content)
}
fn render_worktree_creation_status(&self, cx: &mut Context) -> Option {
@@ -5265,13 +5426,23 @@ mod tests {
// Change thread target to NewWorktree.
panel.update_in(cx, |panel, window, cx| {
- panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
+ panel.set_start_thread_in(
+ &StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ window,
+ cx,
+ );
});
panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
- StartThreadIn::NewWorktree,
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
"thread target should be NewWorktree after set_thread_target"
);
});
@@ -5289,7 +5460,10 @@ mod tests {
loaded_panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
- StartThreadIn::NewWorktree,
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
"thread target should survive serialization round-trip"
);
});
@@ -5420,6 +5594,53 @@ mod tests {
);
}
+ #[test]
+ fn test_resolve_worktree_branch_target() {
+ let existing_branches = HashSet::from_iter([
+ "main".to_string(),
+ "feature".to_string(),
+ "origin/main".to_string(),
+ ]);
+
+ let resolved = AgentPanel::resolve_worktree_branch_target(
+ &NewWorktreeBranchTarget::CreateBranch {
+ name: "new-branch".to_string(),
+ from_ref: Some("main".to_string()),
+ },
+ &existing_branches,
+ &HashSet::from_iter(["main".to_string()]),
+ )
+ .unwrap();
+ assert_eq!(
+ resolved,
+ ("new-branch".to_string(), false, Some("main".to_string()))
+ );
+
+ let resolved = AgentPanel::resolve_worktree_branch_target(
+ &NewWorktreeBranchTarget::ExistingBranch {
+ name: "feature".to_string(),
+ },
+ &existing_branches,
+ &HashSet::default(),
+ )
+ .unwrap();
+ assert_eq!(resolved, ("feature".to_string(), true, None));
+
+ let resolved = AgentPanel::resolve_worktree_branch_target(
+ &NewWorktreeBranchTarget::ExistingBranch {
+ name: "main".to_string(),
+ },
+ &existing_branches,
+ &HashSet::from_iter(["main".to_string()]),
+ )
+ .unwrap();
+ assert_eq!(resolved.1, false);
+ assert_eq!(resolved.2, Some("main".to_string()));
+ assert_ne!(resolved.0, "main");
+ assert!(existing_branches.contains("main"));
+ assert!(!existing_branches.contains(&resolved.0));
+ }
+
#[gpui::test]
async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
init_test(cx);
@@ -5513,7 +5734,14 @@ mod tests {
panel.selected_agent = Agent::Custom {
id: CODEX_ID.into(),
};
- panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
+ panel.set_start_thread_in(
+ &StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ window,
+ cx,
+ );
});
// Verify the panel has the Codex agent selected.
@@ -5532,7 +5760,15 @@ mod tests {
"Hello from test",
))];
panel.update_in(cx, |panel, window, cx| {
- panel.handle_worktree_creation_requested(content, window, cx);
+ panel.handle_worktree_requested(
+ content,
+ WorktreeCreationArgs::New {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ window,
+ cx,
+ );
});
// Let the async worktree creation + workspace setup complete.
diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs
index 78b4e3a5a3965c72b96d4ec201139b1d8e510fb2..e19afdecc390268cefbd7be4e5d0759aa2a29c19 100644
--- a/crates/agent_ui/src/agent_registry_ui.rs
+++ b/crates/agent_ui/src/agent_registry_ui.rs
@@ -382,7 +382,7 @@ impl AgentRegistryPage {
self.install_button(agent, install_status, supports_current_platform, cx);
let repository_button = agent.repository().map(|repository| {
- let repository_for_tooltip: SharedString = repository.to_string().into();
+ let repository_for_tooltip = repository.clone();
let repository_for_click = repository.to_string();
IconButton::new(
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 5cff5bfc38d4512d659d919c6e7c4ff02fcc0caf..429bc184f5d889990599c196910ae8d0feb28da1 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -28,13 +28,16 @@ mod terminal_codegen;
mod terminal_inline_assistant;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
+mod thread_branch_picker;
mod thread_history;
mod thread_history_view;
mod thread_import;
pub mod thread_metadata_store;
+mod thread_worktree_picker;
pub mod threads_archive_view;
mod ui;
+use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
@@ -314,16 +317,42 @@ impl Agent {
}
}
+/// Describes which branch to use when creating a new git worktree.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case", tag = "kind")]
+pub enum NewWorktreeBranchTarget {
+ /// Create a new randomly named branch from the current HEAD.
+ /// Will match worktree name if the newly created worktree was also randomly named.
+ #[default]
+ CurrentBranch,
+ /// Check out an existing branch, or create a new branch from it if it's
+ /// already occupied by another worktree.
+ ExistingBranch { name: String },
+ /// Create a new branch with an explicit name, optionally from a specific ref.
+ CreateBranch {
+ name: String,
+ #[serde(default)]
+ from_ref: Option,
+ },
+}
+
/// Sets where new threads will run.
-#[derive(
- Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action,
-)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum StartThreadIn {
#[default]
LocalProject,
- NewWorktree,
+ NewWorktree {
+ /// When this is None, Zed will randomly generate a worktree name
+ /// otherwise, the provided name will be used.
+ #[serde(default)]
+ worktree_name: Option,
+ #[serde(default)]
+ branch_target: NewWorktreeBranchTarget,
+ },
+ /// A linked worktree that already exists on disk.
+ LinkedWorktree { path: PathBuf, display_name: String },
}
/// Content to initialize new external agent with.
@@ -495,7 +524,6 @@ pub fn init(
defaults.collaboration_panel.get_or_insert_default().dock =
Some(DockPosition::Right);
defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right);
- defaults.notification_panel.get_or_insert_default().button = Some(false);
} else {
defaults.agent.get_or_insert_default().dock = Some(DockPosition::Right);
defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Left);
@@ -503,7 +531,6 @@ pub fn init(
defaults.collaboration_panel.get_or_insert_default().dock =
Some(DockPosition::Left);
defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Left);
- defaults.notification_panel.get_or_insert_default().button = Some(true);
}
});
});
@@ -713,6 +740,7 @@ mod tests {
flexible: true,
default_width: px(300.),
default_height: px(600.),
+ max_content_width: px(850.),
default_model: None,
inline_assistant_model: None,
inline_assistant_use_streaming_tools: false,
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index 685621eb3c93632f1e7410bbbad22b623d5e18c7..27ebadade8047db5f2b4de63c5c3731708d9af59 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -869,7 +869,10 @@ impl ThreadView {
.upgrade()
.and_then(|workspace| workspace.read(cx).panel::(cx))
.is_some_and(|panel| {
- panel.read(cx).start_thread_in() == &StartThreadIn::NewWorktree
+ !matches!(
+ panel.read(cx).start_thread_in(),
+ StartThreadIn::LocalProject
+ )
});
if intercept_first_send {
@@ -3011,14 +3014,12 @@ impl ThreadView {
let is_done = thread.read(cx).status() == ThreadStatus::Idle;
let is_canceled_or_failed = self.is_subagent_canceled_or_failed(cx);
+ let max_content_width = AgentSettings::get_global(cx).max_content_width;
+
Some(
h_flex()
- .h(Tab::container_height(cx))
- .pl_2()
- .pr_1p5()
.w_full()
- .justify_between()
- .gap_1()
+ .h(Tab::container_height(cx))
.border_b_1()
.when(is_done && is_canceled_or_failed, |this| {
this.border_dashed()
@@ -3027,50 +3028,61 @@ impl ThreadView {
.bg(cx.theme().colors().editor_background.opacity(0.2))
.child(
h_flex()
- .flex_1()
- .gap_2()
+ .size_full()
+ .max_w(max_content_width)
+ .mx_auto()
+ .pl_2()
+ .pr_1()
+ .flex_shrink_0()
+ .justify_between()
+ .gap_1()
.child(
- Icon::new(IconName::ForwardArrowUp)
- .size(IconSize::Small)
- .color(Color::Muted),
+ h_flex()
+ .flex_1()
+ .gap_2()
+ .child(
+ Icon::new(IconName::ForwardArrowUp)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(self.title_editor.clone())
+ .when(is_done && is_canceled_or_failed, |this| {
+ this.child(Icon::new(IconName::Close).color(Color::Error))
+ })
+ .when(is_done && !is_canceled_or_failed, |this| {
+ this.child(Icon::new(IconName::Check).color(Color::Success))
+ }),
)
- .child(self.title_editor.clone())
- .when(is_done && is_canceled_or_failed, |this| {
- this.child(Icon::new(IconName::Close).color(Color::Error))
- })
- .when(is_done && !is_canceled_or_failed, |this| {
- this.child(Icon::new(IconName::Check).color(Color::Success))
- }),
- )
- .child(
- h_flex()
- .gap_0p5()
- .when(!is_done, |this| {
- this.child(
- IconButton::new("stop_subagent", IconName::Stop)
- .icon_size(IconSize::Small)
- .icon_color(Color::Error)
- .tooltip(Tooltip::text("Stop Subagent"))
- .on_click(move |_, _, cx| {
- thread.update(cx, |thread, cx| {
- thread.cancel(cx).detach();
- });
- }),
- )
- })
.child(
- IconButton::new("minimize_subagent", IconName::Minimize)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Minimize Subagent"))
- .on_click(move |_, window, cx| {
- let _ = server_view.update(cx, |server_view, cx| {
- server_view.navigate_to_session(
- parent_session_id.clone(),
- window,
- cx,
- );
- });
- }),
+ h_flex()
+ .gap_0p5()
+ .when(!is_done, |this| {
+ this.child(
+ IconButton::new("stop_subagent", IconName::Stop)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Error)
+ .tooltip(Tooltip::text("Stop Subagent"))
+ .on_click(move |_, _, cx| {
+ thread.update(cx, |thread, cx| {
+ thread.cancel(cx).detach();
+ });
+ }),
+ )
+ })
+ .child(
+ IconButton::new("minimize_subagent", IconName::Dash)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Minimize Subagent"))
+ .on_click(move |_, window, cx| {
+ let _ = server_view.update(cx, |server_view, cx| {
+ server_view.navigate_to_session(
+ parent_session_id.clone(),
+ window,
+ cx,
+ );
+ });
+ }),
+ ),
),
),
)
@@ -3096,6 +3108,8 @@ impl ThreadView {
(IconName::Maximize, "Expand Message Editor")
};
+ let max_content_width = AgentSettings::get_global(cx).max_content_width;
+
v_flex()
.on_action(cx.listener(Self::expand_message_editor))
.p_2()
@@ -3110,73 +3124,80 @@ impl ThreadView {
})
.child(
v_flex()
- .relative()
- .size_full()
- .when(v2_empty_state, |this| this.flex_1())
- .pt_1()
- .pr_2p5()
- .child(self.message_editor.clone())
- .when(!v2_empty_state, |this| {
- this.child(
- h_flex()
- .absolute()
- .top_0()
- .right_0()
- .opacity(0.5)
- .hover(|this| this.opacity(1.0))
- .child(
- IconButton::new("toggle-height", expand_icon)
- .icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .tooltip({
- move |_window, cx| {
- Tooltip::for_action_in(
- expand_tooltip,
- &ExpandMessageEditor,
- &focus_handle,
- cx,
- )
- }
- })
- .on_click(cx.listener(|this, _, window, cx| {
- this.expand_message_editor(
- &ExpandMessageEditor,
- window,
- cx,
- );
- })),
- ),
- )
- }),
- )
- .child(
- h_flex()
- .flex_none()
- .flex_wrap()
- .justify_between()
+ .flex_1()
+ .w_full()
+ .max_w(max_content_width)
+ .mx_auto()
.child(
- h_flex()
- .gap_0p5()
- .child(self.render_add_context_button(cx))
- .child(self.render_follow_toggle(cx))
- .children(self.render_fast_mode_control(cx))
- .children(self.render_thinking_control(cx)),
+ v_flex()
+ .relative()
+ .size_full()
+ .when(v2_empty_state, |this| this.flex_1())
+ .pt_1()
+ .pr_2p5()
+ .child(self.message_editor.clone())
+ .when(!v2_empty_state, |this| {
+ this.child(
+ h_flex()
+ .absolute()
+ .top_0()
+ .right_0()
+ .opacity(0.5)
+ .hover(|this| this.opacity(1.0))
+ .child(
+ IconButton::new("toggle-height", expand_icon)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip({
+ move |_window, cx| {
+ Tooltip::for_action_in(
+ expand_tooltip,
+ &ExpandMessageEditor,
+ &focus_handle,
+ cx,
+ )
+ }
+ })
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.expand_message_editor(
+ &ExpandMessageEditor,
+ window,
+ cx,
+ );
+ })),
+ ),
+ )
+ }),
)
.child(
h_flex()
- .gap_1()
- .children(self.render_token_usage(cx))
- .children(self.profile_selector.clone())
- .map(|this| {
- // Either config_options_view OR (mode_selector + model_selector)
- match self.config_options_view.clone() {
- Some(config_view) => this.child(config_view),
- None => this
- .children(self.mode_selector.clone())
- .children(self.model_selector.clone()),
- }
- })
- .child(self.render_send_button(cx)),
+ .flex_none()
+ .flex_wrap()
+ .justify_between()
+ .child(
+ h_flex()
+ .gap_0p5()
+ .child(self.render_add_context_button(cx))
+ .child(self.render_follow_toggle(cx))
+ .children(self.render_fast_mode_control(cx))
+ .children(self.render_thinking_control(cx)),
+ )
+ .child(
+ h_flex()
+ .gap_1()
+ .children(self.render_token_usage(cx))
+ .children(self.profile_selector.clone())
+ .map(|this| {
+ // Either config_options_view OR (mode_selector + model_selector)
+ match self.config_options_view.clone() {
+ Some(config_view) => this.child(config_view),
+ None => this
+ .children(self.mode_selector.clone())
+ .children(self.model_selector.clone()),
+ }
+ })
+ .child(self.render_send_button(cx)),
+ ),
),
)
.into_any()
@@ -8556,8 +8577,12 @@ impl Render for ThreadView {
let has_messages = self.list_state.item_count() > 0;
let v2_empty_state = cx.has_flag::() && !has_messages;
+ let max_content_width = AgentSettings::get_global(cx).max_content_width;
+
let conversation = v_flex()
- .when(!v2_empty_state, |this| this.flex_1())
+ .mx_auto()
+ .max_w(max_content_width)
+ .when(!v2_empty_state, |this| this.flex_1().size_full())
.map(|this| {
let this = this.when(self.resumed_without_history, |this| {
this.child(Self::render_resume_notice(cx))
diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs
index 1b2ec0ad2fd460b4eec5a8b757bdd3058d4a3704..880257e3f942bf71d1d51b1e661d911474aa786b 100644
--- a/crates/agent_ui/src/mention_set.rs
+++ b/crates/agent_ui/src/mention_set.rs
@@ -18,7 +18,7 @@ use gpui::{
use http_client::{AsyncBody, HttpClientWithUrl};
use itertools::Either;
use language::Buffer;
-use language_model::LanguageModelImage;
+use language_model::{LanguageModelImage, LanguageModelImageExt};
use multi_buffer::MultiBufferRow;
use postage::stream::Stream as _;
use project::{Project, ProjectItem, ProjectPath, Worktree};
diff --git a/crates/agent_ui/src/thread_branch_picker.rs b/crates/agent_ui/src/thread_branch_picker.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d69cbb4a60054ad83d767928c880f3a43caef4f1
--- /dev/null
+++ b/crates/agent_ui/src/thread_branch_picker.rs
@@ -0,0 +1,695 @@
+use std::collections::{HashMap, HashSet};
+
+use collections::HashSet as CollectionsHashSet;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use fuzzy::StringMatchCandidate;
+use git::repository::Branch as GitBranch;
+use gpui::{
+ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
+ ParentElement, Render, SharedString, Styled, Task, Window, rems,
+};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use project::Project;
+use ui::{
+ HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, ListItemSpacing, Tooltip,
+ prelude::*,
+};
+use util::ResultExt as _;
+
+use crate::{NewWorktreeBranchTarget, StartThreadIn};
+
+pub(crate) struct ThreadBranchPicker {
+ picker: Entity>,
+ focus_handle: FocusHandle,
+ _subscription: gpui::Subscription,
+}
+
+impl ThreadBranchPicker {
+ pub fn new(
+ project: Entity,
+ current_target: &StartThreadIn,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ let project_worktree_paths: HashSet = project
+ .read(cx)
+ .visible_worktrees(cx)
+ .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
+ .collect();
+
+ let has_multiple_repositories = project.read(cx).repositories(cx).len() > 1;
+ let current_branch_name = project
+ .read(cx)
+ .active_repository(cx)
+ .and_then(|repo| {
+ repo.read(cx)
+ .branch
+ .as_ref()
+ .map(|branch| branch.name().to_string())
+ })
+ .unwrap_or_else(|| "HEAD".to_string());
+
+ let repository = if has_multiple_repositories {
+ None
+ } else {
+ project.read(cx).active_repository(cx)
+ };
+ let branches_request = repository
+ .clone()
+ .map(|repo| repo.update(cx, |repo, _| repo.branches()));
+ let default_branch_request = repository
+ .clone()
+ .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false)));
+ let worktrees_request = repository.map(|repo| repo.update(cx, |repo, _| repo.worktrees()));
+
+ let (worktree_name, branch_target) = match current_target {
+ StartThreadIn::NewWorktree {
+ worktree_name,
+ branch_target,
+ } => (worktree_name.clone(), branch_target.clone()),
+ _ => (None, NewWorktreeBranchTarget::default()),
+ };
+
+ let delegate = ThreadBranchPickerDelegate {
+ matches: vec![ThreadBranchEntry::CurrentBranch],
+ all_branches: None,
+ occupied_branches: None,
+ selected_index: 0,
+ worktree_name,
+ branch_target,
+ project_worktree_paths,
+ current_branch_name,
+ default_branch_name: None,
+ has_multiple_repositories,
+ };
+
+ let picker = cx.new(|cx| {
+ Picker::list(delegate, window, cx)
+ .list_measure_all()
+ .modal(false)
+ .max_height(Some(rems(20.).into()))
+ });
+
+ let focus_handle = picker.focus_handle(cx);
+
+ if let (Some(branches_request), Some(default_branch_request), Some(worktrees_request)) =
+ (branches_request, default_branch_request, worktrees_request)
+ {
+ let picker_handle = picker.downgrade();
+ cx.spawn_in(window, async move |_this, cx| {
+ let branches = branches_request.await??;
+ let default_branch = default_branch_request.await.ok().and_then(Result::ok).flatten();
+ let worktrees = worktrees_request.await??;
+
+ let remote_upstreams: CollectionsHashSet<_> = branches
+ .iter()
+ .filter_map(|branch| {
+ branch
+ .upstream
+ .as_ref()
+ .filter(|upstream| upstream.is_remote())
+ .map(|upstream| upstream.ref_name.clone())
+ })
+ .collect();
+
+ let mut occupied_branches = HashMap::new();
+ for worktree in worktrees {
+ let Some(branch_name) = worktree.branch_name().map(ToOwned::to_owned) else {
+ continue;
+ };
+
+ let reason = if picker_handle
+ .read_with(cx, |picker, _| {
+ picker
+ .delegate
+ .project_worktree_paths
+ .contains(&worktree.path)
+ })
+ .unwrap_or(false)
+ {
+ format!(
+ "This branch is already checked out in the current project worktree at {}.",
+ worktree.path.display()
+ )
+ } else {
+ format!(
+ "This branch is already checked out in a linked worktree at {}.",
+ worktree.path.display()
+ )
+ };
+
+ occupied_branches.insert(branch_name, reason);
+ }
+
+ let mut all_branches: Vec<_> = branches
+ .into_iter()
+ .filter(|branch| !remote_upstreams.contains(&branch.ref_name))
+ .collect();
+ all_branches.sort_by_key(|branch| {
+ (
+ branch.is_remote(),
+ !branch.is_head,
+ branch
+ .most_recent_commit
+ .as_ref()
+ .map(|commit| 0 - commit.commit_timestamp),
+ )
+ });
+
+ picker_handle.update_in(cx, |picker, window, cx| {
+ picker.delegate.all_branches = Some(all_branches);
+ picker.delegate.occupied_branches = Some(occupied_branches);
+ picker.delegate.default_branch_name = default_branch.map(|branch| branch.to_string());
+ picker.refresh(window, cx);
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ let subscription = cx.subscribe(&picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ });
+
+ Self {
+ picker,
+ focus_handle,
+ _subscription: subscription,
+ }
+ }
+}
+
+impl Focusable for ThreadBranchPicker {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl EventEmitter for ThreadBranchPicker {}
+
+impl Render for ThreadBranchPicker {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ v_flex()
+ .w(rems(22.))
+ .elevation_3(cx)
+ .child(self.picker.clone())
+ .on_mouse_down_out(cx.listener(|_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }))
+ }
+}
+
+#[derive(Clone)]
+enum ThreadBranchEntry {
+ CurrentBranch,
+ DefaultBranch,
+ ExistingBranch {
+ branch: GitBranch,
+ positions: Vec,
+ occupied_reason: Option,
+ },
+ CreateNamed {
+ name: String,
+ },
+}
+
+pub(crate) struct ThreadBranchPickerDelegate {
+ matches: Vec,
+ all_branches: Option>,
+ occupied_branches: Option>,
+ selected_index: usize,
+ worktree_name: Option,
+ branch_target: NewWorktreeBranchTarget,
+ project_worktree_paths: HashSet,
+ current_branch_name: String,
+ default_branch_name: Option,
+ has_multiple_repositories: bool,
+}
+
+impl ThreadBranchPickerDelegate {
+ fn new_worktree_action(&self, branch_target: NewWorktreeBranchTarget) -> StartThreadIn {
+ StartThreadIn::NewWorktree {
+ worktree_name: self.worktree_name.clone(),
+ branch_target,
+ }
+ }
+
+ fn selected_entry_name(&self) -> Option<&str> {
+ match &self.branch_target {
+ NewWorktreeBranchTarget::CurrentBranch => None,
+ NewWorktreeBranchTarget::ExistingBranch { name } => Some(name),
+ NewWorktreeBranchTarget::CreateBranch {
+ from_ref: Some(from_ref),
+ ..
+ } => Some(from_ref),
+ NewWorktreeBranchTarget::CreateBranch { name, .. } => Some(name),
+ }
+ }
+
+ fn prefer_create_entry(&self) -> bool {
+ matches!(
+ &self.branch_target,
+ NewWorktreeBranchTarget::CreateBranch { from_ref: None, .. }
+ )
+ }
+
+ fn fixed_matches(&self) -> Vec {
+ let mut matches = vec![ThreadBranchEntry::CurrentBranch];
+ if !self.has_multiple_repositories
+ && self
+ .default_branch_name
+ .as_ref()
+ .is_some_and(|default_branch_name| default_branch_name != &self.current_branch_name)
+ {
+ matches.push(ThreadBranchEntry::DefaultBranch);
+ }
+ matches
+ }
+
+ fn current_branch_label(&self) -> SharedString {
+ if self.has_multiple_repositories {
+ SharedString::from("New branch from: current branches")
+ } else {
+ SharedString::from(format!("New branch from: {}", self.current_branch_name))
+ }
+ }
+
+ fn default_branch_label(&self) -> Option {
+ let default_branch_name = self
+ .default_branch_name
+ .as_ref()
+ .filter(|name| *name != &self.current_branch_name)?;
+ let is_occupied = self
+ .occupied_branches
+ .as_ref()
+ .is_some_and(|occupied| occupied.contains_key(default_branch_name));
+ let prefix = if is_occupied {
+ "New branch from"
+ } else {
+ "From"
+ };
+ Some(SharedString::from(format!(
+ "{prefix}: {default_branch_name}"
+ )))
+ }
+
+ fn branch_label_prefix(&self, branch_name: &str) -> &'static str {
+ let is_occupied = self
+ .occupied_branches
+ .as_ref()
+ .is_some_and(|occupied| occupied.contains_key(branch_name));
+ if is_occupied {
+ "New branch from: "
+ } else {
+ "From: "
+ }
+ }
+
+ fn sync_selected_index(&mut self) {
+ let selected_entry_name = self.selected_entry_name().map(ToOwned::to_owned);
+ let prefer_create = self.prefer_create_entry();
+
+ if prefer_create {
+ if let Some(ref selected_entry_name) = selected_entry_name {
+ if let Some(index) = self.matches.iter().position(|entry| {
+ matches!(
+ entry,
+ ThreadBranchEntry::CreateNamed { name } if name == selected_entry_name
+ )
+ }) {
+ self.selected_index = index;
+ return;
+ }
+ }
+ } else if let Some(ref selected_entry_name) = selected_entry_name {
+ if selected_entry_name == &self.current_branch_name {
+ if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadBranchEntry::CurrentBranch))
+ {
+ self.selected_index = index;
+ return;
+ }
+ }
+
+ if self
+ .default_branch_name
+ .as_ref()
+ .is_some_and(|default_branch_name| default_branch_name == selected_entry_name)
+ {
+ if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadBranchEntry::DefaultBranch))
+ {
+ self.selected_index = index;
+ return;
+ }
+ }
+
+ if let Some(index) = self.matches.iter().position(|entry| {
+ matches!(
+ entry,
+ ThreadBranchEntry::ExistingBranch { branch, .. }
+ if branch.name() == selected_entry_name.as_str()
+ )
+ }) {
+ self.selected_index = index;
+ return;
+ }
+ }
+
+ if self.matches.len() > 1
+ && self
+ .matches
+ .iter()
+ .skip(1)
+ .all(|entry| matches!(entry, ThreadBranchEntry::CreateNamed { .. }))
+ {
+ self.selected_index = 1;
+ return;
+ }
+
+ self.selected_index = 0;
+ }
+}
+
+impl PickerDelegate for ThreadBranchPickerDelegate {
+ type ListItem = ListItem;
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc {
+ "Search branches…".into()
+ }
+
+ fn editor_position(&self) -> PickerEditorPosition {
+ PickerEditorPosition::Start
+ }
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context>,
+ ) -> Task<()> {
+ if self.has_multiple_repositories {
+ let mut matches = self.fixed_matches();
+
+ if query.is_empty() {
+ if let Some(name) = self.selected_entry_name().map(ToOwned::to_owned) {
+ if self.prefer_create_entry() {
+ matches.push(ThreadBranchEntry::CreateNamed { name });
+ }
+ }
+ } else {
+ matches.push(ThreadBranchEntry::CreateNamed {
+ name: query.replace(' ', "-"),
+ });
+ }
+
+ self.matches = matches;
+ self.sync_selected_index();
+ return Task::ready(());
+ }
+
+ let Some(all_branches) = self.all_branches.clone() else {
+ self.matches = self.fixed_matches();
+ self.selected_index = 0;
+ return Task::ready(());
+ };
+ let occupied_branches = self.occupied_branches.clone().unwrap_or_default();
+
+ if query.is_empty() {
+ let mut matches = self.fixed_matches();
+ for branch in all_branches.into_iter().filter(|branch| {
+ branch.name() != self.current_branch_name
+ && self
+ .default_branch_name
+ .as_ref()
+ .is_none_or(|default_branch_name| branch.name() != default_branch_name)
+ }) {
+ matches.push(ThreadBranchEntry::ExistingBranch {
+ occupied_reason: occupied_branches.get(branch.name()).cloned(),
+ branch,
+ positions: Vec::new(),
+ });
+ }
+
+ if let Some(selected_entry_name) = self.selected_entry_name().map(ToOwned::to_owned) {
+ let has_existing = matches.iter().any(|entry| {
+ matches!(
+ entry,
+ ThreadBranchEntry::ExistingBranch { branch, .. }
+ if branch.name() == selected_entry_name
+ )
+ });
+ if self.prefer_create_entry() && !has_existing {
+ matches.push(ThreadBranchEntry::CreateNamed {
+ name: selected_entry_name,
+ });
+ }
+ }
+
+ self.matches = matches;
+ self.sync_selected_index();
+ return Task::ready(());
+ }
+
+ let candidates: Vec<_> = all_branches
+ .iter()
+ .enumerate()
+ .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
+ .collect();
+ let executor = cx.background_executor().clone();
+ let query_clone = query.clone();
+ let normalized_query = query.replace(' ', "-");
+
+ let task = cx.background_executor().spawn(async move {
+ fuzzy::match_strings(
+ &candidates,
+ &query_clone,
+ true,
+ true,
+ 10000,
+ &Default::default(),
+ executor,
+ )
+ .await
+ });
+
+ let all_branches_clone = all_branches;
+ cx.spawn_in(window, async move |picker, cx| {
+ let fuzzy_matches = task.await;
+
+ picker
+ .update_in(cx, |picker, _window, cx| {
+ let mut matches = picker.delegate.fixed_matches();
+
+ for candidate in &fuzzy_matches {
+ let branch = all_branches_clone[candidate.candidate_id].clone();
+ if branch.name() == picker.delegate.current_branch_name
+ || picker.delegate.default_branch_name.as_ref().is_some_and(
+ |default_branch_name| branch.name() == default_branch_name,
+ )
+ {
+ continue;
+ }
+ let occupied_reason = occupied_branches.get(branch.name()).cloned();
+ matches.push(ThreadBranchEntry::ExistingBranch {
+ branch,
+ positions: candidate.positions.clone(),
+ occupied_reason,
+ });
+ }
+
+ if fuzzy_matches.is_empty() {
+ matches.push(ThreadBranchEntry::CreateNamed {
+ name: normalized_query.clone(),
+ });
+ }
+
+ picker.delegate.matches = matches;
+ if let Some(index) =
+ picker.delegate.matches.iter().position(|entry| {
+ matches!(entry, ThreadBranchEntry::ExistingBranch { .. })
+ })
+ {
+ picker.delegate.selected_index = index;
+ } else if !fuzzy_matches.is_empty() {
+ picker.delegate.selected_index = 0;
+ } else if let Some(index) =
+ picker.delegate.matches.iter().position(|entry| {
+ matches!(entry, ThreadBranchEntry::CreateNamed { .. })
+ })
+ {
+ picker.delegate.selected_index = index;
+ } else {
+ picker.delegate.sync_selected_index();
+ }
+ cx.notify();
+ })
+ .log_err();
+ })
+ }
+
+ fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) {
+ let Some(entry) = self.matches.get(self.selected_index) else {
+ return;
+ };
+
+ match entry {
+ ThreadBranchEntry::CurrentBranch => {
+ window.dispatch_action(
+ Box::new(self.new_worktree_action(NewWorktreeBranchTarget::CurrentBranch)),
+ cx,
+ );
+ }
+ ThreadBranchEntry::DefaultBranch => {
+ let Some(default_branch_name) = self.default_branch_name.clone() else {
+ return;
+ };
+ window.dispatch_action(
+ Box::new(
+ self.new_worktree_action(NewWorktreeBranchTarget::ExistingBranch {
+ name: default_branch_name,
+ }),
+ ),
+ cx,
+ );
+ }
+ ThreadBranchEntry::ExistingBranch { branch, .. } => {
+ let branch_target = if branch.is_remote() {
+ let branch_name = branch
+ .ref_name
+ .as_ref()
+ .strip_prefix("refs/remotes/")
+ .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
+ .unwrap_or(branch.name())
+ .to_string();
+ NewWorktreeBranchTarget::CreateBranch {
+ name: branch_name,
+ from_ref: Some(branch.name().to_string()),
+ }
+ } else {
+ NewWorktreeBranchTarget::ExistingBranch {
+ name: branch.name().to_string(),
+ }
+ };
+ window.dispatch_action(Box::new(self.new_worktree_action(branch_target)), cx);
+ }
+ ThreadBranchEntry::CreateNamed { name } => {
+ window.dispatch_action(
+ Box::new(
+ self.new_worktree_action(NewWorktreeBranchTarget::CreateBranch {
+ name: name.clone(),
+ from_ref: None,
+ }),
+ ),
+ cx,
+ );
+ }
+ }
+
+ cx.emit(DismissEvent);
+ }
+
+ fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {}
+
+ fn separators_after_indices(&self) -> Vec {
+ let fixed_count = self.fixed_matches().len();
+ if self.matches.len() > fixed_count {
+ vec![fixed_count - 1]
+ } else {
+ Vec::new()
+ }
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) -> Option {
+ let entry = self.matches.get(ix)?;
+
+ match entry {
+ ThreadBranchEntry::CurrentBranch => Some(
+ ListItem::new("current-branch")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted))
+ .child(Label::new(self.current_branch_label())),
+ ),
+ ThreadBranchEntry::DefaultBranch => Some(
+ ListItem::new("default-branch")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted))
+ .child(Label::new(self.default_branch_label()?)),
+ ),
+ ThreadBranchEntry::ExistingBranch {
+ branch,
+ positions,
+ occupied_reason,
+ } => {
+ let prefix = self.branch_label_prefix(branch.name());
+ let branch_name = branch.name().to_string();
+ let full_label = format!("{prefix}{branch_name}");
+ let adjusted_positions: Vec =
+ positions.iter().map(|&p| p + prefix.len()).collect();
+
+ let item = ListItem::new(SharedString::from(format!("branch-{ix}")))
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted))
+ .child(HighlightedLabel::new(full_label, adjusted_positions).truncate());
+
+ Some(if let Some(reason) = occupied_reason.clone() {
+ item.tooltip(Tooltip::text(reason))
+ } else if branch.is_remote() {
+ item.tooltip(Tooltip::text(
+ "Create a new local branch from this remote branch",
+ ))
+ } else {
+ item
+ })
+ }
+ ThreadBranchEntry::CreateNamed { name } => Some(
+ ListItem::new("create-named-branch")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::Plus).color(Color::Accent))
+ .child(Label::new(format!("Create Branch: \"{name}\"…"))),
+ ),
+ }
+ }
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option {
+ None
+ }
+}
diff --git a/crates/agent_ui/src/thread_worktree_picker.rs b/crates/agent_ui/src/thread_worktree_picker.rs
new file mode 100644
index 0000000000000000000000000000000000000000..47a6a12d71822e13ab3523a3a6b0bb1ee57c7b4b
--- /dev/null
+++ b/crates/agent_ui/src/thread_worktree_picker.rs
@@ -0,0 +1,485 @@
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use agent_settings::AgentSettings;
+use fs::Fs;
+use fuzzy::StringMatchCandidate;
+use git::repository::Worktree as GitWorktree;
+use gpui::{
+ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
+ ParentElement, Render, SharedString, Styled, Task, Window, rems,
+};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use project::{Project, git_store::RepositoryId};
+use settings::{NewThreadLocation, Settings, update_settings_file};
+use ui::{
+ HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, ListItemSpacing, Tooltip,
+ prelude::*,
+};
+use util::ResultExt as _;
+
+use crate::ui::HoldForDefault;
+use crate::{NewWorktreeBranchTarget, StartThreadIn};
+
+pub(crate) struct ThreadWorktreePicker {
+ picker: Entity>,
+ focus_handle: FocusHandle,
+ _subscription: gpui::Subscription,
+}
+
+impl ThreadWorktreePicker {
+ pub fn new(
+ project: Entity,
+ current_target: &StartThreadIn,
+ fs: Arc,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ let project_worktree_paths: Vec = project
+ .read(cx)
+ .visible_worktrees(cx)
+ .map(|wt| wt.read(cx).abs_path().to_path_buf())
+ .collect();
+
+ let preserved_branch_target = match current_target {
+ StartThreadIn::NewWorktree { branch_target, .. } => branch_target.clone(),
+ _ => NewWorktreeBranchTarget::default(),
+ };
+
+ let delegate = ThreadWorktreePickerDelegate {
+ matches: vec![
+ ThreadWorktreeEntry::CurrentWorktree,
+ ThreadWorktreeEntry::NewWorktree,
+ ],
+ all_worktrees: project
+ .read(cx)
+ .repositories(cx)
+ .iter()
+ .map(|(repo_id, repo)| (*repo_id, repo.read(cx).linked_worktrees.clone()))
+ .collect(),
+ project_worktree_paths,
+ selected_index: match current_target {
+ StartThreadIn::LocalProject => 0,
+ StartThreadIn::NewWorktree { .. } => 1,
+ _ => 0,
+ },
+ project: project.clone(),
+ preserved_branch_target,
+ fs,
+ };
+
+ let picker = cx.new(|cx| {
+ Picker::list(delegate, window, cx)
+ .list_measure_all()
+ .modal(false)
+ .max_height(Some(rems(20.).into()))
+ });
+
+ let subscription = cx.subscribe(&picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ });
+
+ Self {
+ focus_handle: picker.focus_handle(cx),
+ picker,
+ _subscription: subscription,
+ }
+ }
+}
+
+impl Focusable for ThreadWorktreePicker {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl EventEmitter for ThreadWorktreePicker {}
+
+impl Render for ThreadWorktreePicker {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ v_flex()
+ .w(rems(20.))
+ .elevation_3(cx)
+ .child(self.picker.clone())
+ .on_mouse_down_out(cx.listener(|_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }))
+ }
+}
+
+#[derive(Clone)]
+enum ThreadWorktreeEntry {
+ CurrentWorktree,
+ NewWorktree,
+ LinkedWorktree {
+ worktree: GitWorktree,
+ positions: Vec,
+ },
+ CreateNamed {
+ name: String,
+ disabled_reason: Option,
+ },
+}
+
+pub(crate) struct ThreadWorktreePickerDelegate {
+ matches: Vec,
+ all_worktrees: Vec<(RepositoryId, Arc<[GitWorktree]>)>,
+ project_worktree_paths: Vec,
+ selected_index: usize,
+ preserved_branch_target: NewWorktreeBranchTarget,
+ project: Entity,
+ fs: Arc,
+}
+
+impl ThreadWorktreePickerDelegate {
+ fn new_worktree_action(&self, worktree_name: Option) -> StartThreadIn {
+ StartThreadIn::NewWorktree {
+ worktree_name,
+ branch_target: self.preserved_branch_target.clone(),
+ }
+ }
+
+ fn sync_selected_index(&mut self) {
+ if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { .. }))
+ {
+ self.selected_index = index;
+ } else if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. }))
+ {
+ self.selected_index = index;
+ } else {
+ self.selected_index = 0;
+ }
+ }
+}
+
+impl PickerDelegate for ThreadWorktreePickerDelegate {
+ type ListItem = ListItem;
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc {
+ "Search or create worktrees…".into()
+ }
+
+ fn editor_position(&self) -> PickerEditorPosition {
+ PickerEditorPosition::Start
+ }
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn separators_after_indices(&self) -> Vec {
+ if self.matches.len() > 2 {
+ vec![1]
+ } else {
+ Vec::new()
+ }
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context>,
+ ) -> Task<()> {
+ let has_multiple_repositories = self.all_worktrees.len() > 1;
+
+ let linked_worktrees: Vec<_> = if has_multiple_repositories {
+ Vec::new()
+ } else {
+ self.all_worktrees
+ .iter()
+ .flat_map(|(_, worktrees)| worktrees.iter())
+ .filter(|worktree| {
+ !self
+ .project_worktree_paths
+ .iter()
+ .any(|project_path| project_path == &worktree.path)
+ })
+ .cloned()
+ .collect()
+ };
+
+ let normalized_query = query.replace(' ', "-");
+ let has_named_worktree = self.all_worktrees.iter().any(|(_, worktrees)| {
+ worktrees
+ .iter()
+ .any(|worktree| worktree.display_name() == normalized_query)
+ });
+ let create_named_disabled_reason = if has_multiple_repositories {
+ Some("Cannot create a named worktree in a project with multiple repositories".into())
+ } else if has_named_worktree {
+ Some("A worktree with this name already exists".into())
+ } else {
+ None
+ };
+
+ let mut matches = vec![
+ ThreadWorktreeEntry::CurrentWorktree,
+ ThreadWorktreeEntry::NewWorktree,
+ ];
+
+ if query.is_empty() {
+ for worktree in &linked_worktrees {
+ matches.push(ThreadWorktreeEntry::LinkedWorktree {
+ worktree: worktree.clone(),
+ positions: Vec::new(),
+ });
+ }
+ } else if linked_worktrees.is_empty() {
+ matches.push(ThreadWorktreeEntry::CreateNamed {
+ name: normalized_query,
+ disabled_reason: create_named_disabled_reason,
+ });
+ } else {
+ let candidates: Vec<_> = linked_worktrees
+ .iter()
+ .enumerate()
+ .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.display_name()))
+ .collect();
+
+ let executor = cx.background_executor().clone();
+ let query_clone = query.clone();
+
+ let task = cx.background_executor().spawn(async move {
+ fuzzy::match_strings(
+ &candidates,
+ &query_clone,
+ true,
+ true,
+ 10000,
+ &Default::default(),
+ executor,
+ )
+ .await
+ });
+
+ let linked_worktrees_clone = linked_worktrees;
+ return cx.spawn_in(window, async move |picker, cx| {
+ let fuzzy_matches = task.await;
+
+ picker
+ .update_in(cx, |picker, _window, cx| {
+ let mut new_matches = vec![
+ ThreadWorktreeEntry::CurrentWorktree,
+ ThreadWorktreeEntry::NewWorktree,
+ ];
+
+ for candidate in &fuzzy_matches {
+ new_matches.push(ThreadWorktreeEntry::LinkedWorktree {
+ worktree: linked_worktrees_clone[candidate.candidate_id].clone(),
+ positions: candidate.positions.clone(),
+ });
+ }
+
+ let has_exact_match = linked_worktrees_clone
+ .iter()
+ .any(|worktree| worktree.display_name() == query);
+
+ if !has_exact_match {
+ new_matches.push(ThreadWorktreeEntry::CreateNamed {
+ name: normalized_query.clone(),
+ disabled_reason: create_named_disabled_reason.clone(),
+ });
+ }
+
+ picker.delegate.matches = new_matches;
+ picker.delegate.sync_selected_index();
+
+ cx.notify();
+ })
+ .log_err();
+ });
+ }
+
+ self.matches = matches;
+ self.sync_selected_index();
+
+ Task::ready(())
+ }
+
+ fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) {
+ let Some(entry) = self.matches.get(self.selected_index) else {
+ return;
+ };
+
+ match entry {
+ ThreadWorktreeEntry::CurrentWorktree => {
+ if secondary {
+ update_settings_file(self.fs.clone(), cx, |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_new_thread_location(NewThreadLocation::LocalProject);
+ });
+ }
+ window.dispatch_action(Box::new(StartThreadIn::LocalProject), cx);
+ }
+ ThreadWorktreeEntry::NewWorktree => {
+ if secondary {
+ update_settings_file(self.fs.clone(), cx, |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_new_thread_location(NewThreadLocation::NewWorktree);
+ });
+ }
+ window.dispatch_action(Box::new(self.new_worktree_action(None)), cx);
+ }
+ ThreadWorktreeEntry::LinkedWorktree { worktree, .. } => {
+ window.dispatch_action(
+ Box::new(StartThreadIn::LinkedWorktree {
+ path: worktree.path.clone(),
+ display_name: worktree.display_name().to_string(),
+ }),
+ cx,
+ );
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ name,
+ disabled_reason: None,
+ } => {
+ window.dispatch_action(Box::new(self.new_worktree_action(Some(name.clone()))), cx);
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ disabled_reason: Some(_),
+ ..
+ } => {
+ return;
+ }
+ }
+
+ cx.emit(DismissEvent);
+ }
+
+ fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {}
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _window: &mut Window,
+ cx: &mut Context>,
+ ) -> Option {
+ let entry = self.matches.get(ix)?;
+ let project = self.project.read(cx);
+ let is_new_worktree_disabled =
+ project.repositories(cx).is_empty() || project.is_via_collab();
+ let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
+ let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
+ let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
+
+ match entry {
+ ThreadWorktreeEntry::CurrentWorktree => Some(
+ ListItem::new("current-worktree")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
+ .child(Label::new("Current Worktree"))
+ .end_slot(HoldForDefault::new(is_local_default).more_content(false))
+ .tooltip(Tooltip::text("Use the current project worktree")),
+ ),
+ ThreadWorktreeEntry::NewWorktree => {
+ let item = ListItem::new("new-worktree")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .disabled(is_new_worktree_disabled)
+ .start_slot(
+ Icon::new(IconName::Plus).color(if is_new_worktree_disabled {
+ Color::Disabled
+ } else {
+ Color::Muted
+ }),
+ )
+ .child(
+ Label::new("New Git Worktree").color(if is_new_worktree_disabled {
+ Color::Disabled
+ } else {
+ Color::Default
+ }),
+ );
+
+ Some(if is_new_worktree_disabled {
+ item.tooltip(Tooltip::text("Requires a Git repository in the project"))
+ } else {
+ item.end_slot(HoldForDefault::new(is_new_worktree_default).more_content(false))
+ .tooltip(Tooltip::text("Start a thread in a new Git worktree"))
+ })
+ }
+ ThreadWorktreeEntry::LinkedWorktree {
+ worktree,
+ positions,
+ } => {
+ let display_name = worktree.display_name();
+ let first_line = display_name.lines().next().unwrap_or(display_name);
+ let positions: Vec<_> = positions
+ .iter()
+ .copied()
+ .filter(|&pos| pos < first_line.len())
+ .collect();
+
+ Some(
+ ListItem::new(SharedString::from(format!("linked-worktree-{ix}")))
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::GitWorktree).color(Color::Muted))
+ .child(HighlightedLabel::new(first_line.to_owned(), positions).truncate()),
+ )
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ name,
+ disabled_reason,
+ } => {
+ let is_disabled = disabled_reason.is_some();
+ let item = ListItem::new("create-named-worktree")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .disabled(is_disabled)
+ .start_slot(Icon::new(IconName::Plus).color(if is_disabled {
+ Color::Disabled
+ } else {
+ Color::Accent
+ }))
+ .child(Label::new(format!("Create Worktree: \"{name}\"…")).color(
+ if is_disabled {
+ Color::Disabled
+ } else {
+ Color::Default
+ },
+ ));
+
+ Some(if let Some(reason) = disabled_reason.clone() {
+ item.tooltip(Tooltip::text(reason))
+ } else {
+ item
+ })
+ }
+ }
+ }
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option {
+ None
+ }
+}
diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs
index 13b2aa1a37cd506c338d13db78bce751882e426a..7cb8410e5017438b0e8adde673887c13397d9abf 100644
--- a/crates/agent_ui/src/threads_archive_view.rs
+++ b/crates/agent_ui/src/threads_archive_view.rs
@@ -1236,6 +1236,7 @@ impl PickerDelegate for ProjectPickerDelegate {
},
match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
paths: Vec::new(),
+ active: false,
};
Some(
diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml
index 1e2587435489dea6952c697b0e0a4cf627226728..458f9bfae7da4736c4e54e42f08b5e3a926ed30a 100644
--- a/crates/anthropic/Cargo.toml
+++ b/crates/anthropic/Cargo.toml
@@ -18,12 +18,16 @@ path = "src/anthropic.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
+collections.workspace = true
futures.workspace = true
http_client.workspace = true
+language_model_core.workspace = true
+log.workspace = true
schemars = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
thiserror.workspace = true
+tiktoken-rs.workspace = true
diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs
index 5d7790b86b09853e22436252fcde1bebf5feff9b..48fa318d7c1d87e63725cef836baf9c945966206 100644
--- a/crates/anthropic/src/anthropic.rs
+++ b/crates/anthropic/src/anthropic.rs
@@ -12,6 +12,7 @@ use strum::{EnumIter, EnumString};
use thiserror::Error;
pub mod batches;
+pub mod completion;
pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com";
@@ -1026,6 +1027,89 @@ pub async fn count_tokens(
}
}
+// -- Conversions from/to `language_model_core` types --
+
+impl From for Speed {
+ fn from(speed: language_model_core::Speed) -> Self {
+ match speed {
+ language_model_core::Speed::Standard => Speed::Standard,
+ language_model_core::Speed::Fast => Speed::Fast,
+ }
+ }
+}
+
+impl From for language_model_core::LanguageModelCompletionError {
+ fn from(error: AnthropicError) -> Self {
+ let provider = language_model_core::ANTHROPIC_PROVIDER_NAME;
+ match error {
+ AnthropicError::SerializeRequest(error) => Self::SerializeRequest { provider, error },
+ AnthropicError::BuildRequestBody(error) => Self::BuildRequestBody { provider, error },
+ AnthropicError::HttpSend(error) => Self::HttpSend { provider, error },
+ AnthropicError::DeserializeResponse(error) => {
+ Self::DeserializeResponse { provider, error }
+ }
+ AnthropicError::ReadResponse(error) => Self::ApiReadResponseError { provider, error },
+ AnthropicError::HttpResponseError {
+ status_code,
+ message,
+ } => Self::HttpResponseError {
+ provider,
+ status_code,
+ message,
+ },
+ AnthropicError::RateLimit { retry_after } => Self::RateLimitExceeded {
+ provider,
+ retry_after: Some(retry_after),
+ },
+ AnthropicError::ServerOverloaded { retry_after } => Self::ServerOverloaded {
+ provider,
+ retry_after,
+ },
+ AnthropicError::ApiError(api_error) => api_error.into(),
+ }
+ }
+}
+
+impl From for language_model_core::LanguageModelCompletionError {
+ fn from(error: ApiError) -> Self {
+ use ApiErrorCode::*;
+ let provider = language_model_core::ANTHROPIC_PROVIDER_NAME;
+ match error.code() {
+ Some(code) => match code {
+ InvalidRequestError => Self::BadRequestFormat {
+ provider,
+ message: error.message,
+ },
+ AuthenticationError => Self::AuthenticationError {
+ provider,
+ message: error.message,
+ },
+ PermissionError => Self::PermissionError {
+ provider,
+ message: error.message,
+ },
+ NotFoundError => Self::ApiEndpointNotFound { provider },
+ RequestTooLarge => Self::PromptTooLarge {
+ tokens: language_model_core::parse_prompt_too_long(&error.message),
+ },
+ RateLimitError => Self::RateLimitExceeded {
+ provider,
+ retry_after: None,
+ },
+ ApiError => Self::ApiInternalServerError {
+ provider,
+ message: error.message,
+ },
+ OverloadedError => Self::ServerOverloaded {
+ provider,
+ retry_after: None,
+ },
+ },
+ None => Self::Other(error.into()),
+ }
+ }
+}
+
#[test]
fn test_match_window_exceeded() {
let error = ApiError {
diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs
new file mode 100644
index 0000000000000000000000000000000000000000..a6175a4f7c24b3b724734b2edef48ef8acfaa159
--- /dev/null
+++ b/crates/anthropic/src/completion.rs
@@ -0,0 +1,765 @@
+use anyhow::Result;
+use collections::HashMap;
+use futures::{Stream, StreamExt};
+use language_model_core::{
+ LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest,
+ LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
+ Role, StopReason, TokenUsage,
+ util::{fix_streamed_json, parse_tool_arguments},
+};
+use std::pin::Pin;
+use std::str::FromStr;
+
+use crate::{
+ AnthropicError, AnthropicModelMode, CacheControl, CacheControlType, ContentDelta,
+ CountTokensRequest, Event, ImageSource, Message, RequestContent, ResponseContent,
+ StringOrContents, Thinking, Tool, ToolChoice, ToolResultContent, ToolResultPart, Usage,
+};
+
+fn to_anthropic_content(content: MessageContent) -> Option {
+ match content {
+ MessageContent::Text(text) => {
+ let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
+ text.trim_end().to_string()
+ } else {
+ text
+ };
+ if !text.is_empty() {
+ Some(RequestContent::Text {
+ text,
+ cache_control: None,
+ })
+ } else {
+ None
+ }
+ }
+ MessageContent::Thinking {
+ text: thinking,
+ signature,
+ } => {
+ if let Some(signature) = signature
+ && !thinking.is_empty()
+ {
+ Some(RequestContent::Thinking {
+ thinking,
+ signature,
+ cache_control: None,
+ })
+ } else {
+ None
+ }
+ }
+ MessageContent::RedactedThinking(data) => {
+ if !data.is_empty() {
+ Some(RequestContent::RedactedThinking { data })
+ } else {
+ None
+ }
+ }
+ MessageContent::Image(image) => Some(RequestContent::Image {
+ source: ImageSource {
+ source_type: "base64".to_string(),
+ media_type: "image/png".to_string(),
+ data: image.source.to_string(),
+ },
+ cache_control: None,
+ }),
+ MessageContent::ToolUse(tool_use) => Some(RequestContent::ToolUse {
+ id: tool_use.id.to_string(),
+ name: tool_use.name.to_string(),
+ input: tool_use.input,
+ cache_control: None,
+ }),
+ MessageContent::ToolResult(tool_result) => Some(RequestContent::ToolResult {
+ tool_use_id: tool_result.tool_use_id.to_string(),
+ is_error: tool_result.is_error,
+ content: match tool_result.content {
+ LanguageModelToolResultContent::Text(text) => {
+ ToolResultContent::Plain(text.to_string())
+ }
+ LanguageModelToolResultContent::Image(image) => {
+ ToolResultContent::Multipart(vec![ToolResultPart::Image {
+ source: ImageSource {
+ source_type: "base64".to_string(),
+ media_type: "image/png".to_string(),
+ data: image.source.to_string(),
+ },
+ }])
+ }
+ },
+ cache_control: None,
+ }),
+ }
+}
+
+/// Convert a LanguageModelRequest to an Anthropic CountTokensRequest.
+pub fn into_anthropic_count_tokens_request(
+ request: LanguageModelRequest,
+ model: String,
+ mode: AnthropicModelMode,
+) -> CountTokensRequest {
+ let mut new_messages: Vec = Vec::new();
+ let mut system_message = String::new();
+
+ for message in request.messages {
+ if message.contents_empty() {
+ continue;
+ }
+
+ match message.role {
+ Role::User | Role::Assistant => {
+ let anthropic_message_content: Vec = message
+ .content
+ .into_iter()
+ .filter_map(to_anthropic_content)
+ .collect();
+ let anthropic_role = match message.role {
+ Role::User => crate::Role::User,
+ Role::Assistant => crate::Role::Assistant,
+ Role::System => unreachable!("System role should never occur here"),
+ };
+ if anthropic_message_content.is_empty() {
+ continue;
+ }
+
+ if let Some(last_message) = new_messages.last_mut()
+ && last_message.role == anthropic_role
+ {
+ last_message.content.extend(anthropic_message_content);
+ continue;
+ }
+
+ new_messages.push(Message {
+ role: anthropic_role,
+ content: anthropic_message_content,
+ });
+ }
+ Role::System => {
+ if !system_message.is_empty() {
+ system_message.push_str("\n\n");
+ }
+ system_message.push_str(&message.string_contents());
+ }
+ }
+ }
+
+ CountTokensRequest {
+ model,
+ messages: new_messages,
+ system: if system_message.is_empty() {
+ None
+ } else {
+ Some(StringOrContents::String(system_message))
+ },
+ thinking: if request.thinking_allowed {
+ match mode {
+ AnthropicModelMode::Thinking { budget_tokens } => {
+ Some(Thinking::Enabled { budget_tokens })
+ }
+ AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive),
+ AnthropicModelMode::Default => None,
+ }
+ } else {
+ None
+ },
+ tools: request
+ .tools
+ .into_iter()
+ .map(|tool| Tool {
+ name: tool.name,
+ description: tool.description,
+ input_schema: tool.input_schema,
+ eager_input_streaming: tool.use_input_streaming,
+ })
+ .collect(),
+ tool_choice: request.tool_choice.map(|choice| match choice {
+ LanguageModelToolChoice::Auto => ToolChoice::Auto,
+ LanguageModelToolChoice::Any => ToolChoice::Any,
+ LanguageModelToolChoice::None => ToolChoice::None,
+ }),
+ }
+}
+
+/// Estimate tokens using tiktoken. Used as a fallback when the API is unavailable,
+/// or by providers (like Zed Cloud) that don't have direct Anthropic API access.
+pub fn count_anthropic_tokens_with_tiktoken(request: LanguageModelRequest) -> Result {
+ let messages = request.messages;
+ let mut tokens_from_images = 0;
+ let mut string_messages = Vec::with_capacity(messages.len());
+
+ for message in messages {
+ let mut string_contents = String::new();
+
+ for content in message.content {
+ match content {
+ MessageContent::Text(text) => {
+ string_contents.push_str(&text);
+ }
+ MessageContent::Thinking { .. } => {
+ // Thinking blocks are not included in the input token count.
+ }
+ MessageContent::RedactedThinking(_) => {
+ // Thinking blocks are not included in the input token count.
+ }
+ MessageContent::Image(image) => {
+ tokens_from_images += image.estimate_tokens();
+ }
+ MessageContent::ToolUse(_tool_use) => {
+ // TODO: Estimate token usage from tool uses.
+ }
+ MessageContent::ToolResult(tool_result) => match &tool_result.content {
+ LanguageModelToolResultContent::Text(text) => {
+ string_contents.push_str(text);
+ }
+ LanguageModelToolResultContent::Image(image) => {
+ tokens_from_images += image.estimate_tokens();
+ }
+ },
+ }
+ }
+
+ if !string_contents.is_empty() {
+ string_messages.push(tiktoken_rs::ChatCompletionRequestMessage {
+ role: match message.role {
+ Role::User => "user".into(),
+ Role::Assistant => "assistant".into(),
+ Role::System => "system".into(),
+ },
+ content: Some(string_contents),
+ name: None,
+ function_call: None,
+ });
+ }
+ }
+
+ // Tiktoken doesn't yet support these models, so we manually use the
+ // same tokenizer as GPT-4.
+ tiktoken_rs::num_tokens_from_messages("gpt-4", &string_messages)
+ .map(|tokens| (tokens + tokens_from_images) as u64)
+}
+
+pub fn into_anthropic(
+ request: LanguageModelRequest,
+ model: String,
+ default_temperature: f32,
+ max_output_tokens: u64,
+ mode: AnthropicModelMode,
+) -> crate::Request {
+ let mut new_messages: Vec = Vec::new();
+ let mut system_message = String::new();
+
+ for message in request.messages {
+ if message.contents_empty() {
+ continue;
+ }
+
+ match message.role {
+ Role::User | Role::Assistant => {
+ let mut anthropic_message_content: Vec = message
+ .content
+ .into_iter()
+ .filter_map(to_anthropic_content)
+ .collect();
+ let anthropic_role = match message.role {
+ Role::User => crate::Role::User,
+ Role::Assistant => crate::Role::Assistant,
+ Role::System => unreachable!("System role should never occur here"),
+ };
+ if anthropic_message_content.is_empty() {
+ continue;
+ }
+
+ if let Some(last_message) = new_messages.last_mut()
+ && last_message.role == anthropic_role
+ {
+ last_message.content.extend(anthropic_message_content);
+ continue;
+ }
+
+ // Mark the last segment of the message as cached
+ if message.cache {
+ let cache_control_value = Some(CacheControl {
+ cache_type: CacheControlType::Ephemeral,
+ });
+ for message_content in anthropic_message_content.iter_mut().rev() {
+ match message_content {
+ RequestContent::RedactedThinking { .. } => {
+ // Caching is not possible, fallback to next message
+ }
+ RequestContent::Text { cache_control, .. }
+ | RequestContent::Thinking { cache_control, .. }
+ | RequestContent::Image { cache_control, .. }
+ | RequestContent::ToolUse { cache_control, .. }
+ | RequestContent::ToolResult { cache_control, .. } => {
+ *cache_control = cache_control_value;
+ break;
+ }
+ }
+ }
+ }
+
+ new_messages.push(Message {
+ role: anthropic_role,
+ content: anthropic_message_content,
+ });
+ }
+ Role::System => {
+ if !system_message.is_empty() {
+ system_message.push_str("\n\n");
+ }
+ system_message.push_str(&message.string_contents());
+ }
+ }
+ }
+
+ crate::Request {
+ model,
+ messages: new_messages,
+ max_tokens: max_output_tokens,
+ system: if system_message.is_empty() {
+ None
+ } else {
+ Some(StringOrContents::String(system_message))
+ },
+ thinking: if request.thinking_allowed {
+ match mode {
+ AnthropicModelMode::Thinking { budget_tokens } => {
+ Some(Thinking::Enabled { budget_tokens })
+ }
+ AnthropicModelMode::AdaptiveThinking => Some(Thinking::Adaptive),
+ AnthropicModelMode::Default => None,
+ }
+ } else {
+ None
+ },
+ tools: request
+ .tools
+ .into_iter()
+ .map(|tool| Tool {
+ name: tool.name,
+ description: tool.description,
+ input_schema: tool.input_schema,
+ eager_input_streaming: tool.use_input_streaming,
+ })
+ .collect(),
+ tool_choice: request.tool_choice.map(|choice| match choice {
+ LanguageModelToolChoice::Auto => ToolChoice::Auto,
+ LanguageModelToolChoice::Any => ToolChoice::Any,
+ LanguageModelToolChoice::None => ToolChoice::None,
+ }),
+ metadata: None,
+ output_config: if request.thinking_allowed
+ && matches!(mode, AnthropicModelMode::AdaptiveThinking)
+ {
+ request.thinking_effort.as_deref().and_then(|effort| {
+ let effort = match effort {
+ "low" => Some(crate::Effort::Low),
+ "medium" => Some(crate::Effort::Medium),
+ "high" => Some(crate::Effort::High),
+ "max" => Some(crate::Effort::Max),
+ _ => None,
+ };
+ effort.map(|effort| crate::OutputConfig {
+ effort: Some(effort),
+ })
+ })
+ } else {
+ None
+ },
+ stop_sequences: Vec::new(),
+ speed: request.speed.map(Into::into),
+ temperature: request.temperature.or(Some(default_temperature)),
+ top_k: None,
+ top_p: None,
+ }
+}
+
+pub struct AnthropicEventMapper {
+ tool_uses_by_index: HashMap,
+ usage: Usage,
+ stop_reason: StopReason,
+}
+
+impl AnthropicEventMapper {
+ pub fn new() -> Self {
+ Self {
+ tool_uses_by_index: HashMap::default(),
+ usage: Usage::default(),
+ stop_reason: StopReason::EndTurn,
+ }
+ }
+
+ pub fn map_stream(
+ mut self,
+ events: Pin>>>,
+ ) -> impl Stream- >
+ {
+ events.flat_map(move |event| {
+ futures::stream::iter(match event {
+ Ok(event) => self.map_event(event),
+ Err(error) => vec![Err(error.into())],
+ })
+ })
+ }
+
+ pub fn map_event(
+ &mut self,
+ event: Event,
+ ) -> Vec> {
+ match event {
+ Event::ContentBlockStart {
+ index,
+ content_block,
+ } => match content_block {
+ ResponseContent::Text { text } => {
+ vec![Ok(LanguageModelCompletionEvent::Text(text))]
+ }
+ ResponseContent::Thinking { thinking } => {
+ vec![Ok(LanguageModelCompletionEvent::Thinking {
+ text: thinking,
+ signature: None,
+ })]
+ }
+ ResponseContent::RedactedThinking { data } => {
+ vec![Ok(LanguageModelCompletionEvent::RedactedThinking { data })]
+ }
+ ResponseContent::ToolUse { id, name, .. } => {
+ self.tool_uses_by_index.insert(
+ index,
+ RawToolUse {
+ id,
+ name,
+ input_json: String::new(),
+ },
+ );
+ Vec::new()
+ }
+ },
+ Event::ContentBlockDelta { index, delta } => match delta {
+ ContentDelta::TextDelta { text } => {
+ vec![Ok(LanguageModelCompletionEvent::Text(text))]
+ }
+ ContentDelta::ThinkingDelta { thinking } => {
+ vec![Ok(LanguageModelCompletionEvent::Thinking {
+ text: thinking,
+ signature: None,
+ })]
+ }
+ ContentDelta::SignatureDelta { signature } => {
+ vec![Ok(LanguageModelCompletionEvent::Thinking {
+ text: "".to_string(),
+ signature: Some(signature),
+ })]
+ }
+ ContentDelta::InputJsonDelta { partial_json } => {
+ if let Some(tool_use) = self.tool_uses_by_index.get_mut(&index) {
+ tool_use.input_json.push_str(&partial_json);
+
+ // Try to convert invalid (incomplete) JSON into
+ // valid JSON that serde can accept, e.g. by closing
+ // unclosed delimiters. This way, we can update the
+ // UI with whatever has been streamed back so far.
+ if let Ok(input) =
+ serde_json::Value::from_str(&fix_streamed_json(&tool_use.input_json))
+ {
+ return vec![Ok(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: tool_use.id.clone().into(),
+ name: tool_use.name.clone().into(),
+ is_input_complete: false,
+ raw_input: tool_use.input_json.clone(),
+ input,
+ thought_signature: None,
+ },
+ ))];
+ }
+ }
+ vec![]
+ }
+ },
+ Event::ContentBlockStop { index } => {
+ if let Some(tool_use) = self.tool_uses_by_index.remove(&index) {
+ let input_json = tool_use.input_json.trim();
+ let event_result = match parse_tool_arguments(input_json) {
+ Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
+ LanguageModelToolUse {
+ id: tool_use.id.into(),
+ name: tool_use.name.into(),
+ is_input_complete: true,
+ input,
+ raw_input: tool_use.input_json.clone(),
+ thought_signature: None,
+ },
+ )),
+ Err(json_parse_err) => {
+ Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
+ id: tool_use.id.into(),
+ tool_name: tool_use.name.into(),
+ raw_input: input_json.into(),
+ json_parse_error: json_parse_err.to_string(),
+ })
+ }
+ };
+
+ vec![event_result]
+ } else {
+ Vec::new()
+ }
+ }
+ Event::MessageStart { message } => {
+ update_usage(&mut self.usage, &message.usage);
+ vec![
+ Ok(LanguageModelCompletionEvent::UsageUpdate(convert_usage(
+ &self.usage,
+ ))),
+ Ok(LanguageModelCompletionEvent::StartMessage {
+ message_id: message.id,
+ }),
+ ]
+ }
+ Event::MessageDelta { delta, usage } => {
+ update_usage(&mut self.usage, &usage);
+ if let Some(stop_reason) = delta.stop_reason.as_deref() {
+ self.stop_reason = match stop_reason {
+ "end_turn" => StopReason::EndTurn,
+ "max_tokens" => StopReason::MaxTokens,
+ "tool_use" => StopReason::ToolUse,
+ "refusal" => StopReason::Refusal,
+ _ => {
+ log::error!("Unexpected anthropic stop_reason: {stop_reason}");
+ StopReason::EndTurn
+ }
+ };
+ }
+ vec![Ok(LanguageModelCompletionEvent::UsageUpdate(
+ convert_usage(&self.usage),
+ ))]
+ }
+ Event::MessageStop => {
+ vec![Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))]
+ }
+ Event::Error { error } => {
+ vec![Err(error.into())]
+ }
+ _ => Vec::new(),
+ }
+ }
+}
+
+struct RawToolUse {
+ id: String,
+ name: String,
+ input_json: String,
+}
+
+/// Updates usage data by preferring counts from `new`.
+fn update_usage(usage: &mut Usage, new: &Usage) {
+ if let Some(input_tokens) = new.input_tokens {
+ usage.input_tokens = Some(input_tokens);
+ }
+ if let Some(output_tokens) = new.output_tokens {
+ usage.output_tokens = Some(output_tokens);
+ }
+ if let Some(cache_creation_input_tokens) = new.cache_creation_input_tokens {
+ usage.cache_creation_input_tokens = Some(cache_creation_input_tokens);
+ }
+ if let Some(cache_read_input_tokens) = new.cache_read_input_tokens {
+ usage.cache_read_input_tokens = Some(cache_read_input_tokens);
+ }
+}
+
+fn convert_usage(usage: &Usage) -> TokenUsage {
+ TokenUsage {
+ input_tokens: usage.input_tokens.unwrap_or(0),
+ output_tokens: usage.output_tokens.unwrap_or(0),
+ cache_creation_input_tokens: usage.cache_creation_input_tokens.unwrap_or(0),
+ cache_read_input_tokens: usage.cache_read_input_tokens.unwrap_or(0),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::AnthropicModelMode;
+ use language_model_core::{LanguageModelImage, LanguageModelRequestMessage, MessageContent};
+
+ #[test]
+ fn test_cache_control_only_on_last_segment() {
+ let request = LanguageModelRequest {
+ messages: vec![LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![
+ MessageContent::Text("Some prompt".to_string()),
+ MessageContent::Image(LanguageModelImage::empty()),
+ MessageContent::Image(LanguageModelImage::empty()),
+ MessageContent::Image(LanguageModelImage::empty()),
+ MessageContent::Image(LanguageModelImage::empty()),
+ ],
+ cache: true,
+ reasoning_details: None,
+ }],
+ thread_id: None,
+ prompt_id: None,
+ intent: None,
+ stop: vec![],
+ temperature: None,
+ tools: vec![],
+ tool_choice: None,
+ thinking_allowed: true,
+ thinking_effort: None,
+ speed: None,
+ };
+
+ let anthropic_request = into_anthropic(
+ request,
+ "claude-3-5-sonnet".to_string(),
+ 0.7,
+ 4096,
+ AnthropicModelMode::Default,
+ );
+
+ assert_eq!(anthropic_request.messages.len(), 1);
+
+ let message = &anthropic_request.messages[0];
+ assert_eq!(message.content.len(), 5);
+
+ assert!(matches!(
+ message.content[0],
+ RequestContent::Text {
+ cache_control: None,
+ ..
+ }
+ ));
+ for i in 1..3 {
+ assert!(matches!(
+ message.content[i],
+ RequestContent::Image {
+ cache_control: None,
+ ..
+ }
+ ));
+ }
+
+ assert!(matches!(
+ message.content[4],
+ RequestContent::Image {
+ cache_control: Some(CacheControl {
+ cache_type: CacheControlType::Ephemeral,
+ }),
+ ..
+ }
+ ));
+ }
+
+ fn request_with_assistant_content(assistant_content: Vec) -> crate::Request {
+ let mut request = LanguageModelRequest {
+ messages: vec![LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![MessageContent::Text("Hello".to_string())],
+ cache: false,
+ reasoning_details: None,
+ }],
+ thinking_effort: None,
+ thread_id: None,
+ prompt_id: None,
+ intent: None,
+ stop: vec![],
+ temperature: None,
+ tools: vec![],
+ tool_choice: None,
+ thinking_allowed: true,
+ speed: None,
+ };
+ request.messages.push(LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: assistant_content,
+ cache: false,
+ reasoning_details: None,
+ });
+ into_anthropic(
+ request,
+ "claude-sonnet-4-5".to_string(),
+ 1.0,
+ 16000,
+ AnthropicModelMode::Thinking {
+ budget_tokens: Some(10000),
+ },
+ )
+ }
+
+ #[test]
+ fn test_unsigned_thinking_blocks_stripped() {
+ let result = request_with_assistant_content(vec![
+ MessageContent::Thinking {
+ text: "Cancelled mid-think, no signature".to_string(),
+ signature: None,
+ },
+ MessageContent::Text("Some response text".to_string()),
+ ]);
+
+ let assistant_message = result
+ .messages
+ .iter()
+ .find(|m| m.role == crate::Role::Assistant)
+ .expect("assistant message should still exist");
+
+ assert_eq!(
+ assistant_message.content.len(),
+ 1,
+ "Only the text content should remain; unsigned thinking block should be stripped"
+ );
+ assert!(matches!(
+ &assistant_message.content[0],
+ RequestContent::Text { text, .. } if text == "Some response text"
+ ));
+ }
+
+ #[test]
+ fn test_signed_thinking_blocks_preserved() {
+ let result = request_with_assistant_content(vec![
+ MessageContent::Thinking {
+ text: "Completed thinking".to_string(),
+ signature: Some("valid-signature".to_string()),
+ },
+ MessageContent::Text("Response".to_string()),
+ ]);
+
+ let assistant_message = result
+ .messages
+ .iter()
+ .find(|m| m.role == crate::Role::Assistant)
+ .expect("assistant message should exist");
+
+ assert_eq!(
+ assistant_message.content.len(),
+ 2,
+ "Both the signed thinking block and text should be preserved"
+ );
+ assert!(matches!(
+ &assistant_message.content[0],
+ RequestContent::Thinking { thinking, signature, .. }
+ if thinking == "Completed thinking" && signature == "valid-signature"
+ ));
+ }
+
+ #[test]
+ fn test_only_unsigned_thinking_block_omits_entire_message() {
+ let result = request_with_assistant_content(vec![MessageContent::Thinking {
+ text: "Cancelled before any text or signature".to_string(),
+ signature: None,
+ }]);
+
+ let assistant_messages: Vec<_> = result
+ .messages
+ .iter()
+ .filter(|m| m.role == crate::Role::Assistant)
+ .collect();
+
+ assert_eq!(
+ assistant_messages.len(),
+ 0,
+ "An assistant message whose only content was an unsigned thinking block \
+ should be omitted entirely"
+ );
+ }
+}
diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs
index 8b6113e4d5521fb3c7e27a7f2f6547c7a9db86ce..7c1e6e0e4e6ef873345c30c0af4c9e8842699c77 100644
--- a/crates/bedrock/src/models.rs
+++ b/crates/bedrock/src/models.rs
@@ -113,6 +113,10 @@ pub enum Model {
MistralLarge3,
#[serde(rename = "pixtral-large")]
PixtralLarge,
+ #[serde(rename = "devstral-2-123b")]
+ Devstral2_123B,
+ #[serde(rename = "ministral-14b")]
+ Ministral14B,
// Qwen models
#[serde(rename = "qwen3-32b")]
@@ -146,9 +150,27 @@ pub enum Model {
#[serde(rename = "gpt-oss-120b")]
GptOss120B,
+ // NVIDIA Nemotron models
+ #[serde(rename = "nemotron-super-3-120b")]
+ NemotronSuper3_120B,
+ #[serde(rename = "nemotron-nano-3-30b")]
+ NemotronNano3_30B,
+
// MiniMax models
#[serde(rename = "minimax-m2")]
MiniMaxM2,
+ #[serde(rename = "minimax-m2-1")]
+ MiniMaxM2_1,
+ #[serde(rename = "minimax-m2-5")]
+ MiniMaxM2_5,
+
+ // Z.AI GLM models
+ #[serde(rename = "glm-5")]
+ GLM5,
+ #[serde(rename = "glm-4-7")]
+ GLM4_7,
+ #[serde(rename = "glm-4-7-flash")]
+ GLM4_7Flash,
// Moonshot models
#[serde(rename = "kimi-k2-thinking")]
@@ -217,6 +239,8 @@ impl Model {
Self::MagistralSmall => "magistral-small",
Self::MistralLarge3 => "mistral-large-3",
Self::PixtralLarge => "pixtral-large",
+ Self::Devstral2_123B => "devstral-2-123b",
+ Self::Ministral14B => "ministral-14b",
Self::Qwen3_32B => "qwen3-32b",
Self::Qwen3VL235B => "qwen3-vl-235b",
Self::Qwen3_235B => "qwen3-235b",
@@ -230,7 +254,14 @@ impl Model {
Self::Nova2Lite => "nova-2-lite",
Self::GptOss20B => "gpt-oss-20b",
Self::GptOss120B => "gpt-oss-120b",
+ Self::NemotronSuper3_120B => "nemotron-super-3-120b",
+ Self::NemotronNano3_30B => "nemotron-nano-3-30b",
Self::MiniMaxM2 => "minimax-m2",
+ Self::MiniMaxM2_1 => "minimax-m2-1",
+ Self::MiniMaxM2_5 => "minimax-m2-5",
+ Self::GLM5 => "glm-5",
+ Self::GLM4_7 => "glm-4-7",
+ Self::GLM4_7Flash => "glm-4-7-flash",
Self::KimiK2Thinking => "kimi-k2-thinking",
Self::KimiK2_5 => "kimi-k2-5",
Self::DeepSeekR1 => "deepseek-r1",
@@ -257,6 +288,8 @@ impl Model {
Self::MagistralSmall => "mistral.magistral-small-2509",
Self::MistralLarge3 => "mistral.mistral-large-3-675b-instruct",
Self::PixtralLarge => "mistral.pixtral-large-2502-v1:0",
+ Self::Devstral2_123B => "mistral.devstral-2-123b",
+ Self::Ministral14B => "mistral.ministral-3-14b-instruct",
Self::Qwen3VL235B => "qwen.qwen3-vl-235b-a22b",
Self::Qwen3_32B => "qwen.qwen3-32b-v1:0",
Self::Qwen3_235B => "qwen.qwen3-235b-a22b-2507-v1:0",
@@ -270,7 +303,14 @@ impl Model {
Self::Nova2Lite => "amazon.nova-2-lite-v1:0",
Self::GptOss20B => "openai.gpt-oss-20b-1:0",
Self::GptOss120B => "openai.gpt-oss-120b-1:0",
+ Self::NemotronSuper3_120B => "nvidia.nemotron-super-3-120b",
+ Self::NemotronNano3_30B => "nvidia.nemotron-nano-3-30b",
Self::MiniMaxM2 => "minimax.minimax-m2",
+ Self::MiniMaxM2_1 => "minimax.minimax-m2.1",
+ Self::MiniMaxM2_5 => "minimax.minimax-m2.5",
+ Self::GLM5 => "zai.glm-5",
+ Self::GLM4_7 => "zai.glm-4.7",
+ Self::GLM4_7Flash => "zai.glm-4.7-flash",
Self::KimiK2Thinking => "moonshot.kimi-k2-thinking",
Self::KimiK2_5 => "moonshotai.kimi-k2.5",
Self::DeepSeekR1 => "deepseek.r1-v1:0",
@@ -297,6 +337,8 @@ impl Model {
Self::MagistralSmall => "Magistral Small",
Self::MistralLarge3 => "Mistral Large 3",
Self::PixtralLarge => "Pixtral Large",
+ Self::Devstral2_123B => "Devstral 2 123B",
+ Self::Ministral14B => "Ministral 14B",
Self::Qwen3VL235B => "Qwen3 VL 235B",
Self::Qwen3_32B => "Qwen3 32B",
Self::Qwen3_235B => "Qwen3 235B",
@@ -310,7 +352,14 @@ impl Model {
Self::Nova2Lite => "Amazon Nova 2 Lite",
Self::GptOss20B => "GPT OSS 20B",
Self::GptOss120B => "GPT OSS 120B",
+ Self::NemotronSuper3_120B => "Nemotron Super 3 120B",
+ Self::NemotronNano3_30B => "Nemotron Nano 3 30B",
Self::MiniMaxM2 => "MiniMax M2",
+ Self::MiniMaxM2_1 => "MiniMax M2.1",
+ Self::MiniMaxM2_5 => "MiniMax M2.5",
+ Self::GLM5 => "GLM 5",
+ Self::GLM4_7 => "GLM 4.7",
+ Self::GLM4_7Flash => "GLM 4.7 Flash",
Self::KimiK2Thinking => "Kimi K2 Thinking",
Self::KimiK2_5 => "Kimi K2.5",
Self::DeepSeekR1 => "DeepSeek R1",
@@ -338,6 +387,7 @@ impl Model {
Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000,
Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000,
Self::MagistralSmall | Self::MistralLarge3 | Self::PixtralLarge => 128_000,
+ Self::Devstral2_123B | Self::Ministral14B => 256_000,
Self::Qwen3_32B
| Self::Qwen3VL235B
| Self::Qwen3_235B
@@ -349,7 +399,9 @@ impl Model {
Self::NovaPremier => 1_000_000,
Self::Nova2Lite => 300_000,
Self::GptOss20B | Self::GptOss120B => 128_000,
- Self::MiniMaxM2 => 128_000,
+ Self::NemotronSuper3_120B | Self::NemotronNano3_30B => 262_000,
+ Self::MiniMaxM2 | Self::MiniMaxM2_1 | Self::MiniMaxM2_5 => 196_000,
+ Self::GLM5 | Self::GLM4_7 | Self::GLM4_7Flash => 203_000,
Self::KimiK2Thinking | Self::KimiK2_5 => 128_000,
Self::DeepSeekR1 | Self::DeepSeekV3_1 | Self::DeepSeekV3_2 => 128_000,
Self::Custom { max_tokens, .. } => *max_tokens,
@@ -373,6 +425,7 @@ impl Model {
| Self::MagistralSmall
| Self::MistralLarge3
| Self::PixtralLarge => 8_192,
+ Self::Devstral2_123B | Self::Ministral14B => 131_000,
Self::Qwen3_32B
| Self::Qwen3VL235B
| Self::Qwen3_235B
@@ -382,7 +435,9 @@ impl Model {
| Self::Qwen3Coder480B => 8_192,
Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => 5_000,
Self::GptOss20B | Self::GptOss120B => 16_000,
- Self::MiniMaxM2 => 16_000,
+ Self::NemotronSuper3_120B | Self::NemotronNano3_30B => 131_000,
+ Self::MiniMaxM2 | Self::MiniMaxM2_1 | Self::MiniMaxM2_5 => 98_000,
+ Self::GLM5 | Self::GLM4_7 | Self::GLM4_7Flash => 101_000,
Self::KimiK2Thinking | Self::KimiK2_5 => 16_000,
Self::DeepSeekR1 | Self::DeepSeekV3_1 | Self::DeepSeekV3_2 => 16_000,
Self::Custom {
@@ -419,6 +474,7 @@ impl Model {
| Self::ClaudeSonnet4_6 => true,
Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true,
Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true,
+ Self::Devstral2_123B | Self::Ministral14B => true,
// Gemma accepts toolConfig without error but produces unreliable tool
// calls -- malformed JSON args, hallucinated tool names, dropped calls.
Self::Qwen3_32B
@@ -428,7 +484,9 @@ impl Model {
| Self::Qwen3Coder30B
| Self::Qwen3CoderNext
| Self::Qwen3Coder480B => true,
- Self::MiniMaxM2 => true,
+ Self::MiniMaxM2 | Self::MiniMaxM2_1 | Self::MiniMaxM2_5 => true,
+ Self::NemotronSuper3_120B | Self::NemotronNano3_30B => true,
+ Self::GLM5 | Self::GLM4_7 | Self::GLM4_7Flash => true,
Self::KimiK2Thinking | Self::KimiK2_5 => true,
Self::DeepSeekR1 | Self::DeepSeekV3_1 | Self::DeepSeekV3_2 => true,
_ => false,
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
index 7bbaccb22e0e6c7508240186103e216f83be2f0c..532fe38f7df1f686730ed862a81806e9a531e156 100644
--- a/crates/client/Cargo.toml
+++ b/crates/client/Cargo.toml
@@ -36,7 +36,6 @@ gpui_tokio.workspace = true
http_client.workspace = true
http_client_tls.workspace = true
httparse = "1.10"
-language_model.workspace = true
log.workspace = true
parking_lot.workspace = true
paths.workspace = true
diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs
index dfd9963a0ee52d167f8d4edb0b850f4debed7fd4..05ca974f80438542b232262dd375e0e38ab4327c 100644
--- a/crates/client/src/client.rs
+++ b/crates/client/src/client.rs
@@ -14,6 +14,7 @@ use async_tungstenite::tungstenite::{
http::{HeaderValue, Request, StatusCode},
};
use clock::SystemClock;
+use cloud_api_client::LlmApiToken;
use cloud_api_client::websocket_protocol::MessageToClient;
use cloud_api_client::{ClientApiError, CloudApiClient};
use cloud_api_types::OrganizationId;
@@ -26,7 +27,6 @@ use futures::{
};
use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
use http_client::{HttpClient, HttpClientWithUrl, http, read_proxy_from_env};
-use language_model::LlmApiToken;
use parking_lot::{Mutex, RwLock};
use postage::watch;
use proxy::connect_proxy_stream;
diff --git a/crates/client/src/llm_token.rs b/crates/client/src/llm_token.rs
index f62aa6dd4dc3462bc3a0f6f46c35f0e4e5499816..70457679e4b965e3251ae4861d3052bfa41fd65a 100644
--- a/crates/client/src/llm_token.rs
+++ b/crates/client/src/llm_token.rs
@@ -1,10 +1,10 @@
use super::{Client, UserStore};
+use cloud_api_client::LlmApiToken;
use cloud_api_types::websocket_protocol::MessageToClient;
use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription,
};
-use language_model::LlmApiToken;
use std::sync::Arc;
pub trait NeedsLlmTokenRefresh {
diff --git a/crates/cloud_api_client/Cargo.toml b/crates/cloud_api_client/Cargo.toml
index 78c684e3e54ee29a5f3f3ae5620d4a52b445f92e..cf293d83f848e1266dec977c0925af7f66608ce6 100644
--- a/crates/cloud_api_client/Cargo.toml
+++ b/crates/cloud_api_client/Cargo.toml
@@ -20,5 +20,6 @@ gpui_tokio.workspace = true
http_client.workspace = true
parking_lot.workspace = true
serde_json.workspace = true
+smol.workspace = true
thiserror.workspace = true
yawc.workspace = true
diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs
index 13d67838b216f4990f15ec22c1701aa7aef9dbf2..8c605bb3490ef5c7aea6e96045680338e8344a83 100644
--- a/crates/cloud_api_client/src/cloud_api_client.rs
+++ b/crates/cloud_api_client/src/cloud_api_client.rs
@@ -1,3 +1,4 @@
+mod llm_token;
mod websocket;
use std::sync::Arc;
@@ -18,6 +19,8 @@ use yawc::WebSocket;
use crate::websocket::Connection;
+pub use llm_token::LlmApiToken;
+
struct Credentials {
user_id: u32,
access_token: String,
diff --git a/crates/cloud_api_client/src/llm_token.rs b/crates/cloud_api_client/src/llm_token.rs
new file mode 100644
index 0000000000000000000000000000000000000000..711e0d51b89bf34db255d7cb1e58483c9de340fc
--- /dev/null
+++ b/crates/cloud_api_client/src/llm_token.rs
@@ -0,0 +1,74 @@
+use std::sync::Arc;
+
+use cloud_api_types::OrganizationId;
+use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard};
+
+use crate::{ClientApiError, CloudApiClient};
+
+#[derive(Clone, Default)]
+pub struct LlmApiToken(Arc>>);
+
+impl LlmApiToken {
+ pub async fn acquire(
+ &self,
+ client: &CloudApiClient,
+ system_id: Option,
+ organization_id: Option,
+ ) -> Result {
+ let lock = self.0.upgradable_read().await;
+ if let Some(token) = lock.as_ref() {
+ Ok(token.to_string())
+ } else {
+ Self::fetch(
+ RwLockUpgradableReadGuard::upgrade(lock).await,
+ client,
+ system_id,
+ organization_id,
+ )
+ .await
+ }
+ }
+
+ pub async fn refresh(
+ &self,
+ client: &CloudApiClient,
+ system_id: Option,
+ organization_id: Option,
+ ) -> Result {
+ Self::fetch(self.0.write().await, client, system_id, organization_id).await
+ }
+
+ /// Clears the existing token before attempting to fetch a new one.
+ ///
+ /// Used when switching organizations so that a failed refresh doesn't
+ /// leave a token for the wrong organization.
+ pub async fn clear_and_refresh(
+ &self,
+ client: &CloudApiClient,
+ system_id: Option,
+ organization_id: Option,
+ ) -> Result {
+ let mut lock = self.0.write().await;
+ *lock = None;
+ Self::fetch(lock, client, system_id, organization_id).await
+ }
+
+ async fn fetch(
+ mut lock: RwLockWriteGuard<'_, Option>,
+ client: &CloudApiClient,
+ system_id: Option,
+ organization_id: Option,
+ ) -> Result {
+ let result = client.create_llm_token(system_id, organization_id).await;
+ match result {
+ Ok(response) => {
+ *lock = Some(response.token.0.clone());
+ Ok(response.token.0)
+ }
+ Err(err) => {
+ *lock = None;
+ Err(err)
+ }
+ }
+ }
+}
diff --git a/crates/cloud_llm_client/Cargo.toml b/crates/cloud_llm_client/Cargo.toml
index a7b4f925a9302296e8fe25a14177a583e5f44b33..7cc59f255abeb27c6e35a2064654d8eca1a581fe 100644
--- a/crates/cloud_llm_client/Cargo.toml
+++ b/crates/cloud_llm_client/Cargo.toml
@@ -7,6 +7,7 @@ license = "Apache-2.0"
[features]
test-support = []
+predict-edits = ["dep:zeta_prompt"]
[lints]
workspace = true
@@ -20,6 +21,6 @@ serde = { workspace = true, features = ["derive", "rc"] }
serde_json.workspace = true
strum = { workspace = true, features = ["derive"] }
uuid = { workspace = true, features = ["serde"] }
-zeta_prompt.workspace = true
+zeta_prompt = { workspace = true, optional = true }
diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs
index 35eb3f2b80dd400558b1f027781f5b8cf63bb6cb..ac8bdd462a9c4754ef42a6afa41f1bef8b5bbe6a 100644
--- a/crates/cloud_llm_client/src/cloud_llm_client.rs
+++ b/crates/cloud_llm_client/src/cloud_llm_client.rs
@@ -1,3 +1,4 @@
+#[cfg(feature = "predict-edits")]
pub mod predict_edits_v3;
use std::str::FromStr;
diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs
index 2fa67b072f1c3d49ef5ca1b90056fd08d57df1ba..c273005264d0a53b6a083a4013f7597a56919016 100644
--- a/crates/collab/tests/integration/git_tests.rs
+++ b/crates/collab/tests/integration/git_tests.rs
@@ -269,9 +269,11 @@ async fn test_remote_git_worktrees(
cx_b.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.create_worktree(
- "feature-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "feature-branch".to_string(),
+ base_sha: Some("abc123".to_string()),
+ },
worktree_directory.join("feature-branch"),
- Some("abc123".to_string()),
)
})
})
@@ -323,9 +325,11 @@ async fn test_remote_git_worktrees(
cx_b.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.create_worktree(
- "bugfix-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "bugfix-branch".to_string(),
+ base_sha: None,
+ },
worktree_directory.join("bugfix-branch"),
- None,
)
})
})
diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
index 0796323fc5b3d8f6b1cbcb0e108a7d573240f446..d478402a9d66ca9fba4e8f9517cb62898754e677 100644
--- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
+++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
@@ -473,9 +473,11 @@ async fn test_ssh_collaboration_git_worktrees(
cx_b.update(|cx| {
repo_b.update(cx, |repo, _| {
repo.create_worktree(
- "feature-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "feature-branch".to_string(),
+ base_sha: Some("abc123".to_string()),
+ },
worktree_directory.join("feature-branch"),
- Some("abc123".to_string()),
)
})
})
diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml
index efcba05456955e308e5a00e938bf3092d894efeb..920f620e0ea2d48f514c5e0af598add193f80d98 100644
--- a/crates/collab_ui/Cargo.toml
+++ b/crates/collab_ui/Cargo.toml
@@ -32,7 +32,6 @@ test-support = [
anyhow.workspace = true
call.workspace = true
channel.workspace = true
-chrono.workspace = true
client.workspace = true
collections.workspace = true
db.workspace = true
@@ -41,7 +40,6 @@ futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
livekit_client.workspace = true
-log.workspace = true
menu.workspace = true
notifications.workspace = true
picker.workspace = true
@@ -56,7 +54,6 @@ telemetry.workspace = true
theme.workspace = true
theme_settings.workspace = true
time.workspace = true
-time_format.workspace = true
title_bar.workspace = true
ui.workspace = true
util.workspace = true
diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs
index 8d0cdf351163dadf0ac8cbf6a8dc04886f30f583..1cff27ac6b2f3c61f7a90c4a9ca6749d4b1e48b7 100644
--- a/crates/collab_ui/src/collab_panel.rs
+++ b/crates/collab_ui/src/collab_panel.rs
@@ -6,7 +6,7 @@ use crate::{CollaborationPanelSettings, channel_view::ChannelView};
use anyhow::Context as _;
use call::ActiveCall;
use channel::{Channel, ChannelEvent, ChannelStore};
-use client::{ChannelId, Client, Contact, User, UserStore};
+use client::{ChannelId, Client, Contact, Notification, User, UserStore};
use collections::{HashMap, HashSet};
use contact_finder::ContactFinder;
use db::kvp::KeyValueStore;
@@ -21,6 +21,7 @@ use gpui::{
};
use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrevious};
+use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
use project::{Fs, Project};
use rpc::{
ErrorCode, ErrorExt,
@@ -29,19 +30,23 @@ use rpc::{
use serde::{Deserialize, Serialize};
use settings::Settings;
use smallvec::SmallVec;
-use std::{mem, sync::Arc};
+use std::{mem, sync::Arc, time::Duration};
use theme::ActiveTheme;
use theme_settings::ThemeSettings;
use ui::{
- Avatar, AvatarAvailabilityIndicator, ContextMenu, CopyButton, Facepile, HighlightedLabel,
- IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*, tooltip_container,
+ Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, Facepile,
+ HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*,
+ tooltip_container,
};
use util::{ResultExt, TryFutureExt, maybe};
use workspace::{
CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, OpenChannelNotesById,
ScreenShare, ShareProject, Workspace,
dock::{DockPosition, Panel, PanelEvent},
- notifications::{DetachAndPromptErr, NotifyResultExt},
+ notifications::{
+ DetachAndPromptErr, Notification as WorkspaceNotification, NotificationId, NotifyResultExt,
+ SuppressEvent,
+ },
};
const FILTER_OCCUPIED_CHANNELS_KEY: &str = "filter_occupied_channels";
@@ -87,6 +92,7 @@ struct ChannelMoveClipboard {
}
const COLLABORATION_PANEL_KEY: &str = "CollaborationPanel";
+const TOAST_DURATION: Duration = Duration::from_secs(5);
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _| {
@@ -267,6 +273,9 @@ pub struct CollabPanel {
collapsed_channels: Vec,
filter_occupied_channels: bool,
workspace: WeakEntity,
+ notification_store: Entity,
+ current_notification_toast: Option<(u64, Task<()>)>,
+ mark_as_read_tasks: HashMap>>,
}
#[derive(Serialize, Deserialize)]
@@ -394,6 +403,9 @@ impl CollabPanel {
channel_editing_state: None,
selection: None,
channel_store: ChannelStore::global(cx),
+ notification_store: NotificationStore::global(cx),
+ current_notification_toast: None,
+ mark_as_read_tasks: HashMap::default(),
user_store: workspace.user_store().clone(),
project: workspace.project().clone(),
subscriptions: Vec::default(),
@@ -437,6 +449,11 @@ impl CollabPanel {
}
},
));
+ this.subscriptions.push(cx.subscribe_in(
+ &this.notification_store,
+ window,
+ Self::on_notification_event,
+ ));
this
})
@@ -1181,7 +1198,7 @@ impl CollabPanel {
.into();
ListItem::new(project_id as usize)
- .height(px(24.))
+ .height(rems_from_px(24.))
.toggle_state(is_selected)
.on_click(cx.listener(move |this, _, window, cx| {
this.workspace
@@ -1222,7 +1239,7 @@ impl CollabPanel {
let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
ListItem::new(("screen", id))
- .height(px(24.))
+ .height(rems_from_px(24.))
.toggle_state(is_selected)
.start_slot(
h_flex()
@@ -1269,7 +1286,7 @@ impl CollabPanel {
let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id);
ListItem::new("channel-notes")
- .height(px(24.))
+ .height(rems_from_px(24.))
.toggle_state(is_selected)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_channel_notes(channel_id, window, cx);
@@ -2665,26 +2682,28 @@ impl CollabPanel {
window: &mut Window,
cx: &mut Context,
) -> AnyElement {
- let entry = &self.entries[ix];
+ let entry = self.entries[ix].clone();
let is_selected = self.selection == Some(ix);
match entry {
ListEntry::Header(section) => {
- let is_collapsed = self.collapsed_sections.contains(section);
- self.render_header(*section, is_selected, is_collapsed, cx)
+ let is_collapsed = self.collapsed_sections.contains(§ion);
+ self.render_header(section, is_selected, is_collapsed, cx)
+ .into_any_element()
+ }
+ ListEntry::Contact { contact, calling } => {
+ self.mark_contact_request_accepted_notifications_read(contact.user.id, cx);
+ self.render_contact(&contact, calling, is_selected, cx)
.into_any_element()
}
- ListEntry::Contact { contact, calling } => self
- .render_contact(contact, *calling, is_selected, cx)
- .into_any_element(),
ListEntry::ContactPlaceholder => self
.render_contact_placeholder(is_selected, cx)
.into_any_element(),
ListEntry::IncomingRequest(user) => self
- .render_contact_request(user, true, is_selected, cx)
+ .render_contact_request(&user, true, is_selected, cx)
.into_any_element(),
ListEntry::OutgoingRequest(user) => self
- .render_contact_request(user, false, is_selected, cx)
+ .render_contact_request(&user, false, is_selected, cx)
.into_any_element(),
ListEntry::Channel {
channel,
@@ -2694,9 +2713,9 @@ impl CollabPanel {
..
} => self
.render_channel(
- channel,
- *depth,
- *has_children,
+ &channel,
+ depth,
+ has_children,
is_selected,
ix,
string_match.as_ref(),
@@ -2704,10 +2723,10 @@ impl CollabPanel {
)
.into_any_element(),
ListEntry::ChannelEditor { depth } => self
- .render_channel_editor(*depth, window, cx)
+ .render_channel_editor(depth, window, cx)
.into_any_element(),
ListEntry::ChannelInvite(channel) => self
- .render_channel_invite(channel, is_selected, cx)
+ .render_channel_invite(&channel, is_selected, cx)
.into_any_element(),
ListEntry::CallParticipant {
user,
@@ -2715,7 +2734,7 @@ impl CollabPanel {
is_pending,
role,
} => self
- .render_call_participant(user, *peer_id, *is_pending, *role, is_selected, cx)
+ .render_call_participant(&user, peer_id, is_pending, role, is_selected, cx)
.into_any_element(),
ListEntry::ParticipantProject {
project_id,
@@ -2724,20 +2743,20 @@ impl CollabPanel {
is_last,
} => self
.render_participant_project(
- *project_id,
- worktree_root_names,
- *host_user_id,
- *is_last,
+ project_id,
+ &worktree_root_names,
+ host_user_id,
+ is_last,
is_selected,
window,
cx,
)
.into_any_element(),
ListEntry::ParticipantScreen { peer_id, is_last } => self
- .render_participant_screen(*peer_id, *is_last, is_selected, window, cx)
+ .render_participant_screen(peer_id, is_last, is_selected, window, cx)
.into_any_element(),
ListEntry::ChannelNotes { channel_id } => self
- .render_channel_notes(*channel_id, is_selected, window, cx)
+ .render_channel_notes(channel_id, is_selected, window, cx)
.into_any_element(),
}
}
@@ -2846,11 +2865,11 @@ impl CollabPanel {
}
};
- Some(channel.name.as_ref())
+ Some(channel.name.clone())
});
if let Some(name) = channel_name {
- SharedString::from(name.to_string())
+ name
} else {
SharedString::from("Current Call")
}
@@ -3210,7 +3229,7 @@ impl CollabPanel {
(IconName::Star, Color::Default, "Add to Favorites")
};
- let height = px(24.);
+ let height = rems_from_px(24.);
h_flex()
.id(ix)
@@ -3397,6 +3416,178 @@ impl CollabPanel {
item.child(self.channel_name_editor.clone())
}
}
+
+ fn on_notification_event(
+ &mut self,
+ _: &Entity,
+ event: &NotificationEvent,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ match event {
+ NotificationEvent::NewNotification { entry } => {
+ self.add_toast(entry, cx);
+ cx.notify();
+ }
+ NotificationEvent::NotificationRemoved { entry }
+ | NotificationEvent::NotificationRead { entry } => {
+ self.remove_toast(entry.id, cx);
+ cx.notify();
+ }
+ NotificationEvent::NotificationsUpdated { .. } => {
+ cx.notify();
+ }
+ }
+ }
+
+ fn present_notification(
+ &self,
+ entry: &NotificationEntry,
+ cx: &App,
+ ) -> Option<(Option>, String)> {
+ let user_store = self.user_store.read(cx);
+ match &entry.notification {
+ Notification::ContactRequest { sender_id } => {
+ let requester = user_store.get_cached_user(*sender_id)?;
+ Some((
+ Some(requester.clone()),
+ format!("{} wants to add you as a contact", requester.github_login),
+ ))
+ }
+ Notification::ContactRequestAccepted { responder_id } => {
+ let responder = user_store.get_cached_user(*responder_id)?;
+ Some((
+ Some(responder.clone()),
+ format!("{} accepted your contact request", responder.github_login),
+ ))
+ }
+ Notification::ChannelInvitation {
+ channel_name,
+ inviter_id,
+ ..
+ } => {
+ let inviter = user_store.get_cached_user(*inviter_id)?;
+ Some((
+ Some(inviter.clone()),
+ format!(
+ "{} invited you to join the #{channel_name} channel",
+ inviter.github_login
+ ),
+ ))
+ }
+ }
+ }
+
+ fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut Context) {
+ let Some((actor, text)) = self.present_notification(entry, cx) else {
+ return;
+ };
+
+ let notification = entry.notification.clone();
+ let needs_response = matches!(
+ notification,
+ Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. }
+ );
+
+ let notification_id = entry.id;
+
+ self.current_notification_toast = Some((
+ notification_id,
+ cx.spawn(async move |this, cx| {
+ cx.background_executor().timer(TOAST_DURATION).await;
+ this.update(cx, |this, cx| this.remove_toast(notification_id, cx))
+ .ok();
+ }),
+ ));
+
+ let collab_panel = cx.entity().downgrade();
+ self.workspace
+ .update(cx, |workspace, cx| {
+ let id = NotificationId::unique::();
+
+ workspace.dismiss_notification(&id, cx);
+ workspace.show_notification(id, cx, |cx| {
+ let workspace = cx.entity().downgrade();
+ cx.new(|cx| CollabNotificationToast {
+ actor,
+ text,
+ notification: needs_response.then(|| notification),
+ workspace,
+ collab_panel: collab_panel.clone(),
+ focus_handle: cx.focus_handle(),
+ })
+ })
+ })
+ .ok();
+ }
+
+ fn mark_notification_read(&mut self, notification_id: u64, cx: &mut Context) {
+ let client = self.client.clone();
+ self.mark_as_read_tasks
+ .entry(notification_id)
+ .or_insert_with(|| {
+ cx.spawn(async move |this, cx| {
+ let request_result = client
+ .request(proto::MarkNotificationRead { notification_id })
+ .await;
+
+ this.update(cx, |this, _| {
+ this.mark_as_read_tasks.remove(¬ification_id);
+ })?;
+
+ request_result?;
+ Ok(())
+ })
+ });
+ }
+
+ fn mark_contact_request_accepted_notifications_read(
+ &mut self,
+ contact_user_id: u64,
+ cx: &mut Context,
+ ) {
+ let notification_ids = self.notification_store.read_with(cx, |store, _| {
+ (0..store.notification_count())
+ .filter_map(|index| {
+ let entry = store.notification_at(index)?;
+ if entry.is_read {
+ return None;
+ }
+
+ match &entry.notification {
+ Notification::ContactRequestAccepted { responder_id }
+ if *responder_id == contact_user_id =>
+ {
+ Some(entry.id)
+ }
+ _ => None,
+ }
+ })
+ .collect::>()
+ });
+
+ for notification_id in notification_ids {
+ self.mark_notification_read(notification_id, cx);
+ }
+ }
+
+ fn remove_toast(&mut self, notification_id: u64, cx: &mut Context) {
+ if let Some((current_id, _)) = &self.current_notification_toast {
+ if *current_id == notification_id {
+ self.dismiss_toast(cx);
+ }
+ }
+ }
+
+ fn dismiss_toast(&mut self, cx: &mut Context) {
+ self.current_notification_toast.take();
+ self.workspace
+ .update(cx, |workspace, cx| {
+ let id = NotificationId::unique::();
+ workspace.dismiss_notification(&id, cx)
+ })
+ .ok();
+ }
}
fn render_tree_branch(
@@ -3516,12 +3707,38 @@ impl Panel for CollabPanel {
CollaborationPanelSettings::get_global(cx).default_width
}
+ fn set_active(&mut self, active: bool, _window: &mut Window, cx: &mut Context) {
+ if active && self.current_notification_toast.is_some() {
+ self.current_notification_toast.take();
+ let workspace = self.workspace.clone();
+ cx.defer(move |cx| {
+ workspace
+ .update(cx, |workspace, cx| {
+ let id = NotificationId::unique::();
+ workspace.dismiss_notification(&id, cx)
+ })
+ .ok();
+ });
+ }
+ }
+
fn icon(&self, _window: &Window, cx: &App) -> Option {
CollaborationPanelSettings::get_global(cx)
.button
.then_some(ui::IconName::UserGroup)
}
+ fn icon_label(&self, _window: &Window, cx: &App) -> Option {
+ let user_store = self.user_store.read(cx);
+ let count = user_store.incoming_contact_requests().len()
+ + self.channel_store.read(cx).channel_invitations().len();
+ if count == 0 {
+ None
+ } else {
+ Some(count.to_string())
+ }
+ }
+
fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
Some("Collab Panel")
}
@@ -3702,6 +3919,101 @@ impl Render for JoinChannelTooltip {
}
}
+pub struct CollabNotificationToast {
+ actor: Option>,
+ text: String,
+ notification: Option,
+ workspace: WeakEntity,
+ collab_panel: WeakEntity,
+ focus_handle: FocusHandle,
+}
+
+impl Focusable for CollabNotificationToast {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl WorkspaceNotification for CollabNotificationToast {}
+
+impl CollabNotificationToast {
+ fn focus_collab_panel(&self, window: &mut Window, cx: &mut Context) {
+ let workspace = self.workspace.clone();
+ window.defer(cx, move |window, cx| {
+ workspace
+ .update(cx, |workspace, cx| {
+ workspace.focus_panel::(window, cx)
+ })
+ .ok();
+ })
+ }
+
+ fn respond(&mut self, accept: bool, window: &mut Window, cx: &mut Context) {
+ if let Some(notification) = self.notification.take() {
+ self.collab_panel
+ .update(cx, |collab_panel, cx| match notification {
+ Notification::ContactRequest { sender_id } => {
+ collab_panel.respond_to_contact_request(sender_id, accept, window, cx);
+ }
+ Notification::ChannelInvitation { channel_id, .. } => {
+ collab_panel.respond_to_channel_invite(ChannelId(channel_id), accept, cx);
+ }
+ Notification::ContactRequestAccepted { .. } => {}
+ })
+ .ok();
+ }
+ cx.emit(DismissEvent);
+ }
+}
+
+impl Render for CollabNotificationToast {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let needs_response = self.notification.is_some();
+
+ let accept_button = if needs_response {
+ Button::new("accept", "Accept").on_click(cx.listener(|this, _, window, cx| {
+ this.respond(true, window, cx);
+ cx.stop_propagation();
+ }))
+ } else {
+ Button::new("dismiss", "Dismiss").on_click(cx.listener(|_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }))
+ };
+
+ let decline_button = if needs_response {
+ Button::new("decline", "Decline").on_click(cx.listener(|this, _, window, cx| {
+ this.respond(false, window, cx);
+ cx.stop_propagation();
+ }))
+ } else {
+ Button::new("close", "Close").on_click(cx.listener(|_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }))
+ };
+
+ let avatar_uri = self
+ .actor
+ .as_ref()
+ .map(|user| user.avatar_uri.clone())
+ .unwrap_or_default();
+
+ div()
+ .id("collab_notification_toast")
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.focus_collab_panel(window, cx);
+ cx.emit(DismissEvent);
+ }))
+ .child(
+ CollabNotification::new(avatar_uri, accept_button, decline_button)
+ .child(Label::new(self.text.clone())),
+ )
+ }
+}
+
+impl EventEmitter for CollabNotificationToast {}
+impl EventEmitter for CollabNotificationToast {}
+
#[cfg(any(test, feature = "test-support"))]
impl CollabPanel {
pub fn entries_as_strings(&self) -> Vec {
diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs
index 107b2ffa7f625d98dd9c54bb6bbf75df8b72d020..f9c463c0690343a3b4b1b9a048134265326a9f50 100644
--- a/crates/collab_ui/src/collab_ui.rs
+++ b/crates/collab_ui/src/collab_ui.rs
@@ -1,7 +1,6 @@
mod call_stats_modal;
pub mod channel_view;
pub mod collab_panel;
-pub mod notification_panel;
pub mod notifications;
mod panel_settings;
@@ -12,7 +11,7 @@ use gpui::{
App, Pixels, PlatformDisplay, Size, WindowBackgroundAppearance, WindowBounds,
WindowDecorations, WindowKind, WindowOptions, point,
};
-pub use panel_settings::{CollaborationPanelSettings, NotificationPanelSettings};
+pub use panel_settings::CollaborationPanelSettings;
use release_channel::ReleaseChannel;
use ui::px;
use workspace::AppState;
@@ -22,7 +21,6 @@ pub fn init(app_state: &Arc, cx: &mut App) {
call_stats_modal::init(cx);
channel_view::init(cx);
collab_panel::init(cx);
- notification_panel::init(cx);
notifications::init(app_state, cx);
title_bar::init(cx);
}
diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs
deleted file mode 100644
index d7fef4873c687ab23a25b3144ba902cf4c42c137..0000000000000000000000000000000000000000
--- a/crates/collab_ui/src/notification_panel.rs
+++ /dev/null
@@ -1,727 +0,0 @@
-use crate::NotificationPanelSettings;
-use anyhow::Result;
-use channel::ChannelStore;
-use client::{ChannelId, Client, Notification, User, UserStore};
-use collections::HashMap;
-use futures::StreamExt;
-use gpui::{
- AnyElement, App, AsyncWindowContext, ClickEvent, Context, DismissEvent, Element, Entity,
- EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment,
- ListScrollEvent, ListState, ParentElement, Render, StatefulInteractiveElement, Styled, Task,
- WeakEntity, Window, actions, div, img, list, px,
-};
-use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
-use project::Fs;
-use rpc::proto;
-
-use settings::{Settings, SettingsStore};
-use std::{sync::Arc, time::Duration};
-use time::{OffsetDateTime, UtcOffset};
-use ui::{
- Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip, h_flex, prelude::*, v_flex,
-};
-use util::ResultExt;
-use workspace::notifications::{
- Notification as WorkspaceNotification, NotificationId, SuppressEvent,
-};
-use workspace::{
- Workspace,
- dock::{DockPosition, Panel, PanelEvent},
-};
-
-const LOADING_THRESHOLD: usize = 30;
-const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
-const TOAST_DURATION: Duration = Duration::from_secs(5);
-const NOTIFICATION_PANEL_KEY: &str = "NotificationPanel";
-
-pub struct NotificationPanel {
- client: Arc,
- user_store: Entity,
- channel_store: Entity,
- notification_store: Entity,
- fs: Arc,
- active: bool,
- notification_list: ListState,
- subscriptions: Vec,
- workspace: WeakEntity,
- current_notification_toast: Option<(u64, Task<()>)>,
- local_timezone: UtcOffset,
- focus_handle: FocusHandle,
- mark_as_read_tasks: HashMap>>,
- unseen_notifications: Vec,
-}
-
-#[derive(Debug)]
-pub enum Event {
- DockPositionChanged,
- Focus,
- Dismissed,
-}
-
-pub struct NotificationPresenter {
- pub actor: Option>,
- pub text: String,
- pub icon: &'static str,
- pub needs_response: bool,
-}
-
-actions!(
- notification_panel,
- [
- /// Toggles the notification panel.
- Toggle,
- /// Toggles focus on the notification panel.
- ToggleFocus
- ]
-);
-
-pub fn init(cx: &mut App) {
- cx.observe_new(|workspace: &mut Workspace, _, _| {
- workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
- workspace.toggle_panel_focus::(window, cx);
- });
- workspace.register_action(|workspace, _: &Toggle, window, cx| {
- if !workspace.toggle_panel_focus::(window, cx) {
- workspace.close_panel::(window, cx);
- }
- });
- })
- .detach();
-}
-
-impl NotificationPanel {
- pub fn new(
- workspace: &mut Workspace,
- window: &mut Window,
- cx: &mut Context,
- ) -> Entity {
- let fs = workspace.app_state().fs.clone();
- let client = workspace.app_state().client.clone();
- let user_store = workspace.app_state().user_store.clone();
- let workspace_handle = workspace.weak_handle();
-
- cx.new(|cx| {
- let mut status = client.status();
- cx.spawn_in(window, async move |this, cx| {
- while (status.next().await).is_some() {
- if this
- .update(cx, |_: &mut Self, cx| {
- cx.notify();
- })
- .is_err()
- {
- break;
- }
- }
- })
- .detach();
-
- let notification_list = ListState::new(0, ListAlignment::Top, px(1000.));
- notification_list.set_scroll_handler(cx.listener(
- |this, event: &ListScrollEvent, _, cx| {
- if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD
- && let Some(task) = this
- .notification_store
- .update(cx, |store, cx| store.load_more_notifications(false, cx))
- {
- task.detach();
- }
- },
- ));
-
- let local_offset = chrono::Local::now().offset().local_minus_utc();
- let mut this = Self {
- fs,
- client,
- user_store,
- local_timezone: UtcOffset::from_whole_seconds(local_offset).unwrap(),
- channel_store: ChannelStore::global(cx),
- notification_store: NotificationStore::global(cx),
- notification_list,
- workspace: workspace_handle,
- focus_handle: cx.focus_handle(),
- subscriptions: Default::default(),
- current_notification_toast: None,
- active: false,
- mark_as_read_tasks: Default::default(),
- unseen_notifications: Default::default(),
- };
-
- let mut old_dock_position = this.position(window, cx);
- this.subscriptions.extend([
- cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
- cx.subscribe_in(
- &this.notification_store,
- window,
- Self::on_notification_event,
- ),
- cx.observe_global_in::(
- window,
- move |this: &mut Self, window, cx| {
- let new_dock_position = this.position(window, cx);
- if new_dock_position != old_dock_position {
- old_dock_position = new_dock_position;
- cx.emit(Event::DockPositionChanged);
- }
- cx.notify();
- },
- ),
- ]);
- this
- })
- }
-
- pub fn load(
- workspace: WeakEntity,
- cx: AsyncWindowContext,
- ) -> Task>> {
- cx.spawn(async move |cx| {
- workspace.update_in(cx, |workspace, window, cx| Self::new(workspace, window, cx))
- })
- }
-
- fn render_notification(
- &mut self,
- ix: usize,
- window: &mut Window,
- cx: &mut Context,
- ) -> Option {
- let entry = self.notification_store.read(cx).notification_at(ix)?;
- let notification_id = entry.id;
- let now = OffsetDateTime::now_utc();
- let timestamp = entry.timestamp;
- let NotificationPresenter {
- actor,
- text,
- needs_response,
- ..
- } = self.present_notification(entry, cx)?;
-
- let response = entry.response;
- let notification = entry.notification.clone();
-
- if self.active && !entry.is_read {
- self.did_render_notification(notification_id, ¬ification, window, cx);
- }
-
- let relative_timestamp = time_format::format_localized_timestamp(
- timestamp,
- now,
- self.local_timezone,
- time_format::TimestampFormat::Relative,
- );
-
- let absolute_timestamp = time_format::format_localized_timestamp(
- timestamp,
- now,
- self.local_timezone,
- time_format::TimestampFormat::Absolute,
- );
-
- Some(
- div()
- .id(ix)
- .flex()
- .flex_row()
- .size_full()
- .px_2()
- .py_1()
- .gap_2()
- .hover(|style| style.bg(cx.theme().colors().element_hover))
- .children(actor.map(|actor| {
- img(actor.avatar_uri.clone())
- .flex_none()
- .w_8()
- .h_8()
- .rounded_full()
- }))
- .child(
- v_flex()
- .gap_1()
- .size_full()
- .overflow_hidden()
- .child(Label::new(text))
- .child(
- h_flex()
- .child(
- div()
- .id("notification_timestamp")
- .hover(|style| {
- style
- .bg(cx.theme().colors().element_selected)
- .rounded_sm()
- })
- .child(Label::new(relative_timestamp).color(Color::Muted))
- .tooltip(move |_, cx| {
- Tooltip::simple(absolute_timestamp.clone(), cx)
- }),
- )
- .children(if let Some(is_accepted) = response {
- Some(div().flex().flex_grow().justify_end().child(Label::new(
- if is_accepted {
- "You accepted"
- } else {
- "You declined"
- },
- )))
- } else if needs_response {
- Some(
- h_flex()
- .flex_grow()
- .justify_end()
- .child(Button::new("decline", "Decline").on_click({
- let notification = notification.clone();
- let entity = cx.entity();
- move |_, _, cx| {
- entity.update(cx, |this, cx| {
- this.respond_to_notification(
- notification.clone(),
- false,
- cx,
- )
- });
- }
- }))
- .child(Button::new("accept", "Accept").on_click({
- let notification = notification.clone();
- let entity = cx.entity();
- move |_, _, cx| {
- entity.update(cx, |this, cx| {
- this.respond_to_notification(
- notification.clone(),
- true,
- cx,
- )
- });
- }
- })),
- )
- } else {
- None
- }),
- ),
- )
- .into_any(),
- )
- }
-
- fn present_notification(
- &self,
- entry: &NotificationEntry,
- cx: &App,
- ) -> Option {
- let user_store = self.user_store.read(cx);
- let channel_store = self.channel_store.read(cx);
- match entry.notification {
- Notification::ContactRequest { sender_id } => {
- let requester = user_store.get_cached_user(sender_id)?;
- Some(NotificationPresenter {
- icon: "icons/plus.svg",
- text: format!("{} wants to add you as a contact", requester.github_login),
- needs_response: user_store.has_incoming_contact_request(requester.id),
- actor: Some(requester),
- })
- }
- Notification::ContactRequestAccepted { responder_id } => {
- let responder = user_store.get_cached_user(responder_id)?;
- Some(NotificationPresenter {
- icon: "icons/plus.svg",
- text: format!("{} accepted your contact invite", responder.github_login),
- needs_response: false,
- actor: Some(responder),
- })
- }
- Notification::ChannelInvitation {
- ref channel_name,
- channel_id,
- inviter_id,
- } => {
- let inviter = user_store.get_cached_user(inviter_id)?;
- Some(NotificationPresenter {
- icon: "icons/hash.svg",
- text: format!(
- "{} invited you to join the #{channel_name} channel",
- inviter.github_login
- ),
- needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)),
- actor: Some(inviter),
- })
- }
- }
- }
-
- fn did_render_notification(
- &mut self,
- notification_id: u64,
- notification: &Notification,
- window: &mut Window,
- cx: &mut Context,
- ) {
- let should_mark_as_read = match notification {
- Notification::ContactRequestAccepted { .. } => true,
- Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. } => false,
- };
-
- if should_mark_as_read {
- self.mark_as_read_tasks
- .entry(notification_id)
- .or_insert_with(|| {
- let client = self.client.clone();
- cx.spawn_in(window, async move |this, cx| {
- cx.background_executor().timer(MARK_AS_READ_DELAY).await;
- client
- .request(proto::MarkNotificationRead { notification_id })
- .await?;
- this.update(cx, |this, _| {
- this.mark_as_read_tasks.remove(¬ification_id);
- })?;
- Ok(())
- })
- });
- }
- }
-
- fn on_notification_event(
- &mut self,
- _: &Entity,
- event: &NotificationEvent,
- window: &mut Window,
- cx: &mut Context,
- ) {
- match event {
- NotificationEvent::NewNotification { entry } => {
- self.unseen_notifications.push(entry.clone());
- self.add_toast(entry, window, cx);
- }
- NotificationEvent::NotificationRemoved { entry }
- | NotificationEvent::NotificationRead { entry } => {
- self.unseen_notifications.retain(|n| n.id != entry.id);
- self.remove_toast(entry.id, cx);
- }
- NotificationEvent::NotificationsUpdated {
- old_range,
- new_count,
- } => {
- self.notification_list.splice(old_range.clone(), *new_count);
- cx.notify();
- }
- }
- }
-
- fn add_toast(
- &mut self,
- entry: &NotificationEntry,
- window: &mut Window,
- cx: &mut Context,
- ) {
- let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
- else {
- return;
- };
-
- let notification_id = entry.id;
- self.current_notification_toast = Some((
- notification_id,
- cx.spawn_in(window, async move |this, cx| {
- cx.background_executor().timer(TOAST_DURATION).await;
- this.update(cx, |this, cx| this.remove_toast(notification_id, cx))
- .ok();
- }),
- ));
-
- self.workspace
- .update(cx, |workspace, cx| {
- let id = NotificationId::unique::();
-
- workspace.dismiss_notification(&id, cx);
- workspace.show_notification(id, cx, |cx| {
- let workspace = cx.entity().downgrade();
- cx.new(|cx| NotificationToast {
- actor,
- text,
- workspace,
- focus_handle: cx.focus_handle(),
- })
- })
- })
- .ok();
- }
-
- fn remove_toast(&mut self, notification_id: u64, cx: &mut Context) {
- if let Some((current_id, _)) = &self.current_notification_toast
- && *current_id == notification_id
- {
- self.current_notification_toast.take();
- self.workspace
- .update(cx, |workspace, cx| {
- let id = NotificationId::unique::();
- workspace.dismiss_notification(&id, cx)
- })
- .ok();
- }
- }
-
- fn respond_to_notification(
- &mut self,
- notification: Notification,
- response: bool,
-
- cx: &mut Context,
- ) {
- self.notification_store.update(cx, |store, cx| {
- store.respond_to_notification(notification, response, cx);
- });
- }
-}
-
-impl Render for NotificationPanel {
- fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
- v_flex()
- .size_full()
- .child(
- h_flex()
- .justify_between()
- .px_2()
- .py_1()
- // Match the height of the tab bar so they line up.
- .h(Tab::container_height(cx))
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .child(Label::new("Notifications"))
- .child(Icon::new(IconName::Envelope)),
- )
- .map(|this| {
- if !self.client.status().borrow().is_connected() {
- this.child(
- v_flex()
- .gap_2()
- .p_4()
- .child(
- Button::new("connect_prompt_button", "Connect")
- .start_icon(Icon::new(IconName::Github).color(Color::Muted))
- .style(ButtonStyle::Filled)
- .full_width()
- .on_click({
- let client = self.client.clone();
- move |_, window, cx| {
- let client = client.clone();
- window
- .spawn(cx, async move |cx| {
- match client.connect(true, cx).await {
- util::ConnectionResult::Timeout => {
- log::error!("Connection timeout");
- }
- util::ConnectionResult::ConnectionReset => {
- log::error!("Connection reset");
- }
- util::ConnectionResult::Result(r) => {
- r.log_err();
- }
- }
- })
- .detach()
- }
- }),
- )
- .child(
- div().flex().w_full().items_center().child(
- Label::new("Connect to view notifications.")
- .color(Color::Muted)
- .size(LabelSize::Small),
- ),
- ),
- )
- } else if self.notification_list.item_count() == 0 {
- this.child(
- v_flex().p_4().child(
- div().flex().w_full().items_center().child(
- Label::new("You have no notifications.")
- .color(Color::Muted)
- .size(LabelSize::Small),
- ),
- ),
- )
- } else {
- this.child(
- list(
- self.notification_list.clone(),
- cx.processor(|this, ix, window, cx| {
- this.render_notification(ix, window, cx)
- .unwrap_or_else(|| div().into_any())
- }),
- )
- .size_full(),
- )
- }
- })
- }
-}
-
-impl Focusable for NotificationPanel {
- fn focus_handle(&self, _: &App) -> FocusHandle {
- self.focus_handle.clone()
- }
-}
-
-impl EventEmitter for NotificationPanel {}
-impl EventEmitter for NotificationPanel {}
-
-impl Panel for NotificationPanel {
- fn persistent_name() -> &'static str {
- "NotificationPanel"
- }
-
- fn panel_key() -> &'static str {
- NOTIFICATION_PANEL_KEY
- }
-
- fn position(&self, _: &Window, cx: &App) -> DockPosition {
- NotificationPanelSettings::get_global(cx).dock
- }
-
- fn position_is_valid(&self, position: DockPosition) -> bool {
- matches!(position, DockPosition::Left | DockPosition::Right)
- }
-
- fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) {
- settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
- settings.notification_panel.get_or_insert_default().dock = Some(position.into())
- });
- }
-
- fn default_size(&self, _: &Window, cx: &App) -> Pixels {
- NotificationPanelSettings::get_global(cx).default_width
- }
-
- fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context) {
- self.active = active;
-
- if self.active {
- self.unseen_notifications = Vec::new();
- cx.notify();
- }
-
- if self.notification_store.read(cx).notification_count() == 0 {
- cx.emit(Event::Dismissed);
- }
- }
-
- fn icon(&self, _: &Window, cx: &App) -> Option {
- let show_button = NotificationPanelSettings::get_global(cx).button;
- if !show_button {
- return None;
- }
-
- if self.unseen_notifications.is_empty() {
- return Some(IconName::Bell);
- }
-
- Some(IconName::BellDot)
- }
-
- fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
- Some("Notification Panel")
- }
-
- fn icon_label(&self, _window: &Window, cx: &App) -> Option {
- if !NotificationPanelSettings::get_global(cx).show_count_badge {
- return None;
- }
- let count = self.notification_store.read(cx).unread_notification_count();
- if count == 0 {
- None
- } else {
- Some(count.to_string())
- }
- }
-
- fn toggle_action(&self) -> Box {
- Box::new(ToggleFocus)
- }
-
- fn activation_priority(&self) -> u32 {
- 4
- }
-}
-
-pub struct NotificationToast {
- actor: Option>,
- text: String,
- workspace: WeakEntity,
- focus_handle: FocusHandle,
-}
-
-impl Focusable for NotificationToast {
- fn focus_handle(&self, _cx: &App) -> FocusHandle {
- self.focus_handle.clone()
- }
-}
-
-impl WorkspaceNotification for NotificationToast {}
-
-impl NotificationToast {
- fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context) {
- let workspace = self.workspace.clone();
- window.defer(cx, move |window, cx| {
- workspace
- .update(cx, |workspace, cx| {
- workspace.focus_panel::(window, cx)
- })
- .ok();
- })
- }
-}
-
-impl Render for NotificationToast {
- fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
- let user = self.actor.clone();
-
- let suppress = window.modifiers().shift;
- let (close_id, close_icon) = if suppress {
- ("suppress", IconName::Minimize)
- } else {
- ("close", IconName::Close)
- };
-
- h_flex()
- .id("notification_panel_toast")
- .elevation_3(cx)
- .p_2()
- .justify_between()
- .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
- .child(Label::new(self.text.clone()))
- .on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify()))
- .child(
- IconButton::new(close_id, close_icon)
- .tooltip(move |_window, cx| {
- if suppress {
- Tooltip::for_action(
- "Suppress.\nClose with click.",
- &workspace::SuppressNotification,
- cx,
- )
- } else {
- Tooltip::for_action(
- "Close.\nSuppress with shift-click",
- &menu::Cancel,
- cx,
- )
- }
- })
- .on_click(cx.listener(move |_, _: &ClickEvent, _, cx| {
- if suppress {
- cx.emit(SuppressEvent);
- } else {
- cx.emit(DismissEvent);
- }
- })),
- )
- .on_click(cx.listener(|this, _, window, cx| {
- this.focus_notification_panel(window, cx);
- cx.emit(DismissEvent);
- }))
- }
-}
-
-impl EventEmitter for NotificationToast {}
-impl EventEmitter for NotificationToast {}
diff --git a/crates/collab_ui/src/panel_settings.rs b/crates/collab_ui/src/panel_settings.rs
index 938d33159e9adb7a9e63ceb73219b70724efee17..3d6de1015a3751751c13c8ccb6d4c5639755be20 100644
--- a/crates/collab_ui/src/panel_settings.rs
+++ b/crates/collab_ui/src/panel_settings.rs
@@ -10,14 +10,6 @@ pub struct CollaborationPanelSettings {
pub default_width: Pixels,
}
-#[derive(Debug, RegisterSetting)]
-pub struct NotificationPanelSettings {
- pub button: bool,
- pub dock: DockPosition,
- pub default_width: Pixels,
- pub show_count_badge: bool,
-}
-
impl Settings for CollaborationPanelSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let panel = content.collaboration_panel.as_ref().unwrap();
@@ -29,15 +21,3 @@ impl Settings for CollaborationPanelSettings {
}
}
}
-
-impl Settings for NotificationPanelSettings {
- fn from_settings(content: &settings::SettingsContent) -> Self {
- let panel = content.notification_panel.as_ref().unwrap();
- return Self {
- button: panel.button.unwrap(),
- dock: panel.dock.unwrap().into(),
- default_width: panel.default_width.map(px).unwrap(),
- show_count_badge: panel.show_count_badge.unwrap(),
- };
- }
-}
diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml
index eabb1641fd4fbec7b2f8ef0ba399a8fe9600dfa3..87ad4e42e7826cdda4fc6a8c31a27afe888830f0 100644
--- a/crates/edit_prediction/Cargo.toml
+++ b/crates/edit_prediction/Cargo.toml
@@ -21,8 +21,9 @@ heapless.workspace = true
buffer_diff.workspace = true
client.workspace = true
clock.workspace = true
+cloud_api_client.workspace = true
cloud_api_types.workspace = true
-cloud_llm_client.workspace = true
+cloud_llm_client = { workspace = true, features = ["predict-edits"] }
collections.workspace = true
copilot.workspace = true
copilot_ui.workspace = true
diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs
index 280427df006b510e1854ffb40cd7f995fcd9fdc6..2d90e13fb9b45aedd354f753502cd4e616ae3bcd 100644
--- a/crates/edit_prediction/src/edit_prediction.rs
+++ b/crates/edit_prediction/src/edit_prediction.rs
@@ -1,5 +1,6 @@
use anyhow::Result;
use client::{Client, EditPredictionUsage, NeedsLlmTokenRefresh, UserStore, global_llm_token};
+use cloud_api_client::LlmApiToken;
use cloud_api_types::{OrganizationId, SubmitEditPredictionFeedbackBody};
use cloud_llm_client::predict_edits_v3::{
PredictEditsV3Request, PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse,
@@ -31,7 +32,6 @@ use heapless::Vec as ArrayVec;
use language::language_settings::all_language_settings;
use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToOffset, ToPoint};
use language::{BufferSnapshot, OffsetRangeExt};
-use language_model::LlmApiToken;
use project::{DisableAiSettings, Project, ProjectPath, WorktreeId};
use release_channel::AppVersion;
use semver::Version;
diff --git a/crates/edit_prediction/src/ollama.rs b/crates/edit_prediction/src/ollama.rs
index 0250ec44a46cf081c6badc6fa11a9c34ebb65c4a..0ae90dd9f6eca4bfe9f87950a5a66916d8894df4 100644
--- a/crates/edit_prediction/src/ollama.rs
+++ b/crates/edit_prediction/src/ollama.rs
@@ -57,7 +57,7 @@ pub fn fetch_models(cx: &mut App) -> Vec {
let mut models: Vec = provider
.provided_models(cx)
.into_iter()
- .map(|model| SharedString::from(model.id().0.to_string()))
+ .map(|model| model.id().0)
.collect();
models.sort();
models
diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
index c5e97fd87eaad9b98aeb9b946a9a69b1c1071db2..1a574e9389715ce888f8b8c5ec8be921ceab4a38 100644
--- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
+++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs
@@ -177,7 +177,7 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
BufferEditPrediction::Local { prediction } => prediction,
BufferEditPrediction::Jump { prediction } => {
return Some(edit_prediction_types::EditPrediction::Jump {
- id: Some(prediction.id.to_string().into()),
+ id: Some(prediction.id.0.clone()),
snapshot: prediction.snapshot.clone(),
target: prediction.edits.first().unwrap().0.start,
});
@@ -228,7 +228,7 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
}
Some(edit_prediction_types::EditPrediction::Local {
- id: Some(prediction.id.to_string().into()),
+ id: Some(prediction.id.0.clone()),
edits: edits[edit_start_ix..edit_end_ix].to_vec(),
cursor_position: prediction.cursor_position,
edit_preview: Some(prediction.edit_preview.clone()),
diff --git a/crates/edit_prediction_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml
index 323ee3de41902b2140f95da22b0e37fb98d31fd5..a999fed2baf990273f0801bac15573b3aed0cc78 100644
--- a/crates/edit_prediction_cli/Cargo.toml
+++ b/crates/edit_prediction_cli/Cargo.toml
@@ -22,7 +22,7 @@ http_client.workspace = true
chrono.workspace = true
clap = "4"
client.workspace = true
-cloud_llm_client.workspace= true
+cloud_llm_client = { workspace = true, features = ["predict-edits"] }
collections.workspace = true
db.workspace = true
debug_adapter_extension.workspace = true
diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs
index f95f1030276015af4825119fc98ac68b876d0e5f..7cb8040e282a47d27cf5d7b33e5453295b4f645f 100644
--- a/crates/editor/src/display_map.rs
+++ b/crates/editor/src/display_map.rs
@@ -98,7 +98,7 @@ use gpui::{
WeakEntity,
};
use language::{
- Point, Subscription as BufferSubscription,
+ LanguageAwareStyling, Point, Subscription as BufferSubscription,
language_settings::{AllLanguageSettings, LanguageSettings},
};
@@ -1769,7 +1769,10 @@ impl DisplaySnapshot {
self.block_snapshot
.chunks(
BlockRow(display_row.0)..BlockRow(self.max_point().row().next_row().0),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
self.masked,
Highlights::default(),
)
@@ -1783,7 +1786,10 @@ impl DisplaySnapshot {
self.block_snapshot
.chunks(
BlockRow(row)..BlockRow(row + 1),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
self.masked,
Highlights::default(),
)
@@ -1798,7 +1804,7 @@ impl DisplaySnapshot {
pub fn chunks(
&self,
display_rows: Range,
- language_aware: bool,
+ language_aware: LanguageAwareStyling,
highlight_styles: HighlightStyles,
) -> DisplayChunks<'_> {
self.block_snapshot.chunks(
@@ -1818,7 +1824,7 @@ impl DisplaySnapshot {
pub fn highlighted_chunks<'a>(
&'a self,
display_rows: Range,
- language_aware: bool,
+ language_aware: LanguageAwareStyling,
editor_style: &'a EditorStyle,
) -> impl Iterator
- > {
self.chunks(
@@ -1910,7 +1916,10 @@ impl DisplaySnapshot {
let chunks = custom_highlights::CustomHighlightsChunks::new(
multibuffer_range,
- true,
+ LanguageAwareStyling {
+ tree_sitter: true,
+ diagnostics: true,
+ },
None,
Some(&self.semantic_token_highlights),
multibuffer,
@@ -1961,7 +1970,14 @@ impl DisplaySnapshot {
let mut line = String::new();
let range = display_row..display_row.next_row();
- for chunk in self.highlighted_chunks(range, false, editor_style) {
+ for chunk in self.highlighted_chunks(
+ range,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
+ editor_style,
+ ) {
line.push_str(chunk.text);
let text_style = if let Some(style) = chunk.style {
@@ -3388,7 +3404,14 @@ pub mod tests {
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
let mut chunks = Vec::<(String, Option, Rgba)>::new();
- for chunk in snapshot.chunks(DisplayRow(0)..DisplayRow(5), true, Default::default()) {
+ for chunk in snapshot.chunks(
+ DisplayRow(0)..DisplayRow(5),
+ LanguageAwareStyling {
+ tree_sitter: true,
+ diagnostics: true,
+ },
+ Default::default(),
+ ) {
let color = chunk
.highlight_style
.and_then(|style| style.color)
@@ -3940,7 +3963,14 @@ pub mod tests {
) -> Vec<(String, Option, Option)> {
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
let mut chunks: Vec<(String, Option, Option)> = Vec::new();
- for chunk in snapshot.chunks(rows, true, HighlightStyles::default()) {
+ for chunk in snapshot.chunks(
+ rows,
+ LanguageAwareStyling {
+ tree_sitter: true,
+ diagnostics: true,
+ },
+ HighlightStyles::default(),
+ ) {
let syntax_color = chunk
.syntax_highlight_id
.and_then(|id| theme.get(id)?.color);
diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs
index 67318e3300e73085fe40c2e22edfcd06778902c8..17fa7e3de4a361f6728664e76368583788053cfd 100644
--- a/crates/editor/src/display_map/block_map.rs
+++ b/crates/editor/src/display_map/block_map.rs
@@ -9,7 +9,7 @@ use crate::{
};
use collections::{Bound, HashMap, HashSet};
use gpui::{AnyElement, App, EntityId, Pixels, Window};
-use language::{Patch, Point};
+use language::{LanguageAwareStyling, Patch, Point};
use multi_buffer::{
Anchor, ExcerptBoundaryInfo, MultiBuffer, MultiBufferOffset, MultiBufferPoint, MultiBufferRow,
MultiBufferSnapshot, RowInfo, ToOffset, ToPoint as _,
@@ -2140,7 +2140,10 @@ impl BlockSnapshot {
pub fn text(&self) -> String {
self.chunks(
BlockRow(0)..self.transforms.summary().output_rows,
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
false,
Highlights::default(),
)
@@ -2152,7 +2155,7 @@ impl BlockSnapshot {
pub(crate) fn chunks<'a>(
&'a self,
rows: Range,
- language_aware: bool,
+ language_aware: LanguageAwareStyling,
masked: bool,
highlights: Highlights<'a>,
) -> BlockChunks<'a> {
@@ -4300,7 +4303,10 @@ mod tests {
let actual_text = blocks_snapshot
.chunks(
BlockRow(start_row as u32)..BlockRow(end_row as u32),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
false,
Highlights::default(),
)
diff --git a/crates/editor/src/display_map/custom_highlights.rs b/crates/editor/src/display_map/custom_highlights.rs
index 39eabef2f9627b8088dc826ec64379bf76a6c9fa..6e93e562172decb0843da35c7f55fafd92ed21cc 100644
--- a/crates/editor/src/display_map/custom_highlights.rs
+++ b/crates/editor/src/display_map/custom_highlights.rs
@@ -1,6 +1,6 @@
use collections::BTreeMap;
use gpui::HighlightStyle;
-use language::Chunk;
+use language::{Chunk, LanguageAwareStyling};
use multi_buffer::{MultiBufferChunks, MultiBufferOffset, MultiBufferSnapshot, ToOffset as _};
use std::{
cmp,
@@ -34,7 +34,7 @@ impl<'a> CustomHighlightsChunks<'a> {
#[ztracing::instrument(skip_all)]
pub fn new(
range: Range,
- language_aware: bool,
+ language_aware: LanguageAwareStyling,
text_highlights: Option<&'a TextHighlights>,
semantic_token_highlights: Option<&'a SemanticTokensHighlights>,
multibuffer_snapshot: &'a MultiBufferSnapshot,
@@ -308,7 +308,10 @@ mod tests {
// Get all chunks and verify their bitmaps
let chunks = CustomHighlightsChunks::new(
MultiBufferOffset(0)..buffer_snapshot.len(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
None,
None,
&buffer_snapshot,
diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs
index 1554bb96dab0e2f76a17df1396bd945f332af208..4c6c04b86cc3e2fb9ef10be58c14faae623dc65f 100644
--- a/crates/editor/src/display_map/fold_map.rs
+++ b/crates/editor/src/display_map/fold_map.rs
@@ -5,7 +5,7 @@ use super::{
inlay_map::{InlayBufferRows, InlayChunks, InlayEdit, InlayOffset, InlayPoint, InlaySnapshot},
};
use gpui::{AnyElement, App, ElementId, HighlightStyle, Pixels, SharedString, Stateful, Window};
-use language::{Edit, HighlightId, Point};
+use language::{Edit, HighlightId, LanguageAwareStyling, Point};
use multi_buffer::{
Anchor, AnchorRangeExt, MBTextSummary, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot,
RowInfo, ToOffset,
@@ -707,7 +707,10 @@ impl FoldSnapshot {
pub fn text(&self) -> String {
self.chunks(
FoldOffset(MultiBufferOffset(0))..self.len(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
)
.map(|c| c.text)
@@ -909,7 +912,7 @@ impl FoldSnapshot {
pub(crate) fn chunks<'a>(
&'a self,
range: Range,
- language_aware: bool,
+ language_aware: LanguageAwareStyling,
highlights: Highlights<'a>,
) -> FoldChunks<'a> {
let mut transform_cursor = self
@@ -954,7 +957,10 @@ impl FoldSnapshot {
pub fn chars_at(&self, start: FoldPoint) -> impl '_ + Iterator
- {
self.chunks(
start.to_offset(self)..self.len(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
)
.flat_map(|chunk| chunk.text.chars())
@@ -964,7 +970,10 @@ impl FoldSnapshot {
pub fn chunks_at(&self, start: FoldPoint) -> FoldChunks<'_> {
self.chunks(
start.to_offset(self)..self.len(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
)
}
@@ -2131,7 +2140,14 @@ mod tests {
let text = &expected_text[start.0.0..end.0.0];
assert_eq!(
snapshot
- .chunks(start..end, false, Highlights::default())
+ .chunks(
+ start..end,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
+ Highlights::default()
+ )
.map(|c| c.text)
.collect::(),
text,
@@ -2303,7 +2319,10 @@ mod tests {
// Get all chunks and verify their bitmaps
let chunks = snapshot.chunks(
FoldOffset(MultiBufferOffset(0))..FoldOffset(snapshot.len().0),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
);
diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs
index 47ca295ccb1a08768ce129b92d10506294a9cf78..698b58682d7ef7682094e7728f419348fd5d32d9 100644
--- a/crates/editor/src/display_map/inlay_map.rs
+++ b/crates/editor/src/display_map/inlay_map.rs
@@ -10,7 +10,7 @@ use crate::{
inlays::{Inlay, InlayContent},
};
use collections::BTreeSet;
-use language::{Chunk, Edit, Point, TextSummary};
+use language::{Chunk, Edit, LanguageAwareStyling, Point, TextSummary};
use multi_buffer::{
MBTextSummary, MultiBufferOffset, MultiBufferRow, MultiBufferRows, MultiBufferSnapshot,
RowInfo, ToOffset,
@@ -1200,7 +1200,7 @@ impl InlaySnapshot {
pub(crate) fn chunks<'a>(
&'a self,
range: Range,
- language_aware: bool,
+ language_aware: LanguageAwareStyling,
highlights: Highlights<'a>,
) -> InlayChunks<'a> {
let mut cursor = self
@@ -1234,9 +1234,16 @@ impl InlaySnapshot {
#[cfg(test)]
#[ztracing::instrument(skip_all)]
pub fn text(&self) -> String {
- self.chunks(Default::default()..self.len(), false, Highlights::default())
- .map(|chunk| chunk.chunk.text)
- .collect()
+ self.chunks(
+ Default::default()..self.len(),
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
+ Highlights::default(),
+ )
+ .map(|chunk| chunk.chunk.text)
+ .collect()
}
#[ztracing::instrument(skip_all)]
@@ -1979,7 +1986,10 @@ mod tests {
let actual_text = inlay_snapshot
.chunks(
range,
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights {
text_highlights: Some(&text_highlights),
inlay_highlights: Some(&inlay_highlights),
@@ -2158,7 +2168,10 @@ mod tests {
// Get all chunks and verify their bitmaps
let chunks = snapshot.chunks(
InlayOffset(MultiBufferOffset(0))..snapshot.len(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
);
@@ -2293,7 +2306,10 @@ mod tests {
let chunks: Vec<_> = inlay_snapshot
.chunks(
InlayOffset(MultiBufferOffset(0))..inlay_snapshot.len(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
highlights,
)
.collect();
@@ -2408,7 +2424,10 @@ mod tests {
let chunks: Vec<_> = inlay_snapshot
.chunks(
InlayOffset(MultiBufferOffset(0))..inlay_snapshot.len(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
highlights,
)
.collect();
diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs
index 187ed8614e01ddb8dcdae930fd484de9594cf63f..bb0e642df380e04fcfa9b9533f027be7171b4975 100644
--- a/crates/editor/src/display_map/tab_map.rs
+++ b/crates/editor/src/display_map/tab_map.rs
@@ -3,7 +3,7 @@ use super::{
fold_map::{self, Chunk, FoldChunks, FoldEdit, FoldPoint, FoldSnapshot},
};
-use language::Point;
+use language::{LanguageAwareStyling, Point};
use multi_buffer::MultiBufferSnapshot;
use std::{cmp, num::NonZeroU32, ops::Range};
use sum_tree::Bias;
@@ -101,7 +101,10 @@ impl TabMap {
let mut last_tab_with_changed_expansion_offset = None;
'outer: for chunk in old_snapshot.fold_snapshot.chunks(
fold_edit.old.end..old_end_row_successor_offset,
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
) {
let mut remaining_tabs = chunk.tabs;
@@ -244,7 +247,14 @@ impl TabSnapshot {
self.max_point()
};
let first_line_chars = self
- .chunks(range.start..line_end, false, Highlights::default())
+ .chunks(
+ range.start..line_end,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
+ Highlights::default(),
+ )
.flat_map(|chunk| chunk.text.chars())
.take_while(|&c| c != '\n')
.count() as u32;
@@ -254,7 +264,10 @@ impl TabSnapshot {
} else {
self.chunks(
TabPoint::new(range.end.row(), 0)..range.end,
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
)
.flat_map(|chunk| chunk.text.chars())
@@ -274,7 +287,7 @@ impl TabSnapshot {
pub(crate) fn chunks<'a>(
&'a self,
range: Range,
- language_aware: bool,
+ language_aware: LanguageAwareStyling,
highlights: Highlights<'a>,
) -> TabChunks<'a> {
let (input_start, expanded_char_column, to_next_stop) =
@@ -324,7 +337,10 @@ impl TabSnapshot {
pub fn text(&self) -> String {
self.chunks(
TabPoint::zero()..self.max_point(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
)
.map(|chunk| chunk.text)
@@ -1170,7 +1186,10 @@ mod tests {
tab_snapshot
.chunks(
TabPoint::new(0, ix as u32)..tab_snapshot.max_point(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
)
.map(|c| c.text)
@@ -1246,8 +1265,14 @@ mod tests {
let mut chunks = Vec::new();
let mut was_tab = false;
let mut text = String::new();
- for chunk in snapshot.chunks(start..snapshot.max_point(), false, Highlights::default())
- {
+ for chunk in snapshot.chunks(
+ start..snapshot.max_point(),
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
+ Highlights::default(),
+ ) {
if chunk.is_tab != was_tab {
if !text.is_empty() {
chunks.push((mem::take(&mut text), was_tab));
@@ -1296,7 +1321,14 @@ mod tests {
// This should not panic.
let result: String = tab_snapshot
- .chunks(start..end, false, Highlights::default())
+ .chunks(
+ start..end,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
+ Highlights::default(),
+ )
.map(|c| c.text)
.collect();
assert!(!result.is_empty());
@@ -1354,7 +1386,14 @@ mod tests {
let expected_summary = TextSummary::from(expected_text.as_str());
assert_eq!(
tabs_snapshot
- .chunks(start..end, false, Highlights::default())
+ .chunks(
+ start..end,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
+ Highlights::default()
+ )
.map(|c| c.text)
.collect::(),
expected_text,
@@ -1436,7 +1475,10 @@ mod tests {
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let chunks = fold_snapshot.chunks(
FoldOffset(MultiBufferOffset(0))..fold_snapshot.len(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Default::default(),
);
let mut cursor = TabStopCursor::new(chunks);
@@ -1598,7 +1640,10 @@ mod tests {
let (_, fold_snapshot) = FoldMap::new(inlay_snapshot);
let chunks = fold_snapshot.chunks(
FoldOffset(MultiBufferOffset(0))..fold_snapshot.len(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Default::default(),
);
let mut cursor = TabStopCursor::new(chunks);
diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs
index d21642977ed923e15a583dfe767fd566e78c5de9..4ff11b1ef67971c5159a81278a5afaaaea171a28 100644
--- a/crates/editor/src/display_map/wrap_map.rs
+++ b/crates/editor/src/display_map/wrap_map.rs
@@ -5,7 +5,7 @@ use super::{
tab_map::{self, TabEdit, TabPoint, TabSnapshot},
};
use gpui::{App, AppContext as _, Context, Entity, Font, LineWrapper, Pixels, Task};
-use language::Point;
+use language::{LanguageAwareStyling, Point};
use multi_buffer::{MultiBufferSnapshot, RowInfo};
use smol::future::yield_now;
use std::{cmp, collections::VecDeque, mem, ops::Range, sync::LazyLock, time::Duration};
@@ -513,7 +513,10 @@ impl WrapSnapshot {
let mut remaining = None;
let mut chunks = new_tab_snapshot.chunks(
TabPoint::new(edit.new_rows.start, 0)..new_tab_snapshot.max_point(),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
);
let mut edit_transforms = Vec::::new();
@@ -656,7 +659,7 @@ impl WrapSnapshot {
pub(crate) fn chunks<'a>(
&'a self,
rows: Range,
- language_aware: bool,
+ language_aware: LanguageAwareStyling,
highlights: Highlights<'a>,
) -> WrapChunks<'a> {
let output_start = WrapPoint::new(rows.start, 0);
@@ -960,7 +963,10 @@ impl WrapSnapshot {
pub fn text_chunks(&self, wrap_row: WrapRow) -> impl Iterator
- {
self.chunks(
wrap_row..self.max_point().row() + WrapRow(1),
- false,
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: false,
+ },
Highlights::default(),
)
.map(|h| h.text)
@@ -1719,7 +1725,10 @@ mod tests {
let actual_text = self
.chunks(
WrapRow(start_row)..WrapRow(end_row),
- true,
+ LanguageAwareStyling {
+ tree_sitter: true,
+ diagnostics: true,
+ },
Highlights::default(),
)
.map(|c| c.text)
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index 6550d79c9f73799d37ccf6433db38f2719636ee6..e6f597de7ff9138b226cd2474353ef8c2ce16ebb 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -132,9 +132,9 @@ use language::{
AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow,
BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
- IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, LocalFile, OffsetRangeExt,
- OutlineItem, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
- WordsQuery,
+ IndentSize, Language, LanguageAwareStyling, LanguageName, LanguageRegistry, LanguageScope,
+ LocalFile, OffsetRangeExt, OutlineItem, Point, Selection, SelectionGoal, TextObject,
+ TransactionId, TreeSitterOptions, WordsQuery,
language_settings::{
self, AllLanguageSettings, LanguageSettings, LspInsertMode, RewrapBehavior,
WordsCompletionMode, all_language_settings,
@@ -1265,6 +1265,7 @@ pub struct Editor {
>,
use_autoclose: bool,
use_auto_surround: bool,
+ use_selection_highlight: bool,
auto_replace_emoji_shortcode: bool,
jsx_tag_auto_close_enabled_in_any_buffer: bool,
show_git_blame_gutter: bool,
@@ -2468,6 +2469,7 @@ impl Editor {
read_only: is_minimap,
use_autoclose: true,
use_auto_surround: true,
+ use_selection_highlight: true,
auto_replace_emoji_shortcode: false,
jsx_tag_auto_close_enabled_in_any_buffer: false,
leader_id: None,
@@ -3547,6 +3549,10 @@ impl Editor {
self.use_autoclose = autoclose;
}
+ pub fn set_use_selection_highlight(&mut self, highlight: bool) {
+ self.use_selection_highlight = highlight;
+ }
+
pub fn set_use_auto_surround(&mut self, auto_surround: bool) {
self.use_auto_surround = auto_surround;
}
@@ -7699,7 +7705,7 @@ impl Editor {
if matches!(self.mode, EditorMode::SingleLine) {
return None;
}
- if !EditorSettings::get_global(cx).selection_highlight {
+ if !self.use_selection_highlight || !EditorSettings::get_global(cx).selection_highlight {
return None;
}
if self.selections.count() != 1 || self.selections.line_mode() {
@@ -19147,7 +19153,13 @@ impl Editor {
let range = buffer.anchor_before(rename_start)..buffer.anchor_after(rename_end);
let mut old_highlight_id = None;
let old_name: Arc = buffer
- .chunks(rename_start..rename_end, true)
+ .chunks(
+ rename_start..rename_end,
+ LanguageAwareStyling {
+ tree_sitter: true,
+ diagnostics: true,
+ },
+ )
.map(|chunk| {
if old_highlight_id.is_none() {
old_highlight_id = chunk.syntax_highlight_id;
@@ -25005,7 +25017,13 @@ impl Editor {
selection.range()
};
- let chunks = snapshot.chunks(range, true);
+ let chunks = snapshot.chunks(
+ range,
+ LanguageAwareStyling {
+ tree_sitter: true,
+ diagnostics: true,
+ },
+ );
let mut lines = Vec::new();
let mut line: VecDeque = VecDeque::new();
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index 7a532dc7a75ea3583456be6611ef072cd7692bc7..512fbb8855aa11d8c540065a55eb296919012821 100644
--- a/crates/editor/src/element.rs
+++ b/crates/editor/src/element.rs
@@ -51,7 +51,10 @@ use gpui::{
pattern_slash, point, px, quad, relative, size, solid_background, transparent_black,
};
use itertools::Itertools;
-use language::{HighlightedText, IndentGuideSettings, language_settings::ShowWhitespaceSetting};
+use language::{
+ HighlightedText, IndentGuideSettings, LanguageAwareStyling,
+ language_settings::ShowWhitespaceSetting,
+};
use markdown::Markdown;
use multi_buffer::{
Anchor, ExcerptBoundaryInfo, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint,
@@ -3819,7 +3822,11 @@ impl EditorElement {
} else {
let use_tree_sitter = !snapshot.semantic_tokens_enabled
|| snapshot.use_tree_sitter_for_syntax(rows.start, cx);
- let chunks = snapshot.highlighted_chunks(rows.clone(), use_tree_sitter, style);
+ let language_aware = LanguageAwareStyling {
+ tree_sitter: use_tree_sitter,
+ diagnostics: true,
+ };
+ let chunks = snapshot.highlighted_chunks(rows.clone(), language_aware, style);
LineWithInvisibles::from_chunks(
chunks,
style,
@@ -11999,7 +12006,11 @@ pub fn layout_line(
) -> LineWithInvisibles {
let use_tree_sitter =
!snapshot.semantic_tokens_enabled || snapshot.use_tree_sitter_for_syntax(row, cx);
- let chunks = snapshot.highlighted_chunks(row..row + DisplayRow(1), use_tree_sitter, style);
+ let language_aware = LanguageAwareStyling {
+ tree_sitter: use_tree_sitter,
+ diagnostics: true,
+ };
+ let chunks = snapshot.highlighted_chunks(row..row + DisplayRow(1), language_aware, style);
LineWithInvisibles::from_chunks(
chunks,
style,
diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs
index 5e78be70d5627bd4f484a3efd44b13519b31b400..d485cfa70237fed542a240f202a8dc47b07467c4 100644
--- a/crates/editor/src/semantic_tokens.rs
+++ b/crates/editor/src/semantic_tokens.rs
@@ -475,13 +475,17 @@ mod tests {
use gpui::{
AppContext as _, Entity, Focusable as _, HighlightStyle, TestAppContext, UpdateGlobal as _,
};
- use language::{Language, LanguageConfig, LanguageMatcher};
+ use language::{
+ Diagnostic, DiagnosticEntry, DiagnosticSet, Language, LanguageAwareStyling, LanguageConfig,
+ LanguageMatcher,
+ };
use languages::FakeLspAdapter;
+ use lsp::LanguageServerId;
use multi_buffer::{
AnchorRangeExt, ExpandExcerptDirection, MultiBuffer, MultiBufferOffset, PathKey,
};
use project::Project;
- use rope::Point;
+ use rope::{Point, PointUtf16};
use serde_json::json;
use settings::{
GlobalLspSettingsContent, LanguageSettingsContent, SemanticTokenRule, SemanticTokenRules,
@@ -2088,6 +2092,130 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_diagnostics_visible_when_semantic_token_set_to_full(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ update_test_language_settings(cx, &|language_settings| {
+ language_settings.languages.0.insert(
+ "Rust".into(),
+ LanguageSettingsContent {
+ semantic_tokens: Some(SemanticTokens::Full),
+ ..LanguageSettingsContent::default()
+ },
+ );
+ });
+
+ let mut cx = EditorLspTestContext::new_rust(
+ lsp::ServerCapabilities {
+ semantic_tokens_provider: Some(
+ lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(
+ lsp::SemanticTokensOptions {
+ legend: lsp::SemanticTokensLegend {
+ token_types: vec!["function".into()],
+ token_modifiers: Vec::new(),
+ },
+ full: Some(lsp::SemanticTokensFullOptions::Delta { delta: None }),
+ ..lsp::SemanticTokensOptions::default()
+ },
+ ),
+ ),
+ ..lsp::ServerCapabilities::default()
+ },
+ cx,
+ )
+ .await;
+
+ let mut full_request = cx
+ .set_request_handler::(
+ move |_, _, _| {
+ async move {
+ Ok(Some(lsp::SemanticTokensResult::Tokens(
+ lsp::SemanticTokens {
+ data: vec![
+ 0, // delta_line
+ 3, // delta_start
+ 4, // length
+ 0, // token_type
+ 0, // token_modifiers_bitset
+ ],
+ result_id: Some("a".into()),
+ },
+ )))
+ }
+ },
+ );
+
+ cx.set_state("ˇfn main() {}");
+ assert!(full_request.next().await.is_some());
+
+ let task = cx.update_editor(|e, _, _| e.semantic_token_state.take_update_task());
+ task.await;
+
+ cx.update_buffer(|buffer, cx| {
+ buffer.update_diagnostics(
+ LanguageServerId(0),
+ DiagnosticSet::new(
+ [DiagnosticEntry {
+ range: PointUtf16::new(0, 3)..PointUtf16::new(0, 7),
+ diagnostic: Diagnostic {
+ severity: lsp::DiagnosticSeverity::ERROR,
+ group_id: 1,
+ message: "unused function".into(),
+ ..Default::default()
+ },
+ }],
+ buffer,
+ ),
+ cx,
+ )
+ });
+
+ cx.run_until_parked();
+ let chunks = cx.update_editor(|editor, window, cx| {
+ editor
+ .snapshot(window, cx)
+ .display_snapshot
+ .chunks(
+ crate::display_map::DisplayRow(0)..crate::display_map::DisplayRow(1),
+ LanguageAwareStyling {
+ tree_sitter: false,
+ diagnostics: true,
+ },
+ crate::HighlightStyles::default(),
+ )
+ .map(|chunk| {
+ (
+ chunk.text.to_string(),
+ chunk.diagnostic_severity,
+ chunk.highlight_style,
+ )
+ })
+ .collect::>()
+ });
+
+ assert_eq!(
+ extract_semantic_highlights(&cx.editor, &cx),
+ vec![MultiBufferOffset(3)..MultiBufferOffset(7)]
+ );
+
+ assert!(
+ chunks.iter().any(
+ |(text, severity, style): &(
+ String,
+ Option,
+ Option
+ )| {
+ text == "main"
+ && *severity == Some(lsp::DiagnosticSeverity::ERROR)
+ && style.is_some()
+ }
+ ),
+ "expected 'main' chunk to have both diagnostic and semantic styling: {:?}",
+ chunks
+ );
+ }
+
fn extract_semantic_highlight_styles(
editor: &Entity,
cx: &TestAppContext,
diff --git a/crates/env_var/Cargo.toml b/crates/env_var/Cargo.toml
index 2cbbd08c7833d3e57a09766d42ffffe35c620a93..3c879a2f49184e19a131046320d767931e1ca8ec 100644
--- a/crates/env_var/Cargo.toml
+++ b/crates/env_var/Cargo.toml
@@ -12,4 +12,4 @@ workspace = true
path = "src/env_var.rs"
[dependencies]
-gpui.workspace = true
+gpui_shared_string.workspace = true
diff --git a/crates/env_var/src/env_var.rs b/crates/env_var/src/env_var.rs
index 79f671e0147ebfaad4ab76a123cc477dc7e55cb7..cb436e95e0e734e4b7d8d271199246e1558a074d 100644
--- a/crates/env_var/src/env_var.rs
+++ b/crates/env_var/src/env_var.rs
@@ -1,4 +1,4 @@
-use gpui::SharedString;
+use gpui_shared_string::SharedString;
#[derive(Clone)]
pub struct EnvVar {
diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml
index 5eb36f0f5150263629b407dbe07dc73b6eff31cf..67ebab62295e8db90a12f99cbc05e9b9e56c2c6b 100644
--- a/crates/file_finder/Cargo.toml
+++ b/crates/file_finder/Cargo.toml
@@ -21,6 +21,7 @@ editor.workspace = true
file_icons.workspace = true
futures.workspace = true
fuzzy.workspace = true
+fuzzy_nucleo.workspace = true
gpui.workspace = true
menu.workspace = true
open_path_prompt.workspace = true
diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs
index 4302669ddc11c94f7df128534217d00c27ef083a..a4d9ea042dea898b9dd9db7d40354cf960d210d5 100644
--- a/crates/file_finder/src/file_finder.rs
+++ b/crates/file_finder/src/file_finder.rs
@@ -9,7 +9,8 @@ use client::ChannelId;
use collections::HashMap;
use editor::Editor;
use file_icons::FileIcons;
-use fuzzy::{CharBag, PathMatch, PathMatchCandidate, StringMatch, StringMatchCandidate};
+use fuzzy::{StringMatch, StringMatchCandidate};
+use fuzzy_nucleo::{PathMatch, PathMatchCandidate};
use gpui::{
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
@@ -663,15 +664,6 @@ impl Matches {
// For file-vs-file matches, use the existing detailed comparison.
if let (Some(a_panel), Some(b_panel)) = (a.panel_match(), b.panel_match()) {
- let a_in_filename = Self::is_filename_match(a_panel);
- let b_in_filename = Self::is_filename_match(b_panel);
-
- match (a_in_filename, b_in_filename) {
- (true, false) => return cmp::Ordering::Greater,
- (false, true) => return cmp::Ordering::Less,
- _ => {}
- }
-
return a_panel.cmp(b_panel);
}
@@ -691,32 +683,6 @@ impl Matches {
Match::CreateNew(_) => 0.0,
}
}
-
- /// Determines if the match occurred within the filename rather than in the path
- fn is_filename_match(panel_match: &ProjectPanelOrdMatch) -> bool {
- if panel_match.0.positions.is_empty() {
- return false;
- }
-
- if let Some(filename) = panel_match.0.path.file_name() {
- let path_str = panel_match.0.path.as_unix_str();
-
- if let Some(filename_pos) = path_str.rfind(filename)
- && panel_match.0.positions[0] >= filename_pos
- {
- let mut prev_position = panel_match.0.positions[0];
- for p in &panel_match.0.positions[1..] {
- if *p != prev_position + 1 {
- return false;
- }
- prev_position = *p;
- }
- return true;
- }
- }
-
- false
- }
}
fn matching_history_items<'a>(
@@ -731,25 +697,16 @@ fn matching_history_items<'a>(
let history_items_by_worktrees = history_items
.into_iter()
.chain(currently_opened)
- .filter_map(|found_path| {
+ .map(|found_path| {
let candidate = PathMatchCandidate {
is_dir: false, // You can't open directories as project items
path: &found_path.project.path,
// Only match history items names, otherwise their paths may match too many queries, producing false positives.
// E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
// it would be shown first always, despite the latter being a better match.
- char_bag: CharBag::from_iter(
- found_path
- .project
- .path
- .file_name()?
- .to_string()
- .to_lowercase()
- .chars(),
- ),
};
candidates_paths.insert(&found_path.project, found_path);
- Some((found_path.project.worktree_id, candidate))
+ (found_path.project.worktree_id, candidate)
})
.fold(
HashMap::default(),
@@ -767,8 +724,9 @@ fn matching_history_items<'a>(
let worktree_root_name = worktree_name_by_id
.as_ref()
.and_then(|w| w.get(&worktree).cloned());
+
matching_history_paths.extend(
- fuzzy::match_fixed_path_set(
+ fuzzy_nucleo::match_fixed_path_set(
candidates,
worktree.to_usize(),
worktree_root_name,
@@ -778,6 +736,18 @@ fn matching_history_items<'a>(
path_style,
)
.into_iter()
+ // filter matches where at least one matched position is in filename portion, to prevent directory matches, nucleo scores them higher as history items are matched against their full path
+ .filter(|path_match| {
+ if let Some(filename) = path_match.path.file_name() {
+ let filename_start = path_match.path.as_unix_str().len() - filename.len();
+ path_match
+ .positions
+ .iter()
+ .any(|&pos| pos >= filename_start)
+ } else {
+ true
+ }
+ })
.filter_map(|path_match| {
candidates_paths
.remove_entry(&ProjectPath {
@@ -940,7 +910,7 @@ impl FileFinderDelegate {
self.cancel_flag = Arc::new(AtomicBool::new(false));
let cancel_flag = self.cancel_flag.clone();
cx.spawn_in(window, async move |picker, cx| {
- let matches = fuzzy::match_path_sets(
+ let matches = fuzzy_nucleo::match_path_sets(
candidate_sets.as_slice(),
query.path_query(),
&relative_to,
@@ -1452,7 +1422,6 @@ impl PickerDelegate for FileFinderDelegate {
window: &mut Window,
cx: &mut Context>,
) -> Task<()> {
- let raw_query = raw_query.replace(' ', "");
let raw_query = raw_query.trim();
let raw_query = match &raw_query.get(0..2) {
diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs
index cd9cdeee1ff266717d380aeaecf7cbeb66ec8309..7a17202a5e4ba96b001ea46ed310518d02baf1ff 100644
--- a/crates/file_finder/src/file_finder_tests.rs
+++ b/crates/file_finder/src/file_finder_tests.rs
@@ -4161,3 +4161,233 @@ async fn test_clear_navigation_history(cx: &mut TestAppContext) {
"Should have no history items after clearing"
);
}
+
+#[gpui::test]
+async fn test_order_independent_search(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "internal": {
+ "auth": {
+ "login.rs": "",
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ // forward order
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("auth internal"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert_eq!(matches.len(), 1);
+ assert_eq!(matches[0].path.as_unix_str(), "internal/auth/login.rs");
+ });
+
+ // reverse order should give same result
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("internal auth"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert_eq!(matches.len(), 1);
+ assert_eq!(matches[0].path.as_unix_str(), "internal/auth/login.rs");
+ });
+}
+
+#[gpui::test]
+async fn test_filename_preferred_over_directory_match(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "crates": {
+ "settings_ui": {
+ "src": {
+ "pages": {
+ "audio_test_window.rs": "",
+ "audio_input_output_setup.rs": "",
+ }
+ }
+ },
+ "audio": {
+ "src": {
+ "audio_settings.rs": "",
+ }
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("settings audio"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert!(!matches.is_empty(),);
+ assert_eq!(
+ matches[0].path.as_unix_str(),
+ "crates/audio/src/audio_settings.rs"
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_start_of_word_preferred_over_scattered_match(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "crates": {
+ "livekit_client": {
+ "src": {
+ "livekit_client": {
+ "playback.rs": "",
+ }
+ }
+ },
+ "vim": {
+ "test_data": {
+ "test_record_replay_interleaved.json": "",
+ }
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("live pla"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert!(!matches.is_empty(),);
+ assert_eq!(
+ matches[0].path.as_unix_str(),
+ "crates/livekit_client/src/livekit_client/playback.rs",
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_exact_filename_stem_preferred(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "assets": {
+ "icons": {
+ "file_icons": {
+ "nix.svg": "",
+ }
+ }
+ },
+ "crates": {
+ "zed": {
+ "resources": {
+ "app-icon-nightly@2x.png": "",
+ "app-icon-preview@2x.png": "",
+ }
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("nix icon"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert!(!matches.is_empty(),);
+ assert_eq!(
+ matches[0].path.as_unix_str(),
+ "assets/icons/file_icons/nix.svg",
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_exact_filename_with_directory_token(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "crates": {
+ "agent_servers": {
+ "src": {
+ "acp.rs": "",
+ "agent_server.rs": "",
+ "custom.rs": "",
+ }
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("acp server"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert!(!matches.is_empty(),);
+ assert_eq!(
+ matches[0].path.as_unix_str(),
+ "crates/agent_servers/src/acp.rs",
+ );
+ });
+}
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index 751796fb83164b78dc5d6789f0ae7870eff16ce1..7b89a0751f17ef8c2bba837882f2a31c7d5451e5 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/crates/fs/src/fake_git_repo.rs
@@ -6,9 +6,10 @@ use git::{
Oid, RunHook,
blame::Blame,
repository::{
- AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions, FetchOptions,
- GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder,
- LogSource, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree,
+ AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions,
+ CreateWorktreeTarget, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository,
+ GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote,
+ RepoPath, ResetMode, SearchCommitArgs, Worktree,
},
stash::GitStash,
status::{
@@ -60,6 +61,7 @@ pub struct FakeGitRepositoryState {
pub remotes: HashMap,
pub simulated_index_write_error_message: Option,
pub simulated_create_worktree_error: Option,
+ pub simulated_graph_error: Option,
pub refs: HashMap,
pub graph_commits: Vec>,
pub stash_entries: GitStash,
@@ -77,6 +79,7 @@ impl FakeGitRepositoryState {
branches: Default::default(),
simulated_index_write_error_message: Default::default(),
simulated_create_worktree_error: Default::default(),
+ simulated_graph_error: None,
refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
merge_base_contents: Default::default(),
oids: Default::default(),
@@ -540,9 +543,8 @@ impl GitRepository for FakeGitRepository {
fn create_worktree(
&self,
- branch_name: Option,
+ target: CreateWorktreeTarget,
path: PathBuf,
- from_commit: Option,
) -> BoxFuture<'_, Result<()>> {
let fs = self.fs.clone();
let executor = self.executor.clone();
@@ -550,30 +552,82 @@ impl GitRepository for FakeGitRepository {
let common_dir_path = self.common_dir_path.clone();
async move {
executor.simulate_random_delay().await;
- // Check for simulated error and duplicate branch before any side effects.
- fs.with_git_state(&dot_git_path, false, |state| {
- if let Some(message) = &state.simulated_create_worktree_error {
- anyhow::bail!("{message}");
- }
- if let Some(ref name) = branch_name {
- if state.branches.contains(name) {
- bail!("a branch named '{}' already exists", name);
+
+ let branch_name = target.branch_name().map(ToOwned::to_owned);
+ let create_branch_ref = matches!(target, CreateWorktreeTarget::NewBranch { .. });
+
+ // Check for simulated error and validate branch state before any side effects.
+ fs.with_git_state(&dot_git_path, false, {
+ let branch_name = branch_name.clone();
+ move |state| {
+ if let Some(message) = &state.simulated_create_worktree_error {
+ anyhow::bail!("{message}");
+ }
+
+ match (create_branch_ref, branch_name.as_ref()) {
+ (true, Some(branch_name)) => {
+ if state.branches.contains(branch_name) {
+ bail!("a branch named '{}' already exists", branch_name);
+ }
+ }
+ (false, Some(branch_name)) => {
+ if !state.branches.contains(branch_name) {
+ bail!("no branch named '{}' exists", branch_name);
+ }
+ }
+ (false, None) => {}
+ (true, None) => bail!("branch name is required to create a branch"),
}
+
+ Ok(())
}
- Ok(())
})??;
+ let (branch_name, sha, create_branch_ref) = match target {
+ CreateWorktreeTarget::ExistingBranch { branch_name } => {
+ let ref_name = format!("refs/heads/{branch_name}");
+ let sha = fs.with_git_state(&dot_git_path, false, {
+ move |state| {
+ Ok::<_, anyhow::Error>(
+ state
+ .refs
+ .get(&ref_name)
+ .cloned()
+ .unwrap_or_else(|| "fake-sha".to_string()),
+ )
+ }
+ })??;
+ (Some(branch_name), sha, false)
+ }
+ CreateWorktreeTarget::NewBranch {
+ branch_name,
+ base_sha: start_point,
+ } => (
+ Some(branch_name),
+ start_point.unwrap_or_else(|| "fake-sha".to_string()),
+ true,
+ ),
+ CreateWorktreeTarget::Detached {
+ base_sha: start_point,
+ } => (
+ None,
+ start_point.unwrap_or_else(|| "fake-sha".to_string()),
+ false,
+ ),
+ };
+
// Create the worktree checkout directory.
fs.create_dir(&path).await?;
// Create .git/worktrees// directory with HEAD, commondir, gitdir.
- let worktree_entry_name = branch_name
- .as_deref()
- .unwrap_or_else(|| path.file_name().unwrap().to_str().unwrap());
+ let worktree_entry_name = branch_name.as_deref().unwrap_or_else(|| {
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("detached")
+ });
let worktrees_entry_dir = common_dir_path.join("worktrees").join(worktree_entry_name);
fs.create_dir(&worktrees_entry_dir).await?;
- let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string());
let head_content = if let Some(ref branch_name) = branch_name {
let ref_name = format!("refs/heads/{branch_name}");
format!("ref: {ref_name}")
@@ -604,15 +658,22 @@ impl GitRepository for FakeGitRepository {
false,
)?;
- // Update git state: add ref and branch.
- fs.with_git_state(&dot_git_path, true, move |state| {
- if let Some(branch_name) = branch_name {
- let ref_name = format!("refs/heads/{branch_name}");
- state.refs.insert(ref_name, sha);
- state.branches.insert(branch_name);
- }
- Ok::<(), anyhow::Error>(())
- })??;
+ // Update git state for newly created branches.
+ if create_branch_ref {
+ fs.with_git_state(&dot_git_path, true, {
+ let branch_name = branch_name.clone();
+ let sha = sha.clone();
+ move |state| {
+ if let Some(branch_name) = branch_name {
+ let ref_name = format!("refs/heads/{branch_name}");
+ state.refs.insert(ref_name, sha);
+ state.branches.insert(branch_name);
+ }
+ Ok::<(), anyhow::Error>(())
+ }
+ })??;
+ }
+
Ok(())
}
.boxed()
@@ -1268,8 +1329,17 @@ impl GitRepository for FakeGitRepository {
let fs = self.fs.clone();
let dot_git_path = self.dot_git_path.clone();
async move {
- let graph_commits =
- fs.with_git_state(&dot_git_path, false, |state| state.graph_commits.clone())?;
+ let (graph_commits, simulated_error) =
+ fs.with_git_state(&dot_git_path, false, |state| {
+ (
+ state.graph_commits.clone(),
+ state.simulated_graph_error.clone(),
+ )
+ })?;
+
+ if let Some(error) = simulated_error {
+ anyhow::bail!("{}", error);
+ }
for chunk in graph_commits.chunks(GRAPH_CHUNK_SIZE) {
request_tx.send(chunk.to_vec()).await.ok();
diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs
index a26abb81255003e4059f9bcc8a68aa3c6212a73a..52cae537b6f00837b50123af0cae7c093699dedf 100644
--- a/crates/fs/src/fs.rs
+++ b/crates/fs/src/fs.rs
@@ -2168,6 +2168,13 @@ impl FakeFs {
.unwrap();
}
+ pub fn set_graph_error(&self, dot_git: &Path, error: Option) {
+ self.with_git_state(dot_git, true, |state| {
+ state.simulated_graph_error = error;
+ })
+ .unwrap();
+ }
+
/// Put the given git repository into a state with the given status,
/// by mutating the head, index, and unmerged state.
pub fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&str, FileStatus)]) {
diff --git a/crates/fs/tests/integration/fake_git_repo.rs b/crates/fs/tests/integration/fake_git_repo.rs
index f4192a22bb42f88f8769ef59f817b2bf2a288fb9..3be81ad7301e6fc4ee6f4529ce8bb587de3b4565 100644
--- a/crates/fs/tests/integration/fake_git_repo.rs
+++ b/crates/fs/tests/integration/fake_git_repo.rs
@@ -24,9 +24,11 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
// Create a worktree
let worktree_1_dir = worktrees_dir.join("feature-branch");
repo.create_worktree(
- Some("feature-branch".to_string()),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "feature-branch".to_string(),
+ base_sha: Some("abc123".to_string()),
+ },
worktree_1_dir.clone(),
- Some("abc123".to_string()),
)
.await
.unwrap();
@@ -48,9 +50,11 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
// Create a second worktree (without explicit commit)
let worktree_2_dir = worktrees_dir.join("bugfix-branch");
repo.create_worktree(
- Some("bugfix-branch".to_string()),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "bugfix-branch".to_string(),
+ base_sha: None,
+ },
worktree_2_dir.clone(),
- None,
)
.await
.unwrap();
diff --git a/crates/fuzzy_nucleo/Cargo.toml b/crates/fuzzy_nucleo/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..59e8b642524777f449f79edba85093eef069ebff
--- /dev/null
+++ b/crates/fuzzy_nucleo/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "fuzzy_nucleo"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/fuzzy_nucleo.rs"
+doctest = false
+
+[dependencies]
+nucleo.workspace = true
+gpui.workspace = true
+util.workspace = true
+
+[dev-dependencies]
+util = {workspace = true, features = ["test-support"]}
diff --git a/crates/fuzzy_nucleo/LICENSE-GPL b/crates/fuzzy_nucleo/LICENSE-GPL
new file mode 120000
index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4
--- /dev/null
+++ b/crates/fuzzy_nucleo/LICENSE-GPL
@@ -0,0 +1 @@
+../../LICENSE-GPL
\ No newline at end of file
diff --git a/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ddaa5c3489cf55d41d31440f037214b1dce0358c
--- /dev/null
+++ b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs
@@ -0,0 +1,5 @@
+mod matcher;
+mod paths;
+pub use paths::{
+ PathMatch, PathMatchCandidate, PathMatchCandidateSet, match_fixed_path_set, match_path_sets,
+};
diff --git a/crates/fuzzy_nucleo/src/matcher.rs b/crates/fuzzy_nucleo/src/matcher.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b31da011106341420095bcffbfd012f40014ad6c
--- /dev/null
+++ b/crates/fuzzy_nucleo/src/matcher.rs
@@ -0,0 +1,39 @@
+use std::sync::Mutex;
+
+static MATCHERS: Mutex> = Mutex::new(Vec::new());
+
+pub const LENGTH_PENALTY: f64 = 0.01;
+
+pub fn get_matcher(config: nucleo::Config) -> nucleo::Matcher {
+ let mut matchers = MATCHERS.lock().unwrap();
+ match matchers.pop() {
+ Some(mut matcher) => {
+ matcher.config = config;
+ matcher
+ }
+ None => nucleo::Matcher::new(config),
+ }
+}
+
+pub fn return_matcher(matcher: nucleo::Matcher) {
+ MATCHERS.lock().unwrap().push(matcher);
+}
+
+pub fn get_matchers(n: usize, config: nucleo::Config) -> Vec {
+ let mut matchers: Vec<_> = {
+ let mut pool = MATCHERS.lock().unwrap();
+ let available = pool.len().min(n);
+ pool.drain(..available)
+ .map(|mut matcher| {
+ matcher.config = config.clone();
+ matcher
+ })
+ .collect()
+ };
+ matchers.resize_with(n, || nucleo::Matcher::new(config.clone()));
+ matchers
+}
+
+pub fn return_matchers(mut matchers: Vec) {
+ MATCHERS.lock().unwrap().append(&mut matchers);
+}
diff --git a/crates/fuzzy_nucleo/src/paths.rs b/crates/fuzzy_nucleo/src/paths.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ac766622c9d12c6e2a119fbcd7dd7fe7a3b5a90d
--- /dev/null
+++ b/crates/fuzzy_nucleo/src/paths.rs
@@ -0,0 +1,352 @@
+use gpui::BackgroundExecutor;
+use std::{
+ cmp::Ordering,
+ sync::{
+ Arc,
+ atomic::{self, AtomicBool},
+ },
+};
+use util::{paths::PathStyle, rel_path::RelPath};
+
+use nucleo::Utf32Str;
+use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
+
+use crate::matcher::{self, LENGTH_PENALTY};
+
+#[derive(Clone, Debug)]
+pub struct PathMatchCandidate<'a> {
+ pub is_dir: bool,
+ pub path: &'a RelPath,
+}
+
+#[derive(Clone, Debug)]
+pub struct PathMatch {
+ pub score: f64,
+ pub positions: Vec,
+ pub worktree_id: usize,
+ pub path: Arc,
+ pub path_prefix: Arc,
+ pub is_dir: bool,
+ /// Number of steps removed from a shared parent with the relative path
+ /// Used to order closer paths first in the search list
+ pub distance_to_relative_ancestor: usize,
+}
+
+pub trait PathMatchCandidateSet<'a>: Send + Sync {
+ type Candidates: Iterator
- >;
+ fn id(&self) -> usize;
+ fn len(&self) -> usize;
+ fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+ fn root_is_file(&self) -> bool;
+ fn prefix(&self) -> Arc;
+ fn candidates(&'a self, start: usize) -> Self::Candidates;
+ fn path_style(&self) -> PathStyle;
+}
+
+impl PartialEq for PathMatch {
+ fn eq(&self, other: &Self) -> bool {
+ self.cmp(other).is_eq()
+ }
+}
+
+impl Eq for PathMatch {}
+
+impl PartialOrd for PathMatch {
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for PathMatch {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.score
+ .partial_cmp(&other.score)
+ .unwrap_or(Ordering::Equal)
+ .then_with(|| self.worktree_id.cmp(&other.worktree_id))
+ .then_with(|| {
+ other
+ .distance_to_relative_ancestor
+ .cmp(&self.distance_to_relative_ancestor)
+ })
+ .then_with(|| self.path.cmp(&other.path))
+ }
+}
+
+fn make_atoms(query: &str, smart_case: bool) -> Vec {
+ let case = if smart_case {
+ CaseMatching::Smart
+ } else {
+ CaseMatching::Ignore
+ };
+ query
+ .split_whitespace()
+ .map(|word| Atom::new(word, case, Normalization::Smart, AtomKind::Fuzzy, false))
+ .collect()
+}
+
+pub(crate) fn distance_between_paths(path: &RelPath, relative_to: &RelPath) -> usize {
+ let mut path_components = path.components();
+ let mut relative_components = relative_to.components();
+
+ while path_components
+ .next()
+ .zip(relative_components.next())
+ .map(|(path_component, relative_component)| path_component == relative_component)
+ .unwrap_or_default()
+ {}
+ path_components.count() + relative_components.count() + 1
+}
+
+fn get_filename_match_bonus(
+ candidate_buf: &str,
+ query_atoms: &[Atom],
+ matcher: &mut nucleo::Matcher,
+) -> f64 {
+ let filename = match std::path::Path::new(candidate_buf).file_name() {
+ Some(f) => f.to_str().unwrap_or(""),
+ None => return 0.0,
+ };
+ if filename.is_empty() || query_atoms.is_empty() {
+ return 0.0;
+ }
+ let mut buf = Vec::new();
+ let haystack = Utf32Str::new(filename, &mut buf);
+ let mut total_score = 0u32;
+ for atom in query_atoms {
+ if let Some(score) = atom.score(haystack, matcher) {
+ total_score = total_score.saturating_add(score as u32);
+ }
+ }
+ total_score as f64 / filename.len().max(1) as f64
+}
+struct Cancelled;
+
+fn path_match_helper<'a>(
+ matcher: &mut nucleo::Matcher,
+ atoms: &[Atom],
+ candidates: impl Iterator
- >,
+ results: &mut Vec,
+ worktree_id: usize,
+ path_prefix: &Arc,
+ root_is_file: bool,
+ relative_to: &Option>,
+ path_style: PathStyle,
+ cancel_flag: &AtomicBool,
+) -> Result<(), Cancelled> {
+ let mut candidate_buf = if !path_prefix.is_empty() && !root_is_file {
+ let mut s = path_prefix.display(path_style).to_string();
+ s.push_str(path_style.primary_separator());
+ s
+ } else {
+ String::new()
+ };
+ let path_prefix_len = candidate_buf.len();
+ let mut buf = Vec::new();
+ let mut matched_chars: Vec