mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 02:39:49 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0024a1c705 | ||
|
|
fa7710cd4a | ||
|
|
a7cd2e80de | ||
|
|
2273c9a292 | ||
|
|
53aa2d5704 | ||
|
|
af0200e2e7 | ||
|
|
812d27e8cf |
No files matched your search
Generated
-1
@@ -4341,7 +4341,6 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
||||
@@ -58,8 +58,6 @@ pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
|
||||
const MAX_EMBED: u64 = 256 * 1024;
|
||||
/// How long a probe waits for the agent to answer `initialize`.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// How long deleting a task waits for its agent to exit before removing the worktree.
|
||||
const EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The agent tasks of one project.
|
||||
pub struct AgentManager {
|
||||
@@ -841,7 +839,7 @@ impl AgentManager {
|
||||
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
|
||||
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
let live = task.kill();
|
||||
task.kill();
|
||||
let (isolation, cwd, root) = {
|
||||
let s = task.state.lock();
|
||||
(
|
||||
@@ -853,11 +851,6 @@ impl AgentManager {
|
||||
// A copy of the task (a fork) may work in the same worktree.
|
||||
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
|
||||
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
|
||||
// On Windows the agent's processes lock the folder (their working directory,
|
||||
// open files) until they have exited.
|
||||
if let Some(peer) = live {
|
||||
let _ = tokio::time::timeout(EXIT_WAIT, peer.exited()).await;
|
||||
}
|
||||
let worktrees = self.shared.paths.worktrees_dir.clone();
|
||||
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
|
||||
}
|
||||
@@ -1612,12 +1605,11 @@ impl Task {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the agent; returns its connection, to wait for its process to exit.
|
||||
fn kill(&self) -> Option<acp::Conn> {
|
||||
fn kill(&self) {
|
||||
self.cancel_permissions();
|
||||
let live = self.live.lock().take()?;
|
||||
live.peer.kill();
|
||||
Some(live.peer)
|
||||
if let Some(live) = self.live.lock().take() {
|
||||
live.peer.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1109,114 +1109,3 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
assert!(live(&mgr, &task.id));
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
/// Deleting a task whose agent still runs in the worktree removes both and leaves no
|
||||
/// process behind, whether it is waiting for permission or idle without session/load.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn deleting_a_task_waits_for_its_agent_to_exit_before_removing_the_worktree() {
|
||||
for no_load in [false, true] {
|
||||
delete_running_agent(no_load).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_running_agent(no_load: bool) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&root)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
std::fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
|
||||
let agent = Arc::new(FakeAgent { no_load, ..FakeAgent::default() });
|
||||
let pids: Arc<Mutex<Vec<u32>>> = Arc::default();
|
||||
let (fake, started) = (agent.clone(), pids.clone());
|
||||
// The fake agent, served on behalf of a real process that exits only when killed.
|
||||
let connector: Connector = Arc::new(move |_spec, cwd, client, _stderr| {
|
||||
#[cfg(unix)]
|
||||
let mut cmd = {
|
||||
let mut cmd = tokio::process::Command::new("sleep");
|
||||
cmd.arg("30");
|
||||
cmd
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut cmd = tokio::process::Command::new("cmd");
|
||||
cmd.args(["/D", "/C", "echo ready>agent-ready & ping -n 30 127.0.0.1 >nul"]);
|
||||
cmd
|
||||
};
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
cmd.current_dir(cwd).kill_on_drop(true);
|
||||
let child = cmd.spawn()?;
|
||||
started.lock().push(child.id().unwrap());
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
let (br, bw) = tokio::io::split(b);
|
||||
// `connect_with_child` frames with Content-Length.
|
||||
fake.servers.lock().push(Peer::connect(br, bw, fake.clone()));
|
||||
Ok(Peer::connect_with_child(ar, aw, client, child))
|
||||
});
|
||||
let mgr = AgentManager::with_connector(
|
||||
AgentPaths {
|
||||
project_root: root.clone(),
|
||||
store_dir: root.join("store"),
|
||||
worktrees_dir: root.join("worktrees"),
|
||||
models_file: root.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text(if no_load { "again" } else { "Fix the lexer" }),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Worktree,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, if no_load { AgentStatus::Ready } else { AgentStatus::NeedsInput }).await;
|
||||
let pid = *pids.lock().last().expect("the agent was started");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !task.cwd.0.join("agent-ready").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::rename(&task.cwd.0, task.cwd.0.with_extension("moved")).unwrap_err().raw_os_error(),
|
||||
Some(32),
|
||||
"the running agent really locks its worktree"
|
||||
);
|
||||
}
|
||||
|
||||
mgr.delete(&task.id, true).await.unwrap();
|
||||
assert!(!task.cwd.0.exists());
|
||||
// Killed and reaped: not even a zombie is left.
|
||||
#[cfg(unix)]
|
||||
let alive = std::process::Command::new("kill").args(["-0", &pid.to_string()]).status().unwrap().success();
|
||||
#[cfg(windows)]
|
||||
let alive = {
|
||||
let mut cmd = std::process::Command::new("tasklist");
|
||||
ide_core::process::prepare(&mut cmd).args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]);
|
||||
String::from_utf8_lossy(&cmd.output().unwrap().stdout).contains(&format!("\"{pid}\""))
|
||||
};
|
||||
assert!(!alive, "the agent (pid {pid}) is still running");
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
pub(crate) struct Created {
|
||||
pub path: PathBuf,
|
||||
@@ -65,27 +65,7 @@ pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) ->
|
||||
if !path.starts_with(worktrees_dir) || path == worktrees_dir {
|
||||
return Err(err);
|
||||
}
|
||||
retrying(REMOVE_ATTEMPTS, REMOVE_PAUSE, || std::fs::remove_dir_all(path))
|
||||
.map_err(|e| IdeError::internal(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// On Windows a folder can't be deleted while a process still has a file in it open
|
||||
/// (os error 32): an agent's helper that outlived it, an antivirus scan, the search indexer.
|
||||
/// Those let go within moments, so removing is retried for a few seconds.
|
||||
const REMOVE_ATTEMPTS: u32 = 10;
|
||||
const REMOVE_PAUSE: Duration = Duration::from_millis(300);
|
||||
|
||||
fn retrying(attempts: u32, pause: Duration, mut op: impl FnMut() -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
for _ in 1..attempts {
|
||||
match op() {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => std::thread::sleep(pause),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
match op() {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
r => r,
|
||||
}
|
||||
std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
|
||||
@@ -169,54 +149,6 @@ fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removing_is_retried_while_a_file_is_still_in_use() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
// A sharing violation as Windows reports it (os error 32), a vanished folder, other errors.
|
||||
let in_use = || Error::from_raw_os_error(32);
|
||||
for (fails, outcome) in [(0, true), (3, true), (REMOVE_ATTEMPTS - 1, true), (REMOVE_ATTEMPTS, false)] {
|
||||
let mut calls = 0;
|
||||
let r = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
|
||||
calls += 1;
|
||||
if calls <= fails { Err(in_use()) } else { Ok(()) }
|
||||
});
|
||||
assert_eq!(r.is_ok(), outcome, "{fails} failures");
|
||||
assert_eq!(calls, (fails + 1).min(REMOVE_ATTEMPTS));
|
||||
}
|
||||
let mut calls = 0;
|
||||
let gone = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
|
||||
calls += 1;
|
||||
if calls == 1 { Err(in_use()) } else { Err(Error::from(ErrorKind::NotFound)) }
|
||||
});
|
||||
assert!(gone.is_ok(), "deleted meanwhile: done");
|
||||
assert_eq!(calls, 2);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn removing_waits_for_a_windows_file_lock_to_be_released() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
let worktrees = tmp.path().join("wt");
|
||||
let created = create(&repo, &worktrees, "Locked file", None).unwrap();
|
||||
let file = std::fs::OpenOptions::new().read(true).share_mode(0).open(created.path.join("a.txt")).unwrap();
|
||||
assert_eq!(std::fs::remove_file(created.path.join("a.txt")).unwrap_err().raw_os_error(), Some(32));
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
drop(file);
|
||||
});
|
||||
let result = remove(&repo, &worktrees, &created.path);
|
||||
release.join().unwrap();
|
||||
result.unwrap();
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_are_branch_safe() {
|
||||
assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns");
|
||||
|
||||
+20
-220
@@ -21,18 +21,6 @@ pub struct FontFamily {
|
||||
pub monospaced: bool,
|
||||
}
|
||||
|
||||
/// PANOSE bProportion value 9 means "Monospaced"; DirectWrite's
|
||||
/// `IsMonospacedFont` alone misses fonts that only carry this classification.
|
||||
const PANOSE_MONOSPACED: u8 = 9;
|
||||
|
||||
/// One face counts as monospaced when the font system flags it fixed pitch, its
|
||||
/// PANOSE proportion is "Monospaced", or its glyph advances are uniform
|
||||
/// (fonts with wrong/missing metadata, e.g. bitmap-style TTFs).
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn face_is_mono(is_monospaced_font: bool, panose_proportion: u8, advances_equal: bool) -> bool {
|
||||
is_monospaced_font || panose_proportion == PANOSE_MONOSPACED || advances_equal
|
||||
}
|
||||
|
||||
type Cache = Mutex<Option<Arc<[FontFamily]>>>;
|
||||
|
||||
/// The installed families, sorted case-insensitively, read on first use and
|
||||
@@ -207,9 +195,7 @@ mod imp {
|
||||
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
|
||||
unsafe {
|
||||
let collection = CTFontCollection::from_available_fonts(None);
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
descriptors
|
||||
.cast_unchecked::<CTFontDescriptor>()
|
||||
@@ -285,16 +271,14 @@ mod imp {
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::Win32::Graphics::DirectWrite::IDWriteFontFace1;
|
||||
use windows::Win32::Graphics::DirectWrite::{
|
||||
DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory, IDWriteFactory3, IDWriteFactory5, IDWriteFont,
|
||||
IDWriteFont1, IDWriteFontCollection, IDWriteFontFamily, IDWriteLocalizedStrings,
|
||||
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWriteCreateFactory, IDWriteFactory, IDWriteFont1, IDWriteLocalizedStrings,
|
||||
};
|
||||
use windows::core::{BOOL, Interface, PCWSTR, w};
|
||||
use windows::core::{BOOL, Interface, w};
|
||||
|
||||
/// The installed families, named in English when the family has an
|
||||
/// English name (what CSS matches), else its first name.
|
||||
/// The system font collection's families, named in English when the
|
||||
/// family has an English name (what CSS matches), else its first name.
|
||||
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: plain DirectWrite COM calls on interfaces we own.
|
||||
unsafe { read(refresh) }.unwrap_or_default()
|
||||
@@ -302,85 +286,11 @@ mod imp {
|
||||
|
||||
unsafe fn read(refresh: bool) -> windows::core::Result<Vec<(String, bool)>> {
|
||||
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
|
||||
let mut faces = Vec::new();
|
||||
// Windows 10+: the newer collection covers registered per-user fonts.
|
||||
// Exclude downloadable fonts that are not installed on this machine.
|
||||
// Bitmap .FON fonts (e.g. the stock raster Fixedsys) are invisible to
|
||||
// DirectWrite either way: it only reads TrueType/OpenType files.
|
||||
let mut collection = None;
|
||||
if let Ok(factory3) = factory.cast::<IDWriteFactory3>() {
|
||||
let mut local = None;
|
||||
if unsafe { factory3.GetSystemFontCollection(false, &mut local, refresh) }.is_ok() {
|
||||
collection = local.map(Into::into);
|
||||
}
|
||||
}
|
||||
if collection.is_none() {
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
|
||||
}
|
||||
if let Some(collection) = collection {
|
||||
faces.extend(collect(&collection));
|
||||
}
|
||||
// DirectWrite enumerates via the font registry, so font files copied
|
||||
// into the per-user directory without registering (no HKCU entry)
|
||||
// are invisible to the calls above; scan the directory directly.
|
||||
faces.extend(unsafe { user_dir_faces(&factory) });
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
/// Families from `%LOCALAPPDATA%\Microsoft\Windows\Fonts` files, registered
|
||||
/// or not, via a font set built from the files on disk.
|
||||
unsafe fn user_dir_faces(factory: &IDWriteFactory) -> Vec<(String, bool)> {
|
||||
let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") else {
|
||||
return Vec::new();
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
|
||||
let Some(collection) = collection else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let dir = std::path::Path::new(&local_app_data).join(r"Microsoft\Windows\Fonts");
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let files: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
matches!(
|
||||
p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
|
||||
Some("ttf" | "otf" | "ttc" | "otc")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
unsafe { file_faces(factory, &files) }
|
||||
}
|
||||
|
||||
unsafe fn file_faces(factory: &IDWriteFactory, files: &[std::path::PathBuf]) -> Vec<(String, bool)> {
|
||||
if files.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(factory3) = factory.cast::<IDWriteFactory3>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(factory5) = factory.cast::<IDWriteFactory5>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(builder) = (unsafe { factory5.CreateFontSetBuilder() }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
for path in files {
|
||||
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
// SAFETY: wide is a valid NUL-terminated string for the calls.
|
||||
let Ok(file) = (unsafe { factory.CreateFontFileReference(PCWSTR(wide.as_ptr()), None) }) else {
|
||||
continue;
|
||||
};
|
||||
let _ = unsafe { builder.AddFontFile(&file) };
|
||||
}
|
||||
let Ok(set) = (unsafe { builder.CreateFontSet() }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(collection) = (unsafe { factory3.CreateFontCollectionFromFontSet(&set) }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
collect(&collection)
|
||||
}
|
||||
|
||||
fn collect(collection: &IDWriteFontCollection) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..unsafe { collection.GetFontFamilyCount() } {
|
||||
let Ok(family) = (unsafe { collection.GetFontFamily(i) }) else {
|
||||
@@ -389,120 +299,19 @@ mod imp {
|
||||
let Some(name) = unsafe { family.GetFamilyNames() }.ok().and_then(|n| unsafe { english(&n) }) else {
|
||||
continue;
|
||||
};
|
||||
out.push((name, unsafe { family_is_mono(&family) }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A family is monospaced when any face reports fixed pitch, PANOSE
|
||||
/// proportion 9, or uniform glyph advances; probing every face matches
|
||||
/// `normalize`'s any-face merge (macOS and Linux already flag every face,
|
||||
/// not just the regular one).
|
||||
unsafe fn family_is_mono(family: &IDWriteFontFamily) -> bool {
|
||||
(0..unsafe { family.GetFontCount() }).any(|i| {
|
||||
let Ok(font) = (unsafe { family.GetFont(i) }) else {
|
||||
return false;
|
||||
};
|
||||
if let Ok(font1) = font.cast::<IDWriteFont1>() {
|
||||
let is_monospaced = unsafe { font1.IsMonospacedFont() }.as_bool();
|
||||
// The proportion byte is defined for text/display fonts only.
|
||||
let panose = unsafe { font1.GetPanose().values };
|
||||
let panose_proportion = if panose[0] == 2 { panose[3] } else { 0 };
|
||||
if super::face_is_mono(is_monospaced, panose_proportion, false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
unsafe { advances_equal(&font) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Some monospaced fonts carry no fixed-pitch metadata at all (bitmap-style
|
||||
/// TTFs like Fixedsys Excelsior): compare the advance widths of 'i' and
|
||||
/// 'W'. Dual-width CJK monospace fonts pass too (both half-width).
|
||||
unsafe fn advances_equal(font: &IDWriteFont) -> bool {
|
||||
let Ok(face) = (unsafe { font.CreateFontFace() }) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(face1) = face.cast::<IDWriteFontFace1>() else {
|
||||
return false;
|
||||
};
|
||||
let codepoints = ['i' as u32, 'W' as u32];
|
||||
let mut indices = [0u16; 2];
|
||||
if unsafe { face.GetGlyphIndices(codepoints.as_ptr(), 2, indices.as_mut_ptr()) }.is_err()
|
||||
|| indices.contains(&0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut advances = [0i32; 2];
|
||||
unsafe { face1.GetDesignGlyphAdvances(2, indices.as_ptr(), advances.as_mut_ptr(), false) }.is_ok()
|
||||
&& advances[0] > 0
|
||||
&& advances[0] == advances[1]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_probe_distinguishes_monospace_proportional_and_symbol_fonts() {
|
||||
unsafe {
|
||||
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
|
||||
let mut collection = None;
|
||||
factory.GetSystemFontCollection(&mut collection, false).unwrap();
|
||||
let collection = collection.unwrap();
|
||||
for (name, expected) in [
|
||||
("Consolas", true),
|
||||
("Arial", false),
|
||||
("Times New Roman", false),
|
||||
("Wingdings", false),
|
||||
("Webdings", false),
|
||||
("Segoe MDL2 Assets", false),
|
||||
] {
|
||||
let wide: Vec<_> = name.encode_utf16().chain(Some(0)).collect();
|
||||
let mut index = 0;
|
||||
let mut exists = BOOL(0);
|
||||
collection.FindFamilyName(PCWSTR(wide.as_ptr()), &mut index, &mut exists).unwrap();
|
||||
assert!(exists.as_bool(), "{name} ships with Windows");
|
||||
let family = collection.GetFontFamily(index).unwrap();
|
||||
for i in 0..family.GetFontCount() {
|
||||
let font = family.GetFont(i).unwrap();
|
||||
assert_eq!(advances_equal(&font), expected, "{name} face {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_files_include_every_family_in_a_collection() {
|
||||
let path = std::path::PathBuf::from(std::env::var_os("WINDIR").unwrap()).join(r"Fonts\cambria.ttc");
|
||||
unsafe {
|
||||
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
|
||||
let faces = file_faces(&factory, &[path]);
|
||||
for name in ["Cambria", "Cambria Math"] {
|
||||
assert!(faces.iter().any(|(family, _)| family == name), "missing {name}: {faces:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_families_available_only_as_downloads() {
|
||||
use windows::Win32::Graphics::DirectWrite::{DWRITE_E_REMOTEFONT, DWRITE_LOCALITY_LOCAL, IDWriteFont3};
|
||||
|
||||
unsafe {
|
||||
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
|
||||
let factory3: IDWriteFactory3 = factory.cast().unwrap();
|
||||
let set = factory3.GetSystemFontSet().unwrap();
|
||||
let collection = factory3.CreateFontCollectionFromFontSet(&set).unwrap();
|
||||
let listed = super::normalize(read(false).unwrap());
|
||||
let user = super::normalize(user_dir_faces(&factory));
|
||||
for i in 0..collection.GetFontFamilyCount() {
|
||||
let family = collection.GetFontFamily(i).unwrap();
|
||||
let name = english(&family.GetFamilyNames().unwrap()).unwrap();
|
||||
let remote_only = (0..family.GetFontCount()).all(|j| match family.GetFont(j) {
|
||||
Ok(font) => font.cast::<IDWriteFont3>().unwrap().GetLocality() != DWRITE_LOCALITY_LOCAL,
|
||||
Err(error) => error.code() == DWRITE_E_REMOTEFONT,
|
||||
});
|
||||
if remote_only && !user.iter().any(|f| f.name == name) {
|
||||
assert!(!listed.iter().any(|f| f.name == name), "{name} is not installed locally");
|
||||
}
|
||||
let mono = unsafe {
|
||||
family.GetFirstMatchingFont(
|
||||
DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWRITE_FONT_STRETCH_NORMAL,
|
||||
DWRITE_FONT_STYLE_NORMAL,
|
||||
)
|
||||
}
|
||||
.ok()
|
||||
.and_then(|f| f.cast::<IDWriteFont1>().ok())
|
||||
.is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool());
|
||||
out.push((name, mono));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
unsafe fn english(names: &IDWriteLocalizedStrings) -> Option<String> {
|
||||
@@ -667,15 +476,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panose_monospaced_proportion_counts_as_mono() {
|
||||
assert!(face_is_mono(true, 0, false));
|
||||
assert!(face_is_mono(false, PANOSE_MONOSPACED, false));
|
||||
assert!(face_is_mono(false, 0, true));
|
||||
assert!(!face_is_mono(false, 0, false));
|
||||
assert!(!face_is_mono(false, 3, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fc_list_spacing_marks_monospace() {
|
||||
let out = "DejaVu Sans Mono\t100\nDejaVu Sans\t\nNoto Sans Mono CJK SC\t90\nTerminus\t110\nCantarell\nFira\\-Code\tmono\n\t100\n";
|
||||
|
||||
@@ -76,17 +76,9 @@ async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static)
|
||||
tokio::task::spawn_blocking(f).await.ok()
|
||||
}
|
||||
|
||||
/// Items from plugins carry no server: commands don't apply, and a code
|
||||
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
|
||||
/// Items from plugins carry no server (resolve and commands don't apply).
|
||||
const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
|
||||
/// `data` of a plugin's code action whose edits it computes when picked.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PluginActionRef {
|
||||
plugin: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
|
||||
fn plugin_completion_kind(kind: &str) -> Option<CompletionKind> {
|
||||
Some(match kind {
|
||||
@@ -128,14 +120,14 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
|
||||
kind,
|
||||
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
|
||||
sort_text: item.sort_text,
|
||||
filter_text: item.filter_text,
|
||||
filter_text: None,
|
||||
preselect: false,
|
||||
deprecated: false,
|
||||
insert_text: item.insert_text,
|
||||
is_snippet: item.snippet,
|
||||
is_snippet: false,
|
||||
replace_range: item.replace,
|
||||
replace_range_tab: None,
|
||||
additional_edits: item.additional_edits,
|
||||
additional_edits: Vec::new(),
|
||||
commit_characters: Vec::new(),
|
||||
command: None,
|
||||
data: None,
|
||||
@@ -322,7 +314,7 @@ pub async fn lsp_code_actions(
|
||||
};
|
||||
let plugin_actions: Vec<CodeAction> = plugin_actions
|
||||
.into_iter()
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, a))
|
||||
.map(|(_, a)| plugin_code_action(a))
|
||||
.filter(|a| {
|
||||
only.is_empty()
|
||||
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
|
||||
@@ -338,53 +330,24 @@ pub async fn lsp_code_actions(
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
// With `data` and no edits, the plugin computes them when the action is picked.
|
||||
let lazy = a.edits.is_empty() && a.data.is_some();
|
||||
let files: Vec<_> = a
|
||||
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
|
||||
let files = a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
|
||||
.collect();
|
||||
let data = a
|
||||
.data
|
||||
.filter(|_| lazy)
|
||||
.and_then(|data| serde_json::to_value(PluginActionRef { plugin: plugin.to_owned(), data }).ok());
|
||||
CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
is_preferred: a.preferred,
|
||||
disabled_reason: None,
|
||||
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
command: None,
|
||||
data,
|
||||
data: None,
|
||||
server: PLUGIN_SERVER,
|
||||
}
|
||||
}
|
||||
|
||||
/// A code action resolved by whoever offered it: its language server, or
|
||||
/// the plugin its `data` names.
|
||||
async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
|
||||
if action.server != PLUGIN_SERVER {
|
||||
return host.project(window)?.lsp.resolve_code_action(doc, action).await;
|
||||
}
|
||||
let Some(r) = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok()) else {
|
||||
return Ok(action);
|
||||
};
|
||||
let (plugins, d) = plugin_providers(host, window, doc, ProviderKind::CodeActions)
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("the plugin that offered this action is gone"))?;
|
||||
let pending = ide_plugin::CodeAction {
|
||||
title: action.title.clone(),
|
||||
kind: action.kind.clone(),
|
||||
preferred: action.is_preferred,
|
||||
edits: Vec::new(),
|
||||
data: Some(r.data),
|
||||
};
|
||||
let plugin = r.plugin.clone();
|
||||
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
|
||||
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
pub async fn lsp_code_action_query(
|
||||
host: &Host,
|
||||
@@ -418,7 +381,7 @@ pub async fn lsp_resolve_code_action(
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let action = resolve_action(host, window, doc, action).await?;
|
||||
let action = project.lsp.resolve_code_action(doc, action).await?;
|
||||
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
|
||||
}
|
||||
|
||||
@@ -428,7 +391,7 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
let action = if action.edit.is_none() && action.command.is_none() {
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let action = resolve_action(host, window, doc, action).await?;
|
||||
let action = project.lsp.resolve_code_action(doc, action).await?;
|
||||
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
|
||||
} else {
|
||||
action
|
||||
@@ -838,25 +801,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_plugin_action_with_data_and_no_edits_is_resolved_by_its_plugin() {
|
||||
let action = |edits: Vec<(std::path::PathBuf, Vec<TextEdit>)>, data: Option<&str>| ide_plugin::CodeAction {
|
||||
title: "Fix".into(),
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits,
|
||||
data: data.map(str::to_owned),
|
||||
};
|
||||
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
|
||||
assert!(lazy.edit.is_none());
|
||||
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
|
||||
assert_eq!((r.plugin.as_str(), r.data.as_str()), ("acme.x", "ref"));
|
||||
// With edits, `data` is the plugin's business: applied as is.
|
||||
let edit = TextEdit { range: Range::default(), new_text: "x".into() };
|
||||
let eager = plugin_code_action("acme.x", action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
assert!(eager.edit.is_some() && eager.data.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_completion_kind_the_plugin_api_documents_is_understood() {
|
||||
let wit = include_str!("../../../../wit/providers.wit");
|
||||
|
||||
@@ -292,21 +292,6 @@ impl PluginHost {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The edits of an action `plugin_id` returned with `data`, now that the
|
||||
/// user picked it: a call with the command budget, not the edit path's.
|
||||
pub fn resolve_code_action(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
doc: &ProviderDocument,
|
||||
action: CodeAction,
|
||||
) -> ide_core::IdeResult<CodeAction> {
|
||||
let d = Arc::new(doc.clone());
|
||||
let timeout = self.limits.timeout;
|
||||
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
|
||||
rt.resolve_code_action(&d, &action, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
|
||||
|
||||
@@ -274,15 +274,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "plugin_set_theme_frame"
|
||||
| "plugin_catalog"
|
||||
| "plugin_assets"
|
||||
| "plugin_market"
|
||||
| "plugin_market_url"
|
||||
| "plugin_set_market_url"
|
||||
| "plugin_market_download"
|
||||
| "plugin_unpack_package"
|
||||
| "plugin_stage_package"
|
||||
| "plugin_preview_install"
|
||||
| "plugin_read_intellij_theme"
|
||||
| "plugin_installed_intellij_themes"
|
||||
| "notebook_open"
|
||||
| "notebook_get"
|
||||
| "notebook_edit"
|
||||
@@ -328,31 +319,12 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
) || cmd.starts_with("plugin:")
|
||||
}
|
||||
|
||||
/// Commands that change which plugin code runs: allowed where no project is open (the
|
||||
/// welcome screen), otherwise only in a trusted project.
|
||||
pub fn projectless_command_allowed(cmd: &str) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
"plugin_install_dir"
|
||||
| "plugin_uninstall"
|
||||
| "plugin_set_enabled"
|
||||
| "plugin_grant"
|
||||
| "plugin_set_capability"
|
||||
| "plugin_roll_back"
|
||||
| "plugin_reload"
|
||||
)
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
|
||||
if restricted_command_allowed(command) {
|
||||
return Ok(());
|
||||
}
|
||||
match self.project(window) {
|
||||
Ok(project) => project.require_trust(),
|
||||
Err(_) if projectless_command_allowed(command) => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,19 +123,6 @@ fn misbehave_pkg() -> PathBuf {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
|
||||
}
|
||||
|
||||
/// Whether `f` holds within 5 s. A call a slow runner keeps waiting behind
|
||||
/// another (or behind a new instance starting) holds once it caught up.
|
||||
fn eventually(mut f: impl FnMut() -> bool) -> bool {
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
while !f() {
|
||||
if Instant::now() > until {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A theme package with a light and a dark theme, plus licence files, in `dir`.
|
||||
fn write_theme_pair(dir: &Path) {
|
||||
std::fs::create_dir_all(dir.join("t")).unwrap();
|
||||
@@ -410,22 +397,26 @@ fn crashes_restart_with_backoff_until_the_circuit_opens() {
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.activations, info.error), (PluginStatus::Active, 2, None));
|
||||
|
||||
// Three in a row: stopped for the session, backing off between them (the wait
|
||||
// doubles: `Limits::backoff`, tested where it is defined; wall-clock waits on CI
|
||||
// are too noisy to compare).
|
||||
// Three in a row: stopped for the session, with growing waits between.
|
||||
f.take_events();
|
||||
for crash in 0..3 {
|
||||
// How long each crash kept the next call waiting, without the restart itself, whose
|
||||
// instantiation time varies a lot on CI.
|
||||
let mut waits = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let started = Instant::now();
|
||||
let err = loop {
|
||||
let attempt = Instant::now();
|
||||
match f.run("misbehave", "panic", None) {
|
||||
Err(e) if e.contains("restarts in") => std::thread::sleep(Duration::from_millis(20)),
|
||||
other => break other.unwrap_err(),
|
||||
other => {
|
||||
waits.push(attempt - started);
|
||||
break other.unwrap_err();
|
||||
}
|
||||
}
|
||||
};
|
||||
assert!(err.contains("crashed"), "{err}");
|
||||
if crash < 2 {
|
||||
assert!(f.run("misbehave", "echo", None).unwrap_err().contains("restarts in"), "backing off");
|
||||
}
|
||||
}
|
||||
assert!(waits[1] >= backoff / 2 && waits[2] > waits[1] + backoff / 2, "the wait doubles: {waits:?}");
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Failed);
|
||||
assert!(info.error.as_deref().is_some_and(|e| e.contains("3 times in a row")), "{:?}", info.error);
|
||||
@@ -540,22 +531,16 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
|
||||
let provider = host.syntax_provider();
|
||||
let mis = LanguageId::new("mis");
|
||||
|
||||
// The first call starts the instance, which may take longer than the soft deadline.
|
||||
let mut tokens = None;
|
||||
assert!(eventually(|| {
|
||||
tokens = provider.highlights(&mis, "fast");
|
||||
tokens.is_some()
|
||||
}));
|
||||
let tokens = tokens.unwrap();
|
||||
let tokens = provider.highlights(&mis, "fast").unwrap();
|
||||
assert_eq!((tokens[0].start, tokens[0].end, tokens[0].kind.as_str()), (0, 4, "keyword"));
|
||||
|
||||
// Slower than the soft deadline: dropped (however late this thread gets
|
||||
// to look), but the instance lives on.
|
||||
assert!(provider.highlights(&mis, "sleep:150").is_none());
|
||||
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
assert!(provider.highlights(&mis, "fast").is_some());
|
||||
let info = host.info_of("misbehave").unwrap();
|
||||
assert!(info.stats.slow_calls >= 1, "{:?}", info.stats);
|
||||
assert_eq!((info.stats.crashes, info.stats.activations), (0, 1));
|
||||
assert_eq!((info.stats.slow_calls, info.stats.crashes, info.stats.activations), (1, 0, 1));
|
||||
|
||||
// Past the hard deadline: interrupted; the next call gets a new instance.
|
||||
assert!(provider.highlights(&mis, "spin").is_none());
|
||||
@@ -564,8 +549,7 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
|
||||
let info = host.info_of("misbehave").unwrap();
|
||||
assert_eq!(info.stats.crashes, 1, "{:?}", info.error);
|
||||
assert!(info.error.as_deref().is_some_and(|e| e.contains("timed out after 400ms")), "{:?}", info.error);
|
||||
// The new instance may take longer to start than the soft deadline on a slow runner.
|
||||
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
|
||||
assert!(provider.highlights(&mis, "fast").is_some());
|
||||
assert_eq!(host.info_of("misbehave").unwrap().stats.activations, 2);
|
||||
}
|
||||
|
||||
@@ -574,10 +558,9 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
|
||||
#[test]
|
||||
fn providers_of_several_plugins_run_concurrently() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// A budget no runner misses, so only the overlap is measured.
|
||||
let limits = Limits {
|
||||
edit_soft: Duration::from_secs(5),
|
||||
edit_hard: Duration::from_secs(10),
|
||||
edit_soft: Duration::from_millis(300),
|
||||
edit_hard: Duration::from_secs(2),
|
||||
fuel: u64::MAX,
|
||||
..Limits::default()
|
||||
};
|
||||
@@ -610,12 +593,12 @@ fn providers_of_several_plugins_run_concurrently() {
|
||||
assert_eq!(host.provide_completion(&doc("fast"), at, None).len(), 2);
|
||||
|
||||
let started = Instant::now();
|
||||
let items = host.provide_completion(&doc("sleep:500"), at, None);
|
||||
let items = host.provide_completion(&doc("sleep:200"), at, None);
|
||||
let elapsed = started.elapsed();
|
||||
let mut from: Vec<&str> = items.iter().map(|(p, _)| p.as_str()).collect();
|
||||
from.sort();
|
||||
assert_eq!(from, ["slow-a", "slow-b"], "both within the budget");
|
||||
assert!(elapsed < Duration::from_millis(950), "one after the other would take 1000ms: {elapsed:?}");
|
||||
assert!(elapsed < Duration::from_millis(350), "one after the other would take 400ms: {elapsed:?}");
|
||||
assert_eq!(host.info_of("slow-b").unwrap().stats.slow_calls, 0);
|
||||
}
|
||||
|
||||
@@ -635,7 +618,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
|
||||
assert_eq!(f.command_titles("misbehave").len(), 10, "contributions stay");
|
||||
assert_eq!(f.command_titles("misbehave").len(), 8, "contributions stay");
|
||||
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
|
||||
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
|
||||
|
||||
@@ -2410,56 +2393,3 @@ fn uninstalling_deletes_the_plugins_secrets() {
|
||||
f.host.uninstall("openidea.ai-completion").unwrap();
|
||||
assert!(keychain.0.lock().is_empty(), "{:?}", keychain.0.lock().keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Plugin API 1.0 leaves room to grow: event kinds are names, and the editor
|
||||
/// providers carry snippets, imports, lazily computed quick fixes and tagged
|
||||
/// problems.
|
||||
#[test]
|
||||
fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
let f = fixture(Limits::default());
|
||||
let pkg = f._tmp.path().join("misbehave-providers");
|
||||
copy_dir(&misbehave_pkg(), &pkg);
|
||||
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!("{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"),
|
||||
)
|
||||
.unwrap();
|
||||
f.install(&pkg);
|
||||
let doc = |text: &str| ide_plugin::ProviderDocument {
|
||||
path: f.project.join("a.txt"),
|
||||
language: "text".into(),
|
||||
version: 1,
|
||||
text: text.into(),
|
||||
};
|
||||
let at = ide_core::Pos::new(0, 0);
|
||||
|
||||
let items = f.host.provide_completion(&doc("rich"), at, None);
|
||||
let item = &items[0].1;
|
||||
assert_eq!(
|
||||
(item.insert_text.as_str(), item.snippet, item.filter_text.as_deref()),
|
||||
("f(${1:x})$0", true, Some("fun"))
|
||||
);
|
||||
assert_eq!(item.additional_edits[0].new_text, "use f;\n");
|
||||
|
||||
// Offered without edits; computed when picked.
|
||||
let actions = f.host.provide_code_actions(&doc("lazy"), ide_core::Range::default());
|
||||
let (plugin, action) = actions.into_iter().next().expect("offered");
|
||||
assert!(action.edits.is_empty() && action.data.as_deref() == Some("fix"));
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone()).unwrap();
|
||||
assert_eq!(resolved.edits[0].1[0].new_text, "fixed ");
|
||||
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
|
||||
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
|
||||
|
||||
// A kind this IDE doesn't know is refused, not a trap.
|
||||
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no event kind"), "{}", err.message);
|
||||
let err = f.host.run_command("misbehave", "subscribe", Some("file-saved"), f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("events"), "known, but not granted: {}", err.message);
|
||||
|
||||
// Tags the IDE knows, the tool that found it.
|
||||
let path = f.project.join("a.txt");
|
||||
f.host.run_command("misbehave", "diagnose", Some(&path.to_string_lossy()), f.inv()).unwrap();
|
||||
let d = f.workspace.diagnostics.lock().get(&("misbehave".into(), path)).cloned().unwrap();
|
||||
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
|
||||
}
|
||||
@@ -58,11 +58,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"vcs_write_patch",
|
||||
"remote_bootstrap_list",
|
||||
"deploy_test_connection",
|
||||
"plugin_market",
|
||||
"plugin_market_url",
|
||||
"plugin_set_market_url",
|
||||
"plugin_market_download",
|
||||
"plugin_preview_install",
|
||||
] {
|
||||
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
for window in ["main", "welcome"] {
|
||||
@@ -77,6 +72,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"tools_list",
|
||||
"tools_install",
|
||||
"tools_uninstall",
|
||||
"plugin_install_dir",
|
||||
"plugin_reload",
|
||||
"vcs_git_version",
|
||||
"docker_compose_op",
|
||||
"db_execute",
|
||||
@@ -96,12 +93,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
assert!(host.authorize_command(window, command).is_err(), "{window}: {command}");
|
||||
}
|
||||
}
|
||||
// Plugin installs run plugin code: from the welcome screen, but not in an untrusted project.
|
||||
for command in ["plugin_install_dir", "plugin_uninstall", "plugin_set_enabled", "plugin_reload"] {
|
||||
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
assert!(host.authorize_command("main", command).is_err(), "{command}");
|
||||
assert!(host.authorize_command("welcome", command).is_ok(), "{command}");
|
||||
}
|
||||
// These requests validate the vendor before any network access, independently of project trust.
|
||||
for window in ["main", "welcome"] {
|
||||
let error = ide_host::ops::tools::jdk_download_releases(
|
||||
|
||||
@@ -16,6 +16,3 @@ parking_lot.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -243,9 +243,6 @@ struct Inner {
|
||||
pid: Option<u32>,
|
||||
/// Fired when the reader task has stopped (EOF, error or kill).
|
||||
reader_done: CancellationToken,
|
||||
/// Fired once the child process has exited and been reaped (after [`Peer::kill`],
|
||||
/// also what it started); at once when there is no child.
|
||||
exited: CancellationToken,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
@@ -435,7 +432,6 @@ impl Peer {
|
||||
(child.wait().await.ok(), true)
|
||||
}
|
||||
};
|
||||
inner.exited.cancel();
|
||||
if !killed {
|
||||
// Bounded: a grandchild may keep the pipes open.
|
||||
let drained = async {
|
||||
@@ -514,11 +510,7 @@ impl Peer {
|
||||
kill: CancellationToken::new(),
|
||||
pid,
|
||||
reader_done: CancellationToken::new(),
|
||||
exited: CancellationToken::new(),
|
||||
});
|
||||
if pid.is_none() {
|
||||
inner.exited.cancel();
|
||||
}
|
||||
|
||||
// Writer: a single task drains the queue so messages never interleave.
|
||||
{
|
||||
@@ -712,13 +704,6 @@ impl Peer {
|
||||
self.inner.close_transport();
|
||||
self.inner.kill.cancel();
|
||||
}
|
||||
|
||||
/// Resolves once the child process is gone: exited on its own, or killed with what it
|
||||
/// started and reaped. [`closed`](Self::closed) can come earlier, at EOF. Until then the
|
||||
/// child may still hold files open, which on Windows can't be deleted.
|
||||
pub async fn exited(&self) {
|
||||
self.inner.exited.cancelled().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the pending entry and tells the remote side to stop when a request
|
||||
|
||||
@@ -413,65 +413,6 @@ async fn kill_terminates_process() {
|
||||
tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn exited_resolves_once_the_killed_process_is_reaped() {
|
||||
let peer =
|
||||
Peer::spawn("sleep", &["30".into()], &std::env::temp_dir(), &[], Arc::new(NoopHandler), Box::new(|_| {}))
|
||||
.unwrap();
|
||||
let pid = peer.pid().unwrap().to_string();
|
||||
let exited = tokio::time::timeout(Duration::from_millis(200), peer.exited()).await;
|
||||
assert!(exited.is_err(), "running until killed");
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), peer.exited()).await.unwrap();
|
||||
let alive = std::process::Command::new("kill").args(["-0", &pid]).status().unwrap().success();
|
||||
assert!(!alive, "reaped, not even a zombie");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn exited_resolves_at_once_without_a_process() {
|
||||
let (client, _server) = pair(Arc::new(NoopHandler), Arc::new(Recorder::default()));
|
||||
tokio::time::timeout(Duration::from_secs(1), client.exited()).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn exited_waits_for_a_windows_descendant_to_release_its_file() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("hold.ps1"),
|
||||
"$f=[IO.File]::Open('locked.txt','Create','ReadWrite','None'); [IO.File]::WriteAllText('ready.txt','ready'); Start-Sleep 30",
|
||||
)
|
||||
.unwrap();
|
||||
let peer = Peer::spawn(
|
||||
"cmd",
|
||||
&[
|
||||
"/D".into(),
|
||||
"/C".into(),
|
||||
"powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File hold.ps1".into(),
|
||||
],
|
||||
tmp.path(),
|
||||
&[],
|
||||
Arc::new(NoopHandler),
|
||||
Box::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !tmp.path().join("ready.txt").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let open = || std::fs::OpenOptions::new().read(true).share_mode(0).open(tmp.path().join("locked.txt"));
|
||||
assert_eq!(open().unwrap_err().raw_os_error(), Some(32));
|
||||
assert!(tokio::time::timeout(Duration::from_millis(200), peer.exited()).await.is_err());
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), peer.exited()).await.unwrap();
|
||||
drop(open().expect("exited must wait for the descendant's file handle to close"));
|
||||
}
|
||||
|
||||
/// A launcher that prints in the system code page and exits before speaking the
|
||||
/// protocol: `jdtls.bat` on a Chinese Windows when its Python step fails, then
|
||||
/// `pause` prints `请按任意键继续. . .` in GBK on stdout.
|
||||
|
||||
@@ -467,6 +467,7 @@ const MAX_SYNTAX_ITEMS: usize = 200_000;
|
||||
const MAX_BATCH: usize = 1000;
|
||||
|
||||
fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
use crate::types::ActionSource as S;
|
||||
match e {
|
||||
WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p),
|
||||
WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p),
|
||||
@@ -476,7 +477,14 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
|
||||
action: a.action,
|
||||
title: a.title,
|
||||
source: a.source.name().to_owned(),
|
||||
source: match a.source {
|
||||
S::Keyboard => wit_events::ActionSource::Keyboard,
|
||||
S::Menu => wit_events::ActionSource::Menu,
|
||||
S::Toolbar => wit_events::ActionSource::Toolbar,
|
||||
S::ContextMenu => wit_events::ActionSource::ContextMenu,
|
||||
S::Search => wit_events::ActionSource::Search,
|
||||
S::Other => wit_events::ActionSource::Other,
|
||||
},
|
||||
shortcut: a.shortcut,
|
||||
}),
|
||||
}
|
||||
@@ -662,17 +670,9 @@ impl Runtime {
|
||||
detail: i.detail.map(|d| clip(d, MAX_TITLE)),
|
||||
kind: i.kind.map(|k| clip(k, 32)),
|
||||
insert_text: clip(i.insert_text, 64 << 10),
|
||||
snippet: i.snippet,
|
||||
replace: i.replace.map(range_from_wit),
|
||||
sort_text: i.sort_text.map(|t| clip(t, 64)),
|
||||
filter_text: i.filter_text.map(|t| clip(t, MAX_TITLE)),
|
||||
documentation: i.documentation.map(|d| clip(d, 16 << 10)),
|
||||
additional_edits: i
|
||||
.additional_edits
|
||||
.into_iter()
|
||||
.take(MAX_ADDITIONAL_EDITS)
|
||||
.map(edit_from_wit)
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -699,20 +699,27 @@ impl Runtime {
|
||||
) -> Result<Vec<CodeAction>, CallError> {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
|
||||
}
|
||||
|
||||
/// The edits of an action `provide_code_actions` returned with `data`.
|
||||
pub fn resolve_code_action(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
action: &CodeAction,
|
||||
timeout: Duration,
|
||||
) -> Result<CodeAction, CallError> {
|
||||
let d = doc_to_wit(doc);
|
||||
let a = code_action_to_wit(action);
|
||||
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
|
||||
resolved.map(code_action_from_wit).map_err(|e| CallError::Guest(e.into()))
|
||||
Ok(actions
|
||||
.into_iter()
|
||||
.take(MAX_PROVIDER_ITEMS)
|
||||
.map(|a| CodeAction {
|
||||
title: clip(a.title, MAX_TITLE),
|
||||
kind: a.kind.map(|k| clip(k, 64)),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let edits = f
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|e| ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text })
|
||||
.collect();
|
||||
(std::path::PathBuf::from(f.path), edits)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn provide_definition(
|
||||
@@ -961,47 +968,6 @@ const MAX_VIEW_ITEMS: usize = 20_000;
|
||||
const MAX_CELL: usize = 64 << 10;
|
||||
const MAX_HTML: usize = 4 << 20;
|
||||
|
||||
/// Most additional edits of a completion item.
|
||||
const MAX_ADDITIONAL_EDITS: usize = 100;
|
||||
|
||||
fn edit_from_wit(e: wit_types::TextEdit) -> ide_core::TextEdit {
|
||||
ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text }
|
||||
}
|
||||
|
||||
fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
|
||||
CodeAction {
|
||||
title: clip(a.title, MAX_TITLE),
|
||||
kind: a.kind.map(|k| clip(k, 64)),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
}
|
||||
}
|
||||
|
||||
fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
|
||||
wit_prov::CodeAction {
|
||||
title: a.title.clone(),
|
||||
kind: a.kind.clone(),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.iter()
|
||||
.map(|(path, edits)| wit_prov::FileEdit {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
edits: edits
|
||||
.iter()
|
||||
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
data: a.data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
s.map(|s| clip(s, max))
|
||||
}
|
||||
@@ -1009,9 +975,6 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
fn view_from_wit(v: wit_vt::View) -> View {
|
||||
let body = match v.body {
|
||||
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
|
||||
wit_vt::Body::Other(e) => {
|
||||
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
|
||||
}
|
||||
wit_vt::Body::Tree(items) => ViewBody::Tree(
|
||||
items
|
||||
.into_iter()
|
||||
@@ -1043,8 +1006,10 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
fields
|
||||
.into_iter()
|
||||
.take(MAX_VIEW_ITEMS)
|
||||
.filter_map(|f| {
|
||||
let value = match f.value {
|
||||
.map(|f| ViewField {
|
||||
key: clip(f.key, 128),
|
||||
label: clip(f.label, MAX_TITLE),
|
||||
value: match f.value {
|
||||
wit_vt::FieldValue::Text(t) => FieldValue::Text(clip(t, MAX_CELL)),
|
||||
wit_vt::FieldValue::Multiline(t) => FieldValue::Multiline(clip(t, 1 << 20)),
|
||||
wit_vt::FieldValue::Checkbox(b) => FieldValue::Checkbox(b),
|
||||
@@ -1052,10 +1017,7 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
|
||||
selected: clip(selected, MAX_TITLE),
|
||||
},
|
||||
// A field type of a newer API: left out.
|
||||
wit_vt::FieldValue::Other(_) => return None,
|
||||
};
|
||||
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
@@ -1105,26 +1067,3 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
|
||||
ViewEvent::Message { text } => W::Message(text),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ext(kind: &str) -> wit_types::Extension {
|
||||
wit_types::Extension { kind: kind.into(), data: "{}".into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bodies_and_fields_of_a_newer_api_degrade() {
|
||||
let view = |body| wit_vt::View { toolbar: Vec::new(), body, status: None };
|
||||
let shown = view_from_wit(view(wit_vt::Body::Other(ext("markdown"))));
|
||||
assert_eq!(shown.body, ViewBody::Message("This view needs a newer version of OpenIDEA (markdown).".into()));
|
||||
let field = |key: &str, value| wit_vt::Field { key: key.into(), label: key.into(), value };
|
||||
let form = view_from_wit(view(wit_vt::Body::Form(vec![
|
||||
field("color", wit_vt::FieldValue::Other(ext("color"))),
|
||||
field("name", wit_vt::FieldValue::Text("n".into())),
|
||||
])));
|
||||
let ViewBody::Form(fields) = form.body else { panic!("a form") };
|
||||
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
|
||||
}
|
||||
}
|
||||
@@ -126,20 +126,6 @@ pub enum ActionSource {
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ActionSource {
|
||||
/// As plugins see it.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Keyboard => "keyboard",
|
||||
Self::Menu => "menu",
|
||||
Self::Toolbar => "toolbar",
|
||||
Self::ContextMenu => "context-menu",
|
||||
Self::Search => "search",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionPerformed {
|
||||
@@ -317,16 +303,6 @@ pub fn net_host(url: &str) -> Result<String, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_restart_backoff_doubles_up_to_a_minute() {
|
||||
let limits = Limits { restart_backoff: Duration::from_millis(150), ..Limits::default() };
|
||||
let waits: Vec<u128> = (1..=4).map(|n| limits.backoff(n).as_millis()).collect();
|
||||
assert_eq!(waits, [150, 300, 600, 1200]);
|
||||
assert_eq!(limits.backoff(30), Duration::from_millis(150 * 64), "the factor stops growing");
|
||||
let long = Limits { restart_backoff: Duration::from_secs(10), ..Limits::default() };
|
||||
assert_eq!(long.backoff(5), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -362,14 +338,9 @@ pub struct CompletionItem {
|
||||
/// WIT `completion-kind` name (`method`, `keyword`, …).
|
||||
pub kind: Option<String>,
|
||||
pub insert_text: String,
|
||||
/// `insert_text` has tab stops and placeholders.
|
||||
pub snippet: bool,
|
||||
pub replace: Option<ide_core::Range>,
|
||||
pub sort_text: Option<String>,
|
||||
pub filter_text: Option<String>,
|
||||
pub documentation: Option<String>,
|
||||
/// Applied with the item elsewhere in the document (an import).
|
||||
pub additional_edits: Vec<ide_core::TextEdit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -384,8 +355,6 @@ pub struct CodeAction {
|
||||
pub kind: Option<String>,
|
||||
pub preferred: bool,
|
||||
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
|
||||
/// The plugin's reference: the edits come from `resolve_code_action`.
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -440,11 +440,20 @@ impl vcs::Host for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from_wit(k: events::EventKind) -> EventKind {
|
||||
match k {
|
||||
events::EventKind::FileOpened => EventKind::FileOpened,
|
||||
events::EventKind::FileSaved => EventKind::FileSaved,
|
||||
events::EventKind::EditorFocused => EventKind::EditorFocused,
|
||||
events::EventKind::ActionPerformed => EventKind::ActionPerformed,
|
||||
events::EventKind::VcsChanged => EventKind::VcsChanged,
|
||||
events::EventKind::Scheduled => EventKind::Scheduled,
|
||||
}
|
||||
}
|
||||
|
||||
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"))));
|
||||
};
|
||||
fn subscribe(&mut self, kind: events::EventKind) -> Fallible<()> {
|
||||
let kind = kind_from_wit(kind);
|
||||
if let Some(cap) = kind.capability()
|
||||
&& let Err(e) = self.require(cap)
|
||||
{
|
||||
@@ -454,10 +463,8 @@ impl events::Host for StoreData {
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
|
||||
if let Some(kind) = EventKind::parse(&kind) {
|
||||
self.host.subscribe(kind, false);
|
||||
}
|
||||
fn unsubscribe(&mut self, kind: events::EventKind) -> wasmtime::Result<()> {
|
||||
self.host.subscribe(kind_from_wit(kind), false);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -535,10 +542,10 @@ impl diagnostics::Host for StoreData {
|
||||
diagnostics::Severity::Hint => ide_core::Severity::Hint,
|
||||
},
|
||||
message: clip(d.message, 4096),
|
||||
source: Some(d.source.map_or_else(|| source.clone(), |s| clip(s, 128))),
|
||||
source: Some(source.clone()),
|
||||
code: d.code.map(|c| clip(c, 128)),
|
||||
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
|
||||
deprecated: d.tags.iter().any(|t| t == "deprecated"),
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: Some(source.clone()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c9acbc021f3840649823cb7680e13907d429c6fda8271fdb0ae6e5ea89ed15ee
|
||||
86dfe70a30ab1f7955a16e96c1b32c8be663456b871cc4d3bed7dc6052c062da
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
|
||||
0dacb82ac35c1f132dc66e85dbf0663b729f818279d3b32cd06403e502fbf880
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
|
||||
0ad2e4c6977a576b7eb27fc1e535fd056dd9da64bdfe6cfda9ca9f57537d0384
|
||||
@@ -40,11 +40,3 @@ title = "insert"
|
||||
[[contributes.commands]]
|
||||
id = "cancel"
|
||||
title = "cancel"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "subscribe"
|
||||
title = "subscribe"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "diagnose"
|
||||
title = "diagnose"
|
||||
+4
-52
@@ -14,9 +14,7 @@ wit_bindgen::generate!({
|
||||
generate_all,
|
||||
});
|
||||
|
||||
use exports::openidea::plugin::providers::{
|
||||
self, CodeAction, CompletionItem, Document, FileEdit, InlineSuggestion, LineMarker,
|
||||
};
|
||||
use exports::openidea::plugin::providers::{self, CodeAction, CompletionItem, Document, InlineSuggestion, LineMarker};
|
||||
use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token};
|
||||
use exports::openidea::plugin::{commands, event_handler, lifecycle};
|
||||
use openidea::plugin::{invocation::Invocation, types};
|
||||
@@ -69,23 +67,6 @@ impl commands::Guest for Misbehave {
|
||||
}
|
||||
// The user gave up: not the plugin failing.
|
||||
"cancel" => Err(types::Error::Cancelled),
|
||||
// Event kinds are names: one this IDE doesn't know is refused.
|
||||
"subscribe" => openidea::plugin::events::subscribe(&arg.unwrap_or_default()).map(|()| None),
|
||||
// Tagged problems from another tool on the line 0 of `arg`.
|
||||
"diagnose" => {
|
||||
use openidea::plugin::diagnostics::{self, Diagnostic, Severity};
|
||||
let start = types::Position { line: 0, character: 0 };
|
||||
let d = Diagnostic {
|
||||
range: types::Range { start, end: start },
|
||||
severity: Severity::Warning,
|
||||
message: "unused".into(),
|
||||
code: None,
|
||||
source: Some("lint".into()),
|
||||
tags: vec!["unnecessary".into(), "glowing".into()],
|
||||
related: Vec::new(),
|
||||
};
|
||||
diagnostics::publish(&arg.unwrap_or_default(), &[d]).map(|()| None)
|
||||
}
|
||||
other => Err(types::Error::NotFound(format!("command {other}"))),
|
||||
}
|
||||
}
|
||||
@@ -132,24 +113,14 @@ impl syntax_provider::Guest for Misbehave {
|
||||
impl providers::Guest for Misbehave {
|
||||
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
|
||||
behave(&doc.text);
|
||||
// `rich`: a snippet that also adds an import.
|
||||
let rich = doc.text == "rich";
|
||||
let start = types::Position { line: 0, character: 0 };
|
||||
vec![CompletionItem {
|
||||
label: doc.path.clone(),
|
||||
detail: None,
|
||||
kind: Some("keyword".into()),
|
||||
insert_text: if rich { "f(${1:x})$0".into() } else { doc.path },
|
||||
snippet: rich,
|
||||
insert_text: doc.path,
|
||||
replace: None,
|
||||
sort_text: None,
|
||||
filter_text: rich.then(|| "fun".into()),
|
||||
documentation: None,
|
||||
additional_edits: if rich {
|
||||
vec![types::TextEdit { range: types::Range { start, end: start }, text: "use f;\n".into() }]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -157,27 +128,8 @@ impl providers::Guest for Misbehave {
|
||||
None
|
||||
}
|
||||
|
||||
/// `lazy`: one action whose edits come from `resolve-code-action`.
|
||||
fn code_actions(doc: Document, _selection: types::Range) -> Vec<CodeAction> {
|
||||
if doc.text != "lazy" {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![CodeAction {
|
||||
title: "Fix later".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
edits: Vec::new(),
|
||||
data: Some("fix".into()),
|
||||
}]
|
||||
}
|
||||
|
||||
fn resolve_code_action(doc: Document, action: CodeAction) -> Result<CodeAction, types::Error> {
|
||||
if action.data.as_deref() != Some("fix") {
|
||||
return Err(types::Error::Invalid("not one of mine".into()));
|
||||
}
|
||||
let start = types::Position { line: 0, character: 0 };
|
||||
let edit = types::TextEdit { range: types::Range { start, end: start }, text: "fixed ".into() };
|
||||
Ok(CodeAction { edits: vec![FileEdit { path: doc.path, edits: vec![edit] }], ..action })
|
||||
fn code_actions(_doc: Document, _selection: types::Range) -> Vec<CodeAction> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn definition(_doc: Document, _at: types::Position) -> Vec<types::Location> {
|
||||
|
||||
+2
-25
@@ -321,11 +321,6 @@ Events are batched: a running plugin gets them within a fraction of a second,
|
||||
an idle one collects them for 2 s before it is woken, so frequent events
|
||||
(Key Promoter's actions) don't keep it alive.
|
||||
|
||||
Kinds are names (`subscribe` takes a string), so later IDEs add kinds without
|
||||
a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind
|
||||
newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON).
|
||||
The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm.
|
||||
|
||||
### Providers
|
||||
|
||||
Declare what the plugin provides in the editor; only plugins whose
|
||||
@@ -343,14 +338,7 @@ Then `register_plugin!(MyPlugin, providers)` and implement
|
||||
the document (path, language, version, text). Results join the language
|
||||
servers' (completion lists, ⌥⏎ quick fixes, ⌘B, gutter icons; a code action
|
||||
carries edits for any files, applied as one change). A completion item's
|
||||
`kind` (`CompletionKind` in the SDK, a string in the WIT) picks its icon; it
|
||||
can be a `snippet` (`$1`, `${1:name}`, `$0`), match on `filter_text` instead
|
||||
of its label, and bring `additional_edits` elsewhere in the document (an
|
||||
import). A code action can leave `edits` empty and set `data`: its edits are
|
||||
then computed by `resolve_code_action` when the user picks it, under the
|
||||
command budget instead of the 50 ms. Problems (`publish_diagnostics`) can name
|
||||
the tool that found them (`source`) and be `unnecessary` (faded) or
|
||||
`deprecated` (struck through).
|
||||
`kind` (`CompletionKind` in the SDK, a string in the WIT) picks its icon.
|
||||
Editor providers run under the edit-path budget: 50 ms, after which the
|
||||
result is dropped, and an interruption at 500 ms. Every plugin providing a
|
||||
kind is asked at the same time, so each has the whole budget.
|
||||
@@ -405,9 +393,6 @@ collapse of items, form fields, table cells, inserted and deleted rows, and
|
||||
`html` messages. A tree is a flat list whose items name their `parent`; an
|
||||
`expandable` item gets its children when it is expanded. A table gets only
|
||||
the rows it shows (`viewport`): report `total_rows` and return those.
|
||||
Bodies, field types and view events added by later APIs travel as `Other {
|
||||
kind, data }`: an IDE that doesn't know a body says it needs updating, and
|
||||
leaves out a field type it doesn't know.
|
||||
|
||||
Showing a view activates the plugin (`on-view:<id>`). `views` become tool
|
||||
windows; a `customEditors` file (`priority = "default"`) opens in the
|
||||
@@ -905,10 +890,7 @@ only grows:
|
||||
Extend with new types and new functions (or interfaces). `types.error` is
|
||||
used everywhere and is no exception; new situations use `failed(string)`
|
||||
or `invalid(string)`. Sets that keep growing (completion, token, symbol and
|
||||
fold kinds, icons, event kinds, action sources, diagnostic tags) are
|
||||
strings for this reason, and variants that grow (`event`, view `body`,
|
||||
`view-event`, `field-value`) have an `other(extension)` case, a kind and
|
||||
JSON data, that carries later cases to older plugins and IDEs.
|
||||
fold kinds, icons) are strings for this reason.
|
||||
2. **Host interfaces (imports) may gain functions and interfaces**: older
|
||||
components don't import them.
|
||||
3. **Plugin interfaces (exports) never gain functions** once released: the
|
||||
@@ -920,11 +902,6 @@ only grows:
|
||||
minor with a message that says to update the IDE or rebuild with the SDK.
|
||||
5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and
|
||||
`API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`.
|
||||
6. **1.x calls are synchronous and pass documents whole.** Host calls block
|
||||
the plugin's instance, and providers get the document's text with each
|
||||
call. WASI 0.3 async components come with 2.0, behind an adapter in the
|
||||
host that keeps 1.x plugins running; incremental document sync, if
|
||||
needed, is a new interface.
|
||||
|
||||
Checked automatically:
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
28e4bf10a41bdda011111b0e150ebe96205c57f410bad98c19eea3f1286349d6
|
||||
aa09a92c0ba9d85a01c56bbf4455e40ae44f005b443ce1002bbe8011408ff975
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7e015486cc916f5edbac542863373fd7204f5bcd534f6c408d5f9d2b72913862
|
||||
908c2fd177ad2bb9a8a8492505151c3b81579191156c5ec53f705127f50a8ee9
|
||||
@@ -97,8 +97,12 @@ fn diagnostic(p: &Problem, text: &str) -> Diagnostic {
|
||||
let line_len = text.split('\n').nth(p.line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
|
||||
let start = p.column.unwrap_or(0).min(line_len);
|
||||
let end = if p.column.is_some() && start < line_len { start + 1 } else { line_len };
|
||||
let range = Range::new(Position::new(p.line, start), Position::new(p.line, end.max(start)));
|
||||
Diagnostic::new(range, p.severity, p.message.clone()).code(p.check.clone())
|
||||
Diagnostic {
|
||||
range: Range::new(Position::new(p.line, start), Position::new(p.line, end.max(start))),
|
||||
severity: p.severity,
|
||||
message: p.message.clone(),
|
||||
code: Some(p.check.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The project containing `path`.
|
||||
@@ -184,7 +188,6 @@ impl Providers for Checkstyle {
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: true,
|
||||
edits: vec![(doc.path.clone(), vec![edit])],
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a6cf59e74595e13908e2cbf1752c58665c6963de258413c9d7332d2a7dd9cbd0
|
||||
64270fa0f5b3084c78764f09b8ce161742fdbff9c28fb90590da39594e558db1
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b8dde206a74b222b93e61056d4cbe403617ae240d142e01e91420a2aacdb2da4
|
||||
22876f842f727a2b9aa292986df3393f5b5f30c5c34319f5665f7d2701c8accf
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a681c2abd4a65d6aeb62efd514e9b170f4fca52b0c14d2d380a3b04be0a3fbc6
|
||||
14c60782eca79a25cf2c38d58c05be0eb97c4afc607d4cc83302d1445d094aab
|
||||
@@ -52,9 +52,12 @@ pub fn diagnostics(text: &str, reports: &[Report]) -> Vec<Diagnostic> {
|
||||
.map(|r| {
|
||||
let line = r.line.map_or(0, |l| l.saturating_sub(1)).min(lines.len().saturating_sub(1) as u32);
|
||||
let len = lines.get(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
|
||||
let range = Range::new(Position::new(line, 0), Position::new(line, len));
|
||||
let severity = if r.error { Severity::Error } else { Severity::Warning };
|
||||
Diagnostic::new(range, severity, r.message.clone()).code("graphviz")
|
||||
Diagnostic {
|
||||
range: Range::new(Position::new(line, 0), Position::new(line, len)),
|
||||
severity: if r.error { Severity::Error } else { Severity::Warning },
|
||||
message: r.message.clone(),
|
||||
code: Some("graphviz".into()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
3c83bc9559df54457e19e650cdd21bab9d609e22daaa1fc4f596910b70834a34
|
||||
7f5aacde2b8a8bcee5365c8cbda91045195688899c6537dc2159037cca74a672
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
63f1bbdf6eca5a4642cd3453ae384ff3f876de0373c12008b757a63852164b71
|
||||
45a7ee993b5c234b0d25e5b05ef60e714fa3edf63d6bd0d6a29b13587c8d09ce
|
||||
@@ -129,8 +129,12 @@ pub fn header_edit(path: &str, text: &str, year: i64, owner: &str, license: &str
|
||||
fn missing(text: &str) -> Diagnostic {
|
||||
let line = insert_line(text);
|
||||
let len = text.lines().nth(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
|
||||
Diagnostic::new(Range::new(Position::new(line, 0), Position::new(line, len)), Severity::Info, "No license header")
|
||||
.code(CODE)
|
||||
Diagnostic {
|
||||
range: Range::new(Position::new(line, 0), Position::new(line, len)),
|
||||
severity: Severity::Info,
|
||||
message: "No license header".into(),
|
||||
code: Some(CODE.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports `path` when it takes a header and has none, clears it otherwise.
|
||||
@@ -270,7 +274,6 @@ impl Providers for LicenseHeader {
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: true,
|
||||
edits: vec![(doc.path.clone(), vec![edit])],
|
||||
data: None,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
0db5cb24c358651d0e728ab3efb52286290c1a38ec379435225c935d4165ad6a
|
||||
d5cf81455ae8b2b0fd6d1402d8775913bd7999db62b9bdf87675890ce52b1e80
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
257478b050ea360c55e904f2a89c6069293139ff0d97ba860de3f29dac927787
|
||||
227c8150c6ad7a5a20236764f149c876cbbc3b082763bf2e7fc05de99726b740
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
21e7510d6a3712bf0f21d53a5c8d652e3c2787d89a1fc25998cde96efe124207
|
||||
bbadcaa7a86a3e0a3f8be6d86aed2229d5cdfd128fda3435b4d64c7e19ad1df2
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
04844bfbfbcb1770f4f4742658944c592922b037b2387e9340de58ef0d9baacc
|
||||
09cc92e8e731f8f59f3857a71f9a30cc3d9e04a1c32092920f573b0f26592855
|
||||
@@ -152,12 +152,24 @@ mod imp {
|
||||
wit::vcs::fetch(p).map_err(error)
|
||||
}
|
||||
|
||||
fn kind(k: EventKind) -> wit::events::EventKind {
|
||||
use wit::events::EventKind as K;
|
||||
match k {
|
||||
EventKind::FileOpened => K::FileOpened,
|
||||
EventKind::FileSaved => K::FileSaved,
|
||||
EventKind::EditorFocused => K::EditorFocused,
|
||||
EventKind::ActionPerformed => K::ActionPerformed,
|
||||
EventKind::VcsChanged => K::VcsChanged,
|
||||
EventKind::Scheduled => K::Scheduled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(k: EventKind) -> Result<(), Error> {
|
||||
wit::events::subscribe(k.name()).map_err(error)
|
||||
wit::events::subscribe(kind(k)).map_err(error)
|
||||
}
|
||||
|
||||
pub fn unsubscribe(k: EventKind) {
|
||||
wit::events::unsubscribe(k.name())
|
||||
wit::events::unsubscribe(kind(k))
|
||||
}
|
||||
|
||||
pub fn begin_task(title: &str, cancellable: bool) -> TaskRes {
|
||||
@@ -177,7 +189,7 @@ mod imp {
|
||||
}
|
||||
|
||||
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
|
||||
use wit::diagnostics::{Diagnostic as D, RelatedInformation as R, Severity as S};
|
||||
use wit::diagnostics::{Diagnostic as D, Severity as S};
|
||||
let items: Vec<D> = items
|
||||
.iter()
|
||||
.map(|d| D {
|
||||
@@ -190,23 +202,6 @@ mod imp {
|
||||
},
|
||||
message: d.message.clone(),
|
||||
code: d.code.clone(),
|
||||
source: d.source.clone(),
|
||||
tags: [(d.unnecessary, "unnecessary"), (d.deprecated, "deprecated")]
|
||||
.into_iter()
|
||||
.filter(|(on, _)| *on)
|
||||
.map(|(_, tag)| tag.to_owned())
|
||||
.collect(),
|
||||
related: d
|
||||
.related
|
||||
.iter()
|
||||
.map(|r| R {
|
||||
location: wit::types::Location {
|
||||
path: r.location.path.clone(),
|
||||
range: range_out(r.location.range),
|
||||
},
|
||||
message: r.message.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
wit::diagnostics::publish(path, &items).map_err(error)
|
||||
@@ -269,7 +264,7 @@ mod imp {
|
||||
}
|
||||
|
||||
pub fn event(e: wit::events::Event) -> Event {
|
||||
use wit::events::Event as E;
|
||||
use wit::events::{ActionSource as S, Event as E};
|
||||
match e {
|
||||
E::FileOpened(p) => Event::FileOpened(p),
|
||||
E::FileSaved(p) => Event::FileSaved(p),
|
||||
@@ -279,10 +274,16 @@ mod imp {
|
||||
E::ActionPerformed(a) => Event::ActionPerformed(ActionPerformed {
|
||||
action: a.action,
|
||||
title: a.title,
|
||||
source: ActionSource::parse(&a.source),
|
||||
source: match a.source {
|
||||
S::Keyboard => ActionSource::Keyboard,
|
||||
S::Menu => ActionSource::Menu,
|
||||
S::Toolbar => ActionSource::Toolbar,
|
||||
S::ContextMenu => ActionSource::ContextMenu,
|
||||
S::Search => ActionSource::Search,
|
||||
S::Other => ActionSource::Other,
|
||||
},
|
||||
shortcut: a.shortcut,
|
||||
}),
|
||||
E::Other(x) => Event::Other { kind: x.kind, data: x.data },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,16 +255,6 @@ macro_rules! __providers_exports {
|
||||
$crate::__rt::code_actions::<$plugin>(doc, selection)
|
||||
}
|
||||
|
||||
fn resolve_code_action(
|
||||
doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document,
|
||||
action: $crate::providers_bindings::exports::openidea::plugin::providers::CodeAction,
|
||||
) -> ::core::result::Result<
|
||||
$crate::providers_bindings::exports::openidea::plugin::providers::CodeAction,
|
||||
$crate::bindings::openidea::plugin::types::Error,
|
||||
> {
|
||||
$crate::__rt::resolve_code_action::<$plugin>(doc, action)
|
||||
}
|
||||
|
||||
fn definition(
|
||||
doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document,
|
||||
at: $crate::bindings::openidea::plugin::types::Position,
|
||||
@@ -430,12 +420,9 @@ pub mod __rt {
|
||||
detail: i.detail,
|
||||
kind: i.kind.map(|k| k.as_str().to_owned()),
|
||||
insert_text: i.insert_text,
|
||||
snippet: i.snippet,
|
||||
replace: i.replace.map(crate::host::__range_to),
|
||||
sort_text: i.sort_text,
|
||||
filter_text: i.filter_text,
|
||||
documentation: i.documentation,
|
||||
additional_edits: i.additional_edits.into_iter().map(edit_out).collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -456,54 +443,25 @@ pub mod __rt {
|
||||
sel: b::types::Range,
|
||||
) -> Vec<w::CodeAction> {
|
||||
let d = doc(d);
|
||||
with::<T, _>(|x| x.code_actions(&d, crate::host::__range_from(sel))).into_iter().map(action_out).collect()
|
||||
}
|
||||
|
||||
pub fn resolve_code_action<T: Plugin + p::Providers + Default>(
|
||||
d: w::Document,
|
||||
action: w::CodeAction,
|
||||
) -> Result<w::CodeAction, b::types::Error> {
|
||||
let d = doc(d);
|
||||
with::<T, _>(|x| x.resolve_code_action(&d, action_in(action))).map(action_out).map_err(error_out)
|
||||
}
|
||||
|
||||
fn edit_out(e: crate::TextEdit) -> b::types::TextEdit {
|
||||
b::types::TextEdit { range: crate::host::__range_to(e.range), text: e.text }
|
||||
}
|
||||
|
||||
fn action_out(a: p::CodeAction) -> w::CodeAction {
|
||||
w::CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| w::FileEdit { path, edits: edits.into_iter().map(edit_out).collect() })
|
||||
.collect(),
|
||||
data: a.data,
|
||||
}
|
||||
}
|
||||
|
||||
fn action_in(a: w::CodeAction) -> p::CodeAction {
|
||||
p::CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let edits = f
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|e| crate::TextEdit { range: crate::host::__range_from(e.range), text: e.text })
|
||||
.collect();
|
||||
(f.path, edits)
|
||||
})
|
||||
.collect(),
|
||||
data: a.data,
|
||||
}
|
||||
with::<T, _>(|x| x.code_actions(&d, crate::host::__range_from(sel)))
|
||||
.into_iter()
|
||||
.map(|a| w::CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| w::FileEdit {
|
||||
path,
|
||||
edits: edits
|
||||
.into_iter()
|
||||
.map(|e| b::types::TextEdit { range: crate::host::__range_to(e.range), text: e.text })
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn definition<T: Plugin + p::Providers + Default>(
|
||||
@@ -573,9 +531,6 @@ pub mod __rt {
|
||||
v::FieldValue::Multiline(t) => vt::FieldValue::Multiline(t),
|
||||
v::FieldValue::Checkbox(c) => vt::FieldValue::Checkbox(c),
|
||||
v::FieldValue::Choice(o, s) => vt::FieldValue::Choice((o, s)),
|
||||
v::FieldValue::Other { kind, data } => {
|
||||
vt::FieldValue::Other(b::types::Extension { kind, data })
|
||||
}
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
@@ -588,7 +543,6 @@ pub mod __rt {
|
||||
editable: t.editable,
|
||||
}),
|
||||
v::Body::Html(h) => vt::Body::Html(h),
|
||||
v::Body::Other { kind, data } => vt::Body::Other(b::types::Extension { kind, data }),
|
||||
};
|
||||
vt::View {
|
||||
toolbar: view
|
||||
@@ -628,7 +582,6 @@ pub mod __rt {
|
||||
vt::ViewEvent::InsertRow(r) => v::ViewEvent::InsertRow(r),
|
||||
vt::ViewEvent::DeleteRows(r) => v::ViewEvent::DeleteRows(r),
|
||||
vt::ViewEvent::Message(m) => v::ViewEvent::Message(m),
|
||||
vt::ViewEvent::Other(x) => v::ViewEvent::Other { kind: x.kind, data: x.data },
|
||||
};
|
||||
with::<T, _>(|p| p.handle(&view, event, &ctx)).map_err(error_out)
|
||||
}
|
||||
|
||||
@@ -722,8 +722,11 @@ mod tests {
|
||||
let out = mock.invocation().project().unwrap().run_process("java", &["-version".into()], None).unwrap();
|
||||
assert_eq!(out.stdout, "-version");
|
||||
assert_eq!(mock.process_runs()[0].cwd.as_deref(), Some("/project"));
|
||||
host::publish_diagnostics("/project/a.java", &[Diagnostic::new(Range::default(), Severity::Warning, "m")])
|
||||
.unwrap();
|
||||
host::publish_diagnostics(
|
||||
"/project/a.java",
|
||||
&[Diagnostic { range: Range::default(), severity: Severity::Warning, message: "m".into(), code: None }],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(mock.diagnostics()["/project/a.java"].len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -92,18 +92,12 @@ pub struct CompletionItem {
|
||||
pub label: String,
|
||||
pub detail: Option<String>,
|
||||
pub kind: Option<CompletionKind>,
|
||||
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
|
||||
pub insert_text: String,
|
||||
pub snippet: bool,
|
||||
/// Replaced on accept; `None`: the word at the caret.
|
||||
pub replace: Option<Range>,
|
||||
pub sort_text: Option<String>,
|
||||
/// Matched against what was typed instead of `label`.
|
||||
pub filter_text: Option<String>,
|
||||
/// Markdown.
|
||||
pub documentation: Option<String>,
|
||||
/// Applied with the item elsewhere in the document (an import).
|
||||
pub additional_edits: Vec<TextEdit>,
|
||||
}
|
||||
|
||||
impl CompletionItem {
|
||||
@@ -113,12 +107,9 @@ impl CompletionItem {
|
||||
detail: None,
|
||||
kind: Some(kind),
|
||||
insert_text: label.into(),
|
||||
snippet: false,
|
||||
replace: None,
|
||||
sort_text: None,
|
||||
filter_text: None,
|
||||
documentation: None,
|
||||
additional_edits: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,9 +129,6 @@ pub struct CodeAction {
|
||||
pub preferred: bool,
|
||||
/// (absolute path, edits) per file.
|
||||
pub edits: Vec<(String, Vec<TextEdit>)>,
|
||||
/// The plugin's own reference: with it and no `edits`, the edits come
|
||||
/// from [`Providers::resolve_code_action`] once the user picks the action.
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -169,12 +157,6 @@ pub trait Providers {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The edits of an action [`Providers::code_actions`] returned with
|
||||
/// `data`, when the user picks it (the command budget applies, not 50 ms).
|
||||
fn resolve_code_action(&mut self, _doc: &Document, action: CodeAction) -> Result<CodeAction, crate::Error> {
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
fn definition(&mut self, _doc: &Document, _at: Position) -> Vec<Location> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -143,9 +143,7 @@ pub struct StatusItem {
|
||||
pub command: Option<String>,
|
||||
}
|
||||
|
||||
/// Kinds of workbench events; later SDKs add more.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum EventKind {
|
||||
FileOpened,
|
||||
FileSaved,
|
||||
@@ -156,46 +154,17 @@ pub enum EventKind {
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
/// The name the IDE knows it by.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::FileOpened => "file-opened",
|
||||
Self::FileSaved => "file-saved",
|
||||
Self::EditorFocused => "editor-focused",
|
||||
Self::ActionPerformed => "action-performed",
|
||||
Self::VcsChanged => "vcs-changed",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the user started an action; later IDEs may report more.
|
||||
/// How the user started an action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ActionSource {
|
||||
Keyboard,
|
||||
Menu,
|
||||
Toolbar,
|
||||
ContextMenu,
|
||||
Search,
|
||||
/// Anything else, including sources this SDK doesn't know.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ActionSource {
|
||||
pub fn parse(name: &str) -> Self {
|
||||
match name {
|
||||
"keyboard" => Self::Keyboard,
|
||||
"menu" => Self::Menu,
|
||||
"toolbar" => Self::Toolbar,
|
||||
"context-menu" => Self::ContextMenu,
|
||||
"search" => Self::Search,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ActionPerformed {
|
||||
/// Action id (`editor.duplicate-line`).
|
||||
@@ -208,7 +177,6 @@ pub struct ActionPerformed {
|
||||
|
||||
/// A workbench event (see [`crate::host::subscribe`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Event {
|
||||
FileOpened(String),
|
||||
FileSaved(String),
|
||||
@@ -218,11 +186,6 @@ pub enum Event {
|
||||
VcsChanged(String),
|
||||
/// The `on-schedule` period elapsed.
|
||||
Scheduled,
|
||||
/// A kind this SDK doesn't know: its name and JSON data.
|
||||
Other {
|
||||
kind: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The repository containing a project.
|
||||
@@ -264,41 +227,6 @@ pub struct Diagnostic {
|
||||
pub message: String,
|
||||
/// Rule or check id.
|
||||
pub code: Option<String>,
|
||||
/// The tool that found it, when not the plugin itself.
|
||||
pub source: Option<String>,
|
||||
/// Code that does nothing (shown faded).
|
||||
pub unnecessary: bool,
|
||||
/// Use of something deprecated (struck through).
|
||||
pub deprecated: bool,
|
||||
/// Other places that explain it.
|
||||
pub related: Vec<RelatedInformation>,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
pub fn new(range: Range, severity: Severity, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
range,
|
||||
severity,
|
||||
message: message.into(),
|
||||
code: None,
|
||||
source: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
related: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(mut self, code: impl Into<String>) -> Self {
|
||||
self.code = Some(code.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Another place that explains a diagnostic.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RelatedInformation {
|
||||
pub location: Location,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// An HTTP request (see [`crate::host::fetch`]).
|
||||
@@ -348,17 +276,3 @@ pub struct ProcessOutput {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_the_ide_uses() {
|
||||
assert_eq!(EventKind::ActionPerformed.name(), "action-performed");
|
||||
assert_eq!(ActionSource::parse("context-menu"), ActionSource::ContextMenu);
|
||||
assert_eq!(ActionSource::parse("pen"), ActionSource::Other, "a source of a newer IDE");
|
||||
let d = Diagnostic::new(Range::default(), Severity::Hint, "m").code("c");
|
||||
assert_eq!((d.code.as_deref(), d.unnecessary, d.related.len()), (Some("c"), false, 0));
|
||||
}
|
||||
}
|
||||
@@ -110,19 +110,12 @@ impl ListItem {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum FieldValue {
|
||||
Text(String),
|
||||
Multiline(String),
|
||||
Checkbox(bool),
|
||||
/// Options and the chosen one.
|
||||
Choice(Vec<String>, String),
|
||||
/// A field type of a newer plugin API: its kind and JSON data (IDEs that
|
||||
/// don't know it leave the field out).
|
||||
Other {
|
||||
kind: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -149,7 +142,6 @@ pub struct Table {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Body {
|
||||
Message(String),
|
||||
Tree(Vec<TreeItem>),
|
||||
@@ -159,12 +151,6 @@ pub enum Body {
|
||||
/// Shown in a sandboxed frame; its script talks to the plugin with
|
||||
/// `openidea.postMessage(text)` and `openidea.onMessage(fn)`.
|
||||
Html(String),
|
||||
/// A body of a newer plugin API: its kind and JSON data (IDEs that don't
|
||||
/// know it say they need updating).
|
||||
Other {
|
||||
kind: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -211,7 +197,6 @@ impl Viewport {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ViewEvent {
|
||||
Button(String),
|
||||
Activate(String),
|
||||
@@ -228,11 +213,6 @@ pub enum ViewEvent {
|
||||
InsertRow(u32),
|
||||
DeleteRows(Vec<u32>),
|
||||
Message(String),
|
||||
/// An event this SDK doesn't know: its kind and JSON data.
|
||||
Other {
|
||||
kind: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Content of the plugin's declared views and custom editors.
|
||||
|
||||
@@ -2,15 +2,36 @@ package openidea:[email protected];
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
use types.{error, extension};
|
||||
use types.{error};
|
||||
|
||||
enum event-kind {
|
||||
file-opened,
|
||||
file-saved,
|
||||
/// The focused editor changed.
|
||||
editor-focused,
|
||||
/// An IDE action ran. Requires `events.input`.
|
||||
action-performed,
|
||||
/// A repository's state changed (commit, checkout, fetch, …).
|
||||
vcs-changed,
|
||||
/// The plugin's `on-schedule` period elapsed.
|
||||
scheduled,
|
||||
}
|
||||
|
||||
/// How the user started an action.
|
||||
enum action-source {
|
||||
keyboard,
|
||||
menu,
|
||||
toolbar,
|
||||
context-menu,
|
||||
search,
|
||||
other,
|
||||
}
|
||||
|
||||
record action-performed {
|
||||
/// Action id (`editor.duplicate-line`).
|
||||
action: string,
|
||||
title: string,
|
||||
/// How the user started it: `keyboard`, `menu`, `toolbar`,
|
||||
/// `context-menu`, `search` or `other` (more may be added).
|
||||
source: string,
|
||||
source: action-source,
|
||||
/// Its keyboard shortcut, when it has one (`Ctrl+D`).
|
||||
shortcut: option<string>,
|
||||
}
|
||||
@@ -22,21 +43,14 @@ interface events {
|
||||
action-performed(action-performed),
|
||||
vcs-changed(string),
|
||||
scheduled,
|
||||
/// A kind added after 1.0 (subscribed to by its name).
|
||||
other(extension),
|
||||
}
|
||||
|
||||
/// Delivers `kind` events from now on, for the rest of the session (also
|
||||
/// across idle reclaim: an event activates the plugin again). Declare
|
||||
/// `activation = ["on-event:<kind>"]` to get them before first use.
|
||||
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
|
||||
/// changed), `vcs-changed` (a repository's state changed: commit,
|
||||
/// checkout, fetch…), `action-performed` (an IDE action ran) and
|
||||
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
|
||||
/// and an IDE that doesn't know one fails with `invalid`.
|
||||
/// Requires `events` (`events.input` for `action-performed`).
|
||||
subscribe: func(kind: string) -> result<_, error>;
|
||||
unsubscribe: func(kind: string);
|
||||
subscribe: func(kind: event-kind) -> result<_, error>;
|
||||
unsubscribe: func(kind: event-kind);
|
||||
}
|
||||
|
||||
/// Long-running work with a progress indicator. While a task is open, the
|
||||
|
||||
@@ -2,7 +2,7 @@ package openidea:[email protected];
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
use types.{error, range, location};
|
||||
use types.{error, range};
|
||||
|
||||
enum severity {
|
||||
error,
|
||||
@@ -11,23 +11,12 @@ interface diagnostics {
|
||||
hint,
|
||||
}
|
||||
|
||||
/// Another place that explains a diagnostic (the earlier declaration, say).
|
||||
record related-information {
|
||||
location: location,
|
||||
message: string,
|
||||
}
|
||||
|
||||
record diagnostic {
|
||||
range: range,
|
||||
severity: severity,
|
||||
message: string,
|
||||
/// Rule or check id (`LineLength`).
|
||||
code: option<string>,
|
||||
/// The tool that found it, when not the plugin itself (`checkstyle`).
|
||||
source: option<string>,
|
||||
/// `unnecessary` (shown faded) and `deprecated` (struck through); unknown ones are ignored.
|
||||
tags: list<string>,
|
||||
related: list<related-information>,
|
||||
}
|
||||
|
||||
/// Replaces the plugin's diagnostics for the file at `path` (absolute, in
|
||||
|
||||
@@ -5,7 +5,7 @@ package openidea:[email protected];
|
||||
/// on the editing path: a result later than ~50 ms is dropped, a call still
|
||||
/// running at 500 ms is interrupted.
|
||||
interface providers {
|
||||
use types.{error, position, range, text-edit, location};
|
||||
use types.{position, range, text-edit, location};
|
||||
|
||||
/// The document a request is about.
|
||||
record document {
|
||||
@@ -24,17 +24,11 @@ interface providers {
|
||||
/// constant, struct, event, operator or type-parameter. Unknown kinds
|
||||
/// show no icon.
|
||||
kind: option<string>,
|
||||
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
|
||||
insert-text: string,
|
||||
snippet: bool,
|
||||
/// Replaced on accept; `none`: the word at the caret.
|
||||
replace: option<range>,
|
||||
sort-text: option<string>,
|
||||
/// Matched against what was typed instead of `label`.
|
||||
filter-text: option<string>,
|
||||
documentation: option<string>,
|
||||
/// Applied with the item elsewhere in the document (an import).
|
||||
additional-edits: list<text-edit>,
|
||||
}
|
||||
|
||||
record inline-suggestion {
|
||||
@@ -54,9 +48,6 @@ interface providers {
|
||||
kind: option<string>,
|
||||
preferred: bool,
|
||||
edits: list<file-edit>,
|
||||
/// The plugin's own reference: with it and no `edits`, the edits are
|
||||
/// computed by `resolve-code-action` once the user picks the action.
|
||||
data: option<string>,
|
||||
}
|
||||
|
||||
record line-marker {
|
||||
@@ -73,9 +64,6 @@ interface providers {
|
||||
inline-completion: func(doc: document, at: position) -> option<inline-suggestion>;
|
||||
/// Actions for `selection` (quick fixes for the plugin's own diagnostics).
|
||||
code-actions: func(doc: document, selection: range) -> list<code-action>;
|
||||
/// The edits of an action `code-actions` returned with `data`, when the
|
||||
/// user picks it (the command budget applies, not the editor's 50 ms).
|
||||
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
|
||||
definition: func(doc: document, at: position) -> list<location>;
|
||||
line-markers: func(doc: document) -> list<line-marker>;
|
||||
}
|
||||
@@ -52,15 +52,6 @@ interface types {
|
||||
range: range,
|
||||
}
|
||||
|
||||
/// A case added after 1.0, carried by the `other` case of a variant that
|
||||
/// grows (events, view bodies, view events, form fields): its kind and its
|
||||
/// data as JSON. Whoever doesn't know the kind ignores it or shows a
|
||||
/// fallback; the SDKs decode the kinds they know.
|
||||
record extension {
|
||||
kind: string,
|
||||
data: string,
|
||||
}
|
||||
|
||||
/// Passed to `lifecycle.activate`.
|
||||
record context {
|
||||
plugin-id: string,
|
||||
|
||||
@@ -8,8 +8,6 @@ package openidea:[email protected];
|
||||
/// runs in the WebView, except a `html` body, which is shown in a sandboxed
|
||||
/// frame that can only exchange messages with the plugin.
|
||||
interface view-types {
|
||||
use types.{extension};
|
||||
|
||||
record button {
|
||||
id: string,
|
||||
label: string,
|
||||
@@ -45,8 +43,6 @@ interface view-types {
|
||||
checkbox(bool),
|
||||
/// The options and the chosen one.
|
||||
choice(tuple<list<string>, string>),
|
||||
/// A field type added after 1.0 (an IDE that doesn't know it leaves it out).
|
||||
other(extension),
|
||||
}
|
||||
|
||||
record field {
|
||||
@@ -76,8 +72,6 @@ interface view-types {
|
||||
/// network, no IDE API. Its script talks to the plugin with
|
||||
/// `openidea.postMessage(text)` / `openidea.onMessage(fn)`.
|
||||
html(string),
|
||||
/// A body added after 1.0; an IDE that doesn't know it says it needs updating.
|
||||
other(extension),
|
||||
}
|
||||
|
||||
record view {
|
||||
@@ -115,8 +109,6 @@ interface view-types {
|
||||
delete-rows(list<u32>),
|
||||
/// From the `html` body's script.
|
||||
message(string),
|
||||
/// An event added after 1.0, from a body or field added after 1.0.
|
||||
other(extension),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -924,7 +924,7 @@ export function PluginsPage() {
|
||||
<CategoryFilter market={market ?? []} value={category} onChange={setCategory} />
|
||||
</li>
|
||||
)}
|
||||
{!marketUrl && !marketError && (
|
||||
{!marketUrl && (
|
||||
<li className={styles.notice} role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span>
|
||||
|
||||
@@ -570,21 +570,6 @@ describe("PluginsPage", () => {
|
||||
expect(within(list).getAllByText("Installed").length).toBeGreaterThan(0);
|
||||
expect(notes.at(-1)).toMatchObject({ title: "Installed Toy Language 0.1.0" });
|
||||
});
|
||||
|
||||
it("shows why the marketplace couldn't be read instead of claiming there is none", async () => {
|
||||
// Commands without a result type reject with the backend's error object, not an Error.
|
||||
setPluginsBackend({
|
||||
...createMemoryPluginsBackend([], sampleCatalog()),
|
||||
marketUrl: () => Promise.reject({ kind: "unavailable", message: "Index unreachable", data: null }),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<PluginsPage />);
|
||||
await user.click(screen.getByRole("tab", { name: /Available/ }));
|
||||
const list = await screen.findByRole("list", { name: "Available plugins" });
|
||||
expect(await within(list).findByText("Index unreachable")).toBeInTheDocument();
|
||||
expect(within(list).queryByText("[object Object]")).not.toBeInTheDocument();
|
||||
expect(within(list).queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("secrets", () => {
|
||||
|
||||
@@ -54,13 +54,7 @@ export const usePlugins = create<PluginsState>(() => ({
|
||||
marketError: null,
|
||||
}));
|
||||
|
||||
/** A rejection's message: an `Error`, or an `IdeError` object from a command without a result type. */
|
||||
export const errorText = (e: unknown): string =>
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: typeof e === "object" && e !== null && "message" in e && typeof e.message === "string"
|
||||
? e.message
|
||||
: String(e);
|
||||
export const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/** Statuses whose contributions are in effect. */
|
||||
export const isLive = (status: PluginStatus) => status === "active" || status === "inactive" || status === "static";
|
||||
|
||||
+27
-13
@@ -2,15 +2,36 @@ package openidea:[email protected];
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
use types.{error, extension};
|
||||
use types.{error};
|
||||
|
||||
enum event-kind {
|
||||
file-opened,
|
||||
file-saved,
|
||||
/// The focused editor changed.
|
||||
editor-focused,
|
||||
/// An IDE action ran. Requires `events.input`.
|
||||
action-performed,
|
||||
/// A repository's state changed (commit, checkout, fetch, …).
|
||||
vcs-changed,
|
||||
/// The plugin's `on-schedule` period elapsed.
|
||||
scheduled,
|
||||
}
|
||||
|
||||
/// How the user started an action.
|
||||
enum action-source {
|
||||
keyboard,
|
||||
menu,
|
||||
toolbar,
|
||||
context-menu,
|
||||
search,
|
||||
other,
|
||||
}
|
||||
|
||||
record action-performed {
|
||||
/// Action id (`editor.duplicate-line`).
|
||||
action: string,
|
||||
title: string,
|
||||
/// How the user started it: `keyboard`, `menu`, `toolbar`,
|
||||
/// `context-menu`, `search` or `other` (more may be added).
|
||||
source: string,
|
||||
source: action-source,
|
||||
/// Its keyboard shortcut, when it has one (`Ctrl+D`).
|
||||
shortcut: option<string>,
|
||||
}
|
||||
@@ -22,21 +43,14 @@ interface events {
|
||||
action-performed(action-performed),
|
||||
vcs-changed(string),
|
||||
scheduled,
|
||||
/// A kind added after 1.0 (subscribed to by its name).
|
||||
other(extension),
|
||||
}
|
||||
|
||||
/// Delivers `kind` events from now on, for the rest of the session (also
|
||||
/// across idle reclaim: an event activates the plugin again). Declare
|
||||
/// `activation = ["on-event:<kind>"]` to get them before first use.
|
||||
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
|
||||
/// changed), `vcs-changed` (a repository's state changed: commit,
|
||||
/// checkout, fetch…), `action-performed` (an IDE action ran) and
|
||||
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
|
||||
/// and an IDE that doesn't know one fails with `invalid`.
|
||||
/// Requires `events` (`events.input` for `action-performed`).
|
||||
subscribe: func(kind: string) -> result<_, error>;
|
||||
unsubscribe: func(kind: string);
|
||||
subscribe: func(kind: event-kind) -> result<_, error>;
|
||||
unsubscribe: func(kind: event-kind);
|
||||
}
|
||||
|
||||
/// Long-running work with a progress indicator. While a task is open, the
|
||||
|
||||
+1
-12
@@ -2,7 +2,7 @@ package openidea:[email protected];
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
use types.{error, range, location};
|
||||
use types.{error, range};
|
||||
|
||||
enum severity {
|
||||
error,
|
||||
@@ -11,23 +11,12 @@ interface diagnostics {
|
||||
hint,
|
||||
}
|
||||
|
||||
/// Another place that explains a diagnostic (the earlier declaration, say).
|
||||
record related-information {
|
||||
location: location,
|
||||
message: string,
|
||||
}
|
||||
|
||||
record diagnostic {
|
||||
range: range,
|
||||
severity: severity,
|
||||
message: string,
|
||||
/// Rule or check id (`LineLength`).
|
||||
code: option<string>,
|
||||
/// The tool that found it, when not the plugin itself (`checkstyle`).
|
||||
source: option<string>,
|
||||
/// `unnecessary` (shown faded) and `deprecated` (struck through); unknown ones are ignored.
|
||||
tags: list<string>,
|
||||
related: list<related-information>,
|
||||
}
|
||||
|
||||
/// Replaces the plugin's diagnostics for the file at `path` (absolute, in
|
||||
|
||||
+1
-13
@@ -5,7 +5,7 @@ package openidea:[email protected];
|
||||
/// on the editing path: a result later than ~50 ms is dropped, a call still
|
||||
/// running at 500 ms is interrupted.
|
||||
interface providers {
|
||||
use types.{error, position, range, text-edit, location};
|
||||
use types.{position, range, text-edit, location};
|
||||
|
||||
/// The document a request is about.
|
||||
record document {
|
||||
@@ -24,17 +24,11 @@ interface providers {
|
||||
/// constant, struct, event, operator or type-parameter. Unknown kinds
|
||||
/// show no icon.
|
||||
kind: option<string>,
|
||||
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
|
||||
insert-text: string,
|
||||
snippet: bool,
|
||||
/// Replaced on accept; `none`: the word at the caret.
|
||||
replace: option<range>,
|
||||
sort-text: option<string>,
|
||||
/// Matched against what was typed instead of `label`.
|
||||
filter-text: option<string>,
|
||||
documentation: option<string>,
|
||||
/// Applied with the item elsewhere in the document (an import).
|
||||
additional-edits: list<text-edit>,
|
||||
}
|
||||
|
||||
record inline-suggestion {
|
||||
@@ -54,9 +48,6 @@ interface providers {
|
||||
kind: option<string>,
|
||||
preferred: bool,
|
||||
edits: list<file-edit>,
|
||||
/// The plugin's own reference: with it and no `edits`, the edits are
|
||||
/// computed by `resolve-code-action` once the user picks the action.
|
||||
data: option<string>,
|
||||
}
|
||||
|
||||
record line-marker {
|
||||
@@ -73,9 +64,6 @@ interface providers {
|
||||
inline-completion: func(doc: document, at: position) -> option<inline-suggestion>;
|
||||
/// Actions for `selection` (quick fixes for the plugin's own diagnostics).
|
||||
code-actions: func(doc: document, selection: range) -> list<code-action>;
|
||||
/// The edits of an action `code-actions` returned with `data`, when the
|
||||
/// user picks it (the command budget applies, not the editor's 50 ms).
|
||||
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
|
||||
definition: func(doc: document, at: position) -> list<location>;
|
||||
line-markers: func(doc: document) -> list<line-marker>;
|
||||
}
|
||||
@@ -52,15 +52,6 @@ interface types {
|
||||
range: range,
|
||||
}
|
||||
|
||||
/// A case added after 1.0, carried by the `other` case of a variant that
|
||||
/// grows (events, view bodies, view events, form fields): its kind and its
|
||||
/// data as JSON. Whoever doesn't know the kind ignores it or shows a
|
||||
/// fallback; the SDKs decode the kinds they know.
|
||||
record extension {
|
||||
kind: string,
|
||||
data: string,
|
||||
}
|
||||
|
||||
/// Passed to `lifecycle.activate`.
|
||||
record context {
|
||||
plugin-id: string,
|
||||
|
||||
@@ -8,8 +8,6 @@ package openidea:[email protected];
|
||||
/// runs in the WebView, except a `html` body, which is shown in a sandboxed
|
||||
/// frame that can only exchange messages with the plugin.
|
||||
interface view-types {
|
||||
use types.{extension};
|
||||
|
||||
record button {
|
||||
id: string,
|
||||
label: string,
|
||||
@@ -45,8 +43,6 @@ interface view-types {
|
||||
checkbox(bool),
|
||||
/// The options and the chosen one.
|
||||
choice(tuple<list<string>, string>),
|
||||
/// A field type added after 1.0 (an IDE that doesn't know it leaves it out).
|
||||
other(extension),
|
||||
}
|
||||
|
||||
record field {
|
||||
@@ -76,8 +72,6 @@ interface view-types {
|
||||
/// network, no IDE API. Its script talks to the plugin with
|
||||
/// `openidea.postMessage(text)` / `openidea.onMessage(fn)`.
|
||||
html(string),
|
||||
/// A body added after 1.0; an IDE that doesn't know it says it needs updating.
|
||||
other(extension),
|
||||
}
|
||||
|
||||
record view {
|
||||
@@ -115,8 +109,6 @@ interface view-types {
|
||||
delete-rows(list<u32>),
|
||||
/// From the `html` body's script.
|
||||
message(string),
|
||||
/// An event added after 1.0, from a body or field added after 1.0.
|
||||
other(extension),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user