From 32a778d28de67a6becc2f7807dd01a5842e77e5b Mon Sep 17 00:00:00 2001 From: Vadim Lazuk Date: Sun, 8 Nov 2026 04:32:37 +0300 Subject: [PATCH] feat(interpreters): add an existing Python virtual environment as an interpreter (#2076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1907 Add Python Interpreter → Existing environment: Browse picks the environment folder, which is resolved right away (pyvenv.cfg, bin/python or Scripts\python.exe) and added to the interpreter list as "Python X.Y ()", then remembered. Picking the folder rather than the executable keeps the environment's identity, since the native file picker resolves the venv's python symlink to the base interpreter. Works for `python -m venv` and uv environments; a broken environment shows an error instead of being added. Probing runs off the command thread. Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com> --- crates/ide-host/src/ops/sdk.rs | 79 +++++++++++ crates/ide-sdk/src/discover.rs | 30 +++- crates/ide-sdk/src/tests.rs | 70 ++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sdk.rs | 11 ++ src/bindings.ts | 20 +++ .../__tests__/addInterpreter.test.tsx | 128 +++++++++++++++++- .../languages/AddInterpreterDialog.tsx | 88 ++++++++++-- .../settings/languages/Sdk.module.css | 3 + src/features/settings/languages/api.ts | 17 ++- src/platform/locales/text/en.json | 3 + src/platform/locales/text/zh.json | 3 + src/ui/Select.tsx | 2 + 13 files changed, 430 insertions(+), 25 deletions(-) diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs index e9d2fb79b..97973510a 100644 --- a/crates/ide-host/src/ops/sdk.rs +++ b/crates/ide-host/src/ops/sdk.rs @@ -45,6 +45,15 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult IdeResult> { + host.require_trust(window)?; + let root = host.project(window).ok().map(|p| p.root.clone()); + tokio::task::spawn_blocking(move || Ok(ide_sdk::python_interpreter_sdk(&path, root.as_deref()))) + .await + .map_err(join_err)? +} + /// The interpreter the project uses: the configured one, else its virtualenv. pub fn sdk_python_effective(host: &Host, window: &str, near: Option) -> IdeResult> { host.require_trust(window)?; @@ -288,6 +297,10 @@ pub async fn sdk_python_pip( /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await }); + d.add( + "sdk_python_interpreter", + |c| async move { sdk_python_interpreter(&c.host, &c.window, c.arg("path")?).await }, + ); d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) }); d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) }); d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await }); @@ -334,3 +347,69 @@ pub(crate) fn register(d: &mut Dispatcher) { .await }); } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::HostConfig; + use std::path::Path; + use std::sync::Arc; + + fn fake_venv(root: &Path, name: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let venv = root.join(name); + std::fs::create_dir_all(venv.join("bin")).unwrap(); + std::fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.12.1\n").unwrap(); + let python = venv.join("bin/python"); + std::fs::write(&python, "#!/bin/sh\necho 3.12.1\n").unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + python + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_hand_picked_venv_is_resolved_like_a_detected_interpreter() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + std::fs::create_dir(&root).unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(|_| {})), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + + let sdk = sdk_python_interpreter(&host, "main", fake_venv(&root, ".venv")).await.unwrap().unwrap(); + assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (project)", "venv")); + let python = fake_venv(&base, "erp19py3.12"); + let sdk = sdk_python_interpreter(&host, "main", python.parent().unwrap().parent().unwrap().into()) + .await + .unwrap() + .unwrap(); + assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (erp19py3.12)", "venv")); + assert_eq!(&*sdk.path, python.as_path()); + host.project("main") + .unwrap() + .settings + .set( + ide_workspace::SettingsScope::Project, + "languages.python.interpreter", + serde_json::to_value(&sdk.path).unwrap(), + ) + .unwrap(); + host.close_project("main"); + host.open_project("main", &root).unwrap(); + let detected = sdk_detect(&host, "main", SdkKind::Python).await.unwrap(); + assert!(detected.iter().any(|s| s.path == sdk.path && s.name == sdk.name)); + assert_eq!(sdk_python_effective(&host, "main", None).unwrap(), Some(sdk.path)); + assert!(sdk_python_interpreter(&host, "main", root.join("README.md")).await.unwrap().is_none()); + host.close_project("main"); + } +} diff --git a/crates/ide-sdk/src/discover.rs b/crates/ide-sdk/src/discover.rs index 5469d6301..4caecf117 100644 --- a/crates/ide-sdk/src/discover.rs +++ b/crates/ide-sdk/src/discover.rs @@ -197,16 +197,36 @@ fn python(root: Option<&Path>) -> Vec { par_map(&candidates, |(path, source)| python_sdk(path, source, root)) } -/// Interpreter `path` (one the project is set to, wherever it lives) as an SDK, named -/// like the detected ones. +/// A runnable Python executable or virtualenv folder, named like detected SDKs. pub fn python_interpreter_sdk(path: &Path, root: Option<&Path>) -> Option { - python_sdk(path, if venv_of(path).is_some() { "venv" } else { "system" }, root) + let path = if path.is_dir() && path.join("pyvenv.cfg").is_file() { + path.join(bin_dir()).join(exe("python")) + } else { + path.to_path_buf() + }; + if !path.is_file() || !usable(&path) { + return None; + } + // Run Python code: --version and pyvenv.cfg can still work after the base + // installation (including its standard library) has been removed. + let version = + query(&path, &["-I", "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"], VERSION_TIMEOUT)?; + if version.split('.').count() != 3 + || !version.split('.').all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) + { + return None; + } + Some(python_sdk_with_version(&path, if venv_of(&path).is_some() { "venv" } else { "system" }, root, version)) } pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Option { let version = query(path, &["--version"], VERSION_TIMEOUT) .and_then(|v| v.strip_prefix("Python ").map(str::to_owned)) .or_else(|| pyvenv_version(path))?; + Some(python_sdk_with_version(path, source, root, version)) +} + +fn python_sdk_with_version(path: &Path, source: &str, root: Option<&Path>, version: String) -> Sdk { let short = version.split('.').take(2).collect::>().join("."); let venv = venv_of(path); let label = match (&venv, source) { @@ -236,7 +256,7 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti (None, tool @ ("uv" | "mise" | "asdf")) => format!("Python {short} ({tool})"), _ => format!("Python {short}"), }; - Some(Sdk { + Sdk { path: path.into(), name: label, version: Some(version), @@ -246,7 +266,7 @@ pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Opti .or_else(|| path.parent().and_then(Path::parent).map(Path::to_path_buf)) .map(Into::into), extra: None, - }) + } } /// `.pixi/envs/` of a pixi interpreter (`/python`, `/bin/python`). diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index f6d37b023..b29027e43 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -78,22 +78,92 @@ fn project_venv_is_the_default_interpreter() { } #[test] +#[cfg(unix)] fn an_interpreter_outside_detection_is_named_like_a_detected_one() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().join("shop"); let member = fake_venv(&root.join("backend"), ".venv"); + working_python(&member); let sdk = crate::python_interpreter_sdk(&member, Some(&root)).unwrap(); assert_eq!( (sdk.name.as_str(), sdk.version.as_deref(), sdk.source.as_str()), ("Python 3.12 (backend)", Some("3.12.1"), "venv") ); let own = fake_venv(&root, ".venv"); + working_python(&own); assert_eq!(crate::python_interpreter_sdk(&own, Some(&root)).unwrap().name, "Python 3.12 (shop)"); // Outside the project the folder name says which one it is. let elsewhere = fake_venv(&tmp.path().join("other"), ".venv"); + working_python(&elsewhere); assert_eq!(crate::python_interpreter_sdk(&elsewhere, Some(&root)).unwrap().name, "Python 3.12 (.venv)"); } +#[cfg(unix)] +fn working_python(path: &Path) { + use std::os::unix::fs::PermissionsExt; + std::fs::write(path, "#!/bin/sh\n[ \"$1\" = '-I' ] && [ \"$2\" = '-c' ] || exit 1\necho 3.12.1\n").unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[test] +#[cfg(unix)] +fn picked_venv_folders_and_executables_are_validated_without_resolving_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("base-python"); + working_python(&base); + for (name, cfg) in [("venv", "version = 3.12.1\n"), (".venv", "uv = 0.9.0\nversion_info = 3.12.1\n")] { + let python = fake_venv(tmp.path(), name); + let home = python.parent().unwrap().parent().unwrap(); + std::fs::write(home.join("pyvenv.cfg"), cfg).unwrap(); + std::fs::remove_file(&python).unwrap(); + std::os::unix::fs::symlink(&base, &python).unwrap(); + for picked in [home, python.as_path()] { + let sdk = crate::python_interpreter_sdk(picked, None).unwrap(); + assert_eq!(&*sdk.path, python.as_path()); + assert_eq!(sdk.source, "venv"); + assert_eq!(sdk.version.as_deref(), Some("3.12.1")); + } + } + assert_eq!(crate::python_interpreter_sdk(&base, None).unwrap().source, "system"); + std::fs::remove_file(&base).unwrap(); + assert!(crate::python_interpreter_sdk(&tmp.path().join(".venv"), None).is_none()); +} + +#[test] +fn a_real_existing_virtualenv_resolves_from_its_folder_or_platform_executable() { + let Some(base) = crate::exec::which("python3").or_else(|| crate::exec::which("python")) else { return }; + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("existing"); + let status = + std::process::Command::new(base).args(["-I", "-m", "venv", "--without-pip"]).arg(&home).status().unwrap(); + assert!(status.success()); + let python = home.join(bin_dir()).join(exe("python")); + for picked in [&home, &python] { + let sdk = crate::python_interpreter_sdk(picked, None).unwrap(); + assert_eq!(&*sdk.path, python.as_path()); + assert_eq!(&**sdk.home.as_ref().unwrap(), home.as_path()); + assert_eq!(sdk.source, "venv"); + assert!(sdk.name.ends_with("(existing)")); + } +} + +#[test] +#[cfg(unix)] +fn picked_interpreters_must_run_python_code_even_with_valid_venv_metadata() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let python = fake_venv(tmp.path(), "broken"); + // A broken runtime can still answer --version. + std::fs::write(&python, "#!/bin/sh\n[ \"$1\" = '--version' ] && echo 'Python 3.12.1' && exit 0\nexit 1\n").unwrap(); + std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(crate::python_interpreter_sdk(&python, None).is_none()); + std::fs::write(&python, "#!/bin/sh\necho not-python\n").unwrap(); + assert!(crate::python_interpreter_sdk(&python, None).is_none()); + std::fs::remove_file(&python).unwrap(); + assert!(crate::python_interpreter_sdk(&python, None).is_none()); + assert!(crate::python_interpreter_sdk(tmp.path(), None).is_none()); +} + #[test] fn pyright_gets_python_path_and_venv_env() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a9cb46f7f..0de0ec420 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -411,6 +411,7 @@ pub fn builder() -> tauri_specta::Builder { tools::jdk_download_location, tools::jdk_download, sdk::sdk_detect, + sdk::sdk_python_interpreter, sdk::sdk_python_effective, sdk::sdk_php_effective, sdk::sdk_php_xdebug, diff --git a/src-tauri/src/commands/sdk.rs b/src-tauri/src/commands/sdk.rs index a925f2cc8..2d656c3ea 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -96,6 +96,17 @@ pub async fn sdk_locate_tools( ide_host::ops::sdk::sdk_locate_tools(&state.host, window.label(), names, near).await } +/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run. +#[tauri::command] +#[specta::specta] +pub async fn sdk_python_interpreter( + window: Window, + state: State<'_, AppState>, + path: PathBuf, +) -> IdeResult> { + ide_host::ops::sdk::sdk_python_interpreter(&state.host, window.label(), path).await +} + /// Creates a Python environment with `tool` (`venv` / `uv` / `poetry`); output streams through `on_output`. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index f6394480c..394b626fc 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -823,6 +823,26 @@ export const commands = { jdkDownload: (vendor: string, version: number, location: string | null, request: RequestId, onEvent: Channel) => typedError(__TAURI_INVOKE("jdk_download", { vendor, version, location, request, onEvent })), /** Installed interpreters / toolchains of `kind`; project-local ones first. */ sdkDetect: (kind: SdkKind) => typedError(__TAURI_INVOKE("sdk_detect", { kind })), + /** An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run. */ + sdkPythonInterpreter: (path: string) => typedError<{ + /** + * Executable (`python`, `node`, `pnpm`) or directory (Rust toolchain bin + * dir, GOROOT, TypeScript `lib` dir) that the setting stores. + */ + path: IpcPath, + /** Display name, e.g. `Python 3.12 (.venv)` or `stable-aarch64-apple-darwin`. */ + name: string, + version: string | null, + /** + * Where it was found: `venv` | `system` | `pyenv` | `conda` | `uv` | `nvm` | + * `rustup` | `bundled` | `workspace` | `path` … + */ + source: string, + /** Environment / toolchain root (virtualenv dir, rustup toolchain dir, GOROOT). */ + home: IpcPath | null, + /** Rust standard library sources, Go default GOPATH. */ + extra: IpcPath | null, +} | null, IdeError>(__TAURI_INVOKE("sdk_python_interpreter", { path })), /** The interpreter the project uses: the configured one, else its virtualenv. */ sdkPythonEffective: (near: string | null) => typedError(__TAURI_INVOKE("sdk_python_effective", { near })), /** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */ diff --git a/src/features/settings/__tests__/addInterpreter.test.tsx b/src/features/settings/__tests__/addInterpreter.test.tsx index 07832ab4b..d9bf63082 100644 --- a/src/features/settings/__tests__/addInterpreter.test.tsx +++ b/src/features/settings/__tests__/addInterpreter.test.tsx @@ -5,6 +5,7 @@ import type { Sdk } from "@/bindings"; const mocks = vi.hoisted(() => ({ detect: vi.fn(), createVenv: vi.fn(), + interpreter: vi.fn(), choosePath: vi.fn(), })); vi.mock("@/platform/ipc", async (importOriginal) => { @@ -16,6 +17,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { ...real.commands, sdkDetect: mocks.detect, sdkPythonCreateVenv: mocks.createVenv, + sdkPythonInterpreter: mocks.interpreter, cancelRequest: vi.fn(), }, }; @@ -48,6 +50,7 @@ beforeEach(() => { Object.assign(window, { __TAURI_INTERNALS__: { transformCallback: () => 1 } }); mocks.detect.mockResolvedValue(ok([VENV, SYSTEM])); mocks.createVenv.mockResolvedValue(new Promise(() => {})); + mocks.interpreter.mockResolvedValue(ok(null)); useSdks.setState({ byKind: {}, loading: {}, errors: {} }); }); afterEach(() => { @@ -119,6 +122,7 @@ describe("Add Python Interpreter", () => { }); it("adds an existing interpreter without creating anything", async () => { + mocks.interpreter.mockResolvedValue(ok(VENV)); const onAdded = open(); await waitFor(() => expect(screen.getByRole("button", { name: "Base interpreter" })).toBeInTheDocument()); fireEvent.click(screen.getByRole("radio", { name: "Existing environment" })); @@ -126,6 +130,128 @@ describe("Add Python Interpreter", () => { fireEvent.click(await screen.findByRole("option", { name: /Python 3\.12 \(\.venv\)/ })); fireEvent.click(screen.getByRole("button", { name: "OK" })); expect(mocks.createVenv).not.toHaveBeenCalled(); - expect(onAdded).toHaveBeenCalledWith("/project/.venv/bin/python"); + await waitFor(() => expect(onAdded).toHaveBeenCalledWith("/project/.venv/bin/python")); + expect(mocks.interpreter).toHaveBeenCalledWith("/project/.venv/bin/python"); + await waitFor(() => + expect(useSdks.getState().byKind.python?.some((s) => s.path === "/project/.venv/bin/python")).toBe(true), + ); + }); + + it("registers a virtualenv picked from outside detection", async () => { + const OUTSIDE: Sdk = { + path: "/opt/pyenv/erp19py3.12/bin/python", + name: "Python 3.12 (erp19py3.12)", + version: "3.12.1", + source: "venv", + home: "/opt/pyenv/erp19py3.12", + extra: null, + }; + mocks.interpreter.mockResolvedValue(ok(OUTSIDE)); + mocks.choosePath.mockResolvedValue("/opt/pyenv/erp19py3.12"); + const onAdded = open(); + await waitFor(() => expect(screen.getByRole("button", { name: "Base interpreter" })).toBeInTheDocument()); + fireEvent.click(screen.getByRole("radio", { name: "Existing environment" })); + fireEvent.click(screen.getByRole("button", { name: "Browse…" })); + await waitFor(() => expect(screen.getByRole("button", { name: "OK" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await waitFor(() => expect(onAdded).toHaveBeenCalledWith("/opt/pyenv/erp19py3.12/bin/python")); + expect(mocks.interpreter).toHaveBeenNthCalledWith(1, "/opt/pyenv/erp19py3.12"); + expect(mocks.interpreter).toHaveBeenNthCalledWith(2, OUTSIDE.path); + await waitFor(() => expect(useSdks.getState().byKind.python?.some((s) => s.path === OUTSIDE.path)).toBe(true)); + }); + + it.each([ + ["/project/.venv", "/project/.venv/bin/python"], + ["C:\\project\\.venv", "C:\\project\\.venv\\Scripts\\python.exe"], + ])("waits for validation and resolves the folder %s", async (folder, executable) => { + let finish!: (value: ReturnType>) => void; + mocks.interpreter.mockReturnValue(new Promise((resolve) => (finish = resolve))); + mocks.detect.mockResolvedValue(ok([SYSTEM])); + mocks.choosePath.mockResolvedValue(folder); + const onAdded = open(); + fireEvent.click(screen.getByRole("radio", { name: "Existing environment" })); + fireEvent.click(screen.getByRole("button", { name: "Browse…" })); + await waitFor(() => expect(mocks.interpreter).toHaveBeenCalledWith(folder)); + expect(onAdded).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "OK" })).toBeDisabled(); + expect(mocks.choosePath).toHaveBeenCalledWith({ + directory: true, + multiple: false, + title: "Select Python Environment Folder", + defaultPath: "/project", + }); + expect(screen.getByRole("status", { name: "Checking interpreter" })).toBeInTheDocument(); + await act(async () => finish(ok({ ...VENV, path: executable }))); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + const select = screen.getByLabelText("Interpreter:"); + expect(select).toHaveTextContent(`Python 3.12 (.venv) — ${executable}`); + expect(onAdded).not.toHaveBeenCalled(); + expect(useSdks.getState().byKind.python?.some((s) => s.path === executable && s.source === "venv")).toBe(false); + mocks.interpreter.mockResolvedValue(ok({ ...VENV, path: executable })); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await waitFor(() => expect(onAdded).toHaveBeenCalledWith(executable)); + }); + + it.each(["/project/.venv/bin/python", "C:\\project\\.venv\\Scripts\\python.exe"])( + "preserves the detected interpreter path %s when validating", + async (path) => { + mocks.detect.mockResolvedValue(ok([{ ...VENV, path }])); + mocks.interpreter.mockResolvedValue(ok({ ...VENV, path })); + const onAdded = open(); + fireEvent.click(screen.getByRole("radio", { name: "Existing environment" })); + fireEvent.click(screen.getByRole("button", { name: "Interpreter" })); + fireEvent.click(await screen.findByRole("option", { name: /Python 3\.12 \(\.venv\)/ })); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await waitFor(() => expect(onAdded).toHaveBeenCalledWith(path)); + expect(mocks.interpreter).toHaveBeenCalledWith(path); + expect(mocks.choosePath).not.toHaveBeenCalled(); + }, + ); + + it("ignores a validation result after cancelling", async () => { + let finish!: (value: ReturnType>) => void; + mocks.interpreter.mockReturnValue(new Promise((resolve) => (finish = resolve))); + mocks.choosePath.mockResolvedValue(VENV.path); + const onAdded = open(); + fireEvent.click(screen.getByRole("radio", { name: "Existing environment" })); + fireEvent.click(screen.getByRole("button", { name: "Browse…" })); + await waitFor(() => expect(mocks.interpreter).toHaveBeenCalled()); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await act(async () => finish(ok(VENV))); + expect(onAdded).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Interpreter" })).toHaveTextContent("Select an interpreter"); + expect(screen.getByRole("button", { name: "OK" })).toBeDisabled(); + }); + + it("keeps the newly remembered interpreter when a pending scan finishes", async () => { + let finish!: (value: ReturnType>) => void; + mocks.detect.mockReturnValue(new Promise((resolve) => (finish = resolve))); + mocks.interpreter.mockResolvedValue(ok(VENV)); + mocks.choosePath.mockResolvedValue(VENV.path); + const onAdded = open(); + fireEvent.click(screen.getByRole("radio", { name: "Existing environment" })); + fireEvent.click(screen.getByRole("button", { name: "Browse…" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Interpreter" })).toHaveTextContent(VENV.path)); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await waitFor(() => expect(onAdded).toHaveBeenCalledWith(VENV.path)); + await act(async () => finish(ok([SYSTEM]))); + expect(useSdks.getState().byKind.python?.some((s) => s.path === VENV.path)).toBe(true); + expect(useSdks.getState().byKind.python?.some((s) => s.path === SYSTEM.path)).toBe(true); + }); + + it.each(["unusable", "failed"])("keeps an %s interpreter out of the project and shows an error", async (result) => { + mocks.interpreter.mockImplementation(() => + result === "unusable" ? Promise.resolve(ok(null)) : Promise.reject(new Error("Cannot probe interpreter")), + ); + mocks.choosePath.mockResolvedValue("/broken/bin/python"); + const onAdded = open(); + fireEvent.click(screen.getByRole("radio", { name: "Existing environment" })); + fireEvent.click(screen.getByRole("button", { name: "Browse…" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + result === "unusable" ? "Cannot run this Python interpreter" : "Cannot probe interpreter", + ); + expect(onAdded).not.toHaveBeenCalled(); + expect(useSdks.getState().byKind.python?.some((s) => s.path === "/broken/bin/python")).toBe(false); }); }); diff --git a/src/features/settings/languages/AddInterpreterDialog.tsx b/src/features/settings/languages/AddInterpreterDialog.tsx index ea85a3119..de1367ebc 100644 --- a/src/features/settings/languages/AddInterpreterDialog.tsx +++ b/src/features/settings/languages/AddInterpreterDialog.tsx @@ -5,7 +5,7 @@ import { isCancelled } from "@/platform/ipc"; import { useId, useMemo, useState } from "react"; import { Dialog, IconButton, Input, Select, Spinner, type SelectOption, T } from "@/ui"; -import { choosePath, createVenv, rememberSdk, type VenvTool } from "./api"; +import { choosePath, createVenv, pythonInterpreter, rememberSdk, type Sdk, type VenvTool } from "./api"; import { tildify, useDetected } from "./components"; import styles from "./Sdk.module.css"; import { tx } from "@/platform/i18n"; @@ -36,6 +36,7 @@ export function AddInterpreterDialog({ const [base, setBase] = useState(null); const [location, setLocation] = useState(`${root}/.venv`); const [existing, setExisting] = useState(null); + const [browsed, setBrowsed] = useState([]); const [busy, setBusy] = useState<{ cancel: () => void } | null>(null); const [log, setLog] = useState([]); const [error, setError] = useState(null); @@ -48,17 +49,46 @@ export function AddInterpreterDialog({ detail: tildify(s.path), }); const baseOptions = bases.map(toOption); - const existingOptions = useMemo(() => { - const opts = list.map(toOption); - if (existing && !opts.some((o) => o.value === existing)) - opts.push({ value: existing, label: tildify(existing), detail: "custom" }); - return opts; - }, [list, existing]); + const existingOptions = [...browsed, ...list.filter((s) => !browsed.some((b) => b.path === s.path))].map((s) => ({ + value: s.path, + label: `${s.name} — ${tildify(s.path)}`, + })); + + const checkExisting = (path: string, onResolved: (sdk: Sdk) => void) => { + setError(null); + let cancelled = false; + setBusy({ + cancel: () => { + cancelled = true; + setBusy(null); + }, + }); + void pythonInterpreter(path) + .then((sdk) => { + if (cancelled) return; + if (!sdk) { + setError(tx("Cannot run this Python interpreter. Select a working Python executable or virtualenv folder.")); + return; + } + onResolved(sdk); + }) + .catch((e: unknown) => { + if (!cancelled) setError(String((e as Error)?.message ?? e)); + }) + .finally(() => { + if (!cancelled) setBusy(null); + }); + }; const ok = () => { + if (busy) return; setError(null); if (mode === "existing") { - if (existing) onAdded(existing); + if (existing) + checkExisting(existing, (sdk) => { + rememberSdk("python", sdk); + onAdded(sdk.path); + }); return; } if (!baseValue || !location.trim()) return; @@ -74,7 +104,7 @@ export function AddInterpreterDialog({ .finally(() => setBusy(null)); }; - const canOk = mode === "new" ? !!baseValue && !!location.trim() && !busy : !!existing; + const canOk = !busy && (mode === "new" ? !!baseValue && !!location.trim() : !!existing?.trim()); const note = { venv: tx("Runs “ -m venv ”; the new interpreter becomes the project interpreter."), conda: tx("Runs “conda create -p ”; the new interpreter becomes the project interpreter."), @@ -91,7 +121,11 @@ export function AddInterpreterDialog({ title="Add Python Interpreter" width={680} aria-label="Add Python Interpreter" - footerStart={busy && } + footerStart={ + busy && ( + + ) + } buttons={[ { label: "OK", role: "default", disabled: !canOk, onClick: ok }, { label: "Cancel", role: "cancel" }, @@ -103,6 +137,7 @@ export function AddInterpreterDialog({