toolchains: Allow users to provide custom paths to toolchains (#37009)

Piotr Osiewicz and Danilo Leal created

- **toolchains: Add new state to toolchain selector**
- **Use toolchain term for Add Toolchain button**
- **Hoist out a meta function for toolchain listers**

Closes #27332

Release Notes:

- python: Users can now specify a custom path to their virtual
environment from within the picker.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

Cargo.lock                                          |   5 
assets/keymaps/default-linux.json                   |   8 
assets/keymaps/default-macos.json                   |   8 
crates/file_finder/src/open_path_prompt.rs          |  32 
crates/language/src/language.rs                     |   1 
crates/language/src/toolchain.rs                    |  62 +
crates/languages/src/lib.rs                         |   2 
crates/languages/src/python.rs                      | 100 +
crates/project/src/lsp_store.rs                     |   4 
crates/project/src/project.rs                       |  74 +
crates/project/src/project_tests.rs                 |  29 
crates/project/src/toolchain_store.rs               | 284 ++++
crates/proto/proto/toolchain.proto                  |  13 
crates/proto/proto/zed.proto                        |   5 
crates/proto/src/proto.rs                           |   4 
crates/repl/src/kernels/mod.rs                      |  89 
crates/toolchain_selector/Cargo.toml                |   5 
crates/toolchain_selector/src/active_toolchain.rs   |  34 
crates/toolchain_selector/src/toolchain_selector.rs | 802 ++++++++++++++
crates/workspace/src/persistence.rs                 | 148 ++
crates/workspace/src/persistence/model.rs           |   3 
crates/workspace/src/workspace.rs                   |  31 
22 files changed, 1,508 insertions(+), 235 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -17000,10 +17000,15 @@ checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076"
 name = "toolchain_selector"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
+ "convert_case 0.8.0",
  "editor",
+ "file_finder",
+ "futures 0.3.31",
  "fuzzy",
  "gpui",
  "language",
+ "menu",
  "picker",
  "project",
  "ui",

assets/keymaps/default-linux.json πŸ”—

@@ -628,6 +628,7 @@
       "alt-save": "workspace::SaveAll",
       "ctrl-alt-s": "workspace::SaveAll",
       "ctrl-k m": "language_selector::Toggle",
+      "ctrl-k ctrl-m": "toolchain::AddToolchain",
       "escape": "workspace::Unfollow",
       "ctrl-k ctrl-left": "workspace::ActivatePaneLeft",
       "ctrl-k ctrl-right": "workspace::ActivatePaneRight",
@@ -1028,6 +1029,13 @@
       "tab": "channel_modal::ToggleMode"
     }
   },
+  {
+    "context": "ToolchainSelector",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-shift-a": "toolchain::AddToolchain"
+    }
+  },
   {
     "context": "FileFinder || (FileFinder > Picker > Editor)",
     "bindings": {

assets/keymaps/default-macos.json πŸ”—

@@ -690,6 +690,7 @@
       "cmd-?": "agent::ToggleFocus",
       "cmd-alt-s": "workspace::SaveAll",
       "cmd-k m": "language_selector::Toggle",
+      "cmd-k cmd-m": "toolchain::AddToolchain",
       "escape": "workspace::Unfollow",
       "cmd-k cmd-left": "workspace::ActivatePaneLeft",
       "cmd-k cmd-right": "workspace::ActivatePaneRight",
@@ -1094,6 +1095,13 @@
       "tab": "channel_modal::ToggleMode"
     }
   },
