дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
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 = {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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))];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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[]>(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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": "新建…",
|
||||
|
||||
Reference in new issue
Заблокувати користувача