дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(plugins): import IntelliJ theme plugins (#1184)
Settings › Plugins (gear menu) gets Import Theme from IntelliJ IDE… (theme plugins of the JetBrains IDEs on this machine, the one each IDE uses first) and Import IntelliJ Theme from File… (a Marketplace .jar / .zip or a .theme.json). The host reads the plugin with size limits; the UI converts it with the IntelliJ mapping (moved from scripts/theme to src/features/plugins/intellijTheme.ts, still used by pnpm theme:import-intellij) into one OpenIDEA theme plugin (engines.openidea ^1.0) with all of its themes, installed through the usual install page, and applies the chosen theme. Theme pickers separate each plugin's themes, and Quick Switch Scheme › Theme previews the theme under the pointer or keyboard after 500 ms and restores the selected one when closed without a choice. Co-authored-by: ldg030201 <[email protected]>
Цей коміт міститься в:
1 джерело
a610982655
коміт
6dec1252db
33 files changed
+2284
-87
No files matched your search
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ContextMenuHost, hideContextMenu, MenuList } from "@/ui";
|
||||
import { showQuickSwitch, THEME_PREVIEW_DELAY, themePreview } from "./quickSwitch";
|
||||
import {
|
||||
applyThemeAttributes,
|
||||
currentThemeId,
|
||||
STARTUP_THEME_KEY,
|
||||
syncWindowBackground,
|
||||
useThemeOverride,
|
||||
} from "./theme";
|
||||
|
||||
const root = document.documentElement;
|
||||
const shown = () => [root.dataset.theme, root.dataset.colorTheme ?? null];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useThemeOverride.setState({ theme: null });
|
||||
applyThemeAttributes(currentThemeId());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => hideContextMenu());
|
||||
vi.useRealTimers();
|
||||
useThemeOverride.setState({ theme: null });
|
||||
applyThemeAttributes(currentThemeId());
|
||||
});
|
||||
|
||||
describe("menu items", () => {
|
||||
it("report becoming active by pointer or keyboard, not on opening", () => {
|
||||
const active: string[] = [];
|
||||
const item = (label: string) => ({ label, onActive: () => active.push(label), run: () => {} });
|
||||
render(<MenuList items={[item("One"), item("Two"), item("Three")]} onDone={() => {}} />);
|
||||
expect(active).toEqual([]);
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "ArrowDown" });
|
||||
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Three" }));
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "o" });
|
||||
expect(active).toEqual(["Two", "Three", "One"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quick Switch theme preview", () => {
|
||||
it("shows the active theme after a moment, in this window only, and takes it back", () => {
|
||||
syncWindowBackground();
|
||||
const startup = localStorage.getItem(STARTUP_THEME_KEY);
|
||||
const before = shown();
|
||||
const preview = themePreview();
|
||||
preview.preview("solarized-dark");
|
||||
vi.advanceTimersByTime(THEME_PREVIEW_DELAY - 1);
|
||||
expect(shown()).toEqual(before);
|
||||
// Moving on restarts the delay: only the theme the user stays on shows.
|
||||
preview.preview("one-light");
|
||||
vi.advanceTimersByTime(THEME_PREVIEW_DELAY - 1);
|
||||
expect(shown()).toEqual(before);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(shown()).toEqual(["light", "one-light"]);
|
||||
// Nothing is selected or saved, not even as the next start-up's theme.
|
||||
expect(useThemeOverride.getState().theme).toBeNull();
|
||||
expect(localStorage.getItem(STARTUP_THEME_KEY)).toBe(startup);
|
||||
|
||||
preview.restore();
|
||||
expect(shown()).toEqual(before);
|
||||
expect(localStorage.getItem(STARTUP_THEME_KEY)).toBe(startup);
|
||||
preview.preview("solarized-dark");
|
||||
vi.advanceTimersByTime(THEME_PREVIEW_DELAY);
|
||||
preview.end();
|
||||
expect(shown()).toEqual(before);
|
||||
});
|
||||
|
||||
it("selects the chosen theme and keeps it when the menu closes", () => {
|
||||
const preview = themePreview();
|
||||
preview.preview("solarized-dark");
|
||||
vi.advanceTimersByTime(THEME_PREVIEW_DELAY);
|
||||
preview.choose("one-dark");
|
||||
expect(useThemeOverride.getState().theme).toBe("one-dark");
|
||||
expect(shown()).toEqual(["dark", "one-dark"]);
|
||||
preview.end();
|
||||
expect(shown()).toEqual(["dark", "one-dark"]);
|
||||
});
|
||||
|
||||
it("previews from the Theme submenu and takes the preview back when the menu closes", async () => {
|
||||
const before = shown();
|
||||
render(<ContextMenuHost />);
|
||||
act(() => showQuickSwitch({ x: 10, y: 10 }));
|
||||
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
|
||||
act(() => vi.advanceTimersByTime(200));
|
||||
fireEvent.pointerEnter(screen.getByRole("menuitemcheckbox", { name: "Solarized Light" }));
|
||||
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
|
||||
expect(shown()).toEqual(["light", "solarized-light"]);
|
||||
|
||||
// Back on another entry: the selected theme again.
|
||||
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Keymap" }));
|
||||
expect(shown()).toEqual(before);
|
||||
|
||||
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
|
||||
act(() => vi.advanceTimersByTime(200));
|
||||
fireEvent.pointerEnter(screen.getByRole("menuitemcheckbox", { name: "Solarized Light" }));
|
||||
act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
|
||||
act(() => hideContextMenu());
|
||||
await act(async () => {});
|
||||
expect(shown()).toEqual(before);
|
||||
expect(useThemeOverride.getState().theme).toBeNull();
|
||||
});
|
||||
|
||||
it("selects a theme clicked in the Theme submenu", async () => {
|
||||
render(<ContextMenuHost />);
|
||||
act(() => showQuickSwitch({ x: 10, y: 10 }));
|
||||
fireEvent.pointerEnter(screen.getByRole("menuitem", { name: "Theme" }));
|
||||
act(() => vi.advanceTimersByTime(200));
|
||||
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Solarized Dark" }));
|
||||
await act(async () => {});
|
||||
expect(useThemeOverride.getState().theme).toBe("solarized-dark");
|
||||
expect(shown()).toEqual(["dark", "solarized-dark"]);
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,9 @@ import { listSchemes } from "@/platform/colorScheme";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { PRESET_TITLES, useKeymap } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { themeChoices } from "@/platform/themes";
|
||||
import { currentThemeChoice, setTheme } from "./theme";
|
||||
import { showContextMenu, useContextMenuStore, type MenuItem } from "@/ui";
|
||||
import { themeGroups, type ThemeChoiceId } from "@/platform/themes";
|
||||
import { currentThemeChoice, previewTheme, setTheme } from "./theme";
|
||||
|
||||
async function setGlobal(key: string, value: unknown, what: string) {
|
||||
if (!isTauri()) return;
|
||||
@@ -22,15 +22,67 @@ async function setGlobal(key: string, value: unknown, what: string) {
|
||||
|
||||
export const selectKeymap = (id: string) => setGlobal("keymap", id, "keymap");
|
||||
|
||||
export function themeItems(themeId: string = currentThemeChoice()): MenuItem[] {
|
||||
return themeChoices().map<MenuItem>((c) => ({
|
||||
id: `theme.${c.id}`,
|
||||
label: c.label,
|
||||
// Plugin themes keep their own names.
|
||||
verbatim: !!c.origin,
|
||||
checked: c.id === themeId,
|
||||
run: () => void setTheme(c.id),
|
||||
}));
|
||||
/** How long a theme stays under the pointer or keyboard before Quick Switch shows it (IDEA's delay). */
|
||||
export const THEME_PREVIEW_DELAY = 500;
|
||||
|
||||
/**
|
||||
* IDEA's instant theme switch (`ide.instant.theme.switch`): in Quick Switch
|
||||
* the active theme shows after a moment, in this window only; choosing one
|
||||
* selects it, and closing the menu without a choice brings the selected
|
||||
* theme back.
|
||||
*/
|
||||
export interface ThemePreview {
|
||||
preview(id: ThemeChoiceId): void;
|
||||
/** Back to the selected theme (another Quick Switch entry became active). */
|
||||
restore(): void;
|
||||
choose(id: ThemeChoiceId): void;
|
||||
/** The menu closed. */
|
||||
end(): void;
|
||||
}
|
||||
|
||||
export function themePreview(): ThemePreview {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let shown: ThemeChoiceId | null = null;
|
||||
let chosen = false;
|
||||
const restore = () => {
|
||||
clearTimeout(timer);
|
||||
if (shown !== null && !chosen) previewTheme(null);
|
||||
shown = null;
|
||||
};
|
||||
return {
|
||||
preview(id) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
shown = id;
|
||||
previewTheme(id);
|
||||
}, THEME_PREVIEW_DELAY);
|
||||
},
|
||||
restore,
|
||||
choose(id) {
|
||||
clearTimeout(timer);
|
||||
chosen = true;
|
||||
// Shown right away; selecting it then changes nothing visible.
|
||||
if (shown !== id) previewTheme(id);
|
||||
void setTheme(id);
|
||||
},
|
||||
end: restore,
|
||||
};
|
||||
}
|
||||
|
||||
/** The built-in themes, then each plugin's, a separator between them (as IDEA's Theme list). */
|
||||
export function themeItems(themeId: string = currentThemeChoice(), preview?: ThemePreview): MenuItem[] {
|
||||
return themeGroups().flatMap<MenuItem>((group, i) => [
|
||||
...(i > 0 ? [{ kind: "separator" as const }] : []),
|
||||
...group.themes.map<MenuItem>((c) => ({
|
||||
id: `theme.${c.id}`,
|
||||
label: c.label,
|
||||
// Plugin themes keep their own names.
|
||||
verbatim: !!c.origin,
|
||||
checked: c.id === themeId,
|
||||
onActive: preview && (() => preview.preview(c.id)),
|
||||
run: () => (preview ? preview.choose(c.id) : void setTheme(c.id)),
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
export function keymapItems(keymapId: string, customKeymaps: string[]): MenuItem[] {
|
||||
@@ -78,17 +130,32 @@ export function keymapState(): { keymapId: string; customs: string[] } {
|
||||
};
|
||||
}
|
||||
|
||||
/** View › Quick Switch Scheme…: IDEA's popup of the switchable schemes. */
|
||||
export function quickSwitchItems(): MenuItem[] {
|
||||
/** View › Quick Switch Scheme…: IDEA's popup of the switchable schemes, its themes previewed. */
|
||||
export function quickSwitchItems(preview?: ThemePreview): MenuItem[] {
|
||||
const { keymapId, customs } = keymapState();
|
||||
const restore = preview && (() => preview.restore());
|
||||
return [
|
||||
{
|
||||
id: "qs.scheme",
|
||||
label: "Editor Color Scheme",
|
||||
onActive: restore,
|
||||
submenu: colorSchemeItems(useProject.getState().settings?.editor?.colorScheme),
|
||||
},
|
||||
{ id: "qs.keymap", label: "Keymap", submenu: keymapItems(keymapId, customs) },
|
||||
{ id: "qs.viewMode", label: "View Mode", submenu: VIEW_MODE_ITEMS },
|
||||
{ id: "qs.theme", label: "Theme", submenu: themeItems() },
|
||||
{ id: "qs.keymap", label: "Keymap", onActive: restore, submenu: keymapItems(keymapId, customs) },
|
||||
{ id: "qs.viewMode", label: "View Mode", onActive: restore, submenu: VIEW_MODE_ITEMS },
|
||||
{ id: "qs.theme", label: "Theme", submenu: themeItems(undefined, preview) },
|
||||
];
|
||||
}
|
||||
|
||||
/** Opens Quick Switch Scheme; a theme previewed there goes away with the menu unless chosen. */
|
||||
export function showQuickSwitch(at: { x: number; y: number }) {
|
||||
const preview = themePreview();
|
||||
showContextMenu(at, quickSwitchItems(preview));
|
||||
const menu = useContextMenuStore.getState().menu;
|
||||
const unsubscribe = useContextMenuStore.subscribe((s) => {
|
||||
if (s.menu === menu) return;
|
||||
unsubscribe();
|
||||
// A chosen item runs right after the menu closes: let it choose first.
|
||||
queueMicrotask(() => preview.end());
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { isTauri } from "@/platform/ipc";
|
||||
import { closeProject, confirmLeaveProject, openProject, useProject } from "@/platform/project";
|
||||
import { runSearchFieldCommand, showContextMenu } from "@/ui";
|
||||
import { showCustomizeToolbar } from "./MainToolbarDialog";
|
||||
import { quickSwitchItems } from "./quickSwitch";
|
||||
import { showQuickSwitch } from "./quickSwitch";
|
||||
import { notify } from "./notificationStore";
|
||||
import { NOTIFICATIONS_TOOL_WINDOW } from "./Notifications";
|
||||
import { activateToolWindow } from "./toolwindows/store";
|
||||
@@ -96,7 +96,7 @@ export function registerShellActions(): () => void {
|
||||
title: "Quick Switch Scheme…",
|
||||
category: "View",
|
||||
keywords: ["theme", "color scheme", "keymap", "view mode"],
|
||||
run: () => showContextMenu({ x: window.innerWidth / 2 - 120, y: 96 }, quickSwitchItems()),
|
||||
run: () => showQuickSwitch({ x: window.innerWidth / 2 - 120, y: 96 }),
|
||||
},
|
||||
{
|
||||
id: "toolbar.customize",
|
||||
|
||||
@@ -6,8 +6,10 @@ import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import {
|
||||
baseTheme,
|
||||
isBuiltinTheme,
|
||||
isThemeChoiceId,
|
||||
parsePluginThemeRef,
|
||||
previewPluginTheme,
|
||||
themeChoices,
|
||||
themeKind,
|
||||
useContributedThemes,
|
||||
@@ -181,7 +183,7 @@ export function rememberStartupTheme(root: HTMLElement = document.documentElemen
|
||||
* for the next window's start-up.
|
||||
*/
|
||||
export function syncWindowBackground() {
|
||||
rememberStartupTheme();
|
||||
if (!previewing) rememberStartupTheme();
|
||||
if (!isTauri()) return;
|
||||
const color = windowBackgroundColor();
|
||||
if (!color) return;
|
||||
@@ -231,6 +233,28 @@ export async function setTheme(next: ThemeSetting) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A previewed theme is not the next start-up's (`rememberStartupTheme`). */
|
||||
let previewing = false;
|
||||
|
||||
/**
|
||||
* Shows `id` in this window without selecting it (Quick Switch's instant
|
||||
* theme switch); `null` shows the selected theme again. Nothing is saved and
|
||||
* other windows keep theirs.
|
||||
*/
|
||||
export function previewTheme(id: ThemeChoiceId | null) {
|
||||
previewing = id !== null;
|
||||
if (id === null) {
|
||||
previewPluginTheme("selected");
|
||||
applyThemeAttributes(currentThemeId());
|
||||
} else if (isBuiltinTheme(id)) {
|
||||
previewPluginTheme(null);
|
||||
applyThemeAttributes(id);
|
||||
} else {
|
||||
previewPluginTheme(id);
|
||||
}
|
||||
syncWindowBackground();
|
||||
}
|
||||
|
||||
export async function toggleTheme() {
|
||||
await setTheme(currentTheme() === "dark" ? "light" : "dark");
|
||||
}
|
||||
@@ -244,14 +268,30 @@ export function originDetail(t: { label: string; origin?: string }): string | un
|
||||
* Options for a theme picker: built-in themes, then plugins' (with the plugin
|
||||
* named beside them). A selected plugin theme whose plugin is off stays listed.
|
||||
*/
|
||||
/** The themes for a picker: each plugin's under a heading with its name (`choices` in `themeGroups` order). */
|
||||
export function themeSelectOptions(choices: readonly ThemeChoice[], selected?: string): SelectOption<ThemeChoiceId>[] {
|
||||
const options: SelectOption<ThemeChoiceId>[] = choices.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
detail: originDetail(c),
|
||||
}));
|
||||
const options: SelectOption<ThemeChoiceId>[] = [];
|
||||
let group: string | undefined;
|
||||
for (const c of choices) {
|
||||
const pluginId = parsePluginThemeRef(c.id)?.pluginId;
|
||||
if (pluginId && pluginId !== group)
|
||||
options.push({ value: `plugin:${pluginId}`, label: c.origin ?? pluginId, section: true });
|
||||
group = pluginId;
|
||||
// Under its plugin's heading a theme needs no origin.
|
||||
options.push({ value: c.id, label: c.label });
|
||||
}
|
||||
const missing = parsePluginThemeRef(selected);
|
||||
if (missing && !options.some((o) => o.value === selected))
|
||||
options.push({ value: selected as ThemeChoiceId, label: missing.themeId, detail: "Plugin not enabled" });
|
||||
if (missing && !options.some((o) => o.value === selected)) {
|
||||
// Under its own plugin's heading, not the last one listed.
|
||||
const option = { value: selected as ThemeChoiceId, label: missing.themeId, detail: "Plugin not enabled" };
|
||||
const heading = options.findIndex((o) => o.section && o.value === `plugin:${missing.pluginId}`);
|
||||
if (heading < 0)
|
||||
options.push({ value: `plugin:${missing.pluginId}`, label: missing.pluginId, section: true }, option);
|
||||
else {
|
||||
let end = heading + 1;
|
||||
while (end < options.length && !options[end]!.section) end++;
|
||||
options.splice(end, 0, option);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача