feat(plugins): web views, document bridge and the Excalidraw official plugin

Plugins can now ship a web page as the UI of a view or custom editor
(`web = "web/index.html"`), so editors built on web libraries are plugins
rather than part of the core.

- Web views: every file under the page's directory is part of the package
  (checked, copied on install, covered by the digest and signature) and
  served through the `openidea-plugin` URL scheme, under a per-revision
  path with a CSP of its own, into an `<iframe sandbox="allow-scripts">`.
  An update or reinstall reloads open pages.
- Frame bridge (`window.openidea`), shared by html bodies and web pages:
  theme, messages to the plugin's code, `document.get/edit/replace/onChange`
  (versioned edits that join the text editor's undo history, Save and dirty
  state), `saveFile`/`openFile` through the IDE's dialogs, and IDE shortcuts
  the page leaves alone.
- Custom editor layouts: tab, split (Editor and X) or preview over the text
  editor, switchable from the tab's actions.
- New points `fileIcons` and `fileTemplates`; new optional capability
  `clipboard` for web views.
- Excalidraw official plugin: `.excalidraw` / `.excalidraw.json` open as an
  interactive canvas kept in step with the file's text, with self-hosted
  fonts, PNG/SVG export, images, file icons, a New Drawing template and
  JSON highlighting. Closes #732.
- Fixes: a lock-order deadlock between listing plugins and refreshing
  project pins; the dev server ignored edits under `src/features/plugins`.
- `plugins/build.sh` builds and checks web pages; CI typechecks and tests
  them.

Co-authored-by: Deng Liu <[email protected]>
Цей коміт міститься в:
l0ng-aiтаDeng Liu committed 2026-11-04 02:41:46 +08:00
1 джерело 78e0c7a23e
коміт 900f2e6819
55 files changed
+6237 -135

No files matched your search

