Файли
openidea/src/features/settings/index.ts
T
RuyimgByCNтаl0ng-ai 75773dd889 feat(project-structure): add IDEA-style Project Structure dialog (#1845)
* feat(project-structure): add IDEA-style Project Structure dialog

* test(sdk): use dunce::canonicalize for Windows path parity in project structure test

* fix(project-structure): preserve drafts and enforce supported module settings

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-07 20:46:03 +08:00

262 рядки
9.2 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* Settings: the Settings window (⌘,, action `file.settings`) with built-in
* pages (Appearance, System Settings, Keymap, Editor › General / Font / Code
* Style / Files) plus pages contributed via `registerSettingsPage`, and the
* runtime application of keymap / UI font size / high-contrast settings.
*
* 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;
* `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";
import { call, commands, isTauri } from "@/platform/ipc";
import { isPopupOpen, lazyComponent, lazyWhen } from "@/ui";
import { loadSettings, useProject } from "@/platform/project";
import { logError } from "@/platform/errorLog";
import { restoreRemote } from "@/platform/remote";
import { provideSettingsSearchIndex, registerOverlay } from "@/workbench/contributions";
import { windowBackgroundHex } from "@/workbench/theme";
import {
LazyProjectStructureDialog,
LazySettingsDialog,
loadBuiltinPages,
unloadBuiltinPages,
} from "./dialog/LazySettingsDialog";
import {
closeSettings,
openProjectStructure,
openSettings,
rebaseSettings,
selectPage,
setNewProjects,
setQuery,
settingsTitle,
useSettingsDialog,
} from "./model/staging";
import { zoomActions } from "./appearanceRuntime";
import { activateFileTypes } from "./fileTypesRuntime";
import { trackRuntimeSettings } from "./runtime";
import { installActionProxies, installSettingsHost } from "./window/bridge";
import { showAssignShortcut, useAssignShortcut } from "./keymap/assignShortcutStore";
import { settingsSearchEntries } from "./searchIndex";
import { activatePythonStatus } from "./languages/PythonStatus";
import { activatePhpStatus } from "./languages/PhpStatus";
import { nativeToAsciiAction } from "./nativeToAscii";
import { isImeKey } from "@/platform/ime";
import { trackIconThemePages } from "./iconThemePages";
/** Settings-driven runtime state (zoom, fonts, theme) of a secondary window. */
export const activateRuntimeSettings = trackRuntimeSettings;
export const SettingsWindow = lazyComponent(() => import("./window/SettingsWindow").then((m) => m.SettingsWindow));
/** Same event as `windows::LAUNCH_EVENT`: a pending launch (page) is waiting. */
const LAUNCH_EVENT = "openidea://launch";
/** 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 [
nativeToAsciiAction(),
{
id: "file.settings",
title: "Settings…",
category: "File",
icon: "settings",
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…",
category: "File",
icon: "keyboard",
keywords: ["shortcuts", "keyboard", "bindings"],
run: () => open("keymap"),
},
{
id: "settings.appearance",
title: "Appearance…",
category: "View",
icon: "sun",
keywords: ["theme", "accent", "font size"],
run: () => open("appearance"),
},
];
}
/** Native Settings window when the backend can make one, else the dialog. */
async function showSettings(page?: string, newProjects = false) {
if (isTauri()) {
try {
await call(commands.windowOpenSettings(page ?? null, newProjects, windowBackgroundHex()));
return;
} catch (e) {
console.warn("settings window unavailable, using the dialog", e);
}
}
await loadBuiltinPages();
openSettings(page, { newProjects });
}
const useAssignShortcutOpen = () => useAssignShortcut((s) => s.actionId !== null);
export function activate(): () => void {
const disposers = [
trackRuntimeSettings(),
trackIconThemePages(),
activatePythonStatus(),
activatePhpStatus(),
unloadBuiltinPages,
registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }),
registerOverlay({ id: "settings.projectStructure", component: LazyProjectStructureDialog }),
registerActions(zoomActions()),
registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))),
registerActions([
{
id: "file.projectStructure",
title: "Project Structure…",
category: "File",
icon: "folder",
keywords: ["project structure", "sdk", "jdk", "modules", "libraries", "facets", "artifacts", "sources"],
run: ({ arg }) => openProjectStructure(typeof arg === "string" ? arg : undefined),
},
]),
activateFileTypes(),
registerOverlay({
id: "settings.assignShortcut",
component: lazyWhen(useAssignShortcutOpen, () =>
import("./keymap/AssignShortcut").then((m) => m.AssignShortcutOverlay),
),
}),
registerActions([
{
// Search Everywhere's ⌥↵ on an action (arg: the action id).
id: "settings.assignShortcut",
title: "Assign Shortcut…",
category: "Settings",
hidden: true,
run: ({ arg }) => {
if (typeof arg === "string") showAssignShortcut(arg);
},
},
]),
provideSettingsSearchIndex(settingsSearchEntries),
isTauri() ? installSettingsHost() : () => {},
];
return () => disposers.reverse().forEach((d) => d());
}
function navigate(page: string | undefined) {
if (!page) return;
setQuery("");
selectPage(page);
}
interface SettingsLaunch {
page?: string;
newProjects: boolean;
}
async function takeLaunch(): Promise<SettingsLaunch | null> {
const launch = await commands.windowTakeLaunch().catch(() => null);
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 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 takeLaunch().then(relaunch);
});
return () => {
disposed = true;
off?.();
};
}
/** Esc and ⌘W cancel, like closing the window; ⌘, stays here. */
function installWindowKeys(): () => void {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || isImeKey(e)) return;
const mod = e.metaKey || e.ctrlKey;
if ((e.key === "Escape" && !isPopupOpen()) || (mod && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "w")) {
e.preventDefault();
closeSettings();
} else if (mod && e.key === ",") {
e.preventDefault();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}
/**
* The settings window (`settings-<parent>`): settings pages only, working on
* the parent's project; Cancel / OK / Esc close the window.
*/
export function activateSettingsWindow(parent: string): () => void {
const disposers = [
trackRuntimeSettings(),
trackIconThemePages(),
unloadBuiltinPages,
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 () => {
useSettingsDialog.setState({ pagesLoading: true });
openSettings();
try {
const launch = await takeLaunch();
relaunch(launch);
await Promise.all([
loadBuiltinPages().catch((e) => logError("cannot register settings pages", e)),
(async () => {
await restoreRemote();
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));
rebaseSettings();
})(),
]);
} finally {
useSettingsDialog.setState({ pagesLoading: false });
}
})().catch((e) => logError("settings window startup failed", e));
return () => disposers.reverse().forEach((d) => d());
}