дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
78e0c7a23e
коміт
900f2e6819
55 files changed
+6237
-135
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}/`))
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
@@ -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}" +
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Заблокувати користувача