+8
Переглянути файл
@@ -21,6 +21,7 @@ import {
Annotation,
Compartment,
Facet,
type ChangeSpec,
StateEffect,
type EditorState,
type Extension,
@@ -604,6 +605,13 @@ export interface DocumentAttachment {
*/
undo(): boolean;
redo(): boolean;
/**
* Applies a change made by this view's owner as one edit of the document's
* history (where `undo` works): the text editor tab's when one shows the
* file, so Undo there reverts it too; else this view's. The view receives
* it like any other change of the document, before this returns.
*/
edit(changes: ChangeSpec, userEvent?: string): void;
/** Detach; the document closes when nothing else shows it. */
release(): void;
}
+28
Переглянути файл
@@ -139,4 +139,32 @@ describe("attached document views (editable diff side)", () => {
att.release();
tab.dispose();
});
it("records an attachment's own edits in the document's history: the text tab's when one is open", async () => {
const { manager } = setup({ "/p/d.excalidraw": "{}" });
const att = await attachDocument(manager, "/p/d.excalidraw");
const view = new EditorView({ doc: att.text(), extensions: [att.extension, history()] });
const model = manager.get("/p/d.excalidraw")!;
// No tab: the view's own history.
att.edit({ from: 1, insert: "a" });
expect(view.state.doc.toString()).toBe("{a}");
expect(att.undo()).toBe(true);
expect(view.state.doc.toString()).toBe("{}");
// With a text tab: the edit is the tab's, so Undo there reverts it, and the view follows.
const tab = new EditorSlot(model, "t1", host);
att.edit({ from: 1, insert: "b" }, "input.plugin");
expect(tab.currentState().doc.toString()).toBe("{b}");
expect(view.state.doc.toString()).toBe("{b}");
// `undo` runs where the history is: the tab's (what ⌘Z in the text editor runs).
expect(att.undo()).toBe(true);
expect(tab.currentState().doc.toString()).toBe("{}");
expect(view.state.doc.toString()).toBe("{}");
// The view's own history never had it.
expect(undo(view)).toBe(false);
view.destroy();
att.release();
tab.dispose();
});
});
+6
Переглянути файл
@@ -102,6 +102,12 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr
dirty: () => model.getMeta().dirty,
undo: () => historyCommand(undo),
redo: () => historyCommand(redo),
edit: (changes, userEvent = "input") => {
// Into the document's history, like a typed edit: the text tab's when there is one.
const slot = model.primarySlot();
if (slot) slot.dispatchLocal({ changes, userEvent });
else target.view?.dispatch({ changes, userEvent });
},
release: () => {
if (released) return;
released = true;
+278
Переглянути файл
@@ -0,0 +1,278 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import type { PluginInfo, ViewEvent } from "@/bindings";
import { addFileIconProvider, fileIconName, registerExternalIcons } from "@/ui";
import { addContributedFileTemplate, fileTemplates, isBuiltinFileTemplate } from "@/features/templates/files/store";
import { createMemoryPluginsBackend, setPluginsBackend, webSchemeUrl } from "../backend";
import { usePlugins } from "../store";
import { FRAME_TAG, frameDocument } from "../views/frame";
import {
WebView,
minimalChange,
validChanges,
type DocumentState,
type FrameDocument,
type TextChange,
} from "../views/WebView";
afterEach(() => {
setPluginsBackend(null);
usePlugins.setState({ plugins: [] });
});
/** An in-memory document with the custom editor session's versioning. */
function memoryDocument(text: string) {
let version = 0;
const listeners = new Set<(s: DocumentState) => void>();
const apply = (changes: readonly TextChange[]) => {
for (const c of [...changes].reverse()) text = text.slice(0, c.from) + c.insert + text.slice(c.to);
version++;
};
const doc: FrameDocument & { external(changes: TextChange[]): void; text(): string } = {
read: () => ({ version, text, readOnly: false, name: "a.draw" }),
edit: (at, changes) => {
if (at !== version) throw new Error("stale");
apply(changes);
return version;
},
subscribe: (l) => {
listeners.add(l);
return () => listeners.delete(l);
},
external: (changes) => {
apply(changes);
for (const l of listeners) l({ version, text });
},
text: () => text,
};
return doc;
}
describe("web view helpers", () => {
it("turns a replacement into the smallest change", () => {
expect(minimalChange("abcdef", "abXYef")).toEqual({ from: 2, to: 4, insert: "XY" });
expect(minimalChange("aaa", "aaaa")).toEqual({ from: 3, to: 3, insert: "a" });
expect(minimalChange("abc", "")).toEqual({ from: 0, to: 3, insert: "" });
expect(minimalChange("same", "same")).toBeNull();
});
it("accepts only sorted, in-range, non-overlapping edits", () => {
expect(
validChanges(
[
{ from: 0, to: 1, insert: "x" },
{ from: 2, to: 2, insert: "y" },
],
3,
),
).toHaveLength(2);
expect(() =>
validChanges(
[
{ from: 2, to: 2, insert: "" },
{ from: 0, to: 1, insert: "" },
],
3,
),
).toThrow();
expect(() => validChanges([{ from: 0, to: 4, insert: "" }], 3)).toThrow();
expect(() => validChanges([{ from: 0.5, to: 1, insert: "" }], 3)).toThrow();
expect(() => validChanges("nope", 3)).toThrow();
});
it("spells scheme URLs the way each webview expects", () => {
expect(webSchemeUrl("acme.draw/abc/web/index.html", false)).toBe(
"openidea-plugin://localhost/acme.draw/abc/web/index.html",
);
expect(webSchemeUrl("p/r/web/a b.html", true)).toBe("http://openidea-plugin.localhost/p/r/web/a%20b.html");
});
});
describe("frame bridge", () => {
it("asks the workbench for the document and edits it, and hears of changes made elsewhere", async () => {
const script = /<script>([\s\S]*)<\/script>/.exec(frameDocument("", { dark: true, vars: {} }))![1]!;
const sent: Array<{ call: number; op: string; args: unknown }> = [];
// In jsdom the frame is its own parent: catch what the bridge posts "up".
const post = vi.spyOn(window, "postMessage").mockImplementation((m: unknown) => {
const msg = m as { call?: number; op?: string; args?: unknown };
if (typeof msg.call === "number") sent.push(msg as (typeof sent)[number]);
});
const reply = (data: object) =>
window.dispatchEvent(new MessageEvent("message", { data: { tag: FRAME_TAG, ...data }, source: window }));
try {
new Function(script)();
const api = (window as unknown as { openidea: Record<string, never> }).openidea as unknown as {
document: {
get(): Promise<unknown>;
edit(v: number, e: TextChange[]): Promise<{ version: number }>;
replace(v: number, t: string): Promise<unknown>;
onChange(fn: (s: DocumentState) => void): void;
};
saveFile(o: { name: string; data: string }): Promise<string | null>;
};
const got = api.document.get();
expect(sent.at(-1)).toMatchObject({ op: "document.get" });
reply({ reply: sent.at(-1)!.call, value: { version: 3, text: "abc", readOnly: false } });
await expect(got).resolves.toEqual({ version: 3, text: "abc", readOnly: false });
const edited = api.document.edit(3, [{ from: 0, to: 1, insert: "X" }]);
expect(sent.at(-1)).toMatchObject({ op: "document.edit", args: { version: 3 } });
reply({ reply: sent.at(-1)!.call, error: "stale" });
await expect(edited).rejects.toThrow("stale");
const saved = api.saveFile({ name: "a.svg", data: "<svg/>" });
await waitFor(() => expect(sent.at(-1)).toMatchObject({ op: "saveFile", args: { name: "a.svg" } }));
expect(new TextDecoder().decode((sent.at(-1)!.args as { bytes: Uint8Array }).bytes)).toBe("<svg/>");
reply({ reply: sent.at(-1)!.call, value: "/tmp/a.svg" });
await expect(saved).resolves.toBe("/tmp/a.svg");
const changes: DocumentState[] = [];
api.document.onChange((s) => changes.push(s));
reply({ document: { version: 4, text: "xyz" } });
expect(changes).toEqual([{ version: 4, text: "xyz" }]);
} finally {
post.mockRestore();
delete (window as unknown as { openidea?: unknown }).openidea;
}
});
});
describe("WebView", () => {
function setup(hasCode: boolean) {
const events: ViewEvent[] = [];
setPluginsBackend({
...createMemoryPluginsBackend([]),
webPageUrl: async () => "about:blank",
viewEvent: vi.fn(async (_p: string, _v: string, e: ViewEvent) => void events.push(e)),
});
usePlugins.setState({
plugins: [{ id: "acme.draw", hasCode, capabilities: [] } as unknown as PluginInfo],
});
const doc = memoryDocument("hello world");
const view = render(<WebView pluginId="acme.draw" view="draw" label="Draw" document={doc} doc={7} />);
return { events, doc, view };
}
async function frameOf(container: HTMLElement) {
const frame = await waitFor(() => {
const f = container.querySelector("iframe");
expect(f).not.toBeNull();
return f!;
});
const posted: Array<Record<string, unknown>> = [];
vi.spyOn(frame.contentWindow!, "postMessage").mockImplementation((m: unknown) => void posted.push(m as never));
const send = (data: object) =>
window.dispatchEvent(
new MessageEvent("message", { data: { tag: FRAME_TAG, ...data }, source: frame.contentWindow }),
);
return { frame, posted, send };
}
it("loads the page sandboxed, from its URL, without clipboard access unless granted", async () => {
const { view } = setup(false);
const { frame } = await frameOf(view.container);
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame.getAttribute("src")).toBe("about:blank");
expect(frame.getAttribute("allow")).toBeNull();
});
it("serves the document to the page: versioned edits, replace, stale versions, outside changes", async () => {
const { view, doc } = setup(false);
const { posted, send } = await frameOf(view.container);
const replyTo = async (id: number) =>
waitFor(() => {
const r = posted.find((m) => m.reply === id);
expect(r).toBeDefined();
return r!;
});
send({ call: 1, op: "document.get" });
expect((await replyTo(1)).value).toEqual({ version: 0, text: "hello world", readOnly: false, name: "a.draw" });
send({ call: 2, op: "document.edit", args: { version: 0, edits: [{ from: 0, to: 5, insert: "HELLO" }] } });
expect((await replyTo(2)).value).toEqual({ version: 1 });
expect(doc.text()).toBe("HELLO world");
send({ call: 3, op: "document.edit", args: { version: 0, edits: [] } });
expect((await replyTo(3)).error).toBe("stale");
send({ call: 4, op: "document.replace", args: { version: 1, text: "HELLO there" } });
expect((await replyTo(4)).value).toEqual({ version: 2 });
expect(doc.text()).toBe("HELLO there");
send({ call: 5, op: "document.edit", args: { version: 2, edits: [{ from: 0, to: 99, insert: "" }] } });
expect(String((await replyTo(5)).error)).toContain("in range");
// Changes made elsewhere reach the page; its own are not echoed back.
const before = posted.filter((m) => m.document).length;
doc.external([{ from: 0, to: 0, insert: "> " }]);
await waitFor(() => expect(posted.filter((m) => m.document)).toHaveLength(before + 1));
expect(posted.at(-1)).toMatchObject({ document: { version: 3, text: "> HELLO there" } });
send({ call: 6, op: "nope" });
expect(String((await replyTo(6)).error)).toContain("unknown request");
});
it("follows the installed package: a new revision reloads the page, an unchanged one keeps it", async () => {
let url = "openidea-plugin://localhost/acme.draw/r1/web/index.html";
setPluginsBackend({ ...createMemoryPluginsBackend([]), webPageUrl: async () => url });
const info = (version: string) =>
({ id: "acme.draw", version, hasCode: false, capabilities: [] }) as unknown as PluginInfo;
usePlugins.setState({ plugins: [info("1.0.0")] });
const view = render(<WebView pluginId="acme.draw" view="draw" label="Draw" />);
const { frame } = await frameOf(view.container);
expect(frame.getAttribute("src")).toContain("/r1/");
// Plugins refreshed, same package: the same frame, not reloaded.
usePlugins.setState({ plugins: [info("1.0.0")] });
await new Promise((r) => setTimeout(r, 0));
expect(view.container.querySelector("iframe")).toBe(frame);
expect(frame.getAttribute("src")).toContain("/r1/");
url = "openidea-plugin://localhost/acme.draw/r2/web/index.html";
usePlugins.setState({ plugins: [info("1.0.1")] });
await waitFor(() => expect(view.container.querySelector("iframe")?.getAttribute("src")).toContain("/r2/"));
});
it("forwards the page's messages to the plugin's code only when it has code", async () => {
for (const hasCode of [false, true]) {
const { view, events } = setup(hasCode);
const { send } = await frameOf(view.container);
send({ text: "hi" });
await new Promise((r) => setTimeout(r, 0));
expect(events).toEqual(hasCode ? [{ kind: "message", text: "hi" }] : []);
view.unmount();
}
});
});
describe("contributed file icons and templates", () => {
it("resolves a plugin's file icon before the built-in one, and stops when removed", () => {
const builtin = fileIconName("a.excalidraw");
const unregister = registerExternalIcons([["ext:fileIcon/acme/draw", "<svg viewBox='0 0 16 16'/>"]]);
const remove = addFileIconProvider((name) => (name.endsWith(".excalidraw") ? "ext:fileIcon/acme/draw" : null));
try {
expect(fileIconName("a.excalidraw")).toBe("ext:fileIcon/acme/draw");
expect(fileIconName("a.txt")).not.toBe("ext:fileIcon/acme/draw");
} finally {
remove();
unregister();
}
expect(fileIconName("a.excalidraw")).toBe(builtin);
});
it("adds a plugin's template to the New menu like a built-in", () => {
const remove = addContributedFileTemplate({
id: "plugin.acme.draw",
name: "Drawing",
icon: "file-text",
extension: "draw",
nameStyle: "asis",
kinds: [{ id: "file", label: "Drawing", body: "{}" }],
});
expect(fileTemplates().some((t) => t.id === "plugin.acme.draw")).toBe(true);
expect(isBuiltinFileTemplate("plugin.acme.draw")).toBe(true);
remove();
expect(fileTemplates().some((t) => t.id === "plugin.acme.draw")).toBe(false);
});
});
+58
Переглянути файл
@@ -72,6 +72,12 @@ export interface PluginsBackend {
/** Content of a plugin view (`doc`: the document of a custom editor). */
renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>;
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
/** The URL of a web view's page (`web = …`), under the `openidea-plugin` scheme. */
webPageUrl(pluginId: string, view: string): Promise<string>;
/** Save dialog for a web view's `openidea.saveFile`; the path written, or `null` when cancelled. */
saveWebFile(name: string, filters: FileFilter[] | undefined, bytes: Uint8Array): Promise<string | null>;
/** Open dialog for a web view's `openidea.openFile`; `null` when cancelled. */
openWebFile(filters: FileFilter[] | undefined): Promise<{ name: string; data: Uint8Array } | null>;
/** Which of a plugin's declared secrets are stored (values never come back). */
secrets(pluginId: string): Promise<PluginSecretState[]>;
/** Stores a secret (`null` removes it). */
@@ -86,6 +92,34 @@ export interface PluginsBackend {
onEvent(handler: (e: PluginEvent) => void): () => void;
}
/** A file type choice of the save / open dialogs. */
export interface FileFilter {
name: string;
extensions: string[];
}
/**
* `path` under the `openidea-plugin` scheme, as the platform's webview spells
* custom schemes (WebView2 maps them onto `http://<scheme>.localhost`).
*/
export function webSchemeUrl(path: string, windows = /windows/i.test(navigator.userAgent)): string {
const encoded = path.split("/").map(encodeURIComponent).join("/");
return windows ? `http://openidea-plugin.localhost/${encoded}` : `openidea-plugin://localhost/${encoded}`;
}
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(binary);
}
function fromBase64(b64: string): Uint8Array {
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
export const tauriPluginsBackend: PluginsBackend = {
list: () => call(commands.pluginList()),
setEnabled: async (id, enabled) => {
@@ -134,6 +168,25 @@ export const tauriPluginsBackend: PluginsBackend = {
viewEvent: async (pluginId, view, event, doc) => {
await call(commands.pluginViewEvent(pluginId, view, event, doc));
},
webPageUrl: async (pluginId, view) => {
const page = await call(commands.pluginWebPage(pluginId, view));
return webSchemeUrl(page.path);
},
saveWebFile: async (name, filters, bytes) => {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: name, filters });
if (!path) return null;
await call(commands.pluginWebSaveFile(path, toBase64(bytes)));
return path;
},
openWebFile: async (filters) => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ multiple: false, filters });
const path = (Array.isArray(picked) ? picked[0] : picked) ?? null;
if (!path) return null;
const data = fromBase64(await call(commands.pluginWebReadFile(path)));
return { name: path.split(/[\\/]/).pop() ?? path, data };
},
secrets: (pluginId) => call(commands.pluginSecrets(pluginId)),
setSecret: async (pluginId, name, value) => {
await call(commands.pluginSetSecret(pluginId, name, value));
@@ -437,6 +490,11 @@ export function createMemoryPluginsBackend(
status: null,
}),
viewEvent: async () => {},
webPageUrl: async (pluginId, view) => {
throw new IpcError({ kind: "unavailable", message: `${pluginId} · ${view}: web views need the desktop app` });
},
saveWebFile: async () => null,
openWebFile: async () => null,
secrets: async (pluginId) =>
[...secrets.entries()]
.filter(([k]) => k.startsWith(`${pluginId}/`))
+122 -11
Переглянути файл
@@ -6,8 +6,11 @@
* - `keymaps`: default shortcuts below the IDE's (a taken keystroke is dropped);
* - `statusBar`: items shown before the code runs (its `set-status-item` with the same id updates them);
* - `settings`: a page under Settings › Plugins generated from the schema;
* - `views`: tool windows the plugin renders;
* - `customEditors`: files opening in a plugin-rendered editor sharing the text document.
* - `views`: tool windows the plugin renders, or a web page of its package;
* - `customEditors`: files opening in a plugin editor sharing the text document, in its own tab or
* beside the text editor;
* - `fileIcons`: icons for the files a plugin knows, whatever icon theme is in use;
* - `fileTemplates`: entries of the New menu.
*/
import { useMemo } from "react";
import type { PluginInfo } from "@/bindings";
@@ -15,18 +18,31 @@ import { getAction } from "@/platform/actions";
import { evaluateWhen } from "@/platform/context";
import { tx } from "@/platform/i18n";
import { isMacHost, registerExtensionBindings } from "@/platform/keymap";
import { Checkbox, Input, Select, T, type MenuItem } from "@/ui";
import {
Checkbox,
Input,
Select,
T,
addFileIconProvider,
fileIconName,
registerExternalIcons,
type MenuItem,
} from "@/ui";
import { addContributedFileTemplate } from "@/features/templates/files/store";
import {
registerEditorKind,
registerFileEditorProvider,
registerMenuGroup,
registerMenuSlotItem,
registerSettingsPage,
registerTextEditorWrapper,
registerToolWindow,
useSettingsForm,
type ContextMenuId,
type ToolWindowAnchor,
} from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
import { usePlugins } from "./store";
import { pluginActionId } from "./points";
import { defineUiPoint } from "./projection";
import { declareStatusItem } from "./runtimeUi";
@@ -39,7 +55,9 @@ import {
matchesPattern,
type CustomEditorDecl,
} from "./views/customEditor";
import { layoutActions, layoutWrapper } from "./views/editorLayout";
import { PluginView } from "./views/PluginView";
import { WebView } from "./views/WebView";
import styles from "./PluginsPage.module.css";
// --- menus ------------------------------------------------------------------------
@@ -205,6 +223,69 @@ interface ViewDecl {
title: string;
icon?: string | null;
location: ToolWindowAnchor;
web?: string;
}
interface FileIconDecl {
id: string;
patterns: string[];
/** A dark-theme variant was given. */
dark: boolean;
}
interface FileTemplateDecl {
id: string;
name: string;
extension: string;
body: string;
}
/** A new file's icon: the plugin's own file icon for it, else the built-in one. */
function templateIcon(pluginId: string, extension: string): string {
const file = `untitled.${extension}`;
const own = usePlugins
.getState()
.ui.find(
(c) =>
c.pluginId === pluginId &&
c.point === "fileIcons" &&
(c.decl as FileIconDecl).patterns.some((p) => matchesPattern(p, file)),
);
return own ? `ext:fileIcon/${pluginId}/${(own.decl as FileIconDecl).id}` : fileIconName(file);
}
/** Registers a plugin's file icon (fetched first) and resolves matching files to it. */
function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void {
let disposed = false;
let undo = () => {};
const name = `ext:fileIcon/${pluginId}/${decl.id}`;
void pluginsBackend()
.assets(pluginId, `fileIcon/${decl.id}/`)
.then((assets) => {
if (disposed) return;
const svg = (variant: string) => assets.find(([id]) => id === `fileIcon/${decl.id}/${variant}`)?.[1];
const light = svg("light");
if (!light) return;
const dark = svg("dark");
const unregister = registerExternalIcons(
dark
? [
[name, light],
[`${name}_dark`, dark],
]
: [[name, light]],
);
const remove = addFileIconProvider((file) => (decl.patterns.some((p) => matchesPattern(p, file)) ? name : null));
undo = () => {
remove();
unregister();
};
})
.catch((e: unknown) => console.warn(`plugin ${pluginId}: file icon ${decl.id}`, e));
return () => {
disposed = true;
undo();
};
}
export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`;
@@ -252,21 +333,35 @@ export function defineViewUiPoints(): () => void {
icon: decl.icon ?? "plugin",
anchor: decl.location,
order: 900,
component: () => <PluginView pluginId={pluginId} view={decl.id} label={decl.title} />,
component: () =>
decl.web ? (
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
) : (
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} />
),
}),
}),
defineUiPoint<CustomEditorDecl>("customEditors", {
title: "Editors",
project: ({ pluginId, decl }) => {
const matches = (path: string) => decl.patterns.some((p) => matchesPattern(p, basename(path)));
const beside = decl.layout === "split" || decl.layout === "preview";
// A side-by-side editor wraps the text editor the file opens in; the others open their own tab.
const provider =
decl.priority === "default"
? registerFileEditorProvider({
id: `plugin.${pluginId}.${decl.id}`,
order: 40,
resolve: (path) => (matches(path) ? customEditorInput(pluginId, decl, path) : null),
})
: () => {};
decl.priority !== "default"
? () => {}
: beside
? registerTextEditorWrapper({
id: `plugin.${pluginId}.${decl.id}`,
matches,
component: layoutWrapper(pluginId, decl),
tabActions: layoutActions(pluginId, decl),
})
: registerFileEditorProvider({
id: `plugin.${pluginId}.${decl.id}`,
order: 40,
resolve: (path) => (matches(path) ? customEditorInput(pluginId, decl, path) : null),
});
openWith.set(`${pluginId}/${decl.id}`, { pluginId, decl, matches });
return () => {
provider();
@@ -274,6 +369,22 @@ export function defineViewUiPoints(): () => void {
};
},
}),
defineUiPoint<FileIconDecl>("fileIcons", {
title: "File Icons",
project: ({ pluginId, decl }) => projectFileIcon(pluginId, decl),
}),
defineUiPoint<FileTemplateDecl>("fileTemplates", {
title: "File Templates",
project: ({ pluginId, decl }) =>
addContributedFileTemplate({
id: `plugin.${pluginId}.${decl.id}`,
name: decl.name,
icon: templateIcon(pluginId, decl.extension),
extension: decl.extension,
nameStyle: "asis",
kinds: [{ id: "file", label: decl.name, body: decl.body }],
}),
}),
];
return () => disposers.reverse().forEach((d) => d());
}
+36
Переглянути файл
@@ -173,3 +173,39 @@
height: 100%;
background: transparent;
}
/* A custom editor beside the text editor (`layout = "split"` / `"preview"`). */
.split {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
position: relative;
}
.splitEditor {
flex: 1 1 50%;
min-width: 0;
display: flex;
flex-direction: column;
}
.splitPreview {
flex: 1 1 50%;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-editor, var(--bg-app));
}
.layout-preview .splitEditor {
display: none;
}
.layout-split .splitEditor {
border-right: 1px solid var(--border-editor-tabs, var(--border));
}
.layoutActions {
display: flex;
align-items: center;
}
.layoutActions > button {
width: var(--h-floating-toolbar-button);
height: var(--h-floating-toolbar-button);
}
+259
Переглянути файл
@@ -0,0 +1,259 @@
/**
* A plugin's web view (`web = "web/index.html"` on a view or custom editor):
* a page shipped in the plugin package, loaded from the `openidea-plugin`
* scheme into an `<iframe sandbox="allow-scripts">`. The frame is an opaque
* origin with no network and no IDE API; the desktop serves only the files
* under the page's directory, with a CSP of their own, and injects the bridge
* (`frame-bridge.js`). Through it, and only it, the page:
*
* - follows the IDE theme;
* - exchanges messages with the plugin's code, when it has any;
* - reads and edits the document of the custom editor it is (versioned edits
* that join the document's undo history, Save and dirty state);
* - asks the user to save or open a file (the IDE's dialogs: the user picks
* the path, the page never names one);
* - uses the clipboard, when the plugin was granted `clipboard`;
* - passes on IDE shortcuts it doesn't handle itself (Save, Close…).
*/
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { tx } from "@/platform/i18n";
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
import { EmptyState, Spinner } from "@/ui";
import { pluginsBackend, type FileFilter } from "../backend";
import { errorText, usePlugins } from "../store";
import { FRAME_TAG, frameTheme, frameThemeMessage } from "./frame";
import { onViewMessage } from "./viewStore";
import styles from "./PluginView.module.css";
/** One change of a document, in UTF-16 offsets of the text it applies to. */
export interface TextChange {
from: number;
to: number;
insert: string;
}
export interface DocumentState {
version: number;
text: string;
}
/** The document a web custom editor shows, as its frame sees it. */
export interface FrameDocument {
/** `name` is the file's name, without its directory. */
read(): DocumentState & { readOnly: boolean; name: string };
/** Applies the frame's edits as one change; throws `stale` when not at `version`. Returns the new version. */
edit(version: number, changes: readonly TextChange[]): number;
/** Changes made other than through `edit` (the text editor, Undo, other windows). */
subscribe(listener: (state: DocumentState) => void): () => void;
}
/** Largest text a frame may write in one edit. */
const MAX_EDIT = 64 << 20;
/** The smallest change turning `from` into `to`: their common prefix and suffix stay. */
export function minimalChange(from: string, to: string): TextChange | null {
if (from === to) return null;
let start = 0;
const max = Math.min(from.length, to.length);
while (start < max && from.charCodeAt(start) === to.charCodeAt(start)) start++;
let end = 0;
while (end < max - start && from.charCodeAt(from.length - 1 - end) === to.charCodeAt(to.length - 1 - end)) end++;
return { from: start, to: from.length - end, insert: to.slice(start, to.length - end) };
}
/** Checks a frame's edits against the text they apply to: in range, sorted, not overlapping. */
export function validChanges(value: unknown, length: number): TextChange[] {
if (!Array.isArray(value) || value.length > 100_000) throw new Error("edits must be an array");
let at = 0;
let size = 0;
return value.map((c: unknown) => {
const { from, to, insert } = (c ?? {}) as Partial<TextChange>;
if (!Number.isInteger(from) || !Number.isInteger(to) || typeof insert !== "string")
throw new Error("an edit is { from, to, insert }");
if (from! < at || to! < from! || to! > length) throw new Error("edits must be sorted, in range and not overlap");
size += insert.length;
if (size > MAX_EDIT) throw new Error("the edit is too large");
at = to!;
return { from: from!, to: to!, insert };
});
}
function validFilters(value: unknown): FileFilter[] | undefined {
if (!Array.isArray(value)) return undefined;
return value
.slice(0, 10)
.filter(
(f): f is FileFilter =>
!!f &&
typeof f.name === "string" &&
Array.isArray(f.extensions) &&
f.extensions.every((e: unknown) => typeof e === "string" && /^[\w.+-]{1,32}$/.test(e)),
)
.map((f) => ({ name: f.name.slice(0, 64), extensions: f.extensions.slice(0, 20) }));
}
type PageState = { url: string } | { error: string };
/**
* The `openidea-plugin` URL of the page, or why there is none. Asked again
* whenever the plugin changes (`plugin`: its info, a new object then): the URL
* names the installed package's revision, so an update, a reinstall or
* disabling it reach the frame, and the frame only reloads when the URL is new.
*/
function usePageUrl(pluginId: string, view: string, plugin: unknown) {
const page = `${pluginId}/${view}`;
const [state, setState] = useState<{ page: string; value: PageState } | null>(null);
useEffect(() => {
let live = true;
const set = (value: PageState) =>
live &&
setState((s) =>
s?.page === page && "url" in s.value && "url" in value && s.value.url === value.url ? s : { page, value },
);
pluginsBackend()
.webPageUrl(pluginId, view)
.then(
(url) => set({ url }),
(e: unknown) => set({ error: errorText(e) }),
);
return () => {
live = false;
};
}, [pluginId, view, page, plugin]);
return state?.page === page ? state.value : null;
}
export interface WebViewProps {
pluginId: string;
view: string;
label: string;
/** The custom editor's document; none in a tool window. */
document?: FrameDocument | null;
/** The backend document, for messages to the plugin's code. */
doc?: number | null;
}
export function WebView({ pluginId, view, label, document = null, doc = null }: WebViewProps) {
const plugin = usePlugins((s) => s.plugins.find((p) => p.id === pluginId));
const page = usePageUrl(pluginId, view, plugin);
const hasCode = plugin?.hasCode ?? false;
const clipboard = plugin?.capabilities.some((c) => c.id === "clipboard" && c.granted) ?? false;
const frame = useRef<HTMLIFrameElement>(null);
const post = useCallback((message: object, transfer: Transferable[] = []) => {
frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, ...message }, "*", transfer);
}, []);
const postTheme = useCallback(() => post(frameThemeMessage(frameTheme())), [post]);
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
useEffect(postTheme, [theme, postTheme]);
// The document's changes made elsewhere.
useEffect(() => document?.subscribe((state) => post({ document: state })), [document, post]);
useEffect(() => {
let dialog = false;
const run = async (op: string, args: Record<string, unknown>): Promise<[unknown, Transferable[]?]> => {
switch (op) {
case "document.get": {
if (!document) throw new Error("this view shows no document");
return [document.read()];
}
case "document.edit":
case "document.replace": {
if (!document) throw new Error("this view shows no document");
const current = document.read();
if (current.readOnly) throw new Error("the document is read-only");
if (args.version !== current.version) throw new Error("stale");
let changes: TextChange[];
if (op === "document.replace") {
if (typeof args.text !== "string" || args.text.length > MAX_EDIT) throw new Error("text must be a string");
const change = minimalChange(current.text, args.text);
changes = change ? [change] : [];
} else changes = validChanges(args.edits, current.text.length);
return [{ version: changes.length ? document.edit(current.version, changes) : current.version }];
}
case "saveFile":
case "openFile": {
// One dialog at a time per frame.
if (dialog) throw new Error("a file dialog is already open");
dialog = true;
try {
const filters = validFilters(args.filters);
if (op === "openFile") {
const file = await pluginsBackend().openWebFile(filters);
return file ? [file, [file.data.buffer]] : [null];
}
if (!(args.bytes instanceof Uint8Array)) throw new Error("data must be bytes");
const name = typeof args.name === "string" ? args.name.slice(0, 255) : "untitled";
return [await pluginsBackend().saveWebFile(name, filters, args.bytes)];
} finally {
dialog = false;
}
}
default:
throw new Error(`unknown request ${op}`);
}
};
const onMessage = (e: MessageEvent) => {
if (!frame.current || e.source !== frame.current.contentWindow) return;
const m = e.data as {
tag?: unknown;
text?: unknown;
call?: unknown;
op?: unknown;
args?: unknown;
key?: unknown;
} | null;
if (m?.tag !== FRAME_TAG) return;
if (m.key && typeof m.key === "object") {
// A shortcut the page didn't handle: as if pressed on the frame, for the IDE's keymap.
const k = m.key as KeyboardEventInit;
const init: KeyboardEventInit = { bubbles: true, cancelable: true };
for (const f of ["key", "code"] as const) if (typeof k[f] === "string") init[f] = k[f];
for (const f of ["metaKey", "ctrlKey", "altKey", "shiftKey", "repeat"] as const) init[f] = k[f] === true;
frame.current.dispatchEvent(new KeyboardEvent("keydown", init));
} else if (typeof m.call === "number" && typeof m.op === "string") {
const id = m.call;
run(m.op, (m.args ?? {}) as Record<string, unknown>).then(
([value, transfer]) => post({ reply: id, value }, transfer),
(err: unknown) => post({ reply: id, error: errorText(err) }),
);
} else if (typeof m.text === "string" && hasCode) {
pluginsBackend()
.viewEvent(pluginId, view, { kind: "message", text: m.text }, doc)
.catch((err: unknown) => console.warn(`plugin ${pluginId}: ${errorText(err)}`));
}
};
window.addEventListener("message", onMessage);
const off = onViewMessage(pluginId, view, (text) => post({ text }));
return () => {
window.removeEventListener("message", onMessage);
off();
};
}, [pluginId, view, document, doc, hasCode, post]);
if (!page)
return (
<div className={styles.empty}>
<Spinner label={tx("Loading")} />
</div>
);
if ("error" in page)
return (
<EmptyState className={styles.empty} title={tx`Cannot show ${label}`}>
{page.error}
</EmptyState>
);
// No `allow-same-origin`: an opaque origin without access to the IDE. `onLoad` sends the theme.
return (
<iframe
ref={frame}
className={styles.frame}
title={label}
sandbox="allow-scripts"
allow={clipboard ? "clipboard-read; clipboard-write" : undefined}
src={page.url}
onLoad={postTheme}
/>
);
}
+151 -31
Переглянути файл
@@ -13,7 +13,7 @@
import { history } from "@codemirror/commands";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { attachDocument, type DocumentAttachment } from "@/editor/api";
import { tx } from "@/platform/i18n";
import { EmptyState, Spinner } from "@/ui";
@@ -24,6 +24,7 @@ import {
type EditorInput,
} from "@/workbench/contributions";
import { PluginView } from "./PluginView";
import { WebView, type DocumentState, type FrameDocument } from "./WebView";
import styles from "./PluginView.module.css";
export const CUSTOM_EDITOR_KIND = "pluginEditor";
@@ -32,6 +33,8 @@ export interface CustomEditorData {
pluginId: string;
editorId: string;
title: string;
/** A web page of the package (`web`) rather than content the code renders. */
web?: boolean;
}
export interface CustomEditorDecl {
@@ -39,10 +42,13 @@ export interface CustomEditorDecl {
title: string;
patterns: string[];
priority: "default" | "option";
/** `tab`: its own tab; `split` / `preview`: beside the text editor, starting in that layout. */
layout?: "tab" | "split" | "preview";
web?: string;
}
export function customEditorInput(pluginId: string, decl: CustomEditorDecl, path: string): EditorInput {
const data: CustomEditorData = { pluginId, editorId: decl.id, title: decl.title };
const data: CustomEditorData = { pluginId, editorId: decl.id, title: decl.title, web: !!decl.web };
return { kind: CUSTOM_EDITOR_KIND, key: `${CUSTOM_EDITOR_KIND}:${pluginId}/${decl.id}:${path}`, path, data };
}
@@ -88,6 +94,8 @@ interface Session {
view: EditorView;
revision: number;
listeners: Set<(revision: number) => void>;
/** The document as web frames see it. */
frameDocument: FrameDocument;
cancelTimer: () => void;
}
@@ -96,14 +104,24 @@ const sessions = new Map<string, Promise<Session>>();
/** Edits settle in the backend before the plugin renders the table again. */
const RENDER_DELAY_MS = 120;
function session(input: EditorInput): Promise<Session> {
let s = sessions.get(input.key);
function session(key: string, path: string): Promise<Session> {
let s = sessions.get(key);
if (!s) {
s = attachDocument(input.path ?? "").then((attachment) => {
s = attachDocument(path).then((attachment) => {
const listeners = new Set<(revision: number) => void>();
const frameListeners = new Set<(state: DocumentState) => void>();
let timer: ReturnType<typeof setTimeout> | null = null;
const changed = () => {
// Bumped by every change: what frames' versioned edits are checked against.
let version = 0;
// Set while a frame's edit is applied: its changes (arriving synchronously) aren't echoed back.
let framing = false;
const changed = (byFrame: boolean) => {
version++;
notifyEditorDocumentChanged();
if (!byFrame && frameListeners.size) {
const state = { version, text: view.state.doc.toString() };
for (const l of frameListeners) l(state);
}
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
@@ -116,24 +134,58 @@ function session(input: EditorInput): Promise<Session> {
const view = new EditorView({
state: EditorState.create({
doc: attachment.text(),
extensions: [attachment.extension, history(), EditorView.updateListener.of((u) => u.docChanged && changed())],
extensions: [
attachment.extension,
history(),
EditorView.updateListener.of((u) => u.docChanged && changed(framing)),
],
}),
});
const frameDocument: FrameDocument = {
read: () => ({ version, text: view.state.doc.toString(), readOnly: attachment.readOnly, name: basename(path) }),
edit: (at, changes) => {
if (at !== version) throw new Error("stale");
// One edit of the document's history: Undo in the text editor (or this tab) reverts it.
framing = true;
try {
attachment.edit([...changes], "input.plugin");
} finally {
framing = false;
}
return version;
},
subscribe: (listener) => {
frameListeners.add(listener);
return () => frameListeners.delete(listener);
},
};
const state: Session = {
attachment,
view,
revision: 0,
listeners,
frameDocument,
cancelTimer: () => timer && clearTimeout(timer),
};
return state;
});
sessions.set(input.key, s);
s.catch(() => sessions.delete(input.key));
sessions.set(key, s);
s.catch(() => sessions.delete(key));
}
return s;
}
async function closeSession(key: string) {
const s = sessions.get(key);
sessions.delete(key);
if (!s) return;
const state = await s;
settled.delete(key);
state.cancelTimer();
state.attachment.release();
state.view.destroy();
}
async function withSession<T>(input: EditorInput, f: (s: Session) => T | Promise<T>): Promise<T | undefined> {
const s = sessions.get(input.key);
return s ? f(await s) : undefined;
@@ -157,16 +209,7 @@ export const customEditorDocument: EditorDocumentLifecycle = {
discard: async () => {
// The document's unsaved changes go when it closes (`close` releases it).
},
close: async (input) => {
const s = sessions.get(input.key);
sessions.delete(input.key);
if (!s) return;
const state = await s;
settled.delete(input.key);
state.cancelTimer();
state.attachment.release();
state.view.destroy();
},
close: (input) => closeSession(input.key),
undo: async (input) => {
await withSession(input, (s) => void s.attachment.undo());
},
@@ -175,29 +218,70 @@ export const customEditorDocument: EditorDocumentLifecycle = {
},
};
export function CustomEditor({ input }: { input: EditorInput }) {
const data = input.data as CustomEditorData;
const [state, setState] = useState<{ doc: number; revision: number } | { error: string } | null>(null);
type SessionState = { session: Session; revision: number } | { error: string } | null;
/** The session `key` of `path`, kept for as long as the component shows it. */
function useSession(key: string, path: string, release: boolean): SessionState {
const [state, setState] = useState<SessionState>(null);
useEffect(() => {
let live = true;
let off = () => {};
session(input).then(
session(key, path).then(
(s) => {
if (!live) return;
settled.set(input.key, s);
const listener = (revision: number) => setState({ doc: s.attachment.doc, revision });
settled.set(key, s);
const listener = (revision: number) => setState({ session: s, revision });
s.listeners.add(listener);
off = () => s.listeners.delete(listener);
setState({ doc: s.attachment.doc, revision: s.revision });
setState({ session: s, revision: s.revision });
},
(e: unknown) => live && setState({ error: e instanceof Error ? e.message : String(e) }),
);
return () => {
live = false;
off();
// A tab's session lives until the tab closes (its lifecycle); a preview's until it unmounts.
if (release) void closeSession(key);
};
}, [input]);
}, [key, path, release]);
return state;
}
function SessionBody({
state,
pluginId,
editorId,
web,
label,
path,
}: {
state: Exclude<SessionState, null | { error: string }>;
pluginId: string;
editorId: string;
web: boolean;
label: string;
path: string;
}) {
const { session: s, revision } = state;
if (web)
return (
<WebView pluginId={pluginId} view={editorId} label={label} document={s.frameDocument} doc={s.attachment.doc} />
);
return (
<PluginView
pluginId={pluginId}
view={editorId}
doc={s.attachment.doc}
revision={revision}
label={label}
key={path}
/>
);
}
export function CustomEditor({ input }: { input: EditorInput }) {
const data = input.data as CustomEditorData;
const state = useSession(input.key, input.path ?? "", false);
if (!state)
return (
@@ -213,12 +297,13 @@ export function CustomEditor({ input }: { input: EditorInput }) {
);
return (
<div className={styles.view}>
<PluginView
<SessionBody
state={state}
pluginId={data.pluginId}
view={data.editorId}
doc={state.doc}
revision={state.revision}
editorId={data.editorId}
web={!!data.web}
label={tx`${data.title}: ${basename(input.path ?? "")}`}
path={input.path ?? ""}
/>
<div className={styles.status}>
<button
@@ -232,3 +317,38 @@ export function CustomEditor({ input }: { input: EditorInput }) {
</div>
);
}
/**
* A custom editor beside the text editor (`layout = "split"` / `"preview"`):
* the same document, so each follows the other's edits.
*/
export function CustomEditorPreview({
pluginId,
decl,
path,
}: {
pluginId: string;
decl: CustomEditorDecl;
path: string;
}) {
const key = useMemo(() => `pluginPreview:${pluginId}/${decl.id}:${path}`, [pluginId, decl.id, path]);
const state = useSession(key, path, true);
const label = tx`${decl.title}: ${basename(path)}`;
if (!state)
return (
<div className={styles.empty}>
<Spinner label={tx("Loading")} />
</div>
);
if ("error" in state)
return (
<EmptyState className={styles.empty} title={tx`Cannot open ${basename(path)}`}>
{state.error}
</EmptyState>
);
return (
<div className={styles.view}>
<SessionBody state={state} pluginId={pluginId} editorId={decl.id} web={!!decl.web} label={label} path={path} />
</div>
);
}
+90
Переглянути файл
@@ -0,0 +1,90 @@
/**
* Custom editors that sit beside the text editor (`layout = "split"` or
* `"preview"`): the file opens in the text editor, wrapped with the plugin's
* editor and the Editor / Editor and Preview / Preview switch (in the tab
* strip, or floating when tabs are hidden), as Markdown's preview. The
* layout is kept per file; files not switched yet start in the declared one.
*/
import clsx from "clsx";
import { useRef, type ReactNode } from "react";
import { create } from "zustand";
import { tx } from "@/platform/i18n";
import { FloatingToolbar, IconButton } from "@/ui";
import { CustomEditorPreview, type CustomEditorDecl } from "./customEditor";
import styles from "./PluginView.module.css";
export type EditorLayout = "editor" | "split" | "preview";
/** `<plugin>/<editor>` → path → layout. */
export const useEditorLayouts = create<{ byPath: Record<string, EditorLayout> }>(() => ({ byPath: {} }));
const layoutKey = (pluginId: string, editorId: string, path: string) => `${pluginId}/${editorId}:${path}`;
export function layoutOf(pluginId: string, decl: CustomEditorDecl, path: string, byPath: Record<string, EditorLayout>) {
return byPath[layoutKey(pluginId, decl.id, path)] ?? (decl.layout === "preview" ? "preview" : "split");
}
export function setEditorLayout(pluginId: string, editorId: string, path: string, layout: EditorLayout) {
useEditorLayouts.setState((s) => ({ byPath: { ...s.byPath, [layoutKey(pluginId, editorId, path)]: layout } }));
}
function LayoutActions({ pluginId, decl, path }: { pluginId: string; decl: CustomEditorDecl; path: string }) {
const layout = useEditorLayouts((s) => layoutOf(pluginId, decl, path, s.byPath));
const button = (value: EditorLayout, icon: string, title: string) => (
<IconButton
icon={icon}
iconSize={16}
size="small"
title={title}
pressed={layout === value}
onClick={() => setEditorLayout(pluginId, decl.id, path, value)}
/>
);
return (
<div className={styles.layoutActions} role="toolbar" aria-label={tx`${decl.title} layout`}>
{button("editor", "editor-only", tx("Editor Only"))}
{button("split", "editor-preview", tx`Editor and ${decl.title}`)}
{button("preview", "preview-only", tx`${decl.title} Only`)}
</div>
);
}
/** The `tabActions` of a wrapper: the layout switch in the editor's tab strip. */
export function layoutActions(pluginId: string, decl: CustomEditorDecl) {
return function PluginLayoutActions({ path }: { path: string; tabId: string }) {
return <LayoutActions pluginId={pluginId} decl={decl} path={path} />;
};
}
/** The text editor wrapper of a custom editor with a side-by-side layout. */
export function layoutWrapper(pluginId: string, decl: CustomEditorDecl) {
return function PluginEditorLayout({
path,
children,
actionsInTabs = false,
}: {
path: string;
tabId: string;
children: ReactNode;
actionsInTabs?: boolean;
}) {
const layout = useEditorLayouts((s) => layoutOf(pluginId, decl, path, s.byPath));
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} className={clsx(styles.split, styles[`layout-${layout}`])}>
{/* Always rendered: the text editor's view stays alive in every layout. */}
<div className={styles.splitEditor}>{children}</div>
{layout !== "editor" && (
<div className={styles.splitPreview}>
<CustomEditorPreview pluginId={pluginId} decl={decl} path={path} />
</div>
)}
{!actionsInTabs && (
<FloatingToolbar targetRef={ref}>
<LayoutActions pluginId={pluginId} decl={decl} path={path} />
</FloatingToolbar>
)}
</div>
);
};
}
+135
Переглянути файл
@@ -0,0 +1,135 @@
/*
* `window.openidea` inside a plugin view's sandboxed frame: the frame's only
* way to the IDE. Shared by both kinds of frames: inlined into the `html`
* bodies plugin code renders (src/features/plugins/views/frame.ts) and
* injected by the desktop into the pages of web views
* (src-tauri/src/plugin_web.rs). Part of the plugin API: members are only
* ever added.
*
* The file is one function expression, called with the IDE theme when it is
* known up front (null otherwise: the workbench posts it once the frame has
* loaded).
*/
(initialTheme) => {
const TAG = "openidea-view";
const listeners = [];
const themeListeners = [];
const documentListeners = [];
const pending = new Map();
const root = document.documentElement;
let theme = null;
let applied = [];
let nextCall = 1;
const safely = (fn, value) => {
try {
fn(value);
} catch (err) {
console.error(err);
}
};
const applyTheme = (t) => {
if (!t || typeof t !== "object" || !t.vars || typeof t.vars !== "object") return;
const vars = {};
for (const [k, v] of Object.entries(t.vars)) if (k.startsWith("--openidea-") && typeof v === "string") vars[k] = v;
for (const k of applied) if (!(k in vars)) root.style.removeProperty(k);
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
applied = Object.keys(vars);
root.dataset.theme = t.dark ? "dark" : "light";
theme = Object.freeze({ dark: !!t.dark, vars: Object.freeze(vars) });
for (const l of themeListeners) safely(l, theme);
};
/** A request to the workbench; resolves with its reply. */
const call = (op, args, transfer) =>
new Promise((resolve, reject) => {
const id = nextCall++;
pending.set(id, { resolve, reject });
window.parent.postMessage({ tag: TAG, call: id, op, args }, "*", transfer || []);
});
const bytesOf = async (data) => {
if (typeof data === "string") return new TextEncoder().encode(data);
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
throw new TypeError("data must be a string, bytes or a Blob");
};
if (initialTheme) applyTheme(initialTheme);
// IDE shortcuts (Save, Close, Find Action…) pressed while the page has focus:
// the ones the page left alone go to the workbench. Editing keys stay here.
const EDITING_KEYS = new Set(["a", "c", "v", "x", "z", "y"]);
window.addEventListener("keydown", (e) => {
const fn = /^F\d{1,2}$/.test(e.key);
const mod = (e.metaKey || e.ctrlKey) && !EDITING_KEYS.has(e.key.toLowerCase());
if (e.isComposing || (!fn && !mod)) return;
// After every handler of the page has seen it.
setTimeout(() => {
if (e.defaultPrevented) return;
const { key, code, metaKey, ctrlKey, altKey, shiftKey, repeat } = e;
window.parent.postMessage({ tag: TAG, key: { key, code, metaKey, ctrlKey, altKey, shiftKey, repeat } }, "*");
}, 0);
});
window.addEventListener("message", (e) => {
const m = e.data;
if (e.source !== window.parent || !m || m.tag !== TAG) return;
if (m.theme) applyTheme(m.theme);
else if (typeof m.reply === "number") {
const p = pending.get(m.reply);
pending.delete(m.reply);
if (p) "error" in m ? p.reject(new Error(String(m.error))) : p.resolve(m.value);
} else if (m.document) {
for (const l of documentListeners) safely(l, m.document);
} else if (typeof m.text === "string") {
for (const l of listeners) safely(l, m.text);
}
});
const documentApi = Object.freeze({
/** `{ version, text, readOnly, name }` of the document a custom editor shows (`name`: the file's, no directory). */
get: () => call("document.get"),
/** Called with `{ version, text }` when the document changes other than through this frame. */
onChange: (fn) => {
if (typeof fn === "function") documentListeners.push(fn);
},
/**
* Applies `edits` (`{ from, to, insert }`, offsets in UTF-16 code units of
* the text at `version`) as one change; rejects with "stale" when the
* document is no longer at `version`. Resolves with the new version.
*/
edit: (version, edits) => call("document.edit", { version, edits }),
/** Replaces the whole text (sent as the smallest edit). */
replace: (version, text) => call("document.replace", { version, text: String(text) }),
});
window.openidea = Object.freeze({
/** To the plugin's code (`ViewEvent::Message`). */
postMessage: (text) => window.parent.postMessage({ tag: TAG, text: String(text) }, "*"),
/** From the plugin's code (`host::post_view_message`). */
onMessage: (fn) => {
if (typeof fn === "function") listeners.push(fn);
},
get theme() {
return theme;
},
onThemeChange: (fn) => {
if (typeof fn === "function") themeListeners.push(fn);
},
document: documentApi,
/**
* Asks the user where to save `data` (a string, bytes or a Blob), starting
* with `name`; resolves with the chosen path, or null when cancelled.
*/
saveFile: async ({ name, data, filters } = {}) => {
// A copy: its buffer is handed over, the caller's data stays usable.
const bytes = (await bytesOf(data)).slice();
return call("saveFile", { name: String(name || "untitled"), filters, bytes }, [bytes.buffer]);
},
/** Asks the user for a file; resolves with `{ name, data }` (bytes), or null when cancelled. */
openFile: ({ filters } = {}) => call("openFile", { filters }),
});
};
+4 -31
Переглянути файл
@@ -11,6 +11,7 @@
* the same to scripts (canvas drawing, say).
*/
import { resolveTokenColor, toRgb } from "@/platform/cssColor";
import BRIDGE_SOURCE from "./frame-bridge.js?raw";
export const FRAME_CSP =
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:";
@@ -78,37 +79,9 @@ export function frameTheme(root: HTMLElement = document.documentElement): FrameT
/** The message that updates a loaded frame's theme. */
export const frameThemeMessage = (theme: FrameTheme) => ({ tag: FRAME_TAG, theme });
const bridge = (initial: FrameTheme) => `
(() => {
const listeners = [];
const themeListeners = [];
const root = document.documentElement;
let theme = null;
let applied = [];
const applyTheme = (t) => {
if (!t || typeof t !== "object" || !t.vars || typeof t.vars !== "object") return;
const vars = {};
for (const [k, v] of Object.entries(t.vars)) if (k.startsWith("--openidea-") && typeof v === "string") vars[k] = v;
for (const k of applied) if (!(k in vars)) root.style.removeProperty(k);
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
applied = Object.keys(vars);
root.dataset.theme = t.dark ? "dark" : "light";
theme = Object.freeze({ dark: !!t.dark, vars: Object.freeze(vars) });
for (const l of themeListeners) { try { l(theme); } catch (err) { console.error(err); } }
};
applyTheme(${JSON.stringify(initial).replace(/</g, "\\u003c")});
window.addEventListener("message", (e) => {
if (e.source !== window.parent || !e.data || e.data.tag !== "${FRAME_TAG}") return;
if (e.data.theme) { applyTheme(e.data.theme); return; }
for (const l of listeners) { try { l(String(e.data.text)); } catch (err) { console.error(err); } }
});
window.openidea = Object.freeze({
postMessage: (text) => window.parent.postMessage({ tag: "${FRAME_TAG}", text: String(text) }, "*"),
onMessage: (fn) => { if (typeof fn === "function") listeners.push(fn); },
get theme() { return theme; },
onThemeChange: (fn) => { if (typeof fn === "function") themeListeners.push(fn); },
});
})();`;
/** `window.openidea`, a function expression taking the initial theme (shared with web view pages). */
const bridge = (initial: FrameTheme | null) =>
`(${BRIDGE_SOURCE.trim().replace(/;$/, "")})(${JSON.stringify(initial).replace(/</g, "\\u003c")});`;
const BASE_STYLE =
"html[data-theme=light]{color-scheme:light}html[data-theme=dark]{color-scheme:dark}" +
+25 -5
Переглянути файл
@@ -28,13 +28,33 @@ interface Customizations {
const EMPTY: Customizations = { templates: {}, deleted: [], includes: {}, deletedIncludes: [], code: {} };
/** Templates plugins contribute (`fileTemplates`): treated like built-ins (customised, reset, hidden). */
let contributed: readonly FileTemplate[] = [];
const isBase = (id: string) => BUILTIN_IDS.has(id) || contributed.some((t) => t.id === id);
export function mergeFileTemplates(c: Pick<Customizations, "templates" | "deleted">): FileTemplate[] {
const deleted = new Set(c.deleted);
const out = BUILTIN_FILE_TEMPLATES.filter((t) => !deleted.has(t.id)).map((t) => c.templates[t.id] ?? t);
for (const t of Object.values(c.templates)) if (!BUILTIN_IDS.has(t.id) && !deleted.has(t.id)) out.push(t);
const base = [...BUILTIN_FILE_TEMPLATES, ...contributed];
const out = base.filter((t) => !deleted.has(t.id)).map((t) => c.templates[t.id] ?? t);
for (const t of Object.values(c.templates)) if (!isBase(t.id) && !deleted.has(t.id)) out.push(t);
return out;
}
/** Adds a plugin's template to the New menu until the returned function is called. */
export function addContributedFileTemplate(t: FileTemplate): () => void {
contributed = [...contributed.filter((x) => x.id !== t.id), t];
refreshMerged();
return () => {
contributed = contributed.filter((x) => x !== t);
refreshMerged();
};
}
function refreshMerged() {
useFileTemplates.setState((s) => ({ templates: mergeFileTemplates(s.custom) }));
}
export function mergeIncludes(c: Pick<Customizations, "includes" | "deletedIncludes">): TemplateFragment[] {
const deleted = new Set(c.deletedIncludes);
const out = BUILTIN_INCLUDES.filter((f) => !deleted.has(f.name)).map((f) => c.includes[f.name] ?? f);
@@ -98,7 +118,7 @@ function commit(custom: Customizations) {
const custom = () => useFileTemplates.getState().custom;
export const fileTemplates = () => useFileTemplates.getState().templates;
export const isBuiltinFileTemplate = (id: string) => BUILTIN_IDS.has(id);
export const isBuiltinFileTemplate = (id: string) => isBase(id);
/** `#parse` lookup over the current scheme's includes, then its code templates. */
export function templateIncludes(): IncludeResolver {
@@ -115,7 +135,7 @@ export function removeFileTemplate(id: string) {
const c = custom();
const templates = { ...c.templates };
delete templates[id];
commit({ ...c, templates, deleted: BUILTIN_IDS.has(id) ? [...new Set([...c.deleted, id])] : c.deleted });
commit({ ...c, templates, deleted: isBase(id) ? [...new Set([...c.deleted, id])] : c.deleted });
}
export function resetFileTemplate(id: string) {
@@ -126,7 +146,7 @@ export function resetFileTemplate(id: string) {
}
export function isModifiedFileTemplate(id: string): boolean {
return BUILTIN_IDS.has(id) && id in custom().templates;
return isBase(id) && id in custom().templates;
}
/** Saves an include; `previous` is its name before a rename. */
+7 -1
Переглянути файл
@@ -5770,5 +5770,11 @@
"{icons} icons": "{icons} 个图标",
"Use Default Icons": "使用默认图标",
"Apply icon theme {label}": "应用图标主题 {label}",
"File Icon Themes": "文件图标主题"
"File Icon Themes": "文件图标主题",
"File Icons": "文件图标",
"File Templates": "文件模板",
"Cannot show {label}": "无法显示 {label}",
"{title} layout": "{title} 布局",
"Editor and {title}": "编辑器和{title}",
"{title} Only": "仅{title}"
}
+17
Переглянути файл
@@ -986,10 +986,27 @@ export function setFileIconOverride(resolve: ((fileName: string) => IconName | n
iconOverride = resolve;
}
/** Icons for particular files (plugin `fileIcons`), newest first: after the user's, before the icon theme's. */
let fileIconProviders: ReadonlyArray<(fileName: string) => string | null> = [];
/** Adds a file icon provider (an `ext:` icon name or null); the returned function removes it. */
export function addFileIconProvider(resolve: (fileName: string) => string | null): () => void {
fileIconProviders = [resolve, ...fileIconProviders];
useFileIconRevision.setState((n) => n + 1, true);
return () => {
fileIconProviders = fileIconProviders.filter((p) => p !== resolve);
useFileIconRevision.setState((n) => n + 1, true);
};
}
/** Picks a file-type icon from a file name. */
export function fileIconName(fileName: string): IconName {
const custom = iconOverride?.(fileName);
if (custom) return custom;
for (const provide of fileIconProviders) {
const icon = provide(fileName);
if (icon && iconPath(icon)) return icon as IconName;
}
const themed = fileIconTheme?.file(fileName);
if (themed) return themed as IconName;
const lower = fileName.toLowerCase();