Порівняти коміти

..
21 Коміти
Автор SHA1 Повідомлення Дата
l0ng-ai a812a03405 feat(agents): close the agent task tab in focus, or close it and start a new task (#2862)
Close Agent Task (agents.closeTask) closes the active agent task tab, leaving other tabs alone; the task keeps running in the Agents tool window. Close and New Agent Task (agents.closeAndNewTask) then opens the New Task page unless closing was cancelled. No default shortcut. Closes #2521.
2026-11-12 10:33:49 +08:00
l0ng-ai 17204bbdca chore(bindings): regenerate after the recent-project branch doc change (#2860)
#2851 changed the doc comment on RecentProject::branch, which is part of the generated src/bindings.ts.
2026-11-12 10:29:20 +08:00
arookieofcтаa-rookie-of-C-language feef7db39d feat(keymap): export the current keymap as JSON (#2852)
Settings › Keymap's gear menu gets Export Keymap as JSON…: the selected keymap's name, the preset it is based on and every configurable action's resolved bindings, so the full mapping can be read or diffed outside the IDE. Written with the fs commands like the color scheme export; strings in the en/zh catalog. Part of #287.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-12 10:24:35 +08:00
Gabriel Gondim c54253fba2 fix(welcome): show the live git branch on recent projects (#2851)
The welcome screen showed the branch stored when a project was last opened, so a checkout made later (in or outside the IDE) stayed stale. recent_projects now reads each project's HEAD when the list is built (linked worktrees via the .git file's gitdir; a detached HEAD shows the short commit), off the async runtime, and keeps the stored value when HEAD can't be read.

Co-authored-by: GGVidal <[email protected]>
2026-11-12 10:19:57 +08:00
Deng Liuтаdengliu ac54a064ef fix(plugins): scale Wasm fuel budget proportionally for background tasks (#2850)
A call that starts a tasks progress handle gets the background wall-clock budget (60 s, renewed on report), but its Wasm fuel stayed at the per-call foreground budget, so long background work trapped with OutOfFuel. Background calls now get fuel scaled by background/timeout, refilled when tasks.begin, task.report and task.is-cancelled return (fuel is only reloaded by compiled code across host calls); every new call is re-armed with the normal budget, and OutOfFuel reports the budget that was active.

Co-authored-by: dengliu <[email protected]>
2026-11-12 10:14:09 +08:00
l0ng-ai 04dd202fab test(plugins): match the process output test to the 16 MiB stdout limit (#2855)
The process output test still expected both streams to be cut at MAX_OUTPUT after #2838 raised it from 4 to 16 MiB while the test wrote 5 MiB. stdout within the limit now comes back whole; stderr beyond it is truncated.
2026-11-12 10:11:52 +08:00
Lucas 43b5c69ecd feat(plugins): add bounded background WebSockets (API 1.6) (#2544)
Plugins get a host-owned WebSocket resource (net.websocket-connect → websocket with id/send/drain/close; dropping it closes the transport). The connection is bound to the project it was opened for, needs the endpoint's net:<host> grant, uses WSS outside loopback with the shared rustls config, and is capped at eight per plugin. Request headers (secret placeholders expanded) go with the handshake, except the ones the handshake owns; an empty subprotocol requests none; the handshake is bounded by the call's I/O budget. Incoming frames queue in a bounded FIFO, signalled by the websocket-ready event. Checked-in components are rebuilt for API 1.6.

Co-authored-by: SorPuti <[email protected]>
2026-11-12 10:09:15 +08:00
Gabriel Gondim 597d94bb42 fix(ui): close open popups when a modal dialog opens (#2845)
Popups stack above dialogs (--z-popup > --z-dialog), so a dialog opened from a popup rendered underneath it: Delete from the Agent sessions popup's context menu left the confirmation half hidden. Opening a modal dialog now closes the open popups that would cover it, the way they close when the window loses focus; popups that stay open on deactivation and popups the dialog is rendered inside are kept. Covers every dialog opened from a popup, not just the agent task menu.

Co-authored-by: GGVidal <[email protected]>
2026-11-12 09:54:43 +08:00
l0ng-ai ffbd80e857 fix(editor): compute implementation gutter icons for the whole file (#2848)
Each pass asked the language server about at most 60 declarations in and near the viewport, so a method further down got its implemented/overriding icon only after scrolling to it and waiting for the next debounced pass. A pass now asks the viewport first, then the rest of the file in batches nearest first. Refs #2770.
2026-11-12 09:50:02 +08:00
Deng Liuтаdengliu f4ff2feefa fix(plugins): raise process stdout limit to 16 MiB and surface truncation (#2838)
process.run kept at most 4 MiB of stdout and silently dropped the rest while reporting success, so large structured output (kubectl -o json, cargo metadata) reached plugins cut off mid-document. The limit is now 16 MiB, as for file reads, and stdout beyond it fails the call with an explicit error instead of returning truncated output. stderr is still truncated without failing.

Co-authored-by: dengliu <[email protected]>
2026-11-12 09:31:52 +08:00
l0ng-ai a7af19d6b9 fix(lsp): don't start a second jdtls for module-folder queries or a new workspace layout (#2837)
Root markers skipped the path itself, so a module folder whose own folder holds the build file (what Run/Debug's classpath and launch queries pass) resolved to the enclosing root, and with per-module jdtls a query started a second instance; a directory now counts as its own ancestor. A reconfigure that switches the jdtls workspace layout restarted the instance it had just created, because its settings were compared before the start recorded them; servers created in the same pass are skipped.
2026-11-12 09:17:23 +08:00
l0ng-ai a2dd7e3939 fix(ci): ignore the Django parser terms in knip and fix two flaky tests (#2835)
knip now ignores the generated Django parser terms like the other template parsers'. The progress-replacement test started its barrier task while the released search could still be measuring indexing, so it sometimes saw Indexing; the barrier now starts after the search. The local download test passes '/'-separated remote paths, as the local file system lists them on Windows.
2026-11-12 08:58:45 +08:00
l0ng-ai 3ed5157e03 fix(agents): don't show +0 −0 for binary files in a task's changes (#2833)
git has no line counts for binary files, so a task's changes list showed +0 −0 for each binary the task produced. The list now shows only non-zero counts, as the run summaries in the same view do.
2026-11-12 08:50:07 +08:00
Deng Liuтаdengliu 68a9bf37e3 fix(workbench): clear orphaned maximized state when a tool window unregisters (#2830)
When a maximized tool window unregistered (its plugin was disabled, uninstalled or crashed), the layout kept its side maximized with nothing left to show there, which hid the editor and every other tool window and left the workbench blank; Hide Active Tool Window and Maximize did nothing. Unregistration now clears a maximized side that has no registered visible window, along with a stale focused or last-active window (focus returns to the editor), and visibility, context keys and Hide Active Tool Window ignore such a stale maximized side.

Co-authored-by: dengliu <[email protected]>
2026-11-12 08:48:02 +08:00
Deng Liuтаdengliu 96711ff85a fix(plugins): unwind host process/net timeouts as guest errors instead of trapping (#2831)
process.run, process.run-command and net.fetch were given the call's whole remaining wall-clock budget, so a subprocess or request that timed out returned to Wasm already past the deadline and the next epoch check trapped the instance. Three slow external calls then counted as three crashes and disabled the plugin. Blocking host I/O now gets the budget minus a 250 ms unwind window, and a failed I/O call near the deadline gets one 250 ms extension per call so the guest can handle the error. Pure Wasm loops still trap at the deadline.

Co-authored-by: dengliu <[email protected]>
2026-11-12 08:46:55 +08:00
l0ng-ai 29bbaf3b78 fix(problems): show the active notebook's diagnostics in the File tab (#2829)
The File tab followed only the active text editor, so with a notebook active it said no file was open; notebook diagnostics are also stored per cell document. It now follows the active tab's path for other editor kinds and collects the notebook's cell documents (percent-encoded and Windows drive paths included).
2026-11-12 07:42:57 +08:00
Gabriel Gondim b30657ec50 feat(java): Delegate IDE build/run actions to Maven (#2828)
jdtls builds launches with ECJ, which rejects some code javac accepts (for example a generic method call used as an if condition), so such a Maven project could not be run from the IDE. Settings › Build, Execution, Deployment › Maven › Delegate IDE build/run actions to Maven makes Automatic and Build (Make) launches of Maven modules compile with Maven, as Rebuild already did. Classes JDT wrote for sources with errors look up to date to Maven's incremental compiler, so a delegated launch JDT reports errors for recompiles everything and javac's result decides whether it starts. Editor diagnostics still come from jdtls. Off by default.

Co-authored-by: GGVidal <[email protected]>
2026-11-12 07:39:30 +08:00
rohitkumardandaтаrohitdanda fd5f4b8639 feat(notebook): completion, hover, diagnostics and navigation in notebook cells (#2824)
Notebook cells had no language server support. Servers that advertise notebookDocumentSync (basedpyright, Ruff) now receive the notebook through LSP 3.17 notebookDocument/didOpen, didChange and didClose, with one vscode-notebook-cell document per cell, so names defined in one cell resolve in the others. Cell editors get completion, hover, diagnostics and go to declaration; a location in another cell scrolls to and selects it in that cell, and cell diagnostics show under the notebook in Problems.

Co-authored-by: rohitdanda <[email protected]>
2026-11-12 07:28:31 +08:00
Bruno Belloni 0567bb88bf feat(database): pick up data sources edited on disk without reopening the project (#2826)
Edits to .openidea/data-sources.toml and the global data-sources.toml made outside the IDE (git pull, branch switch, a text editor) were only read when the project opened. A notify watcher on the project root, .openidea and the global data directory now reloads both files after a short settle delay, closes connections whose source changed or was removed, and emits DbSourcesChanged so the Database tool window refreshes. Saves from the IDE update the on-disk snapshot so they don't echo back as external changes.

Co-authored-by: brunobelloni <[email protected]>
2026-11-12 07:23:39 +08:00
Vadim LazukтаInf1mum a7e968e869 docs(java): note the ECJ vs javac divergence for generic calls in boolean conditions (#2825)
jdtls' bundled ECJ rejects a generic method call used directly as an if/while/for/do or ternary condition (Type mismatch: cannot convert from Object to boolean) where javac infers boolean; no ECJ release accepts it. Document the case and the workaround (assign to a boolean first) in the Java limitations, en and zh. Refs #2624.

Co-authored-by: Inf1mum <[email protected]>
2026-11-12 05:56:01 +08:00
l0ng-ai e9a464cfef test(agents): give the Claude command discovery test's waits a 10 s bound (#2823)
The test waited 2 s for its Python stub agent to start and to exit. Under a
parallel test run the interpreter's start-up alone can take longer, so the test
failed now and then. Both are polls for a condition, so a longer bound costs
nothing when it passes.
2026-11-12 05:03:21 +08:00
242 змінених файлів з 4003 додано та 376 видалено

No files matched your search

Створено
+7
Переглянути файл
@@ -4303,6 +4303,7 @@ dependencies = [
"ide-core",
"ide-http",
"ide-ssh",
"notify",
"oracledb",
"parking_lot",
"reqwest",
@@ -4459,6 +4460,7 @@ dependencies = [
"specta-typescript",
"tempfile",
"tokio",
"tokio-tungstenite",
"toml 0.9.12+spec-1.1.0",
"tracing",
"tracing-subscriber",
@@ -9867,7 +9869,10 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
]
@@ -10561,6 +10566,8 @@ dependencies = [
"httparse",
"log",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"sha1 0.10.7",
"thiserror 2.0.21",
"utf-8",
+1
Переглянути файл
@@ -68,6 +68,7 @@ parking_lot = "0.12"
rayon = "1"
dashmap = "6"
futures = "0.3"
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "__rustls-tls"] }
# errors & diagnostics
thiserror = "2"
+5 -1
Переглянути файл
@@ -1203,6 +1203,8 @@ pub struct MavenSettings {
pub threads: String,
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
pub active_profiles: String,
/// Delegate IDE build/run actions to Maven: launches build with Maven (`javac`) instead of jdtls (ECJ).
pub delegate_build: bool,
}
impl MavenSettings {
@@ -1707,10 +1709,12 @@ mod maven_linkage_tests {
fn maven_runner_jre_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.runner_jre.is_empty());
assert!(!old.delegate_build);
assert!(MavenSettings::default().runner_jre.is_empty());
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), delegate_build: true, ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
assert!(encoded.contains(r#""delegateBuild":true"#));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
+1
Переглянути файл
@@ -17,6 +17,7 @@ blake3.workspace = true
tokio.workspace = true
tracing.workspace = true
parking_lot.workspace = true
notify.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
+42
Переглянути файл
@@ -12,6 +12,8 @@ pub mod secrets;
pub mod source;
pub mod split;
pub mod sql;
mod watch;
pub use watch::SourcesWatch;
pub use conn::{Held, Pool, QueryResult, ResultColumn};
pub use history::HistoryEntry;
@@ -235,6 +237,9 @@ pub struct DbManager {
/// Manual-commit data sources, each with the connection of its open
/// transaction (taken by the first statement after a commit / rollback).
sessions: Mutex<HashMap<String, Session>>,
/// The data sources as last read from disk, for [`DbManager::reconcile_sources`]
/// (`None` until [`DbManager::watch_sources`]).
on_disk: Mutex<Option<Vec<DataSource>>>,
}
type Session = Arc<tokio::sync::Mutex<Option<Held>>>;
@@ -362,6 +367,7 @@ impl DbManager {
tunnel_open: tokio::sync::Mutex::new(()),
ssh,
session_passwords: Mutex::default(),
on_disk: Mutex::default(),
sessions: Mutex::default(),
}
}
@@ -474,9 +480,45 @@ impl DbManager {
}
}
}
// Our own write is not reported as an edit on disk.
if let Some(known) = self.on_disk.lock().as_mut() {
*known = self.sources().unwrap_or(sources);
}
Ok(())
}
/// Watches the data source files (the project's and the global one) for edits made
/// outside the IDE; `on_change` runs on a background thread once they settle, and
/// should call [`DbManager::reconcile_sources`]. Stops when the result is dropped.
pub fn watch_sources(&self, on_change: Box<dyn Fn() + Send>) -> IdeResult<SourcesWatch> {
*self.on_disk.lock() = Some(self.sources().unwrap_or_default());
SourcesWatch::start(&self.root, self.global.as_ref().map(|(dir, _)| dir.as_path()), on_change)
}
/// Re-reads the data sources after their files changed on disk: connections of
/// removed or changed sources close, as on [`DbManager::save_sources`] (saved
/// passwords stay, the source may come back). Returns whether the list the tree
/// shows changed, or the files no longer parse (the tree reports the error).
pub async fn reconcile_sources(&self) -> bool {
let now = match self.sources() {
Ok(now) => now,
Err(e) => {
tracing::warn!("data sources: {}", e.message);
return true;
}
};
let before = self.on_disk.lock().replace(now.clone()).unwrap_or_default();
if before == now {
return false;
}
for o in &before {
if now.iter().find(|s| s.id == o.id) != Some(o) {
self.disconnect_source(&o.id).await;
}
}
true
}
/// Remembers `password` for the session, and in the saved passwords when
/// the data source saves passwords (`None` forgets it).
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
+1 -1
Переглянути файл
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
const FILE: &str = "data-sources.toml";
pub(crate) const FILE: &str = "data-sources.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+151
Переглянути файл
@@ -0,0 +1,151 @@
//! Data source files edited outside the Database tool window (by hand, by a coding
//! agent, by `git checkout`) are picked up while the project is open, as IDEA does
//! for `dataSources.xml`. The project file tree's watcher is no help here: it never
//! reports a modification inside a gitignored `.openidea/`.
use crate::source::FILE;
use ide_core::{IdeError, IdeResult, project_dir};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak, mpsc};
use std::time::Duration;
/// How long a burst of writes (an editor's save, an agent's rewrite) settles before
/// the files are read.
const SETTLE: Duration = Duration::from_millis(200);
/// Watches `<root>/.openidea/data-sources.toml` and `<global>/data-sources.toml`;
/// stops when dropped.
pub struct SourcesWatch {
_watcher: Arc<Mutex<RecommendedWatcher>>,
}
impl SourcesWatch {
/// Calls `on_change` once a burst of changes to either file settled, on a
/// background thread.
pub fn start(root: &Path, global: Option<&Path>, on_change: Box<dyn Fn() + Send>) -> IdeResult<Self> {
let config: PathBuf = root.join(project_dir::DIR);
let (tx, rx) = mpsc::channel::<()>();
let relevant = {
let config = config.clone();
move |p: &Path| p.file_name().is_some_and(|n| n == FILE) || p == config
};
let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(e) = res
&& e.paths.iter().any(|p| relevant(p))
{
let _ = tx.send(());
}
})
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
let watcher = Arc::new(Mutex::new(watcher));
{
let mut w = watcher.lock();
// The root (not recursively) sees `.openidea/` itself appear or go.
w.watch(root, RecursiveMode::NonRecursive)
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
if let Some(dir) = global.filter(|d| d.is_dir())
&& let Err(e) = w.watch(dir, RecursiveMode::NonRecursive)
{
tracing::warn!("global data sources watcher: {e}");
}
}
let mut watching = watch_config(&watcher, &config, false);
let weak: Weak<Mutex<RecommendedWatcher>> = Arc::downgrade(&watcher);
std::thread::Builder::new().name("openidea-db-sources-watch".into()).spawn(move || {
while rx.recv().is_ok() {
// Wait until the writes stop.
loop {
match rx.recv_timeout(SETTLE) {
Ok(()) => {}
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
let Some(watcher) = weak.upgrade() else { return };
watching = watch_config(&watcher, &config, watching);
drop(watcher);
on_change();
}
})?;
Ok(Self { _watcher: watcher })
}
}
/// Watches `.openidea/` once it exists (again: a removed folder loses its watch);
/// returns whether it is watched.
fn watch_config(watcher: &Mutex<RecommendedWatcher>, config: &Path, watching: bool) -> bool {
if !config.is_dir() {
return false;
}
if watching {
return true;
}
match watcher.lock().watch(config, RecursiveMode::NonRecursive) {
Ok(()) => true,
Err(e) => {
tracing::warn!("data sources watcher {}: {e}", config.display());
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
fn wait_for(count: &AtomicUsize, at_least: usize) -> bool {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if count.load(Ordering::SeqCst) >= at_least {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
false
}
fn counting() -> (Arc<AtomicUsize>, Box<dyn Fn() + Send>) {
let count = Arc::new(AtomicUsize::new(0));
let c = count.clone();
(count, Box::new(move || _ = c.fetch_add(1, Ordering::SeqCst)))
}
#[test]
fn reports_project_and_global_files_and_a_config_folder_created_later() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let (project, global) = (root.join("p"), root.join("app"));
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&global).unwrap();
let (count, on_change) = counting();
let _watch = SourcesWatch::start(&project, Some(&global), on_change).unwrap();
// No `.openidea/` yet: it is created, then the file written into it.
std::fs::create_dir_all(project.join(".openidea")).unwrap();
assert!(wait_for(&count, 1));
let seen = count.load(Ordering::SeqCst);
std::fs::write(project.join(".openidea/data-sources.toml"), "").unwrap();
assert!(wait_for(&count, seen + 1), "project file");
let seen = count.load(Ordering::SeqCst);
std::fs::write(global.join("data-sources.toml"), "").unwrap();
assert!(wait_for(&count, seen + 1), "global file");
}
#[test]
fn ignores_other_files() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let (count, on_change) = counting();
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
std::fs::write(root.join("main.py"), "").unwrap();
std::thread::sleep(SETTLE * 3);
assert_eq!(count.load(Ordering::SeqCst), 0);
}
}
+28
Переглянути файл
@@ -342,3 +342,31 @@ async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeRes
assert_eq!(db.schemas("s1").await?, vec!["main"]);
Ok(())
}
#[tokio::test]
async fn data_sources_edited_on_disk_close_their_connections() -> IdeResult<()> {
let (tmp, db) = setup().await;
std::fs::File::create(tmp.path().join("other.db")).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let _watch = db.watch_sources(Box::new(move || _ = tx.send(())))?;
db.pool("s1").await?;
assert_eq!(db.connected(), vec!["s1"]);
// Our own save is no edit on disk.
let mut sources = db.sources()?;
sources[0].name = "renamed".into();
db.save_sources(sources.clone()).await?;
assert!(!db.reconcile_sources().await);
// Edited outside the IDE (by hand, by an agent): the watcher reports it, and the
// connection made with the old settings closes.
db.pool("s1").await?;
sources[0].file = Some("other.db".into());
ide_db::source::save(tmp.path(), &sources)?;
rx.recv_timeout(std::time::Duration::from_secs(10)).expect("the watcher reports the edit");
assert!(db.reconcile_sources().await);
assert!(db.connected().is_empty());
assert_eq!(db.sources()?[0].file.as_ref().map(|f| f.0.clone()), Some("other.db".into()));
assert!(!db.reconcile_sources().await);
Ok(())
}
+23 -2
Переглянути файл
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.5.0";
pub const ENGINE_VERSION: &str = "1.6.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
@@ -151,6 +151,7 @@ impl ActivationEvent {
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] = &[
"websocket-ready",
"run-exited",
"terminal-exited",
"content-tab-action",
@@ -366,6 +367,9 @@ impl Manifest {
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if kind == "websocket-ready") {
self.needs_api(&format!("`{event}`"), 6)?;
}
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
@@ -582,6 +586,7 @@ mod tests {
ActivationEvent::parse("on-event:files-changed"),
Ok(ActivationEvent::Event("files-changed".into()))
);
assert!(ActivationEvent::parse("on-event:websocket-ready").is_ok());
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
@@ -860,7 +865,7 @@ mod commands_api_tests {
mod net_ask_api_tests {
use super::*;
#[test]
fn net_ask_requires_api_1_5() {
fn api_1_5_declarations_require_api_1_5() {
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
for engine in ["^1.4", "^1.5"] {
let manifest = format!(
@@ -875,4 +880,20 @@ mod net_ask_api_tests {
}
}
}
#[test]
fn websocket_ready_requires_api_1_6() {
let declaration = "activation = [\"on-event:websocket-ready\"]";
for engine in ["^1.5", "^1.6"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.6" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.6"), "{engine}");
}
}
}
}
+1
Переглянути файл
@@ -14,6 +14,7 @@ encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
futures.workspace = true
tokio-tungstenite.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
regex-syntax.workspace = true
+83 -4
Переглянути файл
@@ -84,6 +84,14 @@ pub struct NotebookService {
registry: Arc<Mutex<Registry>>,
kernels: Arc<KernelManager>,
sink: SharedSink,
observer: parking_lot::RwLock<Option<Arc<dyn NotebookObserver>>>,
}
/// Learns each open notebook's contents after every change of them, and when it closes (the
/// language servers' copy of its code cells).
pub trait NotebookObserver: Send + Sync {
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot);
fn closed(&self, path: &Path);
}
impl NotebookService {
@@ -121,7 +129,23 @@ impl NotebookService {
}
}),
);
Arc::new(Self { registry, kernels: Arc::new(kernels), sink })
Arc::new(Self { registry, kernels: Arc::new(kernels), sink, observer: Default::default() })
}
pub fn set_observer(&self, observer: Arc<dyn NotebookObserver>) {
*self.observer.write() = Some(observer);
}
/// Called with the document's state locked, so observers see its changes in order.
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot) {
if let Some(observer) = self.observer.read().clone() {
observer.changed(path, snapshot);
}
}
/// The file of an open notebook.
pub fn path_of(&self, id: &str) -> IdeResult<PathBuf> {
Ok(self.resolve(id)?.path.clone())
}
pub fn open(&self, path: &Path) -> IdeResult<NotebookSnapshot> {
@@ -136,6 +160,7 @@ impl NotebookService {
}
let document = NotebookDocument::load(&path)?;
let initial = document.snapshot();
self.changed(&path, &initial);
let id = initial.id.clone();
registry.paths.insert(path.clone(), id.clone());
registry.documents.insert(
@@ -173,7 +198,9 @@ impl NotebookService {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
state.document.edit(version, edit)
let snapshot = state.document.edit(version, edit)?;
self.changed(&document.path, &snapshot);
Ok(snapshot)
}
pub fn save(&self, id: &str) -> IdeResult<NotebookSnapshot> {
@@ -190,7 +217,9 @@ impl NotebookService {
if state.active.is_some() {
return Err(IdeError::unavailable("stop notebook execution before undo or redo"));
}
if redo { state.document.redo() } else { state.document.undo() }
let snapshot = if redo { state.document.redo() } else { state.document.undo() }?;
self.changed(&document.path, &snapshot);
Ok(snapshot)
}
pub async fn interrupt(&self, id: &str) -> IdeResult<()> {
@@ -238,7 +267,10 @@ impl NotebookService {
snapshot(&document)
} else {
self.kernels.shutdown_kernel(&document.path).await?;
document.state.lock().document.reload()
let mut state = document.state.lock();
let snapshot = state.document.reload()?;
self.changed(&document.path, &snapshot);
Ok(snapshot)
}
}
.await;
@@ -284,6 +316,10 @@ impl NotebookService {
let mut registry = self.registry.lock();
registry.documents.remove(id);
registry.paths.remove(&document.path);
drop(registry);
if let Some(observer) = self.observer.read().clone() {
observer.closed(&document.path);
}
Ok(())
}
@@ -572,6 +608,49 @@ mod tests {
KernelOutput { kind: kind.into(), content, msg_id: "actual-parent-request".into() }
}
#[tokio::test]
async fn the_observer_follows_every_change_of_an_open_notebook_and_its_close() {
#[derive(Default)]
struct Seen(Mutex<Vec<String>>);
impl NotebookObserver for Seen {
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot) {
let sources: Vec<&str> = snapshot.cells.iter().map(|c| c.source.as_str()).collect();
let name = path.file_name().unwrap().to_string_lossy();
self.0.lock().push(format!("{name}: {}", sources.join(" | ")));
}
fn closed(&self, path: &Path) {
self.0.lock().push(format!("closed {}", path.file_name().unwrap().to_string_lossy()));
}
}
let (dir, service, initial) = fixture();
let seen = Arc::new(Seen::default());
service.set_observer(seen.clone());
service.close(&initial.id).await.unwrap();
let opened = service.open(&dir.path().join("session.ipynb")).unwrap();
assert_eq!(
service.path_of(&opened.id).unwrap(),
dunce::canonicalize(dir.path().join("session.ipynb")).unwrap()
);
let edit = NotebookEdit::SetSource { cell: "second".into(), source: "print(x + 1)".into() };
let edited = service.edit(&opened.id, opened.version, edit).unwrap();
service.undo(&opened.id, false).unwrap();
// A second view of the open notebook changes nothing.
service.open(&dir.path().join("session.ipynb")).unwrap();
assert!(edited.version > opened.version);
service.close(&opened.id).await.unwrap();
assert_eq!(
*seen.0.lock(),
[
"closed session.ipynb",
"session.ipynb: x = 1 | print(x) | notes",
"session.ipynb: x = 1 | print(x + 1) | notes",
"session.ipynb: x = 1 | print(x) | notes",
"closed session.ipynb",
]
);
service.shutdown().await;
}
#[tokio::test]
async fn canonical_split_opens_share_incarnation_but_reopen_and_projects_do_not() {
let (dir, service, initial) = fixture();
+46 -10
Переглянути файл
@@ -48,7 +48,7 @@ async fn resolve_launch(
let build = policy != BuildPolicy::NoBuild;
let full = policy == BuildPolicy::Rebuild;
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let (model, external, delegated) = {
let (model, external, delegated, prefer_maven) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
@@ -58,15 +58,17 @@ async fn resolve_launch(
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
let is_maven =
model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module));
let prefer_maven = settings.languages.java.maven.delegate_build;
let delegated = build
&& delegates_to_external_build(
is_maven,
policy,
non_java,
cache::has_record(&project.root, &project.build_cache_dir(), &module),
prefer_maven,
);
let external = non_java || delegated;
(model, external, delegated)
(model, external, delegated, prefer_maven)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
@@ -79,6 +81,7 @@ async fn resolve_launch(
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
let built = if delegated {
let policy = delegated_policy(policy, prefer_maven, info.error_count);
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&model,
@@ -169,6 +172,17 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
/// compiler, so a launch JDT reports errors for recompiles everything.
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
BuildPolicy::Rebuild
} else {
policy
}
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
@@ -241,8 +255,15 @@ fn apply_library_classpath(
/// Whether a launch should be built by the project's build system instead of jdtls.
/// A Rebuild has to clean the output; jdtls' full build only recompiles sources, so
/// Maven modules always delegate for it, even when every source is Java.
fn delegates_to_external_build(is_maven: bool, policy: BuildPolicy, non_java: bool, has_record: bool) -> bool {
is_maven && (policy == BuildPolicy::Rebuild || non_java || has_record)
/// `prefer_maven` is Settings › Maven › Delegate IDE build/run actions to Maven.
fn delegates_to_external_build(
is_maven: bool,
policy: BuildPolicy,
non_java: bool,
has_record: bool,
prefer_maven: bool,
) -> bool {
is_maven && (prefer_maven || policy == BuildPolicy::Rebuild || non_java || has_record)
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
@@ -321,14 +342,29 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
assert_eq!(
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
BuildPolicy::MakeAndContinueOnErrors
);
}
#[test]
fn rebuild_delegates_maven_builds_to_the_build_system() {
assert!(delegates_to_external_build(true, BuildPolicy::Rebuild, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, true, false));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, true));
assert!(!delegates_to_external_build(true, BuildPolicy::Automatic, false, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Make, false, false));
assert!(!delegates_to_external_build(false, BuildPolicy::Rebuild, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Rebuild, false, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, true, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, true, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Automatic, false, false, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Make, false, false, false));
assert!(!delegates_to_external_build(false, BuildPolicy::Rebuild, false, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, false, false, true));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, false, true));
assert!(!delegates_to_external_build(false, BuildPolicy::Automatic, false, false, true));
}
#[test]
+6 -4
Переглянути файл
@@ -314,10 +314,12 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
let (path, version) = project.documents.identity(doc)?;
if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
}) {
// A notebook cell is a language-server document only: no plugin navigates in it.
if let Ok((path, version)) = project.documents.identity(doc)
&& let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
})
{
return Ok(found.targets);
}
}
+8
Переглянути файл
@@ -19,6 +19,13 @@ pub fn get(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot>
host.project(window)?.notebooks.get(&id)
}
/// The language-server documents of the notebook's code cells (empty when no server syncs notebooks).
pub fn cell_docs(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_lsp::NotebookCellDoc>> {
let project = host.project(window)?;
let path = project.notebooks.path_of(&id)?;
Ok(project.lsp.notebook_cells(&path))
}
pub async fn edit(
host: &Host,
window: &str,
@@ -132,6 +139,7 @@ impl Drop for RequestGuard<'_> {
pub(crate) fn register(d: &mut Dispatcher) {
d.add("notebook_open", |c| async move { open(&c.host, &c.window, c.arg("path")?).await });
d.add("notebook_get", |c| async move { get(&c.host, &c.window, c.arg("id")?) });
d.add("notebook_cell_docs", |c| async move { cell_docs(&c.host, &c.window, c.arg("id")?) });
d.add("notebook_edit", |c| async move {
edit(&c.host, &c.window, c.arg("id")?, c.arg("version")?, c.arg("edit")?).await
});
+70 -11
Переглянути файл
@@ -180,7 +180,7 @@ impl SecretStore for FileSecretStore {
/// Largest response body a plugin receives.
const MAX_RESPONSE: usize = 8 << 20;
/// Largest stdout / stderr kept from a process.
const MAX_OUTPUT: usize = 4 << 20;
const MAX_OUTPUT: usize = 16 << 20;
/// Most processes one plugin keeps running at once.
const MAX_PROCESSES: usize = 8;
/// Most output one stream of a handle holds until the plugin reads it.
@@ -488,6 +488,7 @@ type SettingSnapshots = BTreeMap<(Option<String>, String), BTreeMap<String, Stri
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
pub websockets: super::websocket::WebSocketHub,
pub workspace: RwLock<Option<Arc<dyn PluginWorkspace>>>,
/// `<plugins>/.data`: one directory per plugin id.
pub data_dir: PathBuf,
@@ -565,7 +566,8 @@ impl Shared {
ui,
registry,
locale: RwLock::new("en".into()),
sink,
sink: sink.clone(),
websockets: super::websocket::WebSocketHub::new(sink.clone()),
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
pins,
@@ -775,6 +777,7 @@ impl Shared {
self.kill_processes(plugin);
self.connections.clear(plugin);
self.websockets.disconnect_plugin(plugin);
self.setting_snapshots.lock().retain(|(_, p), _| p != plugin);
let channels: Vec<_> = {
let mut output = self.output.lock();
@@ -2275,6 +2278,49 @@ impl HostApi for PluginApi {
})
}
fn websocket_connect(
&self,
root: &Path,
request: ide_plugin::WebSocketRequest,
timeout: Duration,
) -> Result<u64, HostError> {
let secrets = self.shared.secrets.read().clone();
let plugin = self.id().to_owned();
let headers = request
.headers
.into_iter()
.map(|(name, value)| {
let value = fill_secrets(&value, |name| secrets.get(&plugin, name))?;
Ok((name, value))
})
.collect::<Result<Vec<_>, HostError>>()?;
net_runtime()
.block_on(self.shared.websockets.connect(
&plugin,
&to_ipc(root),
&request.url,
&headers,
&request.subprotocol,
timeout,
))
.map_err(|error| match error {
super::websocket::ConnectError::Invalid(message) => HostError::Invalid(message),
super::websocket::ConnectError::Failed(message) => HostError::Failed(message),
})
}
fn websocket_send(&self, connection: u64, bytes: Vec<u8>) -> Result<(), HostError> {
self.shared.websockets.send(self.id(), connection, bytes).map_err(HostError::Failed)
}
fn websocket_drain(&self, connection: u64, limit: usize) -> Result<ide_plugin::WebSocketBatch, HostError> {
self.shared.websockets.drain(self.id(), connection, limit).map_err(HostError::Invalid)
}
fn websocket_close(&self, connection: u64) {
let _ = self.shared.websockets.disconnect(self.id(), connection);
}
/// The `net:ask` answer: a host the user allowed with "always" is
/// remembered in the ledger (no dialog again, background included); a new
/// host is asked about in `client`'s window when there is one. Dismissal
@@ -2443,9 +2489,14 @@ impl HostApi for PluginApi {
let read = |pipe: Option<Box<dyn Read + Send>>| {
std::thread::spawn(move || {
let mut out = Vec::new();
let mut overflow = false;
if let Some(p) = pipe {
let mut p = p;
let _ = p.by_ref().take(MAX_OUTPUT as u64).read_to_end(&mut out);
let _ = p.by_ref().take((MAX_OUTPUT + 1) as u64).read_to_end(&mut out);
if out.len() > MAX_OUTPUT {
overflow = true;
out.truncate(MAX_OUTPUT);
}
let _ = std::io::copy(&mut p, &mut std::io::sink());
}
let mut text = String::from_utf8_lossy(&out).into_owned();
@@ -2454,7 +2505,7 @@ impl HostApi for PluginApi {
end -= 1;
}
text.truncate(end);
text
(text, overflow)
})
};
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
@@ -2474,11 +2525,18 @@ impl HostApi for PluginApi {
}
std::thread::sleep(Duration::from_millis(10));
};
Ok(ProcessOutput {
status: status.code(),
stdout: stdout.join().unwrap_or_default(),
stderr: stderr.join().unwrap_or_default(),
})
let (stdout, stdout_overflow) = stdout.join().unwrap_or_default();
let (stderr, _) = stderr.join().unwrap_or_default();
if stdout_overflow {
tracing::warn!(
plugin = %self.id(),
program = %cmd.program,
limit_mib = MAX_OUTPUT >> 20,
"process stdout exceeded limit"
);
return Err(HostError::Failed(format!("{} stdout exceeded {} MiB limit", cmd.program, MAX_OUTPUT >> 20)));
}
Ok(ProcessOutput { status: status.code(), stdout, stderr })
}
fn subscribe(&self, kind: EventKind) {
@@ -3807,10 +3865,11 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let api = shell_api(tmp.path());
let token = CancellationToken::new();
let cmd = shell("head -c 5242880 /dev/zero; head -c 5242880 /dev/zero >&2");
// stdout within the limit comes back whole; stderr beyond it is truncated (stdout beyond it fails).
let cmd = shell(&format!("head -c 5242880 /dev/zero; head -c {} /dev/zero >&2", MAX_OUTPUT + (1 << 20)));
let out = api.run_process(cmd.clone(), None, None, &token, Duration::from_secs(5)).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_OUTPUT, MAX_OUTPUT));
assert_eq!((out.stdout.len(), out.stderr.len()), (5 << 20, MAX_OUTPUT));
let id = api.start_process(cmd, None, None).unwrap();
let out = api.wait_process(id, MAX_PENDING, Duration::from_secs(5), &token).unwrap();
assert_eq!(out.status, Some(0));
+3
Переглянути файл
@@ -41,6 +41,7 @@ pub mod language_features;
mod market;
mod mcp;
pub mod navigation;
mod network_inbox;
mod points;
mod providers;
pub mod remote;
@@ -54,6 +55,7 @@ mod types;
pub mod ui_points;
pub mod views;
pub mod web;
mod websocket;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub(crate) use hover::merge as merge_hover;
@@ -1921,6 +1923,7 @@ impl PluginHost {
for p in plugins {
let wanted: Vec<WorkbenchEvent> = events
.iter()
.filter(|event| !matches!(event, WorkbenchEvent::WebsocketReady(ready) if ready.plugin_id != p.id()))
.filter(|e| Self::wants(&p, e.kind(), subs.kinds.get(p.id())))
.filter(|e| {
!matches!(e, WorkbenchEvent::FilesChanged(_))
+203
Переглянути файл
@@ -0,0 +1,203 @@
//! Bounded inbox for the proposed background network API.
//!
//! Experimental transport-independent primitive. This module does not expose
//! sockets to plugins. Overflow closes the inbox: silently dropping a document
//! update would make a collaboration session appear healthy while diverging.
use std::collections::VecDeque;
use std::sync::Mutex;
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Push {
/// The caller must schedule one plugin wake-up after releasing this lock.
Wake,
Queued,
/// This message was refused and the caller must schedule a wake-up.
OverflowWake,
/// This message was refused; a wake-up is already pending.
OverflowQueued,
Closed,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Batch {
pub messages: Vec<Vec<u8>>,
pub closed: bool,
pub more: bool,
}
#[derive(Default)]
struct State {
messages: VecDeque<Box<[u8]>>,
bytes: usize,
closed: bool,
notified: bool,
}
pub(super) struct Inbox {
state: Mutex<State>,
max_messages: usize,
max_bytes: usize,
max_frame: usize,
}
impl Inbox {
pub fn new(max_messages: usize, max_bytes: usize, max_frame: usize) -> Self {
assert!(max_messages > 0 && max_bytes > 0 && max_frame > 0);
Self { state: Mutex::new(State::default()), max_messages, max_bytes, max_frame }
}
pub fn push(&self, message: Vec<u8>) -> Push {
let mut state = self.state.lock().unwrap();
if state.closed {
return Push::Closed;
}
let overflow = message.len() > self.max_frame
|| state.messages.len() >= self.max_messages
|| message.len() > self.max_bytes.saturating_sub(state.bytes);
if overflow {
state.closed = true;
} else {
state.bytes += message.len();
state.messages.push_back(message.into_boxed_slice());
}
let wake = !state.notified;
state.notified = true;
match (overflow, wake) {
(false, true) => Push::Wake,
(false, false) => Push::Queued,
(true, true) => Push::OverflowWake,
(true, false) => Push::OverflowQueued,
}
}
/// Returns whether the caller must schedule a wake-up. Idempotent.
pub fn close(&self) -> bool {
let mut state = self.state.lock().unwrap();
if state.closed {
return false;
}
state.closed = true;
let wake = !state.notified;
state.notified = true;
wake
}
/// A partial drain retains the notification; the consumer must continue
/// draining when `more` is true. An empty drain rearms the next wake-up.
pub fn drain(&self, limit: usize) -> Batch {
assert!(limit > 0);
let mut state = self.state.lock().unwrap();
let count = limit.min(state.messages.len());
let messages: Vec<Vec<u8>> = state.messages.drain(..count).map(|message| message.into_vec()).collect();
state.bytes -= messages.iter().map(Vec::len).sum::<usize>();
let more = !state.messages.is_empty();
state.notified = more;
Batch { messages, closed: state.closed, more }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coalesces_and_rearms_wakeups() {
let inbox = Inbox::new(4, 16, 8);
assert_eq!(inbox.push(vec![1]), Push::Wake);
assert_eq!(inbox.push(vec![2]), Push::Queued);
assert!(inbox.drain(1).more);
assert_eq!(inbox.push(vec![3]), Push::Queued);
assert_eq!(inbox.drain(4).messages, vec![vec![2], vec![3]]);
assert_eq!(inbox.push(vec![4]), Push::Wake);
}
#[test]
fn overflow_preserves_accepted_messages_and_reports_close() {
for (messages, bytes, frame) in [(1, 8, 8), (4, 1, 8), (4, 8, 1)] {
let inbox = Inbox::new(messages, bytes, frame);
assert_eq!(inbox.push(vec![1]), Push::Wake);
assert_eq!(inbox.push(vec![2, 3]), Push::OverflowQueued);
let batch = inbox.drain(4);
assert_eq!(batch.messages, vec![vec![1]]);
assert!(batch.closed);
assert_eq!(inbox.push(vec![]), Push::Closed);
}
}
#[test]
fn accepted_frame_does_not_retain_excess_capacity() {
let inbox = Inbox::new(2, 8, 8);
let mut frame = Vec::with_capacity(1024 * 1024);
frame.push(42);
assert_eq!(inbox.push(frame), Push::Wake);
let state = inbox.state.lock().unwrap();
assert_eq!(state.bytes, 1);
assert_eq!(state.messages[0].len(), 1);
assert_eq!(std::mem::size_of_val(&*state.messages[0]), 1);
}
#[test]
fn close_during_partial_drain_remains_visible_without_duplicate_wakeup() {
let inbox = Inbox::new(3, 12, 4);
assert_eq!(inbox.push(vec![1]), Push::Wake);
assert_eq!(inbox.push(vec![2]), Push::Queued);
let first = inbox.drain(1);
assert_eq!(first.messages, vec![vec![1]]);
assert!(first.more);
assert!(!inbox.close());
let second = inbox.drain(1);
assert_eq!(second.messages, vec![vec![2]]);
assert!(second.closed);
assert!(!second.more);
assert_eq!(inbox.push(vec![3]), Push::Closed);
}
#[test]
fn draining_releases_byte_budget_for_later_messages() {
let inbox = Inbox::new(2, 4, 4);
assert_eq!(inbox.push(vec![1, 2, 3, 4]), Push::Wake);
assert_eq!(inbox.drain(1).messages, vec![vec![1, 2, 3, 4]]);
assert_eq!(inbox.push(vec![5, 6, 7, 8]), Push::Wake);
assert_eq!(inbox.drain(1).messages, vec![vec![5, 6, 7, 8]]);
}
#[test]
fn oversized_first_message_requests_wakeup_and_reports_refusal() {
let inbox = Inbox::new(2, 8, 1);
assert_eq!(inbox.push(vec![1, 2]), Push::OverflowWake);
let batch = inbox.drain(2);
assert!(batch.closed);
assert!(batch.messages.is_empty());
}
#[test]
fn close_wakes_once_even_without_messages() {
let inbox = Inbox::new(4, 16, 8);
assert!(inbox.close());
assert!(!inbox.close());
assert!(inbox.drain(4).closed);
assert_eq!(inbox.push(vec![1]), Push::Closed);
}
#[test]
fn concurrent_producers_remain_bounded_and_fifo_per_producer() {
let inbox = std::sync::Arc::new(Inbox::new(400, 800, 2));
std::thread::scope(|scope| {
for producer in 0..4u8 {
let inbox = inbox.clone();
scope.spawn(move || {
for index in 0..100u8 {
assert_ne!(inbox.push(vec![producer, index]), Push::Closed);
}
});
}
});
let batch = inbox.drain(400);
assert_eq!(batch.messages.len(), 400);
assert!(!batch.closed);
for producer in 0..4u8 {
let sequence: Vec<_> = batch.messages.iter().filter(|m| m[0] == producer).map(|m| m[1]).collect();
assert_eq!(sequence, (0..100u8).collect::<Vec<_>>());
}
}
}
+1
Переглянути файл
@@ -155,6 +155,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::TaskProgress { .. }
| PluginEvent::TaskEnded { .. }
| PluginEvent::SubscriptionsChanged
| PluginEvent::WebsocketReady { .. }
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
+3
Переглянути файл
@@ -275,6 +275,9 @@ pub enum PluginEvent {
/// The kinds of workbench events plugins want changed: re-fetch
/// `plugin_event_kinds` (the UI reports only those).
SubscriptionsChanged,
/// A background WebSocket has messages ready for its owning plugin.
#[serde(rename_all = "camelCase")]
WebsocketReady { plugin_id: String, connection: u64 },
#[serde(rename_all = "camelCase")]
ThemeChanged { theme: Option<ActiveTheme> },
/// A plugin needs capabilities the user has not granted yet (first use
+536
Переглянути файл
@@ -0,0 +1,536 @@
//! Host-owned background WebSocket connections for plugins.
use super::{
PluginEvent, PluginEventSink,
network_inbox::{Inbox, Push},
};
use futures::{SinkExt, StreamExt};
use ide_plugin::WebSocketBatch;
use parking_lot::Mutex;
use std::{
collections::HashMap,
sync::{
Arc, Weak,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use tokio::sync::{Notify, watch};
use tokio_tungstenite::{
Connector, connect_async_tls_with_config,
tungstenite::{
Message,
client::IntoClientRequest,
http::{HeaderName, HeaderValue},
protocol::WebSocketConfig,
},
};
const MAX_MESSAGES: usize = 256;
const MAX_BYTES: usize = 8 << 20;
const MAX_FRAME: usize = 1 << 20;
const DRAIN_BATCH: usize = 32;
const CLOSED_HANDLE_TTL: Duration = Duration::from_secs(60);
const MAX_CONNECTIONS_PER_PLUGIN: usize = 8;
pub type ConnectionId = u64;
#[derive(Debug, PartialEq, Eq)]
pub enum ConnectError {
Invalid(String),
Failed(String),
}
struct Connection {
plugin: String,
#[allow(dead_code, reason = "read by document subscriptions (#2610)")]
project: String,
outgoing: Arc<Inbox>,
incoming: Arc<Inbox>,
wake: Notify,
stop: watch::Sender<bool>,
}
struct Inner {
next: AtomicU64,
connections: Mutex<HashMap<ConnectionId, Arc<Connection>>>,
pending: Mutex<HashMap<String, usize>>,
sink: Option<Arc<dyn PluginEventSink>>,
}
#[derive(Clone)]
pub struct WebSocketHub(Arc<Inner>);
impl Default for WebSocketHub {
fn default() -> Self {
Self(Arc::new(Inner {
next: AtomicU64::new(1),
connections: Mutex::new(HashMap::new()),
pending: Mutex::new(HashMap::new()),
sink: None,
}))
}
}
impl WebSocketHub {
pub fn new(sink: Arc<dyn PluginEventSink>) -> Self {
Self(Arc::new(Inner {
next: AtomicU64::new(1),
connections: Mutex::new(HashMap::new()),
pending: Mutex::new(HashMap::new()),
sink: Some(sink),
}))
}
pub async fn connect(
&self,
plugin: &str,
project: &str,
url: &str,
headers: &[(String, String)],
subprotocol: &str,
timeout: Duration,
) -> Result<ConnectionId, ConnectError> {
validate_url(url).map_err(ConnectError::Invalid)?;
if subprotocol.len() > 256 {
return Err(ConnectError::Invalid("WebSocket subprotocol is too long".into()));
}
if timeout.is_zero() {
return Err(ConnectError::Failed("WebSocket connection timed out".into()));
}
self.reserve(plugin)?;
let result = async {
let mut request =
url.into_client_request().map_err(|_| ConnectError::Invalid("invalid WebSocket URL".into()))?;
for (name, value) in headers {
if protected_header(name) {
return Err(ConnectError::Invalid(format!(
"WebSocket header {name:?} is managed by the handshake"
)));
}
let name: HeaderName =
name.parse().map_err(|_| ConnectError::Invalid("invalid WebSocket header name".into()))?;
let value = HeaderValue::from_str(value)
.map_err(|_| ConnectError::Invalid("invalid WebSocket header value".into()))?;
request.headers_mut().append(name, value);
}
if !subprotocol.is_empty() {
request.headers_mut().insert(
"sec-websocket-protocol",
subprotocol.parse().map_err(|_| ConnectError::Invalid("invalid WebSocket subprotocol".into()))?,
);
}
let config = WebSocketConfig::default()
.read_buffer_size(16 << 10)
.write_buffer_size(16 << 10)
.max_write_buffer_size(2 << 20)
.max_message_size(Some(MAX_FRAME))
.max_frame_size(Some(MAX_FRAME));
let tls = ide_http::client::tls_config()
.map(Arc::new)
.map(Connector::Rustls)
.map_err(|error| ConnectError::Failed(error.message))?;
let (socket, response) =
tokio::time::timeout(timeout, connect_async_tls_with_config(request, Some(config), false, Some(tls)))
.await
.map_err(|_| ConnectError::Failed("WebSocket connection timed out".into()))?
.map_err(|_| ConnectError::Failed("WebSocket connection failed".into()))?;
if !subprotocol.is_empty()
&& response.headers().get("sec-websocket-protocol").and_then(|value| value.to_str().ok())
!= Some(subprotocol)
{
return Err(ConnectError::Failed("WebSocket server did not accept the required subprotocol".into()));
}
let id = self.0.next.fetch_add(1, Ordering::Relaxed);
let (stop, stopped) = watch::channel(false);
let connection = Arc::new(Connection {
plugin: plugin.to_owned(),
project: project.to_owned(),
outgoing: Arc::new(Inbox::new(MAX_MESSAGES, MAX_BYTES, MAX_FRAME)),
incoming: Arc::new(Inbox::new(MAX_MESSAGES, MAX_BYTES, MAX_FRAME)),
wake: Notify::new(),
stop,
});
self.0.connections.lock().insert(id, connection.clone());
tokio::spawn(run_socket(id, socket, connection, Arc::downgrade(&self.0), stopped));
Ok(id)
}
.await;
self.release(plugin);
result
}
pub fn send(&self, plugin: &str, connection: ConnectionId, bytes: Vec<u8>) -> Result<(), String> {
let connection = self.connection(plugin, connection)?;
match connection.outgoing.push(bytes) {
Push::Wake => {
connection.wake.notify_one();
Ok(())
}
Push::Queued => Ok(()),
Push::OverflowWake => {
connection.wake.notify_one();
Err("WebSocket send queue is full; message was rejected".into())
}
Push::OverflowQueued => Err("WebSocket send queue is full; message was rejected".into()),
Push::Closed => Err("WebSocket connection is closed; message was rejected".into()),
}
}
pub fn drain(&self, plugin: &str, connection: ConnectionId, limit: usize) -> Result<WebSocketBatch, String> {
if limit == 0 || limit > DRAIN_BATCH {
return Err("invalid drain limit".into());
}
let connection_state = self.connection(plugin, connection)?;
let batch = connection_state.incoming.drain(limit);
if batch.more {
self.emit_ready(connection, plugin);
}
if batch.closed && !batch.more {
self.0.connections.lock().remove(&connection);
}
Ok(WebSocketBatch { messages: batch.messages, closed: batch.closed })
}
pub fn disconnect(&self, plugin: &str, id: ConnectionId) -> Result<(), String> {
let connection = self
.0
.connections
.lock()
.get(&id)
.filter(|connection| connection.plugin == plugin)
.cloned()
.ok_or_else(|| "unknown WebSocket connection".to_owned())?;
connection.outgoing.close();
let _ = connection.stop.send(true);
connection.wake.notify_one();
self.0.connections.lock().remove(&id);
Ok(())
}
pub fn disconnect_plugin(&self, plugin: &str) {
let removed: Vec<_> = {
let mut connections = self.0.connections.lock();
let ids: Vec<_> =
connections.iter().filter(|(_, connection)| connection.plugin == plugin).map(|(id, _)| *id).collect();
ids.into_iter().filter_map(|id| connections.remove(&id)).collect()
};
for connection in removed {
connection.outgoing.close();
let _ = connection.stop.send(true);
connection.wake.notify_one();
}
}
/// Returns the project captured when this plugin opened the resource.
/// Consumers use this instead of accepting a project selected by plugin code.
#[allow(dead_code, reason = "read by document subscriptions (#2610)")]
pub fn project(&self, plugin: &str, id: ConnectionId) -> Result<String, String> {
Ok(self.connection(plugin, id)?.project.clone())
}
fn connection(&self, plugin: &str, id: ConnectionId) -> Result<Arc<Connection>, String> {
self.0
.connections
.lock()
.get(&id)
.filter(|connection| connection.plugin == plugin)
.cloned()
.ok_or_else(|| "unknown WebSocket connection".into())
}
fn reserve(&self, plugin: &str) -> Result<(), ConnectError> {
let open = self.0.connections.lock().values().filter(|connection| connection.plugin == plugin).count();
let mut pending = self.0.pending.lock();
if open + pending.get(plugin).copied().unwrap_or_default() >= MAX_CONNECTIONS_PER_PLUGIN {
return Err(ConnectError::Invalid("at most 8 WebSocket connections per plugin".into()));
}
*pending.entry(plugin.to_owned()).or_default() += 1;
Ok(())
}
fn release(&self, plugin: &str) {
let mut pending = self.0.pending.lock();
if let Some(count) = pending.get_mut(plugin) {
*count -= 1;
if *count == 0 {
pending.remove(plugin);
}
}
}
fn emit_ready(&self, connection: ConnectionId, plugin: &str) {
if let Some(sink) = &self.0.sink {
sink.emit(None, PluginEvent::WebsocketReady { plugin_id: plugin.to_owned(), connection });
}
}
}
fn validate_url(url: &str) -> Result<(), String> {
let parsed = reqwest::Url::parse(url).map_err(|_| "invalid WebSocket URL")?;
let loopback = parsed.host_str().is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "::1"));
if parsed.scheme() != "wss" && !(parsed.scheme() == "ws" && loopback) {
return Err("WebSocket requires WSS outside loopback".into());
}
if !parsed.username().is_empty() || parsed.password().is_some() || parsed.fragment().is_some() {
return Err("WebSocket URLs may not carry credentials or a fragment".into());
}
Ok(())
}
fn protected_header(name: &str) -> bool {
name.eq_ignore_ascii_case("host")
|| name.eq_ignore_ascii_case("connection")
|| name.eq_ignore_ascii_case("upgrade")
|| name.get(.."sec-websocket-".len()).is_some_and(|prefix| prefix.eq_ignore_ascii_case("sec-websocket-"))
}
async fn run_socket<S>(
id: ConnectionId,
socket: tokio_tungstenite::WebSocketStream<S>,
connection: Arc<Connection>,
inner: Weak<Inner>,
mut stopped: watch::Receiver<bool>,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let (mut writer, mut reader) = socket.split();
loop {
tokio::select! {
_ = connection.wake.notified() => {
let batch = connection.outgoing.drain(DRAIN_BATCH);
let mut failed = false;
for bytes in batch.messages {
if writer.send(Message::Binary(bytes.into())).await.is_err() {
failed = true;
break;
}
}
if failed || batch.closed { break; }
if batch.more { connection.wake.notify_one(); }
}
changed = stopped.changed() => {
if changed.is_err() || *stopped.borrow() {
let mut failed = false;
loop {
let batch = connection.outgoing.drain(DRAIN_BATCH);
for bytes in batch.messages {
if writer.send(Message::Binary(bytes.into())).await.is_err() {
failed = true;
break;
}
}
if failed || !batch.more { break; }
}
break;
}
}
message = reader.next() => match message {
Some(Ok(Message::Binary(bytes))) => {
let pushed = connection.incoming.push(bytes.to_vec());
if matches!(pushed, Push::Wake | Push::OverflowWake)
&& let Some(inner) = inner.upgrade()
&& let Some(sink) = &inner.sink
{
sink.emit(None, PluginEvent::WebsocketReady { plugin_id: connection.plugin.clone(), connection: id });
}
if matches!(pushed, Push::OverflowWake | Push::OverflowQueued | Push::Closed) { break; }
}
Some(Ok(Message::Ping(bytes))) => if writer.send(Message::Pong(bytes)).await.is_err() { break; },
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
_ => {}
}
}
}
let _ = writer.close().await;
connection.outgoing.close();
let wake = connection.incoming.close();
if let Some(inner) = inner.upgrade() {
if wake && let Some(sink) = &inner.sink {
sink.emit(None, PluginEvent::WebsocketReady { plugin_id: connection.plugin.clone(), connection: id });
}
let retained = connection.clone();
tokio::spawn(async move {
tokio::time::sleep(CLOSED_HANDLE_TTL).await;
let mut connections = inner.connections.lock();
if connections.get(&id).is_some_and(|current| Arc::ptr_eq(current, &retained)) {
connections.remove(&id);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
use tokio_tungstenite::{
accept_hdr_async,
tungstenite::handshake::server::{ErrorResponse, Request, Response},
};
#[allow(clippy::result_large_err)] // Required by tungstenite's callback signature.
fn accept_test_protocol(request: &Request, mut response: Response) -> Result<Response, ErrorResponse> {
assert_eq!(request.headers()["authorization"], "Bearer test");
assert_eq!(request.headers()["sec-websocket-protocol"], "openidea.test.v1");
response.headers_mut().insert("sec-websocket-protocol", "openidea.test.v1".parse().unwrap());
Ok(response)
}
async fn server() -> (String, tokio::task::JoinHandle<Vec<Vec<u8>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/socket", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let socket = accept_hdr_async(stream, accept_test_protocol).await.unwrap();
let (_, mut reader) = socket.split();
let mut messages = Vec::new();
while let Ok(Some(message)) = tokio::time::timeout(Duration::from_secs(2), reader.next()).await {
match message.unwrap() {
Message::Binary(bytes) => messages.push(bytes.to_vec()),
Message::Close(_) => break,
_ => {}
}
}
messages
});
(url, task)
}
#[tokio::test]
async fn outbound_message_reaches_the_websocket_server() {
let (url, server) = server().await;
let hub = WebSocketHub::default();
let connection = hub
.connect(
"plugin",
"/project",
&url,
&[("authorization".into(), "Bearer test".into())],
"openidea.test.v1",
Duration::from_secs(1),
)
.await
.unwrap();
hub.send("plugin", connection, b"message".to_vec()).unwrap();
hub.disconnect("plugin", connection).unwrap();
assert_eq!(server.await.unwrap(), [b"message"]);
}
#[tokio::test]
async fn inbound_messages_can_be_drained_and_disconnect_cleans_the_handle() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/socket", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = accept_hdr_async(stream, accept_test_protocol).await.unwrap();
socket.send(Message::Binary(vec![1, 2, 3].into())).await.unwrap();
socket.close(None).await.unwrap();
});
let hub = WebSocketHub::default();
let connection = hub
.connect(
"plugin",
"/project",
&url,
&[("authorization".into(), "Bearer test".into())],
"openidea.test.v1",
Duration::from_secs(1),
)
.await
.unwrap();
server.await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(
hub.drain("plugin", connection, 1).unwrap(),
WebSocketBatch { messages: vec![vec![1, 2, 3]], closed: true }
);
assert!(hub.drain("plugin", connection, 1).is_err());
}
#[test]
fn send_rejects_full_queues_without_reporting_success() {
let hub = WebSocketHub::default();
let (stop, _) = watch::channel(false);
let connection = Arc::new(Connection {
plugin: "plugin".into(),
project: "/project".into(),
outgoing: Arc::new(Inbox::new(1, MAX_BYTES, MAX_FRAME)),
incoming: Arc::new(Inbox::new(1, MAX_BYTES, MAX_FRAME)),
wake: Notify::new(),
stop,
});
hub.0.connections.lock().insert(1, connection.clone());
assert!(hub.send("other-plugin", 1, b"cross-plugin".to_vec()).is_err());
assert!(hub.drain("other-plugin", 1, 1).is_err());
assert!(hub.send("plugin", 1, b"first".to_vec()).is_ok());
assert!(hub.send("plugin", 1, b"second".to_vec()).unwrap_err().contains("rejected"));
assert_eq!(connection.outgoing.drain(1).messages, [b"first"]);
}
#[test]
fn websocket_urls_require_tls_and_keep_credentials_out_of_the_url() {
assert!(validate_url("wss://relay.example/socket").is_ok());
assert!(validate_url("wss://relay.example/socket?channel=main&token=opaque").is_ok());
assert!(validate_url("ws://127.0.0.1:9000/socket").is_ok());
assert!(validate_url("ws://relay.example/socket").unwrap_err().contains("WSS"));
assert!(validate_url("wss://[email protected]/socket").is_err());
assert!(validate_url("wss://relay.example/socket#fragment").is_err());
assert!(protected_header("Host"));
assert!(protected_header("Connection"));
assert!(protected_header("Upgrade"));
assert!(protected_header("Sec-WebSocket-Key"));
assert!(!protected_header("Cookie"));
assert!(!protected_header("X-API-Key"));
}
#[tokio::test]
#[allow(clippy::result_large_err)] // Required by tungstenite's callback signature.
async fn optional_subprotocol_and_custom_headers_reach_the_handshake() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/socket?channel=main", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = accept_hdr_async(stream, |request: &Request, response: Response| {
assert_eq!(request.headers()["cookie"], "session=test");
assert_eq!(request.headers()["x-api-key"], "key");
assert!(request.headers().get("sec-websocket-protocol").is_none());
Ok(response)
})
.await
.unwrap();
socket.close(None).await.unwrap();
});
let hub = WebSocketHub::default();
let connection = hub
.connect(
"plugin",
"/project",
&url,
&[("cookie".into(), "session=test".into()), ("x-api-key".into(), "key".into())],
"",
Duration::from_secs(1),
)
.await
.unwrap();
hub.disconnect("plugin", connection).unwrap();
server.await.unwrap();
}
#[test]
fn connection_limit_is_enforced_before_connecting() {
let hub = WebSocketHub::default();
for _ in 0..MAX_CONNECTIONS_PER_PLUGIN {
hub.reserve("plugin").unwrap();
}
assert_eq!(
hub.reserve("plugin"),
Err(ConnectError::Invalid("at most 8 WebSocket connections per plugin".into()))
);
for _ in 0..MAX_CONNECTIONS_PER_PLUGIN {
hub.release("plugin");
}
}
}
+47
Переглянути файл
@@ -69,6 +69,8 @@ pub struct Project {
/// watchers for (a virtualenv elsewhere, a path dependency).
lsp_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
pub(crate) _scratch_watcher: Option<Watcher>,
/// Picks up data sources edited on disk outside the Database tool window.
_db_sources_watch: Option<ide_db::SourcesWatch>,
/// A LightEdit window's loose files ([`Host::open_light`]): global settings only,
/// nothing indexed, no language servers, repositories or project services.
pub light: bool,
@@ -536,10 +538,12 @@ impl Host {
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings);
let db_sources_watch = db_sources_watch(&db, &sink, &self.runtime);
let notebooks = crate::notebook::NotebookService::new(
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
notebooks.set_observer(Arc::new(NotebookLspObserver(lsp.clone())));
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
let plugin_diagnostics =
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &root);
@@ -576,6 +580,7 @@ impl Host {
content_watchers: Mutex::new(content_watchers),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: scratch_watcher,
_db_sources_watch: db_sources_watch,
files_events_active,
light: false,
});
@@ -738,6 +743,7 @@ impl Host {
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
_db_sources_watch: None,
files_events_active: Arc::new(AtomicBool::new(false)),
light: true,
root: dir,
@@ -790,6 +796,28 @@ fn documents(sink: &SharedSink, languages: &Arc<LanguageRegistry>, root: Option<
)
}
/// Data sources edited on disk outside the Database tool window (by hand, by a coding
/// agent): connections made with the old settings close and the tree re-reads them,
/// without reopening the project.
fn db_sources_watch(
db: &Arc<ide_db::DbManager>,
sink: &SharedSink,
runtime: &tokio::runtime::Handle,
) -> Option<ide_db::SourcesWatch> {
let (weak, sink, runtime) = (Arc::downgrade(db), sink.clone(), runtime.clone());
db.watch_sources(Box::new(move || {
let Some(db) = weak.upgrade() else { return };
let sink = sink.clone();
runtime.spawn(async move {
if db.reconcile_sources().await {
sink.emit(IdeEvent::DbSourcesChanged);
}
});
}))
.inspect_err(|e| tracing::warn!("{}", e.message))
.ok()
}
fn light_watcher(
dir: &Path,
sink: &SharedSink,
@@ -1578,6 +1606,25 @@ impl DocumentObserver for LspObserver {
}
}
/// Feeds the code cells of open notebooks to the Python language servers that sync notebooks.
struct NotebookLspObserver(Arc<LspManager>);
impl crate::notebook::NotebookObserver for NotebookLspObserver {
fn changed(&self, path: &Path, snapshot: &ide_notebook::NotebookSnapshot) {
let cells: Vec<ide_lsp::NotebookCell> = snapshot
.cells
.iter()
.filter(|c| c.cell_type == "code")
.map(|c| ide_lsp::NotebookCell { id: c.id.clone(), source: c.source.clone() })
.collect();
// Notebooks run Python kernels only.
self.0.sync_notebook(path, &LanguageId::new("python"), &cells);
}
fn closed(&self, path: &Path) {
self.0.close_notebook(path);
}
}
/// Feeds document edits into the per-project syntax tree cache.
struct SyntaxObserver(Arc<SyntaxCache>);
+89
Переглянути файл
@@ -808,6 +808,28 @@ fn a_reporting_task_outlives_its_background_budget() {
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[test]
fn background_tasks_scale_and_renew_cpu_fuel_budget() {
let f = fixture(Limits {
fuel: 3_500,
timeout: Duration::from_millis(500),
background: Duration::from_millis(5_000),
..Limits::default()
});
f.install(&misbehave_pkg());
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
// Warm up activation in its own call so `patient` starts with a fresh 3_500-fuel budget.
assert_eq!(f.run("misbehave", "echo", Some("warm")).unwrap().as_deref(), Some("warm"));
let out = f.run("misbehave", "patient", None).expect("background task succeeds").expect("output");
assert_eq!(out, "still here");
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("exceeded its CPU budget (3500 fuel per call)"), "{err}");
}
#[test]
fn private_directory_is_owned_persistent_and_removed_on_uninstall() {
let f = fixture(Limits {
@@ -6648,6 +6670,73 @@ fn project_write_cannot_replace_the_git_file_of_a_linked_worktree() {
}
}
#[cfg(unix)]
#[test]
fn process_timeout_returns_guest_error_without_disabling_plugin() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits {
timeout: Duration::from_millis(120),
max_failures: 3,
restart_backoff: Duration::ZERO,
..Limits::default()
});
let package = f._tmp.path().join("api14-slow-proc");
copy_dir(&sample("api14"), &package);
let manifest = package.join("plugin.toml");
let text =
std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", ");
std::fs::write(manifest, text).unwrap();
f.install(&package);
let slow = f._tmp.path().join("slow-tool");
std::fs::write(&slow, "#!/bin/sh\nsleep 5\n").unwrap();
std::fs::set_permissions(&slow, std::fs::Permissions::from_mode(0o755)).unwrap();
let slow = slow.canonicalize().unwrap();
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&slow)).unwrap();
// Even past `max_failures` (3), external subprocess timeouts unwind as guest errors
// instead of poisoning the Wasm instance and disabling the plugin for the session.
for _ in 0..4 {
let err = f.run("api14", "process-run", None).unwrap_err();
assert!(err.contains("api14-tool timed out"), "{err}");
}
let info = f.host.info_of("api14").unwrap();
assert_eq!(info.status, PluginStatus::Active);
assert_eq!(info.stats.crashes, 0);
}
#[cfg(unix)]
#[test]
fn process_stdout_accepts_large_payloads_and_reports_overflow_explicitly() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits { timeout: Duration::from_secs(10), ..Limits::default() });
let package = f._tmp.path().join("api14-large-proc");
copy_dir(&sample("api14"), &package);
let manifest = package.join("plugin.toml");
let text =
std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", ");
std::fs::write(manifest, text).unwrap();
f.install(&package);
let medium_bytes = 5 * 1024 * 1024;
let medium = f._tmp.path().join("medium-tool");
std::fs::write(&medium, "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null | tr '\\0' 'x'\n").unwrap();
std::fs::set_permissions(&medium, std::fs::Permissions::from_mode(0o755)).unwrap();
let medium = medium.canonicalize().unwrap();
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&medium)).unwrap();
let out = f.run("api14", "process-run", None).unwrap().unwrap();
assert_eq!(out.len(), medium_bytes);
let huge = f._tmp.path().join("huge-tool");
std::fs::write(&huge, "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=17 2>/dev/null | tr '\\0' 'x'\n").unwrap();
std::fs::set_permissions(&huge, std::fs::Permissions::from_mode(0o755)).unwrap();
let huge = huge.canonicalize().unwrap();
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&huge)).unwrap();
let err = f.run("api14", "process-run", None).unwrap_err();
assert!(err.contains("stdout exceeded 16 MiB limit"), "{err}");
assert_eq!(f.host.info_of("api14").unwrap().stats.crashes, 0);
}
/// A link already in the project is written through, so it is judged by where it points.
#[cfg(unix)]
#[test]
+12 -6
Переглянути файл
@@ -29,17 +29,23 @@ pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
build_with_redirect(proxy, reqwest::redirect::Policy::default())
}
/// A client with an explicit redirect policy, for callers enforcing network grants.
pub fn build_with_redirect(proxy: &ProxySettings, redirect: reqwest::redirect::Policy) -> IdeResult<reqwest::Client> {
/// The backend's TLS configuration: ring with the operating system's trust store.
pub fn tls_config() -> IdeResult<rustls::ClientConfig> {
use rustls_platform_verifier::BuilderVerifierExt;
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
let fail = |e: String| IdeError::internal(format!("TLS client: {e}"));
let provider = Arc::new(rustls::crypto::ring::default_provider());
let tls = rustls::ClientConfig::builder_with_provider(provider)
rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| fail(e.to_string()))?
.with_platform_verifier()
.map_err(|e| fail(e.to_string()))?
.with_no_client_auth();
.map_err(|e| fail(e.to_string()))
.map(|builder| builder.with_no_client_auth())
}
/// A client with an explicit redirect policy, for callers enforcing network grants.
pub fn build_with_redirect(proxy: &ProxySettings, redirect: reqwest::redirect::Policy) -> IdeResult<reqwest::Client> {
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
let tls = tls_config()?;
// `localhost` is tried on IPv4 first: local servers (Ollama, dev servers)
// usually listen there, and on Windows a refused `::1` costs ~2 s before
// the system resolver's order falls back. Ports come from the URL.
+17 -2
Переглянути файл
@@ -5,10 +5,11 @@
use std::path::{Path, PathBuf};
/// The outermost ancestor of `file` containing a root marker, within
/// `project_root` (any ancestor without one: loose files).
/// `project_root` (any ancestor without one: loose files). A directory counts
/// as its own ancestor: a module folder is served like the files in it.
pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) -> Option<PathBuf> {
let mut best = None;
for dir in file.ancestors().skip(1) {
for dir in file.ancestors().skip(usize::from(!file.is_dir())) {
if project_root.is_some_and(|root| !dir.starts_with(root)) {
break;
}
@@ -112,6 +113,20 @@ mod tests {
assert_eq!(marked_root(Some(root), &root.join("apps/api/main.ts"), &markers), None);
}
#[test]
fn a_marked_directory_is_its_own_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let module = root.join("first");
std::fs::create_dir_all(module.join("src")).unwrap();
std::fs::write(module.join("pom.xml"), "").unwrap();
let markers = vec!["pom.xml".to_string()];
assert_eq!(marked_root(Some(root), &module.join("src/Main.java"), &markers), Some(module.clone()));
assert_eq!(marked_root(Some(root), &module, &markers), Some(module.clone()));
assert_eq!(marked_root(Some(root), &module.join("src"), &markers), Some(module));
assert_eq!(marked_root(Some(root), &root.join("second"), &markers), None);
}
#[test]
fn markers_above_the_project_root_only_count_without_one() {
let tmp = tempfile::tempdir().unwrap();
+51 -1
Переглянути файл
@@ -163,7 +163,9 @@ pub fn client_capabilities() -> Value {
"typeHierarchy": { "dynamicRegistration": false }
},
// rust-analyzer: report when the project has loaded (`quiescent`).
"experimental": { "serverStatusNotification": true }
"experimental": { "serverStatusNotification": true },
// Jupyter notebooks: code cells are synced with `notebookDocument/*` (see `notebook.rs`).
"notebookDocument": { "synchronization": { "dynamicRegistration": false, "executionSummarySupport": false } }
})
}
@@ -426,6 +428,34 @@ impl ServerCaps {
.unwrap_or_default()
}
/// Whether `notebookDocumentSync` takes Jupyter notebooks (`file:`) with cells of `language`:
/// a selector that names no notebook, or `jupyter-notebook` / `*`, and no cell languages or
/// `language` among them.
pub fn syncs_notebooks(&self, language: &str) -> bool {
let Some(selectors) = self.raw.pointer("/notebookDocumentSync/notebookSelector").and_then(Value::as_array)
else {
return false;
};
selectors.iter().any(|s| {
let notebook = match s.get("notebook") {
None | Some(Value::Null) => true,
Some(Value::String(kind)) => kind == "jupyter-notebook" || kind == "*",
Some(filter) => {
filter
.get("notebookType")
.and_then(Value::as_str)
.is_none_or(|t| t == "jupyter-notebook" || t == "*")
&& filter.get("scheme").and_then(Value::as_str).is_none_or(|s| s == "file")
}
};
let cells = match s.get("cells").and_then(Value::as_array) {
None => true,
Some(cells) => cells.iter().any(|c| c.get("language").and_then(Value::as_str) == Some(language)),
};
notebook && cells && (s.get("notebook").is_some() || s.get("cells").is_some())
})
}
/// `workspace.fileOperations.<op>` registration options (`op`: `willRename`, `didRename`).
pub fn file_operation(&self, op: &str) -> Option<&Value> {
self.raw.pointer(&format!("/workspace/fileOperations/{op}")).filter(|v| v.is_object())
@@ -668,6 +698,26 @@ mod tests {
assert_eq!(c.code_action_kinds(), Some(vec!["refactor.inline".into()]));
}
#[test]
fn notebook_sync_selectors() {
let syncs = |selector: Value| {
ServerCaps::parse(json!({ "notebookDocumentSync": { "notebookSelector": [selector] } }))
.syncs_notebooks("python")
};
// basedpyright
assert!(syncs(json!({
"notebook": { "scheme": "file", "notebookType": "jupyter-notebook" }, "cells": [{ "language": "python" }]
})));
// Ruff: any notebook with Python cells.
assert!(syncs(json!({ "cells": [{ "language": "python" }] })));
assert!(syncs(json!({ "notebook": "*" })));
assert!(!syncs(json!({ "notebook": "interactive" })));
assert!(!syncs(json!({ "notebook": { "notebookType": "jupyter-notebook", "scheme": "untitled" } })));
assert!(!syncs(json!({ "cells": [{ "language": "r" }] })));
assert!(!syncs(json!({})));
assert!(!ServerCaps::parse(json!({ "textDocumentSync": 2 })).syncs_notebooks("python"));
}
#[test]
fn language_definitions_use_known_feature_names() {
for lang in ide_language::LanguageRegistry::load(None).unwrap().all() {
+6 -1
Переглянути файл
@@ -10,6 +10,7 @@ use ide_core::{IdeEvent, TaskId};
use ide_jsonrpc::{Handler, codes};
use lsp_types as lsp;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use std::time::Duration;
@@ -229,7 +230,11 @@ impl ClientHandler {
return;
}
};
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
let Some(path) = uri_to_path(params.uri.as_str())
.or_else(|| crate::notebook::is_cell_uri(params.uri.as_str()).then(|| PathBuf::from(params.uri.as_str())))
else {
return;
};
if self.server.name == "jdtls"
&& let Some(failure) = crate::jdtls::build_plan_failure(&path, &self.server.root, &params.diagnostics)
{
+40 -5
Переглянути файл
@@ -25,6 +25,7 @@ mod kotlin_lsp;
mod launch;
mod library_source;
mod loading;
mod notebook;
mod pull;
mod scope;
mod server;
@@ -42,6 +43,7 @@ pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
pub use notebook::{NotebookCell, NotebookCellDoc, cell_uri};
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
@@ -115,6 +117,8 @@ struct DocState {
servers: Vec<Attached>,
/// Open YAML document whose filename does not match a Compose glob and whose content is Compose.
compose_detected: bool,
/// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file.
notebook: Option<PathBuf>,
}
impl DocState {
@@ -229,6 +233,8 @@ pub struct LspManager {
/// Contents fetched for virtual URIs.
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
injections: injections::State,
/// Open notebooks by path. Locked before `docs` when both are.
notebooks: Mutex<HashMap<PathBuf, notebook::NotebookState>>,
}
/// Fetched virtual documents kept before the cache is reset.
@@ -476,6 +482,7 @@ impl LspManager {
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
injections: injections::State::default(),
notebooks: Mutex::new(HashMap::new()),
})
}
@@ -614,8 +621,13 @@ impl LspManager {
/// enabled in settings.
fn attach_fallbacks(&self) {
let _gate = self.injections.gate.lock();
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
let open: Vec<(DocId, LanguageId, PathBuf)> = self
.docs
.lock()
.iter()
.filter(|(_, d)| d.notebook.is_none())
.map(|(id, d)| (*id, d.language.clone(), d.path.clone()))
.collect();
let wanted: Vec<(DocId, Vec<Arc<Server>>)> =
open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect();
let mut start = Vec::new();
@@ -648,6 +660,11 @@ impl LspManager {
for (doc, server) in opened {
self.schedule_pull(doc, Some(&server), Duration::ZERO);
}
for server in self.attach_notebook_servers() {
if !start.iter().any(|s| s.id == server.id) {
start.push(server);
}
}
for server in &start {
self.ensure_started(server);
}
@@ -867,7 +884,7 @@ impl LspManager {
false
} else {
let open_close = run.caps.read().open_close;
for (id, d) in docs.iter_mut() {
for (id, d) in docs.iter_mut().filter(|(_, d)| d.notebook.is_none()) {
let params = d.did_open_params();
for a in
d.servers.iter_mut().filter(|a| a.server.id == server.id && a.opened_gen != Some(generation))
@@ -889,6 +906,7 @@ impl LspManager {
for doc in reopened {
self.schedule_pull(doc, Some(&server), Duration::ZERO);
}
self.reopen_notebooks(&server, &run);
self.check_clang_tidy(&server).await;
self.check_vue_navigation(&server).await;
server.refresh_busy_status(&self.sink);
@@ -1083,6 +1101,7 @@ impl LspManager {
text: Arc::new(DocText::new(text)),
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected,
notebook: None,
};
let compose_added = {
let mut docs = self.docs.lock();
@@ -1214,7 +1233,7 @@ impl LspManager {
return;
}
let docs = self.docs.lock();
for d in docs.values() {
for d in docs.values().filter(|d| d.notebook.is_none()) {
if !d.servers.iter().any(|a| a.server.id == server.id && a.opened_gen == Some(generation)) {
continue;
}
@@ -1288,13 +1307,23 @@ impl LspManager {
fn snapshot(&self, doc: DocId) -> IdeResult<DocSnap> {
let docs = self.docs.lock();
let d = docs.get(&doc).ok_or_else(|| IdeError::not_found(format!("{doc} is not open")))?;
// A cell is asked only of the servers its notebook is open on (those that sync notebooks).
let servers = d
.servers
.iter()
.filter(|a| {
d.notebook.is_none()
|| a.opened_gen.is_some_and(|g| a.server.running().is_some_and(|run| run.generation == g))
})
.map(|a| a.server.clone())
.collect();
Ok(DocSnap {
version: d.version,
path: d.path.clone(),
uri: d.uri.clone(),
language: d.language.clone(),
text: d.text.clone(),
servers: d.servers.iter().map(|a| a.server.clone()).collect(),
servers,
})
}
@@ -3490,11 +3519,17 @@ impl LspManager {
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
return;
}
let existing: Vec<ide_core::ServerId> = self.servers.lock().iter().map(|s| s.id).collect();
self.reconfigure_jdtls_workspace().await;
self.stop_unlisted().await;
let Some(me) = self.me.upgrade() else { return };
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
for s in servers {
// Created in this pass (the jdtls of a new workspace layout): it starts with the
// current configuration, though it may not have recorded it yet.
if !existing.contains(&s.id) {
continue;
}
// The settings hook may run tools (`npm root -g`), as when the server starts.
let next = match tokio::task::spawn_blocking({
let (me, s) = (me.clone(), s.clone());
+382
Переглянути файл
@@ -0,0 +1,382 @@
//! Jupyter notebooks on servers that sync them with LSP 3.17 `notebookDocument/*` (basedpyright,
//! Ruff). Each code cell is a document of its own, `vscode-notebook-cell:<notebook path>#<cell id>`,
//! kept in `docs` under a [`DocId`]: completion, hover and the other requests address a cell like
//! any open file. Only opening, changing and closing go through the notebook methods; a change in
//! the cells' order closes and reopens the notebook.
use crate::server::{Running, Server};
use crate::text::DocText;
use crate::{Attached, DocState, LspManager, uri};
use ide_core::{DocId, LanguageId, ServerId};
use lsp_types as lsp;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use serde::Serialize;
use serde_json::{Value, json};
use specta::Type;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
const CELL_SCHEME: &str = "vscode-notebook-cell";
const NOTEBOOK_TYPE: &str = "jupyter-notebook";
/// `NotebookCellKind.Code`.
const CODE_CELL: u32 = 2;
/// Escaped in a cell id: nbformat ids are `[a-zA-Z0-9-_]`, older notebooks may have others.
const FRAGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_');
/// A code cell as the notebook holds it.
pub struct NotebookCell {
pub id: String,
pub source: String,
}
/// The document the language servers know a code cell by.
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NotebookCellDoc {
pub cell: String,
pub doc: DocId,
/// Also the `path` of the cell's diagnostics and of locations in it.
pub uri: String,
}
pub(crate) struct NotebookState {
uri: lsp::Uri,
language: LanguageId,
version: i32,
/// Code cells in notebook order.
cells: Vec<(String, DocId)>,
servers: Vec<Arc<Server>>,
/// Servers it is open on, by instance generation.
opened: Vec<(ServerId, u64)>,
}
/// The URI of cell `cell` of the notebook at `path`: the notebook's `file:` URI with the cell
/// scheme and the cell id as fragment (as VS Code names cells; basedpyright expects the scheme).
pub fn cell_uri(path: &Path, cell: &str) -> String {
let file = uri::path_to_uri_string(path);
let rest = file.strip_prefix("file://").unwrap_or(&file);
// `file:///C:/a` → `…:/C:/a`; a share (`file://server/s/a`) keeps its authority.
let authority = if rest.starts_with('/') { "" } else { "//" };
format!("{CELL_SCHEME}:{authority}{rest}#{}", utf8_percent_encode(cell, FRAGMENT))
}
pub(crate) fn is_cell_uri(uri: &str) -> bool {
uri.strip_prefix(CELL_SCHEME).is_some_and(|rest| rest.starts_with(':'))
}
fn parse_uri(s: &str) -> Option<lsp::Uri> {
lsp::Uri::from_str(s).inspect_err(|e| tracing::debug!("not a valid URI {s}: {e}")).ok()
}
impl NotebookState {
fn opened_on(&self, server: ServerId, generation: u64) -> bool {
self.opened.contains(&(server, generation))
}
/// The servers it is open on whose instance is still the one it was opened on.
fn open_targets(&self) -> Vec<(Arc<Server>, Running)> {
self.servers
.iter()
.filter_map(|s| {
let run = s.running()?;
self.opened_on(s.id, run.generation).then(|| (s.clone(), run))
})
.collect()
}
}
impl LspManager {
/// Opens the notebook at `path` on its language's servers that sync notebooks, or brings
/// them up to date with `cells` (its code cells, in order).
pub fn sync_notebook(&self, path: &Path, language: &LanguageId, cells: &[NotebookCell]) {
let mut notebooks = self.notebooks.lock();
let mut changed = Vec::new();
let start = match notebooks.get_mut(path) {
None => {
let Some(notebook_uri) = parse_uri(&uri::path_to_uri_string(path)) else { return };
let servers = self.servers_for(language, path);
let mut state = NotebookState {
uri: notebook_uri,
language: language.clone(),
version: 1,
cells: Vec::new(),
servers: servers.clone(),
opened: Vec::new(),
};
self.set_cells(path, &mut state, cells);
self.open_notebook(&mut state, None);
notebooks.insert(path.to_owned(), state);
servers
}
Some(state) => {
let same_cells =
state.cells.len() == cells.len() && state.cells.iter().zip(cells).all(|((id, _), c)| *id == c.id);
if same_cells {
changed = self.change_cells(state, cells);
} else {
self.close_notebook_on_servers(state);
state.version += 1;
self.set_cells(path, state, cells);
self.open_notebook(state, None);
}
Vec::new()
}
};
drop(notebooks);
for server in &start {
self.ensure_started(server);
}
for doc in changed {
self.schedule_pull(doc, None, crate::pull::AFTER_EDIT);
}
}
/// Closes the notebook at `path` on its servers and forgets its cells.
pub fn close_notebook(&self, path: &Path) {
let Some(mut state) = self.notebooks.lock().remove(path) else { return };
self.close_notebook_on_servers(&mut state);
let removed: Vec<DocId> = state.cells.iter().map(|(_, doc)| *doc).collect();
self.remove_cells(&state.servers, &removed);
}
/// The documents of the notebook's code cells, in notebook order; empty when it isn't open.
pub fn notebook_cells(&self, path: &Path) -> Vec<NotebookCellDoc> {
let notebooks = self.notebooks.lock();
let Some(state) = notebooks.get(path) else { return Vec::new() };
let docs = self.docs.lock();
state
.cells
.iter()
.filter_map(|(cell, doc)| {
Some(NotebookCellDoc { cell: cell.clone(), doc: *doc, uri: docs.get(doc)?.uri.as_str().to_owned() })
})
.collect()
}
/// Opens the notebooks of `server`'s language on its instance that just became ready.
pub(crate) fn reopen_notebooks(&self, server: &Arc<Server>, run: &Running) {
let mut pull = Vec::new();
{
let mut notebooks = self.notebooks.lock();
for state in notebooks.values_mut() {
if state.servers.iter().any(|s| s.id == server.id) && !state.opened_on(server.id, run.generation) {
self.open_notebook(state, Some(server));
pull.extend(state.cells.iter().map(|(_, doc)| *doc));
}
}
}
for doc in pull {
self.schedule_pull(doc, Some(server), std::time::Duration::ZERO);
}
}
/// Adds servers of the notebooks' languages that were not wanted when they opened (a
/// fallback for a missing server, one enabled in settings) and returns them to be started.
pub(crate) fn attach_notebook_servers(&self) -> Vec<Arc<Server>> {
let mut start = Vec::new();
let mut notebooks = self.notebooks.lock();
for (path, state) in notebooks.iter_mut() {
let added: Vec<Arc<Server>> = self
.servers_for(&state.language, path)
.into_iter()
.filter(|s| !state.servers.iter().any(|known| known.id == s.id))
.collect();
if added.is_empty() {
continue;
}
{
let mut docs = self.docs.lock();
for (_, doc) in &state.cells {
if let Some(d) = docs.get_mut(doc) {
d.servers.extend(added.iter().map(|s| Attached { server: s.clone(), opened_gen: None }));
}
}
}
state.servers.extend(added.iter().cloned());
for server in &added {
self.open_notebook(state, Some(server));
}
start.extend(added);
}
start
}
/// Replaces the cell documents with `cells`: kept cells keep their [`DocId`].
fn set_cells(&self, path: &Path, state: &mut NotebookState, cells: &[NotebookCell]) {
let mut docs = self.docs.lock();
let previous = std::mem::take(&mut state.cells);
for cell in cells {
let kept = previous.iter().find(|(id, _)| *id == cell.id).map(|(_, doc)| *doc);
if let Some(doc) = kept
&& let Some(d) = docs.get_mut(&doc)
{
if d.text.as_str() != cell.source {
Arc::make_mut(&mut d.text).set(&cell.source);
}
d.version += 1;
state.cells.push((cell.id.clone(), doc));
continue;
}
let uri_string = cell_uri(path, &cell.id);
let Some(cell_uri) = parse_uri(&uri_string) else { continue };
let doc = DocId::next();
let key = PathBuf::from(&uri_string);
docs.insert(
doc,
DocState {
canonical: key.clone(),
path: key,
uri: cell_uri,
language: state.language.clone(),
version: 1,
text: Arc::new(DocText::new(&cell.source)),
servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
compose_detected: false,
notebook: Some(path.to_owned()),
},
);
state.cells.push((cell.id.clone(), doc));
}
drop(docs);
let removed: Vec<DocId> = previous
.into_iter()
.filter(|(_, doc)| !state.cells.iter().any(|(_, d)| d == doc))
.map(|(_, d)| d)
.collect();
self.remove_cells(&state.servers, &removed);
}
/// Sends the cells whose text differs from `cells` as one change; returns their documents.
fn change_cells(&self, state: &mut NotebookState, cells: &[NotebookCell]) -> Vec<DocId> {
let mut docs = self.docs.lock();
let mut changed = Vec::new();
let mut content = Vec::new();
for ((_, doc), cell) in state.cells.iter().zip(cells) {
let Some(d) = docs.get_mut(doc) else { continue };
if d.text.as_str() == cell.source {
continue;
}
Arc::make_mut(&mut d.text).set(&cell.source);
d.version += 1;
content.push(json!({
"document": { "uri": d.uri, "version": d.version },
"changes": [{ "text": cell.source }],
}));
changed.push(*doc);
}
drop(docs);
if content.is_empty() {
return changed;
}
state.version += 1;
let params = json!({
"notebookDocument": { "uri": state.uri, "version": state.version },
"change": { "cells": { "textContent": content } },
});
for (server, run) in state.open_targets() {
if let Err(e) = run.peer.notify("notebookDocument/didChange", params.clone()) {
tracing::debug!("{}: notebookDocument/didChange: {e}", server.name);
}
}
changed
}
/// Opens the notebook on its running servers that sync notebooks (`only`: on that one).
fn open_notebook(&self, state: &mut NotebookState, only: Option<&Arc<Server>>) {
let mut docs = self.docs.lock();
let mut cells = Vec::new();
let mut texts = Vec::new();
for (_, doc) in &state.cells {
let Some(d) = docs.get(doc) else { continue };
cells.push(json!({ "kind": CODE_CELL, "document": d.uri }));
texts.push(json!({
"uri": d.uri,
"languageId": d.language.as_str(),
"version": d.version,
"text": d.text.as_str(),
}));
}
let params = json!({
"notebookDocument": { "uri": state.uri, "notebookType": NOTEBOOK_TYPE, "version": state.version, "cells": cells },
"cellTextDocuments": texts,
});
let servers: Vec<Arc<Server>> =
state.servers.iter().filter(|s| only.is_none_or(|o| o.id == s.id)).cloned().collect();
for server in servers {
let Some(run) = server.running() else { continue };
if state.opened_on(server.id, run.generation) || !run.caps.read().syncs_notebooks(state.language.as_str()) {
continue;
}
if let Err(e) = run.peer.notify("notebookDocument/didOpen", params.clone()) {
tracing::debug!("{}: notebookDocument/didOpen: {e}", server.name);
continue;
}
state.opened.retain(|(id, _)| *id != server.id);
state.opened.push((server.id, run.generation));
for (_, doc) in &state.cells {
if let Some(a) = docs.get_mut(doc).and_then(|d| d.servers.iter_mut().find(|a| a.server.id == server.id))
{
a.opened_gen = Some(run.generation);
}
}
}
}
fn close_notebook_on_servers(&self, state: &mut NotebookState) {
let cells: Vec<Value> = {
let docs = self.docs.lock();
state.cells.iter().filter_map(|(_, doc)| docs.get(doc)).map(|d| json!({ "uri": d.uri })).collect()
};
let params = json!({ "notebookDocument": { "uri": state.uri }, "cellTextDocuments": cells });
for (server, run) in state.open_targets() {
if let Err(e) = run.peer.notify("notebookDocument/didClose", params.clone()) {
tracing::debug!("{}: notebookDocument/didClose: {e}", server.name);
}
}
state.opened.clear();
let mut docs = self.docs.lock();
for (_, doc) in &state.cells {
if let Some(d) = docs.get_mut(doc) {
d.servers.iter_mut().for_each(|a| a.opened_gen = None);
}
}
}
/// Forgets cell documents and clears what their servers reported about them: a server need
/// not clear the diagnostics of a cell that left the notebook.
fn remove_cells(&self, servers: &[Arc<Server>], removed: &[DocId]) {
let gone: Vec<DocState> = {
let mut docs = self.docs.lock();
removed.iter().filter_map(|doc| docs.remove(doc)).collect()
};
for d in gone {
for server in servers {
let run = server.running();
self.forget_pulled(server, &d.path, run.as_ref());
let enc = run.as_ref().map(|r| r.caps.read().encoding).unwrap_or_default();
let shown = server.set_diagnostics(d.path.clone(), Vec::new());
self.emit_diagnostics(server, d.path.clone(), shown, enc);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cell_uris_name_the_notebook_file_and_the_cell() {
let root = if cfg!(windows) { PathBuf::from(r"C:\work\my nb") } else { PathBuf::from("/work/my nb") };
let expected = if cfg!(windows) {
"vscode-notebook-cell:/C:/work/my%20nb/a.ipynb#f3-x_1%2E"
} else {
"vscode-notebook-cell:/work/my%20nb/a.ipynb#f3-x_1%2E"
};
let uri = cell_uri(&root.join("a.ipynb"), "f3-x_1.");
assert_eq!(uri, expected);
assert!(is_cell_uri(&uri) && parse_uri(&uri).is_some());
assert!(!is_cell_uri("file:///a.ipynb") && !is_cell_uri("vscode-notebook-cellx:/a"));
}
}
+146 -4
Переглянути файл
@@ -687,6 +687,8 @@ async fn java_classpath_waits_for_a_config_restart_that_is_reaping_startup() {
assert!(result.unwrap_err().message.contains("restart configuration error"));
} else {
assert!(result.is_ok(), "a configuration restart is transient: {result:?}");
// The module folder resolves to the instance serving its files, not a second one.
assert_eq!(h.mgr.servers().len(), 1);
assert_eq!(state.launches.load(Ordering::SeqCst), 2);
assert_eq!(h.mgr.servers()[0].id != old, shared);
}
@@ -1916,16 +1918,17 @@ async fn replacing_progress_keeps_cancellation_reports_and_indexing_on_the_new_t
for _ in 0..2 {
peer.notify("$/progress", json!({ "token": 7, "value": { "kind": "end" } })).unwrap();
}
// A later task is a barrier for both end notifications.
h.wait_status("mock", ServerStatus::Ready).await;
let refs = tokio::time::timeout(Duration::from_secs(5), search).await.unwrap().unwrap().unwrap();
assert_eq!(refs.incomplete, None);
// A later task is a barrier for both end notifications. It starts only after the search
// finished: one that is still running would see the barrier as indexing.
peer.notify("$/progress", json!({ "token": "barrier", "value": { "kind": "begin", "title": "Barrier" } })).unwrap();
peer.notify("$/progress", json!({ "token": "barrier", "value": { "kind": "end" } })).unwrap();
eventually(|| {
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "Barrier"))
})
.await;
h.wait_status("mock", ServerStatus::Ready).await;
let refs = tokio::time::timeout(Duration::from_secs(5), search).await.unwrap().unwrap().unwrap();
assert_eq!(refs.incomplete, None);
assert!(!ide_core::task::cancel(new));
let events = h.events.lock();
for id in [old, new] {
@@ -6781,3 +6784,142 @@ async fn gomod_files_are_served_by_gopls() {
assert_eq!(items[0].message, "missing requirement");
mgr.shutdown_all().await;
}
// ------------------------------------------------------------------ notebooks
fn notebook_caps() -> Value {
let mut caps = incremental_caps();
caps["notebookDocumentSync"] = json!({
"notebookSelector": [{
"notebook": { "scheme": "file", "notebookType": "jupyter-notebook" },
"cells": [{ "language": "python" }]
}]
});
caps
}
fn cells(cells: &[(&str, &str)]) -> Vec<ide_lsp::NotebookCell> {
cells.iter().map(|(id, source)| ide_lsp::NotebookCell { id: (*id).into(), source: (*source).into() }).collect()
}
fn cell_texts(open: &Value) -> Vec<(String, String)> {
open["cellTextDocuments"]
.as_array()
.unwrap()
.iter()
.map(|c| (c["uri"].as_str().unwrap().to_owned(), c["text"].as_str().unwrap().to_owned()))
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn notebook_cells_are_documents_of_the_servers_that_sync_notebooks() {
let h = harness_for("python", "py", &[("nb", notebook_caps()), ("plain", incremental_caps())], &[]);
let path = h.root.join("analysis.ipynb");
let python = LanguageId::new("python");
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "import os\n"), ("b", "os.pa")]));
h.wait_status("nb", ServerStatus::Ready).await;
h.wait_status("plain", ServerStatus::Ready).await;
let (nb, plain) = (h.launcher.state("nb"), h.launcher.state("plain"));
eventually(|| !nb.notified("notebookDocument/didOpen").is_empty()).await;
let docs = h.mgr.notebook_cells(&path);
assert_eq!(docs.iter().map(|d| d.cell.as_str()).collect::<Vec<_>>(), ["a", "b"]);
assert_eq!(docs[0].uri, ide_lsp::cell_uri(&path, "a"));
let open = &nb.notified("notebookDocument/didOpen")[0];
assert_eq!(open["notebookDocument"]["uri"], uri_of(&path));
assert_eq!(open["notebookDocument"]["notebookType"], "jupyter-notebook");
assert_eq!(open["notebookDocument"]["cells"][1], json!({ "kind": 2, "document": docs[1].uri }));
assert_eq!(
cell_texts(open),
[(docs[0].uri.clone(), "import os\n".to_owned()), (docs[1].uri.clone(), "os.pa".to_owned())]
);
assert!(plain.notified("notebookDocument/didOpen").is_empty() && plain.notified("textDocument/didOpen").is_empty());
// Requests address a cell like a file, and go only to the server it is open on.
nb.respond("textDocument/completion", json!({ "isIncomplete": false, "items": [{ "label": "path" }] }));
plain.respond("textDocument/completion", json!({ "isIncomplete": false, "items": [{ "label": "wrong" }] }));
let list = h.mgr.completion(docs[1].doc, Pos::new(0, 5), None, Default::default()).await.unwrap();
assert_eq!(list.items.iter().map(|i| i.label.as_str()).collect::<Vec<_>>(), ["path"]);
assert_eq!(nb.requested("textDocument/completion")[0]["textDocument"]["uri"], docs[1].uri);
assert!(plain.requested("textDocument/completion").is_empty());
// Its diagnostics are reported under the cell's URI.
nb.peer()
.notify(
"textDocument/publishDiagnostics",
json!({ "uri": docs[1].uri, "diagnostics": [{
"range": {"start":{"line":0,"character":3},"end":{"line":0,"character":5}}, "message": "no attribute pa"
}] }),
)
.unwrap();
let cell_path = PathBuf::from(&docs[1].uri);
eventually(|| {
h.events
.lock()
.iter()
.any(|e| matches!(e, IdeEvent::Diagnostics { path, items, .. } if path.0 == cell_path && items.len() == 1))
})
.await;
h.mgr.close_notebook(&path);
eventually(|| !nb.notified("notebookDocument/didClose").is_empty()).await;
assert_eq!(nb.notified("notebookDocument/didClose")[0]["cellTextDocuments"].as_array().unwrap().len(), 2);
assert!(h.mgr.notebook_cells(&path).is_empty());
assert!(h.mgr.completion(docs[1].doc, Pos::new(0, 5), None, Default::default()).await.is_err());
let last = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Diagnostics { path, items, source } if path.0 == cell_path && source == "nb" => Some(items.len()),
_ => None,
})
.next_back();
assert_eq!(last, Some(0), "a closed notebook's diagnostics are cleared");
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn notebook_edits_change_cell_text_and_reorders_reopen_it() {
let h = harness_for("python", "py", &[("nb", notebook_caps())], &[]);
let path = h.root.join("n.ipynb");
let python = LanguageId::new("python");
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "x = 1\n"), ("b", "print(x)\n")]));
h.wait_status("nb", ServerStatus::Ready).await;
let nb = h.launcher.state("nb");
eventually(|| nb.notified("notebookDocument/didOpen").len() == 1).await;
let before = h.mgr.notebook_cells(&path);
// Typing in one cell sends only that cell's text.
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "x = 1\n"), ("b", "print(x + 1)\n")]));
h.mgr.sync_notebook(&path, &python, &cells(&[("a", "x = 1\n"), ("b", "print(x + 1)\n")]));
eventually(|| !nb.notified("notebookDocument/didChange").is_empty()).await;
let changes = nb.notified("notebookDocument/didChange");
assert_eq!(changes.len(), 1, "an unchanged notebook sends nothing");
let content = &changes[0]["change"]["cells"]["textContent"];
assert_eq!(content.as_array().unwrap().len(), 1);
assert_eq!(content[0]["document"]["uri"], before[1].uri);
assert_eq!(content[0]["changes"], json!([{ "text": "print(x + 1)\n" }]));
assert_eq!(changes[0]["notebookDocument"]["version"], 2);
// Inserting and moving cells reopens the notebook; kept cells keep their documents.
h.mgr.sync_notebook(&path, &python, &cells(&[("c", "import math\n"), ("b", "print(x + 1)\n"), ("a", "x = 1\n")]));
eventually(|| nb.notified("notebookDocument/didOpen").len() == 2).await;
assert_eq!(nb.notified("notebookDocument/didClose").len(), 1);
let after = h.mgr.notebook_cells(&path);
assert_eq!(after.iter().map(|d| d.cell.as_str()).collect::<Vec<_>>(), ["c", "b", "a"]);
assert_eq!((after[1].doc, after[2].doc), (before[1].doc, before[0].doc));
let reopened = &nb.notified("notebookDocument/didOpen")[1];
assert_eq!(
cell_texts(reopened).into_iter().map(|(_, t)| t).collect::<Vec<_>>(),
["import math\n", "print(x + 1)\n", "x = 1\n"]
);
// A restarted server gets the notebook again.
let launches = nb.launches.load(Ordering::SeqCst);
let server = h.mgr.servers()[0].id;
h.mgr.restart(server).await.unwrap();
eventually(|| nb.launches.load(Ordering::SeqCst) > launches && nb.notified("notebookDocument/didOpen").len() == 3)
.await;
h.mgr.shutdown_all().await;
}
+96
Переглянути файл
@@ -920,3 +920,99 @@ async fn angular_language_server_end_to_end() {
}
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
/// A notebook's code cells on basedpyright and Ruff (LSP 3.17 notebook sync): names flow from
/// earlier cells, IPython magics parse, and locations and diagnostics name the cells.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires ruff and basedpyright on PATH"]
async fn basedpyright_and_ruff_serve_notebook_cells() {
use ide_lsp::{NotebookCell, cell_uri};
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let root = dunce::simplified(&root).to_path_buf();
std::fs::write(root.join("pyproject.toml"), "[tool.ruff.lint]\nselect = [\"F\"]\n").unwrap();
let path = root.join("analysis.ipynb");
std::fs::write(&path, "{}").unwrap();
let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap();
python.language_servers.retain(|server| matches!(server.name.as_str(), "basedpyright" | "ruff"));
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python]));
let cell = |id: &str, source: &str| NotebookCell { id: id.into(), source: source.into() };
let first = "import os\nimport sys\n\ndef area(r: float) -> float:\n return 3.14 * r * r\n";
let second = "%matplotlib inline\nsize: int = area(2)\nos.pa";
mgr.sync_notebook(&path, &LanguageId::new("python"), &[cell("first", first), cell("second", second)]);
for name in ["basedpyright", "ruff"] {
retry(name, Duration::from_secs(60), || {
let status = last_status(&events, name);
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
panic!("{name} failed: {msg:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
}
let docs = mgr.notebook_cells(&path);
let b = docs[1].doc;
let (a_uri, b_uri) = (cell_uri(&path, "first"), cell_uri(&path, "second"));
assert_eq!((docs[0].uri.as_str(), docs[1].uri.as_str()), (a_uri.as_str(), b_uri.as_str()));
// `os`, imported in the first cell, completes in the second.
let labels = retry("completion in a cell", Duration::from_secs(30), || {
let mgr = mgr.clone();
async move {
let list = mgr.completion(b, Pos::new(2, 5), Some(".".into()), Default::default()).await.ok()?;
let labels: Vec<String> = list.items.into_iter().map(|i| i.label).collect();
labels.iter().any(|l| l == "path").then_some(labels)
}
})
.await;
assert!(labels.iter().any(|l| l == "path"), "{labels:?}");
let hover = mgr.hover(b, Pos::new(1, 13), Default::default()).await.unwrap().expect("hover on `area`");
assert!(
hover.contents.value.contains("area") && hover.contents.value.contains("float"),
"{}",
hover.contents.value
);
let defs = mgr.goto(b, Pos::new(1, 13), GotoKind::Definition).await.unwrap();
assert_eq!(defs.len(), 1, "{defs:?}");
assert_eq!((defs[0].path.to_path_buf(), defs[0].range.start.line), (std::path::PathBuf::from(&a_uri), 3));
let diagnostics = |source: &'static str, uri: String| {
let events = events.clone();
move || {
let items = events.lock().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { path, source: s, items } if s == source && path.0 == Path::new(&uri) => {
Some(items.clone())
}
_ => None,
});
async move { items.filter(|items| !items.is_empty()) }
}
};
// `float` is not assignable to `int`; the magic on line 0 is no error.
let typed =
retry("basedpyright cell diagnostics", Duration::from_secs(30), diagnostics("basedpyright", b_uri)).await;
assert!(typed.iter().any(|d| d.range.start.line == 1), "{typed:?}");
assert!(typed.iter().all(|d| d.range.start.line != 0), "{typed:?}");
// F401: `sys` is unused.
let linted = retry("ruff cell diagnostics", Duration::from_secs(30), diagnostics("ruff", a_uri)).await;
assert!(linted.iter().any(|d| d.code.as_deref() == Some("F401") && d.range.start.line == 1), "{linted:?}");
// An edit reaches both servers: the error is fixed.
let fixed = "%matplotlib inline\nsize: float = area(2)\nos.path";
mgr.sync_notebook(&path, &LanguageId::new("python"), &[cell("first", first), cell("second", fixed)]);
retry("diagnostics after the fix", Duration::from_secs(30), || {
let uri = cell_uri(&path, "second");
let clear = events.lock().iter().rev().find_map(|e| match e {
IdeEvent::Diagnostics { path, source, items } if source == "basedpyright" && path.0 == Path::new(&uri) => {
Some(items.is_empty())
}
_ => None,
});
async move { (clear == Some(true)).then_some(()) }
})
.await;
mgr.close_notebook(&path);
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
+82 -6
Переглянути файл
@@ -55,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.5";
pub const API_VERSION: &str = "1.6";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -432,6 +432,27 @@ pub trait HostApi: Send + Sync {
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError>;
/// Performs `request` (its host is granted), filling in secret placeholders.
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
fn websocket_connect(
&self,
root: &Path,
request: crate::WebSocketRequest,
timeout: Duration,
) -> Result<u64, HostError> {
let _ = (root, request, timeout);
Err(HostError::Invalid("background WebSockets are unavailable on this host".into()))
}
fn websocket_send(&self, connection: u64, bytes: Vec<u8>) -> Result<(), HostError> {
let _ = (connection, bytes);
Err(HostError::Invalid("background WebSockets are unavailable on this host".into()))
}
fn websocket_drain(&self, connection: u64, limit: usize) -> Result<crate::WebSocketBatch, HostError> {
let _ = (connection, limit);
Err(HostError::Invalid("background WebSockets are unavailable on this host".into()))
}
fn websocket_close(&self, connection: u64) {
let _ = connection;
}
/// The `net:ask` answer for a request to HTTPS host `host` of this plugin
/// (`subject`): `Some(true)` allowed and remembered (a host the user
/// already allowed connects without asking again, also in the background),
@@ -606,6 +627,10 @@ impl PluginEngine {
let mut linker = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker).map_err(|e| e.to_string())?;
bindings::Plugin::add_to_linker::<_, HasSelf<_>>(&mut linker, |s| s).map_err(|e| e.to_string())?;
// Cranelift caches fuel in a JIT local across `new_epoch` yields and only
// reloads `VMStoreContext::fuel_consumed` across host import calls, so
// `tasks` host calls refill `StoreContextMut::set_fuel` directly on return.
bind_task_fuel_refill(&mut linker).map_err(|e| e.to_string())?;
let weak = engine.weak();
std::thread::Builder::new()
@@ -799,7 +824,7 @@ impl CallCancel {
self.token.lock().clone()
}
fn is_set(&self) -> bool {
pub(crate) fn is_set(&self) -> bool {
self.flag.load(Ordering::Acquire)
}
@@ -835,6 +860,10 @@ pub struct StoreData {
/// Tasks begun by the running call and not ended yet.
pub(crate) open_tasks: Vec<u64>,
pub(crate) last_report: Option<Instant>,
/// One-shot unwind window after a blocking host I/O call times out.
pub(crate) host_io_grace_used: bool,
/// Fuel budget active for the running call (scales when a background task begins).
pub(crate) active_fuel: u64,
}
impl WasiView for StoreData {
@@ -987,6 +1016,10 @@ const MAX_BATCH: usize = 1000;
fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
match e {
WorkbenchEvent::WebsocketReady(value) => wit_events::Event::Other(wit_types::Extension {
kind: "websocket-ready".into(),
data: serde_json::to_string(&value).expect("WebSocket ready event serializes"),
}),
WorkbenchEvent::RunExited(value) => wit_events::Event::Other(wit_types::Extension {
kind: "run-exited".into(),
data: serde_json::to_string(&value).unwrap(),
@@ -1067,6 +1100,8 @@ impl Runtime {
action_prompt_client: None,
open_tasks: Vec::new(),
last_report: None,
host_io_grace_used: false,
active_fuel: setup.limits.fuel,
};
let mut store = Store::new(&engine.engine, data);
store.limiter(|d| &mut d.store_limits);
@@ -1083,7 +1118,7 @@ impl Runtime {
});
arm(&mut store, &setup.limits, setup.limits.timeout).map_err(|e| e.to_string())?;
let api_hint = |e: wasmtime::Error| {
let msg = describe_trap(&e, &setup.limits);
let msg = describe_trap(&e, &setup.limits, setup.limits.fuel);
if msg.contains("openidea:plugin/") { link_error(&msg) } else { msg }
};
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
@@ -1868,10 +1903,11 @@ impl Runtime {
if !stderr.trim().is_empty() {
tracing::info!(plugin = %data.plugin_id, "stderr: {}", stderr.trim_end());
}
let active_fuel = data.active_fuel;
match result {
Ok(r) => Ok(r),
Err(e) => {
let mut msg = describe_trap(&e, &self.limits);
let mut msg = describe_trap(&e, &self.limits, active_fuel);
// Typically the panic message: "panicked at src/lib.rs:1:2:\nboom".
let detail: Vec<&str> =
stderr.lines().filter(|l| !l.trim().is_empty() && !l.starts_with("note:")).collect();
@@ -1923,15 +1959,55 @@ fn arm(store: &mut Store<StoreData>, limits: &Limits, timeout: Duration) -> wasm
data.deadline = now + timeout;
data.timeout = timeout;
data.last_report = None;
data.host_io_grace_used = false;
data.active_fuel = limits.fuel;
data.cancel.reset();
// The callback above runs every tick and decides.
store.set_epoch_deadline(1);
Ok(())
}
fn describe_trap(e: &wasmtime::Error, limits: &Limits) -> String {
fn bind_task_fuel_refill(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
use crate::wit_host::TaskRes;
use crate::wit_host::bindings::openidea::plugin::tasks::{Host, HostTask};
use wasmtime::StoreContextMut;
use wasmtime::component::Resource;
linker.allow_shadowing(true);
let tasks_iface = format!("openidea:plugin/tasks@{}", ide_extension::ENGINE_VERSION);
let mut inst = linker.instance(&tasks_iface)?;
inst.func_wrap("begin", |mut caller: StoreContextMut<'_, StoreData>, (title, cancellable): (String, bool)| {
let task = Host::begin(caller.data_mut(), title, cancellable)?;
let fuel = caller.data().active_fuel;
caller.set_fuel(fuel)?;
Ok((task,))
})?;
inst.func_wrap(
"[method]task.report",
|mut caller: StoreContextMut<'_, StoreData>,
(this, done, total, message): (Resource<TaskRes>, u32, Option<u32>, Option<String>)| {
HostTask::report(caller.data_mut(), this, done, total, message)?;
let fuel = caller.data().active_fuel;
caller.set_fuel(fuel)?;
Ok(())
},
)?;
inst.func_wrap(
"[method]task.is-cancelled",
|mut caller: StoreContextMut<'_, StoreData>, (this,): (Resource<TaskRes>,)| {
let cancelled = HostTask::is_cancelled(caller.data_mut(), this)?;
let fuel = caller.data().active_fuel;
caller.set_fuel(fuel)?;
Ok((cancelled,))
},
)?;
linker.allow_shadowing(false);
Ok(())
}
fn describe_trap(e: &wasmtime::Error, limits: &Limits, active_fuel: u64) -> String {
match e.downcast_ref::<wasmtime::Trap>() {
Some(wasmtime::Trap::OutOfFuel) => format!("exceeded its CPU budget ({} fuel per call)", limits.fuel),
Some(wasmtime::Trap::OutOfFuel) => format!("exceeded its CPU budget ({active_fuel} fuel per call)"),
Some(wasmtime::Trap::Interrupt) => CANCELLED.to_owned(),
Some(wasmtime::Trap::UnreachableCodeReached) => "crashed (panic / unreachable)".to_owned(),
Some(trap) => format!("trapped: {trap}"),
+29 -1
Переглянути файл
@@ -104,6 +104,7 @@ pub enum Prompt {
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum EventKind {
WebsocketReady,
FileClosed,
SettingsChanged,
InputModeChanged,
@@ -121,7 +122,8 @@ pub enum EventKind {
}
impl EventKind {
pub const ALL: [Self; 14] = [
pub const ALL: [Self; 15] = [
Self::WebsocketReady,
Self::FileClosed,
Self::SettingsChanged,
Self::InputModeChanged,
@@ -140,6 +142,7 @@ impl EventKind {
pub fn name(self) -> &'static str {
match self {
Self::WebsocketReady => "websocket-ready",
Self::FileClosed => "file-closed",
Self::SettingsChanged => "settings-changed",
Self::InputModeChanged => "input-mode-changed",
@@ -227,10 +230,34 @@ pub struct InputMode {
pub sub: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebsocketReady {
pub plugin_id: String,
pub connection: u64,
}
/// A host-owned background WebSocket request.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WebSocketRequest {
pub url: String,
pub headers: Vec<(String, String)>,
pub subprotocol: String,
}
/// Frames received from a host-owned WebSocket and its transport state.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebSocketBatch {
pub messages: Vec<Vec<u8>>,
pub closed: bool,
}
/// A workbench event delivered to plugins.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
pub enum WorkbenchEvent {
WebsocketReady(WebsocketReady),
RunExited(crate::RunExited),
TerminalExited(crate::TerminalExited),
ContentTabAction(crate::ContentTabAction),
@@ -251,6 +278,7 @@ pub enum WorkbenchEvent {
impl WorkbenchEvent {
pub fn kind(&self) -> EventKind {
match self {
Self::WebsocketReady(_) => EventKind::WebsocketReady,
Self::RunExited(_) => EventKind::RunExited,
Self::TerminalExited(_) => EventKind::TerminalExited,
Self::ContentTabAction(_) => EventKind::ContentTabAction,
+128 -8
Переглянути файл
@@ -26,6 +26,7 @@ pub(crate) mod bindings {
"openidea:plugin/tasks.task": super::TaskRes,
"openidea:plugin/process.child": super::ChildRes,
"openidea:plugin/ipc.connection": super::ConnectionRes,
"openidea:plugin/net.websocket": super::WebSocketRes,
"openidea:plugin/auth.loopback": super::LoopbackRes,
},
});
@@ -162,6 +163,19 @@ pub struct ChildRes {
host: std::sync::Arc<dyn crate::HostApi>,
}
/// A project-bound background WebSocket owned by this plugin instance.
/// Releasing the component resource closes the underlying transport.
pub struct WebSocketRes {
id: u64,
host: std::sync::Arc<dyn crate::HostApi>,
}
impl Drop for WebSocketRes {
fn drop(&mut self) {
self.host.websocket_close(self.id);
}
}
impl Drop for ChildRes {
fn drop(&mut self) {
self.host.release_process(self.id);
@@ -183,6 +197,8 @@ pub struct TaskRes {
const MAX_EDITS: usize = 10_000;
const MAX_DIAGNOSTICS: usize = 5_000;
const MAX_REQUEST_BODY: usize = 8 << 20;
/// Upper bound for a WebSocket handshake; the call's remaining time can make it shorter.
const WEBSOCKET_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_FIND: u32 = 10_000;
const MAX_STATUS_TEXT: usize = 128;
const MAX_PICK_ITEMS: usize = 5000;
@@ -857,6 +873,7 @@ impl editor::Host for StoreData {
fn core_range(r: wit::Range) -> ide_core::Range {
ide_core::Range::new(pos(r.start), pos(r.end))
}
impl StoreData {
/// A `project` resource's path: inside the resource's root, or — for a
/// project window group — inside any of its member roots (as the editor and
@@ -1267,6 +1284,11 @@ impl tasks::HostTask for StoreData {
}
}
/// Headroom reserved before `StoreData::deadline` so a timed-out host subprocess
/// or HTTP request can unwind as a recoverable `wit::Error` in guest code instead
/// of immediately tripping the Wasm epoch trap on re-entry.
const HOST_IO_UNWIND_GRACE: Duration = Duration::from_millis(250);
impl StoreData {
/// Lets the running call reach `from` plus the background budget (the
/// longer of it and what was left), so a task that keeps saying it is alive
@@ -1277,6 +1299,8 @@ impl StoreData {
self.deadline = extended;
self.timeout = self.limits.background;
}
let scale = (self.limits.background.as_millis() / self.limits.timeout.as_millis().max(1)).max(1) as u64;
self.active_fuel = self.limits.fuel.saturating_mul(scale);
}
/// What is left of the running call's wall-clock budget (host operations
@@ -1284,6 +1308,28 @@ impl StoreData {
fn remaining(&self) -> Duration {
self.deadline.saturating_duration_since(Instant::now())
}
/// Budget handed to a blocking host I/O call (`process.run`, `net.fetch`),
/// leaving room before `self.deadline` when possible so a timed-out I/O call
/// returns `Err` to the guest instead of trapping the Wasm instance.
fn io_timeout(&self) -> Duration {
let rem = self.remaining();
if !self.host_io_grace_used && rem > HOST_IO_UNWIND_GRACE * 2 { rem - HOST_IO_UNWIND_GRACE } else { rem }
}
/// Grants at most one short unwind window per call when a blocking host I/O
/// call failed at or near `self.deadline` without caller cancellation. An
/// error well before the deadline leaves the window for a later call.
fn grant_io_error_grace(&mut self) {
if self.host_io_grace_used || self.cancel.is_set() {
return;
}
let min_deadline = Instant::now() + HOST_IO_UNWIND_GRACE;
if min_deadline > self.deadline {
self.deadline = min_deadline;
self.host_io_grace_used = true;
}
}
}
impl diagnostics::Host for StoreData {
@@ -1371,13 +1417,15 @@ impl net::Host for StoreData {
url: req.url,
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
body: req.body,
timeout: wanted.min(self.remaining()),
timeout: wanted.min(self.io_timeout()),
grants,
};
let token = self.cancel.token();
Ok(self
.host
.fetch(request, &token)
let outcome = self.host.fetch(request, &token);
if outcome.is_err() {
self.grant_io_error_grace();
}
Ok(outcome
.map(|r| net::Response {
status: r.status,
headers: r.headers.into_iter().map(|(name, value)| net::Header { name, value }).collect(),
@@ -1385,6 +1433,70 @@ impl net::Host for StoreData {
})
.map_err(to_wit))
}
fn websocket_connect(
&mut self,
project: Resource<ProjectRes>,
req: net::WebsocketRequest,
) -> Fallible<Resource<WebSocketRes>> {
let capability = match net_capability(&req.url) {
Ok(capability) => capability,
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
};
if let Err(error) = self.require(capability) {
return Ok(Err(error));
}
if req.headers.iter().any(|header| header.value.contains("{{secret:"))
&& let Err(error) = self.require(Capability::Secrets)
{
return Ok(Err(error));
}
let request = crate::WebSocketRequest {
url: req.url,
headers: req.headers.into_iter().map(|header| (header.name, header.value)).collect(),
subprotocol: req.subprotocol,
};
let root = self.table.get(&project)?.root.clone();
let timeout = WEBSOCKET_HANDSHAKE_TIMEOUT.min(self.io_timeout());
match self.host.websocket_connect(&root, request, timeout) {
Ok(id) => self.push(WebSocketRes { id, host: self.host.clone() }).map(Ok),
Err(error) => {
self.grant_io_error_grace();
Ok(Err(to_wit(error)))
}
}
}
}
impl net::HostWebsocket for StoreData {
fn id(&mut self, this: Resource<WebSocketRes>) -> wasmtime::Result<u64> {
Ok(self.table.get(&this)?.id)
}
fn send(&mut self, this: Resource<WebSocketRes>, bytes: Vec<u8>) -> Fallible<()> {
let id = self.table.get(&this)?.id;
Ok(self.host.websocket_send(id, bytes).map_err(to_wit))
}
fn drain(&mut self, this: Resource<WebSocketRes>, limit: u32) -> Fallible<net::WebsocketBatch> {
let id = self.table.get(&this)?.id;
Ok(self
.host
.websocket_drain(id, limit as usize)
.map(|batch| net::WebsocketBatch { messages: batch.messages, closed: batch.closed })
.map_err(to_wit))
}
fn close(&mut self, this: Resource<WebSocketRes>) -> Fallible<()> {
let id = self.table.get(&this)?.id;
self.host.websocket_close(id);
Ok(Ok(()))
}
fn drop(&mut self, this: Resource<WebSocketRes>) -> wasmtime::Result<()> {
self.table.delete(this)?;
Ok(())
}
}
impl secrets::Host for StoreData {
@@ -1466,8 +1578,12 @@ impl process::Host for StoreData {
};
let cwd = self.cwd_of(cwd)?;
let token = self.cancel.token();
let timeout = self.remaining();
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
let timeout = self.io_timeout();
let outcome = self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout);
if outcome.is_err() {
self.grant_io_error_grace();
}
Ok(outcome.map(to_output).map_err(to_wit))
}
fn run_command(
@@ -1482,8 +1598,12 @@ impl process::Host for StoreData {
};
let cwd = self.cwd_of(cwd)?;
let token = self.cancel.token();
let timeout = self.remaining();
Ok(self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout).map(to_output).map_err(to_wit))
let timeout = self.io_timeout();
let outcome = self.host.run_process(cmd, cwd.as_deref(), stdin, &token, timeout);
if outcome.is_err() {
self.grant_io_error_grace();
}
Ok(outcome.map(to_output).map_err(to_wit))
}
fn start(&mut self, cmd: process::Command, cwd: Option<Resource<ProjectRes>>) -> Fallible<Resource<ChildRes>> {
+5 -5
Переглянути файл
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.5.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.0.0-rc.1"] {
let host = v("1.6.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.6.0"), &host).unwrap_err();
assert!(newer.contains("1.6") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.7.0"), &host).unwrap_err();
assert!(newer.contains("1.7") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.5 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.6 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
eb7d608bf1e7379620ef7d9ce5289dd9b927f137af2aafdd0861a84d60d677a2
ae3534411d52f991abd831863fe28a6314f8a31ade213d862f0db1c3048e8416
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
195cb4025508e7af6e6fb63d81660c4a977327fa3d101a2a77e5f448f43518f8
0d83cfd047a837cd4b1f89f6907e90f8722ba262f6f9e1013d631868d025c474
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
49491ad2d57592cca2451b0abde5f88a3aedd3f5d36ceaf8259c3254f59c5023
644a189a18937a74e14b9ff23c8317f91fd04c994817768aea29654f1fedb144
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b24383f6e05ec531ef8db368b79af710433bc3782438f5a9e3578ebb730a1ce2
30e4773ec15badf340a76d850e3bb74a4e17b1b366afada67061113fc55d996d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ef01f199e668063b2c444e4ccf169d5b5407e567dbe8ff583cfdfc111b2438cf
6d71f38536e5e51c6ac8f34e7dbf1c5b54e41a89d2289e7b8bf2c43747e01829
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a4dbe2a261e00809b64039ce9c89db681b186788db8a92b7456eca84b33c5945
014c682c68f9dab26218e380636df37c88f4d6cdeec6197db88289e1966d096f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
3c9db6ffeaa9b782970c7a7a132878f862b0b5b18e9d11ad56649ee28317e3ca
1d20aaff7593ae15afee027e76290070ef4cbe67ae1a31b700f24c1685c68b85
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ac1dceb016b7963f44e876784ea62f776e90dd63fc6f660a5dad3dfbd7433b1c
07300bc9c2cf131b454ac485ee1026c7650a16351059757dbc2051b49c5da983
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
11dc1a74c0650b493ed6ec88b5d15e08421ab08c5f2320099dabd5df1bdc5096
c4767d713c183342a5c5d8fb54b76ec59baf548bb8d727339c355a7636b02c83
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
94c391142af832a67c694968d307217aabd7c139ae29e3299930b56ce644e45b
3c8f89cf605514fe9e2a619d9cc0e2467a5d5c3f35ba629159b2fc3032ff8cc5
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e2c1893a160a0f27af7eb9e689d703577335cce32f746713c2435ae70fb9753d
c08e5c750d47cf053e306eeb64beebf96028d90a01b450b4c7bf1c7166696068
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
83a8f59e0bde75c5c8fc725aa0fe34954279b31b7237acc868470ccf545ca229
30964cb3a8f0782213645b7f09cb7aa7af9567a47269e3e99fb4f2406640c2b3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f9741cc8f7250a71311264613d636c5c844f1ae294362d4d331015ca73acbc57
8c78e741376751b85da8f73f245e4f8d9a2062bc6efbfd040f1555804ad143cf
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
a6f65bb0d13527d726cef88fa50a5db19d48517b1ae516db36e9756dc87e75bc
abdbce4ef7bbfc9a574ca8c4bba8e6b66473bfe76c6edd553fe28161a08d082a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c9dbae18196b98eb4fad86fd5f36033c2b82a3087b4f51c4cba5a1362924d795
a8845772589a3aa97c60bf1add85ec593d4787fc1e613d24de1e9d521a69126f
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
1086cdc27def1958e8ec818e197af67edc2fd252b198e0ac358b149e0f5abfe8
064ddd450934c84479a1039638780b589f18c837d7b68bd1f762114acb41bf12
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
48fe38b954f91bf8adc01a4b7b41802040f663c16fde586bd5daa51be98ba51d
03000655273ff6b9e55b5f01b7a2152740864125ddcd5c721605601af4396623
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
9ff2eaaa054ecdab56bf01220c8529da2b8d074b923cfb8b332d347767ba04f4
fd9d6ca9e176fa7f1255465348b8ce1b917f6c65a8e1751c15c9e453ce65fae3
+3 -3
Переглянути файл
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.5.0;
export openidea:plugin/providers@1.5.0;
export openidea:plugin/file-actions@1.5.0;
include openidea:plugin/plugin-syntax@1.6.0;
export openidea:plugin/providers@1.6.0;
export openidea:plugin/file-actions@1.6.0;
}
",
path: "../../../../../wit",
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
d72afe7db6283cdaecd7c673d1d26ff5af39f105733109ec73901ca37b00ea28
0db73360824abf2094a796b4446476cc002270e86a1ba4dfa4a2b7f684464644
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
bccf0174932e661c40f05d779bf094e7aa4c91c359399688e68dd7a0a1d389b6
2c59f700724248d2140d250a9282d6f8382fe4cf3fa1099b510f57cb2a980bc9
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
77909d57469273d0abd56d382e408ebb9a6c5dd4463b0c2cbca8f27a19bf116c
0eb81f54ae9a2984bafe167742429e21c4a5a9c79afd6e552d1bd90bf24c509a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
79beb3b8fdb9f412132dda4a76b88f55cfa859e3462c8cb020a0e431ad23fd72
77ffae79e5c7f1b8184c6f157b6e91bbcb51ea1c318aadaa7e821352be35fa4d
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f2430f9afa0c8af35d96832a0cf7b1eade8e5977fb46eed93123b65f950f4f15
52f53bac5598afcb1006ff7bca003bbc10d0918084bd4b73bab2cec98ad096cb
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
65753d7c4938560bf2cbc2d1eb2d5588f455ae40f265369aa1126c4f1fd0e5ce
2d5ccc3a6d88a0480c30c2c52e1cc2ca0dc9f9134b0e2a13f2e8a4e556fc4bbb
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
74c8049646495d754a09fea877adb9085f8f8c27df5871cfa45cb05bdde0b91f
43f1aa05f45566bd87f4ac07d2d9858650a00a0cf72e15dabf2b5cc4b9e0d1c5
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ec33ec151022221ea985d6f3af9a583ddc0ecd6c81adb34c946f90a3cf3b4fa4
396ec51c975f05aee6aa570045e4fb702fc89e812ea6eab9da0f488587dc3a1a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
461d6c405c6feb77715accc1df794afab90715b02d7ac161480287527afe35ea
f6ab309d54409545b448a65e833f5251080fd078bf7c9e7eb94b2c13385d67e5
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
815a63d8c8f81b975fe68511a15dc0b28c6acf524768858e16eb548e68b56e9a
d3468d21dbd8787e07f566a1bb7d96c939a4cd82ee2178deec4927abee12d57f
+2 -1
Переглянути файл
@@ -333,7 +333,8 @@ async fn download_into_copies_only_the_selected_items() {
std::fs::write(server.join("index.php"), "php").unwrap();
std::fs::write(project.join("index.php"), "old").unwrap();
let service = service(tmp.path());
let remotes = [server.join("drush"), server.join("index.php")].map(|p| p.to_str().unwrap().to_owned());
// Remote paths use `/`, as the local file system lists them on Windows too.
let remotes = [server.join("drush"), server.join("index.php")].map(|p| p.to_str().unwrap().replace('\\', "/"));
let report = service
.download_into(&FsConnection::Local, &remotes, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
+97 -1
Переглянути файл
@@ -10,7 +10,8 @@ pub struct RecentProject {
pub path: ide_core::IpcPath,
pub name: String,
pub opened_at_ms: f64,
/// Current git branch when last opened, shown on the welcome screen.
/// Git branch shown on the welcome screen. Persisted as of last open; [`RecentProjects::list_live`]
/// refreshes it from `.git/HEAD` when the list is shown.
pub branch: Option<String>,
}
@@ -94,6 +95,55 @@ fn now_ms() -> f64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
/// The git directory for `project`: `.git/` or the path in a worktree's `.git` file (`gitdir: …`).
fn git_dir(project: &Path) -> Option<PathBuf> {
let dot = project.join(".git");
let meta = std::fs::symlink_metadata(&dot).ok()?;
if meta.file_type().is_dir() {
return Some(dot);
}
if !meta.file_type().is_file() {
return None;
}
let text = std::fs::read_to_string(&dot).ok()?;
let raw = text.lines().find_map(|line| line.strip_prefix("gitdir:"))?.trim();
if raw.is_empty() {
return None;
}
let path = PathBuf::from(raw);
Some(if path.is_absolute() { path } else { project.join(path) })
}
/// Branch name, or a short commit when detached. `Err` when HEAD cannot be read (keep the stored value).
fn read_head_branch(project: &Path) -> Result<Option<String>, ()> {
let head = std::fs::read_to_string(git_dir(project).ok_or(())?.join("HEAD")).map_err(|_| ())?;
let head = head.trim();
if head.is_empty() {
return Ok(None);
}
if let Some(reference) = head.strip_prefix("ref:") {
let reference = reference.trim();
if let Some(branch) = reference.strip_prefix("refs/heads/") {
return Ok(if branch.is_empty() { None } else { Some(branch.to_owned()) });
}
// Other symbolic refs (rare for HEAD) are not a branch name.
return Ok(None);
}
// Detached HEAD: object id — show a short commit, never the previous branch.
let short = head.get(..7).unwrap_or(head);
Ok(if short.is_empty() { None } else { Some(short.to_owned()) })
}
/// Overlay each entry's `branch` from `.git/HEAD` when readable; otherwise keep the stored value.
pub fn with_live_branches(mut items: Vec<RecentProject>) -> Vec<RecentProject> {
for item in &mut items {
if let Ok(branch) = read_head_branch(item.path.as_ref()) {
item.branch = branch;
}
}
items
}
impl RecentProjects {
pub fn load(config_dir: &Path) -> Self {
let file = config_dir.join("recent.json");
@@ -115,6 +165,11 @@ impl RecentProjects {
self.items.lock().clone()
}
/// Like [`list`], but each `branch` is refreshed from that project's `.git/HEAD` when readable.
pub fn list_live(&self) -> Vec<RecentProject> {
with_live_branches(self.list())
}
pub fn groups(&self) -> Vec<RecentProjectGroup> {
self.groups.lock().clone()
}
@@ -261,4 +316,45 @@ mod tests {
assert_eq!(recent.list()[0].name, "app");
assert_eq!(recent.list().len(), 1);
}
fn write_head(git_dir: &Path, contents: &str) {
std::fs::create_dir_all(git_dir).unwrap();
std::fs::write(git_dir.join("HEAD"), contents).unwrap();
}
#[test]
fn list_live_reads_branch_from_head_and_keeps_stored_when_unreadable() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
write_head(&repo.join(".git"), "ref: refs/heads/feature/list-live\n");
let plain = tmp.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
let recent = RecentProjects::load(tmp.path().join("config").as_path());
recent.touch(&repo, Some("stale".into())).unwrap();
recent.touch(&plain, Some("kept".into())).unwrap();
let live = recent.list_live();
let by_path = |p: &Path| live.iter().find(|i| i.path.as_ref() == p).unwrap();
assert_eq!(by_path(&repo).branch.as_deref(), Some("feature/list-live"));
assert_eq!(by_path(&plain).branch.as_deref(), Some("kept"));
assert_eq!(recent.list().iter().find(|i| i.path.as_ref() == repo).unwrap().branch.as_deref(), Some("stale"));
}
#[test]
fn list_live_follows_worktree_gitdir_and_shows_short_detached_head() {
let tmp = tempfile::tempdir().unwrap();
let main = tmp.path().join("main");
let wt = tmp.path().join("wt");
std::fs::create_dir_all(&wt).unwrap();
let wt_git = main.join(".git/worktrees/wt");
write_head(&wt_git, "abcdefghijklmnopqrstuvwxyz0123456789abcd\n");
std::fs::write(wt.join(".git"), "gitdir: ../main/.git/worktrees/wt\n").unwrap();
assert_eq!(read_head_branch(&wt).unwrap().as_deref(), Some("abcdefg"));
let recent = RecentProjects::load(tmp.path().join("config").as_path());
recent.touch(&wt, Some("old-branch".into())).unwrap();
assert_eq!(recent.list_live()[0].branch.as_deref(), Some("abcdefg"));
}
}
+1 -1
Переглянути файл
@@ -19,7 +19,7 @@ The plugin host runs **where the project is**: in the desktop app for local proj
## The interface
The API is defined in WIT, in `wit/` (package `openidea:plugin@1.5.0`). A plugin is a component targeting the `plugin` world (or `plugin-syntax`). The guest SDK `sdk/rust/openidea-plugin` wraps it in a typed Rust API and carries a copy of `wit/`; `crates/ide-plugin/tests/api_compat.rs` checks the copies are identical.
The API is defined in WIT, in `wit/` (package `openidea:plugin@1.6.0`). A plugin is a component targeting the `plugin` world (or `plugin-syntax`). The guest SDK `sdk/rust/openidea-plugin` wraps it in a typed Rust API and carries a copy of `wit/`; `crates/ide-plugin/tests/api_compat.rs` checks the copies are identical.
Released API versions are frozen. wasmtime resolves interface names by semver, but function types must match exactly, so a released 1.x only grows: new interfaces and new optional exports, never changed records or variants. See _Evolving the plugin API_ in `plugins/README.md` before touching `wit/`.
+11 -10
Переглянути файл
@@ -61,16 +61,17 @@ When you edit a POM in the editor, the tool window shows **Build files changed s
**Settings › Build, Execution, Deployment › Maven**:
| Setting | What it does |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **JRE** | JDK for Maven sync, builds, goals and tests |
| **Maven home path** | A Maven installation to use instead of the project's `mvnw` |
| **User settings file** | Passed as `-s` |
| **Toolchains file** | Passed as `-t` |
| **Local repository** | Passed as `-Dmaven.repo.local` |
| **Active profiles** | Comma-separated profiles (`-P`); `!id` excludes one |
| **Thread count** | Passed as `-T` (for example `4` or `2C`) |
| **Use Maven Daemon (mvnd)** | Runs builds, sync and run configurations through `mvnd`; falls back to `mvn` if it is missing or the JDK is older than 17 |
| Setting | What it does |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **Delegate IDE build/run actions to Maven** | Run and Debug build Maven modules with Maven (`javac`) instead of jdtls |
| **JRE** | JDK for Maven sync, builds, goals and tests |
| **Maven home path** | A Maven installation to use instead of the project's `mvnw` |
| **User settings file** | Passed as `-s` |
| **Toolchains file** | Passed as `-t` |
| **Local repository** | Passed as `-Dmaven.repo.local` |
| **Active profiles** | Comma-separated profiles (`-P`); `!id` excludes one |
| **Thread count** | Passed as `-T` (for example `4` or `2C`) |
| **Use Maven Daemon (mvnd)** | Runs builds, sync and run configurations through `mvnd`; falls back to `mvn` if it is missing or the JDK is older than 17 |
## Gradle
+1
Переглянути файл
@@ -66,6 +66,7 @@ The data source dialog has a name, an optional **Color:** (tints the tree and co
- Project data sources: `.openidea/data-sources.toml` in the project.
- Global data sources: `data-sources.toml` in the IDE's data directory (see [Data directories](/en/reference/data-directories)).
- You can edit these files by hand or have a coding agent edit them while the project is open: the Database tool window picks up the change, and connections of data sources whose settings changed or that were removed are closed.
- Passwords are never written to these files. Saved passwords go to the OS credential store (Keychain, Windows Credential Manager or Secret Service). Unsaved ones are kept in memory for the session; OpenIDEA asks for them when it connects.
## Browse the database
+8
Переглянути файл
@@ -54,6 +54,14 @@ Use **Add Code Cell** and **Add Markdown Cell** at the end of the notebook to ap
Code cells show the execution count (`[3]`) next to the cell toolbar. Raw cells and cell types OpenIDEA doesn't know are shown read-only; their content is kept as it is.
## Code intelligence
Code cells get completion, quick documentation on hover, parameter info and error highlighting from the project's Python language server, as in a `.py` file. The server reads the code cells in order, so names defined in one cell are known in the cells below it, and IPython syntax such as `%matplotlib inline` or `!pip list` isn't reported as an error.
`⌘`-click (macOS) or `Ctrl`-click (Windows/Linux) a name to go to its declaration, in another cell or in a library.
This needs a language server that supports notebooks: **basedpyright** (the default) and **Ruff** do. With other Python servers, code cells only get syntax highlighting.
## Keyboard
| Action | Keys |
+2
Переглянути файл
@@ -144,6 +144,8 @@ Add tasks under **Modify options › Add before launch task**. They run in order
For Java, Maven and Gradle configurations, the **Build** option picks how the project is built first: **Automatic (incremental)**, **Build (Make)**, **Rebuild**, **No build** or **Build, no error check**.
Maven modules are built by jdtls (ECJ) for **Automatic** and **Build (Make)**, and by Maven for **Rebuild**. Turn on **Settings › Build, Execution, Deployment › Maven › Delegate IDE build/run actions to Maven** to build them with Maven (the project's `javac`) every time; editor **Problems** still come from jdtls.
### Compound configurations
A **Compound** configuration starts several configurations at once. Run it to start all of them, or Debug it to debug each of them. Stop from the run widget stops them all.
+2 -1
Переглянути файл
@@ -86,7 +86,7 @@ When the project uses Lombok (in its build file, an inherited parent POM or Grad
jdtls imports Maven and Gradle projects (including the Gradle wrapper) automatically. A folder without a build file is opened as a plain Java project, and jars under `lib/` are added to its classpath. OpenIDEA also shows **Maven** and **Gradle** tool windows for running goals and tasks, reloading, profiles and dependency analysis. Their settings are in **Settings › Build, Execution, Deployment › Maven** and **Gradle**:
- Maven: **JRE**, **Maven home path** (default: the project's `mvnw`, else `mvn` on `PATH`), **User settings file**, **Toolchains file**, **Local repository**, **Active profiles**, **Thread count** and **Use Maven Daemon (mvnd)**. The profiles you choose in the Maven tool window are passed to jdtls too, so the editor sees the same profile-specific dependencies.
- Maven: **Delegate IDE build/run actions to Maven** (Run and Debug build with Maven's `javac` instead of jdtls), **JRE**, **Maven home path** (default: the project's `mvnw`, else `mvn` on `PATH`), **User settings file**, **Toolchains file**, **Local repository**, **Active profiles**, **Thread count** and **Use Maven Daemon (mvnd)**. The profiles you choose in the Maven tool window are passed to jdtls too, so the editor sees the same profile-specific dependencies.
- Gradle: **Build and run using** (Gradle or OpenIDEA), **Gradle JVM**, **Distribution** (wrapper or local installation), **Gradle home** and **Gradle user home**.
The IDE passes its proxy settings to jdtls, so dependency resolution works behind a proxy. See [Build tools](/en/guide/build-tools) for the tool windows.
@@ -141,5 +141,6 @@ Navigation between MyBatis mapper interfaces and their XML statements is availab
- One jdtls runs per project window. With several large projects open at once, memory adds up. Lower `-Xmx` in **JVM options** or close windows you are not using. **Share JDT LS across builds** helps only within one window.
- Without jdtls (for example with `kmp-lsp` only), Application configurations, classpath-based features and Java debugging are not available.
- Java tests run through Maven or Gradle. A project without either build tool has no test runner.
- The jdtls compiler (ECJ) is stricter than javac in some places. Known case: a generic method call used directly as an `if`, `while`, `for` or `do` condition or a ternary condition — for example `if (getCacheObject(key))` with a `<T> T getCacheObject(String key)` method — compiles with javac but ECJ reports `Type mismatch: cannot convert from Object to boolean`. Assigning the call to a `boolean` variable first compiles on both (see [#2624](https://github.com/l0ng-ai/openidea/issues/2624)).
- The Kotlin bridge is Maven only and exposes compiled Kotlin classes to Java. It does not give Java-to-Kotlin source navigation or refactoring.
- Groovy needs a `groovy-language-server` you build yourself, and JSP has syntax highlighting only.
+1 -1
Переглянути файл
@@ -3,7 +3,7 @@ title: "API reference"
description: "Every interface of the openidea:plugin WIT package, with a one-line summary and a link to its source."
---
The plugin API is the WIT package `openidea:plugin@1.5.0` in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit). The WIT files are the reference: each interface, type and function is documented in comments there. 1.5.0 is the version on `main`; the latest released version is 1.4.0, whose WIT is kept in [`sdk/rust/openidea-plugin/tests/api/released/`](https://github.com/l0ng-ai/openidea/tree/main/sdk/rust/openidea-plugin/tests/api/released).
The plugin API is the WIT package `openidea:plugin@1.6.0` in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit). The WIT files are the reference: each interface, type and function is documented in comments there. 1.6.0 is the version on `main`; the latest released version is 1.4.0, whose WIT is kept in [`sdk/rust/openidea-plugin/tests/api/released/`](https://github.com/l0ng-ai/openidea/tree/main/sdk/rust/openidea-plugin/tests/api/released).
<Info>
A generated API reference is planned. Until then, use the WIT files below, `cargo doc` for the Rust SDK
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff. Show more