diff --git a/src/features/settings/__tests__/pythonStatus.test.tsx b/src/features/settings/__tests__/pythonStatus.test.tsx new file mode 100644 index 000000000..47dadb79c --- /dev/null +++ b/src/features/settings/__tests__/pythonStatus.test.tsx @@ -0,0 +1,155 @@ +import { act, render, screen, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { executeAction, registerAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import { useProject } from "@/platform/project"; +import type { ProjectInfo, Sdk } from "@/bindings"; + +const mocks = vi.hoisted(() => ({ + notify: vi.fn(), + files: vi.fn(), + effective: vi.fn(), + detect: vi.fn(), + set: vi.fn(), + component: null as React.ComponentType | null, +})); +vi.mock("@/platform/ipc", () => ({ + call: (p: unknown) => p, + isTauri: () => true, + onIdeEvent: () => () => {}, + commands: { + findFiles: mocks.files, + sdkPythonEffective: mocks.effective, + sdkDetect: mocks.detect, + settingsSet: mocks.set, + }, +})); +vi.mock("@/workbench/contributions", () => ({ + notifications: () => ({ notify: mocks.notify }), + registerStatusItem: (item: { component: React.ComponentType }) => { + mocks.component = item.component; + return () => {}; + }, +})); +import { activatePythonStatus, interpreterChoices, isPythonPath } from "../languages/PythonStatus"; + +let dispose: () => void; +beforeEach(() => { + vi.clearAllMocks(); + mocks.files.mockResolvedValue([]); + mocks.effective.mockResolvedValue(null); + mocks.detect.mockResolvedValue([]); + mocks.set.mockResolvedValue({ languages: { python: { interpreter: "/new/python" } } }); + useProject.setState({ project: { root: "/project" } as ProjectInfo, settings: null }); + setContext("projectOpen", true); + dispose = activatePythonStatus(); +}); +afterEach(() => { + cleanup(); + dispose(); +}); + +const mount = () => { + const Component = mocks.component!; + return render(); +}; + +describe("Python interpreter status", () => { + it.each([ + "src/main.py", + "types/lib.pyi", + "C:\\app\\main.pyw", + "pyproject.toml", + "requirements-dev.txt", + "SConstruct", + ])("recognizes %s", (path) => expect(isPythonPath(path)).toBe(true)); + it.each(["main.ts", "python.md", "pyproject.toml.bak", "copy.py.json"])("rejects %s", (path) => + expect(isPythonPath(path)).toBe(false), + ); + it("keeps custom interpreters and deduplicates detected paths", () => { + const sdk = { path: "/python", name: "Python 3.12" } as Sdk; + expect(interpreterChoices([sdk, sdk], "/custom")).toEqual([sdk, { path: "/custom", name: "/custom" }]); + }); + it("hides for non-Python projects without probing SDKs", async () => { + mount(); + await waitFor(() => expect(mocks.files).toHaveBeenCalled()); + expect(screen.queryByRole("button")).toBeNull(); + expect(mocks.detect).not.toHaveBeenCalled(); + }); + it("shows a warning for Python files without an interpreter", async () => { + mocks.files.mockResolvedValue([{ path: "/project/src/main.py" }]); + mount(); + expect(await screen.findByText("No interpreter")).toBeTruthy(); + const button = screen.getByRole("button", { name: "Python Interpreter: No interpreter" }); + expect(button.querySelector("svg")).toBeTruthy(); + }); + it("shows the paths of interpreters that share a name", async () => { + mocks.files.mockResolvedValue([{ path: "/project/main.py" }]); + mocks.effective.mockResolvedValue("/usr/local/bin/python3.11"); + mocks.detect.mockResolvedValue([ + { path: "/usr/local/bin/python3.11", name: "Python 3.11" }, + { path: "/opt/homebrew/bin/python3.11", name: "Python 3.11" }, + { path: "/venv/bin/python", name: "Python 3.12 (.venv)" }, + ]); + mount(); + await screen.findByRole("button", { name: "Python Interpreter: Python 3.11" }); + await act(() => executeAction("python.interpreterPopup")); + expect(await screen.findByText("/opt/homebrew/bin/python3.11")).toBeTruthy(); + expect(screen.getByText("/usr/local/bin/python3.11")).toBeTruthy(); + expect(screen.queryByText("/venv/bin/python")).toBeNull(); + }); + it("opens the Python page when the widget is hidden", async () => { + const run = vi.fn(); + const off = registerAction({ id: "file.settings", title: "Settings…", run }); + mount(); + await waitFor(() => expect(mocks.files).toHaveBeenCalled()); + await executeAction("python.interpreterPopup"); + expect(run.mock.calls.map(([ctx]) => ctx.arg)).toEqual(["python.interpreter"]); + off(); + }); + it("shows the effective SDK name and hides old state on project change", async () => { + mocks.files.mockResolvedValue([{ path: "/project/pyproject.toml" }]); + mocks.effective.mockResolvedValue("/venv/python"); + mocks.detect.mockResolvedValue([{ path: "/venv/python", name: "Python 3.12 (.venv)" }]); + mount(); + expect(await screen.findByText("Python 3.12 (.venv)")).toBeTruthy(); + mocks.files.mockResolvedValue([]); + act(() => useProject.setState({ project: { root: "/other" } as ProjectInfo })); + expect(screen.queryByRole("button")).toBeNull(); + }); + it("writes the project setting through the same backend path as Settings", async () => { + await executeAction("python.selectInterpreter", "/new/python"); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.python.interpreter", '"/new/python"'); + expect(useProject.getState().settings?.languages?.python?.interpreter).toBe("/new/python"); + }); + it("reports write failures without changing the selected interpreter", async () => { + mocks.set.mockRejectedValueOnce(new Error("write failed")); + await executeAction("python.selectInterpreter", "/new/python"); + expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" })); + expect(useProject.getState().settings).toBeNull(); + }); + it("discards discovery completing after a project switch", async () => { + mocks.files.mockResolvedValue([{ path: "/project/main.py" }]); + let finish!: (sdks: Sdk[]) => void; + mocks.detect.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + mocks.effective.mockResolvedValue("/old/python"); + mount(); + await waitFor(() => expect(mocks.detect).toHaveBeenCalled()); + mocks.files.mockResolvedValue([]); + act(() => useProject.setState({ project: { root: "/other" } as ProjectInfo })); + await act(async () => finish([{ path: "/old/python", name: "Python 3.12" } as Sdk])); + expect(screen.queryByRole("button")).toBeNull(); + }); + it("opens the Python page for both settings entries", async () => { + const run = vi.fn(); + const off = registerAction({ id: "file.settings", title: "Settings…", run }); + await executeAction("python.addInterpreter"); + await executeAction("python.interpreterSettings"); + expect(run.mock.calls.map(([ctx]) => ctx.arg)).toEqual(["python.interpreter", "python.interpreter"]); + off(); + }); +}); diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index 9e5f38617..3be8e8f04 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -35,6 +35,7 @@ import { trackRuntimeSettings } from "./runtime"; import { installActionProxies, installSettingsHost } from "./window/bridge"; import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut"; import { settingsSearchEntries } from "./searchIndex"; +import { activatePythonStatus } from "./languages/PythonStatus"; import { isImeKey } from "@/platform/ime"; export { settingsWindowParent } from "./window/bridge"; @@ -100,6 +101,7 @@ async function showSettings(page?: string, newProjects = false) { export function activate(): () => void { const disposers = [ trackRuntimeSettings(), + activatePythonStatus(), unloadBuiltinPages, registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }), registerActions(zoomActions()), diff --git a/src/features/settings/languages/PythonStatus.tsx b/src/features/settings/languages/PythonStatus.tsx new file mode 100644 index 000000000..cab324900 --- /dev/null +++ b/src/features/settings/languages/PythonStatus.tsx @@ -0,0 +1,219 @@ +import { useEffect, useRef, useState } from "react"; +import { create } from "zustand"; +import { executeAction, registerActions } from "@/platform/actions"; +import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { tx } from "@/platform/i18n"; +import { notifications, registerStatusItem } from "@/workbench/contributions"; +import { BarButton, Menu, type MenuItem } from "@/ui"; +import { Icon } from "@/ui/icons"; +import { effectivePython, type Sdk } from "./api"; +import { tildify } from "./components"; + +const usePopup = create<{ open: boolean; shown: boolean }>(() => ({ open: false, shown: false })); +const close = () => usePopup.setState({ open: false }); +const markers = [ + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "pyrightconfig.json", + "ty.toml", + "uv.lock", + "poetry.lock", + "SConstruct", + "SConscript", + ".python-version", +]; + +export function isPythonPath(path: string): boolean { + const name = path.replaceAll("\\", "/").split("/").at(-1)!; + return /\.(py|pyi|pyw)$/.test(name) || markers.includes(name) || /^requirements[\w.-]*\.txt$/.test(name); +} + +export function interpreterChoices(sdks: Sdk[], current: string | null): { path: string; name: string }[] { + const choices = [...new Map(sdks.map((sdk) => [sdk.path, sdk])).values()]; + if (!current || choices.some((sdk) => sdk.path === current)) return choices; + return [...choices, { path: current, name: current }]; +} + +async function hasPythonFiles(): Promise { + const matches = await Promise.all( + [".py", "requirements", ...markers].map((query) => call(commands.findFiles(query, 10000))), + ); + return matches.some((files) => files.some((file) => isPythonPath(file.path))); +} + +function PythonStatus() { + const root = useProject((s) => s.project?.root); + const configured = useProject((s) => s.settings?.languages?.python?.interpreter ?? ""); + const open = usePopup((s) => s.open); + const anchor = useRef(null); + const [state, setState] = useState<{ + root: string; + configured: string; + visible: boolean; + python: string | null; + sdks: Sdk[]; + } | null>(null); + useEffect(() => { + close(); + if (!root || !isTauri()) return; + let live = true; + let sequence = 0; + let detected: Promise | undefined; + let timer: ReturnType | undefined; + const refresh = async () => { + const request = ++sequence; + try { + const visible = await hasPythonFiles(); + if (!live || sequence !== request) return; + const [python, sdks] = + visible || configured + ? await Promise.all([effectivePython(), (detected ??= call(commands.sdkDetect("python")))]) + : [null, [] as Sdk[]]; + if (live && sequence === request) setState({ root, configured, visible, python, sdks }); + } catch (e) { + console.warn("Cannot refresh Python interpreter status", e); + } + }; + const schedule = () => { + clearTimeout(timer); + timer = setTimeout(() => void refresh(), 300); + }; + void refresh(); + const off = [ + onIdeEvent("indexState", (e) => { + if (!e.indexing) schedule(); + }), + onIdeEvent("fsChanged", (e) => { + if (e.created.length || e.removed.length) detected = undefined; + if ( + e.created.length || + e.removed.length || + e.modified.some((path) => markers.includes(path.replaceAll("\\", "/").split("/").at(-1)!)) + ) + schedule(); + }), + onIdeEvent("settingsChanged", () => { + detected = undefined; + schedule(); + }), + ]; + return () => { + live = false; + clearTimeout(timer); + off.forEach((dispose) => dispose()); + close(); + }; + }, [root, configured]); + const current = state && state.root === root && state.configured === configured ? state : null; + const shown = !!root && !!current && (current.visible || !!configured); + useEffect(() => { + usePopup.setState({ shown }); + return () => usePopup.setState({ shown: false, open: false }); + }, [shown]); + if (!shown || !current) return null; + const choices = interpreterChoices(current.sdks, current.python); + const label = choices.find((sdk) => sdk.path === current.python)?.name ?? tx("No interpreter"); + const names = new Map(); + for (const sdk of choices) names.set(sdk.name, (names.get(sdk.name) ?? 0) + 1); + const items: MenuItem[] = [ + { kind: "header", label: "Python Interpreter" }, + ...choices.map((sdk): MenuItem => ({ + label: sdk.name, + verbatim: true, + // Several system interpreters share a name ("Python 3.11"); their paths tell them apart. + shortcut: (names.get(sdk.name) ?? 0) > 1 ? tildify(sdk.path) : undefined, + checked: sdk.path === current.python, + run: () => void executeAction("python.selectInterpreter", sdk.path), + })), + { kind: "separator" }, + { kind: "action", action: "python.addInterpreter" }, + { kind: "action", action: "python.interpreterSettings" }, + ]; + return ( + <> + void executeAction("python.interpreterPopup")} + > + {!current.python && } + {label} + + + + ); +} + +export function activatePythonStatus(): () => void { + const dispose = [ + registerStatusItem({ + id: "python.interpreter", + title: "Python Interpreter", + align: "right", + priority: 90, + component: PythonStatus, + }), + registerActions([ + { + id: "python.interpreterPopup", + title: "Python Interpreter", + category: "Settings", + when: "projectOpen", + run: () => + usePopup.getState().shown + ? usePopup.setState((s) => ({ open: !s.open })) + : void executeAction("file.settings", "python.interpreter"), + }, + { + id: "python.selectInterpreter", + title: "Select Python Interpreter", + category: "Settings", + hidden: true, + run: async ({ arg }) => { + const project = useProject.getState().project; + if (!project || typeof arg !== "string" || !arg) return; + try { + const settings = await call( + commands.settingsSet("project", "languages.python.interpreter", JSON.stringify(arg)), + ); + if (useProject.getState().project === project) useProject.setState({ settings }); + } catch (e) { + notifications().notify({ level: "error", title: "Cannot select Python interpreter", body: String(e) }); + } + }, + }, + { + id: "python.addInterpreter", + title: "Add New Interpreter…", + category: "Settings", + when: "projectOpen", + run: () => void executeAction("file.settings", "python.interpreter"), + }, + { + id: "python.interpreterSettings", + title: "Interpreter Settings…", + category: "Settings", + when: "projectOpen", + run: () => void executeAction("file.settings", "python.interpreter"), + }, + ]), + ]; + return () => { + close(); + dispose.reverse().forEach((d) => d()); + }; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c503ad1db..ec3e85689 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4729,5 +4729,10 @@ "Proxy host is empty": "代理主机为空", "Invalid proxy host": "代理主机无效", "Proxy port must be between 1 and 65535": "代理端口必须介于 1 和 65535 之间", - "Proxy login is empty": "代理登录名为空" + "Proxy login is empty": "代理登录名为空", + "No interpreter": "无解释器", + "Add New Interpreter…": "添加新的解释器…", + "Interpreter Settings…": "解释器设置…", + "Cannot select Python interpreter": "无法选择 Python 解释器", + "Python Interpreter: {label}": "Python 解释器:{label}" }