дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)
A plugin system built on WebAssembly components (wasmtime, WASI p2), with a typed WIT API (`openidea:[email protected]`) and a Rust SDK, shipped as a preview. - Extension points declared in `plugin.toml` (commands, languages, themes, icon themes, menus, key bindings, status items, settings, views, custom editors, file icons and templates, run detectors, editor providers), validated before anything takes effect and projected onto the workbench's own registries: plugin themes and file icon themes are chosen in Settings › Appearance and Quick Switch, plugin shortcuts are edited in Settings › Keymap, plugin problems are grouped in Settings › Inspections. - Capabilities with risk levels and consent at install, pinned `process:` programs, secrets in the OS keychain. - Runtime: one thread per active plugin, fuel/time/memory budgets, soft and hard deadlines on the editing path, cancellation, crash backoff, idle reclaim, a precompiled-component cache; remote projects run code plugins on `openidea-server`. - Web views and custom editors with a sandboxed page, a document bridge joined to the text editor's undo history, and split or preview layouts. - Distribution: signed `.oiplugin` packages, a static marketplace index with revocation, versioned installs with rollback, install from a `.zip` or a folder. The release workflow signs and publishes the official plugins and their index, which released builds use as the default marketplace. - Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion, Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor Night, Excalidraw, Graphviz, Regex Tester, License Header. Closes #732 Co-authored-by: Deng Liu <[email protected]> Co-authored-by: florentvdry <[email protected]>
Цей коміт міститься в:
450 files changed
+47018
-3058
No files matched your search
@@ -0,0 +1,211 @@
|
||||
.view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.textButton {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.textButton:hover:not(:disabled) {
|
||||
background: var(--bg-toolbar-hover);
|
||||
}
|
||||
|
||||
.textButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 2px 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--fg-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 4px 8px;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.listItem:hover,
|
||||
.listItem:focus-visible {
|
||||
background: var(--bg-toolbar-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.detail {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 80px;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono, monospace);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.tableScroller {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tableCanvas {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: flex;
|
||||
height: 24px;
|
||||
min-width: 100%;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tableRow[aria-selected="true"] {
|
||||
background: var(--bg-tree-selected);
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-island);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rowNumber {
|
||||
flex: 0 0 48px;
|
||||
padding: 0 6px;
|
||||
text-align: right;
|
||||
color: var(--fg-muted);
|
||||
line-height: 24px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cell {
|
||||
flex: 0 0 160px;
|
||||
padding: 0 6px;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: 32px 12px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.frame {
|
||||
border: none;
|
||||
width: 100%;
|
||||
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,506 @@
|
||||
/**
|
||||
* Renders a plugin's declarative view: the
|
||||
* plugin returns data (`View`), this draws it with the workbench's own
|
||||
* components and sends the user's actions back (`ViewEvent`). Used by plugin
|
||||
* tool windows and custom editors. A refresh comes from the plugin
|
||||
* (`viewChanged`), from `revision` (a custom editor's document changed), or
|
||||
* from the table scrolling to rows it hasn't got.
|
||||
*/
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import {
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
Tree,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { pluginsBackend } from "../backend";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
|
||||
import { onViewMessage, useViewRevision } from "./viewStore";
|
||||
import styles from "./PluginView.module.css";
|
||||
|
||||
export interface PluginViewProps {
|
||||
pluginId: string;
|
||||
view: string;
|
||||
/** The document of a custom editor. */
|
||||
doc?: number | null;
|
||||
/** Bumped by the owner when the view must render again (document edits). */
|
||||
revision?: number;
|
||||
/** Called after an event was handled (custom editors re-render on the document instead). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Table rows asked for beyond the visible ones (each side). */
|
||||
const OVERSCAN = 50;
|
||||
const ROW_HEIGHT = 24;
|
||||
|
||||
export function PluginView({ pluginId, view, doc = null, revision = 0, label }: PluginViewProps) {
|
||||
const [content, setContent] = useState<View | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewport, setViewport] = useState<Viewport | null>(null);
|
||||
const pluginRevision = useViewRevision(pluginId, view);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const mine = ++seq.current;
|
||||
pluginsBackend()
|
||||
.renderView(pluginId, view, viewport, doc)
|
||||
.then(
|
||||
(v) => {
|
||||
if (mine !== seq.current) return;
|
||||
setContent(v);
|
||||
setError(null);
|
||||
},
|
||||
(e: unknown) => {
|
||||
if (mine !== seq.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
},
|
||||
);
|
||||
}, [pluginId, view, doc, viewport, revision, pluginRevision]);
|
||||
|
||||
const send = useCallback(
|
||||
(event: ViewEvent) => {
|
||||
pluginsBackend()
|
||||
.viewEvent(pluginId, view, event, doc)
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
|
||||
},
|
||||
[pluginId, view, doc],
|
||||
);
|
||||
|
||||
if (!content)
|
||||
return error ? (
|
||||
<EmptyState className={styles.empty} title={tx("The plugin could not show this view")}>
|
||||
{error}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
<Spinner label={tx("Loading")} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.view} aria-label={label}>
|
||||
{content.toolbar.length > 0 && (
|
||||
<div className={styles.toolbar} role="toolbar">
|
||||
{content.toolbar.map((b) =>
|
||||
b.icon ? (
|
||||
<IconButton
|
||||
key={b.id}
|
||||
icon={b.icon}
|
||||
title={b.label}
|
||||
disabled={!b.enabled}
|
||||
onClick={() => send({ kind: "button", id: b.id })}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
className={styles.textButton}
|
||||
disabled={!b.enabled}
|
||||
onClick={() => send({ kind: "button", id: b.id })}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<Body content={content} send={send} pluginId={pluginId} view={view} onViewport={setViewport} />
|
||||
</div>
|
||||
{content.status && <div className={styles.status}>{content.status}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BodyProps {
|
||||
content: View;
|
||||
send: (e: ViewEvent) => void;
|
||||
pluginId: string;
|
||||
view: string;
|
||||
onViewport: (v: Viewport) => void;
|
||||
}
|
||||
|
||||
function Body({ content, send, pluginId, view, onViewport }: BodyProps) {
|
||||
const body = content.body;
|
||||
switch (body.kind) {
|
||||
case "message":
|
||||
return <EmptyState className={styles.empty} title={body.value} />;
|
||||
case "tree":
|
||||
return <TreeBody items={body.value} send={send} />;
|
||||
case "list":
|
||||
return (
|
||||
<ul className={styles.list} role="listbox">
|
||||
{body.value.map((i) => (
|
||||
<li
|
||||
key={i.key}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
tabIndex={0}
|
||||
className={styles.listItem}
|
||||
onClick={() => send({ kind: "select", key: i.key })}
|
||||
onDoubleClick={() => send({ kind: "activate", key: i.key })}
|
||||
onKeyDown={(e) => e.key === "Enter" && send({ kind: "activate", key: i.key })}
|
||||
>
|
||||
{i.icon && <Icon name={i.icon} size={14} />}
|
||||
<span>{i.label}</span>
|
||||
{i.detail && <span className={styles.detail}>{i.detail}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case "form":
|
||||
return (
|
||||
<div className={styles.form}>
|
||||
{body.value.map((f) => (
|
||||
<FormField key={f.key} field={f} send={send} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "table":
|
||||
return <TableBody table={body.value} send={send} onViewport={onViewport} />;
|
||||
case "html":
|
||||
return <HtmlBody html={body.value} send={send} pluginId={pluginId} view={view} />;
|
||||
}
|
||||
}
|
||||
|
||||
// --- tree -------------------------------------------------------------------------
|
||||
|
||||
interface Node {
|
||||
item: ViewTreeItem;
|
||||
children: Node[];
|
||||
}
|
||||
|
||||
/** The flat item list as nodes (items naming an unknown parent are roots). */
|
||||
export function treeNodes(items: readonly ViewTreeItem[]): Node[] {
|
||||
const byKey = new Map<string, Node>();
|
||||
const roots: Node[] = [];
|
||||
for (const item of items) {
|
||||
const node: Node = { item, children: [] };
|
||||
if (!byKey.has(item.key)) byKey.set(item.key, node);
|
||||
const parent = item.parent != null ? byKey.get(item.parent) : undefined;
|
||||
(parent ? parent.children : roots).push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function TreeBody({ items, send }: { items: ViewTreeItem[]; send: (e: ViewEvent) => void }) {
|
||||
const roots = useMemo(() => treeNodes(items), [items]);
|
||||
const expanded = useMemo(() => new Set(items.filter((i) => i.expanded).map((i) => i.key)), [items]);
|
||||
return (
|
||||
<Tree<Node>
|
||||
roots={roots}
|
||||
getKey={(n) => n.item.key}
|
||||
getLabel={(n) => n.item.label}
|
||||
getChildren={(n) => (n.children.length ? n.children : n.item.expandable ? undefined : [])}
|
||||
isExpandable={(n) => n.item.expandable || n.children.length > 0}
|
||||
getIcon={(n) => n.item.icon ?? undefined}
|
||||
getDetail={(n) => n.item.detail ?? undefined}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(_, node, isExpanded) =>
|
||||
send(isExpanded ? { kind: "expand", key: node.item.key } : { kind: "collapse", key: node.item.key })
|
||||
}
|
||||
onSelectionChange={(_, lead) => lead && send({ kind: "select", key: lead.item.key })}
|
||||
onActivate={(n) => send({ kind: "activate", key: n.item.key })}
|
||||
multiSelect={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- form -------------------------------------------------------------------------
|
||||
|
||||
function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) => void }) {
|
||||
const commit = (value: string) => send({ kind: "field", key: field.key, value });
|
||||
const v = field.value;
|
||||
const id = `plugin-field-${field.key}`;
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
{v.kind !== "checkbox" && (
|
||||
<label className={styles.fieldLabel} htmlFor={id}>
|
||||
{field.label}
|
||||
</label>
|
||||
)}
|
||||
{v.kind === "text" && <TextField id={id} initial={v.value} onCommit={commit} />}
|
||||
{v.kind === "multiline" && <TextField id={id} initial={v.value} onCommit={commit} multiline />}
|
||||
{v.kind === "checkbox" && (
|
||||
<Checkbox checked={v.value} label={field.label} onChange={(on) => commit(on ? "true" : "false")} />
|
||||
)}
|
||||
{v.kind === "choice" && (
|
||||
<Select
|
||||
aria-label={field.label}
|
||||
value={v.value.selected}
|
||||
options={v.value.options.map((o) => ({ value: o, label: o }))}
|
||||
onChange={commit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({
|
||||
id,
|
||||
initial,
|
||||
onCommit,
|
||||
multiline,
|
||||
}: {
|
||||
id: string;
|
||||
initial: string;
|
||||
onCommit: (v: string) => void;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
useEffect(() => setValue(initial), [initial]);
|
||||
const commit = () => value !== initial && onCommit(value);
|
||||
return multiline ? (
|
||||
<textarea
|
||||
id={id}
|
||||
className={styles.textarea}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- table ------------------------------------------------------------------------
|
||||
|
||||
/** The rows to ask for when `first..first + visible` is shown. */
|
||||
export function wantedViewport(first: number, visible: number, total: number): Viewport {
|
||||
const start = Math.max(0, first - OVERSCAN);
|
||||
const end = Math.min(total, first + visible + OVERSCAN);
|
||||
return { firstRow: start, rows: Math.max(0, end - start) };
|
||||
}
|
||||
|
||||
function TableBody({
|
||||
table,
|
||||
send,
|
||||
onViewport,
|
||||
}: {
|
||||
table: ViewTable;
|
||||
send: (e: ViewEvent) => void;
|
||||
onViewport: (v: Viewport) => void;
|
||||
}) {
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [height, setHeight] = useState(400);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [editing, setEditing] = useState<{ row: number; column: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setHeight(el.clientHeight || 400));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const first = Math.floor(scrollTop / ROW_HEIGHT);
|
||||
const visible = Math.ceil(height / ROW_HEIGHT) + 1;
|
||||
const loadedEnd = table.firstRow + table.rows.length;
|
||||
const shownEnd = Math.min(table.totalRows, first + visible);
|
||||
// Ask for rows the scroll position shows but the last render didn't include.
|
||||
useEffect(() => {
|
||||
if (first < table.firstRow || shownEnd > loadedEnd) onViewport(wantedViewport(first, visible, table.totalRows));
|
||||
}, [first, visible, shownEnd, loadedEnd, table.firstRow, table.totalRows, onViewport]);
|
||||
|
||||
const rowMenu = (row: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const rows = selected.has(row) ? [...selected].sort((a, b) => a - b) : [row];
|
||||
if (!selected.has(row)) setSelected(new Set([row]));
|
||||
const items: MenuItem[] = [
|
||||
{ label: tx("Insert Row Above"), icon: "plus", run: () => send({ kind: "insertRow", before: row }) },
|
||||
{ label: tx("Insert Row Below"), run: () => send({ kind: "insertRow", before: row + 1 }) },
|
||||
{ kind: "separator" },
|
||||
{
|
||||
label: rows.length > 1 ? tx`Delete ${rows.length} Rows` : tx("Delete Row"),
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
run: () => send({ kind: "deleteRows", rows }),
|
||||
},
|
||||
];
|
||||
if (table.editable) showContextMenu(e, items);
|
||||
};
|
||||
|
||||
const rows: React.ReactNode[] = [];
|
||||
for (let r = Math.max(first, table.firstRow); r < Math.min(shownEnd, loadedEnd); r++) {
|
||||
const cells = table.rows[r - table.firstRow] ?? [];
|
||||
rows.push(
|
||||
<div
|
||||
key={r}
|
||||
role="row"
|
||||
aria-rowindex={r + 2}
|
||||
aria-selected={selected.has(r)}
|
||||
className={styles.tableRow}
|
||||
style={{ top: (r + 1) * ROW_HEIGHT }}
|
||||
onClick={(e) =>
|
||||
setSelected((s) => {
|
||||
if (!(e.metaKey || e.ctrlKey)) return new Set([r]);
|
||||
const next = new Set(s);
|
||||
if (next.has(r)) next.delete(r);
|
||||
else next.add(r);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onContextMenu={(e) => rowMenu(r, e)}
|
||||
>
|
||||
<div className={styles.rowNumber} role="rowheader">
|
||||
{r + 1}
|
||||
</div>
|
||||
{table.columns.map((_, c) => (
|
||||
<div
|
||||
key={c}
|
||||
role="gridcell"
|
||||
className={styles.cell}
|
||||
title={cells[c]}
|
||||
onDoubleClick={() => table.editable && setEditing({ row: r, column: c })}
|
||||
>
|
||||
{editing?.row === r && editing.column === c ? (
|
||||
<CellEditor
|
||||
initial={cells[c] ?? ""}
|
||||
onDone={(value) => {
|
||||
setEditing(null);
|
||||
if (value !== null && value !== (cells[c] ?? "")) send({ kind: "cell", row: r, column: c, value });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
cells[c]
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scroller}
|
||||
className={styles.tableScroller}
|
||||
role="grid"
|
||||
aria-rowcount={table.totalRows + 1}
|
||||
aria-colcount={table.columns.length}
|
||||
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||
>
|
||||
<div className={styles.tableCanvas} style={{ height: (table.totalRows + 1) * ROW_HEIGHT }}>
|
||||
<div className={`${styles.tableRow} ${styles.tableHeader}`} role="row" aria-rowindex={1}>
|
||||
<div className={styles.rowNumber} />
|
||||
{table.columns.map((c, i) => (
|
||||
<div key={i} role="columnheader" className={styles.cell} title={c}>
|
||||
{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rows}
|
||||
</div>
|
||||
{table.totalRows === 0 && <div className={styles.tableEmpty}>{tx("No rows")}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CellEditor({ initial, onDone }: { initial: string; onDone: (value: string | null) => void }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
const done = useRef(false);
|
||||
const finish = (v: string | null) => {
|
||||
if (done.current) return;
|
||||
done.current = true;
|
||||
onDone(v);
|
||||
};
|
||||
const onKeyDown = (e: ReactKeyboardEvent) => {
|
||||
if (e.key === "Enter") finish(value);
|
||||
else if (e.key === "Escape") finish(null);
|
||||
};
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
variant="bare"
|
||||
aria-label={tx("Cell value")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => finish(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- html -------------------------------------------------------------------------
|
||||
|
||||
function HtmlBody({
|
||||
html,
|
||||
send,
|
||||
pluginId,
|
||||
view,
|
||||
}: {
|
||||
html: string;
|
||||
send: (e: ViewEvent) => void;
|
||||
pluginId: string;
|
||||
view: string;
|
||||
}) {
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
// The theme at load time is in the document; later changes are posted, so a
|
||||
// theme switch doesn't reload the frame and lose its state.
|
||||
const srcDoc = useMemo(() => frameDocument(html, frameTheme()), [html]);
|
||||
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
|
||||
const postTheme = useCallback(
|
||||
() => frame.current?.contentWindow?.postMessage(frameThemeMessage(frameTheme()), "*"),
|
||||
[],
|
||||
);
|
||||
useEffect(postTheme, [theme, postTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.source !== frame.current?.contentWindow) return;
|
||||
const data = e.data as { tag?: unknown; text?: unknown } | null;
|
||||
if (data?.tag === FRAME_TAG && typeof data.text === "string") send({ kind: "message", text: data.text });
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
const off = onViewMessage(pluginId, view, (text) =>
|
||||
frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, text }, "*"),
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
off();
|
||||
};
|
||||
}, [pluginId, view, send]);
|
||||
|
||||
// No `allow-same-origin`: an opaque origin without access to the IDE.
|
||||
// `onLoad` re-sends the theme in case it changed between building `srcDoc` and the frame loading.
|
||||
return (
|
||||
<iframe
|
||||
ref={frame}
|
||||
className={styles.frame}
|
||||
title={view}
|
||||
sandbox="allow-scripts"
|
||||
srcDoc={srcDoc}
|
||||
onLoad={postTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Plugin custom editors (`[[contributes.customEditors]]`): a file matching
|
||||
* the declared patterns opens in a tab whose content the plugin renders from
|
||||
* the file's document. The tab joins the document shared by every editor of
|
||||
* the file (`attachDocument`), so:
|
||||
*
|
||||
* - the plugin edits the document (versioned `apply-edits`), and a text tab
|
||||
* of the same file shows the change at once — and the other way round;
|
||||
* - Undo / Redo work on the document's one history: the text tab's when one
|
||||
* is open, else this tab's own;
|
||||
* - unsaved changes, Save, and the close prompts are the document's.
|
||||
*/
|
||||
import { history } from "@codemirror/commands";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { attachDocument, type DocumentAttachment } from "@/editor/api";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { EmptyState, Spinner } from "@/ui";
|
||||
import {
|
||||
editorService,
|
||||
notifyEditorDocumentChanged,
|
||||
type EditorDocumentLifecycle,
|
||||
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";
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
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, web: !!decl.web };
|
||||
return { kind: CUSTOM_EDITOR_KIND, key: `${CUSTOM_EDITOR_KIND}:${pluginId}/${decl.id}:${path}`, path, data };
|
||||
}
|
||||
|
||||
/** File name glob (`*`, `?`, `[abc]`, `{a,b}`), case-insensitive. */
|
||||
export function matchesPattern(pattern: string, name: string): boolean {
|
||||
let re = "";
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
const c = pattern[i]!;
|
||||
if (c === "*") re += "[^/]*";
|
||||
else if (c === "?") re += "[^/]";
|
||||
else if (c === "{") {
|
||||
const end = pattern.indexOf("}", i);
|
||||
if (end < 0) re += "\\{";
|
||||
else {
|
||||
re += `(?:${pattern
|
||||
.slice(i + 1, end)
|
||||
.split(",")
|
||||
.map((p) => p.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*"))
|
||||
.join("|")})`;
|
||||
i = end;
|
||||
}
|
||||
} else if (c === "[") {
|
||||
const end = pattern.indexOf("]", i);
|
||||
if (end < 0) re += "\\[";
|
||||
else {
|
||||
re += pattern.slice(i, end + 1);
|
||||
i = end;
|
||||
}
|
||||
} else re += c.replace(/[.+^$()|\\]/g, "\\$&");
|
||||
}
|
||||
return new RegExp(`^${re}$`, "i").test(name);
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path;
|
||||
}
|
||||
|
||||
// --- sessions: one per document shown by a custom editor (splits share it) -----------
|
||||
|
||||
interface Session {
|
||||
attachment: DocumentAttachment;
|
||||
/** Holds this tab's undo history and receives the document's changes. */
|
||||
view: EditorView;
|
||||
revision: number;
|
||||
listeners: Set<(revision: number) => void>;
|
||||
/** The document as web frames see it. */
|
||||
frameDocument: FrameDocument;
|
||||
cancelTimer: () => void;
|
||||
}
|
||||
|
||||
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(key: string, path: string): Promise<Session> {
|
||||
let s = sessions.get(key);
|
||||
if (!s) {
|
||||
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;
|
||||
// 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;
|
||||
void attachment.synced().then(() => {
|
||||
state.revision++;
|
||||
for (const l of listeners) l(state.revision);
|
||||
});
|
||||
}, RENDER_DELAY_MS);
|
||||
};
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: attachment.text(),
|
||||
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(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;
|
||||
}
|
||||
|
||||
const settled = new Map<string, Session>();
|
||||
|
||||
/** Lifecycle of the tabs' document (dirty mark, Save, close prompts, Undo). */
|
||||
export const customEditorDocument: EditorDocumentLifecycle = {
|
||||
isDirty: (input) => settled.get(input.key)?.attachment.dirty() ?? false,
|
||||
flush: async (input) => {
|
||||
await withSession(input, (s) => s.attachment.synced());
|
||||
},
|
||||
save: async (input) => {
|
||||
await withSession(input, (s) => s.attachment.save());
|
||||
},
|
||||
prepareClose: async (input) => {
|
||||
await withSession(input, (s) => s.attachment.synced());
|
||||
},
|
||||
cancelClose: () => {},
|
||||
discard: async () => {
|
||||
// The document's unsaved changes go when it closes (`close` releases it).
|
||||
},
|
||||
close: (input) => closeSession(input.key),
|
||||
undo: async (input) => {
|
||||
await withSession(input, (s) => void s.attachment.undo());
|
||||
},
|
||||
redo: async (input) => {
|
||||
await withSession(input, (s) => void s.attachment.redo());
|
||||
},
|
||||
};
|
||||
|
||||
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(key, path).then(
|
||||
(s) => {
|
||||
if (!live) return;
|
||||
settled.set(key, s);
|
||||
const listener = (revision: number) => setState({ session: s, revision });
|
||||
s.listeners.add(listener);
|
||||
off = () => s.listeners.delete(listener);
|
||||
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);
|
||||
};
|
||||
}, [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 (
|
||||
<div className={styles.empty}>
|
||||
<Spinner label={tx("Loading")} />
|
||||
</div>
|
||||
);
|
||||
if ("error" in state)
|
||||
return (
|
||||
<EmptyState className={styles.empty} title={tx`Cannot open ${basename(input.path ?? "")}`}>
|
||||
{state.error}
|
||||
</EmptyState>
|
||||
);
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<SessionBody
|
||||
state={state}
|
||||
pluginId={data.pluginId}
|
||||
editorId={data.editorId}
|
||||
web={!!data.web}
|
||||
label={tx`${data.title}: ${basename(input.path ?? "")}`}
|
||||
path={input.path ?? ""}
|
||||
/>
|
||||
<div className={styles.status}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.textButton}
|
||||
onClick={() => input.path && void editorService().openFile(input.path, { asText: true })}
|
||||
>
|
||||
{tx("Open as Text")}
|
||||
</button>
|
||||
</div>
|
||||
</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 }),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The document of a view's `html` body. It runs in an `<iframe sandbox=
|
||||
* "allow-scripts">` (an opaque origin: no cookies, storage, IDE API or parent
|
||||
* access) under a CSP that allows only its own inline scripts and styles and
|
||||
* `data:` images — no network. `openidea.postMessage` / `openidea.onMessage`
|
||||
* are its only way to the plugin.
|
||||
*
|
||||
* The IDE theme reaches the frame as `data-theme="light|dark"` on its root and
|
||||
* the `--openidea-*` variables of {@link FRAME_THEME_TOKENS}, kept current
|
||||
* without reloading the frame; `openidea.theme` / `openidea.onThemeChange` give
|
||||
* 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:";
|
||||
|
||||
/** Marks frame ↔ workbench messages. */
|
||||
export const FRAME_TAG = "openidea-view";
|
||||
|
||||
/**
|
||||
* The theme variables a frame gets: public name → IDE token. Part of the
|
||||
* plugin API: names are only ever added, so the IDE's own tokens can change.
|
||||
*/
|
||||
export const FRAME_THEME_TOKENS = {
|
||||
"--openidea-bg": "--bg-island",
|
||||
"--openidea-bg-elevated": "--bg-elev",
|
||||
"--openidea-bg-hover": "--bg-hover",
|
||||
"--openidea-bg-selected": "--bg-tree-selected",
|
||||
"--openidea-bg-input": "--bg-input",
|
||||
"--openidea-fg": "--fg",
|
||||
"--openidea-fg-strong": "--fg-strong",
|
||||
"--openidea-fg-muted": "--fg-muted",
|
||||
"--openidea-border": "--border",
|
||||
"--openidea-accent": "--accent",
|
||||
"--openidea-fg-on-accent": "--fg-on-accent",
|
||||
"--openidea-link": "--link",
|
||||
"--openidea-focus-ring": "--focus-ring",
|
||||
"--openidea-danger": "--danger",
|
||||
"--openidea-warning": "--warning",
|
||||
"--openidea-success": "--success",
|
||||
"--openidea-info": "--info",
|
||||
} as const;
|
||||
|
||||
/** Non-colour variables: public name → IDE token. */
|
||||
const FRAME_FONT_TOKENS = {
|
||||
"--openidea-font-ui": "--font-ui",
|
||||
"--openidea-font-mono": "--font-mono",
|
||||
"--openidea-font-size": "--fs-ui",
|
||||
} as const;
|
||||
|
||||
export interface FrameTheme {
|
||||
dark: boolean;
|
||||
/** `--openidea-*` → a concrete value (colours resolved, no `var()`: the frame has no IDE stylesheet). */
|
||||
vars: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Font stacks and sizes only; anything that could leave its declaration is dropped. */
|
||||
const safeValue = (v: string) => v.length <= 400 && !/[<>{};\\]/.test(v);
|
||||
|
||||
/** The IDE theme as a frame sees it, read from the root element's tokens. */
|
||||
export function frameTheme(root: HTMLElement = document.documentElement): FrameTheme {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const [name, token] of Object.entries(FRAME_THEME_TOKENS)) {
|
||||
const c = resolveTokenColor(token, root);
|
||||
if (c) vars[name] = toRgb(c);
|
||||
}
|
||||
if (typeof getComputedStyle !== "undefined") {
|
||||
const css = getComputedStyle(root);
|
||||
for (const [name, token] of Object.entries(FRAME_FONT_TOKENS)) {
|
||||
const v = css.getPropertyValue(token).trim();
|
||||
if (v && safeValue(v)) vars[name] = v;
|
||||
}
|
||||
}
|
||||
return { dark: root.dataset.theme !== "light", vars };
|
||||
}
|
||||
|
||||
/** The message that updates a loaded frame's theme. */
|
||||
export const frameThemeMessage = (theme: FrameTheme) => ({ tag: FRAME_TAG, theme });
|
||||
|
||||
/** `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}" +
|
||||
"body{margin:8px;color:var(--openidea-fg);font-family:var(--openidea-font-ui,system-ui,sans-serif);" +
|
||||
"font-size:var(--openidea-font-size,13px)}a{color:var(--openidea-link)}";
|
||||
|
||||
/** `html` wrapped with the CSP, the message bridge, the theme and base styles. */
|
||||
export function frameDocument(html: string, theme: FrameTheme): string {
|
||||
return [
|
||||
`<!doctype html><html data-theme="${theme.dark ? "dark" : "light"}"><head><meta charset="utf-8">`,
|
||||
`<meta http-equiv="Content-Security-Policy" content="${FRAME_CSP}">`,
|
||||
`<style>${BASE_STYLE}</style>`,
|
||||
`<script>${bridge(theme)}</script>`,
|
||||
"</head><body>",
|
||||
html,
|
||||
"</body></html>",
|
||||
].join("");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Client state of plugin views: a revision per (plugin, view) that bumps when
|
||||
* the plugin asks for a refresh (`viewChanged`), and the messages its code
|
||||
* sends to the script of an `html` body (`viewMessage`).
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
|
||||
export const viewKey = (pluginId: string, view: string) => `${pluginId}/${view}`;
|
||||
|
||||
export const useViewRevisions = create<{ revisions: Record<string, number> }>(() => ({ revisions: {} }));
|
||||
|
||||
/** The plugin's view content changed: rendered views fetch it again. */
|
||||
export function bumpView(pluginId: string, view: string) {
|
||||
const key = viewKey(pluginId, view);
|
||||
useViewRevisions.setState((s) => ({ revisions: { ...s.revisions, [key]: (s.revisions[key] ?? 0) + 1 } }));
|
||||
}
|
||||
|
||||
export function useViewRevision(pluginId: string, view: string): number {
|
||||
return useViewRevisions((s) => s.revisions[viewKey(pluginId, view)] ?? 0);
|
||||
}
|
||||
|
||||
type MessageListener = (message: string) => void;
|
||||
const messageListeners = new Map<string, Set<MessageListener>>();
|
||||
|
||||
/** Delivers a plugin's message to the frames of its view. */
|
||||
export function deliverViewMessage(pluginId: string, view: string, message: string) {
|
||||
for (const l of messageListeners.get(viewKey(pluginId, view)) ?? []) l(message);
|
||||
}
|
||||
|
||||
export function onViewMessage(pluginId: string, view: string, listener: MessageListener): () => void {
|
||||
const key = viewKey(pluginId, view);
|
||||
const set = messageListeners.get(key) ?? new Set();
|
||||
set.add(listener);
|
||||
messageListeners.set(key, set);
|
||||
return () => {
|
||||
set.delete(listener);
|
||||
if (!set.size) messageListeners.delete(key);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача