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({