diff --git a/Cargo.lock b/Cargo.lock index fbdf0e848c356620f2a2cca800cf40ef850c3b13..295c3a83c52e3b355a8e43e9d36c09149fdc694f 100644 --- a/Cargo.lock +++ b/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", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 70a002cf081deaf5df66a2173dc17e7f02ce3aeb..ac44b3f1ae55feb11b0027efea14c6afed8cb62a 100644 --- a/assets/keymaps/default-linux.json +++ b/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": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 21504c7e623583017459baaac7d25191d7a08b68..337915527ca22f04afc8450cf6a366d1f2995551 100644 --- a/assets/keymaps/default-macos.json +++ b/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, diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 51e8f5c437ab1aa86433f91022a01e8a2e09f664..c0abb372b28ff817853e9dc7b6523f676359e157 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/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>>>, lister: DirectoryLister, @@ -35,6 +34,9 @@ pub struct OpenPathDelegate { prompt_root: String, path_style: PathStyle, replace_prompt: Task<()>, + render_footer: + Arc>) -> Option + '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>) -> Option + '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 { 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>, + ) -> Option { + (self.render_footer)(window, cx) + } + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { Some(match &self.directory_state { DirectoryState::Create { .. } => SharedString::from("Type a path…"), diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index e4a1510d7df128158691842206a27844304b3237..86faf2b9d316dd068c400c48c5b0b99196cfc191 100644 --- a/crates/language/src/language.rs +++ b/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; diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 84b10c7961eddb130f88b24c9e3438ff2882f8d3..2cc86881fbd515317d4d6f5949e82eb3da63a1bb 100644 --- a/crates/language/src/toolchain.rs +++ b/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), + 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(&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, project_env: Option>, ) -> 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>, + ) -> anyhow::Result; + async fn activation_script( &self, toolchain: &Toolchain, shell: ShellKind, fs: &dyn Fs, ) -> Vec; + /// 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)] diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 168cf8f57ca25444e54c11bb8e594faa94726b5d..33fb2af0612a203b45276bb8e7f580c5a86a90b6 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -97,7 +97,7 @@ pub fn init(languages: Arc, 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())); diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 06fb49293f838fca2d54de076139ac8c4ebacfc2..d1f40a8233a3590b382bc1e0edbe5dd69b3317d8 100644 --- a/crates/languages/src/python.rs +++ b/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 { #[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>, + ) -> anyhow::Result { + 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 { + 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>>, project_env: &'a HashMap, diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 7f7e759b275baadfe3b2d3931955ad39b03fdb05..a247c07c910c135b46714123c9dec8b452cbc60b 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -3933,8 +3933,8 @@ impl LspStore { event: &ToolchainStoreEvent, _: &mut Context, ) { - match event { - ToolchainStoreEvent::ToolchainActivated => self.request_workspace_config_refresh(), + if let ToolchainStoreEvent::ToolchainActivated = event { + self.request_workspace_config_refresh() } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 66924f159a0a97dce558d742ca3ee80456542305..0ebfd83f4e414763e0f99d473e3b60dab159f743 100644 --- a/crates/project/src/project.rs +++ b/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)>> { + ) -> Task> { 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, language_name: LanguageName, - ) -> Option { + ) -> Option { 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, + ) { + 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, + ) { + 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>> { + Some(self.toolchain_store.as_ref()?.read(cx).user_toolchains()) + } + + pub fn resolve_toolchain( + &self, + path: PathBuf, + language_name: LanguageName, + cx: &App, + ) -> Task> { + 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> { diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 969e18f6d40346aa86d83bd0beb77d6652ff0763..e65da3acd41e7ce4db06821da58fe0969a74217f 100644 --- a/crates/project/src/project_tests.rs +++ b/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) -> Arc { ..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>, + ) -> anyhow::Result { + 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 { vec![] diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 57d492e26fc7b59df02df0128ed6b9ade132c6d9..e76b98f697768c987f527eaf444c159334b12c96 100644 --- a/crates/project/src/toolchain_store.rs +++ b/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>, + _sub: Subscription, +} + enum ToolchainStoreInner { - Local( - Entity, - #[allow(dead_code)] Subscription, - ), - Remote( - Entity, - #[allow(dead_code)] Subscription, - ), + Local(Entity), + Remote(Entity), } +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, + pub user_toolchains: BTreeMap>, +} impl EventEmitter 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 { 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> { - 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> { + self.user_toolchains.clone() + } + pub(crate) fn add_toolchain( + &mut self, + toolchain: Toolchain, + scope: ToolchainScope, + cx: &mut Context, + ) { + 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, + ) { + 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, + ) -> Task> { + 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, - ) -> Task)>> { - match &self.0 { - ToolchainStoreInner::Local(local, _) => { + ) -> Task> { + 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::>(), + ) + }) + .collect::>(); + 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> { - 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, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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 { - 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> { - 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); #[derive(Clone)] pub enum ToolchainStoreEvent { ToolchainActivated, + CustomToolchainsModified, } impl EventEmitter 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, + ) -> Task> { + 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 for RemoteToolchainStore {} @@ -556,4 +723,47 @@ impl RemoteToolchainStore { }) }) } + + fn resolve_toolchain( + &self, + abs_path: PathBuf, + language_name: LanguageName, + cx: &mut Context, + ) -> Task> { + 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}"); + } + } + }) + } } diff --git a/crates/proto/proto/toolchain.proto b/crates/proto/proto/toolchain.proto index 08844a307a2c44cf2a30405b3202f10c72db579d..b190322ca0602078ea28d00fe970e4958fb17fb0 100644 --- a/crates/proto/proto/toolchain.proto +++ b/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; + } +} diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 3763671a7a1f29949194d61c70866f96ca6ad972..39fa1fdd53d140cb5d88da751d843e6a7ad1db70 100644 --- a/crates/proto/proto/zed.proto +++ b/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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 3c98ae62e7a4b1489c071a0ac673d23b394c28d5..4c0fc3dc98e22029cf167c0506916d71f3e93602 100644 --- a/crates/proto/src/proto.rs +++ b/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, diff --git a/crates/repl/src/kernels/mod.rs b/crates/repl/src/kernels/mod.rs index 52188a39c48f5fc07a1f4a64949a82d205f75f9f..fb16cb1ea3b093b0592cb114a1224dc4858630fe 100644 --- a/crates/repl/src/kernels/mod.rs +++ b/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 diff --git a/crates/toolchain_selector/Cargo.toml b/crates/toolchain_selector/Cargo.toml index 46b88594fdda8979a861fb33317cae81a32d2ea1..a17f82564093e2ae17f95ec82559f308b910b2dd 100644 --- a/crates/toolchain_selector/Cargo.toml +++ b/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 diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index bf45bffea30791a062e4a130b0f742f3d47c1342..3e26f3ad6c3d23c4b0e00c4c9f67e37fd9c33d32 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/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 } }) } diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index feeca8cf52a5116d53562826da72a0bb304d16ce..2f946a69152f76912a1da996e429c48e3ec3be10 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/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) -> SearchState + 'static>, + language: Option>, + project: Entity, + language_name: LanguageName, + worktree_id: WorktreeId, + relative_path: Arc, +} + +#[derive(Clone)] +struct SearchState { picker: Entity>, } +struct AddToolchainState { + state: AddState, + project: Entity, + language_name: LanguageName, + root_path: ProjectPath, + weak: WeakEntity, +} + +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>, + error: Option>, + input_state: PathInputState, + _subscription: Subscription, + }, + Name { + toolchain: Toolchain, + editor: Entity, + scope_picker: ScopePickerState, + }, +} + +impl AddToolchainState { + fn new( + project: Entity, + language_name: LanguageName, + root_path: ProjectPath, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + 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, + cx: &mut Context, + ) -> (OpenPathDelegate, oneshot::Receiver>>) { + 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, + window: &mut Window, + cx: &mut Context, + ) -> 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>>, + window: &mut Window, + cx: &mut Context, + ) -> 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, + ) { + 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) { + 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) -> 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), +} + +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::(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 { - 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| { + 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, + ) { + 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) -> impl IntoElement { - v_flex().w(rems(34.)).child(self.picker.clone()) + fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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, - candidates: ToolchainList, + candidates: Arc<[(Toolchain, Option)]>, matches: Vec, selected_index: usize, workspace: WeakEntity, @@ -150,6 +743,9 @@ pub struct ToolchainSelectorDelegate { worktree_abs_path_root: Arc, relative_path: Arc, placeholder_text: Arc, + add_toolchain_text: Arc, + project: Entity, + focus_handle: FocusHandle, _fetch_candidates_task: Task>, } @@ -166,19 +762,33 @@ impl ToolchainSelectorDelegate { window: &mut Window, cx: &mut Context>, ) -> 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>) { 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>, + _: &mut Window, + cx: &mut Context>, ) -> Option { 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>, + ) -> Option { + 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(), ) } } diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index ef5a86a2762510fbea6f6a1a5172953a0ea20f7d..d674f6dd4d56ba95a664ac7d9e4ebf25969e2125 100644 --- a/crates/workspace/src/persistence.rs +++ b/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 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 -#[derive(Debug)] -#[allow(dead_code)] -struct Breakpoints(Vec); - 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, + ) -> BTreeMap> { + type RowKind = (WorkspaceId, u64, String, String, String, String, String); + + let toolchains: Vec = 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::>(); @@ -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::>(); diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 005a1ba2347f8ac3847199ad4564d8ca45420f4a..08a2f2e38dd142848f8a9c07652e147b58bee233 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/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, pub(crate) breakpoints: BTreeMap, Vec>, + pub(crate) user_toolchains: BTreeMap>, pub(crate) window_id: Option, } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 6b4e7c1731b23e2e35086431d4d83bda4958d33f..58373b5d1a30a431106282d26589aa09694d3382 100644 --- a/crates/workspace/src/workspace.rs +++ b/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 |_| {