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}"
}