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

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