+  {
+    "context": "ToolchainSelector",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-shift-a": "toolchain::AddToolchain"
+    }
+  },
   {
     "context": "FileFinder || (FileFinder > Picker > Editor)",
     "use_key_equivalents": true,

crates/file_finder/src/open_path_prompt.rs πŸ”—

@@ -23,7 +23,6 @@ use workspace::Workspace;
 
 pub(crate) struct OpenPathPrompt;
 
-#[derive(Debug)]
 pub struct OpenPathDelegate {
     tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
     lister: DirectoryLister,
@@ -35,6 +34,9 @@ pub struct OpenPathDelegate {
     prompt_root: String,
     path_style: PathStyle,
     replace_prompt: Task<()>,
+    render_footer:
+        Arc<dyn Fn(&mut Window, &mut Context<Picker<Self>>) -> Option<AnyElement> + 'static>,
+    hidden_entries: bool,
 }
 
 impl OpenPathDelegate {
@@ -60,9 +62,25 @@ impl OpenPathDelegate {
             },
             path_style,
             replace_prompt: Task::ready(()),
+            render_footer: Arc::new(|_, _| None),
+            hidden_entries: false,
         }
     }
 
+    pub fn with_footer(
+        mut self,
+        footer: Arc<
+            dyn Fn(&mut Window, &mut Context<Picker<Self>>) -> Option<AnyElement> + 'static,
+        >,
+    ) -> Self {
+        self.render_footer = footer;
+        self
+    }
+
+    pub fn show_hidden(mut self) -> Self {
+        self.hidden_entries = true;
+        self
+    }
     fn get_entry(&self, selected_match_index: usize) -> Option<CandidateInfo> {
         match &self.directory_state {
             DirectoryState::List { entries, .. } => {
@@ -269,7 +287,7 @@ impl PickerDelegate for OpenPathDelegate {
         self.cancel_flag.store(true, atomic::Ordering::Release);
         self.cancel_flag = Arc::new(AtomicBool::new(false));
         let cancel_flag = self.cancel_flag.clone();
-
+        let hidden_entries = self.hidden_entries;
         let parent_path_is_root = self.prompt_root == dir;
         let current_dir = self.current_dir();
         cx.spawn_in(window, async move |this, cx| {
@@ -363,7 +381,7 @@ impl PickerDelegate for OpenPathDelegate {
             };
 
             let mut max_id = 0;
-            if !suffix.starts_with('.') {
+            if !suffix.starts_with('.') && !hidden_entries {
                 new_entries.retain(|entry| {
                     max_id = max_id.max(entry.path.id);
                     !entry.path.string.starts_with('.')
@@ -781,6 +799,14 @@ impl PickerDelegate for OpenPathDelegate {
         }
     }
 
+    fn render_footer(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<AnyElement> {
+        (self.render_footer)(window, cx)
+    }
+
     fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
         Some(match &self.directory_state {
             DirectoryState::Create { .. } => SharedString::from("Type a path…"),

crates/language/src/language.rs πŸ”—

@@ -69,6 +69,7 @@ pub use text_diff::{
 use theme::SyntaxTheme;
 pub use toolchain::{
     LanguageToolchainStore, LocalLanguageToolchainStore, Toolchain, ToolchainList, ToolchainLister,
+    ToolchainMetadata, ToolchainScope,
 };
 use tree_sitter::{self, Query, QueryCursor, WasmStore, wasmtime};
 use util::serde::default_true;

crates/language/src/toolchain.rs πŸ”—

@@ -29,6 +29,40 @@ pub struct Toolchain {
     pub as_json: serde_json::Value,
 }
 
+/// Declares a scope of a toolchain added by user.
+///
+/// When the user adds a toolchain, we give them an option to see that toolchain in:
+/// - All of their projects
+/// - A project they're currently in.
+/// - Only in the subproject they're currently in.
+#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub enum ToolchainScope {
+    Subproject(WorktreeId, Arc<Path>),
+    Project,
+    /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines.
+    Global,
+}
+
+impl ToolchainScope {
+    pub fn label(&self) -> &'static str {
+        match self {
+            ToolchainScope::Subproject(_, _) => "Subproject",
+            ToolchainScope::Project => "Project",
+            ToolchainScope::Global => "Global",
+        }
+    }
+
+    pub fn description(&self) -> &'static str {
+        match self {
+            ToolchainScope::Subproject(_, _) => {
+                "Available only in the subproject you're currently in."
+            }
+            ToolchainScope::Project => "Available in all locations in your current project.",
+            ToolchainScope::Global => "Available in all of your projects on this machine.",
+        }
+    }
+}
+
 impl std::hash::Hash for Toolchain {
     fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
         let Self {
@@ -58,23 +92,41 @@ impl PartialEq for Toolchain {
 }
 
 #[async_trait]
-pub trait ToolchainLister: Send + Sync {
+pub trait ToolchainLister: Send + Sync + 'static {
+    /// List all available toolchains for a given path.
     async fn list(
         &self,
         worktree_root: PathBuf,
         subroot_relative_path: Arc<Path>,
         project_env: Option<HashMap<String, String>>,
     ) -> ToolchainList;
-    // Returns a term which we should use in UI to refer to a toolchain.
-    fn term(&self) -> SharedString;
-    /// Returns the name of the manifest file for this toolchain.
-    fn manifest_name(&self) -> ManifestName;
+
+    /// Given a user-created toolchain, resolve lister-specific details.
+    /// Put another way: fill in the details of the toolchain so the user does not have to.
+    async fn resolve(
+        &self,
+        path: PathBuf,
+        project_env: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<Toolchain>;
+
     async fn activation_script(
         &self,
         toolchain: &Toolchain,
         shell: ShellKind,
         fs: &dyn Fs,
     ) -> Vec<String>;
+    /// Returns various "static" bits of information about this toolchain lister. This function should be pure.
+    fn meta(&self) -> ToolchainMetadata;
+}
+
+#[derive(Clone, PartialEq, Eq, Hash)]
+pub struct ToolchainMetadata {
+    /// Returns a term which we should use in UI to refer to toolchains produced by a given `[ToolchainLister]`.
+    pub term: SharedString,
+    /// A user-facing placeholder describing the semantic meaning of a path to a new toolchain.
+    pub new_toolchain_placeholder: SharedString,
+    /// The name of the manifest file for this toolchain.
+    pub manifest_name: ManifestName,
 }
 
 #[async_trait(?Send)]

crates/languages/src/lib.rs πŸ”—

@@ -97,7 +97,7 @@ pub fn init(languages: Arc<LanguageRegistry>, node: NodeRuntime, cx: &mut App) {
     let python_context_provider = Arc::new(python::PythonContextProvider);
     let python_lsp_adapter = Arc::new(python::PythonLspAdapter::new(node.clone()));
     let basedpyright_lsp_adapter = Arc::new(BasedPyrightLspAdapter::new());
-    let python_toolchain_provider = Arc::new(python::PythonToolchainProvider::default());
+    let python_toolchain_provider = Arc::new(python::PythonToolchainProvider);
     let rust_context_provider = Arc::new(rust::RustContextProvider);
     let rust_lsp_adapter = Arc::new(rust::RustLspAdapter);
     let tailwind_adapter = Arc::new(tailwind::TailwindLspAdapter::new(node.clone()));

crates/languages/src/python.rs πŸ”—

@@ -5,19 +5,19 @@ use collections::HashMap;
 use futures::AsyncBufReadExt;
 use gpui::{App, Task};
 use gpui::{AsyncApp, SharedString};
-use language::Toolchain;
 use language::ToolchainList;
 use language::ToolchainLister;
 use language::language_settings::language_settings;
 use language::{ContextLocation, LanguageToolchainStore};
 use language::{ContextProvider, LspAdapter, LspAdapterDelegate};
 use language::{LanguageName, ManifestName, ManifestProvider, ManifestQuery};
+use language::{Toolchain, ToolchainMetadata};
 use lsp::LanguageServerBinary;
 use lsp::LanguageServerName;
 use node_runtime::{NodeRuntime, VersionStrategy};
 use pet_core::Configuration;
 use pet_core::os_environment::Environment;
-use pet_core::python_environment::PythonEnvironmentKind;
+use pet_core::python_environment::{PythonEnvironment, PythonEnvironmentKind};
 use project::Fs;
 use project::lsp_store::language_server_settings;
 use serde_json::{Value, json};
@@ -688,17 +688,7 @@ fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str {
     }
 }
 
-pub(crate) struct PythonToolchainProvider {
-    term: SharedString,
-}
-
-impl Default for PythonToolchainProvider {
-    fn default() -> Self {
-        Self {
-            term: SharedString::new_static("Virtual Environment"),
-        }
-    }
-}
+pub(crate) struct PythonToolchainProvider;
 
 static ENV_PRIORITY_LIST: &[PythonEnvironmentKind] = &[
     // Prioritize non-Conda environments.
@@ -744,9 +734,6 @@ async fn get_worktree_venv_declaration(worktree_root: &Path) -> Option<String> {
 
 #[async_trait]
 impl ToolchainLister for PythonToolchainProvider {
-    fn manifest_name(&self) -> language::ManifestName {
-        ManifestName::from(SharedString::new_static("pyproject.toml"))
-    }
     async fn list(
         &self,
         worktree_root: PathBuf,
@@ -847,32 +834,7 @@ impl ToolchainLister for PythonToolchainProvider {
 
         let mut toolchains: Vec<_> = toolchains
             .into_iter()
-            .filter_map(|toolchain| {
-                let mut name = String::from("Python");
-                if let Some(version) = &toolchain.version {
-                    _ = write!(name, " {version}");
-                }
-
-                let name_and_kind = match (&toolchain.name, &toolchain.kind) {
-                    (Some(name), Some(kind)) => {
-                        Some(format!("({name}; {})", python_env_kind_display(kind)))
-                    }
-                    (Some(name), None) => Some(format!("({name})")),
-                    (None, Some(kind)) => Some(format!("({})", python_env_kind_display(kind))),
-                    (None, None) => None,
-                };
-
-                if let Some(nk) = name_and_kind {
-                    _ = write!(name, " {nk}");
-                }
-
-                Some(Toolchain {
-                    name: name.into(),
-                    path: toolchain.executable.as_ref()?.to_str()?.to_owned().into(),
-                    language_name: LanguageName::new("Python"),
-                    as_json: serde_json::to_value(toolchain.clone()).ok()?,
-                })
-            })
+            .filter_map(venv_to_toolchain)
             .collect();
         toolchains.dedup();
         ToolchainList {
@@ -881,9 +843,34 @@ impl ToolchainLister for PythonToolchainProvider {
             groups: Default::default(),
         }
     }
-    fn term(&self) -> SharedString {
-        self.term.clone()
+    fn meta(&self) -> ToolchainMetadata {
+        ToolchainMetadata {
+            term: SharedString::new_static("Virtual Environment"),
+            new_toolchain_placeholder: SharedString::new_static(
+                "A path to the python3 executable within a virtual environment, or path to virtual environment itself",
+            ),
+            manifest_name: ManifestName::from(SharedString::new_static("pyproject.toml")),
+        }
+    }
+
+    async fn resolve(
+        &self,
+        path: PathBuf,
+        env: Option<HashMap<String, String>>,
+    ) -> anyhow::Result<Toolchain> {
+        let env = env.unwrap_or_default();
+        let environment = EnvironmentApi::from_env(&env);
+        let locators = pet::locators::create_locators(
+            Arc::new(pet_conda::Conda::from(&environment)),
+            Arc::new(pet_poetry::Poetry::from(&environment)),
+            &environment,
+        );
+        let toolchain = pet::resolve::resolve_environment(&path, &locators, &environment)
+            .context("Could not find a virtual environment in provided path")?;
+        let venv = toolchain.resolved.unwrap_or(toolchain.discovered);
+        venv_to_toolchain(venv).context("Could not convert a venv into a toolchain")
     }
+
     async fn activation_script(
         &self,
         toolchain: &Toolchain,
@@ -956,6 +943,31 @@ impl ToolchainLister for PythonToolchainProvider {
     }
 }
 
+fn venv_to_toolchain(venv: PythonEnvironment) -> Option<Toolchain> {
+    let mut name = String::from("Python");
+    if let Some(ref version) = venv.version {
+        _ = write!(name, " {version}");
+    }
+
+    let name_and_kind = match (&venv.name, &venv.kind) {
+        (Some(name), Some(kind)) => Some(format!("({name}; {})", python_env_kind_display(kind))),
+        (Some(name), None) => Some(format!("({name})")),
+        (None, Some(kind)) => Some(format!("({})", python_env_kind_display(kind))),
+        (None, None) => None,
+    };
+
+    if let Some(nk) = name_and_kind {
+        _ = write!(name, " {nk}");
+    }
+
+    Some(Toolchain {
+        name: name.into(),
+        path: venv.executable.as_ref()?.to_str()?.to_owned().into(),
+        language_name: LanguageName::new("Python"),
+        as_json: serde_json::to_value(venv).ok()?,
+    })
+}
+
 pub struct EnvironmentApi<'a> {
     global_search_locations: Arc<Mutex<Vec<PathBuf>>>,
     project_env: &'a HashMap<String, String>,

crates/project/src/lsp_store.rs πŸ”—

@@ -3933,8 +3933,8 @@ impl LspStore {
         event: &ToolchainStoreEvent,
         _: &mut Context<Self>,
     ) {
-        match event {
-            ToolchainStoreEvent::ToolchainActivated => self.request_workspace_config_refresh(),
+        if let ToolchainStoreEvent::ToolchainActivated = event {
+            self.request_workspace_config_refresh()
         }
     }
 

crates/project/src/project.rs πŸ”—

@@ -48,7 +48,7 @@ use clock::ReplicaId;
 
 use dap::client::DebugAdapterClient;
 
-use collections::{BTreeSet, HashMap, HashSet};
+use collections::{BTreeSet, HashMap, HashSet, IndexSet};
 use debounced_delay::DebouncedDelay;
 pub use debugger::breakpoint_store::BreakpointWithPosition;
 use debugger::{
@@ -74,8 +74,9 @@ use gpui::{
 };
 use language::{
     Buffer, BufferEvent, Capability, CodeLabel, CursorShape, Language, LanguageName,
-    LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainList, Transaction,
-    Unclipped, language_settings::InlayHintKind, proto::split_operations,
+    LanguageRegistry, PointUtf16, ToOffset, ToPointUtf16, Toolchain, ToolchainMetadata,
+    ToolchainScope, Transaction, Unclipped, language_settings::InlayHintKind,
+    proto::split_operations,
 };
 use lsp::{
     CodeActionKind, CompletionContext, CompletionItemKind, DocumentHighlightKind, InsertTextMode,
@@ -104,6 +105,7 @@ use snippet::Snippet;
 use snippet_provider::SnippetProvider;
 use std::{
     borrow::Cow,
+    collections::BTreeMap,
     ops::Range,
     path::{Component, Path, PathBuf},
     pin::pin,
@@ -117,7 +119,7 @@ use terminals::Terminals;
 use text::{Anchor, BufferId, OffsetRangeExt, Point, Rope};
 use toolchain_store::EmptyToolchainStore;
 use util::{
-    ResultExt as _,
+    ResultExt as _, maybe,
     paths::{PathStyle, RemotePathBuf, SanitizedPath, compare_paths},
 };
 use worktree::{CreatedEntry, Snapshot, Traversal};
@@ -142,7 +144,7 @@ pub use lsp_store::{
     LanguageServerStatus, LanguageServerToQuery, LspStore, LspStoreEvent,
     SERVER_PROGRESS_THROTTLE_TIMEOUT,
 };
-pub use toolchain_store::ToolchainStore;
+pub use toolchain_store::{ToolchainStore, Toolchains};
 const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500;
 const MAX_SEARCH_RESULT_FILES: usize = 5_000;
 const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
@@ -3370,7 +3372,7 @@ impl Project {
         path: ProjectPath,
         language_name: LanguageName,
         cx: &App,
-    ) -> Task<Option<(ToolchainList, Arc<Path>)>> {
+    ) -> Task<Option<Toolchains>> {
         if let Some(toolchain_store) = self.toolchain_store.as_ref().map(Entity::downgrade) {
             cx.spawn(async move |cx| {
                 toolchain_store
@@ -3383,16 +3385,70 @@ impl Project {
         }
     }
 
-    pub async fn toolchain_term(
+    pub async fn toolchain_metadata(
         languages: Arc<LanguageRegistry>,
         language_name: LanguageName,
-    ) -> Option<SharedString> {
+    ) -> Option<ToolchainMetadata> {
         languages
             .language_for_name(language_name.as_ref())
             .await
             .ok()?
             .toolchain_lister()
-            .map(|lister| lister.term())
+            .map(|lister| lister.meta())
+    }
+
+    pub fn add_toolchain(
+        &self,
+        toolchain: Toolchain,
+        scope: ToolchainScope,
+        cx: &mut Context<Self>,
+    ) {
+        maybe!({
+            self.toolchain_store.as_ref()?.update(cx, |this, cx| {
+                this.add_toolchain(toolchain, scope, cx);
+            });
+            Some(())
+        });
+    }
+
+    pub fn remove_toolchain(
+        &self,
+        toolchain: Toolchain,
+        scope: ToolchainScope,
+        cx: &mut Context<Self>,
+    ) {
+        maybe!({
+            self.toolchain_store.as_ref()?.update(cx, |this, cx| {
+                this.remove_toolchain(toolchain, scope, cx);
+            });
+            Some(())
+        });
+    }
+
+    pub fn user_toolchains(
+        &self,
+        cx: &App,
+    ) -> Option<BTreeMap<ToolchainScope, IndexSet<Toolchain>>> {
+        Some(self.toolchain_store.as_ref()?.read(cx).user_toolchains())
+    }
+
+    pub fn resolve_toolchain(
+        &self,
+        path: PathBuf,
+        language_name: LanguageName,
+        cx: &App,
+    ) -> Task<Result<Toolchain>> {
+        if let Some(toolchain_store) = self.toolchain_store.as_ref().map(Entity::downgrade) {
+            cx.spawn(async move |cx| {
+                toolchain_store
+                    .update(cx, |this, cx| {
+                        this.resolve_toolchain(path, language_name, cx)
+                    })?
+                    .await
+            })
+        } else {
+            Task::ready(Err(anyhow!("This project does not support toolchains")))
+        }
     }
 
     pub fn toolchain_store(&self) -> Option<Entity<ToolchainStore>> {

crates/project/src/project_tests.rs πŸ”—

@@ -22,7 +22,7 @@ use itertools::Itertools;
 use language::{
     Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, DiskState, FakeLspAdapter,
     LanguageConfig, LanguageMatcher, LanguageName, LineEnding, ManifestName, ManifestProvider,
-    ManifestQuery, OffsetRangeExt, Point, ToPoint, ToolchainLister,
+    ManifestQuery, OffsetRangeExt, Point, ToPoint, ToolchainList, ToolchainLister,
     language_settings::{AllLanguageSettings, LanguageSettingsContent, language_settings},
     tree_sitter_rust, tree_sitter_typescript,
 };
@@ -727,7 +727,12 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree(
     // We're not using venvs at all here, so both folders should fall under the same root.
     assert_eq!(server.server_id(), LanguageServerId(0));
     // Now, let's select a different toolchain for one of subprojects.
-    let (available_toolchains_for_b, root_path) = project
+
+    let Toolchains {
+        toolchains: available_toolchains_for_b,
+        root_path,
+        ..
+    } = project
         .update(cx, |this, cx| {
             let worktree_id = this.worktrees(cx).next().unwrap().read(cx).id();
             this.available_toolchains(
@@ -9213,13 +9218,21 @@ fn python_lang(fs: Arc<FakeFs>) -> Arc<Language> {
                 ..Default::default()
             }
         }
-        // Returns a term which we should use in UI to refer to a toolchain.
-        fn term(&self) -> SharedString {
-            SharedString::new_static("virtual environment")
+        async fn resolve(
+            &self,
+            _: PathBuf,
+            _: Option<HashMap<String, String>>,
+        ) -> anyhow::Result<Toolchain> {
+            Err(anyhow::anyhow!("Not implemented"))
         }
-        /// Returns the name of the manifest file for this toolchain.
-        fn manifest_name(&self) -> ManifestName {
-            SharedString::new_static("pyproject.toml").into()
+        fn meta(&self) -> ToolchainMetadata {
+            ToolchainMetadata {
+                term: SharedString::new_static("Virtual Environment"),
+                new_toolchain_placeholder: SharedString::new_static(
+                    "A path to the python3 executable within a virtual environment, or path to virtual environment itself",
+                ),
+                manifest_name: ManifestName::from(SharedString::new_static("pyproject.toml")),
+            }
         }
         async fn activation_script(&self, _: &Toolchain, _: ShellKind, _: &dyn Fs) -> Vec<String> {
             vec![]

crates/project/src/toolchain_store.rs πŸ”—

@@ -4,20 +4,23 @@ use std::{
     sync::Arc,
 };
 
-use anyhow::{Result, bail};
+use anyhow::{Context as _, Result, bail};
 
 use async_trait::async_trait;
-use collections::BTreeMap;
+use collections::{BTreeMap, IndexSet};
 use gpui::{
     App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity,
 };
 use language::{
     LanguageName, LanguageRegistry, LanguageToolchainStore, ManifestDelegate, Toolchain,
-    ToolchainList,
+    ToolchainList, ToolchainScope,
 };
 use rpc::{
     AnyProtoClient, TypedEnvelope,
-    proto::{self, FromProto, ToProto},
+    proto::{
+        self, FromProto, ResolveToolchainResponse, ToProto,
+        resolve_toolchain_response::Response as ResolveResponsePayload,
+    },
 };
 use settings::WorktreeId;
 use util::ResultExt as _;
@@ -28,24 +31,31 @@ use crate::{
     worktree_store::WorktreeStore,
 };
 
-pub struct ToolchainStore(ToolchainStoreInner);
+pub struct ToolchainStore {
+    mode: ToolchainStoreInner,
+    user_toolchains: BTreeMap<ToolchainScope, IndexSet<Toolchain>>,
+    _sub: Subscription,
+}
+
 enum ToolchainStoreInner {
-    Local(
-        Entity<LocalToolchainStore>,
-        #[allow(dead_code)] Subscription,
-    ),
-    Remote(
-        Entity<RemoteToolchainStore>,
-        #[allow(dead_code)] Subscription,
-    ),
+    Local(Entity<LocalToolchainStore>),
+    Remote(Entity<RemoteToolchainStore>),
 }
 
+pub struct Toolchains {
+    /// Auto-detected toolchains.
+    pub toolchains: ToolchainList,
+    /// Path of the project root at which we ran the automatic toolchain detection.
+    pub root_path: Arc<Path>,
+    pub user_toolchains: BTreeMap<ToolchainScope, IndexSet<Toolchain>>,
+}
 impl EventEmitter<ToolchainStoreEvent> for ToolchainStore {}
 impl ToolchainStore {
     pub fn init(client: &AnyProtoClient) {
         client.add_entity_request_handler(Self::handle_activate_toolchain);
         client.add_entity_request_handler(Self::handle_list_toolchains);
         client.add_entity_request_handler(Self::handle_active_toolchain);
+        client.add_entity_request_handler(Self::handle_resolve_toolchain);
     }
 
     pub fn local(
@@ -62,18 +72,26 @@ impl ToolchainStore {
             active_toolchains: Default::default(),
             manifest_tree,
         });
-        let subscription = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| {
+        let _sub = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| {
             cx.emit(e.clone())
         });
-        Self(ToolchainStoreInner::Local(entity, subscription))
+        Self {
+            mode: ToolchainStoreInner::Local(entity),
+            user_toolchains: Default::default(),
+            _sub,
+        }
     }
 
     pub(super) fn remote(project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) -> Self {
         let entity = cx.new(|_| RemoteToolchainStore { client, project_id });
-        let _subscription = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| {
+        let _sub = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| {
             cx.emit(e.clone())
         });
-        Self(ToolchainStoreInner::Remote(entity, _subscription))
+        Self {
+            mode: ToolchainStoreInner::Remote(entity),
+            user_toolchains: Default::default(),
+            _sub,
+        }
     }
     pub(crate) fn activate_toolchain(
         &self,
@@ -81,43 +99,130 @@ impl ToolchainStore {
         toolchain: Toolchain,
         cx: &mut App,
     ) -> Task<Option<()>> {
-        match &self.0 {
-            ToolchainStoreInner::Local(local, _) => {
+        match &self.mode {
+            ToolchainStoreInner::Local(local) => {
                 local.update(cx, |this, cx| this.activate_toolchain(path, toolchain, cx))
             }
-            ToolchainStoreInner::Remote(remote, _) => {
+            ToolchainStoreInner::Remote(remote) => {
                 remote.update(cx, |this, cx| this.activate_toolchain(path, toolchain, cx))
             }
         }
     }
+
+    pub(crate) fn user_toolchains(&self) -> BTreeMap<ToolchainScope, IndexSet<Toolchain>> {
+        self.user_toolchains.clone()
+    }
+    pub(crate) fn add_toolchain(
+        &mut self,
+        toolchain: Toolchain,
+        scope: ToolchainScope,
+        cx: &mut Context<Self>,
+    ) {
+        let did_insert = self
+            .user_toolchains
+            .entry(scope)
+            .or_default()
+            .insert(toolchain);
+        if did_insert {
+            cx.emit(ToolchainStoreEvent::CustomToolchainsModified);
+        }
+    }
+
+    pub(crate) fn remove_toolchain(
+        &mut self,
+        toolchain: Toolchain,
+        scope: ToolchainScope,
+        cx: &mut Context<Self>,
+    ) {
+        let mut did_remove = false;
+        self.user_toolchains
+            .entry(scope)
+            .and_modify(|toolchains| did_remove = toolchains.shift_remove(&toolchain));
+        if did_remove {
+            cx.emit(ToolchainStoreEvent::CustomToolchainsModified);
+        }
+    }
+
+    pub(crate) fn resolve_toolchain(
+        &self,
+        abs_path: PathBuf,
+        language_name: LanguageName,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<Toolchain>> {
+        debug_assert!(abs_path.is_absolute());
+        match &self.mode {
+            ToolchainStoreInner::Local(local) => local.update(cx, |this, cx| {
+                this.resolve_toolchain(abs_path, language_name, cx)
+            }),
+            ToolchainStoreInner::Remote(remote) => remote.update(cx, |this, cx| {
+                this.resolve_toolchain(abs_path, language_name, cx)
+            }),
+        }
+    }
     pub(crate) fn list_toolchains(
         &self,
         path: ProjectPath,
         language_name: LanguageName,
         cx: &mut Context<Self>,
-    ) -> Task<Option<(ToolchainList, Arc<Path>)>> {
-        match &self.0 {
-            ToolchainStoreInner::Local(local, _) => {
+    ) -> Task<Option<Toolchains>> {
+        let user_toolchains = self
+            .user_toolchains
+            .iter()
+            .filter(|(scope, _)| {
+                if let ToolchainScope::Subproject(worktree_id, relative_path) = scope {
+                    path.worktree_id == *worktree_id && relative_path.starts_with(&path.path)
+                } else {
+                    true
+                }
+            })
+            .map(|(scope, toolchains)| {
+                (
+                    scope.clone(),
+                    toolchains
+                        .iter()
+                        .filter(|toolchain| toolchain.language_name == language_name)
+                        .cloned()
+                        .collect::<IndexSet<_>>(),
+                )
+            })
+            .collect::<BTreeMap<_, _>>();
+        let task = match &self.mode {
+            ToolchainStoreInner::Local(local) => {
                 local.update(cx, |this, cx| this.list_toolchains(path, language_name, cx))
             }
-            ToolchainStoreInner::Remote(remote, _) => {
+            ToolchainStoreInner::Remote(remote) => {
                 remote.read(cx).list_toolchains(path, language_name, cx)
             }
-        }
+        };
+        cx.spawn(async move |_, _| {
+            let (mut toolchains, root_path) = task.await?;
+            toolchains.toolchains.retain(|toolchain| {
+                !user_toolchains
+                    .values()
+                    .any(|toolchains| toolchains.contains(toolchain))
+            });
+
+            Some(Toolchains {
+                toolchains,
+                root_path,
+                user_toolchains,
+            })
+        })
     }
+
     pub(crate) fn active_toolchain(
         &self,
         path: ProjectPath,
         language_name: LanguageName,
         cx: &App,
     ) -> Task<Option<Toolchain>> {
-        match &self.0 {
-            ToolchainStoreInner::Local(local, _) => Task::ready(local.read(cx).active_toolchain(
+        match &self.mode {
+            ToolchainStoreInner::Local(local) => Task::ready(local.read(cx).active_toolchain(
                 path.worktree_id,
                 &path.path,
                 language_name,
             )),
-            ToolchainStoreInner::Remote(remote, _) => {
+            ToolchainStoreInner::Remote(remote) => {
                 remote.read(cx).active_toolchain(path, language_name, cx)
             }
         }
@@ -197,7 +302,7 @@ impl ToolchainStore {
             })?
             .await;
         let has_values = toolchains.is_some();
-        let groups = if let Some((toolchains, _)) = &toolchains {
+        let groups = if let Some(Toolchains { toolchains, .. }) = &toolchains {
             toolchains
                 .groups
                 .iter()
@@ -211,7 +316,12 @@ impl ToolchainStore {
         } else {
             vec![]
         };
-        let (toolchains, relative_path) = if let Some((toolchains, relative_path)) = toolchains {
+        let (toolchains, relative_path) = if let Some(Toolchains {
+            toolchains,
+            root_path: relative_path,
+            ..
+        }) = toolchains
+        {
             let toolchains = toolchains
                 .toolchains
                 .into_iter()
@@ -236,16 +346,45 @@ impl ToolchainStore {
             relative_worktree_path: Some(relative_path.to_string_lossy().into_owned()),
         })
     }
+
+    async fn handle_resolve_toolchain(
+        this: Entity<Self>,
+        envelope: TypedEnvelope<proto::ResolveToolchain>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::ResolveToolchainResponse> {
+        let toolchain = this
+            .update(&mut cx, |this, cx| {
+                let language_name = LanguageName::from_proto(envelope.payload.language_name);
+                let path = PathBuf::from(envelope.payload.abs_path);
+                this.resolve_toolchain(path, language_name, cx)
+            })?
+            .await;
+        let response = match toolchain {
+            Ok(toolchain) => {
+                let toolchain = proto::Toolchain {
+                    name: toolchain.name.to_string(),
+                    path: toolchain.path.to_string(),
+                    raw_json: toolchain.as_json.to_string(),
+                };
+                ResolveResponsePayload::Toolchain(toolchain)
+            }
+            Err(e) => ResolveResponsePayload::Error(e.to_string()),
+        };
+        Ok(ResolveToolchainResponse {
+            response: Some(response),
+        })
+    }
+
     pub fn as_language_toolchain_store(&self) -> Arc<dyn LanguageToolchainStore> {
-        match &self.0 {
-            ToolchainStoreInner::Local(local, _) => Arc::new(LocalStore(local.downgrade())),
-            ToolchainStoreInner::Remote(remote, _) => Arc::new(RemoteStore(remote.downgrade())),
+        match &self.mode {
+            ToolchainStoreInner::Local(local) => Arc::new(LocalStore(local.downgrade())),
+            ToolchainStoreInner::Remote(remote) => Arc::new(RemoteStore(remote.downgrade())),
         }
     }
     pub fn as_local_store(&self) -> Option<&Entity<LocalToolchainStore>> {
-        match &self.0 {
-            ToolchainStoreInner::Local(local, _) => Some(local),
-            ToolchainStoreInner::Remote(_, _) => None,
+        match &self.mode {
+            ToolchainStoreInner::Local(local) => Some(local),
+            ToolchainStoreInner::Remote(_) => None,
         }
     }
 }
@@ -311,6 +450,7 @@ struct RemoteStore(WeakEntity<RemoteToolchainStore>);
 #[derive(Clone)]
 pub enum ToolchainStoreEvent {
     ToolchainActivated,
+    CustomToolchainsModified,
 }
 
 impl EventEmitter<ToolchainStoreEvent> for LocalToolchainStore {}
@@ -351,7 +491,7 @@ impl LocalToolchainStore {
                 .await
                 .ok()?;
             let toolchains = language.toolchain_lister()?;
-            let manifest_name = toolchains.manifest_name();
+            let manifest_name = toolchains.meta().manifest_name;
             let (snapshot, worktree) = this
                 .update(cx, |this, cx| {
                     this.worktree_store
@@ -414,6 +554,33 @@ impl LocalToolchainStore {
             })
             .cloned()
     }
+
+    fn resolve_toolchain(
+        &self,
+        path: PathBuf,
+        language_name: LanguageName,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<Toolchain>> {
+        let registry = self.languages.clone();
+        let environment = self.project_environment.clone();
+        cx.spawn(async move |_, cx| {
+            let language = cx
+                .background_spawn(registry.language_for_name(&language_name.0))
+                .await
+                .with_context(|| format!("Language {} not found", language_name.0))?;
+            let toolchain_lister = language.toolchain_lister().with_context(|| {
+                format!("Language {} does not support toolchains", language_name.0)
+            })?;
+
+            let project_env = environment
+                .update(cx, |environment, cx| {
+                    environment.get_directory_environment(path.as_path().into(), cx)
+                })?
+                .await;
+            cx.background_spawn(async move { toolchain_lister.resolve(path, project_env).await })
+                .await
+        })
+    }
 }
 
 impl EventEmitter<ToolchainStoreEvent> for RemoteToolchainStore {}
@@ -556,4 +723,47 @@ impl RemoteToolchainStore {
             })
         })
     }
+
+    fn resolve_toolchain(
+        &self,
+        abs_path: PathBuf,
+        language_name: LanguageName,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<Toolchain>> {
+        let project_id = self.project_id;
+        let client = self.client.clone();
+        cx.background_spawn(async move {
+            let response: proto::ResolveToolchainResponse = client
+                .request(proto::ResolveToolchain {
+                    project_id,
+                    language_name: language_name.clone().into(),
+                    abs_path: abs_path.to_string_lossy().into_owned(),
+                })
+                .await?;
+
+            let response = response
+                .response
+                .context("Failed to resolve toolchain via RPC")?;
+            use proto::resolve_toolchain_response::Response;
+            match response {
+                Response::Toolchain(toolchain) => {
+                    Ok(Toolchain {
+                        language_name: language_name.clone(),
+                        name: toolchain.name.into(),
+                        // todo(windows)
+                        // Do we need to convert path to native string?
+                        path: PathBuf::from_proto(toolchain.path)
+                            .to_string_lossy()
+                            .to_string()
+                            .into(),
+                        as_json: serde_json::Value::from_str(&toolchain.raw_json)
+                            .context("Deserializing ResolveToolchain LSP response")?,
+                    })
+                }
+                Response::Error(error) => {
+                    anyhow::bail!("{error}");
+                }
+            }
+        })
+    }
 }

crates/proto/proto/toolchain.proto πŸ”—

@@ -44,3 +44,16 @@ message ActiveToolchain {
 message ActiveToolchainResponse {
     optional Toolchain toolchain = 1;
 }
+
+message ResolveToolchain {
+    uint64 project_id = 1;
+    string abs_path = 2;
+    string language_name = 3;
+}
+
+message ResolveToolchainResponse {
+    oneof response {
+        Toolchain toolchain = 1;
+        string error = 2;
+    }
+}

crates/proto/proto/zed.proto πŸ”—

@@ -402,7 +402,10 @@ message Envelope {
         UpdateUserSettings update_user_settings = 368;
 
         GetProcesses get_processes = 369;
-        GetProcessesResponse get_processes_response = 370; // current max
+        GetProcessesResponse get_processes_response = 370;
+
+        ResolveToolchain resolve_toolchain = 371;
+        ResolveToolchainResponse resolve_toolchain_response = 372; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs πŸ”—

@@ -26,6 +26,8 @@ messages!(
     (ActivateToolchain, Foreground),
     (ActiveToolchain, Foreground),
     (ActiveToolchainResponse, Foreground),
+    (ResolveToolchain, Background),
+    (ResolveToolchainResponse, Background),
     (AddNotification, Foreground),
     (AddProjectCollaborator, Foreground),
     (AddWorktree, Foreground),
@@ -459,6 +461,7 @@ request_messages!(
     (ListToolchains, ListToolchainsResponse),
     (ActivateToolchain, Ack),
     (ActiveToolchain, ActiveToolchainResponse),
+    (ResolveToolchain, ResolveToolchainResponse),
     (GetPathMetadata, GetPathMetadataResponse),
     (GetCrashFiles, GetCrashFilesResponse),
     (CancelLanguageServerWork, Ack),
@@ -612,6 +615,7 @@ entity_messages!(
     ListToolchains,
     ActivateToolchain,
     ActiveToolchain,
+    ResolveToolchain,
     GetPathMetadata,
     GetProcesses,
     CancelLanguageServerWork,

crates/repl/src/kernels/mod.rs πŸ”—

@@ -11,7 +11,7 @@ use language::LanguageName;
 pub use native_kernel::*;
 
 mod remote_kernels;
-use project::{Project, ProjectPath, WorktreeId};
+use project::{Project, ProjectPath, Toolchains, WorktreeId};
 pub use remote_kernels::*;
 
 use anyhow::Result;
@@ -92,49 +92,58 @@ pub fn python_env_kernel_specifications(
     let background_executor = cx.background_executor().clone();
 
     async move {
-        let toolchains = if let Some((toolchains, _)) = toolchains.await {
-            toolchains
+        let (toolchains, user_toolchains) = if let Some(Toolchains {
+            toolchains,
+            root_path: _,
+            user_toolchains,
+        }) = toolchains.await
+        {
+            (toolchains, user_toolchains)
         } else {
             return Ok(Vec::new());
         };
 
-        let kernelspecs = toolchains.toolchains.into_iter().map(|toolchain| {
-            background_executor.spawn(async move {
-                let python_path = toolchain.path.to_string();
-
-                // Check if ipykernel is installed
-                let ipykernel_check = util::command::new_smol_command(&python_path)
-                    .args(&["-c", "import ipykernel"])
-                    .output()
-                    .await;
-
-                if ipykernel_check.is_ok() && ipykernel_check.unwrap().status.success() {
-                    // Create a default kernelspec for this environment
-                    let default_kernelspec = JupyterKernelspec {
-                        argv: vec![
-                            python_path.clone(),
-                            "-m".to_string(),
-                            "ipykernel_launcher".to_string(),
-                            "-f".to_string(),
-                            "{connection_file}".to_string(),
-                        ],
-                        display_name: toolchain.name.to_string(),
-                        language: "python".to_string(),
-                        interrupt_mode: None,
-                        metadata: None,
-                        env: None,
-                    };
-
-                    Some(KernelSpecification::PythonEnv(LocalKernelSpecification {
-                        name: toolchain.name.to_string(),
-                        path: PathBuf::from(&python_path),
-                        kernelspec: default_kernelspec,
-                    }))
-                } else {
-                    None
-                }
-            })
-        });
+        let kernelspecs = user_toolchains
+            .into_values()
+            .flatten()
+            .chain(toolchains.toolchains)
+            .map(|toolchain| {
+                background_executor.spawn(async move {
+                    let python_path = toolchain.path.to_string();
+
+                    // Check if ipykernel is installed
+                    let ipykernel_check = util::command::new_smol_command(&python_path)
+                        .args(&["-c", "import ipykernel"])
+                        .output()
+                        .await;
+
+                    if ipykernel_check.is_ok() && ipykernel_check.unwrap().status.success() {
+                        // Create a default kernelspec for this environment
+                        let default_kernelspec = JupyterKernelspec {
+                            argv: vec![
+                                python_path.clone(),
+                                "-m".to_string(),
+                                "ipykernel_launcher".to_string(),
+                                "-f".to_string(),
+                                "{connection_file}".to_string(),
+                            ],
+                            display_name: toolchain.name.to_string(),
+                            language: "python".to_string(),
+                            interrupt_mode: None,
+                            metadata: None,
+                            env: None,
+                        };
+
+                        Some(KernelSpecification::PythonEnv(LocalKernelSpecification {
+                            name: toolchain.name.to_string(),
+                            path: PathBuf::from(&python_path),
+                            kernelspec: default_kernelspec,
+                        }))
+                    } else {
+                        None
+                    }
+                })
+            });
 
         let kernel_specs = futures::future::join_all(kernelspecs)
             .await

crates/toolchain_selector/Cargo.toml πŸ”—

@@ -6,10 +6,15 @@ publish.workspace = true
 license = "GPL-3.0-or-later"
 
 [dependencies]
+anyhow.workspace = true
+convert_case.workspace = true
 editor.workspace = true
+file_finder.workspace = true
+futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 language.workspace = true
+menu.workspace = true
 picker.workspace = true
 project.workspace = true
 ui.workspace = true

crates/toolchain_selector/src/active_toolchain.rs πŸ”—

@@ -5,8 +5,8 @@ use gpui::{
     AsyncWindowContext, Context, Entity, IntoElement, ParentElement, Render, Subscription, Task,
     WeakEntity, Window, div,
 };
-use language::{Buffer, BufferEvent, LanguageName, Toolchain};
-use project::{Project, ProjectPath, WorktreeId, toolchain_store::ToolchainStoreEvent};
+use language::{Buffer, BufferEvent, LanguageName, Toolchain, ToolchainScope};
+use project::{Project, ProjectPath, Toolchains, WorktreeId, toolchain_store::ToolchainStoreEvent};
 use ui::{Button, ButtonCommon, Clickable, FluentBuilder, LabelSize, SharedString, Tooltip};
 use util::maybe;
 use workspace::{StatusItemView, Workspace, item::ItemHandle};
@@ -69,15 +69,15 @@ impl ActiveToolchain {
                     .read_with(cx, |this, _| Some(this.language()?.name()))
                     .ok()
                     .flatten()?;
-                let term = workspace
+                let meta = workspace
                     .update(cx, |workspace, cx| {
                         let languages = workspace.project().read(cx).languages();
-                        Project::toolchain_term(languages.clone(), language_name.clone())
+                        Project::toolchain_metadata(languages.clone(), language_name.clone())
                     })
                     .ok()?
                     .await?;
                 let _ = this.update(cx, |this, cx| {
-                    this.term = term;
+                    this.term = meta.term;
                     cx.notify();
                 });
                 let (worktree_id, path) = active_file
@@ -170,7 +170,11 @@ impl ActiveToolchain {
                 let project = workspace
                     .read_with(cx, |this, _| this.project().clone())
                     .ok()?;
-                let (toolchains, relative_path) = cx
+                let Toolchains {
+                    toolchains,
+                    root_path: relative_path,
+                    user_toolchains,
+                } = cx
                     .update(|_, cx| {
                         project.read(cx).available_toolchains(
                             ProjectPath {
@@ -183,8 +187,20 @@ impl ActiveToolchain {
                     })
                     .ok()?
                     .await?;
-                if let Some(toolchain) = toolchains.toolchains.first() {
-                    // Since we don't have a selected toolchain, pick one for user here.
+                // Since we don't have a selected toolchain, pick one for user here.
+                let default_choice = user_toolchains
+                    .iter()
+                    .find_map(|(scope, toolchains)| {
+                        if scope == &ToolchainScope::Global {
+                            // Ignore global toolchains when making a default choice. They're unlikely to be the right choice.
+                            None
+                        } else {
+                            toolchains.first()
+                        }
+                    })
+                    .or_else(|| toolchains.toolchains.first())
+                    .cloned();
+                if let Some(toolchain) = &default_choice {
                     workspace::WORKSPACE_DB
                         .set_toolchain(
                             workspace_id,
@@ -209,7 +225,7 @@ impl ActiveToolchain {
                         .await;
                 }
 
-                toolchains.toolchains.first().cloned()
+                default_choice
             }
         })
     }

crates/toolchain_selector/src/toolchain_selector.rs πŸ”—

@@ -1,25 +1,39 @@
 mod active_toolchain;
 
 pub use active_toolchain::ActiveToolchain;
+use convert_case::Casing as _;
 use editor::Editor;
+use file_finder::OpenPathDelegate;
+use futures::channel::oneshot;
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
 use gpui::{
-    App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ParentElement,
-    Render, Styled, Task, WeakEntity, Window, actions,
+    Action, Animation, AnimationExt, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
+    Focusable, KeyContext, ParentElement, Render, Styled, Subscription, Task, WeakEntity, Window,
+    actions, pulsating_between,
 };
-use language::{LanguageName, Toolchain, ToolchainList};
+use language::{Language, LanguageName, Toolchain, ToolchainScope};
 use picker::{Picker, PickerDelegate};
-use project::{Project, ProjectPath, WorktreeId};
-use std::{borrow::Cow, path::Path, sync::Arc};
-use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
-use util::ResultExt;
+use project::{DirectoryLister, Project, ProjectPath, Toolchains, WorktreeId};
+use std::{
+    borrow::Cow,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
+use ui::{
+    Divider, HighlightedLabel, KeyBinding, List, ListItem, ListItemSpacing, Navigable,
+    NavigableEntry, prelude::*,
+};
+use util::{ResultExt, maybe, paths::PathStyle};
 use workspace::{ModalView, Workspace};
 
 actions!(
     toolchain,
     [
         /// Selects a toolchain for the current project.
-        Select
+        Select,
+        /// Adds a new toolchain for the current project.
+        AddToolchain
     ]
 );
 
@@ -28,9 +42,513 @@ pub fn init(cx: &mut App) {
 }
 
 pub struct ToolchainSelector {
+    state: State,
+    create_search_state: Arc<dyn Fn(&mut Window, &mut Context<Self>) -> SearchState + 'static>,
+    language: Option<Arc<Language>>,
+    project: Entity<Project>,
+    language_name: LanguageName,
+    worktree_id: WorktreeId,
+    relative_path: Arc<Path>,
+}
+
+#[derive(Clone)]
+struct SearchState {
     picker: Entity<Picker<ToolchainSelectorDelegate>>,
 }
 
+struct AddToolchainState {
+    state: AddState,
+    project: Entity<Project>,
+    language_name: LanguageName,
+    root_path: ProjectPath,
+    weak: WeakEntity<ToolchainSelector>,
+}
+
+struct ScopePickerState {
+    entries: [NavigableEntry; 3],
+    selected_scope: ToolchainScope,
+}
+
+#[expect(
+    dead_code,
+    reason = "These tasks have to be kept alive to run to completion"
+)]
+enum PathInputState {
+    WaitingForPath(Task<()>),
+    Resolving(Task<()>),
+}
+
+enum AddState {
+    Path {
+        picker: Entity<Picker<file_finder::OpenPathDelegate>>,
+        error: Option<Arc<str>>,
+        input_state: PathInputState,
+        _subscription: Subscription,
+    },
+    Name {
+        toolchain: Toolchain,
+        editor: Entity<Editor>,
+        scope_picker: ScopePickerState,
+    },
+}
+
+impl AddToolchainState {
+    fn new(
+        project: Entity<Project>,
+        language_name: LanguageName,
+        root_path: ProjectPath,
+        window: &mut Window,
+        cx: &mut Context<ToolchainSelector>,
+    ) -> Entity<Self> {
+        let weak = cx.weak_entity();
+
+        cx.new(|cx| {
+            let (lister, rx) = Self::create_path_browser_delegate(project.clone(), cx);
+            let picker = cx.new(|cx| Picker::uniform_list(lister, window, cx));
+            Self {
+                state: AddState::Path {
+                    _subscription: cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
+                        cx.stop_propagation();
+                    }),
+                    picker,
+                    error: None,
+                    input_state: Self::wait_for_path(rx, window, cx),
+                },
+                project,
+                language_name,
+                root_path,
+                weak,
+            }
+        })
+    }
+
+    fn create_path_browser_delegate(
+        project: Entity<Project>,
+        cx: &mut Context<Self>,
+    ) -> (OpenPathDelegate, oneshot::Receiver<Option<Vec<PathBuf>>>) {
+        let (tx, rx) = oneshot::channel();
+        let weak = cx.weak_entity();
+        let lister = OpenPathDelegate::new(
+            tx,
+            DirectoryLister::Project(project),
+            false,
+            PathStyle::current(),
+        )
+        .show_hidden()
+        .with_footer(Arc::new(move |_, cx| {
+            let error = weak
+                .read_with(cx, |this, _| {
+                    if let AddState::Path { error, .. } = &this.state {
+                        error.clone()
+                    } else {
+                        None
+                    }
+                })
+                .ok()
+                .flatten();
+            let is_loading = weak
+                .read_with(cx, |this, _| {
+                    matches!(
+                        this.state,
+                        AddState::Path {
+                            input_state: PathInputState::Resolving(_),
+                            ..
+                        }
+                    )
+                })
+                .unwrap_or_default();
+            Some(
+                v_flex()
+                    .child(Divider::horizontal())
+                    .child(
+                        h_flex()
+                            .p_1()
+                            .justify_between()
+                            .gap_2()
+                            .child(Label::new("Select Toolchain Path").color(Color::Muted).map(
+                                |this| {
+                                    if is_loading {
+                                        this.with_animation(
+                                            "select-toolchain-label",
+                                            Animation::new(Duration::from_secs(2))
+                                                .repeat()
+                                                .with_easing(pulsating_between(0.4, 0.8)),
+                                            |label, delta| label.alpha(delta),
+                                        )
+                                        .into_any()
+                                    } else {
+                                        this.into_any_element()
+                                    }
+                                },
+                            ))
+                            .when_some(error, |this, error| {
+                                this.child(Label::new(error).color(Color::Error))
+                            }),
+                    )
+                    .into_any(),
+            )
+        }));
+
+        (lister, rx)
+    }
+    fn resolve_path(
+        path: PathBuf,
+        root_path: ProjectPath,
+        language_name: LanguageName,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> PathInputState {
+        PathInputState::Resolving(cx.spawn_in(window, async move |this, cx| {
+            _ = maybe!(async move {
+                let toolchain = project
+                    .update(cx, |this, cx| {
+                        this.resolve_toolchain(path.clone(), language_name, cx)
+                    })?
+                    .await;
+                let Ok(toolchain) = toolchain else {
+                    // Go back to the path input state
+                    _ = this.update_in(cx, |this, window, cx| {
+                        if let AddState::Path {
+                            input_state,
+                            picker,
+                            error,
+                            ..
+                        } = &mut this.state
+                            && matches!(input_state, PathInputState::Resolving(_))
+                        {
+                            let Err(e) = toolchain else { unreachable!() };
+                            *error = Some(Arc::from(e.to_string()));
+                            let (delegate, rx) =
+                                Self::create_path_browser_delegate(this.project.clone(), cx);
+                            picker.update(cx, |picker, cx| {
+                                *picker = Picker::uniform_list(delegate, window, cx);
+                                picker.set_query(
+                                    Arc::from(path.to_string_lossy().as_ref()),
+                                    window,
+                                    cx,
+                                );
+                            });
+                            *input_state = Self::wait_for_path(rx, window, cx);
+                            this.focus_handle(cx).focus(window);
+                        }
+                    });
+                    return Err(anyhow::anyhow!("Failed to resolve toolchain"));
+                };
+                let resolved_toolchain_path = project.read_with(cx, |this, cx| {
+                    this.find_project_path(&toolchain.path.as_ref(), cx)
+                })?;
+
+                // Suggest a default scope based on the applicability.
+                let scope = if let Some(project_path) = resolved_toolchain_path {
+                    if root_path.path.as_ref() != Path::new("")
+                        && project_path.starts_with(&root_path)
+                    {
+                        ToolchainScope::Subproject(root_path.worktree_id, root_path.path)
+                    } else {
+                        ToolchainScope::Project
+                    }
+                } else {
+                    // This path lies outside of the project.
+                    ToolchainScope::Global
+                };
+
+                _ = this.update_in(cx, |this, window, cx| {
+                    let scope_picker = ScopePickerState {
+                        entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
+                        selected_scope: scope,
+                    };
+                    this.state = AddState::Name {
+                        editor: cx.new(|cx| {
+                            let mut editor = Editor::single_line(window, cx);
+                            editor.set_text(toolchain.name.as_ref(), window, cx);
+                            editor
+                        }),
+                        toolchain,
+                        scope_picker,
+                    };
+                    this.focus_handle(cx).focus(window);
+                });
+
+                Result::<_, anyhow::Error>::Ok(())
+            })
+            .await;
+        }))
+    }
+
+    fn wait_for_path(
+        rx: oneshot::Receiver<Option<Vec<PathBuf>>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> PathInputState {
+        let task = cx.spawn_in(window, async move |this, cx| {
+            maybe!(async move {
+                let result = rx.await.log_err()?;
+
+                let path = result
+                    .into_iter()
+                    .flat_map(|paths| paths.into_iter())
+                    .next()?;
+                this.update_in(cx, |this, window, cx| {
+                    if let AddState::Path {
+                        input_state, error, ..
+                    } = &mut this.state
+                        && matches!(input_state, PathInputState::WaitingForPath(_))
+                    {
+                        error.take();
+                        *input_state = Self::resolve_path(
+                            path,
+                            this.root_path.clone(),
+                            this.language_name.clone(),
+                            this.project.clone(),
+                            window,
+                            cx,
+                        );
+                    }
+                })
+                .ok()?;
+                Some(())
+            })
+            .await;
+        });
+        PathInputState::WaitingForPath(task)
+    }
+
+    fn confirm_toolchain(
+        &mut self,
+        _: &menu::Confirm,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let AddState::Name {
+            toolchain,
+            editor,
+            scope_picker,
+        } = &mut self.state
+        else {
+            return;
+        };
+
+        let text = editor.read(cx).text(cx);
+        if text.is_empty() {
+            return;
+        }
+
+        toolchain.name = SharedString::from(text);
+        self.project.update(cx, |this, cx| {
+            this.add_toolchain(toolchain.clone(), scope_picker.selected_scope.clone(), cx);
+        });
+        _ = self.weak.update(cx, |this, cx| {
+            this.state = State::Search((this.create_search_state)(window, cx));
+            this.focus_handle(cx).focus(window);
+            cx.notify();
+        });
+    }
+}
+impl Focusable for AddToolchainState {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        match &self.state {
+            AddState::Path { picker, .. } => picker.focus_handle(cx),
+            AddState::Name { editor, .. } => editor.focus_handle(cx),
+        }
+    }
+}
+
+impl AddToolchainState {
+    fn select_scope(&mut self, scope: ToolchainScope, cx: &mut Context<Self>) {
+        if let AddState::Name { scope_picker, .. } = &mut self.state {
+            scope_picker.selected_scope = scope;
+            cx.notify();
+        }
+    }
+}
+
+impl Focusable for State {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        match self {
+            State::Search(state) => state.picker.focus_handle(cx),
+            State::AddToolchain(state) => state.focus_handle(cx),
+        }
+    }
+}
+impl Render for AddToolchainState {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = cx.theme().clone();
+        let weak = self.weak.upgrade();
+        let label = SharedString::new_static("Add");
+
+        v_flex()
+            .size_full()
+            // todo: These modal styles shouldn't be needed as the modal picker already has `elevation_3`
+            // They get duplicated in the middle state of adding a virtual env, but then are needed for this last state
+            .bg(cx.theme().colors().elevated_surface_background)
+            .border_1()
+            .border_color(cx.theme().colors().border_variant)
+            .rounded_lg()
+            .when_some(weak, |this, weak| {
+                this.on_action(window.listener_for(
+                    &weak,
+                    |this: &mut ToolchainSelector, _: &menu::Cancel, window, cx| {
+                        this.state = State::Search((this.create_search_state)(window, cx));
+                        this.state.focus_handle(cx).focus(window);
+                        cx.notify();
+                    },
+                ))
+            })
+            .on_action(cx.listener(Self::confirm_toolchain))
+            .map(|this| match &self.state {
+                AddState::Path { picker, .. } => this.child(picker.clone()),
+                AddState::Name {
+                    editor,
+                    scope_picker,
+                    ..
+                } => {
+                    let scope_options = [
+                        ToolchainScope::Global,
+                        ToolchainScope::Project,
+                        ToolchainScope::Subproject(
+                            self.root_path.worktree_id,
+                            self.root_path.path.clone(),
+                        ),
+                    ];
+
+                    let mut navigable_scope_picker = Navigable::new(
+                        v_flex()
+                            .child(
+                                h_flex()
+                                    .w_full()
+                                    .p_2()
+                                    .border_b_1()
+                                    .border_color(theme.colors().border)
+                                    .child(editor.clone()),
+                            )
+                            .child(
+                                v_flex()
+                                    .child(
+                                        Label::new("Scope")
+                                            .size(LabelSize::Small)
+                                            .color(Color::Muted)
+                                            .mt_1()
+                                            .ml_2(),
+                                    )
+                                    .child(List::new().children(
+                                        scope_options.iter().enumerate().map(|(i, scope)| {
+                                            let is_selected = *scope == scope_picker.selected_scope;
+                                            let label = scope.label();
+                                            let description = scope.description();
+                                            let scope_clone_for_action = scope.clone();
+                                            let scope_clone_for_click = scope.clone();
+
+                                            div()
+                                                .id(SharedString::from(format!("scope-option-{i}")))
+                                                .track_focus(&scope_picker.entries[i].focus_handle)
+                                                .on_action(cx.listener(
+                                                    move |this, _: &menu::Confirm, _, cx| {
+                                                        this.select_scope(
+                                                            scope_clone_for_action.clone(),
+                                                            cx,
+                                                        );
+                                                    },
+                                                ))
+                                                .child(
+                                                    ListItem::new(SharedString::from(format!(
+                                                        "scope-{i}"
+                                                    )))
+                                                    .toggle_state(
+                                                        is_selected
+                                                            || scope_picker.entries[i]
+                                                                .focus_handle
+                                                                .contains_focused(window, cx),
+                                                    )
+                                                    .inset(true)
+                                                    .spacing(ListItemSpacing::Sparse)
+                                                    .child(
+                                                        h_flex()
+                                                            .gap_2()
+                                                            .child(Label::new(label))
+                                                            .child(
+                                                                Label::new(description)
+                                                                    .size(LabelSize::Small)
+                                                                    .color(Color::Muted),
+                                                            ),
+                                                    )
+                                                    .on_click(cx.listener(move |this, _, _, cx| {
+                                                        this.select_scope(
+                                                            scope_clone_for_click.clone(),
+                                                            cx,
+                                                        );
+                                                    })),
+                                                )
+                                        }),
+                                    ))
+                                    .child(Divider::horizontal())
+                                    .child(h_flex().p_1p5().justify_end().map(|this| {
+                                        let is_disabled = editor.read(cx).is_empty(cx);
+                                        let handle = self.focus_handle(cx);
+                                        this.child(
+                                            Button::new("add-toolchain", label)
+                                                .disabled(is_disabled)
+                                                .key_binding(KeyBinding::for_action_in(
+                                                    &menu::Confirm,
+                                                    &handle,
+                                                    window,
+                                                    cx,
+                                                ))
+                                                .on_click(cx.listener(|this, _, window, cx| {
+                                                    this.confirm_toolchain(
+                                                        &menu::Confirm,
+                                                        window,
+                                                        cx,
+                                                    );
+                                                }))
+                                                .map(|this| {
+                                                    if false {
+                                                        this.with_animation(
+                                                            "inspecting-user-toolchain",
+                                                            Animation::new(Duration::from_millis(
+                                                                500,
+                                                            ))
+                                                            .repeat()
+                                                            .with_easing(pulsating_between(
+                                                                0.4, 0.8,
+                                                            )),
+                                                            |label, delta| label.alpha(delta),
+                                                        )
+                                                        .into_any()
+                                                    } else {
+                                                        this.into_any_element()
+                                                    }
+                                                }),
+                                        )
+                                    })),
+                            )
+                            .into_any_element(),
+                    );
+
+                    for entry in &scope_picker.entries {
+                        navigable_scope_picker = navigable_scope_picker.entry(entry.clone());
+                    }
+
+                    this.child(navigable_scope_picker.render(window, cx))
+                }
+            })
+    }
+}
+
+#[derive(Clone)]
+enum State {
+    Search(SearchState),
+    AddToolchain(Entity<AddToolchainState>),
+}
+
+impl RenderOnce for State {
+    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
+        match self {
+            State::Search(state) => state.picker.into_any_element(),
+            State::AddToolchain(state) => state.into_any_element(),
+        }
+    }
+}
 impl ToolchainSelector {
     fn register(
         workspace: &mut Workspace,
@@ -40,6 +558,16 @@ impl ToolchainSelector {
         workspace.register_action(move |workspace, _: &Select, window, cx| {
             Self::toggle(workspace, window, cx);
         });
+        workspace.register_action(move |workspace, _: &AddToolchain, window, cx| {
+            let Some(toolchain_selector) = workspace.active_modal::<Self>(cx) else {
+                Self::toggle(workspace, window, cx);
+                return;
+            };
+
+            toolchain_selector.update(cx, |toolchain_selector, cx| {
+                toolchain_selector.handle_add_toolchain(&AddToolchain, window, cx);
+            });
+        });
     }
 
     fn toggle(
@@ -105,35 +633,100 @@ impl ToolchainSelector {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let toolchain_selector = cx.entity().downgrade();
-        let picker = cx.new(|cx| {
-            let delegate = ToolchainSelectorDelegate::new(
-                active_toolchain,
-                toolchain_selector,
-                workspace,
-                worktree_id,
-                worktree_root,
-                project,
-                relative_path,
-                language_name,
+        let language_registry = project.read(cx).languages().clone();
+        cx.spawn({
+            let language_name = language_name.clone();
+            async move |this, cx| {
+                let language = language_registry
+                    .language_for_name(&language_name.0)
+                    .await
+                    .ok();
+                this.update(cx, |this, cx| {
+                    this.language = language;
+                    cx.notify();
+                })
+                .ok();
+            }
+        })
+        .detach();
+        let project_clone = project.clone();
+        let language_name_clone = language_name.clone();
+        let relative_path_clone = relative_path.clone();
+
+        let create_search_state = Arc::new(move |window: &mut Window, cx: &mut Context<Self>| {
+            let toolchain_selector = cx.entity().downgrade();
+            let picker = cx.new(|cx| {
+                let delegate = ToolchainSelectorDelegate::new(
+                    active_toolchain.clone(),
+                    toolchain_selector,
+                    workspace.clone(),
+                    worktree_id,
+                    worktree_root.clone(),
+                    project_clone.clone(),
+                    relative_path_clone.clone(),
+                    language_name_clone.clone(),
+                    window,
+                    cx,
+                );
+                Picker::uniform_list(delegate, window, cx)
+            });
+            let picker_focus_handle = picker.focus_handle(cx);
+            picker.update(cx, |picker, _| {
+                picker.delegate.focus_handle = picker_focus_handle.clone();
+            });
+            SearchState { picker }
+        });
+
+        Self {
+            state: State::Search(create_search_state(window, cx)),
+            create_search_state,
+            language: None,
+            project,
+            language_name,
+            worktree_id,
+            relative_path,
+        }
+    }
+
+    fn handle_add_toolchain(
+        &mut self,
+        _: &AddToolchain,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if matches!(self.state, State::Search(_)) {
+            self.state = State::AddToolchain(AddToolchainState::new(
+                self.project.clone(),
+                self.language_name.clone(),
+                ProjectPath {
+                    worktree_id: self.worktree_id,
+                    path: self.relative_path.clone(),
+                },
                 window,
                 cx,
-            );
-            Picker::uniform_list(delegate, window, cx)
-        });
-        Self { picker }
+            ));
+            self.state.focus_handle(cx).focus(window);
+            cx.notify();
+        }
     }
 }
 
 impl Render for ToolchainSelector {
-    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
-        v_flex().w(rems(34.)).child(self.picker.clone())
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let mut key_context = KeyContext::new_with_defaults();
+        key_context.add("ToolchainSelector");
+
+        v_flex()
+            .key_context(key_context)
+            .w(rems(34.))
+            .on_action(cx.listener(Self::handle_add_toolchain))
+            .child(self.state.clone().render(window, cx))
     }
 }
 
 impl Focusable for ToolchainSelector {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
-        self.picker.focus_handle(cx)
+        self.state.focus_handle(cx)
     }
 }
 
@@ -142,7 +735,7 @@ impl ModalView for ToolchainSelector {}
 
 pub struct ToolchainSelectorDelegate {
     toolchain_selector: WeakEntity<ToolchainSelector>,
-    candidates: ToolchainList,
+    candidates: Arc<[(Toolchain, Option<ToolchainScope>)]>,
     matches: Vec<StringMatch>,
     selected_index: usize,
     workspace: WeakEntity<Workspace>,
@@ -150,6 +743,9 @@ pub struct ToolchainSelectorDelegate {
     worktree_abs_path_root: Arc<Path>,
     relative_path: Arc<Path>,
     placeholder_text: Arc<str>,
+    add_toolchain_text: Arc<str>,
+    project: Entity<Project>,
+    focus_handle: FocusHandle,
     _fetch_candidates_task: Task<Option<()>>,
 }
 
@@ -166,19 +762,33 @@ impl ToolchainSelectorDelegate {
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Self {
+        let _project = project.clone();
+
         let _fetch_candidates_task = cx.spawn_in(window, {
             async move |this, cx| {
-                let term = project
+                let meta = _project
                     .read_with(cx, |this, _| {
-                        Project::toolchain_term(this.languages().clone(), language_name.clone())
+                        Project::toolchain_metadata(this.languages().clone(), language_name.clone())
                     })
                     .ok()?
                     .await?;
                 let relative_path = this
-                    .read_with(cx, |this, _| this.delegate.relative_path.clone())
+                    .update(cx, |this, cx| {
+                        this.delegate.add_toolchain_text = format!(
+                            "Add {}",
+                            meta.term.as_ref().to_case(convert_case::Case::Title)
+                        )
+                        .into();
+                        cx.notify();
+                        this.delegate.relative_path.clone()
+                    })
                     .ok()?;
 
-                let (available_toolchains, relative_path) = project
+                let Toolchains {
+                    toolchains: available_toolchains,
+                    root_path: relative_path,
+                    user_toolchains,
+                } = _project
                     .update(cx, |this, cx| {
                         this.available_toolchains(
                             ProjectPath {
@@ -200,7 +810,7 @@ impl ToolchainSelectorDelegate {
                     }
                 };
                 let placeholder_text =
-                    format!("Select a {} for {pretty_path}…", term.to_lowercase(),).into();
+                    format!("Select a {} for {pretty_path}…", meta.term.to_lowercase(),).into();
                 let _ = this.update_in(cx, move |this, window, cx| {
                     this.delegate.relative_path = relative_path;
                     this.delegate.placeholder_text = placeholder_text;
@@ -208,15 +818,27 @@ impl ToolchainSelectorDelegate {
                 });
 
                 let _ = this.update_in(cx, move |this, window, cx| {
-                    this.delegate.candidates = available_toolchains;
+                    this.delegate.candidates = user_toolchains
+                        .into_iter()
+                        .flat_map(|(scope, toolchains)| {
+                            toolchains
+                                .into_iter()
+                                .map(move |toolchain| (toolchain, Some(scope.clone())))
+                        })
+                        .chain(
+                            available_toolchains
+                                .toolchains
+                                .into_iter()
+                                .map(|toolchain| (toolchain, None)),
+                        )
+                        .collect();
 
                     if let Some(active_toolchain) = active_toolchain
                         && let Some(position) = this
                             .delegate
                             .candidates
-                            .toolchains
                             .iter()
-                            .position(|toolchain| *toolchain == active_toolchain)
+                            .position(|(toolchain, _)| *toolchain == active_toolchain)
                     {
                         this.delegate.set_selected_index(position, window, cx);
                     }
@@ -238,6 +860,9 @@ impl ToolchainSelectorDelegate {
             placeholder_text,
             relative_path,
             _fetch_candidates_task,
+            project,
+            focus_handle: cx.focus_handle(),
+            add_toolchain_text: Arc::from("Add Toolchain"),
         }
     }
     fn relativize_path(path: SharedString, worktree_root: &Path) -> SharedString {
@@ -263,7 +888,7 @@ impl PickerDelegate for ToolchainSelectorDelegate {
 
     fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
         if let Some(string_match) = self.matches.get(self.selected_index) {
-            let toolchain = self.candidates.toolchains[string_match.candidate_id].clone();
+            let (toolchain, _) = self.candidates[string_match.candidate_id].clone();
             if let Some(workspace_id) = self
                 .workspace
                 .read_with(cx, |this, _| this.database_id())
@@ -330,11 +955,11 @@ impl PickerDelegate for ToolchainSelectorDelegate {
         cx.spawn_in(window, async move |this, cx| {
             let matches = if query.is_empty() {
                 candidates
-                    .toolchains
                     .into_iter()
                     .enumerate()
-                    .map(|(index, candidate)| {
-                        let path = Self::relativize_path(candidate.path, &worktree_root_path);
+                    .map(|(index, (candidate, _))| {
+                        let path =
+                            Self::relativize_path(candidate.path.clone(), &worktree_root_path);
                         let string = format!("{}{}", candidate.name, path);
                         StringMatch {
                             candidate_id: index,
@@ -346,11 +971,11 @@ impl PickerDelegate for ToolchainSelectorDelegate {
                     .collect()
             } else {
                 let candidates = candidates
-                    .toolchains
                     .into_iter()
                     .enumerate()
-                    .map(|(candidate_id, toolchain)| {
-                        let path = Self::relativize_path(toolchain.path, &worktree_root_path);
+                    .map(|(candidate_id, (toolchain, _))| {
+                        let path =
+                            Self::relativize_path(toolchain.path.clone(), &worktree_root_path);
                         let string = format!("{}{}", toolchain.name, path);
                         StringMatchCandidate::new(candidate_id, &string)
                     })
@@ -383,11 +1008,11 @@ impl PickerDelegate for ToolchainSelectorDelegate {
         &self,
         ix: usize,
         selected: bool,
-        _window: &mut Window,
-        _: &mut Context<Picker<Self>>,
+        _: &mut Window,
+        cx: &mut Context<Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let mat = &self.matches[ix];
-        let toolchain = &self.candidates.toolchains[mat.candidate_id];
+        let (toolchain, scope) = &self.candidates[mat.candidate_id];
 
         let label = toolchain.name.clone();
         let path = Self::relativize_path(toolchain.path.clone(), &self.worktree_abs_path_root);
@@ -399,8 +1024,9 @@ impl PickerDelegate for ToolchainSelectorDelegate {
         path_highlights.iter_mut().for_each(|index| {
             *index -= label.len();
         });
+        let id: SharedString = format!("toolchain-{ix}",).into();
         Some(
-            ListItem::new(ix)
+            ListItem::new(id)
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .toggle_state(selected)
@@ -409,7 +1035,89 @@ impl PickerDelegate for ToolchainSelectorDelegate {
                     HighlightedLabel::new(path, path_highlights)
                         .size(LabelSize::Small)
                         .color(Color::Muted),
-                ),
+                )
+                .when_some(scope.as_ref(), |this, scope| {
+                    let id: SharedString = format!(
+                        "delete-custom-toolchain-{}-{}",
+                        toolchain.name, toolchain.path
+                    )
+                    .into();
+                    let toolchain = toolchain.clone();
+                    let scope = scope.clone();
+
+                    this.end_slot(IconButton::new(id, IconName::Trash))
+                        .on_click(cx.listener(move |this, _, _, cx| {
+                            this.delegate.project.update(cx, |this, cx| {
+                                this.remove_toolchain(toolchain.clone(), scope.clone(), cx)
+                            });
+
+                            this.delegate.matches.retain_mut(|m| {
+                                if m.candidate_id == ix {
+                                    return false;
+                                } else if m.candidate_id > ix {
+                                    m.candidate_id -= 1;
+                                }
+                                true
+                            });
+
+                            this.delegate.candidates = this
+                                .delegate
+                                .candidates
+                                .iter()
+                                .enumerate()
+                                .filter_map(|(i, toolchain)| (ix != i).then_some(toolchain.clone()))
+                                .collect();
+
+                            if this.delegate.selected_index >= ix {
+                                this.delegate.selected_index =
+                                    this.delegate.selected_index.saturating_sub(1);
+                            }
+                            cx.stop_propagation();
+                            cx.notify();
+                        }))
+                }),
+        )
+    }
+    fn render_footer(
+        &self,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<AnyElement> {
+        Some(
+            v_flex()
+                .rounded_b_md()
+                .child(Divider::horizontal())
+                .child(
+                    h_flex()
+                        .p_1p5()
+                        .gap_0p5()
+                        .justify_end()
+                        .child(
+                            Button::new("xd", self.add_toolchain_text.clone())
+                                .key_binding(KeyBinding::for_action_in(
+                                    &AddToolchain,
+                                    &self.focus_handle,
+                                    _window,
+                                    cx,
+                                ))
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(Box::new(AddToolchain), cx)
+                                }),
+                        )
+                        .child(
+                            Button::new("select", "Select")
+                                .key_binding(KeyBinding::for_action_in(
+                                    &menu::Confirm,
+                                    &self.focus_handle,
+                                    _window,
+                                    cx,
+                                ))
+                                .on_click(|_, window, cx| {
+                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
+                                }),
+                        ),
+                )
+                .into_any_element(),
         )
     }
 }

crates/workspace/src/persistence.rs πŸ”—

@@ -9,7 +9,7 @@ use std::{
 };
 
 use anyhow::{Context as _, Result, bail};
-use collections::HashMap;
+use collections::{HashMap, IndexSet};
 use db::{
     query,
     sqlez::{connection::Connection, domain::Domain},
@@ -18,16 +18,16 @@ use db::{
 use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size};
 use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
 
-use language::{LanguageName, Toolchain};
+use language::{LanguageName, Toolchain, ToolchainScope};
 use project::WorktreeId;
 use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions};
 use sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
-    statement::{SqlType, Statement},
+    statement::Statement,
     thread_safe_connection::ThreadSafeConnection,
 };
 
-use ui::{App, px};
+use ui::{App, SharedString, px};
 use util::{ResultExt, maybe};
 use uuid::Uuid;
 
@@ -169,6 +169,7 @@ impl From<BreakpointState> for BreakpointStateWrapper<'static> {
         BreakpointStateWrapper(Cow::Owned(kind))
     }
 }
+
 impl StaticColumnCount for BreakpointStateWrapper<'_> {
     fn column_count() -> usize {
         1
@@ -193,11 +194,6 @@ impl Column for BreakpointStateWrapper<'_> {
     }
 }
 
-/// This struct is used to implement traits on Vec<breakpoint>
-#[derive(Debug)]
-#[allow(dead_code)]
-struct Breakpoints(Vec<Breakpoint>);
-
 impl sqlez::bindable::StaticColumnCount for Breakpoint {
     fn column_count() -> usize {
         // Position, log message, condition message, and hit condition message
@@ -246,26 +242,6 @@ impl Column for Breakpoint {
     }
 }
 
-impl Column for Breakpoints {
-    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
-        let mut breakpoints = Vec::new();
-        let mut index = start_index;
-
-        loop {
-            match statement.column_type(index) {
-                Ok(SqlType::Null) => break,
-                _ => {
-                    let (breakpoint, next_index) = Breakpoint::column(statement, index)?;
-
-                    breakpoints.push(breakpoint);
-                    index = next_index;
-                }
-            }
-        }
-        Ok((Breakpoints(breakpoints), index))
-    }
-}
-
 #[derive(Clone, Debug, PartialEq)]
 struct SerializedPixels(gpui::Pixels);
 impl sqlez::bindable::StaticColumnCount for SerializedPixels {}
@@ -711,6 +687,18 @@ impl Domain for WorkspaceDb {
 
             CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(remote_connection_id, paths);
         ),
+        sql!(CREATE TABLE user_toolchains (
+            remote_connection_id INTEGER,
+            workspace_id INTEGER NOT NULL,
+            worktree_id INTEGER NOT NULL,
+            relative_worktree_path TEXT NOT NULL,
+            language_name TEXT NOT NULL,
+            name TEXT NOT NULL,
+            path TEXT NOT NULL,
+            raw_json TEXT NOT NULL,
+
+            PRIMARY KEY (workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json)
+        ) STRICT;),
     ];
 
     // Allow recovering from bad migration that was initially shipped to nightly
@@ -831,6 +819,7 @@ impl WorkspaceDb {
             session_id: None,
             breakpoints: self.breakpoints(workspace_id),
             window_id,
+            user_toolchains: self.user_toolchains(workspace_id, remote_connection_id),
         })
     }
 
@@ -880,6 +869,73 @@ impl WorkspaceDb {
         }
     }
 
+    fn user_toolchains(
+        &self,
+        workspace_id: WorkspaceId,
+        remote_connection_id: Option<RemoteConnectionId>,
+    ) -> BTreeMap<ToolchainScope, IndexSet<Toolchain>> {
+        type RowKind = (WorkspaceId, u64, String, String, String, String, String);
+
+        let toolchains: Vec<RowKind> = self
+            .select_bound(sql! {
+                SELECT workspace_id, worktree_id, relative_worktree_path,
+                language_name, name, path, raw_json
+                FROM user_toolchains WHERE remote_connection_id IS ?1 AND (
+                      workspace_id IN (0, ?2)
+                )
+            })
+            .and_then(|mut statement| {
+                (statement)((remote_connection_id.map(|id| id.0), workspace_id))
+            })
+            .unwrap_or_default();
+        let mut ret = BTreeMap::<_, IndexSet<_>>::default();
+
+        for (
+            _workspace_id,
+            worktree_id,
+            relative_worktree_path,
+            language_name,
+            name,
+            path,
+            raw_json,
+        ) in toolchains
+        {
+            // INTEGER's that are primary keys (like workspace ids, remote connection ids and such) start at 1, so we're safe to
+            let scope = if _workspace_id == WorkspaceId(0) {
+                debug_assert_eq!(worktree_id, u64::MAX);
+                debug_assert_eq!(relative_worktree_path, String::default());
+                ToolchainScope::Global
+            } else {
+                debug_assert_eq!(workspace_id, _workspace_id);
+                debug_assert_eq!(
+                    worktree_id == u64::MAX,
+                    relative_worktree_path == String::default()
+                );
+
+                if worktree_id != u64::MAX && relative_worktree_path != String::default() {
+                    ToolchainScope::Subproject(
+                        WorktreeId::from_usize(worktree_id as usize),
+                        Arc::from(relative_worktree_path.as_ref()),
+                    )
+                } else {
+                    ToolchainScope::Project
+                }
+            };
+            let Ok(as_json) = serde_json::from_str(&raw_json) else {
+                continue;
+            };
+            let toolchain = Toolchain {
+                name: SharedString::from(name),
+                path: SharedString::from(path),
+                language_name: LanguageName::from_proto(language_name),
+                as_json,
+            };
+            ret.entry(scope).or_default().insert(toolchain);
+        }
+
+        ret
+    }
+
     /// Saves a workspace using the worktree roots. Will garbage collect any workspaces
     /// that used this workspace previously
     pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) {
@@ -935,6 +991,22 @@ impl WorkspaceDb {
                         }
                     }
                 }
+                for (scope, toolchains) in workspace.user_toolchains {
+                    for toolchain in toolchains {
+                        let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8));
+                        let (workspace_id, worktree_id, relative_worktree_path) = match scope {
+                            ToolchainScope::Subproject(worktree_id, ref path) => (Some(workspace.id), Some(worktree_id), Some(path.to_string_lossy().into_owned())),
+                            ToolchainScope::Project => (Some(workspace.id), None, None),
+                            ToolchainScope::Global => (None, None, None),
+                        };
+                        let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_id.map_or(usize::MAX,|id| id.to_usize()), relative_worktree_path.unwrap_or_default(),
+                        toolchain.language_name.as_ref().to_owned(), toolchain.name.to_string(), toolchain.path.to_string(), toolchain.as_json.to_string());
+                        if let Err(err) = conn.exec_bound(query)?(args) {
+                            log::error!("{err}");
+                            continue;
+                        }
+                    }
+                }
 
                 conn.exec_bound(sql!(
                     DELETE
@@ -1797,6 +1869,7 @@ mod tests {
             },
             session_id: None,
             window_id: None,
+            user_toolchains: Default::default(),
         };
 
         db.save_workspace(workspace.clone()).await;
@@ -1917,6 +1990,7 @@ mod tests {
             },
             session_id: None,
             window_id: None,
+            user_toolchains: Default::default(),
         };
 
         db.save_workspace(workspace.clone()).await;
@@ -1950,6 +2024,7 @@ mod tests {
             breakpoints: collections::BTreeMap::default(),
             session_id: None,
             window_id: None,
+            user_toolchains: Default::default(),
         };
 
         db.save_workspace(workspace_without_breakpoint.clone())
@@ -2047,6 +2122,7 @@ mod tests {
             breakpoints: Default::default(),
             session_id: None,
             window_id: None,
+            user_toolchains: Default::default(),
         };
 
         let workspace_2 = SerializedWorkspace {
@@ -2061,6 +2137,7 @@ mod tests {
             breakpoints: Default::default(),
             session_id: None,
             window_id: None,
+            user_toolchains: Default::default(),
         };
 
         db.save_workspace(workspace_1.clone()).await;
@@ -2167,6 +2244,7 @@ mod tests {
             centered_layout: false,
             session_id: None,
             window_id: Some(999),
+            user_toolchains: Default::default(),
         };
 
         db.save_workspace(workspace.clone()).await;
@@ -2200,6 +2278,7 @@ mod tests {
             centered_layout: false,
             session_id: None,
             window_id: Some(1),
+            user_toolchains: Default::default(),
         };
 
         let mut workspace_2 = SerializedWorkspace {
@@ -2214,6 +2293,7 @@ mod tests {
             breakpoints: Default::default(),
             session_id: None,
             window_id: Some(2),
+            user_toolchains: Default::default(),
         };
 
         db.save_workspace(workspace_1.clone()).await;
@@ -2255,6 +2335,7 @@ mod tests {
             centered_layout: false,
             session_id: None,
             window_id: Some(3),
+            user_toolchains: Default::default(),
         };
 
         db.save_workspace(workspace_3.clone()).await;
@@ -2292,6 +2373,7 @@ mod tests {
             breakpoints: Default::default(),
             session_id: Some("session-id-1".to_owned()),
             window_id: Some(10),
+            user_toolchains: Default::default(),
         };
 
         let workspace_2 = SerializedWorkspace {
@@ -2306,6 +2388,7 @@ mod tests {
             breakpoints: Default::default(),
             session_id: Some("session-id-1".to_owned()),
             window_id: Some(20),
+            user_toolchains: Default::default(),
         };
 
         let workspace_3 = SerializedWorkspace {
@@ -2320,6 +2403,7 @@ mod tests {
             breakpoints: Default::default(),
             session_id: Some("session-id-2".to_owned()),
             window_id: Some(30),
+            user_toolchains: Default::default(),
         };
 
         let workspace_4 = SerializedWorkspace {
@@ -2334,6 +2418,7 @@ mod tests {
             breakpoints: Default::default(),
             session_id: None,
             window_id: None,
+            user_toolchains: Default::default(),
         };
 
         let connection_id = db
@@ -2359,6 +2444,7 @@ mod tests {
             breakpoints: Default::default(),
             session_id: Some("session-id-2".to_owned()),
             window_id: Some(50),
+            user_toolchains: Default::default(),
         };
 
         let workspace_6 = SerializedWorkspace {
@@ -2373,6 +2459,7 @@ mod tests {
             centered_layout: false,
             session_id: Some("session-id-3".to_owned()),
             window_id: Some(60),
+            user_toolchains: Default::default(),
         };
 
         db.save_workspace(workspace_1.clone()).await;
@@ -2424,6 +2511,7 @@ mod tests {
             centered_layout: false,
             session_id: None,
             window_id: None,
+            user_toolchains: Default::default(),
         }
     }
 
@@ -2458,6 +2546,7 @@ mod tests {
             session_id: Some("one-session".to_owned()),
             breakpoints: Default::default(),
             window_id: Some(window_id),
+            user_toolchains: Default::default(),
         })
         .collect::<Vec<_>>();
 
@@ -2555,6 +2644,7 @@ mod tests {
             session_id: Some("one-session".to_owned()),
             breakpoints: Default::default(),
             window_id: Some(window_id),
+            user_toolchains: Default::default(),
         })
         .collect::<Vec<_>>();
 

crates/workspace/src/persistence/model.rs πŸ”—

@@ -5,12 +5,14 @@ use crate::{
 };
 use anyhow::Result;
 use async_recursion::async_recursion;
+use collections::IndexSet;
 use db::sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
     statement::Statement,
 };
 use gpui::{AsyncWindowContext, Entity, WeakEntity};
 
+use language::{Toolchain, ToolchainScope};
 use project::{Project, debugger::breakpoint_store::SourceBreakpoint};
 use remote::RemoteConnectionOptions;
 use std::{
@@ -57,6 +59,7 @@ pub(crate) struct SerializedWorkspace {
     pub(crate) docks: DockStructure,
     pub(crate) session_id: Option<String>,
     pub(crate) breakpoints: BTreeMap<Arc<Path>, Vec<SourceBreakpoint>>,
+    pub(crate) user_toolchains: BTreeMap<ToolchainScope, IndexSet<Toolchain>>,
     pub(crate) window_id: Option<u64>,
 }
 

crates/workspace/src/workspace.rs πŸ”—

@@ -73,6 +73,7 @@ use postage::stream::Stream;
 use project::{
     DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
     debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
+    toolchain_store::ToolchainStoreEvent,
 };
 use remote::{RemoteClientDelegate, RemoteConnectionOptions, remote_client::ConnectionIdentifier};
 use schemars::JsonSchema;
@@ -1275,6 +1276,19 @@ impl Workspace {
             },
         )
         .detach();
+        if let Some(toolchain_store) = project.read(cx).toolchain_store() {
+            cx.subscribe_in(
+                &toolchain_store,
+                window,
+                |workspace, _, event, window, cx| match event {
+                    ToolchainStoreEvent::CustomToolchainsModified => {
+                        workspace.serialize_workspace(window, cx);
+                    }
+                    _ => {}
+                },
+            )
+            .detach();
+        }
 
         cx.on_focus_lost(window, |this, window, cx| {
             let focus_handle = this.focus_handle(cx);
@@ -1565,6 +1579,16 @@ impl Workspace {
                     })?
                     .await;
             }
+            if let Some(workspace) = serialized_workspace.as_ref() {
+                project_handle.update(cx, |this, cx| {
+                    for (scope, toolchains) in &workspace.user_toolchains {
+                        for toolchain in toolchains {
+                            this.add_toolchain(toolchain.clone(), scope.clone(), cx);
+                        }
+                    }
+                })?;
+            }
+
             let window = if let Some(window) = requesting_window {
                 let centered_layout = serialized_workspace
                     .as_ref()
@@ -5240,10 +5264,16 @@ impl Workspace {
                         .read(cx)
                         .all_source_breakpoints(cx)
                 });
+                let user_toolchains = self
+                    .project
+                    .read(cx)
+                    .user_toolchains(cx)
+                    .unwrap_or_default();
 
                 let center_group = build_serialized_pane_group(&self.center.root, window, cx);
                 let docks = build_serialized_docks(self, window, cx);
                 let window_bounds = Some(SerializedWindowBounds(window.window_bounds()));
+
                 let serialized_workspace = SerializedWorkspace {
                     id: database_id,
                     location,
@@ -5256,6 +5286,7 @@ impl Workspace {
                     session_id: self.session_id.clone(),
                     breakpoints,
                     window_id: Some(window.window_handle().window_id().as_u64()),
+                    user_toolchains,
                 };
 
                 window.spawn(cx, async move |_| {