дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
views.open-log-tab(key, title, process-log) (SDK host::open_log_tab) opens the read-only log tab a web view opens with open-log-tab, from a command or event of a window, reusing the Services pane's process-log. The host checks the process grant and limits when called and remembers what each key runs in that window; the tab starts it by key (plugin_log_tab_spawn) through the web-view PTY checks: pinned program, trusted project, session cap, lifecycle and grant revocation. Remote windows report that these tabs aren't available there yet.
1492 рядки
59 KiB
Rust
1492 рядки
59 KiB
Rust
//! The host side of the `openidea:[email protected]` world: bindings, resource
|
|
//! types and the implementation of every import. Capabilities are checked
|
|
//! here; a missing one returns `error::denied` (the plugin can degrade).
|
|
|
|
use crate::runtime::{MAX_TITLE, StoreData, clip};
|
|
use crate::sandbox;
|
|
use crate::types::{
|
|
EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, PickItem, ProcessChunk, ProcessCommand,
|
|
Prompt, StatusItem, net_host,
|
|
};
|
|
use ide_core::ipc_path::to_ipc;
|
|
use ide_extension::Capability;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant};
|
|
use wasmtime::component::Resource;
|
|
|
|
pub(crate) mod bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "plugin",
|
|
path: "../../wit",
|
|
imports: { default: trappable },
|
|
with: {
|
|
"openidea:plugin/workspace.project": super::ProjectRes,
|
|
"openidea:plugin/editor.text-editor": super::EditorRes,
|
|
"openidea:plugin/invocation.invocation": super::InvocationRes,
|
|
"openidea:plugin/tasks.task": super::TaskRes,
|
|
"openidea:plugin/process.child": super::ChildRes,
|
|
},
|
|
});
|
|
}
|
|
|
|
/// The optional `syntax-provider` export, bound on its own when present.
|
|
pub(crate) mod syntax_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "plugin-syntax",
|
|
path: "../../wit",
|
|
imports: { default: trappable },
|
|
with: {
|
|
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
|
"openidea:plugin/log": super::bindings::openidea::plugin::log,
|
|
"openidea:plugin/ui": super::bindings::openidea::plugin::ui,
|
|
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
|
|
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
|
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
|
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
|
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
|
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
|
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
|
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
|
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
|
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
|
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
|
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
|
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
|
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
|
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
|
},
|
|
});
|
|
}
|
|
|
|
/// The optional `providers` export.
|
|
pub(crate) mod providers_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "plugin-providers",
|
|
path: "../../wit",
|
|
imports: { default: trappable },
|
|
with: {
|
|
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
|
"openidea:plugin/log": super::bindings::openidea::plugin::log,
|
|
"openidea:plugin/ui": super::bindings::openidea::plugin::ui,
|
|
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
|
|
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
|
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
|
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
|
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
|
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
|
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
|
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
|
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
|
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
|
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
|
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
|
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
|
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
|
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
|
},
|
|
});
|
|
}
|
|
|
|
/// The optional `view-provider` export.
|
|
pub(crate) mod views_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "plugin-views",
|
|
path: "../../wit",
|
|
imports: { default: trappable },
|
|
with: {
|
|
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
|
"openidea:plugin/log": super::bindings::openidea::plugin::log,
|
|
"openidea:plugin/ui": super::bindings::openidea::plugin::ui,
|
|
"openidea:plugin/storage": super::bindings::openidea::plugin::storage,
|
|
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
|
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
|
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
|
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
|
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
|
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
|
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
|
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
|
"openidea:plugin/net": super::bindings::openidea::plugin::net,
|
|
"openidea:plugin/secrets": super::bindings::openidea::plugin::secrets,
|
|
"openidea:plugin/process": super::bindings::openidea::plugin::process,
|
|
"openidea:plugin/fs": super::bindings::openidea::plugin::fs,
|
|
"openidea:plugin/views": super::bindings::openidea::plugin::views,
|
|
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
|
},
|
|
});
|
|
}
|
|
|
|
use bindings::openidea::plugin::{
|
|
commit, detail_types, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage,
|
|
tasks, types as wit, ui, vcs, views, workspace,
|
|
};
|
|
|
|
/// A project handle: its root.
|
|
pub struct ProjectRes {
|
|
root: PathBuf,
|
|
}
|
|
|
|
/// A program a plugin started behind a handle (`process.start`); dropping the
|
|
/// handle ends it if it still runs.
|
|
pub struct ChildRes {
|
|
id: u64,
|
|
host: std::sync::Arc<dyn crate::HostApi>,
|
|
}
|
|
|
|
impl Drop for ChildRes {
|
|
fn drop(&mut self) {
|
|
self.host.release_process(self.id);
|
|
}
|
|
}
|
|
|
|
/// The invoking window's focused editor.
|
|
pub struct EditorRes {
|
|
ctx: EditorContext,
|
|
}
|
|
|
|
/// The current call's invocation (read from [`StoreData`]).
|
|
pub struct InvocationRes;
|
|
|
|
pub struct TaskRes {
|
|
id: u64,
|
|
}
|
|
|
|
const MAX_EDITS: usize = 10_000;
|
|
const MAX_DIAGNOSTICS: usize = 5_000;
|
|
const MAX_REQUEST_BODY: usize = 8 << 20;
|
|
const MAX_FIND: u32 = 10_000;
|
|
const MAX_STATUS_TEXT: usize = 128;
|
|
const MAX_PICK_ITEMS: usize = 5000;
|
|
const MAX_PICK_KEY: usize = 256;
|
|
const MAX_ARGS: usize = 256;
|
|
const MAX_ARG_BYTES: usize = 4096;
|
|
const MAX_ENV: usize = 64;
|
|
const MAX_ENV_NAME: usize = 256;
|
|
const MAX_ENV_VALUE: usize = 4096;
|
|
/// Most output one `poll` / `wait` hands out, per stream.
|
|
const MAX_CHUNK: usize = 1 << 20;
|
|
/// Most text one `write` puts into a process's stdin.
|
|
const MAX_STDIN_BYTES: usize = 1 << 20;
|
|
|
|
type Fallible<T> = wasmtime::Result<Result<T, wit::Error>>;
|
|
|
|
pub(crate) fn to_wit(e: HostError) -> wit::Error {
|
|
match e {
|
|
HostError::Denied(c) => wit::Error::Denied(c),
|
|
HostError::Stale => wit::Error::Stale,
|
|
HostError::NotFound(m) => wit::Error::NotFound(m),
|
|
HostError::Invalid(m) => wit::Error::Invalid(m),
|
|
HostError::Cancelled => wit::Error::Cancelled,
|
|
HostError::Failed(m) => wit::Error::Failed(m),
|
|
}
|
|
}
|
|
|
|
fn notification_level(level: wit::NotificationLevel) -> NotificationLevel {
|
|
match level {
|
|
wit::NotificationLevel::Info => NotificationLevel::Info,
|
|
wit::NotificationLevel::Warning => NotificationLevel::Warning,
|
|
wit::NotificationLevel::Error => NotificationLevel::Error,
|
|
}
|
|
}
|
|
|
|
/// A guest's `error` as text (`denied: net:example.com`, `stale`, …).
|
|
impl From<wit::Error> for crate::runtime::GuestError {
|
|
fn from(e: wit::Error) -> Self {
|
|
let clip = |m: String| clip(m, crate::runtime::MAX_TITLE * 16);
|
|
match e {
|
|
wit::Error::Denied(c) => Self::Denied(clip(c)),
|
|
wit::Error::Stale => Self::Stale,
|
|
wit::Error::NotFound(m) => Self::NotFound(clip(m)),
|
|
wit::Error::Invalid(m) => Self::Invalid(clip(m)),
|
|
wit::Error::Cancelled => Self::Cancelled,
|
|
wit::Error::Failed(m) => Self::Failed(clip(m)),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pos(p: wit::Position) -> ide_core::Pos {
|
|
ide_core::Pos::new(p.line, p.character)
|
|
}
|
|
|
|
fn range_to_wit(r: &ide_core::Range) -> wit::Range {
|
|
wit::Range {
|
|
start: wit::Position { line: r.start.line, character: r.start.col },
|
|
end: wit::Position { line: r.end.line, character: r.end.col },
|
|
}
|
|
}
|
|
|
|
impl StoreData {
|
|
fn require(&self, cap: Capability) -> Result<(), wit::Error> {
|
|
if self.granted.iter().any(|g| g.covers(&cap)) { Ok(()) } else { Err(wit::Error::Denied(cap.to_string())) }
|
|
}
|
|
|
|
/// `path` as the real path an `fs.read:…` grant covers, or what the plugin
|
|
/// should be told instead (`denied` naming the capability to declare).
|
|
fn granted_file(&self, path: &str) -> Result<PathBuf, wit::Error> {
|
|
let mut granted = self.granted.clone();
|
|
if let Ok(dir) = self.host.plugin_dir() {
|
|
granted.push(Capability::FsRead(to_ipc(&dir)));
|
|
}
|
|
sandbox::resolve_granted(&granted, path).map_err(Into::into)
|
|
}
|
|
|
|
/// The same for `fs.write:…`: a file that does not exist yet is resolved
|
|
/// through the directory it would be created in.
|
|
fn writable_file(&self, path: &str) -> Result<PathBuf, wit::Error> {
|
|
let mut granted = self.granted.clone();
|
|
if let Ok(dir) = self.host.plugin_dir() {
|
|
granted.push(Capability::FsWrite(to_ipc(&dir)));
|
|
}
|
|
sandbox::resolve_writable(&granted, path).map_err(Into::into)
|
|
}
|
|
|
|
fn client(&self) -> Option<&str> {
|
|
self.invocation.client.as_deref()
|
|
}
|
|
|
|
fn push<T: Send + 'static>(&mut self, value: T) -> wasmtime::Result<Resource<T>> {
|
|
Ok(self.table.push(value)?)
|
|
}
|
|
|
|
pub(crate) fn new_invocation(&mut self) -> wasmtime::Result<Resource<InvocationRes>> {
|
|
self.push(InvocationRes)
|
|
}
|
|
|
|
pub(crate) fn drop_invocation(&mut self, res: Resource<InvocationRes>) {
|
|
let _ = self.table.delete(res);
|
|
}
|
|
}
|
|
|
|
impl wit::Host for StoreData {}
|
|
|
|
impl log::Host for StoreData {
|
|
fn log(&mut self, level: wit::LogLevel, message: String) -> wasmtime::Result<()> {
|
|
let id = &self.plugin_id;
|
|
let message = clip(message, crate::runtime::MAX_TEXT);
|
|
match level {
|
|
wit::LogLevel::Trace => tracing::trace!(plugin = %id, "{message}"),
|
|
wit::LogLevel::Debug => tracing::debug!(plugin = %id, "{message}"),
|
|
wit::LogLevel::Info => tracing::info!(plugin = %id, "{message}"),
|
|
wit::LogLevel::Warn => tracing::warn!(plugin = %id, "{message}"),
|
|
wit::LogLevel::Error => tracing::error!(plugin = %id, "{message}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl StoreData {
|
|
/// Asks the user and waits; the wait doesn't count against the call's
|
|
/// wall-clock budget.
|
|
fn ask(&mut self, prompt: Prompt) -> Result<Option<Vec<String>>, wit::Error> {
|
|
let client = match (&prompt, &self.action_prompt_client) {
|
|
(
|
|
Prompt::QuickPick { .. } | Prompt::Input { multiline: false, .. } | Prompt::Confirm { .. },
|
|
Some(client),
|
|
) => Some(client.clone()),
|
|
_ => {
|
|
self.require_interactive()?;
|
|
self.invocation.client.clone()
|
|
}
|
|
};
|
|
let started = Instant::now();
|
|
let answer = self.host.ask(client.as_deref(), prompt, &self.cancel.token());
|
|
let waited = started.elapsed();
|
|
self.deadline += waited;
|
|
self.call_started += waited;
|
|
answer.map_err(to_wit)
|
|
}
|
|
|
|
fn pick_prompt(
|
|
&mut self,
|
|
title: String,
|
|
items: Vec<ui::PickItem>,
|
|
multiple: bool,
|
|
) -> Result<Option<Vec<String>>, wit::Error> {
|
|
if items.len() > MAX_PICK_ITEMS {
|
|
return Err(wit::Error::Invalid(format!("more than {MAX_PICK_ITEMS} items")));
|
|
}
|
|
if items.iter().any(|i| i.key.len() > MAX_PICK_KEY) {
|
|
return Err(wit::Error::Invalid(format!("an item key is longer than {MAX_PICK_KEY} bytes")));
|
|
}
|
|
let items = items
|
|
.into_iter()
|
|
.map(|i| PickItem {
|
|
key: i.key,
|
|
label: clip(i.label, MAX_TITLE),
|
|
description: i.description.map(|d| clip(d, 1024)),
|
|
})
|
|
.collect();
|
|
self.ask(Prompt::QuickPick { title: clip(title, MAX_TITLE), items, multiple })
|
|
}
|
|
|
|
/// APIs reserved for commands and events exclude code-action resolution.
|
|
fn require_interactive(&self) -> Result<(), wit::Error> {
|
|
if self.interactive {
|
|
return Ok(());
|
|
}
|
|
Err(wit::Error::Invalid("can only ask the user from a command or event".into()))
|
|
}
|
|
}
|
|
|
|
impl ui::Host for StoreData {
|
|
fn notify(
|
|
&mut self,
|
|
level: wit::NotificationLevel,
|
|
title: String,
|
|
body: String,
|
|
actions: Vec<ui::NotificationAction>,
|
|
) -> Fallible<()> {
|
|
const MAX_ACTIONS: usize = 3;
|
|
if actions.len() > MAX_ACTIONS {
|
|
return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions"))));
|
|
}
|
|
let mut checked = Vec::with_capacity(actions.len());
|
|
for a in actions {
|
|
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
|
|
return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label))));
|
|
}
|
|
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
|
|
return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into())));
|
|
}
|
|
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
|
|
}
|
|
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
|
Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit))
|
|
}
|
|
|
|
fn show_notification(
|
|
&mut self,
|
|
level: wit::NotificationLevel,
|
|
title: String,
|
|
body: String,
|
|
) -> wasmtime::Result<()> {
|
|
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
|
// A plain notification has nothing the host could refuse.
|
|
let _ = self.host.notify(self.client(), notification_level(level), title, body, Vec::new());
|
|
Ok(())
|
|
}
|
|
|
|
fn set_status_item(&mut self, id: String, item: ui::StatusItem) -> wasmtime::Result<()> {
|
|
let item = StatusItem {
|
|
text: clip(item.text, MAX_STATUS_TEXT),
|
|
tooltip: item.tooltip.map(|t| clip(t, 1024)),
|
|
command: item.command.map(|c| clip(c, 128)),
|
|
};
|
|
self.host.status_item(&clip(id, 64), Some(item));
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_status_item(&mut self, id: String) -> wasmtime::Result<()> {
|
|
self.host.status_item(&clip(id, 64), None);
|
|
Ok(())
|
|
}
|
|
|
|
fn set_context(&mut self, key: String, value: ui::ContextValue) -> Fallible<()> {
|
|
let valid_key =
|
|
(1..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'));
|
|
if !valid_key {
|
|
return Ok(Err(wit::Error::Invalid(format!("context key {key:?}: expected 1-64 of A-Za-z0-9_-"))));
|
|
}
|
|
// Kept as JSON text, which the windows parse into their context.
|
|
let json = match value {
|
|
ui::ContextValue::Flag(b) => b.to_string(),
|
|
ui::ContextValue::Number(n) if n.fract() == 0.0 && n.abs() < 9e15 => (n as i64).to_string(),
|
|
ui::ContextValue::Number(n) => match serde_json::Number::from_f64(n) {
|
|
Some(n) => n.to_string(),
|
|
None => return Ok(Err(wit::Error::Invalid("context value: not a finite number".into()))),
|
|
},
|
|
ui::ContextValue::Text(t) if t.len() <= 1024 => serde_json::Value::String(t).to_string(),
|
|
ui::ContextValue::Text(_) => {
|
|
return Ok(Err(wit::Error::Invalid("context value: text longer than 1024 bytes".into())));
|
|
}
|
|
};
|
|
let client = self.invocation.client.clone();
|
|
Ok(self.host.set_context(client.as_deref(), &key, Some(json)).map_err(to_wit))
|
|
}
|
|
|
|
fn remove_context(&mut self, key: String) -> wasmtime::Result<()> {
|
|
let client = self.invocation.client.clone();
|
|
// Nothing to remove under an invalid key.
|
|
let _ = self.host.set_context(client.as_deref(), &key, None);
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_to_clipboard(&mut self, text: String) -> Fallible<()> {
|
|
if let Err(e) = self.require(Capability::Clipboard) {
|
|
return Ok(Err(e));
|
|
}
|
|
let client = self.invocation.client.clone();
|
|
Ok(self.host.copy_to_clipboard(client.as_deref(), clip(text, crate::runtime::MAX_TEXT)).map_err(to_wit))
|
|
}
|
|
|
|
fn open_url(&mut self, url: String) -> Fallible<()> {
|
|
let trimmed = url.trim();
|
|
let valid = (trimmed.starts_with("https://") || trimmed.starts_with("http://")) && trimmed.len() <= 8192;
|
|
if !valid {
|
|
return Ok(Err(wit::Error::Invalid(format!("{url:?}: expected an http:// or https:// URL"))));
|
|
}
|
|
let client = self.invocation.client.clone();
|
|
Ok(self.host.open_url(client.as_deref(), trimmed.to_owned()).map_err(to_wit))
|
|
}
|
|
|
|
fn pick(&mut self, title: String, items: Vec<ui::PickItem>) -> Fallible<Option<String>> {
|
|
let answer = self.pick_prompt(title, items, false);
|
|
Ok(answer.map(|keys| keys.and_then(|keys| keys.into_iter().next())))
|
|
}
|
|
|
|
fn pick_directory(&mut self, title: String, initial: Option<String>) -> Fallible<Option<String>> {
|
|
if let Some(path) = &initial
|
|
&& let Err(error) = validate_ui_path(path)
|
|
{
|
|
return Ok(Err(error));
|
|
}
|
|
let answer = self.ask(Prompt::PickDirectory { title: clip(title, MAX_TITLE), initial });
|
|
Ok(answer.and_then(|answer| {
|
|
let path = answer.and_then(|items| items.into_iter().next());
|
|
if let Some(path) = &path {
|
|
validate_ui_path(path)?;
|
|
}
|
|
Ok(path)
|
|
}))
|
|
}
|
|
|
|
fn pick_many(&mut self, title: String, items: Vec<ui::PickItem>) -> Fallible<Option<Vec<String>>> {
|
|
Ok(self.pick_prompt(title, items, true))
|
|
}
|
|
|
|
fn input(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
|
|
let prompt =
|
|
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: false };
|
|
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
|
|
}
|
|
|
|
fn input_multiline(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
|
|
let prompt =
|
|
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: true };
|
|
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
|
|
}
|
|
|
|
fn confirm(&mut self, title: String, body: String) -> Fallible<bool> {
|
|
let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) };
|
|
Ok(self.ask(prompt).map(|answer| answer.is_some()))
|
|
}
|
|
|
|
fn locale(&mut self) -> wasmtime::Result<String> {
|
|
Ok(self.host.locale())
|
|
}
|
|
}
|
|
|
|
impl storage::Host for StoreData {
|
|
fn get(&mut self, key: String) -> Fallible<Option<String>> {
|
|
if let Err(e) = self.require(Capability::Storage) {
|
|
return Ok(Err(e));
|
|
}
|
|
Ok(self.host.storage_get(&key).map_err(to_wit))
|
|
}
|
|
|
|
fn set(&mut self, key: String, value: String) -> Fallible<()> {
|
|
if let Err(e) = self.require(Capability::Storage) {
|
|
return Ok(Err(e));
|
|
}
|
|
Ok(self.host.storage_set(&key, Some(value)).map_err(to_wit))
|
|
}
|
|
|
|
fn delete(&mut self, key: String) -> Fallible<()> {
|
|
if let Err(e) = self.require(Capability::Storage) {
|
|
return Ok(Err(e));
|
|
}
|
|
Ok(self.host.storage_set(&key, None).map_err(to_wit))
|
|
}
|
|
|
|
fn keys(&mut self, prefix: String) -> Fallible<Vec<String>> {
|
|
if let Err(e) = self.require(Capability::Storage) {
|
|
return Ok(Err(e));
|
|
}
|
|
Ok(self.host.storage_keys(&prefix).map_err(to_wit))
|
|
}
|
|
}
|
|
|
|
impl workspace::Host for StoreData {
|
|
fn open_project(&mut self, path: String, new_window: bool) -> Fallible<()> {
|
|
if let Err(error) = self.require_interactive().and_then(|_| validate_ui_path(&path)) {
|
|
return Ok(Err(error));
|
|
}
|
|
let client = self.invocation.client.clone();
|
|
Ok(self.host.open_project(client.as_deref(), path, new_window).map_err(to_wit))
|
|
}
|
|
|
|
fn project_of(&mut self, path: String) -> wasmtime::Result<Option<Resource<ProjectRes>>> {
|
|
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
|
|
}
|
|
|
|
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
|
|
if let Err(e) = self.require(Capability::ProjectRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let mut out = Vec::new();
|
|
for root in self.host.projects() {
|
|
out.push(self.push(ProjectRes { root })?);
|
|
}
|
|
Ok(Ok(out))
|
|
}
|
|
}
|
|
|
|
impl workspace::HostProject for StoreData {
|
|
fn root(&mut self, this: Resource<ProjectRes>) -> Fallible<String> {
|
|
if let Err(e) = self.require(Capability::ProjectRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
// Paths reach plugins in IPC form (`/` on Windows), as the UI's event paths do, so they compare as text.
|
|
Ok(Ok(to_ipc(&self.table.get(&this)?.root)))
|
|
}
|
|
|
|
fn read_file(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<String> {
|
|
if let Err(e) = self.require(Capability::ProjectRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let root = self.table.get(&this)?.root.clone();
|
|
let real = match sandbox::resolve_in_root(&root, &path) {
|
|
Ok(real) => real,
|
|
Err(e) => {
|
|
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
|
return Ok(Err(e));
|
|
}
|
|
};
|
|
if let Some(text) = self.host.buffer_text(&real) {
|
|
return Ok(Ok(text));
|
|
}
|
|
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
|
}
|
|
|
|
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
|
|
if let Err(e) = self.require(Capability::ProjectRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let root = self.table.get(&this)?.root.clone();
|
|
match sandbox::inside_root(&root, &path) {
|
|
Err(e) => Ok(Err(wit::Error::Invalid(e))),
|
|
Ok(false) => {
|
|
Ok(Err(wit::Error::Invalid(format!("{path}: access outside the project root is not allowed"))))
|
|
}
|
|
Ok(true) => {
|
|
let requested = Path::new(&path);
|
|
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
|
Ok(Ok(joined.exists()))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn stat(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<workspace::FileStat> {
|
|
if let Err(e) = self.require(Capability::ProjectRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let root = self.table.get(&this)?.root.clone();
|
|
let real = match sandbox::resolve_in_root(&root, &path) {
|
|
Ok(real) => real,
|
|
Err(e) => {
|
|
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
|
return Ok(Err(e));
|
|
}
|
|
};
|
|
Ok(file_stat(&real, &path))
|
|
}
|
|
|
|
fn find_files(&mut self, this: Resource<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
|
|
if let Err(e) = self.require(Capability::ProjectRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let root = self.table.get(&this)?.root.clone();
|
|
Ok(self.host.find_files(&root, &glob, limit.min(MAX_FIND) as usize).map_err(to_wit))
|
|
}
|
|
|
|
fn symbols(
|
|
&mut self,
|
|
this: Resource<ProjectRes>,
|
|
query: String,
|
|
limit: u32,
|
|
) -> Fallible<Vec<workspace::SymbolInfo>> {
|
|
if let Err(e) = self.require(Capability::ProjectRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
if query.len() > 256 {
|
|
return Ok(Err(wit::Error::Invalid("symbol query exceeds 256 UTF-8 bytes".into())));
|
|
}
|
|
let root = &self.table.get(&this)?.root;
|
|
Ok(self
|
|
.host
|
|
.symbols(root, &query, limit.min(1000) as usize, &self.cancel.token(), self.deadline)
|
|
.map(|symbols| {
|
|
symbols
|
|
.into_iter()
|
|
.map(|s| workspace::SymbolInfo {
|
|
name: s.name,
|
|
kind: s.kind,
|
|
container: s.container,
|
|
location: wit::Location {
|
|
path: to_ipc(&s.location.path),
|
|
range: range_to_wit(&s.location.range),
|
|
},
|
|
})
|
|
.collect()
|
|
})
|
|
.map_err(to_wit))
|
|
}
|
|
|
|
fn write_file(&mut self, this: Resource<ProjectRes>, path: String, text: String) -> Fallible<()> {
|
|
if let Err(e) = self.require(Capability::ProjectWrite) {
|
|
return Ok(Err(e));
|
|
}
|
|
if text.len() > sandbox::MAX_WRITE_BYTES {
|
|
return Ok(Err(too_much_to_write(&path)));
|
|
}
|
|
let root = self.table.get(&this)?.root.clone();
|
|
let real = match sandbox::resolve_new_in_root(&root, &path) {
|
|
Ok(real) => real,
|
|
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
|
};
|
|
// The directories it goes in are created, like a new file in the project view.
|
|
if let Some(parent) = real.parent()
|
|
&& let Err(e) = std::fs::create_dir_all(parent)
|
|
{
|
|
return Ok(Err(wit::Error::Failed(format!("{path}: {e}"))));
|
|
}
|
|
Ok(self.host.write_text_file(&real, &text).map_err(to_wit))
|
|
}
|
|
|
|
fn drop(&mut self, rep: Resource<ProjectRes>) -> wasmtime::Result<()> {
|
|
self.table.delete(rep)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl editor::Host for StoreData {}
|
|
|
|
impl detail_types::Host for StoreData {}
|
|
|
|
impl editor::HostTextEditor for StoreData {
|
|
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
|
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
|
|
}
|
|
|
|
fn language(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
|
Ok(self.table.get(&this)?.ctx.language.clone())
|
|
}
|
|
|
|
fn document(&mut self, this: Resource<EditorRes>) -> Fallible<editor::Document> {
|
|
if let Err(e) = self.require(Capability::EditorRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let path = self.table.get(&this)?.ctx.path.clone();
|
|
Ok(match self.host.document(&path) {
|
|
Some((version, text)) => Ok(editor::Document { version, text }),
|
|
None => Err(wit::Error::NotFound(format!("{} is not open", path.display()))),
|
|
})
|
|
}
|
|
|
|
fn selections(&mut self, this: Resource<EditorRes>) -> Fallible<Vec<wit::Range>> {
|
|
if let Err(e) = self.require(Capability::EditorRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
Ok(Ok(self.table.get(&this)?.ctx.selections.iter().map(range_to_wit).collect()))
|
|
}
|
|
|
|
fn apply_edits(&mut self, this: Resource<EditorRes>, version: u64, edits: Vec<wit::TextEdit>) -> Fallible<u64> {
|
|
if let Err(e) = self.require(Capability::EditorWrite) {
|
|
return Ok(Err(e));
|
|
}
|
|
if edits.len() > MAX_EDITS {
|
|
return Ok(Err(wit::Error::Invalid(format!("more than {MAX_EDITS} edits"))));
|
|
}
|
|
let total: usize = edits.iter().map(|e| e.text.len()).sum();
|
|
if total > crate::runtime::MAX_TEXT * 8 {
|
|
return Ok(Err(wit::Error::Invalid("edits too large".into())));
|
|
}
|
|
let path = self.table.get(&this)?.ctx.path.clone();
|
|
let edits: Vec<ide_core::TextEdit> = edits
|
|
.into_iter()
|
|
.map(|e| ide_core::TextEdit {
|
|
range: ide_core::Range::new(pos(e.range.start), pos(e.range.end)),
|
|
new_text: e.text,
|
|
})
|
|
.collect();
|
|
let client = self.invocation.client.clone();
|
|
Ok(self.host.apply_edits(client.as_deref(), &path, version, edits).map_err(to_wit))
|
|
}
|
|
|
|
fn drop(&mut self, rep: Resource<EditorRes>) -> wasmtime::Result<()> {
|
|
self.table.delete(rep)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl invocation::Host for StoreData {}
|
|
|
|
impl invocation::HostInvocation for StoreData {
|
|
fn project(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Option<Resource<ProjectRes>>> {
|
|
match self.invocation.workspace_root.clone() {
|
|
Some(root) => Ok(Some(self.push(ProjectRes { root })?)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn editor(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Option<Resource<EditorRes>>> {
|
|
match self.invocation.editor.clone() {
|
|
Some(ctx) => Ok(Some(self.push(EditorRes { ctx })?)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
fn selected_paths(&mut self, _this: Resource<InvocationRes>) -> wasmtime::Result<Vec<String>> {
|
|
Ok(self.invocation.selected_paths.iter().take(MAX_FIND as usize).map(|p| to_ipc(p)).collect())
|
|
}
|
|
|
|
fn drop(&mut self, rep: Resource<InvocationRes>) -> wasmtime::Result<()> {
|
|
// Owned by the host for the duration of the call.
|
|
let _ = rep;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl vcs::Host for StoreData {
|
|
fn status(&mut self, project: Resource<ProjectRes>) -> Fallible<Option<vcs::RepositoryStatus>> {
|
|
if let Err(e) = self.require(Capability::VcsRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let root = self.table.get(&project)?.root.clone();
|
|
Ok(self
|
|
.host
|
|
.vcs_status(&root)
|
|
.map(|s| {
|
|
s.map(|s| vcs::RepositoryStatus {
|
|
root: s.root,
|
|
branch: s.branch,
|
|
commit: s.commit,
|
|
upstream: s.upstream,
|
|
ahead: s.ahead,
|
|
behind: s.behind,
|
|
operation: s.operation,
|
|
changed_files: s.changed_files,
|
|
})
|
|
})
|
|
.map_err(to_wit))
|
|
}
|
|
|
|
fn fetch(&mut self, project: Resource<ProjectRes>) -> Fallible<()> {
|
|
if let Err(e) = self.require(Capability::VcsWrite) {
|
|
return Ok(Err(e));
|
|
}
|
|
let root = self.table.get(&project)?.root.clone();
|
|
let token = self.cancel.token();
|
|
Ok(self.host.vcs_fetch(&root, &token).map_err(to_wit))
|
|
}
|
|
|
|
fn remotes(&mut self, project: Resource<ProjectRes>) -> Fallible<Vec<(String, String)>> {
|
|
if let Err(e) = self.require(Capability::VcsRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let root = self.table.get(&project)?.root.clone();
|
|
Ok(self.host.vcs_remotes(&root).map_err(to_wit))
|
|
}
|
|
}
|
|
|
|
impl commit::Host for StoreData {
|
|
fn pending_commit(&mut self) -> Fallible<Option<commit::CommitDraft>> {
|
|
if let Err(e) = self.require(Capability::VcsRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
if let Err(e) = self.require_interactive() {
|
|
return Ok(Err(e));
|
|
}
|
|
// Waiting for the window (saving, diffing) doesn't count against the call's budget.
|
|
let client = self.invocation.client.clone();
|
|
let started = Instant::now();
|
|
let draft = self.host.pending_commit(client.as_deref(), &self.cancel.token());
|
|
let waited = started.elapsed();
|
|
self.deadline += waited;
|
|
self.call_started += waited;
|
|
Ok(draft.map(|d| d.map(commit_draft_to_wit)).map_err(to_wit))
|
|
}
|
|
|
|
fn set_commit_message(&mut self, revision: u64, message: String) -> Fallible<()> {
|
|
if let Err(e) = self.require(Capability::VcsWrite) {
|
|
return Ok(Err(e));
|
|
}
|
|
if let Err(e) = self.require_interactive() {
|
|
return Ok(Err(e));
|
|
}
|
|
if message.len() > MAX_COMMIT_MESSAGE {
|
|
return Ok(Err(wit::Error::Invalid(format!("a commit message is at most {MAX_COMMIT_MESSAGE} bytes"))));
|
|
}
|
|
let client = self.invocation.client.clone();
|
|
Ok(self.host.set_commit_message(client.as_deref(), revision, message).map_err(to_wit))
|
|
}
|
|
}
|
|
|
|
/// Longest commit message a plugin may set.
|
|
const MAX_COMMIT_MESSAGE: usize = 64 << 10;
|
|
|
|
pub(crate) fn commit_draft_to_wit(d: crate::types::CommitDraft) -> commit::CommitDraft {
|
|
commit::CommitDraft {
|
|
revision: d.revision,
|
|
message: d.message,
|
|
amend: d.amend,
|
|
changes: d
|
|
.changes
|
|
.into_iter()
|
|
.map(|c| commit::CommitChange {
|
|
repository: c.repository,
|
|
path: c.path,
|
|
old_path: c.old_path,
|
|
status: c.status,
|
|
diff: c.diff,
|
|
truncated: c.truncated,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
impl events::Host for StoreData {
|
|
fn subscribe(&mut self, kind: String) -> Fallible<()> {
|
|
let Some(kind) = EventKind::parse(&kind) else {
|
|
return Ok(Err(wit::Error::Invalid(format!("no event kind {kind:?} in this OpenIDEA"))));
|
|
};
|
|
if let Some(cap) = kind.capability()
|
|
&& let Err(e) = self.require(cap)
|
|
{
|
|
return Ok(Err(e));
|
|
}
|
|
self.host.subscribe(kind);
|
|
Ok(Ok(()))
|
|
}
|
|
|
|
fn subscribe_paths(&mut self, kind: String, globs: Vec<String>) -> Fallible<()> {
|
|
if let Err(e) = self.require(Capability::Events) {
|
|
return Ok(Err(e));
|
|
}
|
|
Ok(self.host.subscribe_paths(&kind, globs).map_err(to_wit))
|
|
}
|
|
|
|
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
|
|
self.host.unsubscribe(&kind);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl tasks::Host for StoreData {
|
|
fn begin(&mut self, title: String, cancellable: bool) -> wasmtime::Result<Resource<TaskRes>> {
|
|
let token = self.cancel.token();
|
|
let id = self.host.task_begin(self.client(), clip(title, crate::runtime::MAX_TITLE), cancellable, token);
|
|
self.open_tasks.push(id);
|
|
// The call may now run for the background budget.
|
|
self.renew_background(self.call_started);
|
|
self.push(TaskRes { id })
|
|
}
|
|
}
|
|
|
|
impl tasks::HostTask for StoreData {
|
|
fn report(
|
|
&mut self,
|
|
this: Resource<TaskRes>,
|
|
done: u32,
|
|
total: Option<u32>,
|
|
message: Option<String>,
|
|
) -> wasmtime::Result<()> {
|
|
let id = self.table.get(&this)?.id;
|
|
let now = Instant::now();
|
|
// A task that is still reporting is still working: the call gets another
|
|
// background budget, so long work isn't cut off at the first deadline
|
|
// while a task that goes quiet still is.
|
|
self.renew_background(now);
|
|
// At most ~10 updates per second reach the UI.
|
|
if self.last_report.is_none_or(|t| now.duration_since(t).as_millis() >= 100) || total.is_some_and(|t| done >= t)
|
|
{
|
|
self.last_report = Some(now);
|
|
self.host.task_report(id, done, total, message.map(|m| clip(m, 256)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn is_cancelled(&mut self, this: Resource<TaskRes>) -> wasmtime::Result<bool> {
|
|
let id = self.table.get(&this)?.id;
|
|
// Asking whether to stop is the same sign of life as reporting is.
|
|
self.renew_background(Instant::now());
|
|
Ok(self.cancel.token().is_cancelled() || self.host.task_cancelled(id))
|
|
}
|
|
|
|
fn drop(&mut self, rep: Resource<TaskRes>) -> wasmtime::Result<()> {
|
|
let id = self.table.delete(rep)?.id;
|
|
self.open_tasks.retain(|t| *t != id);
|
|
self.host.task_end(id);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl StoreData {
|
|
/// Lets the running call reach `from` plus the background budget (the
|
|
/// longer of it and what was left), so a task that keeps saying it is alive
|
|
/// isn't cut off at the first deadline.
|
|
fn renew_background(&mut self, from: Instant) {
|
|
let extended = from + self.limits.background;
|
|
if extended > self.deadline {
|
|
self.deadline = extended;
|
|
self.timeout = self.limits.background;
|
|
}
|
|
}
|
|
|
|
/// What is left of the running call's wall-clock budget (host operations
|
|
/// can't be interrupted by the epoch callback, so they get this as timeout).
|
|
fn remaining(&self) -> Duration {
|
|
self.deadline.saturating_duration_since(Instant::now())
|
|
}
|
|
}
|
|
|
|
impl diagnostics::Host for StoreData {
|
|
fn publish(&mut self, path: String, items: Vec<diagnostics::Diagnostic>) -> Fallible<()> {
|
|
if let Err(e) = self.require(Capability::ProjectRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let items = diagnostics_from_wit(items, &self.plugin_id);
|
|
Ok(self.host.publish_diagnostics(Path::new(&path), items).map_err(to_wit))
|
|
}
|
|
}
|
|
|
|
impl net::Host for StoreData {
|
|
fn fetch(&mut self, req: net::Request) -> Fallible<net::Response> {
|
|
let host = match net_host(&req.url) {
|
|
Ok(h) => h,
|
|
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
|
};
|
|
if let Err(e) = self.require(Capability::Net(host)) {
|
|
return Ok(Err(e));
|
|
}
|
|
if req.headers.iter().any(|h| h.value.contains("{{secret:"))
|
|
&& let Err(e) = self.require(Capability::Secrets)
|
|
{
|
|
return Ok(Err(e));
|
|
}
|
|
if req.body.as_ref().is_some_and(|b| b.len() > MAX_REQUEST_BODY) {
|
|
return Ok(Err(wit::Error::Invalid("request body larger than 8 MiB".into())));
|
|
}
|
|
let wanted = Duration::from_millis(u64::from(req.timeout_ms.unwrap_or(30_000)));
|
|
let request = HttpRequest {
|
|
method: req.method,
|
|
url: req.url,
|
|
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
|
|
body: req.body,
|
|
timeout: wanted.min(self.remaining()),
|
|
};
|
|
let token = self.cancel.token();
|
|
Ok(self
|
|
.host
|
|
.fetch(request, &token)
|
|
.map(|r| net::Response {
|
|
status: r.status,
|
|
headers: r.headers.into_iter().map(|(name, value)| net::Header { name, value }).collect(),
|
|
body: r.body,
|
|
})
|
|
.map_err(to_wit))
|
|
}
|
|
}
|
|
|
|
impl secrets::Host for StoreData {
|
|
fn is_set(&mut self, name: String) -> wasmtime::Result<bool> {
|
|
Ok(self.granted.iter().any(|g| g.covers(&Capability::Secrets)) && self.host.secret_is_set(&name))
|
|
}
|
|
}
|
|
|
|
impl StoreData {
|
|
/// The `command` of `process.run-command` / `process.start`, checked: the
|
|
/// program is the one the user approved, the sizes stay in the limits.
|
|
fn command(&self, cmd: process::Command) -> Result<ProcessCommand, wit::Error> {
|
|
self.require(Capability::Process(cmd.program.clone()))?;
|
|
if cmd.args.len() > MAX_ARGS || cmd.args.iter().any(|a| a.len() > MAX_ARG_BYTES) {
|
|
return Err(wit::Error::Invalid(format!("at most {MAX_ARGS} arguments of at most {MAX_ARG_BYTES} bytes")));
|
|
}
|
|
if cmd.env.len() > MAX_ENV {
|
|
return Err(wit::Error::Invalid(format!("at most {MAX_ENV} environment variables")));
|
|
}
|
|
for (name, value) in &cmd.env {
|
|
if name.is_empty() || name.len() > MAX_ENV_NAME || name.contains(['=', '\0']) {
|
|
return Err(wit::Error::Invalid(format!("{name:?} is not an environment variable name")));
|
|
}
|
|
if value.len() > MAX_ENV_VALUE || value.contains('\0') {
|
|
return Err(wit::Error::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
|
|
}
|
|
}
|
|
Ok(ProcessCommand { program: cmd.program, args: cmd.args, env: cmd.env })
|
|
}
|
|
|
|
/// The project a call runs in, when it named one.
|
|
fn cwd_of(&self, cwd: Option<Resource<ProjectRes>>) -> wasmtime::Result<Option<PathBuf>> {
|
|
Ok(match &cwd {
|
|
Some(p) => Some(self.table.get(p)?.root.clone()),
|
|
None => None,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn to_chunk(chunk: ProcessChunk) -> process::Chunk {
|
|
process::Chunk { stdout: chunk.stdout, stderr: chunk.stderr, status: chunk.status }
|
|
}
|
|
|
|
fn to_output(output: crate::types::ProcessOutput) -> process::Output {
|
|
process::Output { status: output.status, stdout: output.stdout, stderr: output.stderr }
|
|
}
|
|
|
|
impl process::Host for StoreData {
|
|
fn run(
|
|
&mut self,
|
|
program: String,
|
|
args: Vec<String>,
|
|
cwd: Option<Resource<ProjectRes>>,
|
|
stdin: Option<String>,
|
|
) -> Fallible<process::Output> {
|
|
let cmd = match self.command(process::Command { program, args, env: Vec::new() }) {
|
|
Ok(cmd) => cmd,
|
|
Err(e) => return Ok(Err(e)),
|
|
};
|
|
let cwd = self.cwd_of(cwd)?;
|
|
let token = self.cancel.token();
|
|
let timeout = self.remaining();
|
|
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
|
|
}
|
|
|
|
fn run_command(
|
|
&mut self,
|
|
cmd: process::Command,
|
|
cwd: Option<Resource<ProjectRes>>,
|
|
stdin: Option<String>,
|
|
) -> Fallible<process::Output> {
|
|
let cmd = match self.command(cmd) {
|
|
Ok(cmd) => cmd,
|
|
Err(e) => return Ok(Err(e)),
|
|
};
|
|
let cwd = self.cwd_of(cwd)?;
|
|
let token = self.cancel.token();
|
|
let timeout = self.remaining();
|
|
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
|
|
}
|
|
|
|
fn start(&mut self, cmd: process::Command, cwd: Option<Resource<ProjectRes>>) -> Fallible<Resource<ChildRes>> {
|
|
let cmd = match self.command(cmd) {
|
|
Ok(cmd) => cmd,
|
|
Err(e) => return Ok(Err(e)),
|
|
};
|
|
let cwd = self.cwd_of(cwd)?;
|
|
let id = match self.host.start_process(cmd, cwd.as_deref(), self.invocation.client.as_deref()) {
|
|
Ok(id) => id,
|
|
Err(e) => return Ok(Err(to_wit(e))),
|
|
};
|
|
self.push(ChildRes { id, host: self.host.clone() }).map(Ok)
|
|
}
|
|
}
|
|
|
|
impl process::HostChild for StoreData {
|
|
fn poll(&mut self, this: Resource<ChildRes>, max_bytes: u32) -> Fallible<process::Chunk> {
|
|
let id = self.table.get(&this)?.id;
|
|
let max = (max_bytes as usize).min(MAX_CHUNK);
|
|
Ok(self.host.poll_process(id, max).map(to_chunk).map_err(to_wit))
|
|
}
|
|
|
|
fn wait(&mut self, this: Resource<ChildRes>, timeout_ms: u32) -> Fallible<process::Chunk> {
|
|
let id = self.table.get(&this)?.id;
|
|
let timeout = Duration::from_millis(u64::from(timeout_ms)).min(self.remaining());
|
|
let token = self.cancel.token();
|
|
Ok(self.host.wait_process(id, MAX_CHUNK, timeout, &token).map(to_chunk).map_err(to_wit))
|
|
}
|
|
|
|
fn write(&mut self, this: Resource<ChildRes>, text: String) -> Fallible<()> {
|
|
if text.len() > MAX_STDIN_BYTES {
|
|
return Ok(Err(wit::Error::Invalid(format!("at most {MAX_STDIN_BYTES} bytes at a time"))));
|
|
}
|
|
let id = self.table.get(&this)?.id;
|
|
Ok(self.host.write_process(id, &text, &self.cancel.token(), self.remaining()).map_err(to_wit))
|
|
}
|
|
|
|
fn close_stdin(&mut self, this: Resource<ChildRes>) -> Fallible<()> {
|
|
let id = self.table.get(&this)?.id;
|
|
Ok(self.host.close_process_stdin(id).map_err(to_wit))
|
|
}
|
|
|
|
fn kill(&mut self, this: Resource<ChildRes>) -> Fallible<()> {
|
|
let id = self.table.get(&this)?.id;
|
|
Ok(self.host.kill_process(id).map_err(to_wit))
|
|
}
|
|
|
|
fn drop(&mut self, rep: Resource<ChildRes>) -> wasmtime::Result<()> {
|
|
self.table.delete(rep)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl views::Host for StoreData {
|
|
fn refresh(&mut self, view: String) -> wasmtime::Result<()> {
|
|
self.host.refresh_view(&clip(view, 64));
|
|
Ok(())
|
|
}
|
|
|
|
fn reveal(&mut self, view: String) -> Fallible<()> {
|
|
if let Err(e) = self.require_interactive() {
|
|
return Ok(Err(e));
|
|
}
|
|
let client = self.invocation.client.clone();
|
|
Ok(self.host.reveal_view(client.as_deref(), &clip(view, 64)).map_err(to_wit))
|
|
}
|
|
|
|
fn post_message(&mut self, view: String, message: String) -> Fallible<()> {
|
|
if message.len() > MAX_VIEW_MESSAGE {
|
|
return Ok(Err(wit::Error::Invalid(format!("messages are limited to {MAX_VIEW_MESSAGE} bytes"))));
|
|
}
|
|
Ok(self.host.post_view_message(&clip(view, 64), message).map_err(to_wit))
|
|
}
|
|
|
|
fn open_terminal(&mut self, title: String, program: String, args: Vec<String>) -> Fallible<()> {
|
|
if let Err(e) = self.require(Capability::Process(program.clone())) {
|
|
return Ok(Err(e));
|
|
}
|
|
if args.len() > 256 || args.iter().any(|a| a.len() > 4096) {
|
|
return Ok(Err(wit::Error::Invalid("too many or too long arguments".into())));
|
|
}
|
|
let cwd = self.invocation.workspace_root.clone();
|
|
let client = self.invocation.client.clone();
|
|
let title = clip(title, crate::runtime::MAX_TITLE);
|
|
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
|
|
}
|
|
|
|
fn open_log_tab(&mut self, key: String, title: String, log: views::ProcessLog) -> Fallible<()> {
|
|
if let Err(e) = self.require_interactive().and_then(|()| self.require(Capability::Process(log.program.clone())))
|
|
{
|
|
return Ok(Err(e));
|
|
}
|
|
if let Err(message) = check_log_tab(&key, &title, &log) {
|
|
return Ok(Err(wit::Error::Invalid(message)));
|
|
}
|
|
let Some(client) = self.invocation.client.clone() else {
|
|
return Ok(Err(wit::Error::Invalid("a log tab opens from a command or event of a window".into())));
|
|
};
|
|
let log = crate::ServiceLog {
|
|
program: log.program,
|
|
args: log.args,
|
|
toggles: log
|
|
.toggles
|
|
.into_iter()
|
|
.map(|t| crate::ServiceLogToggle { id: t.id, label: t.label, args: t.args })
|
|
.collect(),
|
|
};
|
|
Ok(self.host.open_log_tab(&client, key, title, log).map_err(to_wit))
|
|
}
|
|
|
|
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
|
|
if let Ok(dir) = self.host.plugin_dir()
|
|
&& let Ok(real) = sandbox::resolve_granted(&[Capability::FsRead(to_ipc(&dir))], &path)
|
|
{
|
|
if let Err(e) = self.require_interactive() {
|
|
return Ok(Err(e));
|
|
}
|
|
return Ok(self.host.open_location(self.client(), &real, line, column).map_err(to_wit));
|
|
}
|
|
if let Err(e) = self.require(Capability::ProjectRead) {
|
|
return Ok(Err(e));
|
|
}
|
|
let Some(root) = self.invocation.workspace_root.clone() else {
|
|
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
|
|
};
|
|
let real = match sandbox::resolve_in_root(&root, &path) {
|
|
Ok(real) => real,
|
|
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
|
};
|
|
let client = self.invocation.client.clone();
|
|
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
|
|
}
|
|
}
|
|
|
|
impl settings::Host for StoreData {
|
|
fn get(&mut self, key: String) -> wasmtime::Result<Option<String>> {
|
|
let root = if self.invocation.global_settings { None } else { self.invocation.workspace_root.clone() };
|
|
Ok(self.host.setting(root.as_deref(), &clip(key, 128)))
|
|
}
|
|
}
|
|
|
|
impl views_bindings::openidea::plugin::view_types::Host for StoreData {}
|
|
|
|
fn file_kind(t: std::fs::FileType) -> workspace::FileKind {
|
|
if t.is_file() {
|
|
workspace::FileKind::File
|
|
} else if t.is_dir() {
|
|
workspace::FileKind::Directory
|
|
} else if t.is_symlink() {
|
|
workspace::FileKind::Symlink
|
|
} else {
|
|
workspace::FileKind::Other
|
|
}
|
|
}
|
|
|
|
/// `project.stat` and `fs.stat`: the metadata of `real`, named `shown` in errors.
|
|
fn file_stat(real: &Path, shown: &str) -> Result<workspace::FileStat, wit::Error> {
|
|
let meta = std::fs::symlink_metadata(real).map_err(|e| match e.kind() {
|
|
std::io::ErrorKind::NotFound => wit::Error::NotFound(format!("{shown}: no such file")),
|
|
_ => wit::Error::Failed(e.to_string()),
|
|
})?;
|
|
let modified = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
|
|
Ok(workspace::FileStat { kind: file_kind(meta.file_type()), size: meta.len(), modified })
|
|
}
|
|
|
|
/// A refused `fs` call as the plugin sees it: `denied` carries the capability
|
|
/// string, so the plugin can say exactly what its manifest lacks.
|
|
impl From<sandbox::Refusal> for wit::Error {
|
|
fn from(refusal: sandbox::Refusal) -> Self {
|
|
match refusal {
|
|
sandbox::Refusal::Denied(cap) => Self::Denied(cap),
|
|
sandbox::Refusal::Invalid(message) => Self::Invalid(message),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Files outside the open projects, gated by `fs.read:<path>` and
|
|
/// `fs.write:<path>` grants.
|
|
impl fs::Host for StoreData {
|
|
fn plugin_dir(&mut self) -> Fallible<String> {
|
|
Ok(self.host.plugin_dir().map(|dir| to_ipc(&dir)).map_err(to_wit))
|
|
}
|
|
|
|
fn read_file(&mut self, path: String) -> Fallible<String> {
|
|
let real = match self.granted_file(&path) {
|
|
Ok(real) => real,
|
|
Err(e) => return Ok(Err(e)),
|
|
};
|
|
Ok(self.host.read_text_file(&real, sandbox::MAX_READ_BYTES as usize).map_err(to_wit))
|
|
}
|
|
|
|
fn read_bytes(&mut self, path: String) -> Fallible<Vec<u8>> {
|
|
use std::io::Read;
|
|
let real = match self.granted_file(&path) {
|
|
Ok(real) => real,
|
|
Err(e) => return Ok(Err(e)),
|
|
};
|
|
let max = sandbox::MAX_READ_BYTES;
|
|
let read = std::fs::File::open(&real).and_then(|file| {
|
|
let mut bytes = Vec::new();
|
|
// One byte over: a file that grew past the limit is refused, not truncated.
|
|
file.take(max + 1).read_to_end(&mut bytes).map(|_| bytes)
|
|
});
|
|
Ok(match read {
|
|
Ok(bytes) if bytes.len() as u64 > max => {
|
|
Err(wit::Error::Invalid(format!("{path}: larger than {} MiB", max >> 20)))
|
|
}
|
|
Ok(bytes) => Ok(bytes),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
Err(wit::Error::NotFound(format!("{path}: no such file")))
|
|
}
|
|
Err(e) => Err(wit::Error::Failed(format!("{path}: {e}"))),
|
|
})
|
|
}
|
|
|
|
fn stat(&mut self, path: String) -> Fallible<workspace::FileStat> {
|
|
Ok(self.granted_file(&path).and_then(|real| file_stat(&real, &path)))
|
|
}
|
|
|
|
fn read_dir(&mut self, path: String) -> Fallible<Vec<fs::DirEntry>> {
|
|
let real = match self.granted_file(&path) {
|
|
Ok(real) => real,
|
|
Err(e) => return Ok(Err(e)),
|
|
};
|
|
let read = match std::fs::read_dir(&real) {
|
|
Ok(read) => read,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
return Ok(Err(wit::Error::NotFound(format!("{path}: no such directory"))));
|
|
}
|
|
Err(_) if real.is_file() => return Ok(Err(wit::Error::Invalid(format!("{path}: not a directory")))),
|
|
Err(e) => return Ok(Err(wit::Error::Failed(format!("{path}: {e}")))),
|
|
};
|
|
let mut entries: Vec<fs::DirEntry> = read
|
|
.flatten()
|
|
.take(sandbox::MAX_LIST_ENTRIES)
|
|
.map(|e| fs::DirEntry {
|
|
name: e.file_name().to_string_lossy().into_owned(),
|
|
kind: e.file_type().map_or(workspace::FileKind::Other, file_kind),
|
|
})
|
|
.collect();
|
|
// Sorted, so what a plugin picks doesn't depend on the disk's order.
|
|
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Ok(Ok(entries))
|
|
}
|
|
|
|
fn write_file(&mut self, path: String, text: String) -> Fallible<()> {
|
|
if text.len() > sandbox::MAX_WRITE_BYTES {
|
|
return Ok(Err(too_much_to_write(&path)));
|
|
}
|
|
Ok(self.writable_file(&path).and_then(|real| self.host.write_text_file(&real, &text).map_err(to_wit)))
|
|
}
|
|
|
|
fn write_bytes(&mut self, path: String, bytes: Vec<u8>) -> Fallible<()> {
|
|
if bytes.len() > sandbox::MAX_WRITE_BYTES {
|
|
return Ok(Err(too_much_to_write(&path)));
|
|
}
|
|
Ok(self.writable_file(&path).and_then(|real| self.host.write_bytes_file(&real, &bytes).map_err(to_wit)))
|
|
}
|
|
}
|
|
|
|
/// The limits of a log tab, the same a web view's `open-log-tab` has.
|
|
fn check_log_tab(key: &str, title: &str, log: &views::ProcessLog) -> Result<(), String> {
|
|
let line = |s: &str, max: usize| !s.is_empty() && s.chars().count() <= max && !s.contains(['\0', '\r', '\n']);
|
|
let args = |a: &[String]| a.iter().all(|a| a.len() <= 4096 && !a.contains('\0'));
|
|
let total = log.args.len() + log.toggles.iter().map(|t| t.args.len()).sum::<usize>();
|
|
let mut ids = std::collections::BTreeSet::new();
|
|
if !line(key, 128) || !line(title, 256) {
|
|
Err("a log tab's key (at most 128 characters) and title (256) are one nonempty line".into())
|
|
} else if !line(&log.program, 256) || log.program.trim() != log.program {
|
|
Err(format!("{:?} is not a program name", log.program))
|
|
} else if log.toggles.len() > 32
|
|
|| log.toggles.iter().any(|t| !line(&t.id, 64) || !line(&t.label, 128) || !ids.insert(t.id.as_str()))
|
|
{
|
|
Err("at most 32 toggles, with unique ids (at most 64 characters) and labels (128)".into())
|
|
} else if total > 256 || !args(&log.args) || log.toggles.iter().any(|t| !args(&t.args)) {
|
|
Err("at most 256 arguments in all, of at most 4096 bytes each".into())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn too_much_to_write(path: &str) -> wit::Error {
|
|
wit::Error::Invalid(format!("{path}: more than {} MiB to write", sandbox::MAX_WRITE_BYTES >> 20))
|
|
}
|
|
|
|
/// Largest message between a plugin and its view's frame.
|
|
const MAX_VIEW_MESSAGE: usize = 8 << 20;
|
|
|
|
pub(crate) mod indexing_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "indexing", path: "../../wit",
|
|
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
|
});
|
|
}
|
|
|
|
/// The optional `detail-provider` export.
|
|
pub(crate) mod details_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "details", path: "../../wit",
|
|
with: {
|
|
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
|
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
|
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
|
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
|
},
|
|
});
|
|
}
|
|
|
|
/// The optional `commit-checker` export.
|
|
pub(crate) mod commit_checks_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "commit-checks", path: "../../wit",
|
|
with: {
|
|
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
|
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
|
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
|
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
|
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
|
},
|
|
});
|
|
}
|
|
|
|
pub(crate) mod semantic_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "semantic-tokens", path: "../../wit",
|
|
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
|
});
|
|
}
|
|
|
|
pub(crate) mod hover_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "hover", path: "../../wit",
|
|
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
|
});
|
|
}
|
|
|
|
pub(crate) mod inlay_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "inlay-hints", path: "../../wit",
|
|
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
|
});
|
|
}
|
|
|
|
pub(crate) mod endpoints_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "endpoints", path: "../../wit",
|
|
with: {
|
|
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
|
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
|
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
|
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
|
},
|
|
});
|
|
}
|
|
|
|
/// The optional `file-actions` export.
|
|
pub(crate) mod file_actions_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "file-actions-provider", path: "../../wit",
|
|
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
|
});
|
|
}
|
|
|
|
/// Paths belong to the UI machine, which can have a different OS than the plugin host.
|
|
fn validate_ui_path(path: &str) -> Result<(), wit::Error> {
|
|
let b = path.as_bytes();
|
|
let absolute = path.starts_with('/')
|
|
|| path.starts_with("\\\\")
|
|
|| (b.first().is_some_and(u8::is_ascii_alphabetic)
|
|
&& b.get(1) == Some(&b':')
|
|
&& matches!(b.get(2), Some(b'/' | b'\\')));
|
|
if !absolute || path.len() > 8192 || path.chars().any(char::is_control) {
|
|
return Err(wit::Error::Invalid("expected an absolute UI directory path of at most 8192 bytes".into()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod project_ui_tests {
|
|
use super::validate_ui_path;
|
|
#[test]
|
|
fn validates_ui_paths_without_using_the_plugin_hosts_os() {
|
|
for path in ["/work/repo", "C:/work/repo", r"C:\work\repo", r"\\server\share\repo"] {
|
|
assert!(validate_ui_path(path).is_ok(), "{path:?}");
|
|
}
|
|
for path in ["repo", "C:repo", "/bad\npath", ""] {
|
|
assert!(validate_ui_path(path).is_err(), "{path:?}");
|
|
}
|
|
assert!(validate_ui_path(&format!("/{}", "x".repeat(8192))).is_err());
|
|
}
|
|
}
|
|
|
|
pub(crate) mod diagnostics_bindings {
|
|
wasmtime::component::bindgen!({
|
|
world: "editor-diagnostics", path: "../../wit",
|
|
with: {
|
|
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
|
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
|
},
|
|
});
|
|
}
|
|
|
|
pub(crate) fn diagnostics_from_wit(items: Vec<diagnostics::Diagnostic>, source: &str) -> Vec<ide_core::Diagnostic> {
|
|
items
|
|
.into_iter()
|
|
.take(MAX_DIAGNOSTICS)
|
|
.map(|d| ide_core::Diagnostic {
|
|
range: ide_core::Range::new(pos(d.range.start), pos(d.range.end)),
|
|
severity: match d.severity {
|
|
diagnostics::Severity::Error => ide_core::Severity::Error,
|
|
diagnostics::Severity::Warning => ide_core::Severity::Warning,
|
|
diagnostics::Severity::Info => ide_core::Severity::Info,
|
|
diagnostics::Severity::Hint => ide_core::Severity::Hint,
|
|
},
|
|
message: clip(d.message, 4096),
|
|
source: Some(d.source.map_or_else(|| source.to_owned(), |s| clip(s, 128))),
|
|
code: d.code.map(|c| clip(c, 128)),
|
|
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
|
|
deprecated: d.tags.iter().any(|t| t == "deprecated"),
|
|
server: Some(source.to_owned()),
|
|
})
|
|
.collect()
|
|
}
|