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