feat(settings): Python interpreter widget in the status bar (#218)

The status bar shows the project's Python interpreter when the project has Python files or configuration, with a warning state when none is set. Its popup lists detected interpreters (paths shown for same-named ones) and switches immediately through the project settings, which restarts the Python language server and re-resolves run configurations; Add New Interpreter… and Interpreter Settings… open the Python Interpreter settings page.

Fixes #138

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 12:08:27 +08:00
1 джерело aea0b33585
коміт 9fbd57ed15
4 files changed
+382 -1

No files matched your search

+155
Переглянути файл
@@ -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(<Component />);
};
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<Sdk[]>((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();
});
});
+2
Переглянути файл
@@ -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()),
+219
Переглянути файл
@@ -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<boolean> {
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<HTMLButtonElement>(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<Sdk[]> | undefined;
let timer: ReturnType<typeof setTimeout> | 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<string, number>();
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 (
<>
<BarButton
ref={anchor}
tooltip={current.python ?? tx("No interpreter")}
aria-label={tx`Python Interpreter: ${label}`}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => void executeAction("python.interpreterPopup")}
>
{!current.python && <Icon name="warning" size={14} />}
{label}
</BarButton>
<Menu
open={open}
onClose={close}
anchor={anchor}
placement="top-end"
items={items}
aria-label="Python Interpreter"
/>
</>
);
}
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());
};
}
+6 -1
Переглянути файл
@@ -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}"
}