дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
aea0b33585
коміт
9fbd57ed15
4 files changed
+382
-1
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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()),
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача