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 <5108773+dengliu@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-11-04 02:41:46 +08:00
co-authored by Deng Liu
parent 78e0c7a23e
commit 900f2e6819
55 changed files with 6237 additions and 135 deletions
+19 -1
View File
@@ -100,8 +100,26 @@ jobs:
cargo test --locked --manifest-path "$dir/Cargo.toml"
echo "::endgroup::"
done
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22 }
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
- name: Web pages typecheck and test
shell: bash
run: |
set -euo pipefail
for dir in plugins/*/web-src/ plugins/official/*/web-src/; do
[ -f "$dir/package.json" ] || continue
echo "::group::$dir"
pnpm --dir "$dir" install --frozen-lockfile --ignore-workspace
pnpm --dir "$dir" exec tsc --noEmit
pnpm --dir "$dir" test
echo "::endgroup::"
done
# The host's tests run the checked-in components: they must be built from these sources.
- name: Checked-in components are up to date
# Web pages aren't checked in: this builds them and checks the result.
- name: Checked-in components are up to date, web pages build
run: plugins/build.sh --check
deny:
+2
View File
@@ -15,6 +15,8 @@
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
"plugins/*/web",
"plugins/official/*/web",
"crates/**/tests/fixtures"
],
"overrides": [
Generated
+2
View File
@@ -6181,6 +6181,7 @@ dependencies = [
name = "openidea"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"blake3",
"block2 0.6.2",
"dirs 6.0.0",
@@ -6227,6 +6228,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-web-kit 0.3.2",
"parking_lot",
"percent-encoding",
"security-framework",
"serde",
"serde_json",
+10 -1
View File
@@ -28,6 +28,8 @@ pub enum Capability {
Events,
/// Keystrokes and editor input events.
InputEvents,
/// The system clipboard, from the plugin's web views.
Clipboard,
/// HTTP(S) to one host (`*.example.com` matches subdomains).
Net(String),
/// Run one external program. Full code execution: never fine-grained.
@@ -52,7 +54,9 @@ impl Capability {
pub fn risk(&self) -> Risk {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents => Risk::Medium,
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
Risk::Medium
}
Self::Process(_) | Self::FsRead(_) => Risk::High,
}
}
@@ -75,6 +79,7 @@ impl Capability {
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Clipboard => "Read and write the clipboard from its web views".into(),
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
@@ -115,6 +120,7 @@ impl fmt::Display for Capability {
Self::Secrets => f.write_str("secrets"),
Self::Events => f.write_str("events"),
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
@@ -169,6 +175,7 @@ impl FromStr for Capability {
"secrets" => Self::Secrets,
"events" => Self::Events,
"events.input" => Self::InputEvents,
"clipboard" => Self::Clipboard,
_ => return Err(format!("unknown capability {s:?}")),
})
}
@@ -241,6 +248,8 @@ mod tests {
assert_eq!(Capability::ProjectRead.risk(), Risk::Low);
assert_eq!(Capability::Net("x".into()).risk(), Risk::Medium);
assert_eq!(Capability::InputEvents.risk(), Risk::Medium);
assert_eq!("clipboard".parse::<Capability>().unwrap(), Capability::Clipboard);
assert_eq!(Capability::Clipboard.risk(), Risk::Medium);
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
}
+11 -6
View File
@@ -70,6 +70,16 @@ pub fn svg_prefix(theme: &str) -> String {
format!("iconTheme/{theme}/svg/")
}
/// An SVG icon of the package, size-checked and sanitised.
pub(super) fn read_icon(pkg: &Package, path: &std::path::Path) -> Result<String, String> {
check_relative(path, "icon")?;
let svg = pkg.read_text(path)?;
if svg.len() > MAX_SVG {
return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10));
}
sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display()))
}
pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: Decl = decode(POINT_ID, decl)?;
validate_item_id(&d.id, "icon theme id")?;
@@ -117,12 +127,7 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Str
let mut files = vec![d.path.clone()];
for (icon, path) in &theme.icon_definitions {
validate_item_id(icon, "icon id")?;
check_relative(path, "icon")?;
let svg = pkg.read_text(path)?;
if svg.len() > MAX_SVG {
return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10));
}
let clean = sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display()))?;
let clean = read_icon(pkg, path)?;
assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean));
files.push(path.clone());
}
+4 -1
View File
@@ -28,6 +28,7 @@ mod theme;
mod types;
pub mod ui_points;
pub mod views;
pub mod web;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix};
@@ -2057,8 +2058,10 @@ impl PluginHost {
fn info(&self, p: &LoadedPlugin) -> PluginInfo {
let m = p.manifest();
let meta = p.meta.lock();
// The ledger before a plugin's `meta`, as everywhere (`refresh_pins` holds the
// ledger and locks each plugin's): the other order deadlocks.
let grant = self.ledger.lock().get(&p.subject()).cloned();
let meta = p.meta.lock();
let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.confirmed && g.has(c));
let mut capabilities: Vec<CapabilityInfo> =
m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect();
+2
View File
@@ -65,5 +65,7 @@ pub fn register_builtin_points(
registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?;
registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?;
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
Ok(())
}
+141 -17
View File
@@ -32,6 +32,20 @@
//! title = "Table"
//! patterns = ["*.csv", "*.tsv"]
//! priority = "default" # or "option": only through Open With
//! layout = "tab" # or "split" / "preview": beside the text editor
//! web = "web/index.html" # a web page from the package instead of rendered content
//!
//! [[contributes.fileIcons]]
//! id = "drawing"
//! patterns = ["*.excalidraw"]
//! icon = "icons/drawing.svg"
//! iconDark = "icons/drawing_dark.svg"
//!
//! [[contributes.fileTemplates]]
//! id = "drawing"
//! name = "Excalidraw Drawing"
//! extension = "excalidraw"
//! path = "templates/drawing.excalidraw"
//! ```
use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when};
@@ -46,6 +60,8 @@ pub const STATUS_BAR: &str = "statusBar";
pub const SETTINGS: &str = "settings";
pub const VIEWS: &str = "views";
pub const CUSTOM_EDITORS: &str = "customEditors";
pub const FILE_ICONS: &str = "fileIcons";
pub const FILE_TEMPLATES: &str = "fileTemplates";
const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"];
const MAX_TITLE: usize = 256;
@@ -333,22 +349,36 @@ struct ViewDecl {
icon: Option<String>,
#[serde(default = "right")]
location: String,
/// A web page of the package (`web/index.html`) instead of content the code renders.
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
}
/// The files of a `web` entry, or a check that the code renders the view.
fn web_or_code(pkg: &Package, web: Option<&str>, what: &str) -> Result<Vec<PathBuf>, String> {
match web {
Some(entry) => super::web::web_files(pkg, entry, what),
None if pkg.manifest.wasm.is_none() => {
Err(format!("{what}: give a web page (`web`) or the code that renders it (`wasm`)"))
}
None => Ok(Vec::new()),
}
}
pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: ViewDecl = decode(VIEWS, decl)?;
validate_item_id(&d.id, "view id")?;
title(VIEWS, &d.title)?;
if pkg.manifest.wasm.is_none() {
return Err(format!("view {:?}: views are rendered by the plugin's code (`wasm`)", d.id));
}
let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?;
if !["left", "right", "bottom"].contains(&d.location.as_str()) {
return Err(format!("view {:?}: `location` is left, right or bottom", d.id));
}
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
}
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
Ok(out)
}
#[derive(Debug, Deserialize, Serialize)]
@@ -359,31 +389,125 @@ struct CustomEditorDecl {
patterns: Vec<String>,
#[serde(default = "default_priority")]
priority: String,
/// `tab`: the file opens in this editor's own tab. `split` / `preview`:
/// the file opens in the text editor with this editor beside it (Editor /
/// Editor and Preview / Preview); the value is the layout files start in.
#[serde(default = "default_layout")]
layout: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
web: Option<String>,
}
fn default_priority() -> String {
"default".into()
}
fn default_layout() -> String {
"tab".into()
}
/// File name globs (`*.csv`, `{a,b}.json`): no directories.
fn file_patterns(patterns: &[String], what: &str) -> Result<(), String> {
if patterns.is_empty() || patterns.len() > MAX_PATTERNS {
return Err(format!("{what}: 1–{MAX_PATTERNS} file name `patterns`"));
}
for p in patterns {
if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() {
return Err(format!("{what}: invalid file name pattern {p:?}"));
}
}
Ok(())
}
pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: CustomEditorDecl = decode(CUSTOM_EDITORS, decl)?;
validate_item_id(&d.id, "custom editor id")?;
title(CUSTOM_EDITORS, &d.title)?;
if pkg.manifest.wasm.is_none() {
return Err(format!("custom editor {:?}: needs the plugin's code (`wasm`)", d.id));
}
if d.patterns.is_empty() || d.patterns.len() > MAX_PATTERNS {
return Err(format!("custom editor {:?}: 1–{MAX_PATTERNS} file name `patterns`", d.id));
}
for p in &d.patterns {
if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() {
return Err(format!("custom editor {:?}: invalid file name pattern {p:?}", d.id));
}
}
let what = format!("custom editor {:?}", d.id);
let files = web_or_code(pkg, d.web.as_deref(), &what)?;
file_patterns(&d.patterns, &what)?;
if d.priority != "default" && d.priority != "option" {
return Err(format!("custom editor {:?}: `priority` is default or option", d.id));
return Err(format!("{what}: `priority` is default or option"));
}
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
if !["tab", "split", "preview"].contains(&d.layout.as_str()) {
return Err(format!("{what}: `layout` is tab, split or preview"));
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
icon_dark: Option<PathBuf>,
}
/// Asset id of a file icon's SVG (`dark`: its dark-theme variant).
pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
file_patterns(&d.patterns, &what)?;
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
let Some(path) = path else { continue };
let svg = super::icons::read_icon(pkg, path)?;
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
out.files = files;
out.assets = assets;
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileTemplateDecl {
id: String,
name: String,
/// Without the dot.
extension: String,
/// The new file's content (`$NAME$`, `$END$` and the other template variables work).
path: PathBuf,
}
const MAX_TEMPLATE: usize = 256 << 10;
/// `[[contributes.fileTemplates]]`: an entry of the New menu.
pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileTemplateDecl = decode(FILE_TEMPLATES, decl)?;
validate_item_id(&d.id, "file template id")?;
title(FILE_TEMPLATES, &d.name)?;
let what = format!("file template {:?}", d.id);
let ext = d.extension.trim_start_matches('.');
if ext.is_empty()
|| ext.len() > 32
|| !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
return Err(format!("{what}: `extension` is a file extension like \"md\" ({:?})", d.extension));
}
let body = pkg.read_text(&d.path)?;
if body.len() > MAX_TEMPLATE {
return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10));
}
let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body });
let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json);
out.files = vec![d.path.clone()];
Ok(out)
}
#[cfg(test)]
+245
View File
@@ -0,0 +1,245 @@
//! Web views: a view or custom editor whose UI is a web page shipped in the
//! package (`web = "web/index.html"`) instead of content the plugin's code
//! renders.
//!
//! The entry's directory is the page's root. Every file under it is part of
//! the declaration: it is checked when the package is prepared (count, size,
//! no links leaving the package), copied on install and covered by the
//! package digest and signature. The client loads the page in a sandboxed
//! frame (an opaque origin with no network and no IDE API) and fetches its
//! files through [`PluginHost::web_file`] (the desktop's `openidea-plugin`
//! URL scheme); the page talks to the IDE only through the frame bridge.
use super::PluginHost;
use ide_core::{IdeError, IdeResult};
use ide_extension::{Package, check_relative};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Component, Path, PathBuf};
/// Most files under one web root.
const MAX_FILES: usize = 4096;
/// Most bytes under one web root.
const MAX_BYTES: u64 = 256 << 20;
/// Deepest directory nesting under a web root.
const MAX_DEPTH: usize = 16;
/// Checks a declaration's `web` entry: an `.html` file inside a directory of
/// the package (the directory is what the page can load, so it can't be the
/// package root), and everything under that directory. Returns those files,
/// package-relative.
pub(super) fn web_files(pkg: &Package, entry: &str, what: &str) -> Result<Vec<PathBuf>, String> {
let path = Path::new(entry);
check_relative(path, "web")?;
if entry.contains('\\') || !entry.to_ascii_lowercase().ends_with(".html") {
return Err(format!("{what}: `web` names an .html file, `/`-separated ({entry:?})"));
}
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| {
format!("{what}: put the page in a directory of its own (`web/index.html`): the directory is what it can load")
})?;
let real_entry = pkg.resolve(path)?;
if !real_entry.is_file() {
return Err(format!("{what}: {entry} is not a file"));
}
let root = pkg.resolve(dir)?;
let mut files = Vec::new();
let mut total = 0u64;
let mut stack = vec![(root.clone(), dir.to_path_buf(), 0usize)];
while let Some((real, rel, depth)) = stack.pop() {
let mut entries: Vec<_> =
std::fs::read_dir(&real).map_err(|e| format!("{}: {e}", rel.display()))?.filter_map(Result::ok).collect();
entries.sort_by_key(|e| e.file_name());
for e in entries {
let name = e.file_name();
let rel = rel.join(&name);
// Links could point anywhere: only what is really in the package counts.
let ty = e.file_type().map_err(|err| format!("{}: {err}", rel.display()))?;
if ty.is_symlink() {
return Err(format!("{what}: {} is a link; web files must be real files", rel.display()));
}
if ty.is_dir() {
if depth + 1 > MAX_DEPTH {
return Err(format!("{what}: {} is nested deeper than {MAX_DEPTH} directories", rel.display()));
}
stack.push((e.path(), rel, depth + 1));
continue;
}
if name.to_string_lossy() == ".DS_Store" {
continue;
}
let len = e.metadata().map_err(|err| format!("{}: {err}", rel.display()))?.len();
if len > ide_extension::MAX_PACKAGE_FILE {
return Err(format!(
"{what}: {} is larger than {} MiB",
rel.display(),
ide_extension::MAX_PACKAGE_FILE >> 20
));
}
total += len;
files.push(rel);
if files.len() > MAX_FILES || total > MAX_BYTES {
return Err(format!(
"{what}: {} holds more than {MAX_FILES} files or {} MiB",
dir.display(),
MAX_BYTES >> 20
));
}
}
}
files.sort();
Ok(files)
}
/// The page of a web view, for the client to load in its frame.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebPage {
/// `<plugin id>/<revision>/<entry>` under the `openidea-plugin` scheme. The
/// revision changes with the installed package, so a cached file is never
/// stale; relative URLs in the page resolve to the package's files.
pub path: String,
}
/// A file of a web view.
pub struct WebFile {
pub bytes: Vec<u8>,
pub mime: &'static str,
}
impl PluginHost {
/// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated).
fn web_dirs(&self, plugin: &str) -> Vec<String> {
[super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
.iter()
.flat_map(|point| self.ui.items(point, Some(plugin)))
.filter_map(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned))
.filter_map(|entry| entry.rsplit_once('/').map(|(dir, _)| format!("{dir}/")))
.collect()
}
/// Changes whenever the installed package does (cache key of its web files).
fn web_revision(&self, plugin: &super::LoadedPlugin) -> String {
let digest = plugin.pkg.digest.as_deref().unwrap_or_default();
let short: String = digest.chars().filter(char::is_ascii_alphanumeric).take(16).collect();
if short.is_empty() { format!("v{}", plugin.pkg.manifest.version) } else { short }
}
/// The page of `plugin`'s view or custom editor `view`.
pub fn web_page(&self, plugin_id: &str, view: &str) -> IdeResult<WebPage> {
let plugin = self.get(plugin_id)?;
if !plugin.status().is_live() {
return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled")));
}
let entry = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
.iter()
.flat_map(|point| self.ui.items(point, Some(plugin_id)))
.find(|item| item.key == view)
.and_then(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned))
.ok_or_else(|| IdeError::not_found(format!("plugin {plugin_id:?} has no web view {view:?}")))?;
Ok(WebPage { path: format!("{plugin_id}/{}/{entry}", self.web_revision(&plugin)) })
}
/// A file of one of `plugin`'s web views: `path` is `<revision>/<package path>`
/// as [`WebPage::path`] makes them. Only files under a declared web root
/// of a live plugin are served; the revision must be the installed one.
pub fn web_file(&self, plugin_id: &str, path: &str) -> IdeResult<WebFile> {
let plugin = self.get(plugin_id)?;
if !plugin.status().is_live() {
return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled")));
}
let (revision, rel) = path.split_once('/').ok_or_else(|| IdeError::not_found(path.to_owned()))?;
if revision != self.web_revision(&plugin) {
return Err(IdeError::not_found(format!("{path}: not the installed version")));
}
let rel_path = Path::new(rel);
if !rel_path.components().all(|c| matches!(c, Component::Normal(_))) {
return Err(IdeError::invalid(format!("{rel}: not a plain relative path")));
}
if !self.web_dirs(plugin_id).iter().any(|dir| rel.starts_with(dir.as_str())) {
return Err(IdeError::not_found(format!("{rel}: not part of a web view")));
}
let bytes = plugin.pkg.read_bytes(rel_path).map_err(IdeError::not_found)?;
Ok(WebFile { bytes, mime: mime(rel) })
}
}
/// Content type by extension; unknown files are opaque bytes (never sniffed as script).
pub fn mime(path: &str) -> &'static str {
let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"js" | "mjs" | "cjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" | "map" => "application/json",
"wasm" => "application/wasm",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"woff2" => "font/woff2",
"woff" => "font/woff",
"ttf" => "font/ttf",
"otf" => "font/otf",
"txt" | "md" => "text/plain; charset=utf-8",
"mp3" => "audio/mpeg",
"mp4" => "video/mp4",
"webm" => "video/webm",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_extension::Manifest;
fn pkg(dir: &Path) -> Package {
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n";
Package::new(Manifest::parse(src).unwrap(), dir.to_path_buf())
}
#[test]
fn a_web_root_is_its_directory_and_nothing_else() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
std::fs::create_dir_all(d.join("web/assets/fonts")).unwrap();
std::fs::write(d.join("web/index.html"), "<p>hi</p>").unwrap();
std::fs::write(d.join("web/assets/app.js"), "1").unwrap();
std::fs::write(d.join("web/assets/fonts/a.woff2"), "f").unwrap();
std::fs::write(d.join("web/.DS_Store"), "x").unwrap();
std::fs::write(d.join("secret.txt"), "no").unwrap();
let files = web_files(&pkg(d), "web/index.html", "v").unwrap();
let files: Vec<String> = files.iter().map(|f| f.to_string_lossy().replace('\\', "/")).collect();
assert_eq!(files, ["web/assets/app.js", "web/assets/fonts/a.woff2", "web/index.html"]);
std::fs::write(d.join("index.html"), "x").unwrap();
let err = |entry: &str| web_files(&pkg(d), entry, "v").err().unwrap_or_default();
assert!(err("index.html").contains("directory of its own"), "{}", err("index.html"));
assert!(err("web/app.js").contains(".html"));
assert!(err("../web/index.html").contains("relative"));
assert!(err("web/missing.html").contains("no such file"));
}
#[cfg(unix)]
#[test]
fn links_out_of_a_web_root_are_refused() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
std::fs::create_dir_all(d.join("web")).unwrap();
std::fs::write(d.join("web/index.html"), "x").unwrap();
std::os::unix::fs::symlink("/etc/hosts", d.join("web/hosts")).unwrap();
assert!(web_files(&pkg(d), "web/index.html", "v").unwrap_err().contains("is a link"));
}
#[test]
fn content_types_never_make_unknown_files_scripts() {
assert_eq!(mime("a/b.JS"), "text/javascript; charset=utf-8");
assert_eq!(mime("x.woff2"), "font/woff2");
assert_eq!(mime("x.wasm"), "application/wasm");
assert_eq!(mime("x.bin"), "application/octet-stream");
assert_eq!(mime("noext"), "application/octet-stream");
}
}
+213
View File
@@ -1903,3 +1903,216 @@ fn official_kubernetes_browses_the_cluster_and_opens_logs() {
assert_eq!(program.0, bin.join("kubectl").canonicalize().unwrap());
assert_eq!(args, ["logs", "-f", "--tail=200", "-n", "default", "web-1"]);
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
/// templates are projected too.
#[test]
fn web_views_serve_only_their_own_files() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("web-demo");
for (path, text) in [
("web/index.html", "<!doctype html><html><head></head><body>draw</body></html>"),
("web/assets/app.js", "console.log(1)"),
("panel/index.html", "<p>panel</p>"),
(
"icons/draw.svg",
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##,
),
("templates/new.draw", "{\"type\": \"drawing\"}\n"),
("secret.txt", "not served"),
] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
r#"id = "web-demo"
name = "Web Demo"
version = "1.0.0"
engines.openidea = "^1.0"
[capabilities]
optional = ["clipboard"]
[[contributes.customEditors]]
id = "draw"
title = "Drawing"
patterns = ["*.draw"]
layout = "preview"
web = "web/index.html"
[[contributes.views]]
id = "panel"
title = "Panel"
web = "panel/index.html"
[[contributes.fileIcons]]
id = "draw"
patterns = ["*.draw"]
icon = "icons/draw.svg"
[[contributes.fileTemplates]]
id = "draw"
name = "Drawing"
extension = "draw"
path = "templates/new.draw"
"#,
)
.unwrap();
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
assert!(info.capabilities.iter().any(|c| c.id == "clipboard" && c.granted), "{:?}", info.capabilities);
// The pages' directories are installed with the plugin; files nothing declares are not.
let installed = f.installed("web-demo");
assert!(installed.join("web/assets/app.js").is_file() && installed.join("panel/index.html").is_file());
assert!(!installed.join("secret.txt").exists());
let page = f.host.web_page("web-demo", "draw").unwrap();
let (rev, entry) = page.path.strip_prefix("web-demo/").unwrap().split_once('/').unwrap();
assert_eq!(entry, "web/index.html");
let file = |path: &str| f.host.web_file("web-demo", path);
let js = file(&format!("{rev}/web/assets/app.js")).unwrap();
assert_eq!((js.bytes.as_slice(), js.mime), (b"console.log(1)".as_slice(), "text/javascript; charset=utf-8"));
assert!(file(&format!("{rev}/panel/index.html")).is_ok());
assert_eq!(f.host.web_page("web-demo", "panel").unwrap().path, format!("web-demo/{rev}/panel/index.html"));
for denied in [
format!("{rev}/secret.txt"),
format!("{rev}/plugin.toml"),
format!("{rev}/web/../secret.txt"),
format!("{rev}/icons/draw.svg"),
"stale-revision/web/index.html".to_owned(),
] {
assert!(file(&denied).is_err(), "{denied} was served");
}
assert!(f.host.web_page("web-demo", "nope").is_err());
// File icons and templates.
let icon = f.host.assets("web-demo", "fileIcon/draw/").unwrap();
assert_eq!(icon.len(), 1);
assert!(icon[0].0 == "fileIcon/draw/light" && icon[0].1.contains("<rect"));
let templates = f.host.ui_items("fileTemplates");
assert_eq!(templates[0].decl["body"], "{\"type\": \"drawing\"}\n");
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "preview");
// Disabled: nothing is served.
f.host.set_enabled("web-demo", false).unwrap();
assert!(file(&format!("{rev}/web/index.html")).is_err());
assert!(f.host.web_page("web-demo", "draw").is_err());
}
/// A view needs a page or code to render it; a page's directory can't be the package itself.
#[test]
fn web_view_declarations_are_checked() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("bad-web");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(pkg.join("index.html"), "x").unwrap();
let manifest = |decl: &str| {
std::fs::write(
pkg.join("plugin.toml"),
format!("id = \"bad-web\"\nname = \"B\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n{decl}"),
)
.unwrap();
f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err().message
};
let err = manifest("[[contributes.customEditors]]\nid = \"e\"\ntitle = \"E\"\npatterns = [\"*.x\"]\n");
assert!(err.contains("give a web page (`web`) or the code"), "{err}");
let err = manifest("[[contributes.views]]\nid = \"v\"\ntitle = \"V\"\nweb = \"index.html\"\n");
assert!(err.contains("directory of its own"), "{err}");
let err = manifest(
"[[contributes.customEditors]]\nid = \"e\"\ntitle = \"E\"\npatterns = [\"*.x\"]\nlayout = \"side\"\nweb = \"w/i.html\"\n",
);
assert!(err.contains("no such file") || err.contains("`layout`"), "{err}");
}
/// Listing plugins (each plugin's state, then its grants) while another
/// window's project changes (the grants, then each plugin's state) must not
/// deadlock: both take the grant ledger first.
#[test]
fn listing_while_projects_change_does_not_deadlock() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
f.install(&sample("hello"));
let host = Arc::new(f.host);
let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
let workers: Vec<_> = (0..4)
.map(|i| {
let (host, done) = (host.clone(), done.clone());
let root = f._tmp.path().join(format!("p{i}"));
std::thread::spawn(move || {
while !done.load(std::sync::atomic::Ordering::Relaxed) {
if i % 2 == 0 {
host.list(None);
} else {
host.restrict_project(&root);
}
}
})
})
.collect();
std::thread::sleep(Duration::from_millis(500));
done.store(true, std::sync::atomic::Ordering::Relaxed);
let started = Instant::now();
for w in workers {
while !w.is_finished() {
assert!(started.elapsed() < Duration::from_secs(10), "deadlocked");
std::thread::sleep(Duration::from_millis(10));
}
w.join().unwrap();
}
}
/// Excalidraw: a web custom editor beside the text editor, with its fonts,
/// file icon, template and language — no code.
#[test]
fn official_excalidraw_installs_and_serves_its_page() {
// Its page is built by plugins/build.sh, not checked in: the package's own
// files, with a stand-in for the build (what build.sh checks it produces).
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("excalidraw");
let src = official("excalidraw");
for entry in std::fs::read_dir(&src).unwrap().flatten() {
let name = entry.file_name();
if ["web", "web-src", "web.sources"].iter().any(|n| name == *n) {
continue;
}
let to = pkg.join(&name);
if entry.path().is_dir() {
copy_dir(&entry.path(), &to);
} else {
std::fs::create_dir_all(&pkg).unwrap();
std::fs::copy(entry.path(), to).unwrap();
}
}
std::fs::create_dir_all(pkg.join("web/assets")).unwrap();
std::fs::create_dir_all(pkg.join("web/fonts/Virgil")).unwrap();
std::fs::write(
pkg.join("web/index.html"),
r#"<!doctype html><html><head><script type="module" src="./assets/index.js"></script></head></html>"#,
)
.unwrap();
std::fs::write(pkg.join("web/assets/index.js"), "export {};").unwrap();
std::fs::write(pkg.join("web/fonts/Virgil/Virgil-Regular.woff2"), b"wOF2").unwrap();
let f = fixture(Limits::default());
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let page = f.host.web_page("openidea.excalidraw", "canvas").unwrap();
let (rev, entry) = page.path.strip_prefix("openidea.excalidraw/").unwrap().split_once('/').unwrap();
assert_eq!(entry, "web/index.html");
let html = f.host.web_file("openidea.excalidraw", &format!("{rev}/{entry}")).unwrap();
let html = String::from_utf8(html.bytes).unwrap();
// Relative URLs only: the page is served from the package.
let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap();
assert!(script.starts_with("./assets/"), "{script}");
assert!(f.host.web_file("openidea.excalidraw", &format!("{rev}/web/{}", &script[2..])).is_ok());
let font = f.host.web_file("openidea.excalidraw", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap();
assert_eq!(font.mime, "font/woff2");
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "preview");
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "excalidraw"));
assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2);
}
+67 -15
View File
@@ -98,21 +98,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
### Contribution points
| Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------- | ----------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`) | yes |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`) | yes |
| Point | Declaration | Code needed |
| --------------- | -------------------------------------------------------------------------------------------------- | ------------- |
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
Declared contributions are live as soon as the plugin is enabled — commands
show in Find Action, menus and the status bar, shortcuts work, themes and icon
@@ -162,6 +164,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
| `clipboard` | medium | the clipboard API in the plugin's web views (keyboard copy / paste work without it) |
`process:` capabilities are also derived from what the contributions start: a
language's `language-servers` and `debugger` commands and a run detector's
@@ -384,6 +387,12 @@ plugin's editor, or through _Open With_ for `option`. For a custom editor
Undo / Redo are one history with it, and Save, the dirty mark and the close
prompts are the document's. _Open as Text_ opens the text editor.
`layout` puts a custom editor beside the text editor instead of in a tab of
its own: with `"split"` or `"preview"` the file opens in the text editor,
with your editor next to it (the layout files start in) and the _Editor Only /
Editor and … / … Only_ switch in the tab strip, as for Markdown. `"tab"`
(the default) is the editor's own tab.
`Html` is the escape hatch for content the declarative bodies can't show. It
runs in a sandboxed frame — an opaque origin under a CSP that allows only its
inline scripts and styles and `data:` images, no network, no IDE API — and
@@ -407,6 +416,49 @@ Scripts get the same as `openidea.theme` (`{ dark, vars }`) and
`openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only
ever added.
#### Web views
A view or custom editor can instead be a web page of the package, for UIs the
declarative bodies can't express (a drawing canvas, a diagram editor) — no
code needed:
```toml
[[contributes.customEditors]]
id = "canvas"
title = "Drawing"
patterns = ["*.excalidraw"]
layout = "preview"
web = "web/index.html" # the page; its directory is what it can load
```
The page's directory (never the package root) is part of the declaration:
every file under it is checked (at most 4096 files and 256 MiB, no links),
installed and signed with the package. The IDE loads the page from its own
`openidea-plugin` URL scheme into an `<iframe sandbox="allow-scripts">`: an
opaque origin, no network, no cookies or storage, no IDE API, and only the
page's own files (relative URLs, ES modules, fonts, WebAssembly) — so build
it with relative paths (Vite: `base: "./"`). The same `openidea` object as
in `html` bodies is there before the page's scripts run, plus:
| `window.openidea` | What it does |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `document.get()` | `{ version, text, readOnly, name }` of the custom editor's document (`name`: the file's, without its directory; none in a tool window) |
| `document.edit(version, edits)` | Applies `[{ from, to, insert }]` (UTF-16 offsets) as one change of the document's undo history; rejects with `stale` when not at `version`; resolves `{ version }` |
| `document.replace(version, text)` | The same with the whole text (sent as the smallest edit) |
| `document.onChange(fn)` | `{ version, text }` after changes made elsewhere: the text editor, Undo, another window |
| `saveFile({ name, data, filters? })` | The IDE's save dialog; `data` is a string, bytes or a Blob; the chosen path, or `null`. The page never names a path itself |
| `openFile({ filters? })` | The IDE's open dialog; `{ name, data }` (bytes), or `null` |
| `postMessage` / `onMessage` | To and from the plugin's code, when it has some |
Save, the dirty mark and Undo are the document's, as for every custom editor.
IDE shortcuts the page doesn't handle itself (Save, Close, Find Action…) still
work while it has focus; editing keys (⌘C, ⌘V, ⌘Z…) stay with the page. With
the `clipboard` capability the page may use `navigator.clipboard`.
`plugins/official/excalidraw` is a complete example: a Vite + React page in
`web-src/`, built into `web/` by `plugins/build.sh`. The built page is not
checked in (it is tens of MB): run `plugins/build.sh official/excalidraw`
before installing the plugin from the repository.
### Settings
```toml
+57 -13
View File
@@ -1,17 +1,23 @@
#!/usr/bin/env bash
# Builds the repository's Rust plugins into WebAssembly components
# (wasm32-wasip2 emits a component directly; no cargo-component / wasm-tools).
# Builds the repository's plugins: Rust code into WebAssembly components
# (wasm32-wasip2 emits a component directly; no cargo-component / wasm-tools),
# and web pages (`web-src/`, a Vite project) into `web/`.
#
# plugins/build.sh # every component the repo checks in
# plugins/build.sh # every plugin with code or a web page
# plugins/build.sh hello # one plugin (a name under plugins/, or a path)
# plugins/build.sh --check [...] # change nothing; fail if a checked-in component
# # is stale or a plugin doesn't build (CI)
# plugins/build.sh --check [...] # change nothing tracked; fail if a checked-in
# # component is stale or a plugin doesn't build (CI)
#
# Output: <plugin>/dist/plugin.wasm (referenced by `wasm = ...` in plugin.toml)
# and <plugin>/dist/plugin.wasm.sources, a fingerprint of the sources it was
# built from: the plugin (minus target/ and dist/), the SDK and wit/. Builds
# are not byte-reproducible across machines, so --check compares fingerprints,
# not bytes. CARGO_TARGET_DIR, when set, is shared by all plugins.
# built from: the plugin (minus target/, dist/, web/ and web-src/), the SDK
# and wit/. Components are small and checked in; builds are not
# byte-reproducible across machines, so --check compares fingerprints, not
# bytes. A web page (web-src/, a pnpm project) is built into <plugin>/web/,
# which is not checked in (it can be tens of MB): build it before installing
# the plugin from the repository. web.sources (not checked in either) skips
# rebuilding an unchanged page; --check builds it too and checks the result.
# CARGO_TARGET_DIR, when set, is shared by all plugins.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(cd "$here/.." && pwd)"
@@ -33,20 +39,58 @@ list() {
local dir="$1"
shift
(cd "$dir" && git ls-files --cached --others --exclude-standard -- "$@") |
{ grep -Ev '(^|/)(dist|target)/|(^|/)\.DS_Store$' || true; } | LC_ALL=C sort -u | while read -r f; do
{ grep -Ev '(^|/)(dist|target|node_modules)/|(^|/)\.DS_Store$|^web(-src)?/|^web\.sources$' || true; } | LC_ALL=C sort -u | while read -r f; do
if [ -f "$dir/$f" ]; then printf '%s %s\n' "$f" "$(sha256 <"$dir/$f" | cut -d' ' -f1)"; fi
done
}
# Prints the fingerprint of everything plugin $1 is built from.
# Prints the fingerprint of everything plugin $1's component is built from.
fingerprint() {
{ list "$1" .; list "$repo" sdk/rust/openidea-plugin/Cargo.toml sdk/rust/openidea-plugin/src sdk/rust/openidea-plugin/wit wit; } |
sha256 | cut -d' ' -f1
}
# Prints the fingerprint of plugin $1's web page sources.
web_fingerprint() {
(cd "$1/web-src" && git ls-files --cached --others --exclude-standard -- .) |
{ grep -Ev '(^|/)node_modules/|(^|/)\.DS_Store$' || true; } | LC_ALL=C sort -u | while read -r f; do
if [ -f "$1/web-src/$f" ]; then printf '%s %s\n' "$f" "$(sha256 <"$1/web-src/$f" | cut -d' ' -f1)"; fi
done | sha256 | cut -d' ' -f1
}
# Builds plugin $1's web page into web/ unless it was built from the current
# web-src/, then checks what the manifest's `web = "…"` entries need: each
# page is there and loads its files by relative URL (it is served from the package).
build_web() {
local dir="$1" rel="${1#"$repo"/}" want entry
want="$(web_fingerprint "$dir")"
if [ "$want" != "$(cat "$dir/web.sources" 2>/dev/null || true)" ] || [ ! -d "$dir/web" ]; then
echo "building the web page of $rel"
if ! { pnpm --dir "$dir/web-src" install --frozen-lockfile --ignore-workspace --silent &&
pnpm --dir "$dir/web-src" run build; }; then
echo "FAIL $rel: the web page does not build"
failed=1
return
fi
echo "$want" >"$dir/web.sources"
fi
for entry in $(sed -n 's/^web *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | sort -u); do
if [ ! -f "$dir/$entry" ]; then
echo "FAIL $rel: $entry was not built"
failed=1
elif grep -Eq '(src|href)="/' "$dir/$entry"; then
echo "FAIL $rel: $entry loads files by absolute URL; build it with relative paths (Vite: base: \"./\")"
failed=1
fi
done
echo "ok $rel (web, $(du -sh "$dir/web" | cut -f1))"
}
failed=0
build() {
local dir="$1"
if [ -f "$dir/web-src/package.json" ]; then build_web "$dir"; fi
[ -f "$dir/Cargo.toml" ] || return 0
local name target out
name="$(sed -n 's/^name *= *"\(.*\)"/\1/p' "$dir/Cargo.toml" | head -1)"
target="${CARGO_TARGET_DIR:-$dir/target}"
@@ -86,10 +130,10 @@ if [ $# -gt 0 ]; then
fi
done
else
# Every checked-in component built from a plugin.toml + Cargo.toml directory.
for manifest in "$here"/*/Cargo.toml "$here"/official/*/Cargo.toml "$repo"/crates/ide-plugin/tests/fixtures/*/Cargo.toml; do
# Every component (plugin.toml + Cargo.toml) and web page (plugin.toml + web-src/).
for manifest in "$here"/*/plugin.toml "$here"/official/*/plugin.toml "$repo"/crates/ide-plugin/tests/fixtures/*/plugin.toml; do
dir="$(dirname "$manifest")"
[ -f "$dir/plugin.toml" ] && build "$dir"
if [ -f "$dir/Cargo.toml" ] || [ -f "$dir/web-src/package.json" ]; then build "$dir"; fi
done
fi
exit "$failed"
+4
View File
@@ -0,0 +1,4 @@
web-src/node_modules/
# Built by plugins/build.sh, not checked in.
web/
web.sources
+28
View File
@@ -0,0 +1,28 @@
# Third-party software
The page in `web/` is built from `web-src/` and bundles:
- **Excalidraw** (`@excalidraw/excalidraw` 0.18.1), MIT License, Copyright (c) 2020 Excalidraw —
https://github.com/excalidraw/excalidraw — with the libraries it loads on demand (among them
Mermaid, KaTeX and Cytoscape.js, MIT License).
- **React** and **React DOM**, MIT License, Copyright (c) Meta Platforms, Inc. and affiliates.
- The fonts Excalidraw ships in `web/fonts/` (Excalifont, Virgil, Xiaolai, Nunito, Lilita One,
Comic Shanns, Cascadia Code, Liberation Sans, Assistant), each under its own licence (SIL Open
Font License 1.1, or MIT for Comic Shanns); see the Excalidraw repository for their texts.
The MIT License:
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#F1EFFF" stroke="#6E5BD8"/>
<path d="M4 10.5c1.2-2.6 2.4-3.9 3.6-3.9 1.5 0 1.1 2.4 2.6 2.4.9 0 1.5-.8 1.8-1.6" stroke="#6E5BD8" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="4" cy="10.5" r="1" fill="#6E5BD8"/>
</svg>

After

Width:  |  Height:  |  Size: 411 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#2B2747" stroke="#A99CFF"/>
<path d="M4 10.5c1.2-2.6 2.4-3.9 3.6-3.9 1.5 0 1.1 2.4 2.6 2.4.9 0 1.5-.8 1.8-1.6" stroke="#A99CFF" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="4" cy="10.5" r="1" fill="#A99CFF"/>
</svg>

After

Width:  |  Height:  |  Size: 411 B

@@ -0,0 +1,6 @@
id = "excalidraw"
name = "Excalidraw"
extensions = ["excalidraw"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "JSON"
tree-sitter = "json"
+35
View File
@@ -0,0 +1,35 @@
id = "openidea.excalidraw"
name = "Excalidraw"
version = "0.1.0"
description = "Draw and edit Excalidraw diagrams (.excalidraw). The drawing is the file's text, so the text editor follows, Undo and Save are shared, and the IDE theme applies."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: the Excalidraw canvas next to (or instead of) the file's text, kept in step both ways; export to PNG and SVG through the save dialog; a file icon and a New › Excalidraw Drawing template. Based on the Excalidraw editor proposed by Deng Liu (#738)."
engines.openidea = "^1.0"
[capabilities]
# Copy and paste through Excalidraw's menus (the keyboard works without it).
optional = ["clipboard"]
[[contributes.customEditors]]
id = "canvas"
title = "Drawing"
patterns = ["*.excalidraw", "*.excalidraw.json"]
priority = "default"
layout = "preview"
web = "web/index.html"
[[contributes.languages]]
path = "languages/excalidraw.toml"
[[contributes.fileIcons]]
id = "excalidraw"
patterns = ["*.excalidraw", "*.excalidraw.json"]
icon = "icons/excalidraw.svg"
iconDark = "icons/excalidraw_dark.svg"
[[contributes.fileTemplates]]
id = "drawing"
name = "Excalidraw Drawing"
extension = "excalidraw"
path = "templates/drawing.excalidraw"
@@ -0,0 +1,13 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://openidea.dev",
"elements": [],
"appState": {
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Excalidraw</title>
<style>
html,
body,
#root {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.openidea-error {
box-sizing: border-box;
padding: 24px;
font: var(--openidea-font-size, 13px) var(--openidea-font-ui, system-ui, sans-serif);
color: var(--openidea-fg);
}
.openidea-error pre {
white-space: pre-wrap;
color: var(--openidea-danger);
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,25 @@
{
"name": "openidea-plugin-excalidraw-web",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "The Excalidraw plugin's web page; `pnpm build` writes it to ../web.",
"scripts": {
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^6.0.2",
"jsdom": "^30.1.3",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vitest": "^5.0.5"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
/**
* Excalidraw loads its fonts from `EXCALIDRAW_ASSET_PATH` (else from a CDN,
* which the frame can't reach): the page's own directory, where the build
* copies them. Imported before Excalidraw.
*/
window.EXCALIDRAW_ASSET_PATH = new URL("./", window.location.href).href;
@@ -0,0 +1,25 @@
/**
* The frame may not download files (no `allow-downloads`): Excalidraw's
* "Save to disk" and image export click a download link, which this turns
* into the IDE's save dialog (`openidea.saveFile`).
*/
const click = HTMLAnchorElement.prototype.click;
export function routeDownloadsToTheIde(bridge: OpenideaBridge) {
HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) {
const href = this.href;
if (!this.hasAttribute("download") || !/^(blob|data):/.test(href)) return click.call(this);
const name = this.download || "untitled";
void fetch(href)
.then((r) => r.blob())
.then((data) => bridge.saveFile({ name, data, filters: filtersFor(name) }))
.catch((e: unknown) => console.error("could not save", name, e));
};
}
function filtersFor(name: string): OpenideaFileFilter[] | undefined {
const ext = /\.([\w]+)$/.exec(name)?.[1]?.toLowerCase();
if (!ext) return undefined;
const label: Record<string, string> = { png: "PNG image", svg: "SVG image", excalidraw: "Excalidraw drawing" };
return [{ name: label[ext] ?? ext.toUpperCase(), extensions: [ext] }];
}
@@ -0,0 +1,123 @@
/**
* The Excalidraw plugin's page: the canvas of the `.excalidraw` file its
* custom editor shows, kept in step with the file's text (see sync.ts), in
* the IDE's light or dark theme.
*/
import "./assets";
import { Excalidraw, MainMenu, THEME } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI, UIOptions } from "@excalidraw/excalidraw/types";
import { StrictMode, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { routeDownloadsToTheIde } from "./downloads";
import { applyScene, codec, exportName, loadSceneFonts, type Scene } from "./scene";
import { DocumentSync } from "./sync";
// The canvas is the file's: the IDE opens, saves and themes it, so the page
// keeps only what works on the drawing itself (no Open / Save to, no links
// out of a page without network).
const UI_OPTIONS: UIOptions = {
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: false,
loadScene: false,
saveAsImage: true,
saveToActiveFile: false,
toggleTheme: false,
},
};
function Menu() {
return (
<MainMenu>
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
);
}
function useIdeTheme(bridge: OpenideaBridge): "dark" | "light" {
const [dark, setDark] = useState(bridge.theme?.dark ?? window.matchMedia?.("(prefers-color-scheme: dark)").matches);
useEffect(() => bridge.onThemeChange((t) => setDark(t.dark)), [bridge]);
return dark ? THEME.DARK : THEME.LIGHT;
}
type Start = { scene: Scene | null; error: string | null; readOnly: boolean; name: string };
function App({ bridge }: { bridge: OpenideaBridge }) {
const theme = useIdeTheme(bridge);
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
const [start, setStart] = useState<Start | null>(null);
const [error, setError] = useState<string | null>(null);
// The document's `source`: the canvas doesn't keep it, the file should.
const source = useRef<string | undefined>(undefined);
// `apply` reads the API when the document changes, which is after the canvas exists.
const sync = useMemo(() => {
let current: ExcalidrawImperativeAPI | null = null;
const s = new DocumentSync(bridge.document, codec, {
apply: (scene) => {
source.current = scene.source;
if (current) applyScene(current, scene);
},
error: setError,
});
return Object.assign(s, { bind: (a: ExcalidrawImperativeAPI | null) => void (current = a) });
}, [bridge]);
useEffect(() => {
sync.start().then(
(s) => {
source.current = s.scene?.source;
setStart(s);
setError(s.error);
},
(e: unknown) =>
setStart({ scene: null, error: String(e instanceof Error ? e.message : e), readOnly: true, name: "" }),
);
}, [sync]);
useEffect(() => {
sync.bind(api);
if (api) void loadSceneFonts(api);
}, [api, sync]);
if (!start) return null;
if (!start.scene && error)
return (
<div className="openidea-error">
<p>This file can't be shown as a drawing until its text is valid again:</p>
<pre>{error}</pre>
</div>
);
return (
<Excalidraw
excalidrawAPI={setApi}
initialData={start.scene ? { ...start.scene, scrollToContent: true } : null}
theme={theme}
viewModeEnabled={start.readOnly}
name={exportName(start.name)}
UIOptions={UI_OPTIONS}
onChange={(elements, appState, files) => sync.changed({ elements, appState, files, source: source.current })}
>
<Menu />
</Excalidraw>
);
}
const bridge = window.openidea;
const root = createRoot(document.getElementById("root")!);
if (bridge) {
routeDownloadsToTheIde(bridge);
root.render(
<StrictMode>
<App bridge={bridge} />
</StrictMode>,
);
} else {
root.render(<p className="openidea-error">This page runs inside OpenIDEA.</p>);
}
+41
View File
@@ -0,0 +1,41 @@
/** The IDE's frame bridge (`window.openidea`), injected before the page's scripts. */
interface OpenideaDocumentState {
version: number;
text: string;
}
interface OpenideaTextEdit {
from: number;
to: number;
insert: string;
}
interface OpenideaFileFilter {
name: string;
extensions: string[];
}
interface OpenideaBridge {
postMessage(text: string): void;
onMessage(fn: (text: string) => void): void;
readonly theme: { dark: boolean; vars: Readonly<Record<string, string>> } | null;
onThemeChange(fn: (theme: { dark: boolean; vars: Readonly<Record<string, string>> }) => void): void;
document: {
/** `name`: the file's name, without its directory. */
get(): Promise<OpenideaDocumentState & { readOnly: boolean; name: string }>;
onChange(fn: (state: OpenideaDocumentState) => void): void;
edit(version: number, edits: OpenideaTextEdit[]): Promise<{ version: number }>;
replace(version: number, text: string): Promise<{ version: number }>;
};
saveFile(options: {
name: string;
data: string | Uint8Array | ArrayBuffer | Blob;
filters?: OpenideaFileFilter[];
}): Promise<string | null>;
openFile(options?: { filters?: OpenideaFileFilter[] }): Promise<{ name: string; data: Uint8Array } | null>;
}
interface Window {
openidea?: OpenideaBridge;
EXCALIDRAW_ASSET_PATH?: string | string[];
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { SOURCE, exportName, parseScene, serializeScene } from "./scene";
const scene = (text: string) => {
const parsed = parseScene(text);
if ("error" in parsed) throw new Error(parsed.error);
return parsed.scene;
};
describe("scene files", () => {
it("keeps the file's source and writes ours, never the page's address, when it had none", () => {
const own = JSON.parse(
serializeScene(scene('{"type":"excalidraw","source":"https://excalidraw.com","elements":[]}')),
);
expect(own.source).toBe("https://excalidraw.com");
const none = JSON.parse(serializeScene(scene('{"type":"excalidraw","elements":[]}')));
expect(none.source).toBe(SOURCE);
for (const page of ["openidea-plugin://localhost", "http://openidea-plugin.localhost"]) {
const fixed = JSON.parse(serializeScene(scene(`{"type":"excalidraw","source":"${page}","elements":[]}`)));
expect(fixed.source).toBe(SOURCE);
}
});
it("reads element skeletons and refuses what isn't a drawing", () => {
// (Labels become text elements in the browser; jsdom can't measure text.)
const s = scene('[{"type":"rectangle","x":0,"y":0,"width":10,"height":10}]');
expect(s.elements[0]).toMatchObject({ type: "rectangle", width: 10 });
expect(typeof s.elements[0]!.seed).toBe("number");
expect(parseScene("{not json")).toHaveProperty("error");
expect(parseScene('{"type":"other"}')).toHaveProperty("error");
expect(parseScene("")).toHaveProperty("scene");
});
it("names exports after the file", () => {
expect(exportName("diagram.excalidraw")).toBe("diagram");
expect(exportName("Flow.Excalidraw.json")).toBe("Flow");
expect(exportName("notes.v2.excalidraw")).toBe("notes.v2");
expect(exportName("")).toBe("drawing");
});
});
@@ -0,0 +1,170 @@
/**
* The one module that talks to `@excalidraw/excalidraw`'s data API: reading a
* `.excalidraw` file into a scene (through Excalidraw's own `restore`, so old
* files and element skeletons work), writing it back in the format
* Excalidraw saves, and showing a scene that changed in the text.
*/
import {
CaptureUpdateAction,
convertToExcalidrawElements,
getNonDeletedElements,
getSceneVersion,
restore,
serializeAsJSON,
} from "@excalidraw/excalidraw";
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { AppState, BinaryFiles, ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import type { SceneCodec } from "./sync";
export interface Scene {
elements: readonly OrderedExcalidrawElement[];
appState: Partial<AppState>;
files: BinaryFiles;
/** The file's `source`, kept as it was. */
source?: string;
}
/** `source` of files that had none (Excalidraw would write the page's address). */
export const SOURCE = "https://openidea.dev";
type Skeleton = NonNullable<Parameters<typeof convertToExcalidrawElements>[0]>[number];
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
/**
* Hand-written or generated files often hold element skeletons
* (`{ type: "rectangle", x, y, label: { text } }`) rather than full elements:
* expand those, keep full elements (their ids and versions) as they are.
*/
function expandSkeletons(raw: readonly unknown[]): unknown[] {
const out: unknown[] = [];
for (const entry of raw) {
if (!isRecord(entry) || typeof entry.type !== "string") continue;
const skeleton = "label" in entry || typeof entry.seed !== "number";
if (!skeleton) {
out.push(entry);
continue;
}
try {
out.push(...convertToExcalidrawElements([entry as unknown as Skeleton], { regenerateIds: false }));
} catch {
out.push(entry);
}
}
return out;
}
export function parseScene(text: string): { scene: Scene } | { error: string } {
let data: unknown = {};
if (text.trim()) {
try {
data = JSON.parse(text);
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
}
if (Array.isArray(data)) data = { elements: data };
if (!isRecord(data)) return { error: "An Excalidraw file is a JSON object (or an array of elements)." };
if (typeof data.type === "string" && !data.type.startsWith("excalidraw"))
return { error: `Not an Excalidraw file: its "type" is "${data.type}".` };
if (data.elements !== undefined && !Array.isArray(data.elements)) return { error: '"elements" must be an array.' };
const restored = restore(
{
elements: expandSkeletons((data.elements as unknown[]) ?? []) as never,
appState: (isRecord(data.appState) ? data.appState : {}) as Partial<AppState>,
files: (isRecord(data.files) ? data.files : {}) as BinaryFiles,
},
null,
null,
{ refreshDimensions: false, repairBindings: true },
);
// The page's own address (what Excalidraw writes here) is no source worth keeping.
const own = /^(openidea-plugin:|https?:\/\/openidea-plugin\.localhost)/;
const source =
typeof data.source === "string" && data.source.trim() && !own.test(data.source) ? data.source : undefined;
return { scene: { elements: restored.elements, appState: restored.appState, files: restored.files ?? {}, source } };
}
/**
* As Excalidraw saves to disk (`serializeAsJSON(…, "local")`), with a final
* newline, keeping the file's `source`: Excalidraw writes the page's origin,
* which here is the IDE's internal plugin URL.
*/
export function serializeScene(scene: Scene): string {
const json = serializeAsJSON(scene.elements, scene.appState, scene.files, "local");
const source = JSON.stringify(scene.source ?? SOURCE);
// `source` is the third key, before any element: the first match is the file's own.
return `${json.replace(/"source": "(?:[^"\\]|\\.)*"/, `"source": ${source}`)}\n`;
}
/** What is saved changes iff this does: pointer moves, zoom and selection don't. */
export function sceneSignature(scene: Scene): string {
const elements = getNonDeletedElements(scene.elements);
const a = scene.appState;
const files = Object.keys(scene.files).sort().join(",");
return [
getSceneVersion(elements),
elements.length,
a.viewBackgroundColor,
a.gridSize,
a.gridStep,
a.gridModeEnabled,
files,
].join(":");
}
export const codec: SceneCodec<Scene> = { parse: parseScene, serialize: serializeScene, signature: sceneSignature };
/** Shows a scene the text changed to, keeping the view (scroll, zoom) and the canvas's undo history. */
export function applyScene(api: ExcalidrawImperativeAPI, scene: Scene) {
const files = Object.values(scene.files);
if (files.length) api.addFiles(files);
const { viewBackgroundColor, gridSize, gridStep, gridModeEnabled } = scene.appState;
const appState: Pick<AppState, "viewBackgroundColor" | "gridSize" | "gridStep" | "gridModeEnabled"> = {
viewBackgroundColor: viewBackgroundColor ?? "#ffffff",
gridSize: gridSize ?? 20,
gridStep: gridStep ?? 5,
gridModeEnabled: gridModeEnabled ?? false,
};
api.updateScene({ elements: scene.elements, appState, captureUpdate: CaptureUpdateAction.NEVER });
void loadSceneFonts(api);
}
/**
* Excalidraw loads the fonts of the scene it starts with, not of scenes set
* later, and WebKit's canvas doesn't load a font by drawing with it: load
* them, then redraw text (new element objects drop Excalidraw's cached text
* canvases) without recording a change.
*/
export async function loadSceneFonts(api: ExcalidrawImperativeAPI) {
const fonts = new Set<string>();
for (const el of api.getSceneElements()) {
if (el.type === "text") fonts.add(`${el.fontSize}px ${fontStack(el.fontFamily)}`);
}
if (!fonts.size || !document.fonts?.load) return;
await Promise.allSettled([...fonts].map((f) => document.fonts.load(f)));
api.updateScene({
elements: api.getSceneElementsIncludingDeleted().map((el) => ({ ...el })),
captureUpdate: CaptureUpdateAction.NEVER,
});
}
/** Excalidraw's font family ids (`FONT_FAMILY`) → their CSS stack. */
function fontStack(id: number): string {
const names: Record<number, string> = {
1: "Virgil",
2: "Helvetica",
3: "Cascadia",
5: "Excalifont, Xiaolai",
6: "Nunito",
7: "Lilita One",
8: "Comic Shanns",
9: "Liberation Sans",
};
return `${names[id] ?? "Excalifont, Xiaolai"}, Segoe UI Emoji`;
}
/** What exports are called: the file's name without its extension. */
export function exportName(file: string): string {
return file.replace(/\.excalidraw(\.json)?$/i, "") || "drawing";
}
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest";
import { DocumentSync, type SceneCodec, type SyncPort } from "./sync";
/** Scenes are plain strings here: the text is the scene. */
const codec: SceneCodec<string> = {
parse: (text) => (text.startsWith("!") ? { error: "bad" } : { scene: text }),
serialize: (scene) => scene,
signature: (scene) => scene,
};
function port(initial: string) {
let version = 0;
let text = initial;
let listener: (s: { version: number; text: string }) => void = () => {};
const p = {
get: vi.fn(async () => ({ version, text, readOnly: false, name: "a.excalidraw" })),
replace: vi.fn(async (at: number, next: string) => {
if (at !== version) throw new Error("stale");
text = next;
return { version: ++version };
}),
onChange: (fn: typeof listener) => void (listener = fn),
/** An edit made in the text editor. */
external(next: string) {
text = next;
listener({ version: ++version, text });
},
text: () => text,
} satisfies SyncPort & Record<string, unknown>;
return p;
}
const hooks = () => ({ apply: vi.fn(), error: vi.fn() });
describe("DocumentSync", () => {
it("starts from the document and writes canvas changes back, once per saved change", async () => {
const p = port("a");
const h = hooks();
const sync = new DocumentSync(p, codec, h, 0);
expect(await sync.start()).toEqual({ scene: "a", error: null, readOnly: false, name: "a.excalidraw" });
sync.changed("a");
await sync.flush();
expect(p.replace).not.toHaveBeenCalled();
sync.changed("ab");
sync.changed("abc");
await sync.flush();
expect(p.replace).toHaveBeenCalledTimes(1);
expect(p.text()).toBe("abc");
});
it("shows edits made elsewhere and doesn't write them back", async () => {
const p = port("a");
const h = hooks();
const sync = new DocumentSync(p, codec, h, 0);
await sync.start();
p.external("from the text editor");
expect(h.apply).toHaveBeenCalledWith("from the text editor");
sync.changed("from the text editor");
await sync.flush();
expect(p.replace).not.toHaveBeenCalled();
// The next canvas edit is based on the new version.
sync.changed("canvas");
await sync.flush();
expect(p.text()).toBe("canvas");
});
it("lets the document win a race: a stale write is dropped and the canvas takes the text", async () => {
const p = port("a");
const h = hooks();
const sync = new DocumentSync(p, codec, h, 0);
await sync.start();
sync.changed("canvas");
// The document moved on without the sync hearing of it yet.
p.replace.mockImplementationOnce(async () => {
throw new Error("stale");
});
await sync.flush();
expect(p.get).toHaveBeenCalledTimes(2);
expect(p.text()).toBe("a");
});
it("never overwrites text that doesn't parse, and resumes when it does", async () => {
const p = port("a");
const h = hooks();
const sync = new DocumentSync(p, codec, h, 0);
await sync.start();
p.external("!broken");
expect(h.error).toHaveBeenLastCalledWith("bad");
sync.changed("canvas");
await sync.flush();
expect(p.text()).toBe("!broken");
p.external("fixed");
expect(h.error).toHaveBeenLastCalledWith(null);
sync.changed("canvas");
await sync.flush();
expect(p.text()).toBe("canvas");
});
it("starts read-only and invalid documents without writing", async () => {
const p = port("!broken");
const sync = new DocumentSync(p, codec, hooks(), 0);
expect(await sync.start()).toEqual({ scene: null, error: "bad", readOnly: false, name: "a.excalidraw" });
sync.changed("x");
await sync.flush();
expect(p.replace).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,128 @@
/**
* Keeps the canvas and the file's text in step through the IDE's document
* bridge. The text is the truth: the canvas starts from it, its edits are
* written back (debounced, versioned) and edits made elsewhere (the text
* editor, Undo, another window) replace what the canvas shows.
*
* - Canvas changes that don't change what is saved (pointer moves, zoom,
* selection) write nothing: they're compared by `signature`.
* - A write that lost a race (`stale`) is dropped and the canvas takes the
* document's text.
* - Text that doesn't parse is never overwritten: the canvas stops writing
* until the text is valid again.
*/
export interface SyncPort {
get(): Promise<{ version: number; text: string; readOnly: boolean; name?: string }>;
replace(version: number, text: string): Promise<{ version: number }>;
onChange(fn: (state: { version: number; text: string }) => void): void;
}
export interface SceneCodec<S> {
parse(text: string): { scene: S } | { error: string };
serialize(scene: S): string;
/** Equal for scenes that save the same. */
signature(scene: S): string;
}
export interface SyncHooks<S> {
/** Show a scene the document changed to. */
apply(scene: S): void;
/** The document's text doesn't parse (`null`: it does again). */
error(message: string | null): void;
}
export class DocumentSync<S> {
private version = 0;
private text = "";
private readOnly = false;
private valid = true;
private signature: string | null = null;
private pending: S | null = null;
private timer: ReturnType<typeof setTimeout> | null = null;
private writing: Promise<void> | null = null;
constructor(
private readonly port: SyncPort,
private readonly codec: SceneCodec<S>,
private readonly hooks: SyncHooks<S>,
private readonly delayMs = 250,
) {}
/** The document's scene to start from. */
async start(): Promise<{ scene: S | null; error: string | null; readOnly: boolean; name: string }> {
const doc = await this.port.get();
this.version = doc.version;
this.text = doc.text;
this.readOnly = doc.readOnly;
this.port.onChange((state) => this.external(state));
const parsed = this.codec.parse(doc.text);
if ("error" in parsed) {
this.valid = false;
return { scene: null, error: parsed.error, readOnly: doc.readOnly, name: doc.name ?? "" };
}
this.signature = this.codec.signature(parsed.scene);
return { scene: parsed.scene, error: null, readOnly: doc.readOnly, name: doc.name ?? "" };
}
/** The canvas changed. */
changed(scene: S) {
if (this.readOnly || !this.valid) return;
const signature = this.codec.signature(scene);
if (signature === this.signature) return;
this.signature = signature;
this.pending = scene;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
void this.flush();
}, this.delayMs);
}
/** Writes what is pending now (also waits for a write in flight). */
async flush(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
while (this.writing) await this.writing;
const scene = this.pending;
this.pending = null;
if (scene === null) return;
const text = this.codec.serialize(scene);
if (text === this.text) return;
this.writing = (async () => {
try {
const { version } = await this.port.replace(this.version, text);
this.version = version;
this.text = text;
} catch (e) {
if (!(e instanceof Error && e.message === "stale")) throw e;
// Someone else changed it first: theirs wins.
this.external(await this.port.get());
}
})().finally(() => {
this.writing = null;
});
await this.writing;
}
private external(state: { version: number; text: string }) {
this.version = state.version;
if (state.text === this.text) return;
this.text = state.text;
// The document moved on: what the canvas had pending is based on the old text.
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.pending = null;
const parsed = this.codec.parse(state.text);
if ("error" in parsed) {
this.valid = false;
this.hooks.error(parsed.error);
return;
}
this.valid = true;
this.signature = this.codec.signature(parsed.scene);
this.hooks.error(null);
this.hooks.apply(parsed.scene);
}
}
@@ -0,0 +1,10 @@
/**
* jsdom has no canvas: Excalidraw probes a 2D context when it loads (and
* measures text with it). A context whose methods do nothing is enough for
* its data API.
*/
const context = new Proxy(
{ filter: "none", measureText: (text: string) => ({ width: text.length * 8 }) } as Record<string | symbol, unknown>,
{ get: (target, key) => (key in target ? target[key] : () => undefined) },
);
HTMLCanvasElement.prototype.getContext = (() => context) as unknown as HTMLCanvasElement["getContext"];
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
@@ -0,0 +1,48 @@
/**
* Builds the page into ../web (checked in, like the official plugins'
* dist/plugin.wasm). Relative URLs throughout: the IDE serves the page from
* the plugin package under its own URL scheme. Excalidraw's fonts are copied
* next to it; the page points Excalidraw at them (src/assets.ts).
*/
import { cpSync, existsSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import react from "@vitejs/plugin-react";
import type { Plugin } from "vite";
import { defineConfig } from "vitest/config";
const require = createRequire(import.meta.url);
const outDir = path.resolve(import.meta.dirname, "../web");
function excalidrawFonts(): Plugin {
return {
name: "openidea:excalidraw-fonts",
apply: "build",
closeBundle() {
const fonts = path.resolve(path.dirname(require.resolve("@excalidraw/excalidraw")), "fonts");
if (!existsSync(fonts)) throw new Error(`no Excalidraw fonts at ${fonts}`);
rmSync(path.join(outDir, "fonts"), { recursive: true, force: true });
cpSync(fonts, path.join(outDir, "fonts"), { recursive: true });
},
};
}
export default defineConfig({
base: "./",
plugins: [react(), excalidrawFonts()],
define: { "process.env.IS_PREACT": JSON.stringify("false") },
build: {
outDir,
emptyOutDir: true,
target: "es2022",
sourcemap: false,
assetsInlineLimit: 0,
chunkSizeWarningLimit: 4096,
},
test: {
// Excalidraw needs a DOM even for its data API; its packages ship JSON and CSS imports Node can't load.
environment: "jsdom",
setupFiles: ["./src/test-setup.ts"],
server: { deps: { inline: [/@excalidraw/, /open-color/] } },
},
});
+2
View File
@@ -27,6 +27,8 @@ specta-typescript.workspace = true
specta.workspace = true
serde.workspace = true
serde_json.workspace = true
base64.workspace = true
percent-encoding = "2"
tokio.workspace = true
parking_lot.workspace = true
blake3.workspace = true
+3
View File
@@ -437,6 +437,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_cancel_task,
plugin::plugin_view_render,
plugin::plugin_view_event,
plugin::plugin_web_page,
plugin::plugin_web_save_file,
plugin::plugin_web_read_file,
plugin::plugin_line_markers,
plugin::plugin_market,
plugin::plugin_market_url,
+53
View File
@@ -413,6 +413,59 @@ pub async fn plugin_view_event(
ide_host::plugins::views::event(&state.host, plugins, window.label(), plugin_id, view, event, doc).await
}
/// The page of a plugin's web view or custom editor (`web = …`).
#[tauri::command]
#[specta::specta]
pub fn plugin_web_page(
state: State<'_, AppState>,
plugin_id: String,
view: String,
) -> IdeResult<ide_host::plugins::web::WebPage> {
state.plugins.web_page(&plugin_id, &view)
}
/// Largest file a web view saves or opens (`openidea.saveFile` / `openFile`).
const MAX_WEB_VIEW_FILE: u64 = 64 << 20;
/// Writes what a web view saves (`openidea.saveFile`) to the path the user
/// chose in the save dialog, on this machine.
#[tauri::command]
#[specta::specta]
pub async fn plugin_web_save_file(path: PathBuf, base64: String) -> IdeResult<()> {
use base64::Engine;
if base64.len() as u64 > MAX_WEB_VIEW_FILE / 3 * 4 + 4 {
return Err(IdeError::invalid(format!("larger than {} MiB", MAX_WEB_VIEW_FILE >> 20)));
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(base64)
.map_err(|e| IdeError::invalid(format!("not base64: {e}")))?;
tokio::fs::write(&path, bytes)
.await
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, format!("{}: {e}", path.display())))
}
/// Reads the file the user picked in an open dialog for a web view
/// (`openidea.openFile`), on this machine; base64.
#[tauri::command]
#[specta::specta]
pub async fn plugin_web_read_file(path: PathBuf) -> IdeResult<String> {
use base64::Engine;
let meta = tokio::fs::metadata(&path)
.await
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, format!("{}: {e}", path.display())))?;
if !meta.is_file() || meta.len() > MAX_WEB_VIEW_FILE {
return Err(IdeError::invalid(format!(
"{}: not a file of at most {} MiB",
path.display(),
MAX_WEB_VIEW_FILE >> 20
)));
}
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, format!("{}: {e}", path.display())))?;
Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
}
/// The marketplace's plugins: read again when `refresh`, else the last index read.
#[tauri::command]
#[specta::specta]
+3
View File
@@ -14,6 +14,7 @@ mod logging;
#[cfg(target_os = "macos")]
mod macos;
mod menu;
mod plugin_web;
mod remote_dev;
mod state;
@@ -91,6 +92,8 @@ pub fn run() {
let app = app
// Inert until an endpoint is configured (see `commands::window::app_check_update`).
.plugin(tauri_plugin_updater::Builder::new().build());
// Files of plugins' web views, for their sandboxed frames.
let app = app.register_asynchronous_uri_scheme_protocol(plugin_web::SCHEME, plugin_web::handle);
// E2E automation bridge (Tauri MCP), debug builds only, localhost only.
#[cfg(debug_assertions)]
let app = app.plugin(tauri_plugin_mcp_bridge::Builder::new().bind_address("127.0.0.1").build());
+107
View File
@@ -0,0 +1,107 @@
//! The `openidea-plugin` URL scheme: files of plugins' web views
//! (`<plugin id>/<revision>/<path>`, see `ide_host::plugins::web`).
//!
//! Pages run in a sandboxed frame (an opaque origin), so every response allows
//! any origin to read it (module scripts and fonts are fetched in CORS mode),
//! and is immutable: the revision in the URL changes with the installed
//! package. HTML gets a CSP of its own, in case a page navigates its frame to
//! one: still sandboxed, still no network.
use crate::state::AppState;
use tauri::http::{Request, Response, StatusCode, header};
use tauri::{Manager, Runtime, UriSchemeContext, UriSchemeResponder};
pub const SCHEME: &str = "openidea-plugin";
/// What a page loaded from the scheme may do: its own files, nothing else.
const HTML_CSP: &str = "sandbox allow-scripts; default-src 'none'; \
script-src openidea-plugin: http://openidea-plugin.localhost 'unsafe-inline' 'wasm-unsafe-eval'; \
style-src openidea-plugin: http://openidea-plugin.localhost 'unsafe-inline'; \
img-src openidea-plugin: http://openidea-plugin.localhost data: blob:; \
font-src openidea-plugin: http://openidea-plugin.localhost data:; \
media-src openidea-plugin: http://openidea-plugin.localhost data: blob:; \
connect-src openidea-plugin: http://openidea-plugin.localhost data: blob:";
/// `window.openidea` (one function expression taking the initial theme), the same
/// source the workbench inlines into rendered `html` bodies.
const BRIDGE: &str = include_str!("../../src/features/plugins/views/frame-bridge.js");
/// `html` with the bridge as its first script (the theme follows in a message).
fn with_bridge(html: &[u8]) -> Vec<u8> {
let bridge = format!("<script>({})(null);</script>", BRIDGE.trim().trim_end_matches(';'));
let text = String::from_utf8_lossy(html);
let lower = text.to_ascii_lowercase();
// After `<head …>` when there is one, else first.
let at = lower
.find("<head")
.and_then(|i| lower[i..].find('>').map(|j| i + j + 1))
.unwrap_or_else(|| lower.find("<html").and_then(|i| lower[i..].find('>').map(|j| i + j + 1)).unwrap_or(0));
let mut out = String::with_capacity(text.len() + bridge.len());
out.push_str(&text[..at]);
out.push_str(&bridge);
out.push_str(&text[at..]);
out.into_bytes()
}
pub fn handle<R: Runtime>(ctx: UriSchemeContext<'_, R>, request: Request<Vec<u8>>, responder: UriSchemeResponder) {
let app = ctx.app_handle().clone();
let path = request.uri().path().to_owned();
tauri::async_runtime::spawn_blocking(move || {
let response = match app.try_state::<AppState>() {
Some(state) => serve(&state.plugins, &path),
None => status(StatusCode::SERVICE_UNAVAILABLE),
};
responder.respond(response);
});
}
fn serve(plugins: &ide_host::plugins::PluginHost, path: &str) -> Response<Vec<u8>> {
let decoded = percent_encoding::percent_decode_str(path.trim_start_matches('/')).decode_utf8_lossy();
let Some((plugin, rest)) = decoded.split_once('/') else { return status(StatusCode::NOT_FOUND) };
match plugins.web_file(plugin, rest) {
Ok(file) => {
let mut response = Response::builder()
.header(header::CONTENT_TYPE, file.mime)
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header("X-Content-Type-Options", "nosniff");
let mut body = file.bytes;
if file.mime.starts_with("text/html") {
response = response.header(header::CONTENT_SECURITY_POLICY, HTML_CSP);
body = with_bridge(&body);
}
response.body(body).unwrap_or_else(|_| status(StatusCode::INTERNAL_SERVER_ERROR))
}
Err(e) => {
tracing::debug!("{SCHEME}: {path}: {}", e.message);
status(match e.kind {
ide_core::error::ErrorKind::Unavailable => StatusCode::FORBIDDEN,
ide_core::error::ErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
_ => StatusCode::NOT_FOUND,
})
}
}
}
fn status(code: StatusCode) -> Response<Vec<u8>> {
Response::builder()
.status(code)
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(Vec::new())
.expect("a status response")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_bridge_is_the_first_script() {
let out = String::from_utf8(with_bridge(b"<!doctype html><html><HEAD lang=x><script>app()</script>")).unwrap();
let bridge = out.find("window.openidea").unwrap();
assert!(out.find("<HEAD lang=x>").unwrap() < bridge && bridge < out.find("app()").unwrap(), "{out}");
assert!(out.contains("})(null);</script>"), "{out}");
let bare = String::from_utf8(with_bridge(b"<p>x</p>")).unwrap();
assert!(bare.starts_with("<script>(") && bare.ends_with("<p>x</p>"));
}
}
+1 -1
View File
@@ -26,7 +26,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: data: blob: https://avatars.githubusercontent.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:*"
"csp": "default-src 'self'; img-src 'self' asset: data: blob: https://avatars.githubusercontent.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src ipc: http://ipc.localhost; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; frame-src http://127.0.0.1:* openidea-plugin: http://openidea-plugin.localhost"
}
},
"bundle": {
+8
View File
@@ -21,6 +21,7 @@ import {
Annotation,
Compartment,
Facet,
type ChangeSpec,
StateEffect,
type EditorState,
type Extension,
@@ -604,6 +605,13 @@ export interface DocumentAttachment {
*/
undo(): boolean;
redo(): boolean;
/**
* Applies a change made by this view's owner as one edit of the document's
* history (where `undo` works): the text editor tab's when one shows the
* file, so Undo there reverts it too; else this view's. The view receives
* it like any other change of the document, before this returns.
*/
edit(changes: ChangeSpec, userEvent?: string): void;
/** Detach; the document closes when nothing else shows it. */
release(): void;
}
+28
View File
@@ -139,4 +139,32 @@ describe("attached document views (editable diff side)", () => {
att.release();
tab.dispose();
});
it("records an attachment's own edits in the document's history: the text tab's when one is open", async () => {
const { manager } = setup({ "/p/d.excalidraw": "{}" });
const att = await attachDocument(manager, "/p/d.excalidraw");
const view = new EditorView({ doc: att.text(), extensions: [att.extension, history()] });
const model = manager.get("/p/d.excalidraw")!;
// No tab: the view's own history.
att.edit({ from: 1, insert: "a" });
expect(view.state.doc.toString()).toBe("{a}");
expect(att.undo()).toBe(true);
expect(view.state.doc.toString()).toBe("{}");
// With a text tab: the edit is the tab's, so Undo there reverts it, and the view follows.
const tab = new EditorSlot(model, "t1", host);
att.edit({ from: 1, insert: "b" }, "input.plugin");
expect(tab.currentState().doc.toString()).toBe("{b}");
expect(view.state.doc.toString()).toBe("{b}");
// `undo` runs where the history is: the tab's (what ⌘Z in the text editor runs).
expect(att.undo()).toBe(true);
expect(tab.currentState().doc.toString()).toBe("{}");
expect(view.state.doc.toString()).toBe("{}");
// The view's own history never had it.
expect(undo(view)).toBe(false);
view.destroy();
att.release();
tab.dispose();
});
});
+6
View File
@@ -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);
});
});
+58
View File
@@ -72,6 +72,12 @@ export interface PluginsBackend {
/** Content of a plugin view (`doc`: the document of a custom editor). */
renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>;
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
/** The URL of a web view's page (`web = …`), under the `openidea-plugin` scheme. */
webPageUrl(pluginId: string, view: string): Promise<string>;
/** Save dialog for a web view's `openidea.saveFile`; the path written, or `null` when cancelled. */
saveWebFile(name: string, filters: FileFilter[] | undefined, bytes: Uint8Array): Promise<string | null>;
/** Open dialog for a web view's `openidea.openFile`; `null` when cancelled. */
openWebFile(filters: FileFilter[] | undefined): Promise<{ name: string; data: Uint8Array } | null>;
/** Which of a plugin's declared secrets are stored (values never come back). */
secrets(pluginId: string): Promise<PluginSecretState[]>;
/** Stores a secret (`null` removes it). */
@@ -86,6 +92,34 @@ export interface PluginsBackend {
onEvent(handler: (e: PluginEvent) => void): () => void;
}
/** A file type choice of the save / open dialogs. */
export interface FileFilter {
name: string;
extensions: string[];
}
/**
* `path` under the `openidea-plugin` scheme, as the platform's webview spells
* custom schemes (WebView2 maps them onto `http://<scheme>.localhost`).
*/
export function webSchemeUrl(path: string, windows = /windows/i.test(navigator.userAgent)): string {
const encoded = path.split("/").map(encodeURIComponent).join("/");
return windows ? `http://openidea-plugin.localhost/${encoded}` : `openidea-plugin://localhost/${encoded}`;
}
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(binary);
}
function fromBase64(b64: string): Uint8Array {
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
export const tauriPluginsBackend: PluginsBackend = {
list: () => call(commands.pluginList()),
setEnabled: async (id, enabled) => {
@@ -134,6 +168,25 @@ export const tauriPluginsBackend: PluginsBackend = {
viewEvent: async (pluginId, view, event, doc) => {
await call(commands.pluginViewEvent(pluginId, view, event, doc));
},
webPageUrl: async (pluginId, view) => {
const page = await call(commands.pluginWebPage(pluginId, view));
return webSchemeUrl(page.path);
},
saveWebFile: async (name, filters, bytes) => {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: name, filters });
if (!path) return null;
await call(commands.pluginWebSaveFile(path, toBase64(bytes)));
return path;
},
openWebFile: async (filters) => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ multiple: false, filters });
const path = (Array.isArray(picked) ? picked[0] : picked) ?? null;
if (!path) return null;
const data = fromBase64(await call(commands.pluginWebReadFile(path)));
return { name: path.split(/[\\/]/).pop() ?? path, data };
},
secrets: (pluginId) => call(commands.pluginSecrets(pluginId)),
setSecret: async (pluginId, name, value) => {
await call(commands.pluginSetSecret(pluginId, name, value));
@@ -437,6 +490,11 @@ export function createMemoryPluginsBackend(
status: null,
}),
viewEvent: async () => {},
webPageUrl: async (pluginId, view) => {
throw new IpcError({ kind: "unavailable", message: `${pluginId} · ${view}: web views need the desktop app` });
},
saveWebFile: async () => null,
openWebFile: async () => null,
secrets: async (pluginId) =>
[...secrets.entries()]
.filter(([k]) => k.startsWith(`${pluginId}/`))
+122 -11
View File
@@ -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);
}
+259
View File
@@ -0,0 +1,259 @@
/**
* A plugin's web view (`web = "web/index.html"` on a view or custom editor):
* a page shipped in the plugin package, loaded from the `openidea-plugin`
* scheme into an `<iframe sandbox="allow-scripts">`. The frame is an opaque
* origin with no network and no IDE API; the desktop serves only the files
* under the page's directory, with a CSP of their own, and injects the bridge
* (`frame-bridge.js`). Through it, and only it, the page:
*
* - follows the IDE theme;
* - exchanges messages with the plugin's code, when it has any;
* - reads and edits the document of the custom editor it is (versioned edits
* that join the document's undo history, Save and dirty state);
* - asks the user to save or open a file (the IDE's dialogs: the user picks
* the path, the page never names one);
* - uses the clipboard, when the plugin was granted `clipboard`;
* - passes on IDE shortcuts it doesn't handle itself (Save, Close…).
*/
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { tx } from "@/platform/i18n";
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
import { EmptyState, Spinner } from "@/ui";
import { pluginsBackend, type FileFilter } from "../backend";
import { errorText, usePlugins } from "../store";
import { FRAME_TAG, frameTheme, frameThemeMessage } from "./frame";
import { onViewMessage } from "./viewStore";
import styles from "./PluginView.module.css";
/** One change of a document, in UTF-16 offsets of the text it applies to. */
export interface TextChange {
from: number;
to: number;
insert: string;
}
export interface DocumentState {
version: number;
text: string;
}
/** The document a web custom editor shows, as its frame sees it. */
export interface FrameDocument {
/** `name` is the file's name, without its directory. */
read(): DocumentState & { readOnly: boolean; name: string };
/** Applies the frame's edits as one change; throws `stale` when not at `version`. Returns the new version. */
edit(version: number, changes: readonly TextChange[]): number;
/** Changes made other than through `edit` (the text editor, Undo, other windows). */
subscribe(listener: (state: DocumentState) => void): () => void;
}
/** Largest text a frame may write in one edit. */
const MAX_EDIT = 64 << 20;
/** The smallest change turning `from` into `to`: their common prefix and suffix stay. */
export function minimalChange(from: string, to: string): TextChange | null {
if (from === to) return null;
let start = 0;
const max = Math.min(from.length, to.length);
while (start < max && from.charCodeAt(start) === to.charCodeAt(start)) start++;
let end = 0;
while (end < max - start && from.charCodeAt(from.length - 1 - end) === to.charCodeAt(to.length - 1 - end)) end++;
return { from: start, to: from.length - end, insert: to.slice(start, to.length - end) };
}
/** Checks a frame's edits against the text they apply to: in range, sorted, not overlapping. */
export function validChanges(value: unknown, length: number): TextChange[] {
if (!Array.isArray(value) || value.length > 100_000) throw new Error("edits must be an array");
let at = 0;
let size = 0;
return value.map((c: unknown) => {
const { from, to, insert } = (c ?? {}) as Partial<TextChange>;
if (!Number.isInteger(from) || !Number.isInteger(to) || typeof insert !== "string")
throw new Error("an edit is { from, to, insert }");
if (from! < at || to! < from! || to! > length) throw new Error("edits must be sorted, in range and not overlap");
size += insert.length;
if (size > MAX_EDIT) throw new Error("the edit is too large");
at = to!;
return { from: from!, to: to!, insert };
});
}
function validFilters(value: unknown): FileFilter[] | undefined {
if (!Array.isArray(value)) return undefined;
return value
.slice(0, 10)
.filter(
(f): f is FileFilter =>
!!f &&
typeof f.name === "string" &&
Array.isArray(f.extensions) &&
f.extensions.every((e: unknown) => typeof e === "string" && /^[\w.+-]{1,32}$/.test(e)),
)
.map((f) => ({ name: f.name.slice(0, 64), extensions: f.extensions.slice(0, 20) }));
}
type PageState = { url: string } | { error: string };
/**
* The `openidea-plugin` URL of the page, or why there is none. Asked again
* whenever the plugin changes (`plugin`: its info, a new object then): the URL
* names the installed package's revision, so an update, a reinstall or
* disabling it reach the frame, and the frame only reloads when the URL is new.
*/
function usePageUrl(pluginId: string, view: string, plugin: unknown) {
const page = `${pluginId}/${view}`;
const [state, setState] = useState<{ page: string; value: PageState } | null>(null);
useEffect(() => {
let live = true;
const set = (value: PageState) =>
live &&
setState((s) =>
s?.page === page && "url" in s.value && "url" in value && s.value.url === value.url ? s : { page, value },
);
pluginsBackend()
.webPageUrl(pluginId, view)
.then(
(url) => set({ url }),
(e: unknown) => set({ error: errorText(e) }),
);
return () => {
live = false;
};
}, [pluginId, view, page, plugin]);
return state?.page === page ? state.value : null;
}
export interface WebViewProps {
pluginId: string;
view: string;
label: string;
/** The custom editor's document; none in a tool window. */
document?: FrameDocument | null;
/** The backend document, for messages to the plugin's code. */
doc?: number | null;
}
export function WebView({ pluginId, view, label, document = null, doc = null }: WebViewProps) {
const plugin = usePlugins((s) => s.plugins.find((p) => p.id === pluginId));
const page = usePageUrl(pluginId, view, plugin);
const hasCode = plugin?.hasCode ?? false;
const clipboard = plugin?.capabilities.some((c) => c.id === "clipboard" && c.granted) ?? false;
const frame = useRef<HTMLIFrameElement>(null);
const post = useCallback((message: object, transfer: Transferable[] = []) => {
frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, ...message }, "*", transfer);
}, []);
const postTheme = useCallback(() => post(frameThemeMessage(frameTheme())), [post]);
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
useEffect(postTheme, [theme, postTheme]);
// The document's changes made elsewhere.
useEffect(() => document?.subscribe((state) => post({ document: state })), [document, post]);
useEffect(() => {
let dialog = false;
const run = async (op: string, args: Record<string, unknown>): Promise<[unknown, Transferable[]?]> => {
switch (op) {
case "document.get": {
if (!document) throw new Error("this view shows no document");
return [document.read()];
}
case "document.edit":
case "document.replace": {
if (!document) throw new Error("this view shows no document");
const current = document.read();
if (current.readOnly) throw new Error("the document is read-only");
if (args.version !== current.version) throw new Error("stale");
let changes: TextChange[];
if (op === "document.replace") {
if (typeof args.text !== "string" || args.text.length > MAX_EDIT) throw new Error("text must be a string");
const change = minimalChange(current.text, args.text);
changes = change ? [change] : [];
} else changes = validChanges(args.edits, current.text.length);
return [{ version: changes.length ? document.edit(current.version, changes) : current.version }];
}
case "saveFile":
case "openFile": {
// One dialog at a time per frame.
if (dialog) throw new Error("a file dialog is already open");
dialog = true;
try {
const filters = validFilters(args.filters);
if (op === "openFile") {
const file = await pluginsBackend().openWebFile(filters);
return file ? [file, [file.data.buffer]] : [null];
}
if (!(args.bytes instanceof Uint8Array)) throw new Error("data must be bytes");
const name = typeof args.name === "string" ? args.name.slice(0, 255) : "untitled";
return [await pluginsBackend().saveWebFile(name, filters, args.bytes)];
} finally {
dialog = false;
}
}
default:
throw new Error(`unknown request ${op}`);
}
};
const onMessage = (e: MessageEvent) => {
if (!frame.current || e.source !== frame.current.contentWindow) return;
const m = e.data as {
tag?: unknown;
text?: unknown;
call?: unknown;
op?: unknown;
args?: unknown;
key?: unknown;
} | null;
if (m?.tag !== FRAME_TAG) return;
if (m.key && typeof m.key === "object") {
// A shortcut the page didn't handle: as if pressed on the frame, for the IDE's keymap.
const k = m.key as KeyboardEventInit;
const init: KeyboardEventInit = { bubbles: true, cancelable: true };
for (const f of ["key", "code"] as const) if (typeof k[f] === "string") init[f] = k[f];
for (const f of ["metaKey", "ctrlKey", "altKey", "shiftKey", "repeat"] as const) init[f] = k[f] === true;
frame.current.dispatchEvent(new KeyboardEvent("keydown", init));
} else if (typeof m.call === "number" && typeof m.op === "string") {
const id = m.call;
run(m.op, (m.args ?? {}) as Record<string, unknown>).then(
([value, transfer]) => post({ reply: id, value }, transfer),
(err: unknown) => post({ reply: id, error: errorText(err) }),
);
} else if (typeof m.text === "string" && hasCode) {
pluginsBackend()
.viewEvent(pluginId, view, { kind: "message", text: m.text }, doc)
.catch((err: unknown) => console.warn(`plugin ${pluginId}: ${errorText(err)}`));
}
};
window.addEventListener("message", onMessage);
const off = onViewMessage(pluginId, view, (text) => post({ text }));
return () => {
window.removeEventListener("message", onMessage);
off();
};
}, [pluginId, view, document, doc, hasCode, post]);
if (!page)
return (
<div className={styles.empty}>
<Spinner label={tx("Loading")} />
</div>
);
if ("error" in page)
return (
<EmptyState className={styles.empty} title={tx`Cannot show ${label}`}>
{page.error}
</EmptyState>
);
// No `allow-same-origin`: an opaque origin without access to the IDE. `onLoad` sends the theme.
return (
<iframe
ref={frame}
className={styles.frame}
title={label}
sandbox="allow-scripts"
allow={clipboard ? "clipboard-read; clipboard-write" : undefined}
src={page.url}
onLoad={postTheme}
/>
);
}
+151 -31
View File
@@ -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>
);
};
}
+135
View File
@@ -0,0 +1,135 @@
/*
* `window.openidea` inside a plugin view's sandboxed frame: the frame's only
* way to the IDE. Shared by both kinds of frames: inlined into the `html`
* bodies plugin code renders (src/features/plugins/views/frame.ts) and
* injected by the desktop into the pages of web views
* (src-tauri/src/plugin_web.rs). Part of the plugin API: members are only
* ever added.
*
* The file is one function expression, called with the IDE theme when it is
* known up front (null otherwise: the workbench posts it once the frame has
* loaded).
*/
(initialTheme) => {
const TAG = "openidea-view";
const listeners = [];
const themeListeners = [];
const documentListeners = [];
const pending = new Map();
const root = document.documentElement;
let theme = null;
let applied = [];
let nextCall = 1;
const safely = (fn, value) => {
try {
fn(value);
} catch (err) {
console.error(err);
}
};
const applyTheme = (t) => {
if (!t || typeof t !== "object" || !t.vars || typeof t.vars !== "object") return;
const vars = {};
for (const [k, v] of Object.entries(t.vars)) if (k.startsWith("--openidea-") && typeof v === "string") vars[k] = v;
for (const k of applied) if (!(k in vars)) root.style.removeProperty(k);
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
applied = Object.keys(vars);
root.dataset.theme = t.dark ? "dark" : "light";
theme = Object.freeze({ dark: !!t.dark, vars: Object.freeze(vars) });
for (const l of themeListeners) safely(l, theme);
};
/** A request to the workbench; resolves with its reply. */
const call = (op, args, transfer) =>
new Promise((resolve, reject) => {
const id = nextCall++;
pending.set(id, { resolve, reject });
window.parent.postMessage({ tag: TAG, call: id, op, args }, "*", transfer || []);
});
const bytesOf = async (data) => {
if (typeof data === "string") return new TextEncoder().encode(data);
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
throw new TypeError("data must be a string, bytes or a Blob");
};
if (initialTheme) applyTheme(initialTheme);
// IDE shortcuts (Save, Close, Find Action…) pressed while the page has focus:
// the ones the page left alone go to the workbench. Editing keys stay here.
const EDITING_KEYS = new Set(["a", "c", "v", "x", "z", "y"]);
window.addEventListener("keydown", (e) => {
const fn = /^F\d{1,2}$/.test(e.key);
const mod = (e.metaKey || e.ctrlKey) && !EDITING_KEYS.has(e.key.toLowerCase());
if (e.isComposing || (!fn && !mod)) return;
// After every handler of the page has seen it.
setTimeout(() => {
if (e.defaultPrevented) return;
const { key, code, metaKey, ctrlKey, altKey, shiftKey, repeat } = e;
window.parent.postMessage({ tag: TAG, key: { key, code, metaKey, ctrlKey, altKey, shiftKey, repeat } }, "*");
}, 0);
});
window.addEventListener("message", (e) => {
const m = e.data;
if (e.source !== window.parent || !m || m.tag !== TAG) return;
if (m.theme) applyTheme(m.theme);
else if (typeof m.reply === "number") {
const p = pending.get(m.reply);
pending.delete(m.reply);
if (p) "error" in m ? p.reject(new Error(String(m.error))) : p.resolve(m.value);
} else if (m.document) {
for (const l of documentListeners) safely(l, m.document);
} else if (typeof m.text === "string") {
for (const l of listeners) safely(l, m.text);
}
});
const documentApi = Object.freeze({
/** `{ version, text, readOnly, name }` of the document a custom editor shows (`name`: the file's, no directory). */
get: () => call("document.get"),
/** Called with `{ version, text }` when the document changes other than through this frame. */
onChange: (fn) => {
if (typeof fn === "function") documentListeners.push(fn);
},
/**
* Applies `edits` (`{ from, to, insert }`, offsets in UTF-16 code units of
* the text at `version`) as one change; rejects with "stale" when the
* document is no longer at `version`. Resolves with the new version.
*/
edit: (version, edits) => call("document.edit", { version, edits }),
/** Replaces the whole text (sent as the smallest edit). */
replace: (version, text) => call("document.replace", { version, text: String(text) }),
});
window.openidea = Object.freeze({
/** To the plugin's code (`ViewEvent::Message`). */
postMessage: (text) => window.parent.postMessage({ tag: TAG, text: String(text) }, "*"),
/** From the plugin's code (`host::post_view_message`). */
onMessage: (fn) => {
if (typeof fn === "function") listeners.push(fn);
},
get theme() {
return theme;
},
onThemeChange: (fn) => {
if (typeof fn === "function") themeListeners.push(fn);
},
document: documentApi,
/**
* Asks the user where to save `data` (a string, bytes or a Blob), starting
* with `name`; resolves with the chosen path, or null when cancelled.
*/
saveFile: async ({ name, data, filters } = {}) => {
// A copy: its buffer is handed over, the caller's data stays usable.
const bytes = (await bytesOf(data)).slice();
return call("saveFile", { name: String(name || "untitled"), filters, bytes }, [bytes.buffer]);
},
/** Asks the user for a file; resolves with `{ name, data }` (bytes), or null when cancelled. */
openFile: ({ filters } = {}) => call("openFile", { filters }),
});
};
+4 -31
View File
@@ -11,6 +11,7 @@
* the same to scripts (canvas drawing, say).
*/
import { resolveTokenColor, toRgb } from "@/platform/cssColor";
import BRIDGE_SOURCE from "./frame-bridge.js?raw";
export const FRAME_CSP =
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:";
@@ -78,37 +79,9 @@ export function frameTheme(root: HTMLElement = document.documentElement): FrameT
/** The message that updates a loaded frame's theme. */
export const frameThemeMessage = (theme: FrameTheme) => ({ tag: FRAME_TAG, theme });
const bridge = (initial: FrameTheme) => `
(() => {
const listeners = [];
const themeListeners = [];
const root = document.documentElement;
let theme = null;
let applied = [];
const applyTheme = (t) => {
if (!t || typeof t !== "object" || !t.vars || typeof t.vars !== "object") return;
const vars = {};
for (const [k, v] of Object.entries(t.vars)) if (k.startsWith("--openidea-") && typeof v === "string") vars[k] = v;
for (const k of applied) if (!(k in vars)) root.style.removeProperty(k);
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
applied = Object.keys(vars);
root.dataset.theme = t.dark ? "dark" : "light";
theme = Object.freeze({ dark: !!t.dark, vars: Object.freeze(vars) });
for (const l of themeListeners) { try { l(theme); } catch (err) { console.error(err); } }
};
applyTheme(${JSON.stringify(initial).replace(/</g, "\\u003c")});
window.addEventListener("message", (e) => {
if (e.source !== window.parent || !e.data || e.data.tag !== "${FRAME_TAG}") return;
if (e.data.theme) { applyTheme(e.data.theme); return; }
for (const l of listeners) { try { l(String(e.data.text)); } catch (err) { console.error(err); } }
});
window.openidea = Object.freeze({
postMessage: (text) => window.parent.postMessage({ tag: "${FRAME_TAG}", text: String(text) }, "*"),
onMessage: (fn) => { if (typeof fn === "function") listeners.push(fn); },
get theme() { return theme; },
onThemeChange: (fn) => { if (typeof fn === "function") themeListeners.push(fn); },
});
})();`;
/** `window.openidea`, a function expression taking the initial theme (shared with web view pages). */
const bridge = (initial: FrameTheme | null) =>
`(${BRIDGE_SOURCE.trim().replace(/;$/, "")})(${JSON.stringify(initial).replace(/</g, "\\u003c")});`;
const BASE_STYLE =
"html[data-theme=light]{color-scheme:light}html[data-theme=dark]{color-scheme:dark}" +
+25 -5
View File
@@ -28,13 +28,33 @@ interface Customizations {
const EMPTY: Customizations = { templates: {}, deleted: [], includes: {}, deletedIncludes: [], code: {} };
/** Templates plugins contribute (`fileTemplates`): treated like built-ins (customised, reset, hidden). */
let contributed: readonly FileTemplate[] = [];
const isBase = (id: string) => BUILTIN_IDS.has(id) || contributed.some((t) => t.id === id);
export function mergeFileTemplates(c: Pick<Customizations, "templates" | "deleted">): FileTemplate[] {
const deleted = new Set(c.deleted);
const out = BUILTIN_FILE_TEMPLATES.filter((t) => !deleted.has(t.id)).map((t) => c.templates[t.id] ?? t);
for (const t of Object.values(c.templates)) if (!BUILTIN_IDS.has(t.id) && !deleted.has(t.id)) out.push(t);
const base = [...BUILTIN_FILE_TEMPLATES, ...contributed];
const out = base.filter((t) => !deleted.has(t.id)).map((t) => c.templates[t.id] ?? t);
for (const t of Object.values(c.templates)) if (!isBase(t.id) && !deleted.has(t.id)) out.push(t);
return out;
}
/** Adds a plugin's template to the New menu until the returned function is called. */
export function addContributedFileTemplate(t: FileTemplate): () => void {
contributed = [...contributed.filter((x) => x.id !== t.id), t];
refreshMerged();
return () => {
contributed = contributed.filter((x) => x !== t);
refreshMerged();
};
}
function refreshMerged() {
useFileTemplates.setState((s) => ({ templates: mergeFileTemplates(s.custom) }));
}
export function mergeIncludes(c: Pick<Customizations, "includes" | "deletedIncludes">): TemplateFragment[] {
const deleted = new Set(c.deletedIncludes);
const out = BUILTIN_INCLUDES.filter((f) => !deleted.has(f.name)).map((f) => c.includes[f.name] ?? f);
@@ -98,7 +118,7 @@ function commit(custom: Customizations) {
const custom = () => useFileTemplates.getState().custom;
export const fileTemplates = () => useFileTemplates.getState().templates;
export const isBuiltinFileTemplate = (id: string) => BUILTIN_IDS.has(id);
export const isBuiltinFileTemplate = (id: string) => isBase(id);
/** `#parse` lookup over the current scheme's includes, then its code templates. */
export function templateIncludes(): IncludeResolver {
@@ -115,7 +135,7 @@ export function removeFileTemplate(id: string) {
const c = custom();
const templates = { ...c.templates };
delete templates[id];
commit({ ...c, templates, deleted: BUILTIN_IDS.has(id) ? [...new Set([...c.deleted, id])] : c.deleted });
commit({ ...c, templates, deleted: isBase(id) ? [...new Set([...c.deleted, id])] : c.deleted });
}
export function resetFileTemplate(id: string) {
@@ -126,7 +146,7 @@ export function resetFileTemplate(id: string) {
}
export function isModifiedFileTemplate(id: string): boolean {
return BUILTIN_IDS.has(id) && id in custom().templates;
return isBase(id) && id in custom().templates;
}
/** Saves an include; `previous` is its name before a rename. */
+7 -1
View File
@@ -5770,5 +5770,11 @@
"{icons} icons": "{icons} 个图标",
"Use Default Icons": "使用默认图标",
"Apply icon theme {label}": "应用图标主题 {label}",
"File Icon Themes": "文件图标主题"
"File Icon Themes": "文件图标主题",
"File Icons": "文件图标",
"File Templates": "文件模板",
"Cannot show {label}": "无法显示 {label}",
"{title} layout": "{title} 布局",
"Editor and {title}": "编辑器和{title}",
"{title} Only": "仅{title}"
}
+17
View File
@@ -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();
+12 -1
View File
@@ -25,8 +25,19 @@ export default defineConfig(() => ({
strictPort: true,
host: host || false,
hmr: host ? { protocol: "ws", host } : undefined,
watch: { ignored: ["**/src-tauri/**", "**/crates/**", "**/target/**"] },
watch: {
ignored: [
"**/src-tauri/**",
"**/crates/**",
"**/target/**",
// The packages' sources and builds (not `src/features/plugins`).
`${fileURLToPath(new URL("./plugins", import.meta.url))}/**`,
],
},
},
// The app's own entry only: plugins' web pages (`plugins/**/web-src`) are
// separate projects with their own dependencies, never part of this bundle.
optimizeDeps: { entries: ["index.html"] },
build: {
target: "safari16",
sourcemap: true,