Файли
openidea/src/workbench/TitleBar.tsx
T
1c5a92f952 feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)
A plugin system built on WebAssembly components (wasmtime, WASI p2), with a
typed WIT API (`openidea:[email protected]`) and a Rust SDK, shipped as a preview.

- Extension points declared in `plugin.toml` (commands, languages, themes,
  icon themes, menus, key bindings, status items, settings, views, custom
  editors, file icons and templates, run detectors, editor providers),
  validated before anything takes effect and projected onto the workbench's
  own registries: plugin themes and file icon themes are chosen in
  Settings › Appearance and Quick Switch, plugin shortcuts are edited in
  Settings › Keymap, plugin problems are grouped in Settings › Inspections.
- Capabilities with risk levels and consent at install, pinned `process:`
  programs, secrets in the OS keychain.
- Runtime: one thread per active plugin, fuel/time/memory budgets, soft and
  hard deadlines on the editing path, cancellation, crash backoff, idle
  reclaim, a precompiled-component cache; remote projects run code plugins on
  `openidea-server`.
- Web views and custom editors with a sandboxed page, a document bridge
  joined to the text editor's undo history, and split or preview layouts.
- Distribution: signed `.oiplugin` packages, a static marketplace index with
  revocation, versioned installs with rollback, install from a `.zip` or a
  folder. The release workflow signs and publishes the official plugins and
  their index, which released builds use as the default marketplace.
- Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion,
  Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor
  Night, Excalidraw, Graphviz, Regex Tester, License Header.

Closes #732

Co-authored-by: Deng Liu <[email protected]>
Co-authored-by: florentvdry <[email protected]>
2026-11-04 02:54:31 +08:00

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

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
import { useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { useActions } from "@/platform/actions";
import { shortcutLabel, useKeymap } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { BarButton, Icon, IconButton, Menu, type MenuItem, showContextMenu, T } from "@/ui";
import { useContributions, useWorkbenchSlots, type TitleItemContribution } from "./contributions";
import { ErrorBoundary, Slot } from "./ErrorBoundary";
import { MainMenu } from "./MainMenu";
import {
editableToolbar,
entryAction,
isActionEntry,
PROJECT_WIDGET,
resolveToolbar,
saveToolbar,
SEARCH_ENTRY,
toggleWidget,
useMainToolbarSettings,
VCS_WIDGET,
widgetTitle,
} from "./mainToolbar";
import { WindowControls } from "./WindowControls";
import { usePlatform } from "@/platform/platformInfo";
import { projectInitials } from "./projectColor";
import { keymapItems, themeItems, VIEW_MODE_ITEMS } from "./quickSwitch";
import { useContributedThemes } from "@/platform/themes";
import { currentThemeChoice, useThemeOverride } from "./theme";
import styles from "./TitleBar.module.css";
import { useNativeTitlebar } from "./nativeTitlebar";
export function orderTitleItems(items: Iterable<TitleItemContribution>) {
const all = [...items];
return {
left: all.filter((i) => i.align === "left").sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id)),
right: all.filter((i) => i.align === "right").sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id)),
};
}
function TitleItem({ item }: { item: TitleItemContribution }) {
const C = item.component;
return (
<div className={styles.item} data-title-item={item.id}>
<ErrorBoundary name={`title item ${item.id}`}>
<C />
</ErrorBoundary>
</div>
);
}
function ProjectButton() {
const name = useProject((s) => s.project?.name);
const root = useProject((s) => s.project?.root);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const actions = useActions((s) => s.actions);
const Popup = useWorkbenchSlots((s) => s.projectPopup);
const items = useMemo<MenuItem[]>(() => {
const list: MenuItem[] = [{ kind: "action", action: "file.openProject", label: "Open…" }];
if (actions.has("welcome.recentProjects")) list.push({ kind: "action", action: "welcome.recentProjects" });
list.push({ kind: "separator" }, { kind: "action", action: "file.closeProject" });
return list;
}, [actions]);
const label = name ?? "OpenIDEA";
return (
<>
<BarButton
ref={ref}
variant="title"
className={styles.widget}
tooltip={root}
aria-label={label}
aria-haspopup={Popup ? "dialog" : "menu"}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span className={styles.avatar} aria-hidden="true">
{projectInitials(label)}
</span>
<span className={styles.widgetText}>{label}</span>
<Icon name="chevron-down" size={12} className={styles.chevron} />
</BarButton>
{Popup ? (
open && <Slot component={Popup} anchor={ref} onClose={() => setOpen(false)} />
) : (
<Menu open={open} onClose={() => setOpen(false)} anchor={ref} items={items} aria-label="Project" />
)}
</>
);
}
const VCS_FALLBACK_ACTIONS = ["vcs.clone", "vcs.localHistory", "vcs.showLog"];
/** "Version Control ▾" shown while no VCS widget is contributed (project without a repository). */
function VcsFallback() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const actions = useActions((s) => s.actions);
const items = useMemo<MenuItem[]>(
() => VCS_FALLBACK_ACTIONS.filter((id) => actions.has(id)).map((action) => ({ kind: "action", action })),
[actions],
);
if (!items.length) return null;
return (
<>
<BarButton
ref={ref}
variant="title"
className={styles.widget}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span className={styles.widgetText}>
<T>Version Control</T>
</span>
<Icon name="chevron-down" size={12} className={styles.chevron} />
</BarButton>
<Menu open={open} onClose={() => setOpen(false)} anchor={ref} items={items} aria-label="Version Control" />
</>
);
}
/**
* Customize Main Toolbar ›: a checkbox per widget (and added action), then the
* dialog and Restore Default. Also the title bar's context menu.
*/
export function customizeToolbarItems(
shown: { left: string[]; right: string[] },
hidden: readonly string[],
items: ReadonlyMap<string, TitleItemContribution>,
toggle: (id: string) => void,
reset: () => void,
): MenuItem[] {
const entries = [...shown.left, ...shown.right, ...hidden.filter((id) => !isActionEntry(id))];
return [
...entries.map<MenuItem>((id) => ({
id: `toolbar.${id}`,
label: widgetTitle(id, items),
checked: !hidden.includes(id),
run: () => toggle(id),
})),
{ kind: "separator" },
{ kind: "action", action: "toolbar.customize", label: "Customize Toolbar…" },
{ id: "toolbar.reset", label: "Restore Default Toolbar", run: reset },
];
}
/** IDEA's main toolbar gear: Run Anything, Settings, Plugins, quick switches, toolbar customization, updates. */
export function gearMenuItems(
themeId: string,
keymapId: string,
customKeymaps: string[],
customize: MenuItem[] = [{ kind: "action", action: "toolbar.customize", label: "Customize Toolbar…" }],
): MenuItem[] {
return [
{ kind: "action", action: "runAnything.show" },
{ kind: "separator" },
{ kind: "action", action: "file.settings" },
{ kind: "action", action: "plugins.settings" },
{ kind: "separator" },
{
id: "gear.theme",
label: "Theme",
submenu: [...themeItems(themeId), { kind: "separator" }, { kind: "action", action: "settings.appearance" }],
},
{
id: "gear.keymap",
label: "Keymap",
submenu: [
...keymapItems(keymapId, customKeymaps),
{ kind: "separator" },
{ kind: "action", action: "settings.keymap", label: "Configure Keymap…" },
],
},
{ id: "gear.viewMode", label: "View Mode", submenu: VIEW_MODE_ITEMS },
{ kind: "separator" },
{ id: "gear.customizeToolbar", label: "Customize Main Toolbar", submenu: customize },
{ kind: "separator" },
{ kind: "action", action: "app.checkForUpdates" },
];
}
/** Toolbar entries and the customization menu, from the saved layout. */
function useToolbar() {
const map = useContributions((s) => s.titleItems);
const saved = useMainToolbarSettings();
const shown = useMemo(() => resolveToolbar(saved, map.values()), [saved, map]);
const toggle = (id: string) => {
const layout = editableToolbar(saved, map.values());
const side = map.get(id)?.align ?? (id === SEARCH_ENTRY ? "right" : "left");
void saveToolbar(toggleWidget(layout, id, side));
};
const menu = customizeToolbarItems(shown, saved?.hidden ?? [], map, toggle, () => void saveToolbar(null));
return { map, shown, menu };
}
function GearButton({ customize }: { customize: MenuItem[] }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const settings = useProject((s) => s.settings);
useThemeOverride((s) => s.theme);
useActions((s) => s.actions);
const preset = useKeymap((s) => s.preset);
const customs = settings?.customKeymaps;
useContributedThemes((s) => s.themes);
const items = gearMenuItems(
currentThemeChoice(),
typeof settings?.keymap === "string" ? settings.keymap : preset,
customs && typeof customs === "object" ? Object.keys(customs) : [],
customize,
);
return (
<>
<IconButton
ref={ref}
icon="settings"
title="IDE and Project Settings"
iconSize={18}
className={styles.tool}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
/>
<Menu
open={open}
onClose={() => setOpen(false)}
anchor={ref}
items={items}
aria-label="IDE and Project Settings"
/>
</>
);
}
/** One main toolbar entry: a built-in widget, a contributed one or an added action. */
function ToolbarEntry({ id, items }: { id: string; items: ReadonlyMap<string, TitleItemContribution> }) {
const actions = useActions((s) => s.actions);
if (id === PROJECT_WIDGET) return <ProjectButton />;
const item = items.get(id);
if (item) return <TitleItem item={item} />;
if (id === VCS_WIDGET) return <VcsFallback />;
if (!isActionEntry(id)) return null;
const action = actions.get(entryAction(id));
if (!action) return null;
return (
<div className={styles.tools}>
<IconButton
icon={action.icon ?? "run"}
action={action.id}
title={action.title}
shortcut={shortcutLabel(action.id) ?? (action.id === "navigate.searchEverywhere" ? "⇧⇧" : undefined)}
iconSize={18}
className={styles.tool}
/>
</div>
);
}
/**
* 40px main toolbar. macOS: native traffic lights overlay its left edge.
* Windows and Linux: the window is frameless, so the bar carries IDEA's main menu (☰) and
* the caption buttons. `dialog`: a dialog window's bar (Settings), close button only.
* `lightEdit`: a LightEdit window's bar, its title and the menu but no toolbar widgets.
*/
export function TitleBar({
minimal = false,
bare = false,
dialog = false,
lightEdit = false,
title,
}: {
minimal?: boolean;
bare?: boolean;
dialog?: boolean;
lightEdit?: boolean;
title?: string;
}) {
const overlay = usePlatform((s) => s.overlayTitleBar);
const frameless = usePlatform((s) => s.framelessTitleBar);
const { map, shown, menu } = useToolbar();
const [menuExpanded, setMenuExpanded] = useState(false);
const [bar, setBar] = useState<HTMLElement | null>(null);
useNativeTitlebar(bar);
const className = clsx(styles.bar, overlay && styles.overlay, frameless && styles.frameless);
// Distraction Free Mode: the window frame only. Where the title bar carries the
// main menu (Windows, Linux) it stays, or nothing would lead back out of the mode.
if (bare)
return (
<header ref={setBar} className={className} data-tauri-drag-region data-titlebar="bare">
{frameless && (
<>
<MainMenu />
<div className={styles.drag} data-tauri-drag-region />
<WindowControls />
</>
)}
</header>
);
const entry = (id: string) => <ToolbarEntry key={id} id={id} items={map} />;
const toolbar = !minimal && !lightEdit;
return (
<header
ref={setBar}
className={clsx(className, minimal && styles.minimal, dialog && styles.dialogBar)}
data-tauri-drag-region
onContextMenu={(e) => {
if (!toolbar || e.defaultPrevented) return;
e.preventDefault();
showContextMenu(e, menu);
}}
>
{frameless && !minimal && <MainMenu onExpandedChange={setMenuExpanded} />}
{title && (
<span className={styles.windowTitle} data-tauri-drag-region>
{typeof title === "string" ? <T>{title}</T> : title}
</span>
)}
{toolbar && !menuExpanded && shown.left.map(entry)}
<div className={styles.drag} data-tauri-drag-region />
{!minimal && (
<>
{toolbar && !menuExpanded && shown.right.map(entry)}
{!menuExpanded && (
<div className={styles.tools}>
<GearButton customize={menu} />
</div>
)}
</>
)}
{frameless && <WindowControls dialog={dialog} />}
</header>
);
}