feat(java): Separate the jdtls JDK and add Settings for New Projects (#21)

- Languages & Frameworks › Java › Language Server › JDK (languages.java.jdtlsJdk) runs jdtls on its own JDK, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK.
- Toolchain pages get the Global / This project switch (default This project); Global shows and edits the global layer.
- File › New Projects Setup › Settings for New Projects… edits only the global layer, which new projects inherit.
- Configuration errors (launch_error) show as "Not started" instead of "Crashed".

Fixes #18
Fixes #19

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-24 18:26:31 +08:00
1 джерело a765959d48
коміт 059f25f13e
38 files changed
+486 -93

No files matched your search

+24 -3
Переглянути файл
@@ -22,6 +22,11 @@ export const commands = {
languages: () => __TAURI_INVOKE<LanguageInfo_Serialize[]>("languages"),
/** Effective settings for the window's project, or global settings without one. */
settingsGet: () => __TAURI_INVOKE<Settings_Serialize>("settings_get"),
/**
* Defaults plus the global layer, without any project's values: what new
* projects (and projects without their own value) get.
*/
settingsGetGlobal: () => __TAURI_INVOKE<Settings_Serialize>("settings_get_global"),
/**
* `value` is JSON text, e.g. `14` or `"dark"`. Every window (for a project
* value: that project's windows) is told (`IdeEvent::SettingsChanged`) so it
@@ -429,10 +434,11 @@ export const commands = {
/** Opens an empty (welcome) window; returns its label. */
windowNew: () => typedError<string, IdeError>(__TAURI_INVOKE("window_new")),
/**
* Opens this window's Settings window on `page` (or focuses it and navigates).
* Opens this window's Settings window on `page` (or focuses it and navigates);
* `new_projects` edits the global layer only ("Settings for New Projects").
* `background` (`#rrggbb`) paints a new window until its page has loaded.
*/
windowOpenSettings: (page: string | null, background: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_open_settings", { page, background })),
windowOpenSettings: (page: string | null, newProjects: boolean, background: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_open_settings", { page, newProjects, background })),
/** Focuses the next / previous window (⌘` / ⇧⌘`). */
windowCycle: (forward: boolean) => __TAURI_INVOKE<void>("window_cycle", { forward }),
windowToggleFullscreen: () => typedError<boolean, IdeError>(__TAURI_INVOKE("window_toggle_fullscreen")),
@@ -442,6 +448,8 @@ export const commands = {
project: IpcPath | null,
/** Settings windows: the page to show. */
settingsPage: string | null,
/** Settings windows: edit the global layer only ("Settings for New Projects"). */
newProjects: boolean,
} | null>("window_take_launch"),
windowCloseContext: () => __TAURI_INVOKE<CloseContext>("window_close_context"),
/** Projects open in project windows, in window order (title bar project widget). */
@@ -2367,6 +2375,12 @@ export type JavaSettings = {
* Java version it targets.
*/
jdk?: string,
/**
* JDK home the Java language server (jdtls) runs on, apart from the project
* JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
* else the first installed one that is.
*/
jdtlsJdk?: string,
/**
* Extra JVM options for the Java language server (jdtls), space-separated
* (VS Code's `java.jdt.ls.vmargs`).
@@ -2442,6 +2456,8 @@ export type Launch = {
project: IpcPath | null,
/** Settings windows: the page to show. */
settingsPage: string | null,
/** Settings windows: edit the global layer only ("Settings for New Projects"). */
newProjects: boolean,
};
export type LaunchConfig = {
@@ -3381,7 +3397,12 @@ export type ServerInfo = {
pid: number | null,
};
export type ServerStatus = "starting" | "ready" | "busy" | "crashed" | "stopped" | "missing";
export type ServerStatus = "starting" | "ready" | "busy" | "crashed" | "stopped" | "missing" |
/**
* Not started: its configuration can't launch it (e.g. no JDK new enough);
* the message says what to change, and a settings change retries.
*/
"misconfigured";
/** Exception filters of one session (see [`DebugManager::exception_filters`]). */
export type SessionExceptionFilters = {
+1 -1
Переглянути файл
@@ -89,6 +89,6 @@ export function ServerStatus() {
function toneOf(status: string): "ok" | "busy" | "error" | "idle" {
if (status === "ready") return "ok";
if (status === "starting" || status === "busy") return "busy";
if (status === "crashed" || status === "missing") return "error";
if (status === "crashed" || status === "missing" || status === "misconfigured") return "error";
return "idle";
}
+2 -1
Переглянути файл
@@ -126,12 +126,13 @@ export const STATUS_LABEL: Record<ServerStatus, string> = {
crashed: "Crashed",
stopped: "Stopped",
missing: "Not installed",
misconfigured: "Not started",
};
/** One-line summary for the status bar. */
export function serversSummary(servers: readonly ServerEntry[]): { text: string; tone: "ok" | "busy" | "error" | "idle" } | null {
if (!servers.length) return null;
const bad = servers.filter((s) => s.status === "crashed" || s.status === "missing");
const bad = servers.filter((s) => s.status === "crashed" || s.status === "missing" || s.status === "misconfigured");
const busy = servers.filter((s) => s.status === "starting" || s.status === "busy");
const tone = bad.length ? "error" : busy.length ? "busy" : servers.some((s) => s.status === "ready") ? "ok" : "idle";
const names = [...new Set(servers.map((s) => s.name))];
+1
Переглянути файл
@@ -44,6 +44,7 @@ describe("serversSummary", () => {
expect(serversSummary([s("rust-analyzer", "ready")])).toEqual({ text: "rust-analyzer", tone: "ok" });
expect(serversSummary([s("vtsls", "ready"), s("eslint", "starting")])).toEqual({ text: "vtsls +1", tone: "busy" });
expect(serversSummary([s("gopls", "missing")])!.tone).toBe("error");
expect(serversSummary([s("jdtls", "misconfigured")])!.tone).toBe("error");
});
});
+45
Переглянути файл
@@ -4,6 +4,7 @@ import type { Settings } from "@/platform/ipc";
const settingsSet = vi.fn();
const settingsGet = vi.fn();
const settingsGetGlobal = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
@@ -14,6 +15,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
...real.commands,
settingsSet: (...a: unknown[]) => settingsSet(...a),
settingsGet: () => settingsGet(),
settingsGetGlobal: () => settingsGetGlobal(),
},
};
});
@@ -38,6 +40,7 @@ beforeEach(() => {
return { status: "ok", data: current };
});
settingsGet.mockReset().mockImplementation(async () => current);
settingsGetGlobal.mockReset().mockImplementation(async () => current);
useProject.setState({ settings: current, project: null });
dispose = activate();
});
@@ -171,6 +174,48 @@ describe("Settings dialog", () => {
expect(useKeymap.getState().overrides).toEqual({ "t.dup": ["Ctrl+Alt+Shift+Meta+F9"] });
});
describe("toolchain settings with a project open", () => {
let global: Settings;
beforeEach(() => {
// Global layer: -Xmx1g; the project overrides it with -Xmx4g.
global = setAt(structuredClone(DEFAULT_SETTINGS) as Settings, "languages.java.jdtlsVmArgs", "-Xmx1g");
current = setAt(global, "languages.java.jdtlsVmArgs", "-Xmx4g");
settingsGetGlobal.mockImplementation(async () => global);
useProject.setState({
settings: current,
project: { root: "/p", name: "p", settings: current } as never,
});
});
it("edit the project first; Global shows and writes the global layer", async () => {
const dialog = await openDialog("languages.java");
expect(within(dialog).getByRole("radio", { name: "This project" })).toHaveAttribute("aria-checked", "true");
expect(within(dialog).getByLabelText("JVM options")).toHaveValue("-Xmx4g");
fireEvent.click(within(dialog).getByRole("radio", { name: "Global" }));
expect(within(dialog).getByLabelText("JVM options")).toHaveValue("-Xmx1g");
expect(within(dialog).getByText(/used by new projects/)).toBeInTheDocument();
fireEvent.change(within(dialog).getByLabelText("JVM options"), { target: { value: "-Xmx2g" } });
await act(async () => {
fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }));
});
expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsVmArgs", '"-Xmx2g"']]);
});
it("Settings for New Projects edits the global layer on every page", async () => {
render(<SettingsDialog />);
await act(async () => {
await executeAction("file.newProjectsSettings");
});
const dialog = screen.getByRole("dialog", { name: "Settings for New Projects" });
expect(within(dialog).queryByRole("radiogroup", { name: "Settings scope" })).toBeNull();
expect(within(dialog).getByLabelText("JVM options")).toHaveValue("-Xmx1g");
expect(within(dialog).getByLabelText("Language server JDK")).toBeInTheDocument();
expect(within(dialog).getByText(/used by new projects/)).toBeInTheDocument();
});
});
it("survives invalid settings values", async () => {
act(() =>
useProject.setState({
+2 -1
Переглянути файл
@@ -21,7 +21,7 @@ describe("languages settings", () => {
node: { packageManager: "pip" },
rust: { checkCommand: "clippy", checkOnSave: false },
typescript: { version: "" },
java: { jdtlsVmArgs: " -Xmx2g ", jdk: 21, maven: { home: " /opt/maven " } },
java: { jdtlsVmArgs: " -Xmx2g ", jdk: 21, jdtlsJdk: " /opt/jdk-25 ", maven: { home: " /opt/maven " } },
});
expect(v.python).toEqual({ interpreter: "/usr/bin/python3", activateVirtualenv: true });
expect(v.node.packageManager).toBe("");
@@ -30,6 +30,7 @@ describe("languages settings", () => {
expect(v.typescript.version).toBe("bundled");
expect(v.java.jdtlsVmArgs).toBe("-Xmx2g");
expect(v.java.jdk).toBe("");
expect(v.java.jdtlsJdk).toBe("/opt/jdk-25");
expect(v.java.maven).toEqual({ home: "/opt/maven", userSettings: "", localRepository: "" });
expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES);
});
+21
Переглянути файл
@@ -40,6 +40,27 @@ describe("settings staging", () => {
expect(useProject.getState().settings?.editor?.showWhitespace).toBe(false);
});
it("compares the Global scope with the global layer, not the project's values", () => {
const projectValue = setAt(useSettingsDialog.getState().base, "editor.tabSize", 8);
useSettingsDialog.setState({ base: projectValue });
const s = () => useSettingsDialog.getState();
expect(stagedValue(s(), "global", "editor.tabSize")).toBe(4);
expect(stagedValue(s(), "project", "editor.tabSize")).toBe(8);
// 8 is new for the global layer even though the project already has it.
stage("editor.general", "global", "editor.tabSize", 8);
expect(pendingChanges(s())).toEqual([{ scope: "global", key: "editor.tabSize", value: 8, page: "editor.general" }]);
stage("editor.general", "project", "editor.tabSize", 8);
expect(pendingChanges(s())).toHaveLength(1);
});
it("opens Settings for New Projects on request", () => {
expect(useSettingsDialog.getState().newProjects).toBe(false);
openSettings("languages.java", { newProjects: true });
expect(useSettingsDialog.getState()).toMatchObject({ newProjects: true, pageId: "languages.java" });
openSettings();
expect(useSettingsDialog.getState().newProjects).toBe(false);
});
it("drops edits that return to the effective value", () => {
stage("editor.general", "global", "editor.tabSize", 2);
stage("editor.general", "global", "editor.tabSize", 4);
+19 -5
Переглянути файл
@@ -33,6 +33,7 @@ import {
selectPage,
setPageScope,
setQuery,
settingsTitle,
useSettingsDialog,
type Scope,
} from "../model/staging";
@@ -99,10 +100,13 @@ function PageHost({
}) {
const pageId = node?.key ?? "";
const hasProject = useProject((s) => !!s.project);
const newProjects = useSettingsDialog((s) => s.newProjects);
const spec = page ? builtinSpec(page.id) : undefined;
const projectScope = !!spec?.projectScope && hasProject;
const chosen = useSettingsDialog((s) => s.scopes[pageId]);
const projectScope = !!spec?.projectScope && hasProject && !newProjects;
const chosen = useSettingsDialog((s) => s.scopes[pageId]) ?? spec?.defaultScope;
const scope: Scope = projectScope && chosen === "project" ? "project" : "global";
// Where a project value is the norm, say what the global one is for.
const globalNote = newProjects || (projectScope && scope === "global" && spec?.defaultScope === "project");
const ctx = useMemo(() => ({ pageId, scope }), [pageId, scope]);
if (!node) return <EmptyState title="Nothing found" icon="search" />;
const Body = page?.component;
@@ -142,6 +146,12 @@ function PageHost({
<T>Saved to .openidea/settings.toml in this project; overrides the global value.</T>
</div>
)}
{globalNote && (
<div className={styles.scopeNote}>
<Icon name="info" size={14} />
<T>Saved to the global settings: used by new projects and by projects without their own value.</T>
</div>
)}
<div className={styles.pageBody}>
<PageErrorBoundary pageId={pageId}>{Body ? <Body /> : <GroupPage node={node} />}</PageErrorBoundary>
</div>
@@ -155,12 +165,16 @@ export function SettingsDialog() {
return <SettingsDialogInner />;
}
export function useSettingsTitle(): string {
return useSettingsDialog((s) => settingsTitle(s.newProjects));
}
function SettingsDialogInner() {
return (
<Dialog
open
onClose={closeSettings}
title="Settings"
title={useSettingsTitle()}
width={980}
className={styles.dialog}
footerStart={<SettingsStatus />}
@@ -283,8 +297,8 @@ export function SettingsLayout() {
/** OK / Cancel / Apply, ordered per platform by the dialog primitive. */
function useSettingsButtons(): DialogButton[] {
const { staged, base, saving } = useSettingsDialog();
const pending = useMemo(() => pendingChanges({ base, staged }).length > 0, [base, staged]);
const { staged, base, globalBase, saving } = useSettingsDialog();
const pending = useMemo(() => pendingChanges({ base, globalBase, staged }).length > 0, [base, globalBase, staged]);
const ok = async () => {
if (await applySettings()) closeSettings();
};
+53 -13
Переглянути файл
@@ -7,7 +7,9 @@
* On the desktop Settings open in a native child window (`activateSettingsWindow`
* runs there); in the browser build they open as an in-page dialog.
*
* `executeAction("file.settings", "keymap")` opens a specific page.
* `executeAction("file.settings", "keymap")` opens a specific page;
* `file.newProjectsSettings` (IDEA's Settings for New Projects) edits the
* global layer only, which new projects inherit.
*/
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { registerActions, type Action } from "@/platform/actions";
@@ -16,7 +18,15 @@ import { isPopupOpen, lazyComponent } from "@/ui";
import { loadSettings, useProject } from "@/platform/project";
import { registerOverlay } from "@/workbench/contributions";
import { LazySettingsDialog, loadBuiltinPages, unloadBuiltinPages } from "./dialog/LazySettingsDialog";
import { closeSettings, openSettings, selectPage, setQuery, useSettingsDialog } from "./model/staging";
import {
closeSettings,
openSettings,
selectPage,
setNewProjects,
setQuery,
settingsTitle,
useSettingsDialog,
} from "./model/staging";
import { zoomActions } from "./appearanceRuntime";
import { trackRuntimeSettings } from "./runtime";
import { installActionProxies, installSettingsHost } from "./window/bridge";
@@ -27,7 +37,10 @@ export const SettingsWindow = lazyComponent(() => import("./window/SettingsWindo
/** Same event as `windows::LAUNCH_EVENT`: a pending launch (page) is waiting. */
const LAUNCH_EVENT = "openidea://launch";
function settingsActions(open: (page?: string) => void): Action[] {
/** Java's page first: JDK, Maven and the language server are what new projects most often need. */
const NEW_PROJECTS_PAGE = "languages.java";
function settingsActions(open: (page?: string, newProjects?: boolean) => void): Action[] {
return [
{
id: "file.settings",
@@ -37,6 +50,14 @@ function settingsActions(open: (page?: string) => void): Action[] {
keywords: ["preferences", "options", "configure"],
run: ({ arg }) => open(typeof arg === "string" ? arg : undefined),
},
{
id: "file.newProjectsSettings",
title: "Settings for New Projects…",
category: "File",
icon: "settings",
keywords: ["new projects setup", "default settings", "default project", "default jdk", "global settings"],
run: ({ arg }) => open(typeof arg === "string" ? arg : NEW_PROJECTS_PAGE, true),
},
{
id: "settings.keymap",
title: "Keymap…",
@@ -57,18 +78,18 @@ function settingsActions(open: (page?: string) => void): Action[] {
}
/** Native Settings window when the backend can make one, else the dialog. */
async function showSettings(page?: string) {
async function showSettings(page?: string, newProjects = false) {
if (isTauri()) {
try {
const frame = getComputedStyle(document.documentElement).getPropertyValue("--bg-frame").trim();
await call(commands.windowOpenSettings(page ?? null, frame || null));
await call(commands.windowOpenSettings(page ?? null, newProjects, frame || null));
return;
} catch (e) {
console.warn("settings window unavailable, using the dialog", e);
}
}
await loadBuiltinPages();
openSettings(page);
openSettings(page, { newProjects });
}
export function activate(): () => void {
@@ -77,7 +98,7 @@ export function activate(): () => void {
unloadBuiltinPages,
registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }),
registerActions(zoomActions()),
registerActions(settingsActions((page) => void showSettings(page))),
registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))),
isTauri() ? installSettingsHost() : () => {},
];
return () => disposers.reverse().forEach((d) => d());
@@ -89,21 +110,33 @@ function navigate(page: string | undefined) {
selectPage(page);
}
async function takeLaunchPage(): Promise<string | undefined> {
interface SettingsLaunch {
page?: string;
newProjects: boolean;
}
async function takeLaunch(): Promise<SettingsLaunch | null> {
const launch = await commands.windowTakeLaunch().catch(() => null);
return launch?.settingsPage ?? undefined;
return launch && { page: launch.settingsPage ?? undefined, newProjects: launch.newProjects };
}
/** A launch for the open window: its page, and the mode when it asks for new projects. */
function relaunch(launch: SettingsLaunch | null) {
if (!launch) return;
if (launch.newProjects) setNewProjects(true);
navigate(launch.page);
}
function listenLaunch(): () => void {
let disposed = false;
let off: (() => void) | undefined;
void getCurrentWebviewWindow()
.listen(LAUNCH_EVENT, () => void takeLaunchPage().then(navigate))
.listen(LAUNCH_EVENT, () => void takeLaunch().then(relaunch))
.then((u) => {
if (disposed) return u();
off = u;
// A page requested before we listened: its event is gone, the launch is not.
void takeLaunchPage().then(navigate);
void takeLaunch().then(relaunch);
});
return () => {
disposed = true;
@@ -135,12 +168,17 @@ export function activateSettingsWindow(parent: string): () => void {
const disposers = [
trackRuntimeSettings(),
unloadBuiltinPages,
registerActions(settingsActions(navigate)),
registerActions(settingsActions((page, newProjects) => relaunch({ page, newProjects: !!newProjects }))),
installActionProxies(parent),
listenLaunch(),
installWindowKeys(),
useSettingsDialog.subscribe((s, prev) => {
if (prev.open && !s.open) void getCurrentWebviewWindow().close();
if (s.newProjects !== prev.newProjects) {
void getCurrentWebviewWindow()
.setTitle(settingsTitle(s.newProjects))
.catch(() => {});
}
}),
];
void (async () => {
@@ -148,7 +186,9 @@ export function activateSettingsWindow(parent: string): () => void {
const project = await commands.projectCurrent().catch(() => null);
if (project) useProject.setState({ project, settings: project.settings });
else await loadSettings().catch((e) => console.warn("cannot load settings", e));
openSettings(await takeLaunchPage());
// `listenLaunch` may have taken the launch first and applied it already.
const launch = await takeLaunch();
openSettings(launch?.page, { newProjects: launch?.newProjects ?? useSettingsDialog.getState().newProjects });
})();
return () => disposers.reverse().forEach((d) => d());
}
+22 -10
Переглянути файл
@@ -7,7 +7,8 @@ import { useId, useMemo } from "react";
import { IconButton, Input, type SelectOption } from "@/ui";
import { Row, Section, Select, Toggle } from "../components/Form";
import { choosePath } from "./api";
import { SdkField, tildify, useDetected, useProjectSetting } from "./components";
import { useSetting } from "../components/context";
import { SdkField, tildify, useDetected } from "./components";
import styles from "./Sdk.module.css";
function PathInput({
@@ -26,7 +27,7 @@ function PathInput({
/** Browse for a file instead of a directory. */
file?: boolean;
}) {
const [value, set] = useProjectSetting<string>(k);
const [value, set] = useSetting<string>(k);
const id = useId();
return (
<Row label={label} htmlFor={id} hint={hint}>
@@ -59,7 +60,7 @@ function SdkRow({
hint,
...field
}: { k: string; label: string; hint?: string } & Omit<Parameters<typeof SdkField>[0], "value" | "onChange">) {
const [value, set] = useProjectSetting<string>(k);
const [value, set] = useSetting<string>(k);
return (
<Row label={label} hint={hint}>
<SdkField {...field} value={value} onChange={set} />
@@ -71,7 +72,7 @@ function SdkRow({
// Node.js
export function NodePage() {
const [pm, setPm] = useProjectSetting<string>("languages.node.packageManager");
const [pm, setPm] = useSetting<string>("languages.node.packageManager");
const { list } = useDetected("packageManager");
const pmOptions = useMemo<SelectOption[]>(
() => [
@@ -112,10 +113,10 @@ export function NodePage() {
// Rust
export function RustPage() {
const [toolchain] = useProjectSetting<string>("languages.rust.toolchain");
const [checkOnSave, setCheckOnSave] = useProjectSetting<boolean>("languages.rust.checkOnSave");
const [checkCommand, setCheckCommand] = useProjectSetting<string>("languages.rust.checkCommand");
const [rustfmtArgs, setRustfmtArgs] = useProjectSetting<string>("languages.rust.rustfmtArgs");
const [toolchain] = useSetting<string>("languages.rust.toolchain");
const [checkOnSave, setCheckOnSave] = useSetting<boolean>("languages.rust.checkOnSave");
const [checkCommand, setCheckCommand] = useSetting<string>("languages.rust.checkCommand");
const [rustfmtArgs, setRustfmtArgs] = useSetting<string>("languages.rust.rustfmtArgs");
const { list } = useDetected("rust");
const selected = list.find((s) => s.path === toolchain);
const argsId = useId();
@@ -216,7 +217,7 @@ export function GoPage() {
// Java
export function JavaPage() {
const [vmArgs, setVmArgs] = useProjectSetting<string>("languages.java.jdtlsVmArgs");
const [vmArgs, setVmArgs] = useSetting<string>("languages.java.jdtlsVmArgs");
const id = useId();
return (
<div className={styles.page}>
@@ -234,6 +235,17 @@ export function JavaPage() {
/>
</Section>
<Section title="Language Server">
<SdkRow
k="languages.java.jdtlsJdk"
label="JDK"
kind="jdk"
emptyLabel="Auto"
emptyDetail="the project JDK if new enough, else the first installed JDK that is"
directory
browseTitle="Select JDK Home Directory"
aria-label="Language server JDK"
hint="Runs jdtls only, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. jdtls needs JDK 21 or newer (recent releases 25). The jdtls executable itself is set in Language Servers."
/>
<Row
label="JVM options"
htmlFor={id}
@@ -295,7 +307,7 @@ export function MavenPage() {
// TypeScript
export function TypeScriptPage() {
const [version, setVersion] = useProjectSetting<string>("languages.typescript.version");
const [version, setVersion] = useSetting<string>("languages.typescript.version");
const { list } = useDetected("typeScript");
const workspace = list.find((s) => s.source === "workspace");
const options = useMemo<SelectOption[]>(() => {
+4 -3
Переглянути файл
@@ -12,7 +12,8 @@ import { useProject } from "@/platform/project";
import { Section, Toggle } from "../components/Form";
import { effectivePython, listPackages, pip, useSdks, type PythonPackage } from "./api";
import { AddInterpreterDialog } from "./AddInterpreterDialog";
import { SdkField, tildify, useProjectSetting } from "./components";
import { useSetting } from "../components/context";
import { SdkField, tildify } from "./components";
import styles from "./Sdk.module.css";
type Op = "install" | "upgrade" | "uninstall";
@@ -61,8 +62,8 @@ function usePackages(python: string | null) {
}
export function PythonInterpreterPage() {
const [interpreter, setInterpreter] = useProjectSetting<string>("languages.python.interpreter");
const [activate, setActivate] = useProjectSetting<boolean>("languages.python.activateVirtualenv");
const [interpreter, setInterpreter] = useSetting<string>("languages.python.interpreter");
const [activate, setActivate] = useSetting<boolean>("languages.python.activateVirtualenv");
const root = useProject((s) => s.project?.root ?? null);
const [auto, setAuto] = useState<string | null>(null);
const detected = useSdks((s) => s.byKind.python);
+3 -2
Переглянути файл
@@ -7,8 +7,9 @@ import { choosePath, detectSdks, useSdks, type Sdk, type SdkKind } from "./api";
import styles from "./Sdk.module.css";
/**
* Interpreter / toolchain settings belong to the project (like IDEA's project
* SDK): with a project open they are written to `.openidea/settings.toml`.
* Settings that only make sense per project (Project Structure): with a project
* open they are written to `.openidea/settings.toml`. Toolchain pages instead
* follow the page's Global / This project switch (`defaultScope: "project"`).
*/
export function useProjectSetting<T>(key: string): [T, (v: T) => void] {
const hasProject = useProject((s) => !!s.project);
+49 -9
Переглянути файл
@@ -24,6 +24,10 @@ export interface SettingsDialogState {
query: string;
/** Effective settings when the dialog opened / last applied. */
base: ValidSettings;
/** Defaults + global layer (no project values): what the Global scope edits. */
globalBase: ValidSettings;
/** "Settings for New Projects": every page edits the global layer. */
newProjects: boolean;
staged: Record<string, StagedChange>;
/** Scope chosen per page (Global / This project). */
scopes: Record<string, Scope>;
@@ -38,6 +42,8 @@ export const useSettingsDialog = create<SettingsDialogState>(() => ({
pageId: DEFAULT_PAGE,
query: "",
base: validateSettings(undefined),
globalBase: validateSettings(undefined),
newProjects: false,
staged: {},
scopes: {},
saving: false,
@@ -46,19 +52,45 @@ export const useSettingsDialog = create<SettingsDialogState>(() => ({
const stageKey = (scope: Scope, key: string) => `${scope}|${key}`;
export function openSettings(pageId?: string) {
export function openSettings(pageId?: string, opts: { newProjects?: boolean } = {}) {
const s = useSettingsDialog.getState();
const base = validateSettings(useProject.getState().settings);
useSettingsDialog.setState({
open: true,
pageId: pageId ?? s.pageId,
query: "",
base: validateSettings(useProject.getState().settings),
base,
globalBase: base,
newProjects: opts.newProjects ?? false,
staged: {},
saving: false,
error: null,
});
void loadGlobalBase();
}
/** With a project open the effective settings include its values; the Global scope shows the global layer. */
async function loadGlobalBase() {
if (!isTauri() || !useProject.getState().project) return;
try {
const globalBase = validateSettings(await commands.settingsGetGlobal());
useSettingsDialog.setState((s) => (s.open ? { globalBase } : {}));
} catch (e) {
console.warn("cannot load global settings", e);
}
}
/** IDEA's File › New Projects Setup › Settings for New Projects. */
export const settingsTitle = (newProjects: boolean) => (newProjects ? "Settings for New Projects" : "Settings");
export function setNewProjects(newProjects: boolean) {
useSettingsDialog.setState({ newProjects });
}
type Bases = Pick<SettingsDialogState, "base" | "globalBase">;
const baseFor = (s: Bases, scope: Scope) => (scope === "global" ? s.globalBase : s.base);
export function closeSettings() {
useSettingsDialog.setState({ open: false, staged: {}, error: null, saving: false });
}
@@ -76,17 +108,17 @@ export function setPageScope(pageId: string, scope: Scope) {
}
/** Current value of `key` as the page shows it: staged edit, else the effective value. */
export function stagedValue(s: Pick<SettingsDialogState, "base" | "staged">, scope: Scope, key: string): unknown {
export function stagedValue(s: Bases & Pick<SettingsDialogState, "staged">, scope: Scope, key: string): unknown {
const hit = s.staged[stageKey(scope, key)];
return hit ? hit.value : getAt(s.base, key);
return hit ? hit.value : getAt(baseFor(s, scope), key);
}
export function stage(page: string, scope: Scope, key: string, value: unknown) {
useSettingsDialog.setState((s) => {
const staged = { ...s.staged };
const id = stageKey(scope, key);
// Back to the effective value: nothing to write.
if (deepEqual(value, getAt(s.base, key))) delete staged[id];
// Back to the scope's current value: nothing to write.
if (deepEqual(value, getAt(baseFor(s, scope), key))) delete staged[id];
else staged[id] = { scope, key, value, page };
return { staged, error: null };
});
@@ -101,9 +133,9 @@ export function resetPage(page: string) {
}
/** Writes to perform, global layer first so a project value still wins afterwards. */
export function pendingChanges(s: Pick<SettingsDialogState, "base" | "staged">): StagedChange[] {
export function pendingChanges(s: Bases & Pick<SettingsDialogState, "staged">): StagedChange[] {
return Object.values(s.staged)
.filter((c) => !deepEqual(c.value, getAt(s.base, c.key)))
.filter((c) => !deepEqual(c.value, getAt(baseFor(s, c.scope), c.key)))
.sort((a, b) => (a.scope === b.scope ? a.key.localeCompare(b.key) : a.scope === "global" ? -1 : 1));
}
@@ -164,11 +196,19 @@ export async function applySettings(deps?: PersistDeps): Promise<boolean> {
useSettingsDialog.setState({ saving: true, error: null });
const { written, error } = await persistChanges(changes, deps);
const done = new Set(written);
const base = validateSettings(useProject.getState().settings);
useSettingsDialog.setState((st) => ({
saving: false,
error,
base: validateSettings(useProject.getState().settings),
base,
// Written global values right away; the backend's copy follows.
globalBase: useProject.getState().project
? changes
.filter((c) => c.scope === "global" && done.has(stageKey(c.scope, c.key)))
.reduce((g, c) => setAt(g, c.key, c.value), st.globalBase)
: base,
staged: Object.fromEntries(Object.entries(st.staged).filter(([k]) => !done.has(k))),
}));
await loadGlobalBase();
return error === null;
}
+2
Переглянути файл
@@ -92,6 +92,8 @@ function TooltipDelayRow() {
export const EXECUTION_PAGES: PageSpec[] = [
{
id: "build.maven",
projectScope: true,
defaultScope: "project",
title: "Maven",
group: BUILD_GROUP,
// IntelliJ IDEA: Build Tools › Maven, before Console / Debugger.
+14
Переглянути файл
@@ -13,6 +13,8 @@ export const LANGUAGES_GROUP = "Languages & Frameworks";
export const LANGUAGE_PAGES: PageSpec[] = [
{
id: "python.interpreter",
projectScope: true,
defaultScope: "project",
title: "Python Interpreter",
group: LANGUAGES_GROUP,
order: 403,
@@ -41,6 +43,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [
},
{
id: "languages.go",
projectScope: true,
defaultScope: "project",
title: "Go",
group: LANGUAGES_GROUP,
order: 390,
@@ -49,6 +53,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [
},
{
id: "languages.java",
projectScope: true,
defaultScope: "project",
title: "Java",
group: LANGUAGES_GROUP,
order: 395,
@@ -57,6 +63,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [
"project sdk",
"java home",
"jdtls",
"language server jdk",
"jdk for importer",
"jvm options",
"vmargs",
"lombok",
@@ -67,6 +75,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [
},
{
id: "languages.node",
projectScope: true,
defaultScope: "project",
title: "Node.js",
group: LANGUAGES_GROUP,
order: 402,
@@ -75,6 +85,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [
},
{
id: "languages.rust",
projectScope: true,
defaultScope: "project",
title: "Rust",
group: LANGUAGES_GROUP,
order: 404,
@@ -92,6 +104,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [
},
{
id: "languages.typescript",
projectScope: true,
defaultScope: "project",
title: "TypeScript",
group: LANGUAGES_GROUP,
order: 405,
+2
Переглянути файл
@@ -37,6 +37,8 @@ export interface PageSpec {
keywords?: string[];
/** Offers the Global / This project scope switch. */
projectScope?: boolean;
/** Scope shown first with a project open (toolchains belong to the project, like IDEA's project SDK). */
defaultScope?: "global" | "project";
sections?: SectionSpec[];
/** Rendered below the sections (previews). */
footer?: ComponentType;
+3 -2
Переглянути файл
@@ -4,7 +4,7 @@ import { OverlayHost } from "@/workbench/OverlayHost";
import { usePlatform } from "@/platform/platformInfo";
import { TitleBar } from "@/workbench/TitleBar";
import { useApplyTheme } from "@/workbench/theme";
import { SettingsFooter, SettingsLayout } from "../dialog/SettingsDialog";
import { SettingsFooter, SettingsLayout, useSettingsTitle } from "../dialog/SettingsDialog";
import { useSettingsDialog } from "../model/staging";
import styles from "../dialog/SettingsDialog.module.css";
@@ -12,6 +12,7 @@ export function SettingsWindow() {
useApplyTheme();
const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar);
const open = useSettingsDialog((s) => s.open);
const title = useSettingsTitle();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -21,7 +22,7 @@ export function SettingsWindow() {
return (
<div ref={ref} className={styles.window}>
{/* macOS overlays traffic lights on it; Windows is frameless. Linux keeps the OS title bar. */}
{ownTitleBar && <TitleBar minimal dialog title="Settings" />}
{ownTitleBar && <TitleBar minimal dialog title={title} />}
{open && (
<>
<div className={styles.windowBody}>
+3 -1
Переглянути файл
@@ -13,6 +13,7 @@ export interface LanguagesValid {
typescript: { version: string };
java: {
jdk: string;
jdtlsJdk: string;
jdtlsVmArgs: string;
maven: { home: string; userSettings: string; localRepository: string };
};
@@ -34,7 +35,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" },
go: { goroot: "", gopath: "" },
typescript: { version: "bundled" },
java: { jdk: "", jdtlsVmArgs: "", maven: { home: "", userSettings: "", localRepository: "" } },
java: { jdk: "", jdtlsJdk: "", jdtlsVmArgs: "", maven: { home: "", userSettings: "", localRepository: "" } },
};
export const DEFAULT_PROJECT_STRUCTURE: ProjectStructureValid = {
@@ -101,6 +102,7 @@ export function validateLanguages(raw: unknown): LanguagesValid {
typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version },
java: {
jdk: text(java.jdk, d.java.jdk),
jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk),
jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs),
maven: {
home: text(maven.home, d.java.maven.home),
+2
Переглянути файл
@@ -141,6 +141,8 @@
"menu.editor.unsplit": "Unsplit",
"menu.file": "File",
"menu.fileProperties": "File Properties",
"menu.newProjectsSetup": "New Projects Setup",
"menu.file.newProjectsSettings": "Settings for New Projects…",
"menu.file.toggleReadOnly": "Make File Read-only / Writable",
"menu.file.closeProject": "Close Project",
"menu.file.new": "New…",
+6
Переглянути файл
@@ -1330,6 +1330,8 @@
"Passed as -s (mirrors, servers, proxies).": "以 -s 传入(镜像、服务器、代理)。",
"Passed to rustfmt when reformatting, e.g. --edition 2024": "重新格式化时传递给 rustfmt,例如 --edition 2024",
"Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically when the project uses Lombok.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx2g。项目使用 Lombok 时会自动添加 Lombok 代理。",
"Runs jdtls only, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. jdtls needs JDK 21 or newer (recent releases 25). The jdtls executable itself is set in Language Servers.": "仅用于运行 jdtls(类似 IDEA 的「导入器 JDK」);项目仍使用项目 JDK 编译和运行。jdtls 需要 JDK 21 或更高版本(较新版本需要 25)。jdtls 可执行文件本身在「语言服务器」中设置。",
"Language server JDK": "语言服务器 JDK",
"Password": "密码",
"Password:": "密码:",
"Paste": "粘贴",
@@ -1807,6 +1809,9 @@
"Settings pages": "设置页面",
"Settings scope": "设置范围",
"Settings…": "设置…",
"Settings for New Projects": "新项目的设置",
"Settings for New Projects…": "新项目的设置…",
"Saved to the global settings: used by new projects and by projects without their own value.": "保存到全局设置:新项目以及未单独设置的项目都会使用。",
"Shelf": "搁置",
"Shell integration": "Shell 集成",
"Shell path": "Shell 路径",
@@ -2310,6 +2315,7 @@
"or click a line number to set a breakpoint.": "或点击行号设置断点。",
"or pick one in the toolbar.": "或在工具栏中选择一个。",
"per module, the JDK of its Java version": "按模块使用与其 Java 版本匹配的 JDK",
"the project JDK if new enough, else the first installed JDK that is": "项目 JDK 版本足够时使用它,否则使用第一个版本足够的已安装 JDK",
"pip output": "pip 输出",
"places the caret. A line with": "放置光标。包含",
"println! with Debug": "使用 Debug 的 println!",
+2
Переглянути файл
@@ -141,6 +141,8 @@
"menu.editor.unsplit": "取消拆分",
"menu.file": "文件",
"menu.fileProperties": "文件属性",
"menu.newProjectsSetup": "新项目设置",
"menu.file.newProjectsSettings": "新项目的设置…",
"menu.file.toggleReadOnly": "将文件设为只读/可写",
"menu.file.closeProject": "关闭项目",
"menu.file.new": "新建…",