mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 02:31:38 -10:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81b7c1f36 | ||
|
|
6d37f0876c | ||
|
|
06a7aedb66 | ||
|
|
b746a0a3a9 | ||
|
|
2bf412ff0d | ||
|
|
ec171c3188 | ||
|
|
32a778d28d | ||
|
|
9673ccc687 | ||
|
|
fba893b36d | ||
|
|
905a672bad | ||
|
|
99102d8a40 | ||
|
|
a2599a6f79 | ||
|
|
bd1a3ce1e3 | ||
|
|
2329f1e955 | ||
|
|
63065bab64 | ||
|
|
7ecb452350 | ||
|
|
826e5837b1 | ||
|
|
04b1b863df | ||
|
|
a5db5cdda8 | ||
|
|
0630ed0729 | ||
|
|
b2d30e0c95 | ||
|
|
ff7473a277 | ||
|
|
45a1132ece | ||
|
|
704001fd8a |
No files matched your search
Generated
+2
@@ -4783,12 +4783,14 @@ dependencies = [
|
||||
"ignore",
|
||||
"notify",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"trash",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
||||
@@ -1756,12 +1756,7 @@ impl Task {
|
||||
fn ask_permission(self: &Arc<Self>, params: &Value) -> Value {
|
||||
let id = self.next_permission.fetch_add(1, Ordering::Relaxed);
|
||||
let (questions, by_index) = asked_questions(params);
|
||||
let title = params
|
||||
.pointer("/toolCall/title")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|t| !t.is_empty())
|
||||
.unwrap_or("The agent asks for permission")
|
||||
.to_owned();
|
||||
let title = permission_title(params);
|
||||
// An agent that answers by index titles its request "Ask user N question(s)"; its
|
||||
// first question says what it wants, as the Claude Code adapter titles its own.
|
||||
let title =
|
||||
@@ -1935,6 +1930,65 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
|
||||
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
|
||||
}
|
||||
|
||||
fn permission_title(params: &Value) -> String {
|
||||
let call = ¶ms["toolCall"];
|
||||
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
|
||||
if call.get("kind").and_then(Value::as_str) == Some("execute") {
|
||||
if let Some(command) = call.pointer("/rawInput/command").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
// Network approvals describe the host rather than approving command execution.
|
||||
if !title.is_some_and(|t| t.starts_with("Allow network access to ")) {
|
||||
return format!("Run {}", shell_script(command).unwrap_or_else(|| command.to_owned()));
|
||||
}
|
||||
}
|
||||
if title.is_none()
|
||||
&& let Some(content) = call.get("content").and_then(Value::as_array)
|
||||
&& let Some(text) = content.iter().find_map(|c| c.pointer("/content/text").and_then(Value::as_str))
|
||||
{
|
||||
return format!("Run {text}");
|
||||
}
|
||||
}
|
||||
title.unwrap_or("The agent asks for permission").to_owned()
|
||||
}
|
||||
|
||||
/// The script of a `<shell> -c <quoted script>` command line, when the whole line is exactly that.
|
||||
pub(crate) fn shell_script(command: &str) -> Option<String> {
|
||||
let (shell, rest) = command.split_once(' ')?;
|
||||
if !matches!(shell.rsplit('/').next(), Some("sh" | "bash" | "zsh")) {
|
||||
return None;
|
||||
}
|
||||
let quoted = rest.strip_prefix("-lc ").or_else(|| rest.strip_prefix("-c "))?;
|
||||
let mut out = String::new();
|
||||
let mut chars = quoted.chars();
|
||||
// Adjacent quoted words, e.g. 'a'\''b' or "a \"b\"".
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\'' => loop {
|
||||
match chars.next()? {
|
||||
'\'' => break,
|
||||
c => out.push(c),
|
||||
}
|
||||
},
|
||||
'"' => loop {
|
||||
match chars.next()? {
|
||||
'"' => break,
|
||||
'\\' => match chars.next()? {
|
||||
c @ ('"' | '\\' | '$' | '`') => out.push(c),
|
||||
'\n' => {}
|
||||
c => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
},
|
||||
'\\' => out.push(chars.next()?),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The questions a permission request asks, and whether they answer by question index.
|
||||
/// The Claude Code adapter carries them as `_meta.questions`, answered as the option id;
|
||||
/// Qwen Code (ACP) as `_meta.qwenQuestions`, answered with `answers` keyed by index beside
|
||||
|
||||
@@ -822,7 +822,7 @@ impl CodexShim {
|
||||
match method {
|
||||
"item/commandExecution/requestApproval" => {
|
||||
let choices = translate::decisions(params, true);
|
||||
let title = translate::command_approval_title(params, item.as_ref(), cwd);
|
||||
let title = translate::command_approval_title(params, item.as_ref());
|
||||
let call = tool_call_of(
|
||||
&item_id,
|
||||
&title,
|
||||
|
||||
@@ -247,13 +247,39 @@ fn approvals_offer_what_codex_offers_and_always_a_decline() {
|
||||
assert_eq!(
|
||||
command_approval_title(
|
||||
&json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}),
|
||||
None,
|
||||
None
|
||||
),
|
||||
"Run touch b"
|
||||
"Run ls"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_title_shows_the_script_inside_a_shell_wrapper() {
|
||||
let title = |command: &str| command_approval_title(&json!({ "command": command }), None);
|
||||
assert_eq!(
|
||||
title(r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#),
|
||||
"Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'"
|
||||
);
|
||||
assert_eq!(title(r#"bash -lc 'echo '\''hi'\'''"#), "Run echo 'hi'");
|
||||
assert_eq!(title("/bin/zsh -lc ls"), "Run /bin/zsh -lc ls");
|
||||
assert_eq!(title("/bin/zsh -lc 'ls' && rm x"), "Run /bin/zsh -lc 'ls' && rm x");
|
||||
assert_eq!(title("python3 -c 'print(1)'"), "Run python3 -c 'print(1)'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_command_uses_its_request_and_matching_item_only() {
|
||||
let command = "python3 -c 'import pathlib\np = pathlib.Path(\"note.txt\")\np.write_text(\"hi\\n\")'";
|
||||
let pending_read = json!({"id": "read", "type": "commandExecution", "command": "ls",
|
||||
"commandActions": [{"type": "listFiles", "path": "."}]});
|
||||
let request = json!({"itemId": "write", "command": command});
|
||||
assert_eq!(command_approval_title(&request, Some(&pending_read)), format!("Run {command}"));
|
||||
let item = json!({"id": "write", "command": command});
|
||||
let request = json!({"itemId": "write"});
|
||||
assert_eq!(command_approval_title(&request, Some(&item)), format!("Run {command}"));
|
||||
assert_eq!(command_approval_title(&request, Some(&pending_read)), "Run a command");
|
||||
assert_eq!(command_approval_title(&request, None), "Run a command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_replays_turns() {
|
||||
let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [
|
||||
|
||||
@@ -733,18 +733,19 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
|
||||
}
|
||||
|
||||
/// The ACP title of a command approval.
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>, cwd: Option<&str>) -> String {
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
|
||||
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
|
||||
return format!("Allow network access to {host}");
|
||||
}
|
||||
let from_item = item.and_then(|i| tool_call(i, false, cwd, None)).and_then(|c| str_of(&c, "title"));
|
||||
let title = from_item.or_else(|| {
|
||||
let synthetic = json!({ "id": "", "type": "commandExecution", "command": params.get("command"),
|
||||
"commandActions": params.get("commandActions").cloned().unwrap_or(json!([])) });
|
||||
tool_call(&synthetic, false, cwd, None).and_then(|c| str_of(&c, "title"))
|
||||
});
|
||||
match title.filter(|t| !t.is_empty()) {
|
||||
Some(t) => format!("Run {t}"),
|
||||
// Approval payloads can describe a different command from the item's earlier summary.
|
||||
let item = item.filter(|i| i.get("id") == params.get("itemId"));
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
|
||||
match command {
|
||||
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
|
||||
None => "Run a command".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,28 @@
|
||||
use super::*;
|
||||
use tokio::io::duplex;
|
||||
|
||||
#[test]
|
||||
fn permission_uses_its_own_command_payload() {
|
||||
let command = "python3 -c 'import pathlib\npathlib.Path(\"a.txt\").touch()'";
|
||||
let mut request = json!({"toolCall": {"toolCallId": "write", "kind": "execute",
|
||||
"title": "Run List .", "rawInput": {"command": command}}});
|
||||
assert_eq!(permission_title(&request), format!("Run {command}"));
|
||||
request["toolCall"]["title"] = json!("Allow network access to example.com");
|
||||
assert_eq!(permission_title(&request), "Allow network access to example.com");
|
||||
request["toolCall"].as_object_mut().unwrap().remove("title");
|
||||
request["toolCall"].as_object_mut().unwrap().remove("rawInput");
|
||||
request["toolCall"]["content"] = json!([{ "type": "content", "content": {"type": "text", "text": command}}]);
|
||||
assert_eq!(permission_title(&request), format!("Run {command}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_shows_the_script_inside_a_shell_wrapper() {
|
||||
let wrapped = r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#;
|
||||
let request = json!({"toolCall": {"toolCallId": "write", "kind": "execute",
|
||||
"title": "Run python3", "rawInput": {"command": wrapped}}});
|
||||
assert_eq!(permission_title(&request), "Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'");
|
||||
}
|
||||
|
||||
/// Says "Hi", edits a file, asks to run a command and reports what it was allowed.
|
||||
#[derive(Default)]
|
||||
struct FakeAgent {
|
||||
|
||||
@@ -166,6 +166,16 @@ impl Default for CompletionSettings {
|
||||
pub struct AutoImportSettings {
|
||||
pub add_unambiguous: bool,
|
||||
pub optimize_on_the_fly: bool,
|
||||
pub imports_on_paste: ImportsOnPaste,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ImportsOnPaste {
|
||||
#[default]
|
||||
Ask,
|
||||
Always,
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Editor › General › Code Folding.
|
||||
@@ -251,6 +261,7 @@ pub struct SmartKeysSettings {
|
||||
pub tab_out_of_pair: bool,
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub escape_on_paste: bool,
|
||||
pub smart_indent: bool,
|
||||
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
|
||||
pub insert_doc_stub: bool,
|
||||
@@ -268,6 +279,7 @@ impl Default for SmartKeysSettings {
|
||||
surround_selection: true,
|
||||
tab_out_of_pair: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
escape_on_paste: true,
|
||||
smart_indent: true,
|
||||
insert_doc_stub: true,
|
||||
backspace_unindent: "autoindent".into(),
|
||||
@@ -464,3 +476,24 @@ impl Default for EncodingSettings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod auto_import_tests {
|
||||
use super::{AutoImportSettings, ImportsOnPaste};
|
||||
|
||||
#[test]
|
||||
fn imports_on_paste_defaults_to_ask_for_existing_settings() {
|
||||
let settings: AutoImportSettings = serde_json::from_str(r#"{"addUnambiguous":true}"#).unwrap();
|
||||
assert!(settings.add_unambiguous);
|
||||
assert_eq!(settings.imports_on_paste, ImportsOnPaste::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_on_paste_modes_round_trip() {
|
||||
for mode in ["ask", "always", "never"] {
|
||||
let json = serde_json::json!({ "importsOnPaste": mode });
|
||||
let settings: AutoImportSettings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(serde_json::to_value(settings).unwrap()["importsOnPaste"], mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ pub enum Capability {
|
||||
Storage,
|
||||
/// The plugin's own secrets (kept in the client's OS keychain).
|
||||
Secrets,
|
||||
/// Workbench events (file opened, saved, focus…), not keystrokes.
|
||||
/// Workbench events (files, focus, VCS), not keystrokes.
|
||||
Events,
|
||||
/// Keystrokes and editor input events.
|
||||
InputEvents,
|
||||
@@ -86,7 +86,7 @@ impl Capability {
|
||||
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
|
||||
Self::Storage => "Store its own data".into(),
|
||||
Self::Secrets => "Store secrets in the system keychain".into(),
|
||||
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
|
||||
Self::Events => "Observe editor, filesystem and version control events".into(),
|
||||
Self::InputEvents => "Observe keystrokes and editor input".into(),
|
||||
Self::Clipboard => {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
|
||||
@@ -144,7 +144,8 @@ impl ActivationEvent {
|
||||
|
||||
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
|
||||
/// `scheduled` comes from `on-schedule` instead.
|
||||
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
|
||||
pub const EVENT_KINDS: &[&str] =
|
||||
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -331,6 +332,9 @@ impl Manifest {
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for event in &self.activation {
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
|
||||
self.needs_api("`on-event:files-changed`", 3)?;
|
||||
}
|
||||
if !seen.insert(event) {
|
||||
return Err(format!("duplicate activation event {event}"));
|
||||
}
|
||||
@@ -495,6 +499,10 @@ mod tests {
|
||||
fn event_activation() {
|
||||
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
|
||||
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
|
||||
assert_eq!(
|
||||
ActivationEvent::parse("on-event:files-changed"),
|
||||
Ok(ActivationEvent::Event("files-changed".into()))
|
||||
);
|
||||
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");
|
||||
@@ -588,6 +596,19 @@ mod tests {
|
||||
|
||||
const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n";
|
||||
|
||||
#[test]
|
||||
fn files_changed_activation_needs_api_1_3() {
|
||||
let activation = "wasm = \"plugin.wasm\"\nactivation = [\"on-event:files-changed\"]\n";
|
||||
for engine in ["^1.0", "^1.2", ">=1.1, <2"] {
|
||||
let base = BASE.replace("^1.2", engine);
|
||||
assert!(err(&format!("{base}{activation}")).contains("new in plugin API 1.3"), "{engine}");
|
||||
}
|
||||
for engine in ["^1.3", ">=1.3, <2"] {
|
||||
Manifest::parse(&format!("{}{activation}", BASE.replace("^1.2", engine))).unwrap();
|
||||
}
|
||||
Manifest::parse(&format!("{BASE}{}", activation.replace("files-changed", "file-saved"))).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_capabilities_need_api_1_3() {
|
||||
for cap in ["project.write", "fs.write:~/tool"] {
|
||||
|
||||
@@ -49,7 +49,7 @@ ide-tools = { path = "../ide-tools" }
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
|
||||
@@ -15,8 +15,22 @@ fn main() {
|
||||
println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION);
|
||||
return;
|
||||
}
|
||||
if args.first().is_some_and(|a| a == "--connect") {
|
||||
// One forwarded connection (see `ide_host::relay`): stdout carries its bytes.
|
||||
let code = match ide_host::relay::parse_args(&args[1..]).and_then(|(host, port)| {
|
||||
ide_host::relay::relay(&host, port, std::io::stdin(), std::io::stdout().lock())
|
||||
.map_err(|e| format!("cannot connect to {host}:{port}: {e}"))
|
||||
}) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
if !args.iter().any(|a| a == "--stdio") {
|
||||
eprintln!("usage: openidea-server --stdio | --version");
|
||||
eprintln!("usage: openidea-server --stdio | --connect <host> <port> | --version");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// stdout carries the protocol: logs go to stderr.
|
||||
|
||||
@@ -14,6 +14,7 @@ mod phpstan;
|
||||
pub mod plugins;
|
||||
mod project;
|
||||
mod proxy;
|
||||
pub mod relay;
|
||||
mod requests;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
@@ -223,6 +224,7 @@ impl Host {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
|
||||
project.plugin_diagnostics.close();
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
plugins.invalidate_client_navigation(&project.root, Some(window), None);
|
||||
@@ -373,6 +375,9 @@ impl Host {
|
||||
self.processes.kill_all();
|
||||
self.terminals.kill_all();
|
||||
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
|
||||
for project in &projects {
|
||||
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
for project in &projects {
|
||||
project.plugin_diagnostics.close();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
|
||||
+455
-27
@@ -396,25 +396,144 @@ pub async fn lsp_references(
|
||||
libraries: bool,
|
||||
on_partial: super::Channel<References>,
|
||||
) -> IdeResult<References> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp
|
||||
.references_streaming(
|
||||
doc,
|
||||
pos,
|
||||
include_declaration,
|
||||
project_wide,
|
||||
libraries,
|
||||
cancel,
|
||||
Some(&|refs| {
|
||||
let _ = on_partial.send(refs);
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let result = async {
|
||||
let symbol = indexed_symbol(host, window, doc, pos).await?;
|
||||
if let Some(symbol) = &symbol {
|
||||
let project = project.clone();
|
||||
let snapshot = symbol.clone();
|
||||
let cancellation = cancel.clone();
|
||||
blocking(move || validate_indexed_symbol(&project, &snapshot, Some(&cancellation))).await?;
|
||||
let _ = on_partial.send(merge_indexed_references(References::default(), symbol));
|
||||
}
|
||||
let result = lsp
|
||||
.references_streaming(
|
||||
doc,
|
||||
pos,
|
||||
include_declaration,
|
||||
project_wide,
|
||||
libraries,
|
||||
cancel.clone(),
|
||||
Some(&|refs| {
|
||||
let refs =
|
||||
symbol.as_ref().map_or_else(|| refs.clone(), |s| merge_indexed_references(refs.clone(), s));
|
||||
let _ = on_partial.send(refs);
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
match symbol {
|
||||
None => result,
|
||||
Some(symbol) => {
|
||||
let server = match result {
|
||||
Ok(server) => server,
|
||||
Err(e) => References { incomplete: indexed_lsp_error(e)?, ..Default::default() },
|
||||
};
|
||||
blocking(move || {
|
||||
validate_indexed_symbol(&project, &symbol, Some(&cancel))?;
|
||||
Ok(merge_indexed_references(server, &symbol))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Native servers can decline names owned by indexers. A failed native query
|
||||
/// keeps the graph usable but must not look like a complete merged result.
|
||||
fn indexed_lsp_error(error: ide_core::IdeError) -> IdeResult<Option<ReferencesIncomplete>> {
|
||||
match error.kind {
|
||||
ErrorKind::NotFound | ErrorKind::Unavailable => Ok(None),
|
||||
ErrorKind::Protocol => Ok(Some(ReferencesIncomplete::ServerFailed)),
|
||||
_ => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn indexed_symbol(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<crate::plugins::navigation::IndexedSymbol>> {
|
||||
let Some(plugins) = crate::plugins::editor_plugin_host() else { return Ok(None) };
|
||||
let project = host.project(window)?;
|
||||
let client = window.to_owned();
|
||||
blocking(move || {
|
||||
let Ok((path, _)) = project.documents.identity(doc) else { return Ok(None) };
|
||||
let path = ide_plugin::resolve_real(&path);
|
||||
Ok(plugins.indexed_symbol_at(&project.root, &path, pos, Some(&client)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn validate_indexed_symbol(
|
||||
project: &crate::Project,
|
||||
symbol: &crate::plugins::navigation::IndexedSymbol,
|
||||
cancel: Option<&ide_core::CancellationToken>,
|
||||
) -> IdeResult<()> {
|
||||
for file in &symbol.files {
|
||||
if cancel.is_some_and(|cancel| cancel.is_cancelled()) {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let stale = || {
|
||||
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
|
||||
};
|
||||
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
|
||||
return Err(stale());
|
||||
}
|
||||
let text = if let Some(doc) = project.documents.by_path(&file.path) {
|
||||
let snapshot = project.documents.text_snapshot(doc)?;
|
||||
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
snapshot.text
|
||||
} else {
|
||||
let loaded = project
|
||||
.documents
|
||||
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
|
||||
.map_err(|_| stale())?;
|
||||
if loaded.binary || loaded.large {
|
||||
return Err(stale());
|
||||
}
|
||||
loaded.text
|
||||
};
|
||||
if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES
|
||||
|| crate::plugins::navigation::snapshot_hash(&text) != file.hash
|
||||
{
|
||||
return Err(stale());
|
||||
}
|
||||
let text = ide_text::Document::new(&text);
|
||||
for range in &file.ranges {
|
||||
if [range.start, range.end].into_iter().any(|pos| text.offset_to_pos(text.pos_to_offset(pos)) != pos) {
|
||||
return Err(ide_core::IdeError::invalid("indexed name is not on a UTF-16 character boundary"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_indexed_references(mut server: References, symbol: &crate::plugins::navigation::IndexedSymbol) -> References {
|
||||
let key =
|
||||
|location: &Location| (dunce::simplified(&location.path).to_path_buf(), location.range, location.uri.clone());
|
||||
let mut seen: std::collections::HashSet<_> = server.locations.iter().map(key).collect();
|
||||
for file in &symbol.files {
|
||||
for range in &file.ranges {
|
||||
let location = Location::file(file.path.clone(), *range);
|
||||
if seen.insert(key(&location)) {
|
||||
server.locations.push(location);
|
||||
}
|
||||
}
|
||||
}
|
||||
server
|
||||
}
|
||||
|
||||
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
|
||||
host.project(window)?.lsp.document_highlights(doc, pos).await
|
||||
}
|
||||
@@ -667,6 +786,14 @@ async fn is_client_follow_up(
|
||||
}
|
||||
|
||||
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
if let Some(symbol) = indexed_symbol(host, window, doc, pos).await? {
|
||||
let project = host.project(window)?;
|
||||
return blocking(move || {
|
||||
validate_indexed_symbol(&project, &symbol, None)?;
|
||||
Ok(Some(symbol.range))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
host.project(window)?.lsp.prepare_rename(doc, pos).await
|
||||
}
|
||||
|
||||
@@ -733,20 +860,133 @@ pub async fn lsp_rename(
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = project.lsp.rename(doc, pos, new_name, cancel).await;
|
||||
host.end_request(request);
|
||||
let mut renamed = result?;
|
||||
blocking(move || {
|
||||
if project.documents.version(doc)? != version {
|
||||
return Err(ide_core::IdeError::new(
|
||||
ide_core::error::ErrorKind::OutOfSync,
|
||||
"document changed while computing rename; request it again",
|
||||
));
|
||||
let result = async {
|
||||
let symbol = indexed_symbol(host, window, doc, pos).await?;
|
||||
let mut native_owned = false;
|
||||
let result = async {
|
||||
if let Some(symbol) = &symbol {
|
||||
let range = tokio::select! {
|
||||
_ = cancel.cancelled() => return Err(ide_core::IdeError::cancelled()),
|
||||
range = project.lsp.prepare_rename(doc, pos) => range?,
|
||||
};
|
||||
if range != Some(symbol.range) {
|
||||
return Ok(Renamed::default());
|
||||
}
|
||||
native_owned = true;
|
||||
}
|
||||
project.lsp.rename(doc, pos, new_name.clone(), cancel.clone()).await
|
||||
}
|
||||
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
|
||||
Ok(renamed)
|
||||
})
|
||||
.await
|
||||
.await;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let mut renamed = match result {
|
||||
Ok(renamed) => renamed,
|
||||
Err(e) if symbol.is_some() && !native_owned => {
|
||||
Renamed { incomplete: indexed_lsp_error(e)?, ..Default::default() }
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if native_owned
|
||||
&& !renamed.edit.files.iter().any(|file| match file {
|
||||
ide_core::FileEdit::Edit { edits, .. } => !edits.is_empty(),
|
||||
_ => true,
|
||||
})
|
||||
{
|
||||
return Err(ide_core::IdeError::invalid("Native server declined rename"));
|
||||
}
|
||||
blocking(move || {
|
||||
if project.documents.version(doc)? != version {
|
||||
return Err(ide_core::IdeError::new(
|
||||
ErrorKind::OutOfSync,
|
||||
"document changed while computing rename; request it again",
|
||||
));
|
||||
}
|
||||
if let Some(symbol) = &symbol {
|
||||
merge_indexed_rename(&mut renamed, symbol, &new_name, &project.documents);
|
||||
}
|
||||
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
|
||||
if let Some(symbol) = &symbol {
|
||||
validate_indexed_symbol(&project, symbol, Some(&cancel))?;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
Ok(renamed)
|
||||
})
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
fn merge_indexed_rename(
|
||||
renamed: &mut Renamed,
|
||||
symbol: &crate::plugins::navigation::IndexedSymbol,
|
||||
new_name: &str,
|
||||
documents: &ide_workspace::Documents,
|
||||
) {
|
||||
use ide_core::FileEdit;
|
||||
renamed.preview = true;
|
||||
let server = std::mem::take(&mut renamed.edit.files);
|
||||
let mut additions: Vec<Vec<FileEdit>> = vec![vec![]; server.len() + 1];
|
||||
for file in &symbol.files {
|
||||
let mut path = dunce::simplified(&file.path).to_path_buf();
|
||||
let mut at = server.len();
|
||||
for (index, operation) in server.iter().enumerate() {
|
||||
match operation {
|
||||
FileEdit::Edit { path: target, .. } if dunce::simplified(target) == path.as_path() => {
|
||||
at = index;
|
||||
break;
|
||||
}
|
||||
FileEdit::Rename { from, to } => {
|
||||
if let Ok(suffix) = path.strip_prefix(dunce::simplified(from)) {
|
||||
path = dunce::simplified(to).join(suffix);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let covered: Vec<_> = server[at..]
|
||||
.iter()
|
||||
.take_while(|operation| matches!(operation, FileEdit::Edit { .. }))
|
||||
.filter_map(|operation| match operation {
|
||||
FileEdit::Edit { path: target, edits, .. } if dunce::simplified(target) == path.as_path() => {
|
||||
Some(edits)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
let edits: Vec<_> = file
|
||||
.ranges
|
||||
.iter()
|
||||
.filter(|range| {
|
||||
!covered.iter().any(|native| {
|
||||
native.range == **range || (native.range.start < range.end && range.start < native.range.end)
|
||||
})
|
||||
})
|
||||
.map(|range| TextEdit { range: *range, new_text: new_name.to_owned() })
|
||||
.collect();
|
||||
if edits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
additions[at].push(FileEdit::Edit {
|
||||
path: path.into(),
|
||||
version: documents
|
||||
.by_path(&file.path)
|
||||
.and_then(|id| documents.version(id).ok())
|
||||
.and_then(|v| i32::try_from(v).ok()),
|
||||
edits,
|
||||
});
|
||||
}
|
||||
for (index, operation) in server.into_iter().enumerate() {
|
||||
renamed.edit.files.push(operation);
|
||||
renamed.edit.files.append(&mut additions[index]);
|
||||
}
|
||||
renamed.edit.files.append(additions.last_mut().expect("one trailing batch"));
|
||||
renamed.edit.deduplicate();
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
@@ -1103,6 +1343,194 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn indexed_names_survive_native_refusal_without_hiding_cancellation_or_staleness() {
|
||||
use ide_core::IdeError;
|
||||
for kind in [ErrorKind::NotFound, ErrorKind::Unavailable] {
|
||||
assert_eq!(indexed_lsp_error(IdeError::new(kind, "no native provider")).unwrap(), None);
|
||||
}
|
||||
assert_eq!(
|
||||
indexed_lsp_error(IdeError::new(ErrorKind::Protocol, "not a native identifier")).unwrap(),
|
||||
Some(ReferencesIncomplete::ServerFailed)
|
||||
);
|
||||
for kind in [ErrorKind::Cancelled, ErrorKind::OutOfSync, ErrorKind::InvalidInput] {
|
||||
assert_eq!(indexed_lsp_error(IdeError::new(kind, "must propagate")).unwrap_err().kind, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_results_merge_with_lsp_locations_edits_and_incompleteness() {
|
||||
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
|
||||
use ide_core::{FileEdit, LanguageId};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let shared = root.join("shared.idx");
|
||||
let native = root.join("native.txt");
|
||||
std::fs::write(&shared, "old old").unwrap();
|
||||
std::fs::write(&native, "old").unwrap();
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
|
||||
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
|
||||
let symbol = IndexedSymbol {
|
||||
range,
|
||||
files: vec![IndexedAnchors {
|
||||
path: shared.clone(),
|
||||
version: None,
|
||||
hash: blake3::hash(b"old old"),
|
||||
ranges: vec![range, other],
|
||||
}],
|
||||
};
|
||||
// Native file URIs have ordinary Windows paths; index snapshots retain
|
||||
// canonical verbatim paths. Both identify the same file and edit batch.
|
||||
let server_shared = dunce::simplified(&shared).to_path_buf();
|
||||
let server_native = dunce::simplified(&native).to_path_buf();
|
||||
#[cfg(windows)]
|
||||
assert_ne!(shared, server_shared);
|
||||
let location = Location::file(server_shared.clone(), range);
|
||||
let native_location = Location::file(server_native.clone(), range);
|
||||
let merged = merge_indexed_references(
|
||||
References {
|
||||
locations: vec![location.clone(), native_location.clone()],
|
||||
incomplete: Some(ReferencesIncomplete::Indexing),
|
||||
libraries_skipped: true,
|
||||
..Default::default()
|
||||
},
|
||||
&symbol,
|
||||
);
|
||||
assert_eq!(merged.locations, vec![location, native_location, Location::file(shared.clone(), other)]);
|
||||
assert_eq!(merged.incomplete, Some(ReferencesIncomplete::Indexing));
|
||||
assert!(merged.libraries_skipped);
|
||||
let edit = |path, range| FileEdit::Edit {
|
||||
path,
|
||||
version: None,
|
||||
edits: vec![TextEdit { range, new_text: "new".into() }],
|
||||
};
|
||||
let mut renamed = Renamed {
|
||||
edit: WorkspaceEdit {
|
||||
files: vec![edit(server_shared.clone().into(), range), edit(server_native.into(), range)],
|
||||
..Default::default()
|
||||
},
|
||||
incomplete: Some(ReferencesIncomplete::FilesSkipped),
|
||||
..Default::default()
|
||||
};
|
||||
let docs = ide_workspace::Documents::new(
|
||||
std::sync::Arc::new(ide_core::NullSink),
|
||||
Box::new(|_: &Path, _: Option<&str>| LanguageId::plain_text()),
|
||||
);
|
||||
merge_indexed_rename(&mut renamed, &symbol, "new", &docs);
|
||||
assert!(renamed.preview);
|
||||
assert_eq!(renamed.incomplete, Some(ReferencesIncomplete::FilesSkipped));
|
||||
assert_eq!(renamed.edit.files.len(), 2);
|
||||
docs.prepare_workspace_edit(&mut renamed.edit).unwrap();
|
||||
docs.apply_workspace_edit(&renamed.edit).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&shared).unwrap(), "new new");
|
||||
assert_eq!(std::fs::read_to_string(&native).unwrap(), "new");
|
||||
|
||||
// Native rewrites preserve syntax (for example a shorthand property key).
|
||||
std::fs::write(&shared, "old old").unwrap();
|
||||
let mut rewritten = Renamed {
|
||||
edit: WorkspaceEdit {
|
||||
files: vec![FileEdit::Edit {
|
||||
path: shared.clone().into(),
|
||||
version: None,
|
||||
edits: vec![TextEdit { range, new_text: "old: new".into() }],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
merge_indexed_rename(&mut rewritten, &symbol, "new", &docs);
|
||||
docs.prepare_workspace_edit(&mut rewritten.edit).unwrap();
|
||||
docs.apply_workspace_edit(&rewritten.edit).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&shared).unwrap(), "old: new new");
|
||||
std::fs::write(&shared, "new new").unwrap();
|
||||
|
||||
// A class rename may move its file before editing the new path. Indexed
|
||||
// anchors still describe the original file and must join that text batch.
|
||||
let moved = root.join("moved.idx");
|
||||
let server_moved = dunce::simplified(&moved).to_path_buf();
|
||||
let mut renamed = Renamed {
|
||||
edit: WorkspaceEdit {
|
||||
files: vec![
|
||||
FileEdit::Rename { from: server_shared.into(), to: server_moved.clone().into() },
|
||||
FileEdit::Edit {
|
||||
path: server_moved.into(),
|
||||
version: None,
|
||||
edits: vec![TextEdit { range, new_text: "moved".into() }],
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
merge_indexed_rename(&mut renamed, &symbol, "moved", &docs);
|
||||
docs.prepare_workspace_edit(&mut renamed.edit).unwrap();
|
||||
docs.apply_workspace_edit(&renamed.edit).unwrap();
|
||||
assert!(!shared.exists());
|
||||
assert_eq!(std::fs::read_to_string(&moved).unwrap(), "moved moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_edits_yield_to_native_intersections_but_keep_boundary_insertions() {
|
||||
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
|
||||
use ide_core::{FileEdit, LanguageId};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("shared.idx");
|
||||
std::fs::write(&path, "old old").unwrap();
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
|
||||
let symbol = IndexedSymbol {
|
||||
range,
|
||||
files: vec![IndexedAnchors {
|
||||
path: path.clone(),
|
||||
version: None,
|
||||
hash: blake3::hash(b"old old"),
|
||||
ranges: vec![range],
|
||||
}],
|
||||
};
|
||||
let docs = ide_workspace::Documents::new(
|
||||
std::sync::Arc::new(ide_core::NullSink),
|
||||
Box::new(|_: &Path, _: Option<&str>| LanguageId::plain_text()),
|
||||
);
|
||||
for (start, end, expected) in [
|
||||
(0, 3, "native old"),
|
||||
(0, 4, "nativeold"),
|
||||
(1, 4, "onativeold"),
|
||||
(1, 2, "onatived old"),
|
||||
(1, 1, "onativeld old"),
|
||||
(0, 0, "nativeindexed old"),
|
||||
(3, 3, "indexednative old"),
|
||||
(3, 4, "indexednativeold"),
|
||||
(4, 7, "indexed native"),
|
||||
] {
|
||||
let native =
|
||||
TextEdit { range: Range::new(Pos::new(0, start), Pos::new(0, end)), new_text: "native".into() };
|
||||
let mut renamed = Renamed {
|
||||
edit: WorkspaceEdit {
|
||||
files: vec![FileEdit::Edit {
|
||||
path: path.clone().into(),
|
||||
version: None,
|
||||
edits: vec![native.clone()],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
merge_indexed_rename(&mut renamed, &symbol, "indexed", &docs);
|
||||
let edits: Vec<_> = renamed
|
||||
.edit
|
||||
.files
|
||||
.iter()
|
||||
.flat_map(|file| match file {
|
||||
FileEdit::Edit { edits, .. } => edits.clone(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect();
|
||||
assert!(edits.contains(&native));
|
||||
let mut text = ide_text::Document::new("old old");
|
||||
text.apply_edits(&edits).unwrap();
|
||||
assert_eq!(text.text(), expected, "native range {start}..{end}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_action_with_data_and_no_edits_is_resolved_by_its_plugin() {
|
||||
let action = |edits: Vec<(std::path::PathBuf, Vec<TextEdit>)>, data: Option<&str>| ide_plugin::CodeAction {
|
||||
|
||||
@@ -45,6 +45,15 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// An existing Python executable or virtualenv folder as an SDK; `None` if Python cannot run.
|
||||
pub async fn sdk_python_interpreter(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || Ok(ide_sdk::python_interpreter_sdk(&path, root.as_deref())))
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
@@ -288,6 +297,10 @@ pub async fn sdk_python_pip(
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add(
|
||||
"sdk_python_interpreter",
|
||||
|c| async move { sdk_python_interpreter(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
|
||||
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
|
||||
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
|
||||
@@ -334,3 +347,69 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::HostConfig;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fake_venv(root: &Path, name: &str) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let venv = root.join(name);
|
||||
std::fs::create_dir_all(venv.join("bin")).unwrap();
|
||||
std::fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\nversion = 3.12.1\n").unwrap();
|
||||
let python = venv.join("bin/python");
|
||||
std::fs::write(&python, "#!/bin/sh\necho 3.12.1\n").unwrap();
|
||||
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
python
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_hand_picked_venv_is_resolved_like_a_detected_interpreter() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
|
||||
let sdk = sdk_python_interpreter(&host, "main", fake_venv(&root, ".venv")).await.unwrap().unwrap();
|
||||
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (project)", "venv"));
|
||||
let python = fake_venv(&base, "erp19py3.12");
|
||||
let sdk = sdk_python_interpreter(&host, "main", python.parent().unwrap().parent().unwrap().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!((sdk.name.as_str(), sdk.source.as_str()), ("Python 3.12 (erp19py3.12)", "venv"));
|
||||
assert_eq!(&*sdk.path, python.as_path());
|
||||
host.project("main")
|
||||
.unwrap()
|
||||
.settings
|
||||
.set(
|
||||
ide_workspace::SettingsScope::Project,
|
||||
"languages.python.interpreter",
|
||||
serde_json::to_value(&sdk.path).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
host.close_project("main");
|
||||
host.open_project("main", &root).unwrap();
|
||||
let detected = sdk_detect(&host, "main", SdkKind::Python).await.unwrap();
|
||||
assert!(detected.iter().any(|s| s.path == sdk.path && s.name == sdk.name));
|
||||
assert_eq!(sdk_python_effective(&host, "main", None).unwrap(), Some(sdk.path));
|
||||
assert!(sdk_python_interpreter(&host, "main", root.join("README.md")).await.unwrap().is_none());
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -437,8 +437,18 @@ pub(super) struct Shared {
|
||||
pub ui: Arc<ide_extension::UiStore>,
|
||||
/// The UI language the windows report (BCP 47).
|
||||
pub locale: RwLock<String>,
|
||||
/// (plugin, window) → the log tabs its code opened there, oldest first:
|
||||
/// what a tab may run when it starts.
|
||||
log_tabs: Mutex<HashMap<(String, String), LogTabs>>,
|
||||
}
|
||||
|
||||
/// A window's log tabs of one plugin: key → what it runs, oldest first.
|
||||
type LogTabs = Vec<(String, ide_plugin::ServiceLog)>;
|
||||
|
||||
/// Log tabs a plugin's code may have open in one window; opening more forgets
|
||||
/// the oldest (that tab can no longer start its program).
|
||||
const MAX_LOG_TABS: usize = 32;
|
||||
|
||||
impl Shared {
|
||||
pub fn new(
|
||||
sink: Arc<dyn PluginEventSink>,
|
||||
@@ -467,6 +477,7 @@ impl Shared {
|
||||
closed_clients: Mutex::default(),
|
||||
subscriptions: Mutex::default(),
|
||||
diagnostics: Mutex::default(),
|
||||
log_tabs: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -565,6 +576,20 @@ impl Shared {
|
||||
self.closed_clients.lock().remove(client);
|
||||
}
|
||||
|
||||
/// What the log tab `key` that `plugin`'s code opened in `window` runs.
|
||||
pub fn log_tab(&self, plugin: &str, window: &str, key: &str) -> Option<ide_plugin::ServiceLog> {
|
||||
let tabs = self.log_tabs.lock();
|
||||
let tabs = tabs.get(&(plugin.to_owned(), window.to_owned()))?;
|
||||
tabs.iter().find(|(k, _)| k == key).map(|(_, log)| log.clone())
|
||||
}
|
||||
|
||||
/// Forgets the log tabs of a closed window, or of a stopped plugin.
|
||||
pub fn forget_log_tabs(&self, plugin: Option<&str>, window: Option<&str>) {
|
||||
self.log_tabs
|
||||
.lock()
|
||||
.retain(|(p, w), _| plugin.is_some_and(|plugin| plugin != p) || window.is_some_and(|window| window != w));
|
||||
}
|
||||
|
||||
pub fn client_closed(&self, client: &str) -> bool {
|
||||
self.closed_clients.lock().contains(client)
|
||||
}
|
||||
@@ -1777,6 +1802,29 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_log_tab(
|
||||
&self,
|
||||
client: &str,
|
||||
key: String,
|
||||
title: String,
|
||||
log: ide_plugin::ServiceLog,
|
||||
) -> Result<(), HostError> {
|
||||
// Fails now, rather than in the tab, when the approved file is gone or changed.
|
||||
self.approved_program(&log.program)?;
|
||||
{
|
||||
let mut all = self.shared.log_tabs.lock();
|
||||
let tabs = all.entry((self.id().to_owned(), client.to_owned())).or_default();
|
||||
tabs.retain(|(k, _)| *k != key);
|
||||
tabs.push((key.clone(), log.clone()));
|
||||
if tabs.len() > MAX_LOG_TABS {
|
||||
tabs.remove(0);
|
||||
}
|
||||
}
|
||||
let event = PluginEvent::OpenLogTab { plugin_id: self.id().to_owned(), key, title, log };
|
||||
self.shared.sink.emit(Some(client), event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn locale(&self) -> String {
|
||||
self.shared.locale.read().clone()
|
||||
}
|
||||
@@ -1794,6 +1842,16 @@ impl HostApi for PluginApi {
|
||||
serde_json::to_string(&value).ok()
|
||||
}
|
||||
|
||||
fn plugin_dir(&self) -> Result<PathBuf, HostError> {
|
||||
ide_extension::validate_plugin_id(self.id()).map_err(HostError::Invalid)?;
|
||||
std::fs::create_dir_all(&self.shared.data_dir).map_err(failed)?;
|
||||
let data = self.shared.data_dir.join(self.id());
|
||||
ide_vfs::create_private_dir(&data).map_err(failed)?;
|
||||
let files = data.join("files");
|
||||
ide_vfs::create_private_dir(&files).map_err(failed)?;
|
||||
files.canonicalize().map_err(failed)
|
||||
}
|
||||
|
||||
fn read_text_file(&self, real: &Path, max: usize) -> Result<String, HostError> {
|
||||
// Unsaved editor contents win, as they do for project files.
|
||||
if let Some(text) = self.shared.workspace().and_then(|w| w.buffer_text(real)) {
|
||||
@@ -1825,12 +1883,21 @@ impl HostApi for PluginApi {
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
|
||||
Err(e) => return Err(not_writable(real, e)),
|
||||
};
|
||||
ide_vfs::save_text(real, text, &encoding, has_bom).map(drop).map_err(|e| not_writable(real, e))
|
||||
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
|
||||
let bytes = ide_vfs::encode(text, &encoding, has_bom).map_err(|e| not_writable(real, e))?;
|
||||
ide_vfs::write_atomic_private(real, &bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
} else {
|
||||
ide_vfs::save_text(real, text, &encoding, has_bom).map(drop).map_err(|e| not_writable(real, e))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
|
||||
check_unsaved(self.shared.workspace(), real)?;
|
||||
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
|
||||
ide_vfs::write_atomic_private(real, bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
} else {
|
||||
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,10 @@ pub(super) struct PathFilter {
|
||||
|
||||
impl PathFilter {
|
||||
pub fn new(kind: &str, globs: Vec<String>) -> Result<Self, HostError> {
|
||||
if !matches!(kind, "file-opened" | "file-saved") {
|
||||
return Err(HostError::Invalid("subscribe-paths supports file-opened and file-saved".into()));
|
||||
if !matches!(kind, "file-opened" | "file-saved" | "files-changed") {
|
||||
return Err(HostError::Invalid(
|
||||
"subscribe-paths supports file-opened, file-saved and files-changed".into(),
|
||||
));
|
||||
}
|
||||
if globs.len() > 64 {
|
||||
return Err(HostError::Invalid("at most 64 event path globs".into()));
|
||||
@@ -87,26 +89,50 @@ impl Subscriptions {
|
||||
|
||||
impl PluginHost {
|
||||
pub(super) fn filter_path_events(
|
||||
&self,
|
||||
shared: &super::api::Shared,
|
||||
plugin: &LoadedPlugin,
|
||||
client: Option<&str>,
|
||||
events: &[WorkbenchEvent],
|
||||
subs: &Subscriptions,
|
||||
) -> Vec<WorkbenchEvent> {
|
||||
let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() };
|
||||
let workspace = self.shared.workspace();
|
||||
let workspace = shared.workspace();
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
.filter_map(|event| {
|
||||
if let WorkbenchEvent::FilesChanged(batch) = event {
|
||||
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
|
||||
let root = workspace.as_ref()?.project_of(client?)?;
|
||||
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
|
||||
return None;
|
||||
}
|
||||
let paths = |paths: &[String]| {
|
||||
paths
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let path = Path::new(path);
|
||||
path.is_absolute() && filter.matches(&root, path)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let batch = ide_plugin::FilesChanged {
|
||||
created: paths(&batch.created),
|
||||
modified: paths(&batch.modified),
|
||||
removed: paths(&batch.removed),
|
||||
};
|
||||
return (!(batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty()))
|
||||
.then_some(WorkbenchEvent::FilesChanged(batch));
|
||||
}
|
||||
let path = match event {
|
||||
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
|
||||
_ => return true,
|
||||
_ => return Some(event.clone()),
|
||||
};
|
||||
let Some(filter) = filters.get(event.kind().name()) else { return true };
|
||||
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
|
||||
if !path.is_absolute() {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let Some(workspace) = &workspace else { return false };
|
||||
let workspace = workspace.as_ref()?;
|
||||
let root = match client {
|
||||
Some(client) => workspace.project_of(client),
|
||||
None => {
|
||||
@@ -117,14 +143,12 @@ impl PluginHost {
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.max_by_key(|r| r.components().count())
|
||||
}
|
||||
};
|
||||
let Some(root) = root else { return false };
|
||||
}?;
|
||||
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
filter.matches(&root, path)
|
||||
filter.matches(&root, path).then(|| event.clone())
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -142,6 +166,7 @@ mod tests {
|
||||
assert!(PathFilter::new("file-saved", vec!["*".into(); 65]).is_err());
|
||||
assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err());
|
||||
assert!(PathFilter::new("file-saved", vec![]).is_ok());
|
||||
assert!(PathFilter::new("files-changed", vec![]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
//! Filesystem batches retain their project incarnation until delivery.
|
||||
use super::{PluginHost, WorkbenchEvent};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_plugin::FilesChanged;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc, Weak,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Source {
|
||||
active: Weak<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn same(&self, other: &Self) -> bool {
|
||||
self.active.ptr_eq(&other.active)
|
||||
}
|
||||
fn live(&self) -> bool {
|
||||
self.active.upgrade().is_some_and(|active| active.load(Ordering::Acquire))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn retain_live(events: &mut Vec<WorkbenchEvent>, source: Option<&Source>) {
|
||||
if source.is_some_and(|source| !source.live()) {
|
||||
events.retain(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn append(events: &mut Vec<WorkbenchEvent>, incoming: Vec<WorkbenchEvent>) {
|
||||
for event in incoming {
|
||||
if let WorkbenchEvent::FilesChanged(batch) = &event
|
||||
&& let Some(WorkbenchEvent::FilesChanged(previous)) =
|
||||
events.iter_mut().find(|e| matches!(e, WorkbenchEvent::FilesChanged(_)))
|
||||
{
|
||||
use std::collections::BTreeMap;
|
||||
#[derive(Clone, Copy)]
|
||||
enum Change {
|
||||
Created,
|
||||
Modified,
|
||||
Removed,
|
||||
}
|
||||
use Change::*;
|
||||
let mut paths = BTreeMap::new();
|
||||
for (kind, entries) in
|
||||
[(Created, &previous.created), (Modified, &previous.modified), (Removed, &previous.removed)]
|
||||
{
|
||||
for path in entries {
|
||||
paths.insert(path.clone(), kind);
|
||||
}
|
||||
}
|
||||
for (next, entries) in [(Created, &batch.created), (Modified, &batch.modified), (Removed, &batch.removed)] {
|
||||
for path in entries {
|
||||
let kind = match (paths.get(path), next) {
|
||||
(Some(Created), Created | Modified) => Created,
|
||||
(Some(Removed), Created) | (Some(Modified), Created | Modified) => Modified,
|
||||
(Some(Removed), Modified) => Removed,
|
||||
(_, next) => next,
|
||||
};
|
||||
paths.insert(path.clone(), kind);
|
||||
}
|
||||
}
|
||||
*previous = FilesChanged::default();
|
||||
for (path, kind) in paths {
|
||||
match kind {
|
||||
Created => previous.created.push(path),
|
||||
Modified => previous.modified.push(path),
|
||||
Removed => previous.removed.push(path),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(crate) fn post_files_changed(
|
||||
&self,
|
||||
client: &str,
|
||||
root: &Path,
|
||||
active: &Arc<AtomicBool>,
|
||||
batch: &ide_vfs::FsBatch,
|
||||
) {
|
||||
if !active.load(Ordering::Acquire) || self.shared.project_of(client).as_deref() != Some(root) {
|
||||
return;
|
||||
}
|
||||
let paths =
|
||||
|paths: &[PathBuf]| paths.iter().filter(|p| p.starts_with(root)).map(|p| to_ipc(p)).collect::<Vec<_>>();
|
||||
let batch = FilesChanged {
|
||||
created: paths(&batch.created),
|
||||
modified: paths(&batch.modified),
|
||||
removed: paths(&batch.removed),
|
||||
};
|
||||
if batch.created.is_empty() && batch.modified.is_empty() && batch.removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.post_events_from(
|
||||
Some(client),
|
||||
vec![WorkbenchEvent::FilesChanged(batch)],
|
||||
Some(Source { active: Arc::downgrade(active) }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn files_changed_host(root: &Path) -> PluginHost {
|
||||
use super::super::Consent;
|
||||
let host =
|
||||
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
|
||||
host.set_workspace(Some(Arc::new(Workspace(root.to_path_buf()))));
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
|
||||
host.install_from_dir(&fixture, &Consent::all()).unwrap();
|
||||
host
|
||||
}
|
||||
|
||||
fn delta(kind: usize, path: &str) -> WorkbenchEvent {
|
||||
let mut batch = FilesChanged::default();
|
||||
match kind {
|
||||
0 => batch.created.push(path.into()),
|
||||
1 => batch.modified.push(path.into()),
|
||||
_ => batch.removed.push(path.into()),
|
||||
}
|
||||
WorkbenchEvent::FilesChanged(batch)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_preserves_ordered_path_transitions() {
|
||||
for (first, next, expected) in
|
||||
[(0, 0, 0), (0, 1, 0), (0, 2, 2), (1, 0, 1), (1, 1, 1), (1, 2, 2), (2, 0, 1), (2, 1, 2), (2, 2, 2)]
|
||||
{
|
||||
let mut events = vec![delta(first, "/project/path")];
|
||||
append(&mut events, vec![delta(next, "/project/path")]);
|
||||
assert_eq!(events, [delta(expected, "/project/path")], "transition {first} -> {next}");
|
||||
}
|
||||
let mut events = vec![delta(0, "/project/transient"), delta(2, "/project/old-name")];
|
||||
append(&mut events, vec![delta(2, "/project/transient"), delta(0, "/project/new-name")]);
|
||||
append(&mut events, vec![delta(1, "/project/transient")]);
|
||||
// Seed through append too: one coalesced batch keeps both sides of a rename.
|
||||
let mut merged = vec![];
|
||||
append(&mut merged, events);
|
||||
assert_eq!(
|
||||
merged,
|
||||
[WorkbenchEvent::FilesChanged(FilesChanged {
|
||||
created: vec!["/project/new-name".into()],
|
||||
removed: vec!["/project/old-name".into(), "/project/transient".into()],
|
||||
..Default::default()
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_stale_admission_cannot_replace_reopened_queue() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = files_changed_host(&root);
|
||||
let old = Arc::new(AtomicBool::new(true));
|
||||
let new = Arc::new(AtomicBool::new(true));
|
||||
let (entered, waiting) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
std::thread::scope(|scope| {
|
||||
let host = &host;
|
||||
let old = &old;
|
||||
let root = &root;
|
||||
scope.spawn(move || {
|
||||
assert!(old.load(Ordering::Acquire));
|
||||
entered.send(()).unwrap();
|
||||
released.recv().unwrap();
|
||||
host.post_events_from(
|
||||
Some("main"),
|
||||
vec![delta(0, &to_ipc(&root.join("stale.rs")))],
|
||||
Some(Source { active: Arc::downgrade(old) }),
|
||||
);
|
||||
});
|
||||
waiting.recv().unwrap();
|
||||
old.store(false, Ordering::Release);
|
||||
host.post_files_changed(
|
||||
"main",
|
||||
root,
|
||||
&new,
|
||||
&ide_vfs::FsBatch { created: vec![root.join("new.rs")], ..Default::default() },
|
||||
);
|
||||
release.send(()).unwrap();
|
||||
});
|
||||
let queues = host.queues.lock();
|
||||
let queue = queues.values().next().expect("new session queue");
|
||||
assert!(queue.files_source.as_ref().unwrap().same(&Source { active: Arc::downgrade(&new) }));
|
||||
assert_eq!(queue.events, [delta(0, &to_ipc(&root.join("new.rs")))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_restarts_after_an_actor_drops_queued_work() {
|
||||
use std::time::Duration;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = files_changed_host(&root);
|
||||
let plugin = host.get("files-events").unwrap();
|
||||
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
|
||||
let (entered, waiting) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
let crash = actor
|
||||
.submit(None, move |_| {
|
||||
entered.send(()).unwrap();
|
||||
released.recv().unwrap();
|
||||
Err::<(), _>(ide_plugin::CallError::Trap(ide_plugin::CANCELLED.into()))
|
||||
})
|
||||
.unwrap();
|
||||
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("discarded.rs")))]);
|
||||
host.flush_events();
|
||||
release.send(()).unwrap();
|
||||
assert!(crash.wait().is_err());
|
||||
host.flush_events();
|
||||
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("after-restart.rs")))]);
|
||||
host.flush_events();
|
||||
assert!(host.wait_events(Duration::from_secs(1)), "dropped actor work must not keep its mailbox alive");
|
||||
let data = host.run_command("files-events", "events", None, Default::default()).unwrap().unwrap();
|
||||
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
|
||||
assert_eq!(
|
||||
batches,
|
||||
[FilesChanged { created: vec![to_ipc(&root.join("after-restart.rs"))], ..Default::default() }]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_actor_mailbox_is_bounded_and_yields_to_commands() {
|
||||
use std::time::Duration;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = files_changed_host(&root);
|
||||
let plugin = host.get("files-events").unwrap();
|
||||
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
|
||||
let (entered, waiting) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
actor.post(
|
||||
move |_| {
|
||||
entered.send(()).unwrap();
|
||||
released.recv().unwrap();
|
||||
Ok(())
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
for i in 0..600 {
|
||||
host.post_events(Some("main"), vec![delta(i % 3, &to_ipc(&root.join(format!("path-{i}"))))]);
|
||||
host.flush_events();
|
||||
}
|
||||
let pending = plugin.batches.lock().pending;
|
||||
// A command gets a scheduling turn after one coalesced delivery.
|
||||
let command = actor.submit(None, move |rt| rt.run_command("events", None, Default::default())).unwrap();
|
||||
release.send(()).unwrap();
|
||||
let data = command.wait().unwrap().unwrap();
|
||||
assert_eq!(pending, 1, "flushes must not grow the actor job queue");
|
||||
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
|
||||
assert_eq!(batches.len(), 1);
|
||||
assert_eq!(batches[0].created.len(), 200);
|
||||
assert_eq!(batches[0].modified.len(), 200);
|
||||
assert_eq!(batches[0].removed.len(), 200);
|
||||
assert!(host.wait_events(Duration::from_secs(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_rechecks_filters_after_a_busy_actor_command() {
|
||||
use std::time::Duration;
|
||||
for globs in ["", "other/**", "config/*.yaml"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = files_changed_host(&root);
|
||||
let plugin = host.get("files-events").unwrap();
|
||||
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
|
||||
let (entered, waiting) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
actor.post(
|
||||
move |_| {
|
||||
entered.send(()).unwrap();
|
||||
released.recv().unwrap();
|
||||
Ok(())
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
let filter =
|
||||
actor.submit(None, move |rt| rt.run_command("filter", Some(globs), Default::default())).unwrap();
|
||||
host.post_events(
|
||||
Some("main"),
|
||||
vec![WorkbenchEvent::FilesChanged(FilesChanged {
|
||||
created: vec![to_ipc(&root.join("config/a.yaml")), to_ipc(&root.join("src/a.rs"))],
|
||||
modified: vec![to_ipc(&root.join("config/b.yaml"))],
|
||||
removed: vec![to_ipc(&root.join("config/c.yaml"))],
|
||||
})],
|
||||
);
|
||||
host.flush_events();
|
||||
release.send(()).unwrap();
|
||||
filter.wait().unwrap();
|
||||
assert!(host.wait_events(Duration::from_secs(10)));
|
||||
let data = host.run_command("files-events", "events", None, Default::default()).unwrap();
|
||||
if globs == "config/*.yaml" {
|
||||
let batches: Vec<FilesChanged> = serde_json::from_str(&data.unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
batches,
|
||||
[FilesChanged {
|
||||
created: vec![to_ipc(&root.join("config/a.yaml"))],
|
||||
modified: vec![to_ipc(&root.join("config/b.yaml"))],
|
||||
removed: vec![to_ipc(&root.join("config/c.yaml"))],
|
||||
}]
|
||||
);
|
||||
} else {
|
||||
assert_eq!(data, None, "excluded batches must not invoke the guest handler");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_delivery_honors_filters_registered_during_activation() {
|
||||
use super::super::Consent;
|
||||
use std::time::{Duration, Instant};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host =
|
||||
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
|
||||
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/path-events");
|
||||
let copy = root.join("activation-fixture");
|
||||
std::fs::create_dir_all(copy.join("dist")).unwrap();
|
||||
std::fs::write(
|
||||
copy.join("plugin.toml"),
|
||||
std::fs::read_to_string(fixture.join("plugin.toml"))
|
||||
.unwrap()
|
||||
.replace("[\"on-command:*\"]", "[\"on-command:*\", \"on-event:file-opened\"]"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::copy(fixture.join("dist/plugin.wasm"), copy.join("dist/plugin.wasm")).unwrap();
|
||||
host.install_from_dir(©, &Consent::all()).unwrap();
|
||||
host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&root.join("src/excluded.rs")))]);
|
||||
host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(host.wait_events(Duration::from_secs(10)));
|
||||
assert_eq!(host.run_command("test.path-events", "count", None, Default::default()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_rechecks_runtime_unsubscribe_on_busy_actor() {
|
||||
use super::super::Consent;
|
||||
use std::time::Duration;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host =
|
||||
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
|
||||
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
|
||||
let copy = root.join("runtime-fixture");
|
||||
std::fs::create_dir_all(copy.join("dist")).unwrap();
|
||||
std::fs::write(
|
||||
copy.join("plugin.toml"),
|
||||
std::fs::read_to_string(fixture.join("plugin.toml")).unwrap().replace(", \"on-event:files-changed\"", ""),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::copy(fixture.join("dist/plugin.wasm"), copy.join("dist/plugin.wasm")).unwrap();
|
||||
host.install_from_dir(©, &Consent::all()).unwrap();
|
||||
host.run_command("files-events", "filter", Some("**"), Default::default()).unwrap();
|
||||
let plugin = host.get("files-events").unwrap();
|
||||
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
|
||||
let (entered, waiting) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
actor.post(
|
||||
move |_| {
|
||||
entered.send(()).unwrap();
|
||||
released.recv().unwrap();
|
||||
Ok(())
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
let off = actor.submit(None, |rt| rt.run_command("unsubscribe", None, Default::default())).unwrap();
|
||||
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("excluded.rs")))]);
|
||||
host.flush_events();
|
||||
release.send(()).unwrap();
|
||||
off.wait().unwrap();
|
||||
assert!(host.wait_events(Duration::from_secs(10)));
|
||||
assert_eq!(host.run_command("files-events", "events", None, Default::default()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_dropped_actor_job_releases_mailbox_before_restart() {
|
||||
use std::time::Duration;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = files_changed_host(&root);
|
||||
let plugin = host.get("files-events").unwrap();
|
||||
let (actor, _) = host.ensure_active(&plugin, Some("main")).unwrap();
|
||||
let (entered, waiting) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
actor.post(
|
||||
move |_| {
|
||||
entered.send(()).unwrap();
|
||||
released.recv().unwrap();
|
||||
Err::<(), _>(ide_plugin::CallError::Trap("test actor exit".into()))
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
waiting.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("dropped.rs")))]);
|
||||
host.flush_events();
|
||||
assert_eq!(plugin.batches.lock().pending, 1);
|
||||
release.send(()).unwrap();
|
||||
actor.stop(true);
|
||||
assert_eq!(plugin.batches.lock().pending, 0, "dropped closures must release their batch ticket");
|
||||
assert!(plugin.batches.lock().files_pending.is_empty());
|
||||
host.set_enabled("files-events", false).unwrap();
|
||||
host.set_enabled("files-events", true).unwrap();
|
||||
host.post_events(Some("main"), vec![delta(0, &to_ipc(&root.join("fresh.rs")))]);
|
||||
host.flush_events();
|
||||
assert!(host.wait_events(Duration::from_secs(10)));
|
||||
let data = host.run_command("files-events", "events", None, Default::default()).unwrap().unwrap();
|
||||
let batches: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
|
||||
assert_eq!(batches, [FilesChanged { created: vec![to_ipc(&root.join("fresh.rs"))], ..Default::default() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_old_ticket_cannot_clear_successor_mailbox_or_running_state() {
|
||||
use super::super::{BatchTicket, Batches, PendingFiles};
|
||||
let batches = Arc::new(parking_lot::Mutex::new(Batches::default()));
|
||||
let key = (Some("main".into()), Some(PathBuf::from("/project")));
|
||||
let old = Arc::new(());
|
||||
let new = Arc::new(());
|
||||
let ticket = BatchTicket { batches: batches.clone(), files_key: Some(key.clone()), token: old };
|
||||
{
|
||||
let mut state = batches.lock();
|
||||
state.pending = 2;
|
||||
state.running = Some([ide_plugin::EventKind::FilesChanged].into());
|
||||
state.running_token = Some(new.clone());
|
||||
state.files_pending.insert(
|
||||
key.clone(),
|
||||
PendingFiles {
|
||||
queue: super::super::EventQueue {
|
||||
since: std::time::Instant::now(),
|
||||
events: vec![delta(0, "/project/new")],
|
||||
files_source: None,
|
||||
},
|
||||
actor: std::sync::Weak::new(),
|
||||
token: new.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
drop(ticket);
|
||||
let state = batches.lock();
|
||||
assert_eq!(state.pending, 1);
|
||||
assert!(Arc::ptr_eq(&state.files_pending[&key].token, &new));
|
||||
assert!(Arc::ptr_eq(state.running_token.as_ref().unwrap(), &new));
|
||||
assert!(state.running.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_runtime_event_has_a_matching_manifest_activation() {
|
||||
for kind in ide_plugin::EventKind::ALL {
|
||||
if kind != ide_plugin::EventKind::Scheduled {
|
||||
assert!(ide_extension::EVENT_KINDS.contains(&kind.name()));
|
||||
}
|
||||
}
|
||||
for kind in ide_extension::EVENT_KINDS {
|
||||
assert!(ide_plugin::EventKind::parse(kind).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batches_coalesce_without_losing_categories_or_paths() {
|
||||
let mut events = vec![];
|
||||
for i in 0..600 {
|
||||
append(
|
||||
&mut events,
|
||||
vec![WorkbenchEvent::FilesChanged(FilesChanged {
|
||||
created: vec![format!("/project/{i}")],
|
||||
modified: vec!["/project/shared".into()],
|
||||
removed: vec!["/project/gone".into()],
|
||||
})],
|
||||
);
|
||||
}
|
||||
let [WorkbenchEvent::FilesChanged(batch)] = events.as_slice() else { panic!("one coalesced batch") };
|
||||
assert_eq!(batch.created.len(), 600);
|
||||
assert_eq!(batch.modified, ["/project/shared"]);
|
||||
assert_eq!(batch.removed, ["/project/gone"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_incarnations_drop_only_filesystem_events() {
|
||||
let active = Arc::new(AtomicBool::new(true));
|
||||
let source = Source { active: Arc::downgrade(&active) };
|
||||
let mut events = vec![WorkbenchEvent::FilesChanged(FilesChanged::default()), WorkbenchEvent::Scheduled];
|
||||
retain_live(&mut events, Some(&source));
|
||||
assert_eq!(events.len(), 2);
|
||||
active.store(false, Ordering::Release);
|
||||
retain_live(&mut events, Some(&source));
|
||||
assert_eq!(events, [WorkbenchEvent::Scheduled]);
|
||||
assert!(!source.same(&Source { active: Arc::downgrade(&Arc::new(AtomicBool::new(true))) }));
|
||||
}
|
||||
struct Workspace(PathBuf);
|
||||
impl super::super::PluginWorkspace for Workspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.0.clone(), self.0.join("other")]
|
||||
}
|
||||
fn buffer_text(&self, _: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn document(&self, _: &Path) -> Option<(u64, String)> {
|
||||
None
|
||||
}
|
||||
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, ide_plugin::HostError> {
|
||||
unreachable!()
|
||||
}
|
||||
fn trusted(&self, _: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn project_of(&self, window: &str) -> Option<PathBuf> {
|
||||
match window {
|
||||
"main" => Some(self.0.clone()),
|
||||
"other" => Some(self.0.join("other")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn publish_diagnostics(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &Path,
|
||||
_: Vec<ide_core::Diagnostic>,
|
||||
) -> Result<(), ide_plugin::HostError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn host_shutdown_discards_queued_filesystem_batches() {
|
||||
use super::super::Consent;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let backend = crate::Host::new(crate::HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
backend.open_project("main", &root).unwrap();
|
||||
let active = backend.project("main").unwrap().files_events_active.clone();
|
||||
let plugins =
|
||||
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
|
||||
plugins.set_workspace(Some(Arc::new(Workspace(root.clone()))));
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
|
||||
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
|
||||
let batch = ide_vfs::FsBatch { created: vec![root.join("new.rs")], ..Default::default() };
|
||||
plugins.post_files_changed("main", &root, &active, &batch);
|
||||
assert!(!plugins.queues.lock().is_empty());
|
||||
backend.shutdown().await;
|
||||
plugins.flush_events();
|
||||
assert_eq!(plugins.instances(), 0, "host shutdown must discard queued filesystem batches");
|
||||
plugins.post_files_changed("main", &root, &active, &batch);
|
||||
assert!(plugins.queues.lock().is_empty(), "a retained watcher cannot post after host shutdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn producer_respects_project_incarnation_window_and_dev_root() {
|
||||
use super::super::{Consent, PluginSource};
|
||||
use std::time::Duration;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::create_dir(root.join("other")).unwrap();
|
||||
let host =
|
||||
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
|
||||
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
|
||||
host.set_project_policy(Arc::new(|_| true));
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
|
||||
host.load_dir(&fixture, PluginSource::Dev, Some(root.clone()));
|
||||
host.grant("files-events", &Consent::all()).unwrap();
|
||||
let active = Arc::new(AtomicBool::new(true));
|
||||
let batch = ide_vfs::FsBatch {
|
||||
created: vec![root.join("new.rs"), root.parent().unwrap().join("outside.rs")],
|
||||
..Default::default()
|
||||
};
|
||||
host.post_files_changed("closed", &root, &active, &batch);
|
||||
host.post_files_changed("main", &root.join("wrong"), &active, &batch);
|
||||
host.post_files_changed(
|
||||
"other",
|
||||
&root.join("other"),
|
||||
&active,
|
||||
&ide_vfs::FsBatch { modified: vec![root.join("other/a.rs")], ..Default::default() },
|
||||
);
|
||||
host.post_files_changed(
|
||||
"main",
|
||||
&root,
|
||||
&active,
|
||||
&ide_vfs::FsBatch {
|
||||
vcs: vec![root.join(".git/HEAD")],
|
||||
ignored: vec![root.join(".env")],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
host.flush_events();
|
||||
assert_eq!(host.instances(), 0, "unrelated windows, roots, and metadata do not wake a dev plugin");
|
||||
host.post_files_changed("main", &root, &active, &batch);
|
||||
active.store(false, Ordering::Release);
|
||||
host.flush_events();
|
||||
assert_eq!(host.instances(), 0, "closing before delivery discards the batch");
|
||||
host.post_files_changed("main", &root, &active, &batch);
|
||||
assert!(host.queues.lock().is_empty(), "a closing watcher cannot post more events");
|
||||
let reopened = Arc::new(AtomicBool::new(true));
|
||||
host.post_files_changed("main", &root, &reopened, &batch);
|
||||
host.flush_events();
|
||||
assert!(host.wait_events(Duration::from_secs(10)));
|
||||
let inv = ide_plugin::Invocation {
|
||||
client: Some("main".into()),
|
||||
workspace_root: Some(root.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let data = host.run_command("files-events", "events", None, inv).unwrap().unwrap();
|
||||
let received: Vec<FilesChanged> = serde_json::from_str(&data).unwrap();
|
||||
assert_eq!(received, [FilesChanged { created: vec![to_ipc(&root.join("new.rs"))], ..Default::default() }]);
|
||||
}
|
||||
#[test]
|
||||
fn queued_filter_replacement_and_pause_do_not_activate() {
|
||||
use super::super::{Consent, event_paths::PathFilter};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host =
|
||||
PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Default::default()).unwrap();
|
||||
host.set_workspace(Some(Arc::new(Workspace(root.clone()))));
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/files-events");
|
||||
host.install_from_dir(&fixture, &Consent::all()).unwrap();
|
||||
let active = Arc::new(AtomicBool::new(true));
|
||||
let filter = |globs: Vec<String>| {
|
||||
host.shared.subscriptions.lock().set_paths(
|
||||
"files-events",
|
||||
"files-changed",
|
||||
PathFilter::new("files-changed", globs).unwrap(),
|
||||
);
|
||||
};
|
||||
let batch = ide_vfs::FsBatch {
|
||||
created: vec![root.join("config/a.yaml"), root.join("src/unrelated.rs")],
|
||||
modified: vec![root.join("config/b.yaml")],
|
||||
removed: vec![root.join("config/c.yaml")],
|
||||
..Default::default()
|
||||
};
|
||||
filter(vec!["config/*.yaml".into()]);
|
||||
host.post_files_changed("main", &root, &active, &batch);
|
||||
assert!(!host.queues.lock().is_empty());
|
||||
filter(vec!["src/**".into()]);
|
||||
host.flush_events();
|
||||
assert_eq!(host.instances(), 0, "replacement excludes paths already queued");
|
||||
filter(vec!["config/*.yaml".into()]);
|
||||
host.post_files_changed("main", &root, &active, &batch);
|
||||
filter(vec![]);
|
||||
host.flush_events();
|
||||
assert_eq!(host.instances(), 0, "pausing suppresses a queued batch before activation");
|
||||
host.post_files_changed("main", &root, &active, &batch);
|
||||
assert!(host.queues.lock().is_empty(), "paused paths never queue work");
|
||||
}
|
||||
}
|
||||
@@ -83,23 +83,14 @@ pub async fn plugin_navigation_index(
|
||||
}
|
||||
(snapshot.text, Some(snapshot.version), snapshot.language)
|
||||
} else {
|
||||
use std::io::Read;
|
||||
if !std::fs::metadata(&path)
|
||||
.is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(&path) else {
|
||||
let Ok(loaded) = project.documents.load_file_bounded(&path, super::navigation::MAX_DOCUMENT_BYTES)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut text = String::new();
|
||||
if file
|
||||
.take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64)
|
||||
.read_to_string(&mut text)
|
||||
.is_err()
|
||||
{
|
||||
if loaded.binary || loaded.large {
|
||||
continue;
|
||||
}
|
||||
let text = loaded.text;
|
||||
let language = project.languages.detect(&path, text.lines().next());
|
||||
(text, None, language)
|
||||
};
|
||||
@@ -126,6 +117,7 @@ pub async fn plugin_navigation_index(
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
},
|
||||
)?;
|
||||
// A document edit while WASM was executing must not publish stale ranges.
|
||||
|
||||
@@ -24,6 +24,134 @@ pub const ACTIVE_FILE: &str = "active";
|
||||
/// `<root>/previous`: the version an update replaced (for rollback).
|
||||
pub const PREVIOUS_FILE: &str = "previous";
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(super) struct PendingUninstall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub flat: bool,
|
||||
pub secrets: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl PendingUninstall {
|
||||
fn validate(&self) -> io::Result<()> {
|
||||
if self.id.is_empty()
|
||||
|| self.id.starts_with('.')
|
||||
|| Path::new(&self.id).components().count() != 1
|
||||
|| !matches!(Path::new(&self.id).components().next(), Some(std::path::Component::Normal(_)))
|
||||
{
|
||||
return Err(io::ErrorKind::InvalidData.into());
|
||||
}
|
||||
version_dir(&self.version)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn package_dir(&self, root: &Path) -> PathBuf {
|
||||
let dir = root.join(&self.id);
|
||||
if self.flat { dir } else { dir.join(&self.version) }
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn check_not_symlink(path: &Path) -> io::Result<()> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) => {
|
||||
#[cfg(windows)]
|
||||
let reparse = {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
meta.file_attributes() & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let reparse = false;
|
||||
if meta.file_type().is_symlink() || reparse {
|
||||
Err(io::Error::other("refusing symlink in plugin cleanup path"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn check_cleanup_parent(root: &Path, path: &Path) -> io::Result<()> {
|
||||
let relative = path.strip_prefix(root).map_err(io::Error::other)?;
|
||||
let mut parent = root.to_path_buf();
|
||||
for part in relative.parent().unwrap_or(Path::new("")).components() {
|
||||
if !matches!(part, std::path::Component::Normal(_)) {
|
||||
return Err(io::Error::other("invalid plugin cleanup path"));
|
||||
}
|
||||
parent.push(part);
|
||||
check_not_symlink(&parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn uninstall_file(root: &Path, id: &str) -> PathBuf {
|
||||
root.join(".uninstall").join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
pub(super) fn remember_uninstall(root: &Path, pending: &PendingUninstall, dir: &Path) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
pending.validate()?;
|
||||
if pending.package_dir(root) != dir {
|
||||
return Err(io::ErrorKind::InvalidData.into());
|
||||
}
|
||||
let file = uninstall_file(root, &pending.id);
|
||||
check_not_symlink(file.parent().unwrap())?;
|
||||
std::fs::create_dir_all(file.parent().unwrap())?;
|
||||
check_not_symlink(&file)?;
|
||||
let mut out = tempfile::NamedTempFile::new_in(file.parent().unwrap())?;
|
||||
out.write_all(&serde_json::to_vec(pending)?)?;
|
||||
out.as_file().sync_all()?;
|
||||
out.persist(file).map(|_| ()).map_err(|e| e.error)
|
||||
}
|
||||
|
||||
pub(super) fn pending_uninstalls(root: &Path) -> Vec<PendingUninstall> {
|
||||
if check_not_symlink(&root.join(".uninstall")).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(root.join(".uninstall")) else { return Vec::new() };
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|ext| ext != "json") {
|
||||
return None;
|
||||
}
|
||||
let read = || -> io::Result<PendingUninstall> {
|
||||
check_not_symlink(&path)?;
|
||||
let pending: PendingUninstall = serde_json::from_slice(&std::fs::read(&path)?)?;
|
||||
pending.validate()?;
|
||||
if path != uninstall_file(root, &pending.id) {
|
||||
return Err(io::Error::from(io::ErrorKind::InvalidData));
|
||||
}
|
||||
Ok(pending)
|
||||
};
|
||||
match read() {
|
||||
Ok(pending) => Some(pending),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), "cannot recover pending plugin uninstall: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn uninstall_pending(root: &Path, id: &str) -> io::Result<bool> {
|
||||
uninstall_file(root, id).try_exists()
|
||||
}
|
||||
|
||||
pub(super) fn forget_uninstall(root: &Path, id: &str) -> io::Result<()> {
|
||||
check_not_symlink(&root.join(".uninstall"))?;
|
||||
match std::fs::remove_file(uninstall_file(root, id)) {
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
fn nonce() -> String {
|
||||
let n = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
format!("{n:x}-{}", std::process::id())
|
||||
@@ -219,6 +347,99 @@ pub fn recover(dir: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pending_uninstall_records_reject_paths_outside_the_package() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut pending = PendingUninstall {
|
||||
id: "p".into(),
|
||||
name: "Plugin".into(),
|
||||
version: "1.0.0".into(),
|
||||
flat: false,
|
||||
secrets: vec!["key".into()],
|
||||
error: None,
|
||||
};
|
||||
remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).unwrap();
|
||||
assert_eq!(pending_uninstalls(tmp.path())[0].secrets, ["key"]);
|
||||
for (id, version) in [
|
||||
("../outside", "1"),
|
||||
("/outside", "1"),
|
||||
(".data", "p"),
|
||||
("other", "1"),
|
||||
("p", "../outside"),
|
||||
("p", "1/nested"),
|
||||
] {
|
||||
pending.id = id.into();
|
||||
pending.version = version.into();
|
||||
std::fs::write(uninstall_file(tmp.path(), "p"), serde_json::to_vec(&pending).unwrap()).unwrap();
|
||||
assert!(pending_uninstalls(tmp.path()).is_empty(), "{id}/{version}");
|
||||
}
|
||||
pending.id = "invalid-1-0-0".into();
|
||||
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("other/1.0.0")).is_err());
|
||||
forget_uninstall(tmp.path(), "p").unwrap();
|
||||
assert!(pending_uninstalls(tmp.path()).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pending_uninstall_records_do_not_follow_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let pending = PendingUninstall {
|
||||
id: "p".into(),
|
||||
name: "Plugin".into(),
|
||||
version: "1.0.0".into(),
|
||||
flat: false,
|
||||
secrets: Vec::new(),
|
||||
error: None,
|
||||
};
|
||||
let record = outside.path().join("p.json");
|
||||
std::fs::write(&record, "untouched").unwrap();
|
||||
let parent = tmp.path().join(".uninstall");
|
||||
symlink(outside.path(), &parent).unwrap();
|
||||
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
|
||||
assert!(forget_uninstall(tmp.path(), "p").is_err());
|
||||
assert!(pending_uninstalls(tmp.path()).is_empty());
|
||||
std::fs::remove_file(&parent).unwrap();
|
||||
std::fs::create_dir(&parent).unwrap();
|
||||
symlink(&record, uninstall_file(tmp.path(), "p")).unwrap();
|
||||
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
|
||||
assert!(pending_uninstalls(tmp.path()).is_empty());
|
||||
forget_uninstall(tmp.path(), "p").unwrap();
|
||||
symlink(&record, parent.join("p.tmp")).unwrap();
|
||||
remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).unwrap();
|
||||
assert_eq!(pending_uninstalls(tmp.path()).len(), 1);
|
||||
assert_eq!(std::fs::read_to_string(&record).unwrap(), "untouched");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn pending_uninstall_records_do_not_follow_junctions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let record = outside.path().join("p.json");
|
||||
std::fs::write(&record, "untouched").unwrap();
|
||||
let status = std::process::Command::new("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.arg(tmp.path().join(".uninstall"))
|
||||
.arg(outside.path())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let pending = PendingUninstall {
|
||||
id: "p".into(),
|
||||
name: "Plugin".into(),
|
||||
version: "1.0.0".into(),
|
||||
flat: false,
|
||||
secrets: Vec::new(),
|
||||
error: None,
|
||||
};
|
||||
assert!(remember_uninstall(tmp.path(), &pending, &tmp.path().join("p/1.0.0")).is_err());
|
||||
assert!(forget_uninstall(tmp.path(), "p").is_err());
|
||||
assert!(pending_uninstalls(tmp.path()).is_empty());
|
||||
assert_eq!(std::fs::read_to_string(record).unwrap(), "untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_swap_keeps_the_old_version() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod commit;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
mod files_changed;
|
||||
mod hover;
|
||||
mod icon_extensions;
|
||||
mod icon_patterns;
|
||||
@@ -114,6 +115,7 @@ struct Links {
|
||||
}
|
||||
|
||||
struct Meta {
|
||||
uninstalling: bool,
|
||||
status: PluginStatus,
|
||||
error: Option<String>,
|
||||
warnings: Vec<String>,
|
||||
@@ -141,6 +143,7 @@ struct Meta {
|
||||
impl Meta {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
uninstalling: false,
|
||||
status: PluginStatus::Disabled,
|
||||
error: None,
|
||||
warnings: Vec::new(),
|
||||
@@ -187,6 +190,7 @@ const LATENCY_WINDOW: usize = 128;
|
||||
struct EventQueue {
|
||||
since: Instant,
|
||||
events: Vec<WorkbenchEvent>,
|
||||
files_source: Option<files_changed::Source>,
|
||||
}
|
||||
|
||||
/// Most events queued per plugin and window; older ones are dropped.
|
||||
@@ -217,6 +221,39 @@ struct Batches {
|
||||
pending: u32,
|
||||
/// Kinds of the batch being handled right now.
|
||||
running: Option<BTreeSet<EventKind>>,
|
||||
files_pending: HashMap<FilesQueueKey, PendingFiles>,
|
||||
running_token: Option<Arc<()>>,
|
||||
}
|
||||
|
||||
type FilesQueueKey = (Option<String>, Option<PathBuf>);
|
||||
|
||||
struct PendingFiles {
|
||||
queue: EventQueue,
|
||||
actor: std::sync::Weak<Actor>,
|
||||
token: Arc<()>,
|
||||
}
|
||||
|
||||
/// Dropping a posted job must release its mailbox even when the actor never runs it.
|
||||
struct BatchTicket {
|
||||
batches: Arc<Mutex<Batches>>,
|
||||
files_key: Option<FilesQueueKey>,
|
||||
token: Arc<()>,
|
||||
}
|
||||
|
||||
impl Drop for BatchTicket {
|
||||
fn drop(&mut self) {
|
||||
let mut batches = self.batches.lock();
|
||||
if let Some(key) = &self.files_key
|
||||
&& batches.files_pending.get(key).is_some_and(|p| Arc::ptr_eq(&p.token, &self.token))
|
||||
{
|
||||
batches.files_pending.remove(key);
|
||||
}
|
||||
batches.pending = batches.pending.saturating_sub(1);
|
||||
if batches.running_token.as_ref().is_some_and(|token| Arc::ptr_eq(token, &self.token)) {
|
||||
batches.running = None;
|
||||
batches.running_token = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
@@ -457,6 +494,47 @@ impl PluginHost {
|
||||
/// Instantiates no Wasm: code runs on its activation events.
|
||||
pub fn load_all(&self) {
|
||||
let _guard = self.lifecycle.lock();
|
||||
for pending in install::pending_uninstalls(&self.plugins_dir) {
|
||||
if self.plugins.read().contains_key(&pending.id) {
|
||||
continue;
|
||||
}
|
||||
let dir = pending.package_dir(&self.plugins_dir);
|
||||
let mut manifest = placeholder_manifest(&dir);
|
||||
manifest.id = pending.id.clone();
|
||||
manifest.name = pending.name;
|
||||
manifest.version = pending.version;
|
||||
manifest.contributes.insert(
|
||||
providers::SECRETS_POINT.into(),
|
||||
toml::Value::Array(
|
||||
pending
|
||||
.secrets
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
toml::Value::Table(toml::Table::from_iter([("name".into(), toml::Value::String(name))]))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
let mut meta = Meta::new();
|
||||
meta.status = PluginStatus::Failed;
|
||||
meta.uninstalling = true;
|
||||
meta.error = Some(
|
||||
pending.error.unwrap_or_else(|| "Uninstall incomplete; retry Uninstall to finish cleanup.".into()),
|
||||
);
|
||||
self.plugins.write().insert(
|
||||
pending.id,
|
||||
Arc::new(LoadedPlugin {
|
||||
pkg: Package::new(manifest, dir),
|
||||
icon: None,
|
||||
invalid: false,
|
||||
source: PluginSource::Installed,
|
||||
dev_root: None,
|
||||
meta: Mutex::new(meta),
|
||||
batches: Arc::default(),
|
||||
runtime: Mutex::new(None),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let dirs = package_dirs(&self.plugins_dir);
|
||||
self.migrate_grants(&dirs);
|
||||
for dir in dirs {
|
||||
@@ -722,6 +800,7 @@ impl PluginHost {
|
||||
pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
{
|
||||
let mut state = self.state.lock();
|
||||
if enabled {
|
||||
@@ -745,6 +824,7 @@ impl PluginHost {
|
||||
pub fn reload(&self, id: &str) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
let (dir, source, root) = (plugin.dir().to_path_buf(), plugin.source, plugin.dev_root.clone());
|
||||
self.unload(id);
|
||||
let new_id = self.load_dir(&dir, source, root);
|
||||
@@ -806,6 +886,11 @@ impl PluginHost {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let (pkg, set) = self.validate_package(src)?;
|
||||
let id = pkg.id().to_owned();
|
||||
if let Some(plugin) = self.plugins.read().get(&id) {
|
||||
self.check_not_uninstalling(plugin)?;
|
||||
} else {
|
||||
self.check_uninstall_pending(&id)?;
|
||||
}
|
||||
if let Some(existing) = self.plugins.read().get(&id)
|
||||
&& existing.source != PluginSource::Installed
|
||||
{
|
||||
@@ -880,6 +965,13 @@ impl PluginHost {
|
||||
/// Makes `previous` the active version of `id` again and drops `failed`
|
||||
/// (its directory, its consent). Called with the plugin unloaded or not.
|
||||
fn roll_back(&self, id: &str, previous: &str, failed: &str, grant: Option<ledger::Grant>) {
|
||||
let ready = match self.get(id) {
|
||||
Ok(plugin) => self.check_not_uninstalling(&plugin),
|
||||
Err(_) => self.check_uninstall_pending(id),
|
||||
};
|
||||
if ready.is_err() {
|
||||
return;
|
||||
}
|
||||
let root = self.plugins_dir.join(id);
|
||||
self.unload(id);
|
||||
if let Err(e) = install::activate_version(&root, previous) {
|
||||
@@ -934,6 +1026,7 @@ impl PluginHost {
|
||||
pub fn roll_back_update(&self, id: &str) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
if plugin.source != PluginSource::Installed {
|
||||
return Err(IdeError::invalid("only installed plugins have versions to roll back to"));
|
||||
}
|
||||
@@ -952,6 +1045,7 @@ impl PluginHost {
|
||||
pub fn grant(&self, id: &str, consent: &Consent) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
if plugin.invalid {
|
||||
return Err(IdeError::invalid("the plugin's manifest is invalid"));
|
||||
}
|
||||
@@ -972,6 +1066,7 @@ impl PluginHost {
|
||||
pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
|
||||
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
|
||||
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
|
||||
@@ -1010,6 +1105,13 @@ impl PluginHost {
|
||||
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))
|
||||
}
|
||||
|
||||
fn check_uninstall_pending(&self, id: &str) -> IdeResult<()> {
|
||||
if install::uninstall_pending(&self.plugins_dir, id)? {
|
||||
return Err(IdeError::unavailable(std::io::Error::from(std::io::ErrorKind::ResourceBusy).to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn uninstall(&self, id: &str) -> IdeResult<()> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
@@ -1028,21 +1130,65 @@ impl PluginHost {
|
||||
.flatten()
|
||||
.filter_map(|s| s.get("name")?.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
self.unload(id);
|
||||
let mut pending = install::PendingUninstall {
|
||||
id: id.to_owned(),
|
||||
name: plugin.manifest().name.clone(),
|
||||
version: plugin.manifest().version.clone(),
|
||||
flat: plugin.dir() == self.plugins_dir.join(id),
|
||||
secrets: secrets.clone(),
|
||||
error: None,
|
||||
};
|
||||
// A broken manifest has no trusted identity to journal; keep its existing removal path.
|
||||
if !plugin.invalid {
|
||||
install::remember_uninstall(&self.plugins_dir, &pending, plugin.dir())?;
|
||||
}
|
||||
// Retain registration for retry, but prevent concurrent on-demand activation.
|
||||
{
|
||||
let _runtime = plugin.runtime.lock();
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.uninstalling = true;
|
||||
meta.status = PluginStatus::Disabled;
|
||||
}
|
||||
self.stop(&plugin);
|
||||
let cleanup = || -> std::io::Result<()> {
|
||||
install::check_cleanup_parent(&self.plugins_dir, &self.shared.data_dir.join(id))?;
|
||||
install::check_cleanup_parent(&self.plugins_dir, plugin.dir())?;
|
||||
install::check_not_symlink(&self.shared.data_dir.join(id))?;
|
||||
for file in ["secrets.json", "secrets.tmp"] {
|
||||
install::check_not_symlink(&self.shared.data_dir.join(id).join(file))?;
|
||||
}
|
||||
let store = self.shared.secrets.read().clone();
|
||||
for name in &secrets {
|
||||
if let Err(e) = store.set(id, name, None) {
|
||||
tracing::warn!(plugin = %id, "cannot delete secret {name:?}: {e}");
|
||||
return Err(std::io::Error::other(format!("cannot delete secret {name:?}: {e}")));
|
||||
}
|
||||
}
|
||||
// Keep the installed package and registration available until cleanup succeeds.
|
||||
let data = self.shared.data_dir.join(id);
|
||||
match std::fs::remove_dir_all(&data) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !data.exists() => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
// Every version (`<id>/<version>`), or the flat directory of an older install.
|
||||
let target = self.plugins_dir.join(id);
|
||||
match std::fs::remove_dir_all(&target) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !target.exists() => Ok(()),
|
||||
result => result,
|
||||
}
|
||||
};
|
||||
if let Err(e) = cleanup() {
|
||||
pending.error = Some(e.to_string());
|
||||
if !plugin.invalid
|
||||
&& let Err(record_error) = install::remember_uninstall(&self.plugins_dir, &pending, plugin.dir())
|
||||
{
|
||||
tracing::warn!(plugin = %id, "cannot save uninstall error: {record_error}");
|
||||
}
|
||||
self.set_failed(&plugin, e.to_string());
|
||||
self.changed();
|
||||
return Err(e.into());
|
||||
}
|
||||
// Every version (`<id>/<version>`), or the flat directory of an older install.
|
||||
let root = self.plugins_dir.join(id);
|
||||
std::fs::remove_dir_all(if root.is_dir() { root } else { plugin.dir().to_path_buf() })?;
|
||||
self.updates.lock().remove(id);
|
||||
// Its storage goes with it.
|
||||
let _ = std::fs::remove_dir_all(self.shared.data_dir.join(id));
|
||||
{
|
||||
let mut ledger = self.ledger.lock();
|
||||
ledger.remove(&plugin.subject());
|
||||
@@ -1056,6 +1202,12 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
self.save_state();
|
||||
if let Err(e) = install::forget_uninstall(&self.plugins_dir, id) {
|
||||
self.set_failed(&plugin, e.to_string());
|
||||
self.changed();
|
||||
return Err(e.into());
|
||||
}
|
||||
self.unload(id);
|
||||
self.changed();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1457,7 +1609,7 @@ impl PluginHost {
|
||||
let mut out = BTreeSet::new();
|
||||
for p in self.plugins.read().values() {
|
||||
for kind in EventKind::ALL {
|
||||
if self.wants(p, kind, subs.kinds.get(p.id())) {
|
||||
if Self::wants(p, kind, subs.kinds.get(p.id())) {
|
||||
out.insert(kind);
|
||||
}
|
||||
}
|
||||
@@ -1467,7 +1619,7 @@ impl PluginHost {
|
||||
|
||||
/// Whether `plugin` gets `kind` events: subscribed at run time or with
|
||||
/// `on-event:<kind>`, the capability granted, and able to run.
|
||||
fn wants(&self, plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet<EventKind>>) -> bool {
|
||||
fn wants(plugin: &LoadedPlugin, kind: EventKind, runtime: Option<&BTreeSet<EventKind>>) -> bool {
|
||||
let declared =
|
||||
plugin.manifest().activation.iter().any(|a| matches!(a, ActivationEvent::Event(k) if k == kind.name()));
|
||||
if !declared && !runtime.is_some_and(|s| s.contains(&kind)) {
|
||||
@@ -1481,6 +1633,15 @@ impl PluginHost {
|
||||
/// Queues workbench events from window `client` for the plugins that want
|
||||
/// them; [`flush_events`](Self::flush_events) delivers them in batches.
|
||||
pub fn post_events(&self, client: Option<&str>, events: Vec<WorkbenchEvent>) {
|
||||
self.post_events_from(client, events, None);
|
||||
}
|
||||
|
||||
fn post_events_from(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
events: Vec<WorkbenchEvent>,
|
||||
source: Option<files_changed::Source>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -1488,32 +1649,60 @@ impl PluginHost {
|
||||
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
|
||||
let now = Instant::now();
|
||||
let mut queues = self.queues.lock();
|
||||
let mut events = events;
|
||||
files_changed::retain_live(&mut events, source.as_ref());
|
||||
for p in plugins {
|
||||
let wanted: Vec<WorkbenchEvent> =
|
||||
events.iter().filter(|e| self.wants(&p, e.kind(), subs.kinds.get(p.id()))).cloned().collect();
|
||||
let wanted = self.filter_path_events(&p, client, &wanted, &subs);
|
||||
let wanted: Vec<WorkbenchEvent> = events
|
||||
.iter()
|
||||
.filter(|e| Self::wants(&p, e.kind(), subs.kinds.get(p.id())))
|
||||
.filter(|e| {
|
||||
!matches!(e, WorkbenchEvent::FilesChanged(_))
|
||||
|| p.dev_root
|
||||
.as_ref()
|
||||
.is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs);
|
||||
if wanted.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let queue = queues
|
||||
.entry((p.id().to_owned(), client.map(str::to_owned)))
|
||||
.or_insert_with(|| EventQueue { since: now, events: Vec::new() });
|
||||
queue.events.extend(wanted);
|
||||
let queue = queues.entry((p.id().to_owned(), client.map(str::to_owned))).or_insert_with(|| EventQueue {
|
||||
since: now,
|
||||
events: Vec::new(),
|
||||
files_source: None,
|
||||
});
|
||||
if let Some(source) = &source {
|
||||
if !queue.files_source.as_ref().is_some_and(|old| old.same(source)) {
|
||||
queue.events.retain(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)));
|
||||
}
|
||||
queue.files_source = Some(source.clone());
|
||||
}
|
||||
files_changed::append(&mut queue.events, wanted);
|
||||
let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
|
||||
queue.events.drain(..excess);
|
||||
let mut remaining = excess;
|
||||
queue.events.retain(|event| {
|
||||
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
|
||||
remaining -= 1;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivers queued events: to running plugins right away, to idle ones
|
||||
/// (which must be activated) after [`IDLE_BATCH`], so frequent events
|
||||
/// don't keep waking them. Call it a few times a second.
|
||||
/// don't keep waking them. Filesystem events are already debounced by
|
||||
/// the watcher and bypass that delay. Call it a few times a second.
|
||||
pub fn flush_events(&self) {
|
||||
self.flush_events_at(Instant::now());
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn flush_events_at(&self, now: Instant) {
|
||||
let due: Vec<(QueueKey, Vec<WorkbenchEvent>)> = {
|
||||
let due: Vec<(QueueKey, EventQueue)> = {
|
||||
let mut queues = self.queues.lock();
|
||||
let plugins = self.plugins.read();
|
||||
let keys: Vec<_> = queues
|
||||
@@ -1524,15 +1713,17 @@ impl PluginHost {
|
||||
running
|
||||
|| now.saturating_duration_since(q.since) >= IDLE_BATCH
|
||||
|| q.events.len() >= MAX_QUEUED_EVENTS / 2
|
||||
|| q.events.contains(&WorkbenchEvent::Scheduled)
|
||||
|| q.events
|
||||
.iter()
|
||||
.any(|e| matches!(e, WorkbenchEvent::FilesChanged(_) | WorkbenchEvent::Scheduled))
|
||||
})
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q.events))).collect()
|
||||
keys.into_iter().filter_map(|k| queues.remove(&k).map(|q| (k, q))).collect()
|
||||
};
|
||||
let mut changed = false;
|
||||
for ((id, client), events) in due {
|
||||
changed |= self.deliver(&id, client, events);
|
||||
for ((id, client), queue) in due {
|
||||
changed |= self.deliver(&id, client, queue);
|
||||
}
|
||||
// Batches that crashed since.
|
||||
let plugins: Vec<_> = self.plugins.read().values().cloned().collect();
|
||||
@@ -1565,22 +1756,50 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_delivery_events(
|
||||
shared: &api::Shared,
|
||||
plugin: &LoadedPlugin,
|
||||
client: Option<&str>,
|
||||
events: &[WorkbenchEvent],
|
||||
subs: &event_paths::Subscriptions,
|
||||
) -> Vec<WorkbenchEvent> {
|
||||
Self::filter_path_events(shared, plugin, client, events, subs)
|
||||
.into_iter()
|
||||
.filter(|event| {
|
||||
!matches!(
|
||||
event,
|
||||
WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_) | WorkbenchEvent::FilesChanged(_)
|
||||
) || Self::wants(plugin, event.kind(), subs.kinds.get(plugin.id()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Hands one batch to the plugin's `event-handler.handle` without waiting
|
||||
/// for it. A newer batch of the same kinds cooperatively cancels the one
|
||||
/// being handled (a save while the previous save is still being checked).
|
||||
/// Filesystem deltas must all run: a newer delta cannot replace an older one.
|
||||
/// Returns whether the plugin was activated.
|
||||
fn deliver(&self, id: &str, client: Option<String>, events: Vec<WorkbenchEvent>) -> bool {
|
||||
fn deliver(&self, id: &str, client: Option<String>, mut queue: EventQueue) -> bool {
|
||||
files_changed::retain_live(&mut queue.events, queue.files_source.as_ref());
|
||||
if queue.events.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if queue.events.iter().any(|e| matches!(e, WorkbenchEvent::FilesChanged(_)))
|
||||
&& queue.events.iter().any(|e| !matches!(e, WorkbenchEvent::FilesChanged(_)))
|
||||
{
|
||||
let (files, other) = queue.events.into_iter().partition(|e| matches!(e, WorkbenchEvent::FilesChanged(_)));
|
||||
let changed =
|
||||
self.deliver(id, client.clone(), EventQueue { since: queue.since, events: other, files_source: None });
|
||||
return self.deliver(
|
||||
id,
|
||||
client,
|
||||
EventQueue { since: queue.since, events: files, files_source: queue.files_source },
|
||||
) | changed;
|
||||
}
|
||||
let Ok(plugin) = self.get(id) else { return false };
|
||||
let subs = self.shared.subscriptions.lock().clone();
|
||||
let events: Vec<_> = self
|
||||
.filter_path_events(&plugin, client.as_deref(), &events, &subs)
|
||||
.into_iter()
|
||||
.filter(|event| {
|
||||
!matches!(event, WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_))
|
||||
|| self.wants(&plugin, event.kind(), subs.kinds.get(id))
|
||||
})
|
||||
.collect();
|
||||
if events.is_empty() {
|
||||
let eligible = Self::filter_delivery_events(&self.shared, &plugin, client.as_deref(), &queue.events, &subs);
|
||||
if eligible.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if !plugin.meta.lock().pending.is_empty() {
|
||||
@@ -1592,21 +1811,94 @@ impl PluginHost {
|
||||
client: client.clone(),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
};
|
||||
let kinds: BTreeSet<EventKind> = events.iter().map(WorkbenchEvent::kind).collect();
|
||||
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
|
||||
let filesystem = kinds.contains(&EventKind::FilesChanged);
|
||||
let mailbox_key = (client.clone(), invocation.workspace_root.clone());
|
||||
let mut ordinary = None;
|
||||
let token = Arc::new(());
|
||||
{
|
||||
let mut batches = plugin.batches.lock();
|
||||
if batches.running.as_ref() == Some(&kinds) && !kinds.contains(&EventKind::Scheduled) {
|
||||
if batches.running.as_ref() == Some(&kinds)
|
||||
&& !kinds.contains(&EventKind::Scheduled)
|
||||
&& !kinds.contains(&EventKind::FilesChanged)
|
||||
{
|
||||
actor.cancel_soft();
|
||||
}
|
||||
if filesystem {
|
||||
files_changed::retain_live(&mut queue.events, queue.files_source.as_ref());
|
||||
if queue.events.is_empty() {
|
||||
return activated;
|
||||
}
|
||||
if let Some(pending) = batches.files_pending.get_mut(&mailbox_key)
|
||||
&& pending.actor.upgrade().is_some_and(|owner| Arc::ptr_eq(&owner, &actor))
|
||||
{
|
||||
let pending = &mut pending.queue;
|
||||
if queue
|
||||
.files_source
|
||||
.as_ref()
|
||||
.is_some_and(|source| !pending.files_source.as_ref().is_some_and(|old| old.same(source)))
|
||||
{
|
||||
pending.events.clear();
|
||||
pending.files_source = queue.files_source.clone();
|
||||
}
|
||||
files_changed::append(&mut pending.events, queue.events);
|
||||
return activated;
|
||||
}
|
||||
batches.files_pending.insert(
|
||||
mailbox_key.clone(),
|
||||
PendingFiles { queue, actor: Arc::downgrade(&actor), token: token.clone() },
|
||||
);
|
||||
} else {
|
||||
ordinary = Some(queue);
|
||||
}
|
||||
batches.pending += 1;
|
||||
}
|
||||
let started = Instant::now();
|
||||
let (start, finish) = (plugin.batches.clone(), plugin.batches.clone());
|
||||
let start = plugin.batches.clone();
|
||||
let ticket = BatchTicket {
|
||||
batches: plugin.batches.clone(),
|
||||
files_key: filesystem.then(|| mailbox_key.clone()),
|
||||
token: token.clone(),
|
||||
};
|
||||
let done_plugin = plugin.clone();
|
||||
let delivery_plugin = plugin.clone();
|
||||
let shared = self.shared.clone();
|
||||
actor.post(
|
||||
move |rt| {
|
||||
start.lock().running = Some(kinds);
|
||||
let _ticket = ticket;
|
||||
let mut queue = {
|
||||
let mut batches = start.lock();
|
||||
let queue = if filesystem {
|
||||
if !batches.files_pending.get(&mailbox_key).is_some_and(|p| Arc::ptr_eq(&p.token, &token)) {
|
||||
return Ok(());
|
||||
}
|
||||
batches.files_pending.remove(&mailbox_key).expect("owned filesystem batch").queue
|
||||
} else {
|
||||
ordinary.expect("ordinary event batch")
|
||||
};
|
||||
batches.running = Some(kinds);
|
||||
batches.running_token = Some(token);
|
||||
queue
|
||||
};
|
||||
files_changed::retain_live(&mut queue.events, queue.files_source.as_ref());
|
||||
if filesystem
|
||||
&& invocation.workspace_root != invocation.client.as_deref().and_then(|c| shared.project_of(c))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let subs = shared.subscriptions.lock().clone();
|
||||
let events = Self::filter_delivery_events(
|
||||
&shared,
|
||||
&delivery_plugin,
|
||||
invocation.client.as_deref(),
|
||||
&queue.events,
|
||||
&subs,
|
||||
);
|
||||
if events.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
rt.handle_events(events, invocation)
|
||||
},
|
||||
move |result| {
|
||||
@@ -1623,9 +1915,6 @@ impl PluginHost {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut batches = finish.lock();
|
||||
batches.pending = batches.pending.saturating_sub(1);
|
||||
batches.running = None;
|
||||
},
|
||||
);
|
||||
activated
|
||||
@@ -1823,7 +2112,7 @@ impl PluginHost {
|
||||
self.queues
|
||||
.lock()
|
||||
.entry((p.id().to_owned(), None))
|
||||
.or_insert_with(|| EventQueue { since: now, events: Vec::new() })
|
||||
.or_insert_with(|| EventQueue { since: now, events: Vec::new(), files_source: None })
|
||||
.events
|
||||
.push(WorkbenchEvent::Scheduled);
|
||||
}
|
||||
@@ -1891,7 +2180,17 @@ impl PluginHost {
|
||||
fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option<PathBuf>) -> String {
|
||||
let (manifest, error) = match Manifest::load(dir) {
|
||||
Ok(m) => (m, None),
|
||||
Err(e) => (placeholder_manifest(dir), Some(e)),
|
||||
Err(e) => {
|
||||
let mut manifest = placeholder_manifest(dir);
|
||||
// Installed directory names retain identity when discovery metadata is damaged.
|
||||
if source == PluginSource::Installed
|
||||
&& let Ok(relative) = dir.strip_prefix(&self.plugins_dir)
|
||||
&& let Some(name) = relative.components().next().and_then(|p| p.as_os_str().to_str())
|
||||
{
|
||||
manifest.id = name.to_owned();
|
||||
}
|
||||
(manifest, Some(e))
|
||||
}
|
||||
};
|
||||
let invalid = error.is_some();
|
||||
let id = manifest.id.clone();
|
||||
@@ -1941,9 +2240,25 @@ impl PluginHost {
|
||||
self.stop(&plugin);
|
||||
}
|
||||
|
||||
fn check_not_uninstalling(&self, plugin: &LoadedPlugin) -> IdeResult<()> {
|
||||
if plugin.meta.lock().uninstalling {
|
||||
return Err(IdeError::unavailable("Uninstall incomplete; retry Uninstall to finish cleanup."));
|
||||
}
|
||||
if plugin.source == PluginSource::Installed {
|
||||
self.check_uninstall_pending(plugin.id())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates, checks grants and commits the contributions; activates code
|
||||
/// whose activation event already fired.
|
||||
fn start(&self, plugin: &Arc<LoadedPlugin>) {
|
||||
if let Err(e) = self.check_not_uninstalling(plugin) {
|
||||
if !plugin.meta.lock().uninstalling {
|
||||
self.set_failed(plugin, e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
self.clear_provider_diagnostics(plugin.id());
|
||||
if plugin.source == PluginSource::Installed
|
||||
@@ -2003,6 +2318,7 @@ impl PluginHost {
|
||||
/// first if needed. Returns whether it was activated now.
|
||||
fn ensure_active(&self, plugin: &Arc<LoadedPlugin>, client: Option<&str>) -> IdeResult<(Arc<Actor>, bool)> {
|
||||
let mut runtime = plugin.runtime.lock();
|
||||
self.check_not_uninstalling(plugin)?;
|
||||
if let Some(actor) = runtime.as_ref() {
|
||||
if actor.is_alive() {
|
||||
return Ok((actor.clone(), false));
|
||||
|
||||
@@ -28,6 +28,7 @@ pub use ide_plugin::indexing::*;
|
||||
struct File {
|
||||
version: Option<u32>,
|
||||
scope: String,
|
||||
hash: blake3::Hash,
|
||||
edges: Edges,
|
||||
}
|
||||
#[derive(Default)]
|
||||
@@ -61,6 +62,11 @@ impl Graph {
|
||||
}
|
||||
targets
|
||||
}
|
||||
fn scoped_targets(&self, key: &str, scope: &str) -> Vec<&Location> {
|
||||
let targets = self.targets(key);
|
||||
let local = targets.iter().any(|t| self.files.get(&t.path.0).is_some_and(|f| f.scope == scope));
|
||||
targets.into_iter().filter(|t| !local || self.files.get(&t.path.0).is_some_and(|f| f.scope == scope)).collect()
|
||||
}
|
||||
fn remove(&mut self, path: &Path) {
|
||||
if let Some(file) = self.files.remove(path) {
|
||||
self.edges -= file.edges.definitions.len()
|
||||
@@ -303,7 +309,7 @@ impl PluginHost {
|
||||
{
|
||||
return Err(IdeError::unavailable("navigation association limit reached"));
|
||||
}
|
||||
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges });
|
||||
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), hash: snapshot_hash(text), edges });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -365,6 +371,70 @@ impl PluginHost {
|
||||
}
|
||||
result.filter(|r| !r.targets.is_empty())
|
||||
}
|
||||
/// Definitions and references of each indexed key under the caret. Markers
|
||||
/// are navigation affordances, not name anchors, and never participate.
|
||||
pub fn indexed_symbol_at(&self, root: &Path, path: &Path, pos: Pos, client: Option<&str>) -> Option<IndexedSymbol> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return None;
|
||||
}
|
||||
let plugins = self.plugins.read();
|
||||
let index = self.navigation.lock();
|
||||
let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?;
|
||||
let mut result: Option<IndexedSymbol> = None;
|
||||
for (id, graph) in graphs {
|
||||
let Some(plugin) = plugins.get(id) else { continue };
|
||||
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
|
||||
continue;
|
||||
}
|
||||
let Some(source) = graph.files.get(path) else { continue };
|
||||
let anchors: Vec<_> = source
|
||||
.edges
|
||||
.definitions
|
||||
.iter()
|
||||
.chain(&source.edges.references)
|
||||
.filter(|anchor| contains(anchor.range, pos))
|
||||
.collect();
|
||||
let Some(primary) = anchors.first() else { continue };
|
||||
let keys: std::collections::HashSet<_> = anchors.iter().map(|anchor| anchor.key.as_str()).collect();
|
||||
let targets: std::collections::HashSet<_> =
|
||||
keys.iter().flat_map(|key| graph.scoped_targets(key, &source.scope)).collect();
|
||||
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
|
||||
for (path, file) in &graph.files {
|
||||
let mut ranges = std::collections::BTreeSet::new();
|
||||
for candidate in &file.edges.definitions {
|
||||
if targets.contains(&Location::file(path.clone(), candidate.range))
|
||||
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
|
||||
{
|
||||
ranges.insert(candidate.range);
|
||||
}
|
||||
}
|
||||
let mut matches = HashMap::new();
|
||||
for candidate in &file.edges.references {
|
||||
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
|
||||
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
|
||||
resolved.iter().any(|target| targets.contains(target))
|
||||
|| (targets.is_empty()
|
||||
&& resolved.is_empty()
|
||||
&& file.scope == source.scope
|
||||
&& keys.contains(candidate.key.as_str()))
|
||||
});
|
||||
if same {
|
||||
ranges.insert(candidate.range);
|
||||
}
|
||||
}
|
||||
if !ranges.is_empty() {
|
||||
answer.files.push(IndexedAnchors {
|
||||
path: path.clone(),
|
||||
version: file.version,
|
||||
hash: file.hash,
|
||||
ranges: ranges.into_iter().collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Gutter markers of visible files. Uses only existing graph entries; never executes WASM.
|
||||
pub fn navigation_markers(
|
||||
&self,
|
||||
@@ -423,6 +493,24 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IndexedSymbol {
|
||||
pub range: Range,
|
||||
pub files: Vec<IndexedAnchors>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IndexedAnchors {
|
||||
pub path: PathBuf,
|
||||
pub version: Option<u32>,
|
||||
pub hash: blake3::Hash,
|
||||
pub ranges: Vec<Range>,
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot_hash(text: &str) -> blake3::Hash {
|
||||
blake3::hash(ide_text::normalize_line_endings(text).as_bytes())
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
|
||||
@@ -31,6 +31,7 @@ use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::{MANIFEST_FILE, ledger};
|
||||
use ide_jsonrpc::Peer;
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -117,6 +118,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
| PluginEvent::OpenLogTab { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
| PluginEvent::OpenProject { .. }
|
||||
@@ -376,11 +378,13 @@ impl RemotePlugins {
|
||||
let arg: Option<String> = c.arg("arg")?;
|
||||
let editor: Option<PluginEditor> = c.arg("editor")?;
|
||||
let paths: Option<Vec<PathBuf>> = c.arg("paths")?;
|
||||
let settings_scope: Option<SettingsScope> = c.arg("settingsScope")?;
|
||||
let invocation = Invocation {
|
||||
client: Some(c.window.clone()),
|
||||
workspace_root: c.host.project(&c.window).ok().map(|p| p.root.clone()),
|
||||
editor: editor.map(PluginEditor::context),
|
||||
selected_paths: paths.unwrap_or_default(),
|
||||
global_settings: matches!(settings_scope, Some(SettingsScope::Global)),
|
||||
};
|
||||
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
|
||||
}
|
||||
|
||||
@@ -224,6 +224,10 @@ pub enum PluginEvent {
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
|
||||
/// Open (or focus) the log tab `key` following `log` (`views.open-log-tab`);
|
||||
/// it starts the program with `plugin_log_tab_spawn`.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenLogTab { plugin_id: String, key: String, title: String, log: ide_plugin::ServiceLog },
|
||||
/// Plugin languages, or the servers they may start, changed: re-fetch
|
||||
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
LanguagesChanged,
|
||||
|
||||
@@ -283,12 +283,60 @@ pub(super) fn status_item(pkg: &Package, decl: toml::Value) -> Result<UiDecl, St
|
||||
struct SettingsDecl {
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
schema: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pages: Vec<SettingsPage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SettingsPage {
|
||||
id: String,
|
||||
title: String,
|
||||
schema: PathBuf,
|
||||
#[serde(default)]
|
||||
actions: Vec<SettingsAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SettingsAction {
|
||||
title: String,
|
||||
command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SchemaFile {
|
||||
properties: serde_json::Map<String, Value>,
|
||||
#[serde(deserialize_with = "ordered_properties")]
|
||||
properties: Vec<(String, Box<serde_json::value::RawValue>)>,
|
||||
}
|
||||
|
||||
// Preserve schema field order locally, without changing serde_json maps throughout the host.
|
||||
fn ordered_properties<'de, D>(deserializer: D) -> Result<Vec<(String, Box<serde_json::value::RawValue>)>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct Properties;
|
||||
impl<'de> serde::de::Visitor<'de> for Properties {
|
||||
type Value = Vec<(String, Box<serde_json::value::RawValue>)>;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("an object of setting schemas")
|
||||
}
|
||||
|
||||
fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
|
||||
let mut fields = Vec::new();
|
||||
let mut keys = BTreeSet::new();
|
||||
while let Some((key, value)) = map.next_entry::<String, Box<serde_json::value::RawValue>>()? {
|
||||
if !keys.insert(key.clone()) {
|
||||
return Err(serde::de::Error::custom(format!("duplicate setting key {key:?}")));
|
||||
}
|
||||
fields.push((key, value));
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Properties)
|
||||
}
|
||||
|
||||
/// One setting of a plugin.
|
||||
@@ -296,7 +344,7 @@ struct SchemaFile {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingSchema {
|
||||
pub key: String,
|
||||
/// `boolean`, `string`, `number` or `integer`.
|
||||
/// A scalar, top-level `array`, or `object` table row.
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
@@ -307,6 +355,12 @@ pub struct SettingSchema {
|
||||
pub options: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub multiline: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub items: Option<Box<SettingSchema>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub properties: Vec<SettingSchema>,
|
||||
}
|
||||
|
||||
impl SettingSchema {
|
||||
@@ -316,6 +370,13 @@ impl SettingSchema {
|
||||
"boolean" => v.is_boolean(),
|
||||
"integer" => v.is_i64() || v.is_u64(),
|
||||
"number" => v.is_number(),
|
||||
"array" => v.as_array().is_some_and(|a| {
|
||||
a.len() <= 1000 && self.items.as_ref().is_some_and(|item| a.iter().all(|v| item.accepts(v)))
|
||||
}),
|
||||
"object" => v.as_object().is_some_and(|o| {
|
||||
o.len() == self.properties.len()
|
||||
&& self.properties.iter().all(|p| o.get(&p.key).is_some_and(|v| p.accepts(v)))
|
||||
}),
|
||||
_ => v
|
||||
.as_str()
|
||||
.is_some_and(|s| s.len() <= 64 << 10 && self.options.as_ref().is_none_or(|o| o.iter().any(|x| x == s))),
|
||||
@@ -323,17 +384,47 @@ impl SettingSchema {
|
||||
}
|
||||
}
|
||||
|
||||
fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
|
||||
fn setting(key: &str, raw: &serde_json::value::RawValue) -> Result<SettingSchema, String> {
|
||||
setting_at(key, raw, 0)
|
||||
}
|
||||
|
||||
fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result<SettingSchema, String> {
|
||||
if key.is_empty() || key.len() > 64 || !key.chars().all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) {
|
||||
return Err(format!("setting {key:?}: keys are 1–64 of [A-Za-z0-9._-]"));
|
||||
}
|
||||
let o = raw.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
|
||||
let value: Value = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
|
||||
let o = value.as_object().ok_or_else(|| format!("setting {key:?}: expected an object"))?;
|
||||
let kind = o.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let mut items = None;
|
||||
let mut properties = Vec::new();
|
||||
let default = match kind {
|
||||
"boolean" => o.get("default").cloned().unwrap_or(Value::Bool(false)),
|
||||
"string" => o.get("default").cloned().unwrap_or(Value::String(String::new())),
|
||||
"number" | "integer" => o.get("default").cloned().unwrap_or(Value::from(0)),
|
||||
_ => return Err(format!("setting {key:?}: `type` is boolean, string, number or integer")),
|
||||
"array" if depth == 0 => {
|
||||
#[derive(Deserialize)]
|
||||
struct ArraySchema {
|
||||
items: Box<serde_json::value::RawValue>,
|
||||
}
|
||||
let array: ArraySchema = serde_json::from_str(raw.get()).map_err(|e| e.to_string())?;
|
||||
let item = setting_at("item", &array.items, 1)?;
|
||||
if item.kind != "string" && item.kind != "object" {
|
||||
return Err(format!("setting {key:?}: items must be strings or objects"));
|
||||
}
|
||||
items = Some(Box::new(item));
|
||||
o.get("default").cloned().unwrap_or_else(|| Value::Array(Vec::new()))
|
||||
}
|
||||
"object" if depth == 1 => {
|
||||
let fields = serde_json::from_str::<SchemaFile>(raw.get()).map_err(|e| e.to_string())?.properties;
|
||||
if fields.is_empty() || fields.len() > MAX_SETTINGS {
|
||||
return Err(format!("setting {key:?}: table needs 1–{MAX_SETTINGS} columns"));
|
||||
}
|
||||
properties = fields.iter().map(|(k, v)| setting_at(k, v, 2)).collect::<Result<_, _>>()?;
|
||||
o.get("default").cloned().unwrap_or_else(|| {
|
||||
Value::Object(properties.iter().map(|p| (p.key.clone(), p.default.clone())).collect())
|
||||
})
|
||||
}
|
||||
_ => return Err(format!("setting {key:?}: unsupported setting type {kind:?}")),
|
||||
};
|
||||
let options = match o.get("enum") {
|
||||
None => None,
|
||||
@@ -359,6 +450,9 @@ fn setting(key: &str, raw: &Value) -> Result<SettingSchema, String> {
|
||||
default,
|
||||
options,
|
||||
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
|
||||
group: text("group", MAX_TITLE)?,
|
||||
items,
|
||||
properties,
|
||||
};
|
||||
if !s.accepts(&s.default) {
|
||||
return Err(format!("setting {key:?}: the default does not match its type"));
|
||||
@@ -377,13 +471,68 @@ pub fn parse_settings_schema(text: &str) -> Result<Vec<SettingSchema>, String> {
|
||||
|
||||
pub(super) fn settings(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: SettingsDecl = decode(SETTINGS, decl)?;
|
||||
let text = pkg.read_text(&d.schema)?;
|
||||
let properties = parse_settings_schema(&text).map_err(|e| format!("{}: {e}", d.schema.display()))?;
|
||||
if d.schema.is_some() == !d.pages.is_empty() {
|
||||
return Err("settings: use either `schema` or nonempty `pages`".into());
|
||||
}
|
||||
if d.pages.len() > 32 {
|
||||
return Err("settings: at most 32 pages".into());
|
||||
}
|
||||
let title = d.title.unwrap_or_else(|| pkg.manifest.name.clone());
|
||||
title_ok(&title)?;
|
||||
let mut decl =
|
||||
UiDecl::new("settings", title.clone(), serde_json::json!({ "title": title, "properties": properties }));
|
||||
decl.files.push(d.schema);
|
||||
let mut files = Vec::new();
|
||||
let mut keys = BTreeSet::new();
|
||||
let mut load = |path: &Path| -> Result<Vec<SettingSchema>, String> {
|
||||
let properties =
|
||||
parse_settings_schema(&pkg.read_text(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
for p in &properties {
|
||||
if !keys.insert(p.key.clone()) {
|
||||
return Err(format!("settings: duplicate key {:?} across pages", p.key));
|
||||
}
|
||||
if p.group.is_some() || p.kind == "array" {
|
||||
pkg.manifest.needs_api("settings: groups / arrays", 3)?;
|
||||
}
|
||||
}
|
||||
if keys.len() > MAX_SETTINGS {
|
||||
return Err(format!("more than {MAX_SETTINGS} settings"));
|
||||
}
|
||||
files.push(path.to_path_buf());
|
||||
Ok(properties)
|
||||
};
|
||||
let mut properties = Vec::new();
|
||||
let mut pages = Vec::new();
|
||||
if let Some(path) = d.schema {
|
||||
properties = load(&path)?;
|
||||
} else {
|
||||
pkg.manifest.needs_api("contributes.settings.pages", 3)?;
|
||||
let mut ids = BTreeSet::new();
|
||||
for p in d.pages {
|
||||
validate_item_id(&p.id, "settings page id")?;
|
||||
if p.id.contains('.') || !ids.insert(p.id.clone()) {
|
||||
return Err(format!("settings: page id {:?} must be unique and contain no dots", p.id));
|
||||
}
|
||||
title_ok(&p.title)?;
|
||||
if p.actions.len() > 32 {
|
||||
return Err("settings: at most 32 actions per page".into());
|
||||
}
|
||||
for a in &p.actions {
|
||||
title_ok(&a.title)?;
|
||||
declared_command(pkg, SETTINGS, &a.command)?;
|
||||
}
|
||||
let fields = load(&p.schema)?;
|
||||
properties.extend(fields.clone());
|
||||
pages.push(serde_json::json!({
|
||||
"id": p.id, "title": p.title, "properties": fields, "actions": p.actions,
|
||||
}));
|
||||
}
|
||||
}
|
||||
let mut decl = UiDecl::new(
|
||||
"settings",
|
||||
title.clone(),
|
||||
serde_json::json!({
|
||||
"title": title, "properties": properties, "pages": pages,
|
||||
}),
|
||||
);
|
||||
decl.files = files;
|
||||
Ok(decl)
|
||||
}
|
||||
|
||||
@@ -407,6 +556,9 @@ struct ViewDecl {
|
||||
/// Show the selected tree/list item's detail-provider tabs beside it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
detail: bool,
|
||||
/// `default` or `none` (tool-window web views that render their own header row).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
header: Option<String>,
|
||||
/// A web page of the package (`web/index.html`) instead of content the code renders.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
web: Option<String>,
|
||||
@@ -452,6 +604,18 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
return Err(format!("view {:?}: `detail` needs a tree or list rendered by the plugin (no `web`)", d.id));
|
||||
}
|
||||
}
|
||||
if let Some(header) = &d.header {
|
||||
pkg.manifest.needs_api("contributes.views.header", 3)?;
|
||||
if header != "default" && header != "none" {
|
||||
return Err(format!("view {:?}: `header` is default or none", d.id));
|
||||
}
|
||||
if header == "none" && (d.web.is_none() || d.group.is_some()) {
|
||||
return Err(format!(
|
||||
"view {:?}: `header = \"none\"` is only for ungrouped tool-window web views (`web`)",
|
||||
d.id
|
||||
));
|
||||
}
|
||||
}
|
||||
if d.icon.as_ref().is_some_and(|i| i.len() > 64) {
|
||||
return Err(format!("view {:?}: icon names are up to 64 bytes", d.id));
|
||||
}
|
||||
@@ -795,6 +959,104 @@ mod tests {
|
||||
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collections_validate_every_cell_and_keep_schema_order() {
|
||||
let schema =
|
||||
parse_settings_schema(include_str!("../../../ide-plugin/tests/fixtures/settings-pages/llm.json")).unwrap();
|
||||
let profiles = &schema[0];
|
||||
let item = profiles.items.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
item.properties.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(),
|
||||
["name", "provider", "baseUrl", "model", "enabled", "temperature", "limit"]
|
||||
);
|
||||
assert!(profiles.accepts(&serde_json::json!([item.default])));
|
||||
for (field, value) in [
|
||||
("provider", Value::from("unknown")),
|
||||
("enabled", Value::from("true")),
|
||||
("limit", Value::from(1.5)),
|
||||
("model", Value::Null),
|
||||
] {
|
||||
let mut row = item.default.clone();
|
||||
row[field] = value;
|
||||
assert!(!profiles.accepts(&serde_json::json!([row])), "{field}");
|
||||
}
|
||||
assert!(!profiles.accepts(&serde_json::json!([{}])));
|
||||
for items in [
|
||||
r#"{"type":"number"}"#,
|
||||
r#"{"type":"array","items":{"type":"string"}}"#,
|
||||
r#"{"type":"object","properties":{}}"#,
|
||||
r#"{"type":"object","properties":{"nested":{"type":"object"}}}"#,
|
||||
] {
|
||||
assert!(
|
||||
parse_settings_schema(&format!(r#"{{"properties":{{"x":{{"type":"array","items":{items}}}}}}}"#))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let lists = parse_settings_schema(
|
||||
r#"{"properties":{"z":{"type":"array","items":{"type":"string"}},"a":{"type":"boolean"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lists[0].key, "z");
|
||||
assert!(lists[0].accepts(&serde_json::json!(["feat", "fix"])));
|
||||
assert!(!lists[0].accepts(&serde_json::json!([1])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_order_is_local_and_duplicate_fields_are_rejected() {
|
||||
let raw = r#"{"properties":{"z":{"type":"string"},"a":{"type":"boolean"}}}"#;
|
||||
let fields = parse_settings_schema(raw).unwrap();
|
||||
assert_eq!(fields.iter().map(|p| p.key.as_str()).collect::<Vec<_>>(), ["z", "a"]);
|
||||
let ordinary: Value = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(ordinary["properties"].as_object().unwrap().keys().collect::<Vec<_>>(), ["a", "z"]);
|
||||
for raw in [
|
||||
r#"{"properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}"#,
|
||||
r#"{"properties":{"rows":{"type":"array","items":{"type":"object","properties":{"x":{"type":"string"},"x":{"type":"boolean"}}}}}}"#,
|
||||
] {
|
||||
assert!(parse_settings_schema(raw).unwrap_err().contains("duplicate setting key"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_pages_validate_manifest_files_commands_and_api() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/settings-pages");
|
||||
let source = std::fs::read_to_string(root.join("plugin.toml")).unwrap();
|
||||
let check = |source: &str| {
|
||||
let manifest = ide_extension::Manifest::parse(source)?;
|
||||
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
|
||||
settings(&Package::new(manifest, root.clone()), decl)
|
||||
};
|
||||
let valid = check(&source).unwrap();
|
||||
assert_eq!(valid.json["pages"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(valid.json["properties"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(valid.files.len(), 2);
|
||||
for bad in [
|
||||
source.replace("^1.3", "^1.2"),
|
||||
source.replace(r#"id = "llm""#, r#"id = "templates""#),
|
||||
source.replace(r#"command = "test""#, r#"command = "missing""#),
|
||||
source.replace("llm.json", "missing.json"),
|
||||
source.replace("llm.json", "../outside.json"),
|
||||
source.replace("llm.json", "templates.json"),
|
||||
source.replace(r#"id = "llm""#, r#"id = "llm.child""#),
|
||||
] {
|
||||
assert!(check(&bad).is_err(), "{bad}");
|
||||
}
|
||||
let old = source.split("[contributes.settings]").next().unwrap().replace("^1.3", "^1.0");
|
||||
let old = format!("{old}\n[contributes.settings]\nschema = \"legacy.json\"\n");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("legacy.json"), r#"{"properties":{"enabled":{"type":"boolean"}}}"#).unwrap();
|
||||
let manifest = ide_extension::Manifest::parse(&old).unwrap();
|
||||
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
|
||||
assert!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok());
|
||||
for field in [r#"{"type":"string","group":"Provider"}"#, r#"{"type":"array","items":{"type":"string"}}"#] {
|
||||
std::fs::write(dir.path().join("legacy.json"), format!(r#"{{"properties":{{"x":{field}}}}}"#)).unwrap();
|
||||
for (api, valid) in [("^1.0", false), ("^1.2", false), ("^1.3", true)] {
|
||||
let manifest = ide_extension::Manifest::parse(&old.replace("^1.0", api)).unwrap();
|
||||
let decl = manifest.declarations(SETTINGS).into_iter().next().unwrap();
|
||||
assert_eq!(settings(&Package::new(manifest, dir.path().to_path_buf()), decl).is_ok(), valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn package_with_catalogs(files: &[(&str, &str)]) -> (tempfile::TempDir, Package) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join(LOCALES_DIR)).unwrap();
|
||||
|
||||
@@ -134,13 +134,47 @@ impl PluginHost {
|
||||
window: &str,
|
||||
command: WebPtyCommand,
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, || {
|
||||
self.web_pty_options(plugin_id, view, window, command)
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts the program of the log tab `key` that the plugin's code opened
|
||||
/// in `window` (`views.open-log-tab`), with the toggles in `on`: the UI
|
||||
/// names the tab, never what runs.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_log_tab_pty(
|
||||
&self,
|
||||
host: &std::sync::Arc<crate::Host>,
|
||||
plugin_id: &str,
|
||||
key: &str,
|
||||
window: &str,
|
||||
on: &[String],
|
||||
(cols, rows): (u16, u16),
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
self.spawn_plugin_pty(host, plugin_id, window, output, || {
|
||||
self.log_tab_pty_options(plugin_id, key, window, on, (cols, rows))
|
||||
})
|
||||
}
|
||||
|
||||
/// The PTY of a plugin, counted against its sessions and ended with its
|
||||
/// window, project or permission; `options` authorizes it.
|
||||
fn spawn_plugin_pty(
|
||||
&self,
|
||||
host: &std::sync::Arc<crate::Host>,
|
||||
plugin_id: &str,
|
||||
window: &str,
|
||||
output: crate::ops::Emit<String>,
|
||||
options: impl FnOnce() -> IdeResult<ide_terminal::TerminalOptions>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let mut sessions = self.web_ptys.lock();
|
||||
if self.shared.client_closed(window) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let options = self.web_pty_options(plugin_id, view, window, command)?;
|
||||
let options = options()?;
|
||||
let entries = sessions.entry(plugin_id.to_owned()).or_default();
|
||||
entries.retain(|s| s.host.upgrade().is_some_and(|h| h.terminals.contains(s.id)));
|
||||
if entries.len() >= 8 {
|
||||
@@ -166,6 +200,7 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
pub(super) fn forget_web_ptys(&self, window: &str) {
|
||||
self.shared.forget_log_tabs(None, Some(window));
|
||||
for sessions in self.web_ptys.lock().values_mut() {
|
||||
sessions.retain(|session| {
|
||||
if session.window != window {
|
||||
@@ -180,6 +215,7 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
pub(super) fn stop_web_ptys(&self, plugin: &str) {
|
||||
self.shared.forget_log_tabs(Some(plugin), None);
|
||||
if let Some(sessions) = self.web_ptys.lock().remove(plugin) {
|
||||
for session in sessions {
|
||||
if let Some(host) = session.host.upgrade() {
|
||||
@@ -198,8 +234,37 @@ impl PluginHost {
|
||||
command: WebPtyCommand,
|
||||
) -> IdeResult<ide_terminal::TerminalOptions> {
|
||||
self.web_page(plugin_id, view)?;
|
||||
self.get(plugin_id)?.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?;
|
||||
self.pty_options(plugin_id, window, command)
|
||||
}
|
||||
|
||||
/// The options of the log tab `key`'s program, with the toggles in `on`.
|
||||
pub fn log_tab_pty_options(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
key: &str,
|
||||
window: &str,
|
||||
on: &[String],
|
||||
(cols, rows): (u16, u16),
|
||||
) -> IdeResult<ide_terminal::TerminalOptions> {
|
||||
let log = self.shared.log_tab(plugin_id, window, key).ok_or_else(|| {
|
||||
IdeError::not_found(format!("plugin {plugin_id:?} opened no log tab {key:?} in this window"))
|
||||
})?;
|
||||
let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args);
|
||||
let args = log.args.into_iter().chain(toggled).collect();
|
||||
let command = WebPtyCommand { program: log.program, args, env: Default::default(), cols, rows };
|
||||
self.pty_options(plugin_id, window, command)
|
||||
}
|
||||
|
||||
/// Checks `command` against the plugin's current grant, the limits and
|
||||
/// the window's trusted project.
|
||||
fn pty_options(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
window: &str,
|
||||
command: WebPtyCommand,
|
||||
) -> IdeResult<ide_terminal::TerminalOptions> {
|
||||
let plugin = self.get(plugin_id)?;
|
||||
plugin.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?;
|
||||
let cap = ide_extension::Capability::Process(command.program.clone());
|
||||
if !plugin.meta.lock().granted.contains(&cap) {
|
||||
return Err(IdeError::new(
|
||||
|
||||
@@ -20,6 +20,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
pub(crate) trusted: Arc<AtomicBool>,
|
||||
pub(crate) files_events_active: Arc<AtomicBool>,
|
||||
pub root: PathBuf,
|
||||
/// Window-scoped event sink, for commands that emit project events.
|
||||
pub sink: SharedSink,
|
||||
@@ -319,7 +320,10 @@ impl Host {
|
||||
// One-shot latch for the post-start watch-limit warning (#1915): the
|
||||
// synchronous startup walk and a later batch can both observe it.
|
||||
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
|
||||
let files_events_active = Arc::new(AtomicBool::new(true));
|
||||
let watcher = {
|
||||
let files_events_active = files_events_active.clone();
|
||||
let window = window.to_owned();
|
||||
let kotlin_bridge = kotlin_bridge.clone();
|
||||
let phpstan = phpstan.clone();
|
||||
let repos = repos.clone();
|
||||
@@ -357,6 +361,7 @@ impl Host {
|
||||
documents.on_disk_changed(&[&changed[..], &ignored].concat());
|
||||
if let Some(plugins) = crate::plugins::editor_plugin_host() {
|
||||
plugins.invalidate_endpoints(&root, &[changed.clone(), batch.removed.clone()].concat());
|
||||
plugins.post_files_changed(&window, &root, &files_events_active, batch);
|
||||
}
|
||||
// A framework's root marker (`angular.json`) created or deleted: its
|
||||
// templates gain or lose their mode. Markers in `node_modules` only
|
||||
@@ -554,6 +559,7 @@ impl Host {
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
files_events_active,
|
||||
light: false,
|
||||
});
|
||||
{
|
||||
@@ -703,6 +709,7 @@ impl Host {
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
files_events_active: Arc::new(AtomicBool::new(false)),
|
||||
light: true,
|
||||
root: dir,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! `openidea-server --connect <host> <port>`: one forwarded connection. The desktop starts it
|
||||
//! in a dev container (`docker exec -i`) for each connection to a forwarded port and pipes the
|
||||
//! connection through stdin / stdout, so a port of the container is reachable wherever the
|
||||
//! container's network is not.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{Shutdown, TcpStream};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const EOF_GRACE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// `<host> <port>` from the arguments after `--connect`.
|
||||
pub fn parse_args(args: &[String]) -> Result<(String, u16), String> {
|
||||
let [host, port] = args else { return Err("usage: openidea-server --connect <host> <port>".into()) };
|
||||
if host.is_empty() || host.starts_with('-') || host.chars().any(|c| c.is_control() || c.is_whitespace()) {
|
||||
return Err(format!("invalid host {host:?}"));
|
||||
}
|
||||
match port.parse::<u16>() {
|
||||
Ok(p) if p != 0 => Ok((host.clone(), p)),
|
||||
_ => Err(format!("invalid port {port:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects to `host:port`, copies `input` to it and its replies to `output`, and returns when
|
||||
/// the target is done (the copy of `input` may still be blocked: the caller exits). Fails only
|
||||
/// when the connection can't be opened. An input EOF allows five idle seconds for a final reply.
|
||||
pub fn relay(host: &str, port: u16, mut input: impl Read + Send + 'static, mut output: impl Write) -> io::Result<()> {
|
||||
let mut from_target = connect(host, port)?;
|
||||
from_target.set_read_timeout(Some(Duration::from_millis(100)))?;
|
||||
let mut to_target = from_target.try_clone()?;
|
||||
let writing = Arc::new(Mutex::new(None::<Instant>));
|
||||
let stalled = writing.clone();
|
||||
let (finished, eof) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0; 16 * 1024];
|
||||
loop {
|
||||
let n = match input.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
};
|
||||
*writing.lock().unwrap() = Some(Instant::now());
|
||||
let result = to_target.write_all(&buf[..n]);
|
||||
*writing.lock().unwrap() = None;
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The client closed its side: tell the target, which may still answer.
|
||||
let _ = to_target.shutdown(Shutdown::Write);
|
||||
let _ = finished.send(Instant::now());
|
||||
});
|
||||
let mut buf = [0u8; 16 * 1024];
|
||||
let mut input_closed = None;
|
||||
loop {
|
||||
// A blocked write must not hide Docker closing stdin indefinitely.
|
||||
if stalled.lock().unwrap().is_some_and(|at| at.elapsed() >= CONNECT_TIMEOUT) {
|
||||
break;
|
||||
}
|
||||
if let Ok(at) = eof.try_recv() {
|
||||
input_closed = Some(at);
|
||||
}
|
||||
// Docker closing stdin must end an idle relay too. Keep a bounded grace for
|
||||
// a target replying to a half-closed request before tearing down the socket.
|
||||
if input_closed.is_some_and(|at| at.elapsed() >= EOF_GRACE) {
|
||||
break;
|
||||
}
|
||||
match from_target.read(&mut buf) {
|
||||
Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => continue,
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if output.write_all(&buf[..n]).and_then(|()| output.flush()).is_err() {
|
||||
break;
|
||||
}
|
||||
if input_closed.is_some() {
|
||||
input_closed = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = from_target.shutdown(Shutdown::Both);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn connect(host: &str, port: u16) -> io::Result<TcpStream> {
|
||||
use std::net::ToSocketAddrs;
|
||||
let mut last = io::Error::new(io::ErrorKind::NotFound, format!("{host} has no address"));
|
||||
for addr in (host, port).to_socket_addrs()? {
|
||||
match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
|
||||
Ok(s) => return Ok(s),
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
a.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_host_and_port() {
|
||||
assert_eq!(parse_args(&args(&["localhost", "8000"])).unwrap(), ("localhost".into(), 8000));
|
||||
for bad in [&["localhost"][..], &["localhost", "0"], &["localhost", "70000"], &["-x", "80"], &["a b", "80"]] {
|
||||
assert!(parse_args(&args(bad)).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipes_a_request_and_its_reply() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let mut request = String::new();
|
||||
s.read_to_string(&mut request).unwrap();
|
||||
s.write_all(format!("got {request}").as_bytes()).unwrap();
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
relay("127.0.0.1", port, &b"ping"[..], &mut out).unwrap();
|
||||
server.join().unwrap();
|
||||
assert_eq!(out, b"got ping");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_eof_ends_a_relay_even_when_the_target_ignores_it() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (done, wait) = mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut request = Vec::new();
|
||||
socket.read_to_end(&mut request).unwrap();
|
||||
// Remain open after seeing EOF, like an idle keep-alive server.
|
||||
wait.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
});
|
||||
let start = Instant::now();
|
||||
relay("127.0.0.1", port, io::empty(), io::sink()).unwrap();
|
||||
assert!(start.elapsed() < Duration::from_secs(8));
|
||||
done.send(()).unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_responses_keep_flowing_after_input_eof() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
socket.read_to_end(&mut Vec::new()).unwrap();
|
||||
for _ in 0..7 {
|
||||
socket.write_all(b"x").unwrap();
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
relay("127.0.0.1", port, io::empty(), &mut out).unwrap();
|
||||
assert_eq!(out, b"xxxxxxx");
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_that_never_reads_cannot_keep_a_relay_alive() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (done, wait) = mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (_socket, _) = listener.accept().unwrap();
|
||||
wait.recv_timeout(Duration::from_secs(30)).unwrap();
|
||||
});
|
||||
let start = Instant::now();
|
||||
relay("127.0.0.1", port, io::repeat(0).take(32 * 1024 * 1024), io::sink()).unwrap();
|
||||
assert!(start.elapsed() < Duration::from_secs(25));
|
||||
done.send(()).unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_port_is_an_error() {
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
assert!(relay("127.0.0.1", port, io::empty(), io::sink()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,13 @@ fn fixture() -> Fixture {
|
||||
}
|
||||
|
||||
fn inv() -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: None, editor: None, selected_paths: Vec::new() }
|
||||
Invocation {
|
||||
client: Some("main".into()),
|
||||
workspace_root: None,
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn misbehave() -> PathBuf {
|
||||
|
||||
@@ -140,6 +140,21 @@ fn permissions_and_paths_are_checked_before_indexing() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_refactorings_drop_graphs_after_permission_revocation_or_lost_trust() {
|
||||
let f = Fixture::new();
|
||||
let path = f.index("source.idx", "def item\nref item\n", &[], Some(1), "app");
|
||||
let pos = Pos::new(0, 4);
|
||||
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_some());
|
||||
f.host.set_project_policy(Arc::new(|_| false));
|
||||
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
|
||||
f.host.set_project_policy(Arc::new(|_| true));
|
||||
f.host.set_capability("test.indexer", "project.read", false).unwrap();
|
||||
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
|
||||
f.host.set_capability("test.indexer", "project.read", true).unwrap();
|
||||
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none(), "regrant requires fresh snapshots");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() {
|
||||
let f = Fixture::new();
|
||||
@@ -176,6 +191,7 @@ fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
|
||||
workspace_root: Some(f.root.clone()),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -239,3 +255,42 @@ fn invalid_ranges_and_oversized_documents_never_publish() {
|
||||
}
|
||||
assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usages_from_definitions_and_references_follow_aliases_module_scope_and_client() {
|
||||
let f = Fixture::new();
|
||||
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
|
||||
f.index("a/child.idx", "alias child# parent#\n", &[], None, "a");
|
||||
let reference = f.index("a/use.idx", "ref child#item\n", &[], Some(1), "a");
|
||||
let other = f.index("b/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "b");
|
||||
let external = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
|
||||
let unrelated = f.index("b/use.idx", "ref child#item\n", &[], Some(1), "b");
|
||||
for (path, pos) in [(&definition, Pos::new(0, 4)), (&reference, Pos::new(0, 6))] {
|
||||
let symbol = f.host.indexed_symbol_at(&f.root, path, pos, None).unwrap();
|
||||
assert!(symbol.files.iter().any(|f| f.path == definition));
|
||||
assert!(symbol.files.iter().any(|f| f.path == reference));
|
||||
assert!(!symbol.files.iter().any(|f| f.path == other), "prefer same-module definitions and their uses");
|
||||
assert!(symbol.files.iter().any(|f| f.path == external), "external reference resolves to this definition");
|
||||
assert!(!symbol.files.iter().any(|f| f.path == unrelated), "reference resolves to its own module");
|
||||
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
|
||||
}
|
||||
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 0), None).is_none());
|
||||
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), Some("other-window")).is_none());
|
||||
f.host.set_project_policy(Arc::new(|_| false));
|
||||
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_module_reference_and_definition_have_the_same_rename_anchors() {
|
||||
let f = Fixture::new();
|
||||
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
|
||||
f.index("a/alias.idx", "alias child# parent#\n", &[], None, "a");
|
||||
let external = f.index("b/use.idx", "ref item\n", &[], Some(1), "b");
|
||||
let alias = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
|
||||
for path in [&definition, &external, &alias] {
|
||||
let symbol = f.host.indexed_symbol_at(&f.root, path, Pos::new(0, 4), None).unwrap();
|
||||
let paths: Vec<_> = symbol.files.iter().map(|f| f.path.clone()).collect();
|
||||
assert_eq!(paths, vec![definition.clone(), external.clone(), alias.clone()]);
|
||||
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Indexed names use the same preview/apply/undo commands as language-server refactorings.
|
||||
use ide_core::{IdeEvent, Pos, RequestId, error::ErrorKind};
|
||||
use ide_host::{
|
||||
Host, HostConfig, ops,
|
||||
plugins::{Consent, PluginHost, navigation::Snapshot, set_editor_providers},
|
||||
};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let first = root.join("definition.idx");
|
||||
let second = root.join("usage.idx");
|
||||
let source = "def 😀route\nref 😀route\n";
|
||||
let usage = "ref 😀route\nref other\n";
|
||||
std::fs::write(&first, source).unwrap();
|
||||
std::fs::write(&second, usage).unwrap();
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
let captured = events.clone();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: root.join("config"),
|
||||
data_dir: root.join("data"),
|
||||
local_data_dir: root.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| {
|
||||
let events = captured.clone();
|
||||
Arc::new(move |e| events.lock().push(e))
|
||||
}),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_light("main", &first).unwrap();
|
||||
// Editors retain ordinary Windows paths; index snapshots use canonical
|
||||
// (verbatim on Windows) paths and the host's own project-root identity.
|
||||
let doc = project.documents.open(dunce::simplified(&first)).unwrap();
|
||||
let plugins =
|
||||
Arc::new(PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap());
|
||||
plugins.set_project_policy(Arc::new(|_| true));
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
|
||||
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
|
||||
// Two independently published graphs may contain exactly the same anchors.
|
||||
let duplicate = root.join("duplicate");
|
||||
std::fs::create_dir_all(duplicate.join("dist")).unwrap();
|
||||
std::fs::copy(fixture.join("dist/plugin.wasm"), duplicate.join("dist/plugin.wasm")).unwrap();
|
||||
std::fs::write(
|
||||
duplicate.join("plugin.toml"),
|
||||
std::fs::read_to_string(fixture.join("plugin.toml")).unwrap().replace("test.indexer", "test.duplicate"),
|
||||
)
|
||||
.unwrap();
|
||||
plugins.install_from_dir(&duplicate, &Consent::all()).unwrap();
|
||||
set_editor_providers(Some(&plugins));
|
||||
struct Clear;
|
||||
impl Drop for Clear {
|
||||
fn drop(&mut self) {
|
||||
set_editor_providers(None);
|
||||
}
|
||||
}
|
||||
let _clear = Clear;
|
||||
// LSP-managed library documents do not belong to Documents. Optional
|
||||
// index lookup must not replace their native result with a missing-doc error.
|
||||
let virtual_doc = ide_core::DocId(1_000_000);
|
||||
project.lsp.did_open_virtual(
|
||||
virtual_doc,
|
||||
"jdt://contents/test/Route.class",
|
||||
&ide_core::LanguageId::new("java"),
|
||||
0,
|
||||
"class Route {}",
|
||||
);
|
||||
let virtual_result = ops::lsp::lsp_references(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(99),
|
||||
virtual_doc,
|
||||
Pos::new(0, 6),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(virtual_result.kind, ErrorKind::Unavailable, "retain the LSP's native result for virtual documents");
|
||||
project.lsp.did_close(virtual_doc);
|
||||
let index = || {
|
||||
for (path, text, version) in [(&first, source, Some(doc.version)), (&second, usage, None)] {
|
||||
plugins
|
||||
.index_navigation(
|
||||
&project.root,
|
||||
Snapshot { path, text, version, symbols: &[], scope: "app" },
|
||||
Invocation {
|
||||
client: Some("main".into()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
index();
|
||||
for pos in [Pos::new(0, 4), Pos::new(1, 5)] {
|
||||
let prepared = ops::lsp::lsp_prepare_rename(&host, "main", doc.id, pos).await.unwrap().unwrap();
|
||||
assert_eq!((prepared.start.col, prepared.end.col), (4, 11));
|
||||
let partial = Arc::new(Mutex::new(vec![]));
|
||||
let captured = partial.clone();
|
||||
let refs = ops::lsp::lsp_references(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(1),
|
||||
doc.id,
|
||||
pos,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
ops::Channel::new(move |r| captured.lock().push(r)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(refs.locations.len(), 3, "definitions and references, deduplicated across plugins");
|
||||
assert_eq!(partial.lock()[0].locations.len(), 3);
|
||||
}
|
||||
assert!(
|
||||
ops::lsp::lsp_prepare_rename(&host, "main", doc.id, Pos::new(0, 0)).await.is_err(),
|
||||
"unowned text falls through to the unavailable LSP"
|
||||
);
|
||||
let renamed =
|
||||
ops::lsp::lsp_rename(&host, "main", RequestId(2), doc.id, Pos::new(0, 4), "new.route".into()).await.unwrap();
|
||||
assert!(renamed.preview);
|
||||
assert_eq!(renamed.edit.files.len(), 2);
|
||||
assert_eq!(renamed.edit.preconditions.len(), 2);
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), source, "creating/cancelling preview changes nothing");
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), usage);
|
||||
let opened_usage = project.documents.open(&second).unwrap();
|
||||
let changed_version = project
|
||||
.documents
|
||||
.edit(
|
||||
opened_usage.id,
|
||||
opened_usage.version,
|
||||
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 11, insert: "changed".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_rename(&host, "main", RequestId(20), doc.id, Pos::new(0, 4), "new.route".into())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::OutOfSync,
|
||||
"a closed-file snapshot cannot hide newer unsaved contents"
|
||||
);
|
||||
project
|
||||
.documents
|
||||
.edit(
|
||||
opened_usage.id,
|
||||
changed_version,
|
||||
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 11, insert: "😀route".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
project.documents.close(opened_usage.id);
|
||||
// A closed file changing during preview rejects the whole operation.
|
||||
std::fs::write(&second, "ref changed\n").unwrap();
|
||||
assert_eq!(
|
||||
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit.clone()).await.unwrap_err().kind,
|
||||
ErrorKind::OutOfSync
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), source);
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_rename(&host, "main", RequestId(3), doc.id, Pos::new(0, 4), "new.route".into())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::OutOfSync,
|
||||
"stale closed-file anchors cannot be renamed"
|
||||
);
|
||||
std::fs::write(&second, usage).unwrap();
|
||||
let renamed =
|
||||
ops::lsp::lsp_rename(&host, "main", RequestId(4), doc.id, Pos::new(1, 4), "new.route".into()).await.unwrap();
|
||||
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "def new.route\nref new.route\n");
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), "ref new.route\nref other\n");
|
||||
let id = events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), source);
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), usage);
|
||||
ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), "ref new.route\nref other\n");
|
||||
// A matching native preparation owns the decision, including a null rename.
|
||||
std::fs::write(&first, source).unwrap();
|
||||
std::fs::write(&second, usage).unwrap();
|
||||
project.documents.close(doc.id);
|
||||
let doc = project.documents.open(&first).unwrap();
|
||||
for (path, text, version) in [(&first, source, Some(doc.version)), (&second, usage, None)] {
|
||||
plugins
|
||||
.index_navigation(
|
||||
&project.root,
|
||||
Snapshot { path, text, version, symbols: &[], scope: "app" },
|
||||
Invocation {
|
||||
client: Some("main".into()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
native_rename_refusal(&host, &project, doc.id, &first, source).await;
|
||||
plugins.set_enabled("test.indexer", false).unwrap();
|
||||
plugins.set_enabled("test.duplicate", false).unwrap();
|
||||
assert!(plugins.indexed_symbol_at(&project.root, &first, Pos::new(0, 4), Some("main")).is_none());
|
||||
plugins.set_enabled("test.indexer", true).unwrap();
|
||||
project.documents.close(doc.id);
|
||||
let invalid = "def 😀\nsymbols \n";
|
||||
std::fs::write(&first, invalid).unwrap();
|
||||
let doc = project.documents.open(&first).unwrap();
|
||||
let half_surrogate = ide_language::SymbolTag {
|
||||
name: "broken".into(),
|
||||
kind: ide_core::SymbolKind::Method,
|
||||
container: None,
|
||||
range: ide_core::Range::new(Pos::new(0, 4), Pos::new(0, 5)),
|
||||
};
|
||||
plugins
|
||||
.index_navigation(
|
||||
&project.root,
|
||||
Snapshot {
|
||||
path: &first,
|
||||
version: Some(doc.version),
|
||||
text: invalid,
|
||||
symbols: &[half_surrogate],
|
||||
scope: "app",
|
||||
},
|
||||
Invocation {
|
||||
client: Some("main".into()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_prepare_rename(&host, "main", doc.id, Pos::new(0, 4)).await.unwrap_err().kind,
|
||||
ErrorKind::InvalidInput,
|
||||
"malformed UTF-16 anchors cannot split a character"
|
||||
);
|
||||
project.documents.close(doc.id);
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
// BOM-less UTF-16 detection requires mostly ASCII pairs, even with emoji anchors.
|
||||
let padding = format!("ignored {}\n", "x".repeat(128));
|
||||
let encoded_source = format!("{source}{padding}");
|
||||
let encoded_usage = format!("{usage}{padding}");
|
||||
for (encoding, bom) in
|
||||
[("utf-8", true), ("utf-16le", true), ("utf-16be", true), ("utf-16le", false), ("utf-16be", false)]
|
||||
{
|
||||
ide_vfs::save_text(&first, &encoded_source, encoding, bom).unwrap();
|
||||
ide_vfs::save_text(&second, &encoded_usage, encoding, bom).unwrap();
|
||||
let original_second = std::fs::read(&second).unwrap();
|
||||
let doc = project.documents.open(&first).unwrap();
|
||||
assert_eq!(doc.text, encoded_source, "decoded {encoding} BOM={bom}");
|
||||
// Index open, close, then validate that decoded text retains the same hash.
|
||||
let usage_doc = project.documents.open(&second).unwrap();
|
||||
ide_host::plugins::index_ops::plugin_navigation_index(
|
||||
&host,
|
||||
plugins.clone(),
|
||||
"main",
|
||||
vec![first.clone(), second.clone()],
|
||||
RequestId(100),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
project.documents.close(usage_doc.id);
|
||||
assert!(
|
||||
plugins.indexed_symbol_at(&project.root, &first, Pos::new(0, 5), Some("main")).is_some(),
|
||||
"{encoding} BOM={bom} must publish the open definition"
|
||||
);
|
||||
let refs = ops::lsp::lsp_references(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(101),
|
||||
doc.id,
|
||||
Pos::new(0, 5),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(refs.locations.len(), 3, "open-then-closed {encoding} BOM={bom}");
|
||||
plugins.invalidate_client_navigation(&project.root, Some("main"), Some(std::slice::from_ref(&second)));
|
||||
ide_host::plugins::index_ops::plugin_navigation_index(
|
||||
&host,
|
||||
plugins.clone(),
|
||||
"main",
|
||||
vec![second.clone()],
|
||||
RequestId(102),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let renamed =
|
||||
ops::lsp::lsp_rename(&host, "main", RequestId(103), doc.id, Pos::new(0, 5), "new😀".into()).await.unwrap();
|
||||
assert_eq!(renamed.edit.preconditions.len(), 2);
|
||||
assert_eq!(std::fs::read(&second).unwrap(), original_second, "preview preserves bytes");
|
||||
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit).await.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&second).unwrap(),
|
||||
ide_vfs::encode(&format!("ref new😀\nref other\n{padding}"), encoding, bom).unwrap(),
|
||||
"atomic rename preserves {encoding} BOM={bom}"
|
||||
);
|
||||
assert_eq!(project.documents.load_file(&first).unwrap().text, format!("def new😀\nref new😀\n{padding}"));
|
||||
project.documents.close(doc.id);
|
||||
}
|
||||
}
|
||||
|
||||
async fn native_rename_refusal(
|
||||
host: &Host,
|
||||
project: &ide_host::Project,
|
||||
doc: ide_core::DocId,
|
||||
path: &Path,
|
||||
text: &str,
|
||||
) {
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
struct Native(Arc<AtomicU8>);
|
||||
impl Handler for Native {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
|
||||
let mode = self.0.load(Ordering::SeqCst);
|
||||
Ok(match method {
|
||||
"initialize" => {
|
||||
json!({"capabilities": {"textDocumentSync": 1, "renameProvider": {"prepareProvider": true}}})
|
||||
}
|
||||
"textDocument/prepareRename" if mode < 2 => {
|
||||
json!({"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 11}})
|
||||
}
|
||||
"textDocument/prepareRename" if mode == 3 => {
|
||||
json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 3}})
|
||||
}
|
||||
"textDocument/rename" if mode == 1 => json!({"changes": {}}),
|
||||
_ => Value::Null,
|
||||
})
|
||||
}
|
||||
}
|
||||
let mode = Arc::new(AtomicU8::new(0));
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
|
||||
let accepted = peers.clone();
|
||||
let state = mode.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
let (reader, writer) = stream.into_split();
|
||||
accepted.lock().push(Peer::connect(reader, writer, Arc::new(Native(state.clone()))));
|
||||
}
|
||||
});
|
||||
let config: ide_language::LanguageServerConfig = toml::from_str(&format!(
|
||||
"name = 'rename-refusal'\ncommand = 'no-process-required'\nconnect = '127.0.0.1:{port}'"
|
||||
))
|
||||
.unwrap();
|
||||
project.lsp.set_servers_hook(Box::new(move |_, _| vec![config.clone()]));
|
||||
project.lsp.did_open(doc, path, &ide_core::LanguageId::plain_text(), 0, text);
|
||||
for value in 0..4 {
|
||||
mode.store(value, Ordering::SeqCst);
|
||||
let renamed =
|
||||
ops::lsp::lsp_rename(host, "main", RequestId(110 + value as u32), doc, Pos::new(0, 5), "new".into()).await;
|
||||
if value < 2 {
|
||||
assert_eq!(
|
||||
renamed.unwrap_err().kind,
|
||||
ErrorKind::InvalidInput,
|
||||
"matching owner declines via null/empty edit"
|
||||
);
|
||||
} else {
|
||||
let renamed = renamed.unwrap();
|
||||
assert!(renamed.preview);
|
||||
assert_eq!(renamed.edit.files.len(), 2, "unowned native range keeps indexed fallback");
|
||||
}
|
||||
}
|
||||
project.lsp.did_close(doc);
|
||||
project.lsp.shutdown_all().await;
|
||||
project.lsp.set_servers_hook(Box::new(|_, _| vec![]));
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
for peer in peers.lock().drain(..) {
|
||||
peer.kill();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type Events = Arc<Mutex<Vec<(Option<String>, PluginEvent)>>>;
|
||||
type EventReadGate = (std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>);
|
||||
|
||||
/// What plugins see of the test project: real `Documents`, as in the IDE.
|
||||
struct TestWorkspace {
|
||||
@@ -23,10 +24,12 @@ struct TestWorkspace {
|
||||
documents: Arc<ide_workspace::Documents>,
|
||||
trusted: bool,
|
||||
project_queries_left: Mutex<Option<usize>>,
|
||||
event_read_gate: Mutex<Option<EventReadGate>>,
|
||||
/// (producer, path) → published diagnostics.
|
||||
diagnostics: Mutex<std::collections::BTreeMap<(String, PathBuf), Vec<ide_core::Diagnostic>>>,
|
||||
/// plugin → its settings as the user set them.
|
||||
settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
|
||||
global_settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestWorkspace {
|
||||
@@ -41,8 +44,10 @@ impl TestWorkspace {
|
||||
documents: Arc::new(documents),
|
||||
trusted: true,
|
||||
project_queries_left: Mutex::new(None),
|
||||
event_read_gate: Mutex::new(None),
|
||||
diagnostics: Mutex::default(),
|
||||
settings: Mutex::default(),
|
||||
global_settings: Mutex::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,6 +79,13 @@ impl PluginWorkspace for TestWorkspace {
|
||||
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
|
||||
}
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
if path.ends_with("event-barrier.txt") {
|
||||
let gate = self.event_read_gate.lock().take();
|
||||
if let Some((entered, release)) = gate {
|
||||
entered.send(()).unwrap();
|
||||
release.recv_timeout(Duration::from_secs(10)).expect("release blocked event read");
|
||||
}
|
||||
}
|
||||
self.documents.text_of(path)
|
||||
}
|
||||
fn buffer_dirty(&self, path: &Path) -> bool {
|
||||
@@ -115,8 +127,9 @@ impl PluginWorkspace for TestWorkspace {
|
||||
self.diagnostics.lock().insert((source.to_owned(), path.to_path_buf()), items);
|
||||
Ok(())
|
||||
}
|
||||
fn plugin_settings(&self, plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
self.settings.lock().get(plugin).cloned().unwrap_or_default()
|
||||
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
let layer = if root.is_some() { &self.settings } else { &self.global_settings };
|
||||
layer.lock().get(plugin).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +289,7 @@ impl Fixture {
|
||||
workspace_root: Some(self.project.clone()),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,6 +697,460 @@ 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 private_directory_is_owned_persistent_and_removed_on_uninstall() {
|
||||
let f = fixture(Limits {
|
||||
timeout: Duration::from_secs(30),
|
||||
idle_timeout: Some(Duration::from_millis(100)),
|
||||
..Limits::default()
|
||||
});
|
||||
let pkg = sample("private-dir");
|
||||
f.host.install_from_dir(&pkg, &Consent::default()).unwrap();
|
||||
let run = |cmd: &str, arg: Option<&str>| f.host.run_command("test.private-dir", cmd, arg, Invocation::default());
|
||||
let dir = PathBuf::from(run("directory", None).unwrap().unwrap());
|
||||
assert!(!dir.starts_with(f.installed("test.private-dir")));
|
||||
assert_eq!(run("roundtrip", None).unwrap().as_deref(), Some("fake private data\n"));
|
||||
assert_eq!(run("large-cache", None).unwrap().as_deref(), Some("83886080"));
|
||||
let file = dir.join("fake-secret.txt");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
for name in ["fake-secret.txt", "cache.bin"] {
|
||||
assert_eq!(std::fs::metadata(dir.join(name)).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
for path in [f._tmp.path().join("secret.txt"), dir.parent().unwrap().join("adjacent.txt")] {
|
||||
std::fs::write(&path, "other content").unwrap();
|
||||
for cmd in ["read", "write"] {
|
||||
assert_eq!(run(cmd, path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "other content");
|
||||
}
|
||||
let other_pkg = f._tmp.path().join("other-plugin");
|
||||
copy_dir(&pkg, &other_pkg);
|
||||
let manifest = other_pkg.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest).unwrap().replace("test.private-dir", "test.private-dir-other");
|
||||
std::fs::write(manifest, text).unwrap();
|
||||
f.host.install_from_dir(&other_pkg, &Consent::default()).unwrap();
|
||||
f.host.run_command("test.private-dir-other", "roundtrip", None, Invocation::default()).unwrap();
|
||||
let other_dir = PathBuf::from(
|
||||
f.host.run_command("test.private-dir-other", "directory", None, Invocation::default()).unwrap().unwrap(),
|
||||
);
|
||||
for cmd in ["read", "write"] {
|
||||
assert_eq!(run(cmd, other_dir.join("fake-secret.txt").to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
}
|
||||
for path in [
|
||||
dir.join("../adjacent.txt"),
|
||||
dir.join("../../test.private-dir-other/files/fake-secret.txt"),
|
||||
dir.join("../../test.private-dir-other/files/new.txt"),
|
||||
] {
|
||||
assert_eq!(run("write", path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
if path.exists() {
|
||||
assert_eq!(run("read", path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
}
|
||||
}
|
||||
assert!(!other_dir.join("new.txt").exists());
|
||||
assert_eq!(std::fs::read_to_string(dir.parent().unwrap().join("adjacent.txt")).unwrap(), "other content");
|
||||
assert_eq!(std::fs::read_to_string(other_dir.join("fake-secret.txt")).unwrap(), "fake private data\n");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
symlink(other_dir.join("fake-secret.txt"), dir.join("escape.txt")).unwrap();
|
||||
symlink(&other_dir, dir.join("escape-dir")).unwrap();
|
||||
for path in [dir.join("escape.txt"), dir.join("escape-dir/fake-secret.txt")] {
|
||||
for cmd in ["read", "write"] {
|
||||
assert_eq!(run(cmd, path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
run("write", dir.join("escape-dir/new.txt").to_str()).unwrap_err().kind,
|
||||
ErrorKind::PermissionDenied
|
||||
);
|
||||
}
|
||||
// The same real file opens and saves through Documents, preserving privacy.
|
||||
f.workspace.documents.open(&file).unwrap();
|
||||
let doc = f.workspace.documents.by_path(&file).unwrap();
|
||||
f.workspace
|
||||
.documents
|
||||
.apply_edits(doc, &[ide_core::TextEdit { range: ide_core::Range::default(), new_text: "edited ".into() }])
|
||||
.unwrap();
|
||||
assert!(run("write", None).unwrap_err().message.contains("unsaved changes"));
|
||||
assert_eq!(run("read", None).unwrap().as_deref(), Some("edited fake private data\n"));
|
||||
f.workspace.documents.save(doc).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited fake private data\n");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
// Reload and idle reclaim never remove content.
|
||||
f.host.reload("test.private-dir").unwrap();
|
||||
assert_eq!(run("directory", None).unwrap().as_deref(), dir.to_str());
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
f.host.tick();
|
||||
assert_eq!(f.status("test.private-dir"), PluginStatus::Inactive);
|
||||
assert!(file.exists());
|
||||
f.host.uninstall("test.private-dir").unwrap();
|
||||
assert!(!dir.exists());
|
||||
assert!(other_dir.join("fake-secret.txt").exists());
|
||||
f.host.install_from_dir(&pkg, &Consent::default()).unwrap();
|
||||
assert!(run("read", None).unwrap_err().message.contains("no such file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_directory_is_available_to_providers_without_a_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
|
||||
f.host.set_workspace(None);
|
||||
f.host.run_command("test.private-dir", "roundtrip", None, Invocation::default()).unwrap();
|
||||
let doc = ide_plugin::ProviderDocument {
|
||||
path: f._tmp.path().join("outside.txt"),
|
||||
language: "private-fixture".into(),
|
||||
version: 1,
|
||||
text: String::new(),
|
||||
};
|
||||
assert!(eventually(|| f
|
||||
.host
|
||||
.provide_completion(&doc, ide_core::Pos::new(0, 0), None)
|
||||
.iter()
|
||||
.any(|(_, item)| item.label == "fake private data\n")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_directory_survives_host_restart_and_inactive_uninstall() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
|
||||
f.run("test.private-dir", "roundtrip", None).unwrap();
|
||||
let dir = PathBuf::from(f.run("test.private-dir", "directory", None).unwrap().unwrap());
|
||||
f.host.shutdown();
|
||||
let host =
|
||||
PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
|
||||
.unwrap();
|
||||
host.load_all();
|
||||
host.startup_finished();
|
||||
assert_eq!(
|
||||
host.run_command("test.private-dir", "read", None, Invocation::default()).unwrap().as_deref(),
|
||||
Some("fake private data\n")
|
||||
);
|
||||
host.shutdown();
|
||||
host.uninstall("test.private-dir").unwrap();
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_uninstall_cleanup_failure_is_retryable() {
|
||||
for fail_data in [true, false] {
|
||||
let f = fixture(Limits::default());
|
||||
let id = "test.private-dir";
|
||||
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
|
||||
f.run(id, "roundtrip", None).unwrap();
|
||||
let dir = PathBuf::from(f.run(id, "directory", None).unwrap().unwrap());
|
||||
let target = if fail_data { dir.parent().unwrap().to_path_buf() } else { f.plugins_dir.join(id) };
|
||||
let backup = target.with_extension("blocked-backup");
|
||||
// A regular file deterministically makes remove_dir_all fail, even as root.
|
||||
std::fs::rename(&target, &backup).unwrap();
|
||||
std::fs::write(&target, "controlled cleanup failure").unwrap();
|
||||
assert!(f.host.uninstall(id).is_err());
|
||||
assert!(f.host.info_of(id).is_ok(), "failed uninstall must retain its target");
|
||||
assert_eq!(f.status(id), PluginStatus::Failed);
|
||||
assert!(f.command_titles(id).is_empty());
|
||||
std::fs::remove_file(&target).unwrap();
|
||||
std::fs::rename(&backup, &target).unwrap();
|
||||
if fail_data {
|
||||
assert!(f.installed(id).join("plugin.toml").exists());
|
||||
f.host.shutdown();
|
||||
let host = PluginHost::new(
|
||||
f.plugins_dir.clone(),
|
||||
Arc::new(|_: Option<&str>, _: PluginEvent| {}),
|
||||
Limits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
host.load_all();
|
||||
host.uninstall(id).unwrap();
|
||||
} else {
|
||||
f.host.uninstall(id).unwrap();
|
||||
}
|
||||
assert!(!dir.exists());
|
||||
assert!(!f.plugins_dir.join(id).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_uninstall_recovers_after_package_metadata_is_removed() {
|
||||
for remove_entire_package in [false, true] {
|
||||
let f = fixture(Limits::default());
|
||||
let id = "test.private-dir";
|
||||
f.install(&sample("private-dir"));
|
||||
f.run(id, "roundtrip", None).unwrap();
|
||||
let package = f.installed(id);
|
||||
let root = f.plugins_dir.join(id);
|
||||
let backup = root.with_extension("blocked-backup");
|
||||
std::fs::rename(&root, &backup).unwrap();
|
||||
std::fs::write(&root, "controlled package cleanup failure").unwrap();
|
||||
assert!(f.host.uninstall(id).is_err());
|
||||
std::fs::remove_file(&root).unwrap();
|
||||
std::fs::rename(&backup, &root).unwrap();
|
||||
// Reproduce the metadata loss a partial recursive deletion can leave behind.
|
||||
if remove_entire_package {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
} else {
|
||||
std::fs::remove_file(root.join("active")).unwrap();
|
||||
std::fs::remove_file(package.join("plugin.toml")).unwrap();
|
||||
}
|
||||
if remove_entire_package {
|
||||
// An interrupted cleanup (and older records) has no saved failure yet.
|
||||
let record = f.plugins_dir.join(".uninstall").join(format!("{id}.json"));
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&std::fs::read(&record).unwrap()).unwrap();
|
||||
value.as_object_mut().unwrap().remove("error");
|
||||
std::fs::write(record, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
}
|
||||
f.host.shutdown();
|
||||
let host =
|
||||
PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
|
||||
.unwrap();
|
||||
host.load_all();
|
||||
assert!(host.info_of(id).is_ok(), "partial package cleanup must retain a restartable uninstall target");
|
||||
assert!(host.info_of(id).unwrap().error.is_some());
|
||||
assert_eq!(host.info_of(id).unwrap().status, PluginStatus::Failed);
|
||||
assert!(host.reload(id).is_err());
|
||||
assert!(host.set_enabled(id, true).is_err());
|
||||
assert_eq!(
|
||||
host.install_from_dir(&sample("private-dir"), &Consent::all()).unwrap_err().kind,
|
||||
ErrorKind::Unavailable
|
||||
);
|
||||
host.uninstall(id).unwrap();
|
||||
assert!(host.info_of(id).is_err());
|
||||
assert!(!root.exists());
|
||||
assert!(!f.plugins_dir.join(".uninstall").join(format!("{id}.json")).exists());
|
||||
host.install_from_dir(&sample("private-dir"), &Consent::all()).unwrap();
|
||||
host.uninstall(id).unwrap();
|
||||
assert!(!root.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_uninstall_cleans_up_a_package_with_a_damaged_manifest() {
|
||||
let f = fixture(Limits::default());
|
||||
let id = "test.private-dir";
|
||||
f.install(&sample("private-dir"));
|
||||
f.run(id, "roundtrip", None).unwrap();
|
||||
let dir = PathBuf::from(f.run(id, "directory", None).unwrap().unwrap());
|
||||
let package = f.installed(id);
|
||||
f.host.shutdown();
|
||||
std::fs::write(package.join("plugin.toml"), "broken = [").unwrap();
|
||||
let host =
|
||||
PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
|
||||
.unwrap();
|
||||
host.load_all();
|
||||
assert_eq!(host.info_of(id).unwrap().status, PluginStatus::Failed);
|
||||
host.uninstall(id).unwrap();
|
||||
assert!(!dir.exists());
|
||||
assert!(!f.plugins_dir.join(id).exists());
|
||||
|
||||
let broken = f.plugins_dir.join("Broken Package");
|
||||
std::fs::create_dir(&broken).unwrap();
|
||||
std::fs::write(broken.join("plugin.toml"), "broken = [").unwrap();
|
||||
let other = f.plugins_dir.join("invalid-broken-package");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
std::fs::write(other.join("keep.txt"), "untouched").unwrap();
|
||||
host.load_all();
|
||||
host.uninstall("Broken Package").unwrap();
|
||||
assert!(!broken.exists());
|
||||
assert_eq!(std::fs::read_to_string(other.join("keep.txt")).unwrap(), "untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_uninstall_cannot_be_restarted_through_lifecycle_actions() {
|
||||
let f = fixture(Limits::default());
|
||||
let id = "test.private-dir";
|
||||
f.install(&sample("private-dir"));
|
||||
f.run(id, "roundtrip", None).unwrap();
|
||||
let dir = PathBuf::from(f.run(id, "directory", None).unwrap().unwrap());
|
||||
let target = dir.parent().unwrap();
|
||||
let backup = target.with_extension("blocked-backup");
|
||||
std::fs::rename(target, &backup).unwrap();
|
||||
std::fs::write(target, "controlled cleanup failure").unwrap();
|
||||
assert!(f.host.uninstall(id).is_err());
|
||||
let error = f.host.info_of(id).unwrap().error.unwrap();
|
||||
for restart in [false, true] {
|
||||
let recovered;
|
||||
let host = if restart {
|
||||
f.host.shutdown();
|
||||
recovered = PluginHost::new(
|
||||
f.plugins_dir.clone(),
|
||||
Arc::new(|_: Option<&str>, _: PluginEvent| {}),
|
||||
Limits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
recovered.load_all();
|
||||
&recovered
|
||||
} else {
|
||||
&f.host
|
||||
};
|
||||
assert_eq!(host.info_of(id).unwrap().error.as_deref(), Some(error.as_str()));
|
||||
assert!(host.set_enabled(id, true).is_err());
|
||||
assert!(host.set_enabled(id, false).is_err());
|
||||
assert!(host.reload(id).is_err());
|
||||
assert!(host.grant(id, &Consent::default()).is_err());
|
||||
assert!(host.set_capability(id, "project.read", true).is_err());
|
||||
assert!(host.install_from_dir(&sample("private-dir"), &Consent::default()).is_err());
|
||||
assert!(host.roll_back_update(id).is_err());
|
||||
host.startup_finished();
|
||||
assert_eq!(host.info_of(id).unwrap().status, PluginStatus::Failed);
|
||||
assert!(host.run_command(id, "roundtrip", None, Invocation::default()).is_err());
|
||||
if restart {
|
||||
std::fs::remove_file(target).unwrap();
|
||||
std::fs::rename(&backup, target).unwrap();
|
||||
host.uninstall(id).unwrap();
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_uninstall_retains_failed_secret_cleanup_for_retry() {
|
||||
struct FailingSecrets;
|
||||
impl SecretStore for FailingSecrets {
|
||||
fn get(&self, _: &str, _: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
|
||||
Err("keychain locked".into())
|
||||
}
|
||||
}
|
||||
let f = fixture(Limits::default());
|
||||
let id = "test.private-dir";
|
||||
let pkg = f._tmp.path().join("private-with-secret");
|
||||
copy_dir(&sample("private-dir"), &pkg);
|
||||
let manifest = pkg.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest).unwrap().replace("required = []", "required = [\"secrets\"]");
|
||||
std::fs::write(
|
||||
manifest,
|
||||
format!("{text}\n[[contributes.secrets]]\nname = \"cleanup-test\"\nlabel = \"Cleanup test\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
f.install(&pkg);
|
||||
f.host.set_secret_store(Arc::new(FailingSecrets));
|
||||
assert!(f.host.uninstall(id).is_err());
|
||||
assert!(f.host.info_of(id).unwrap().error.unwrap().contains("keychain locked"));
|
||||
assert!(f.installed(id).join("plugin.toml").exists());
|
||||
f.host.shutdown();
|
||||
let host =
|
||||
PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
|
||||
.unwrap();
|
||||
host.load_all();
|
||||
assert!(host.info_of(id).unwrap().error.unwrap().contains("keychain locked"));
|
||||
host.uninstall(id).unwrap();
|
||||
assert!(host.info_of(id).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_uninstall_does_not_follow_cleanup_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
for relative in [".data", ".data/test.private-dir", "test.private-dir"] {
|
||||
let f = fixture(Limits::default());
|
||||
let id = "test.private-dir";
|
||||
f.install(&sample("private-dir"));
|
||||
f.run(id, "roundtrip", None).unwrap();
|
||||
let target = f.plugins_dir.join(relative);
|
||||
let backup = target.with_extension("blocked-backup");
|
||||
std::fs::rename(&target, &backup).unwrap();
|
||||
let outside = f._tmp.path().join("outside");
|
||||
copy_dir(&backup, &outside);
|
||||
std::fs::write(outside.join("keep.txt"), "untouched").unwrap();
|
||||
symlink(&outside, &target).unwrap();
|
||||
assert!(f.host.uninstall(id).is_err(), "{relative}");
|
||||
assert_eq!(std::fs::read_to_string(outside.join("keep.txt")).unwrap(), "untouched");
|
||||
std::fs::remove_file(&target).unwrap();
|
||||
std::fs::rename(&backup, &target).unwrap();
|
||||
f.host.uninstall(id).unwrap();
|
||||
assert!(f.host.info_of(id).is_err());
|
||||
assert_eq!(std::fs::read_to_string(outside.join("keep.txt")).unwrap(), "untouched");
|
||||
}
|
||||
let f = fixture(Limits::default());
|
||||
let id = "test.private-dir";
|
||||
f.install(&sample("private-dir"));
|
||||
let outside = f._tmp.path().join("outside");
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
std::fs::write(outside.join("keep.txt"), "untouched").unwrap();
|
||||
let nested = f.plugins_dir.join(".data").join(id).join("nested");
|
||||
std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
|
||||
symlink(&outside, nested).unwrap();
|
||||
f.host.uninstall(id).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(outside.join("keep.txt")).unwrap(), "untouched");
|
||||
for file in ["secrets.json", "secrets.tmp"] {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("private-dir"));
|
||||
let outside = f._tmp.path().join("outside-secret");
|
||||
std::fs::write(&outside, "untouched").unwrap();
|
||||
let target = f.plugins_dir.join(".data").join(id).join(file);
|
||||
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
symlink(&outside, &target).unwrap();
|
||||
assert!(f.host.uninstall(id).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "untouched");
|
||||
std::fs::remove_file(target).unwrap();
|
||||
f.host.uninstall(id).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "untouched");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_uninstall_blocks_inactive_reactivation_during_cleanup() {
|
||||
struct BlockingSecrets {
|
||||
entered: std::sync::Barrier,
|
||||
release: std::sync::Barrier,
|
||||
}
|
||||
impl SecretStore for BlockingSecrets {
|
||||
fn get(&self, _: &str, _: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
|
||||
self.entered.wait();
|
||||
self.release.wait();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let f = fixture(Limits::default());
|
||||
let pkg = f._tmp.path().join("private-with-secret");
|
||||
copy_dir(&sample("private-dir"), &pkg);
|
||||
let manifest = pkg.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest).unwrap().replace("required = []", "required = [\"secrets\"]");
|
||||
std::fs::write(
|
||||
manifest,
|
||||
format!("{text}\n[[contributes.secrets]]\nname = \"cleanup-test\"\nlabel = \"Cleanup test\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
f.install(&pkg);
|
||||
assert_eq!(f.status("test.private-dir"), PluginStatus::Inactive);
|
||||
let store = Arc::new(BlockingSecrets { entered: std::sync::Barrier::new(2), release: std::sync::Barrier::new(2) });
|
||||
f.host.set_secret_store(store.clone());
|
||||
let result = std::thread::scope(|scope| {
|
||||
let uninstall = scope.spawn(|| f.host.uninstall("test.private-dir"));
|
||||
store.entered.wait();
|
||||
let result = f.run("test.private-dir", "roundtrip", None);
|
||||
store.release.wait();
|
||||
uninstall.join().unwrap().unwrap();
|
||||
result
|
||||
});
|
||||
assert!(result.is_err(), "plugin must remain stopped during uninstall");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_paths_work_with_open_location_and_approved_process_arguments() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
|
||||
f.run("test.private-dir", "roundtrip", None).unwrap();
|
||||
let path = PathBuf::from(f.run("test.private-dir", "open", None).unwrap().unwrap());
|
||||
assert!(f.take_events().iter().any(|(client, event)| client.as_deref() == Some("main")
|
||||
&& matches!(event, PluginEvent::OpenLocation { path: opened, line: 0, column: 0, .. } if opened.0 == path)));
|
||||
assert!(f.run("test.private-dir", "process", None).unwrap_err().contains("process:cat"));
|
||||
f.host.set_capability("test.private-dir", "process:cat", true).unwrap();
|
||||
assert_eq!(f.run("test.private-dir", "process", None).unwrap().as_deref(), Some("fake private data\n"));
|
||||
}
|
||||
|
||||
/// A missing capability is an error the plugin can handle, not a trap.
|
||||
#[test]
|
||||
fn ungranted_capabilities_are_denied_not_trapped() {
|
||||
@@ -1987,8 +2455,10 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
|
||||
other_roots: Mutex::default(),
|
||||
documents: f.workspace.documents.clone(),
|
||||
trusted: false,
|
||||
event_read_gate: Mutex::default(),
|
||||
diagnostics: Mutex::default(),
|
||||
settings: Mutex::default(),
|
||||
global_settings: Mutex::default(),
|
||||
});
|
||||
f.host.set_workspace(Some(untrusted));
|
||||
let err = f.run("openidea.git-toolbox", "fetch", None).unwrap_err();
|
||||
@@ -2593,6 +3063,24 @@ fn official_regex_tester_answers_its_page() {
|
||||
assert_eq!(reply["matches"][0]["groups"][0], serde_json::json!([2, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_actions_choose_their_layer_without_losing_the_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("settings-pages"));
|
||||
for (global, name) in [(false, "Project"), (true, "Global")] {
|
||||
let values = serde_json::json!({ "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] });
|
||||
let layer = if global { &f.workspace.global_settings } else { &f.workspace.settings };
|
||||
layer.lock().insert("settings-pages".into(), values.as_object().unwrap().clone());
|
||||
}
|
||||
for (global_settings, name) in [(false, "Project"), (true, "Global"), (false, "Project")] {
|
||||
let inv = Invocation { global_settings, ..f.inv() };
|
||||
let result = f.host.run_command("settings-pages", "test", None, inv).unwrap().unwrap();
|
||||
let result: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(result["profiles"][0]["name"], name);
|
||||
assert_eq!(result["hasProject"], true);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tree filled from a pinned program's output, and a terminal for the
|
||||
/// activated entry in the invoking window, titled and sized by the plugin's
|
||||
/// settings (the `jobs` fixture).
|
||||
@@ -4470,6 +4958,20 @@ fn view_messages_accept_multi_megabyte_snapshots_and_cap_at_8_mib() {
|
||||
assert!(err.message.contains("limited to"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_content_fixture_installs_and_bridges_view_messages() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.host.install_from_dir(&sample("web-content"), &Consent::default()).unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
assert_eq!(f.run("web-content", "ping", None).unwrap().as_deref(), Some("Web content ready"));
|
||||
f.take_events();
|
||||
assert_eq!(f.run("web-content", "open", None).unwrap(), None);
|
||||
assert!(
|
||||
f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::ViewMessage { plugin_id, view, message }
|
||||
if plugin_id == "web-content" && view == "inspector" && message == "open"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_log_tabs_use_the_web_pty_permission_boundary() {
|
||||
use ide_host::plugins::web::WebPtyCommand;
|
||||
@@ -4514,6 +5016,59 @@ fn web_log_tabs_use_the_web_pty_permission_boundary() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `views.open-log-tab` from the plugin's code: the host remembers what the tab
|
||||
/// may run, and the tab starts it by key, through the same PTY checks.
|
||||
#[test]
|
||||
fn log_tabs_opened_by_code_run_only_what_the_host_approved() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.install_from_dir(&sample("web-log"), &Consent::default()).unwrap();
|
||||
let err = f.run("web-log", "code", None).unwrap_err();
|
||||
assert!(err.contains("process:bash"), "{err}");
|
||||
let options = |key: &str, window: &str, on: &[&str]| {
|
||||
let on: Vec<String> = on.iter().map(|s| s.to_string()).collect();
|
||||
f.host.log_tab_pty_options("web-log", key, window, &on, (240, 40))
|
||||
};
|
||||
assert_eq!(options("code", "main", &[]).unwrap_err().kind, ErrorKind::NotFound);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
f.host.set_capability("web-log", "process:bash", true).unwrap();
|
||||
f.take_events();
|
||||
assert_eq!(f.run("web-log", "code", None).unwrap(), None);
|
||||
let events = f.take_events();
|
||||
let (client, log) = events
|
||||
.iter()
|
||||
.find_map(|(client, event)| match event {
|
||||
PluginEvent::OpenLogTab { plugin_id, key, title, log }
|
||||
if plugin_id == "web-log" && key == "code" && title == "Probe logs (code)" =>
|
||||
{
|
||||
Some((client.clone(), log.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("an OpenLogTab event");
|
||||
assert_eq!(client.as_deref(), Some("main"));
|
||||
assert_eq!((log.program.as_str(), log.toggles[0].id.as_str()), ("bash", "verbose"));
|
||||
|
||||
let got = options("code", "main", &[]).unwrap();
|
||||
assert_eq!(got.shell.as_deref(), f.host.program_for("web-log", "bash").unwrap().to_str());
|
||||
assert_eq!(got.args, log.args);
|
||||
assert_eq!(got.cwd.0, f.project);
|
||||
assert!(got.env.is_empty() && !got.shell_integration);
|
||||
// Toggles append their arguments; unknown ones add nothing.
|
||||
let verbose = options("code", "main", &["verbose", "bogus"]).unwrap();
|
||||
assert_eq!(verbose.args.last().map(String::as_str), Some("--verbose"));
|
||||
assert_eq!(verbose.args.len(), log.args.len() + 1);
|
||||
// Only the window it was opened in, and only that key.
|
||||
assert_eq!(options("code", "other", &[]).unwrap_err().kind, ErrorKind::NotFound);
|
||||
assert_eq!(options("probe", "main", &[]).unwrap_err().kind, ErrorKind::NotFound);
|
||||
// A revoked grant stops it starting, and forgets the tab.
|
||||
f.host.set_capability("web-log", "process:bash", false).unwrap();
|
||||
assert!(options("code", "main", &[]).is_err());
|
||||
f.host.set_capability("web-log", "process:bash", true).unwrap();
|
||||
assert_eq!(options("code", "main", &[]).unwrap_err().kind, ErrorKind::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_ptys_require_a_current_exact_program_grant_and_project() {
|
||||
use ide_host::plugins::web::WebPtyCommand;
|
||||
@@ -4785,3 +5340,208 @@ fn marker_providers_preserve_names_and_package_paths() {
|
||||
["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"]
|
||||
);
|
||||
}
|
||||
|
||||
/// The released WIT variant stays unchanged: SDK decodes other(files-changed).
|
||||
#[test]
|
||||
fn files_changed_wakes_idle_once_and_keeps_every_delta() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("files-events"));
|
||||
assert!(f.host.event_kinds().contains(&EventKind::FilesChanged));
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
for i in 0..600 {
|
||||
f.host.post_events(
|
||||
Some("main"),
|
||||
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
|
||||
created: vec![to_ipc(&f.project.join(format!("新建😀-{i}.rs")))],
|
||||
modified: vec![to_ipc(&f.project.join("shared.rs"))],
|
||||
removed: vec![to_ipc(&f.project.join("gone.rs"))],
|
||||
})],
|
||||
);
|
||||
}
|
||||
// Already debounced by the watcher; no two-second idle delay.
|
||||
f.host.flush_events();
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let batches: Vec<ide_plugin::FilesChanged> =
|
||||
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(batches.len(), 1);
|
||||
assert_eq!(batches[0].created.len(), 600);
|
||||
assert_eq!(batches[0].modified, [to_ipc(&f.project.join("shared.rs"))]);
|
||||
assert_eq!(batches[0].removed, [to_ipc(&f.project.join("gone.rs"))]);
|
||||
for i in 0..3 {
|
||||
f.host.post_events(
|
||||
Some("main"),
|
||||
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
|
||||
modified: vec![to_ipc(&f.project.join(format!("delta-{i}.rs")))],
|
||||
..Default::default()
|
||||
})],
|
||||
);
|
||||
f.host.flush_events();
|
||||
}
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let batches: Vec<ide_plugin::FilesChanged> =
|
||||
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
|
||||
let mut later: Vec<_> = batches[1..].iter().flat_map(|b| b.modified.iter().cloned()).collect();
|
||||
later.sort();
|
||||
assert_eq!(
|
||||
later,
|
||||
(0..3).map(|i| to_ipc(&f.project.join(format!("delta-{i}.rs")))).collect::<Vec<_>>(),
|
||||
"later deltas must not cancel earlier batches"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_requires_events_capability() {
|
||||
let f = fixture(Limits::default());
|
||||
let dir = f._tmp.path().join("without-events");
|
||||
copy_dir(&sample("files-events"), &dir);
|
||||
let manifest = dir.join("plugin.toml");
|
||||
std::fs::write(
|
||||
&manifest,
|
||||
std::fs::read_to_string(&manifest).unwrap().replace("[\"events\", \"storage\"]", "[\"storage\"]"),
|
||||
)
|
||||
.unwrap();
|
||||
f.install(&dir);
|
||||
assert!(!f.host.event_kinds().contains(&EventKind::FilesChanged));
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged::default())]);
|
||||
f.host.flush_events();
|
||||
assert_eq!(f.host.instances(), 0, "an ungranted plugin must not wake");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_does_not_cancel_a_batch_blocked_in_a_host_call() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("files-events"));
|
||||
std::fs::write(f.project.join("event-barrier.txt"), "ready").unwrap();
|
||||
let (entered, waiting) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
*f.workspace.event_read_gate.lock() = Some((entered, released));
|
||||
f.host.post_events(
|
||||
Some("main"),
|
||||
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
|
||||
created: vec![to_ipc(&f.project.join("__block__.txt"))],
|
||||
..Default::default()
|
||||
})],
|
||||
);
|
||||
f.host.flush_events();
|
||||
waiting.recv_timeout(Duration::from_secs(10)).expect("first event reaches its host call");
|
||||
f.host.post_events(
|
||||
Some("main"),
|
||||
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
|
||||
removed: vec![to_ipc(&f.project.join("gone.txt"))],
|
||||
..Default::default()
|
||||
})],
|
||||
);
|
||||
f.host.flush_events();
|
||||
release.send(()).unwrap();
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let batches: Vec<ide_plugin::FilesChanged> =
|
||||
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(batches.len(), 2, "a later filesystem delta cannot discard an earlier one");
|
||||
assert_eq!(batches[0].created, [to_ipc(&f.project.join("__block__.txt"))]);
|
||||
assert_eq!(batches[1].removed, [to_ipc(&f.project.join("gone.txt"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_rechecks_filters_when_an_actor_batch_starts() {
|
||||
for filter in ["", "other/**"] {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("files-events"));
|
||||
f.run("files-events", "filter-after-event", Some(filter)).unwrap();
|
||||
std::fs::write(f.project.join("event-barrier.txt"), "ready").unwrap();
|
||||
let (entered, waiting) = std::sync::mpsc::channel();
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
*f.workspace.event_read_gate.lock() = Some((entered, released));
|
||||
f.host.post_events(
|
||||
Some("main"),
|
||||
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
|
||||
created: vec![to_ipc(&f.project.join("__block__.txt"))],
|
||||
..Default::default()
|
||||
})],
|
||||
);
|
||||
f.host.flush_events();
|
||||
waiting.recv_timeout(Duration::from_secs(10)).expect("first batch blocks before replacing its filter");
|
||||
f.host.post_events(
|
||||
Some("main"),
|
||||
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
|
||||
modified: vec![to_ipc(&f.project.join("queued.txt"))],
|
||||
..Default::default()
|
||||
})],
|
||||
);
|
||||
f.host.flush_events();
|
||||
release.send(()).unwrap();
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let batches: Vec<ide_plugin::FilesChanged> =
|
||||
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(batches.len(), 1, "filter {filter:?} must suppress the batch waiting in the actor");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_changed_filters_each_category_before_waking_idle_plugins() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() });
|
||||
f.install(&sample("files-events"));
|
||||
f.run("files-events", "filter", Some("config/*.yaml")).unwrap();
|
||||
assert!(eventually(|| {
|
||||
f.host.tick();
|
||||
f.host.instances() == 0
|
||||
}));
|
||||
let path = |relative: &str| to_ipc(&f.project.join(relative));
|
||||
f.host.post_events(
|
||||
Some("main"),
|
||||
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
|
||||
created: vec![path("src/unrelated.rs")],
|
||||
modified: vec![path("notes.txt")],
|
||||
removed: vec![path("elsewhere/a.yaml")],
|
||||
})],
|
||||
);
|
||||
f.host.flush_events();
|
||||
assert_eq!(f.host.instances(), 0, "unmatched batches do not wake an idle plugin");
|
||||
f.host.post_events(
|
||||
Some("main"),
|
||||
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
|
||||
created: vec![path("config/new.yaml"), path("src/unrelated.rs")],
|
||||
modified: vec![path("config/changed.yaml"), path("notes.txt")],
|
||||
removed: vec![path("config/gone.yaml"), path("elsewhere/a.yaml")],
|
||||
})],
|
||||
);
|
||||
f.host.flush_events();
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let received: Vec<ide_plugin::FilesChanged> =
|
||||
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
received,
|
||||
[ide_plugin::FilesChanged {
|
||||
created: vec![path("config/new.yaml")],
|
||||
modified: vec![path("config/changed.yaml")],
|
||||
removed: vec![path("config/gone.yaml")],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_header_none_needs_api_1_3_and_an_ungrouped_web_view() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("headerless");
|
||||
std::fs::create_dir_all(pkg.join("web")).unwrap();
|
||||
std::fs::write(pkg.join("web/index.html"), "<!doctype html><p>hi</p>").unwrap();
|
||||
let manifest = |engine: &str, view_fields: &str| {
|
||||
format!(
|
||||
"id = \"headerless\"\nname = \"Headerless\"\nversion = \"1.0.0\"\nengines.openidea = \"{engine}\"\n[[contributes.views]]\nid = \"panel\"\ntitle = \"Panel\"\nlocation = \"bottom\"\n{view_fields}\n"
|
||||
)
|
||||
};
|
||||
for (engine, fields, expected) in [
|
||||
("^1.2", "web = \"web/index.html\"\nheader = \"none\"", "new in plugin API 1.3"),
|
||||
("^1.3", "web = \"web/index.html\"\nheader = \"custom\"", "default or none"),
|
||||
("^1.3", "header = \"none\"", "give a web page"),
|
||||
("^1.3", "web = \"web/index.html\"\ngroup = \"g\"\nheader = \"none\"", "ungrouped"),
|
||||
] {
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest(engine, fields)).unwrap();
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err().message;
|
||||
assert!(err.contains(expected), "{fields}: {err}");
|
||||
}
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest("^1.3", "web = \"web/index.html\"\nheader = \"none\"")).unwrap();
|
||||
let info = f.install(&pkg);
|
||||
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
|
||||
assert_eq!(f.host.ui_items("views")[0].decl["header"], "none");
|
||||
}
|
||||
@@ -233,7 +233,7 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
|
||||
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.unwrap();
|
||||
desktop.set_program_path(path_var.clone());
|
||||
for dir in [official("csv-editor"), fixture("jobs")] {
|
||||
for dir in [official("csv-editor"), fixture("jobs"), fixture("settings-pages")] {
|
||||
let info = desktop.install_from_dir(&dir, &Consent::all()).unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
}
|
||||
@@ -269,8 +269,8 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
|
||||
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
|
||||
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs"]).await;
|
||||
assert_eq!(report.installed.len(), 2, "{report:?}");
|
||||
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs", "settings-pages"]).await;
|
||||
assert_eq!(report.installed.len(), 3, "{report:?}");
|
||||
|
||||
// CSV Editor: the document is the server's.
|
||||
let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap();
|
||||
@@ -329,5 +329,24 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(selected, "/srv/src/a.txt");
|
||||
for (scope, name) in [("global", "Global"), ("project", "Project")] {
|
||||
let values = json!({ "settings-pages": { "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] } });
|
||||
invoke(&peer, "settings_set", json!({ "scope": scope, "key": "plugins", "value": values.to_string() }))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for (scope, name) in
|
||||
[(None, "Project"), (Some("global"), "Global"), (Some("project"), "Project"), (None, "Project")]
|
||||
{
|
||||
let mut args = json!({ "pluginId": "settings-pages", "commandId": "test", "arg": null, "editor": null });
|
||||
if let Some(scope) = scope {
|
||||
args["settingsScope"] = json!(scope);
|
||||
}
|
||||
let result = invoke(&peer, "plugin_run_command", args).await.unwrap();
|
||||
let result: Value = serde_json::from_str(result.as_str().unwrap()).unwrap();
|
||||
assert_eq!(result["profiles"][0]["name"], name);
|
||||
assert_eq!(result["hasProject"], true);
|
||||
}
|
||||
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
@@ -2059,7 +2059,7 @@ impl LspManager {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(Renamed { edit: out.finish(WorkspaceEdit::default())?, incomplete })
|
||||
Ok(Renamed { preview: false, edit: out.finish(WorkspaceEdit::default())?, incomplete })
|
||||
}
|
||||
|
||||
/// The servers that can rename at `pos`, the first one when none can (a
|
||||
@@ -2096,7 +2096,7 @@ impl LspManager {
|
||||
let edit = query().await?;
|
||||
let empty = edit.files.is_empty();
|
||||
let w = scope::Widened::only((), empty, &t.server);
|
||||
return Ok(Renamed { edit, incomplete: w.incomplete });
|
||||
return Ok(Renamed { preview: false, edit, incomplete: w.incomplete });
|
||||
}
|
||||
let files = |e: &WorkspaceEdit| {
|
||||
e.files
|
||||
@@ -2122,7 +2122,7 @@ impl LspManager {
|
||||
let files =
|
||||
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
|
||||
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
|
||||
Ok(Renamed { edit, incomplete: w.incomplete })
|
||||
Ok(Renamed { preview: false, edit, incomplete: w.incomplete })
|
||||
}
|
||||
|
||||
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
|
||||
|
||||
@@ -369,6 +369,8 @@ pub enum ReferencesIncomplete {
|
||||
TooManyFiles,
|
||||
/// Files that mention the name were too large or unreadable to check.
|
||||
FilesSkipped,
|
||||
/// A native query failed; indexed anchors are still available.
|
||||
ServerFailed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
@@ -389,6 +391,10 @@ pub struct References {
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Renamed {
|
||||
/// Indexed names always require preview, including a single-file rename.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub preview: bool,
|
||||
pub edit: WorkspaceEdit,
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_extension::Capability;
|
||||
use parking_lot::Mutex;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -193,7 +193,7 @@ pub trait HostApi: Send + Sync {
|
||||
) -> Result<ProcessOutput, HostError>;
|
||||
/// Delivers every event of `kind`, dropping its path filter.
|
||||
fn subscribe(&self, kind: EventKind);
|
||||
/// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`.
|
||||
/// Delivers `kind` (`file-opened`, `file-saved`, `files-changed`) only for matching paths.
|
||||
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError>;
|
||||
fn unsubscribe(&self, kind: &str);
|
||||
/// Starts `cmd` and returns the handle to it without waiting.
|
||||
@@ -245,6 +245,9 @@ pub trait HostApi: Send + Sync {
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), HostError>;
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
|
||||
/// Opens a log tab `key` in `client`'s window following `log` (already
|
||||
/// checked against the plugin's grants and limits).
|
||||
fn open_log_tab(&self, client: &str, key: String, title: String, log: ServiceLog) -> Result<(), HostError>;
|
||||
/// The IDE's UI language as a BCP 47 tag.
|
||||
fn locale(&self) -> String;
|
||||
/// A setting of the plugin as JSON (project over global over the schema default).
|
||||
@@ -253,6 +256,10 @@ pub trait HostApi: Send + Sync {
|
||||
/// `fs.read` grant already covers: the unsaved buffer wins, then the disk.
|
||||
/// `max` bounds the bytes read.
|
||||
fn read_text_file(&self, real: &Path, max: usize) -> Result<String, HostError>;
|
||||
/// The plugin's private directory, independent of the current invocation.
|
||||
fn plugin_dir(&self) -> Result<PathBuf, HostError> {
|
||||
Err(HostError::Failed("private plugin storage is unavailable".into()))
|
||||
}
|
||||
/// Replaces the text of a file whose real path the caller already checked:
|
||||
/// in one step, keeping its encoding and BOM (a new file is UTF-8). Refused
|
||||
/// while an editor holds unsaved changes for it.
|
||||
@@ -648,6 +655,10 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
match e {
|
||||
WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p),
|
||||
WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p),
|
||||
WorkbenchEvent::FilesChanged(batch) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "files-changed".into(),
|
||||
data: serde_json::to_string(&batch).expect("file paths serialize"),
|
||||
}),
|
||||
WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p),
|
||||
WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p),
|
||||
WorkbenchEvent::Scheduled => wit_events::Event::Scheduled,
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct EditorContext {
|
||||
pub struct Invocation {
|
||||
pub client: Option<String>,
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
/// Settings actions can read the global layer while retaining their project context.
|
||||
pub global_settings: bool,
|
||||
pub editor: Option<EditorContext>,
|
||||
/// Files and folders the command was run on.
|
||||
pub selected_paths: Vec<PathBuf>,
|
||||
@@ -98,6 +100,7 @@ pub enum Prompt {
|
||||
pub enum EventKind {
|
||||
FileOpened,
|
||||
FileSaved,
|
||||
FilesChanged,
|
||||
EditorFocused,
|
||||
ActionPerformed,
|
||||
VcsChanged,
|
||||
@@ -105,9 +108,10 @@ pub enum EventKind {
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub const ALL: [Self; 6] = [
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::FileOpened,
|
||||
Self::FileSaved,
|
||||
Self::FilesChanged,
|
||||
Self::EditorFocused,
|
||||
Self::ActionPerformed,
|
||||
Self::VcsChanged,
|
||||
@@ -118,6 +122,7 @@ impl EventKind {
|
||||
match self {
|
||||
Self::FileOpened => "file-opened",
|
||||
Self::FileSaved => "file-saved",
|
||||
Self::FilesChanged => "files-changed",
|
||||
Self::EditorFocused => "editor-focused",
|
||||
Self::ActionPerformed => "action-performed",
|
||||
Self::VcsChanged => "vcs-changed",
|
||||
@@ -175,12 +180,21 @@ pub struct ActionPerformed {
|
||||
pub shortcut: Option<String>,
|
||||
}
|
||||
|
||||
/// Debounced worktree changes. Paths use the same absolute form as `file-saved`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct FilesChanged {
|
||||
pub created: Vec<String>,
|
||||
pub modified: Vec<String>,
|
||||
pub removed: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
FileOpened(String),
|
||||
FileSaved(String),
|
||||
FilesChanged(FilesChanged),
|
||||
EditorFocused(Option<String>),
|
||||
ActionPerformed(ActionPerformed),
|
||||
VcsChanged(String),
|
||||
@@ -192,6 +206,7 @@ impl WorkbenchEvent {
|
||||
match self {
|
||||
Self::FileOpened(_) => EventKind::FileOpened,
|
||||
Self::FileSaved(_) => EventKind::FileSaved,
|
||||
Self::FilesChanged(_) => EventKind::FilesChanged,
|
||||
Self::EditorFocused(_) => EventKind::EditorFocused,
|
||||
Self::ActionPerformed(_) => EventKind::ActionPerformed,
|
||||
Self::VcsChanged(_) => EventKind::VcsChanged,
|
||||
|
||||
@@ -117,8 +117,8 @@ pub(crate) mod views_bindings {
|
||||
}
|
||||
|
||||
use bindings::openidea::plugin::{
|
||||
commit, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage, tasks,
|
||||
types as wit, ui, vcs, views, workspace,
|
||||
commit, detail_types, diagnostics, editor, events, fs, invocation, log, net, process, secrets, settings, storage,
|
||||
tasks, types as wit, ui, vcs, views, workspace,
|
||||
};
|
||||
|
||||
/// A project handle: its root.
|
||||
@@ -223,13 +223,21 @@ impl StoreData {
|
||||
/// `path` as the real path an `fs.read:…` grant covers, or what the plugin
|
||||
/// should be told instead (`denied` naming the capability to declare).
|
||||
fn granted_file(&self, path: &str) -> Result<PathBuf, wit::Error> {
|
||||
sandbox::resolve_granted(&self.granted, path).map_err(Into::into)
|
||||
let mut granted = self.granted.clone();
|
||||
if let Ok(dir) = self.host.plugin_dir() {
|
||||
granted.push(Capability::FsRead(to_ipc(&dir)));
|
||||
}
|
||||
sandbox::resolve_granted(&granted, path).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// The same for `fs.write:…`: a file that does not exist yet is resolved
|
||||
/// through the directory it would be created in.
|
||||
fn writable_file(&self, path: &str) -> Result<PathBuf, wit::Error> {
|
||||
sandbox::resolve_writable(&self.granted, path).map_err(Into::into)
|
||||
let mut granted = self.granted.clone();
|
||||
if let Ok(dir) = self.host.plugin_dir() {
|
||||
granted.push(Capability::FsWrite(to_ipc(&dir)));
|
||||
}
|
||||
sandbox::resolve_writable(&granted, path).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn client(&self) -> Option<&str> {
|
||||
@@ -511,39 +519,6 @@ impl workspace::Host for StoreData {
|
||||
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
|
||||
}
|
||||
|
||||
fn symbols(
|
||||
&mut self,
|
||||
project: Resource<ProjectRes>,
|
||||
query: String,
|
||||
limit: u32,
|
||||
) -> Fallible<Vec<workspace::SymbolInfo>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if query.len() > 256 {
|
||||
return Ok(Err(wit::Error::Invalid("symbol query exceeds 256 UTF-8 bytes".into())));
|
||||
}
|
||||
let root = &self.table.get(&project)?.root;
|
||||
Ok(self
|
||||
.host
|
||||
.symbols(root, &query, limit.min(1000) as usize, &self.cancel.token(), self.deadline)
|
||||
.map(|symbols| {
|
||||
symbols
|
||||
.into_iter()
|
||||
.map(|s| workspace::SymbolInfo {
|
||||
name: s.name,
|
||||
kind: s.kind,
|
||||
container: s.container,
|
||||
location: wit::Location {
|
||||
path: to_ipc(&s.location.path),
|
||||
range: range_to_wit(&s.location.range),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
|
||||
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -624,6 +599,39 @@ impl workspace::HostProject for StoreData {
|
||||
Ok(self.host.find_files(&root, &glob, limit.min(MAX_FIND) as usize).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn symbols(
|
||||
&mut self,
|
||||
this: Resource<ProjectRes>,
|
||||
query: String,
|
||||
limit: u32,
|
||||
) -> Fallible<Vec<workspace::SymbolInfo>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if query.len() > 256 {
|
||||
return Ok(Err(wit::Error::Invalid("symbol query exceeds 256 UTF-8 bytes".into())));
|
||||
}
|
||||
let root = &self.table.get(&this)?.root;
|
||||
Ok(self
|
||||
.host
|
||||
.symbols(root, &query, limit.min(1000) as usize, &self.cancel.token(), self.deadline)
|
||||
.map(|symbols| {
|
||||
symbols
|
||||
.into_iter()
|
||||
.map(|s| workspace::SymbolInfo {
|
||||
name: s.name,
|
||||
kind: s.kind,
|
||||
container: s.container,
|
||||
location: wit::Location {
|
||||
path: to_ipc(&s.location.path),
|
||||
range: range_to_wit(&s.location.range),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map_err(to_wit))
|
||||
}
|
||||
|
||||
fn write_file(&mut self, this: Resource<ProjectRes>, path: String, text: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectWrite) {
|
||||
return Ok(Err(e));
|
||||
@@ -653,6 +661,8 @@ impl workspace::HostProject for StoreData {
|
||||
|
||||
impl editor::Host for StoreData {}
|
||||
|
||||
impl detail_types::Host for StoreData {}
|
||||
|
||||
impl editor::HostTextEditor for StoreData {
|
||||
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
|
||||
@@ -1143,7 +1153,38 @@ impl views::Host for StoreData {
|
||||
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn open_log_tab(&mut self, key: String, title: String, log: views::ProcessLog) -> Fallible<()> {
|
||||
if let Err(e) = self.require_interactive().and_then(|()| self.require(Capability::Process(log.program.clone())))
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(message) = check_log_tab(&key, &title, &log) {
|
||||
return Ok(Err(wit::Error::Invalid(message)));
|
||||
}
|
||||
let Some(client) = self.invocation.client.clone() else {
|
||||
return Ok(Err(wit::Error::Invalid("a log tab opens from a command or event of a window".into())));
|
||||
};
|
||||
let log = crate::ServiceLog {
|
||||
program: log.program,
|
||||
args: log.args,
|
||||
toggles: log
|
||||
.toggles
|
||||
.into_iter()
|
||||
.map(|t| crate::ServiceLogToggle { id: t.id, label: t.label, args: t.args })
|
||||
.collect(),
|
||||
};
|
||||
Ok(self.host.open_log_tab(&client, key, title, log).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
|
||||
if let Ok(dir) = self.host.plugin_dir()
|
||||
&& let Ok(real) = sandbox::resolve_granted(&[Capability::FsRead(to_ipc(&dir))], &path)
|
||||
{
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
return Ok(self.host.open_location(self.client(), &real, line, column).map_err(to_wit));
|
||||
}
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
@@ -1161,7 +1202,7 @@ impl views::Host for StoreData {
|
||||
|
||||
impl settings::Host for StoreData {
|
||||
fn get(&mut self, key: String) -> wasmtime::Result<Option<String>> {
|
||||
let root = self.invocation.workspace_root.clone();
|
||||
let root = if self.invocation.global_settings { None } else { self.invocation.workspace_root.clone() };
|
||||
Ok(self.host.setting(root.as_deref(), &clip(key, 128)))
|
||||
}
|
||||
}
|
||||
@@ -1204,6 +1245,10 @@ impl From<sandbox::Refusal> for wit::Error {
|
||||
/// Files outside the open projects, gated by `fs.read:<path>` and
|
||||
/// `fs.write:<path>` grants.
|
||||
impl fs::Host for StoreData {
|
||||
fn plugin_dir(&mut self) -> Fallible<String> {
|
||||
Ok(self.host.plugin_dir().map(|dir| to_ipc(&dir)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn read_file(&mut self, path: String) -> Fallible<String> {
|
||||
let real = match self.granted_file(&path) {
|
||||
Ok(real) => real,
|
||||
@@ -1281,6 +1326,27 @@ impl fs::Host for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
/// The limits of a log tab, the same a web view's `open-log-tab` has.
|
||||
fn check_log_tab(key: &str, title: &str, log: &views::ProcessLog) -> Result<(), String> {
|
||||
let line = |s: &str, max: usize| !s.is_empty() && s.chars().count() <= max && !s.contains(['\0', '\r', '\n']);
|
||||
let args = |a: &[String]| a.iter().all(|a| a.len() <= 4096 && !a.contains('\0'));
|
||||
let total = log.args.len() + log.toggles.iter().map(|t| t.args.len()).sum::<usize>();
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
if !line(key, 128) || !line(title, 256) {
|
||||
Err("a log tab's key (at most 128 characters) and title (256) are one nonempty line".into())
|
||||
} else if !line(&log.program, 256) || log.program.trim() != log.program {
|
||||
Err(format!("{:?} is not a program name", log.program))
|
||||
} else if log.toggles.len() > 32
|
||||
|| log.toggles.iter().any(|t| !line(&t.id, 64) || !line(&t.label, 128) || !ids.insert(t.id.as_str()))
|
||||
{
|
||||
Err("at most 32 toggles, with unique ids (at most 64 characters) and labels (128)".into())
|
||||
} else if total > 256 || !args(&log.args) || log.toggles.iter().any(|t| !args(&t.args)) {
|
||||
Err("at most 256 arguments in all, of at most 4096 bytes each".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn too_much_to_write(path: &str) -> wit::Error {
|
||||
wit::Error::Invalid(format!("{path}: more than {} MiB to write", sandbox::MAX_WRITE_BYTES >> 20))
|
||||
}
|
||||
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
54bad5e7b66e800a70e9e8a3189be3c18b815b1020f0718e9f63b6d0d116f700
|
||||
6d7d707c7492d487fbd9debae791ad7e3755ac005f1f518efa54e6536ec59f08
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
d3094365391dba5c9d65f506b4faa7abf3e4cf3906b94bbd058995efda105922
|
||||
652977e0bf63bc7664807016998a4d4795d2a7eb841ee1912ef562057e5437cb
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3fe751d024341ab4f62aa30916c51708d371e52c8b9e7b7518332ed93b24395a
|
||||
42d3856b634bdaa69e21f4a42a9f375b73c2d19753a8986b82f2228bf92d37bc
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
1c5c1ce6d465aa201e39adf6c02e32f0d324d312b50371dd4f248b77ae8cda30
|
||||
cacd381d84e1070fffe8f0ac64fad91b768df63b759c0b3e5e2d22a6a2fdeb73
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
5f0877c4438f91dbb4069c733e9f2a214e366ec6e4c295c5c798a28ea75a5400
|
||||
0d418585dca868fbceb6432d06c5c838c3d42a9e99e7a9c7641199f25fb8fbe8
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-files-events"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "openidea-plugin-files-events"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
6caa94db0c866c0c7f86011dbf837b3f9edb1c891276a4d435a8d02e8f64e0cb
|
||||
@@ -0,0 +1,26 @@
|
||||
id = "files-events"
|
||||
name = "Filesystem events test"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*", "on-event:files-changed"]
|
||||
|
||||
[capabilities]
|
||||
required = ["events", "storage"]
|
||||
optional = ["project.read"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "events"
|
||||
title = "Read filesystem events"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "filter"
|
||||
title = "Filter filesystem events"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "filter-after-event"
|
||||
title = "Change the filter after the next event"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "unsubscribe"
|
||||
title = "Unsubscribe from filesystem events"
|
||||
@@ -0,0 +1,49 @@
|
||||
use openidea_plugin::{Error, Event, FilesChanged, Invocation, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FilesEvents;
|
||||
|
||||
impl Plugin for FilesEvents {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"events" => host::storage::get("events"),
|
||||
"filter" => {
|
||||
host::subscribe_paths("files-changed", &arg.unwrap_or_default().lines().collect::<Vec<_>>())?;
|
||||
Ok(None)
|
||||
}
|
||||
"filter-after-event" => {
|
||||
host::storage::set("next-filter", arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
}
|
||||
"unsubscribe" => {
|
||||
host::unsubscribe(openidea_plugin::EventKind::FilesChanged);
|
||||
Ok(None)
|
||||
}
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_events(&mut self, events: &[Event], ctx: &Invocation) {
|
||||
let mut received: Vec<FilesChanged> =
|
||||
host::storage::get("events").unwrap().map(|s| serde_json::from_str(&s).unwrap()).unwrap_or_default();
|
||||
for event in events {
|
||||
if let Event::FilesChanged(batch) = event {
|
||||
if batch.created.iter().any(|p| p.ends_with("/__block__.txt")) {
|
||||
let task = host::begin_task("Filesystem delta", false);
|
||||
ctx.project().unwrap().read_file("event-barrier.txt").unwrap();
|
||||
if task.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
received.push(batch.clone());
|
||||
if let Some(filter) = host::storage::get("next-filter").unwrap() {
|
||||
host::storage::delete("next-filter").unwrap();
|
||||
host::subscribe_paths("files-changed", &filter.lines().collect::<Vec<_>>()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(FilesEvents);
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ff562c4c62faa7eaf112e12ab839e6bef4dcbdee42fa84d949e6857065432214
|
||||
8d9c6bcd572b6fb54dd42c79d66d4971e55ec3dc072fc769f5634212c2927e22
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ea1066f976a18f765057a217b9de7a6c9871da321c80e0f7d4ccac6642dd44dd
|
||||
2af65b7db7edce8450f0ca829adb3b79fcb64b1ec3d39b785fb9df9423815e7b
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
206f442d5360fc87b9e46ba5fdb7b7d0d4dc5ed550fd33181921c46463b5ea6f
|
||||
40f80dcba2f370d02e2d5ad9609b21500fc42fc7c7202b439485a9cbf7baf727
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -8,3 +8,27 @@ syntax ranges. `invalid key` deliberately returns an out-of-bounds range.
|
||||
Used by `cargo test -p ide-host --test plugin_indexing` to exercise the optional
|
||||
WIT export, lifecycle invalidation, permissions, per-window isolation, candidate
|
||||
selection and warm memory-only lookups. It is not a marketplace plugin.
|
||||
|
||||
The fixture requires `project.read`. Its API 1.2 manifest is retained to check
|
||||
compatibility; Find Usages and Rename consume the same anchors on API 1.3 hosts.
|
||||
|
||||
Build with `plugins/build.sh crates/ide-plugin/tests/fixtures/indexer`, then load
|
||||
this directory as a development plugin in a trusted scratch project containing:
|
||||
|
||||
```text
|
||||
# definition.idx
|
||||
def route
|
||||
ref route
|
||||
|
||||
# usage.idx
|
||||
ref route
|
||||
```
|
||||
|
||||
The filenames and comment lines above describe two separate files. Invoke Find
|
||||
Usages on either `route` anchor: three locations should appear. Rename `route`
|
||||
to `new.route`: the preview must contain all three anchors. Cancel leaves both
|
||||
files unchanged; applying changes both files, and one Undo restores both.
|
||||
|
||||
Run `cargo test -p ide-host --test plugin_indexing --test plugin_refactoring`
|
||||
for the real Wasm graph and preview/apply/undo checks, including UTF-16 names,
|
||||
aliases, duplicate anchors and stale files.
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
1d647b76ef93c768c23b63b93d666d96323d19cb5011865e1acb5a8d178e0c3e
|
||||
a5ca61ba85297c52936cf9892ccf1c678383641c84a5a885389b24582fa1abfb
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
b777e29256630e58a6e92ef95c67db8d091d0fc07af24e8b91b087eaac4d269c
|
||||
5723b90f8a742f912b315209fc597e3ea7f428a2a234cf4ea5b4620454206d71
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
b82d03052dd638871e278a691ad1ed09108ad4999678e6462780d686967eaf95
|
||||
dd5d41490f53a3b9b03d45ee8aba9f3fb08dd9211e835db5d3f9f7c42d3b6f9a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
eb2efa2d8f0f1fd3579de8a965679755e99ed87b06faf528c20c6a558ca0e56c
|
||||
185557c699fc00796eb487f50c688c3e91d8895bd05d4185b492352c187f9d98
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
f72d5a14faaab7fc51dcefe37ba434615ffe47482772339e4aeffa0127f5ce79
|
||||
d047b5618051144ec754b481b6bc75fae428d96c83711e9f0edf78749df59016
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-private-dir"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-private-dir"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,6 @@
|
||||
A real SDK component exercising `fs.plugin-dir` during activation and commands
|
||||
without filesystem/project grants. `large-cache` writes an 80 MiB cache as ten
|
||||
8 MiB files, beyond the storage quota while respecting each filesystem write limit. It only writes fake text; `open` opens it in
|
||||
the editor and `process` passes its path to an explicitly approved `cat`.
|
||||
|
||||
Rebuild with `plugins/build.sh crates/ide-plugin/tests/fixtures/private-dir`.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
18957534fc011510894544b61e154a9d0972c27ab1872d9d2accd01864984fa0
|
||||
@@ -0,0 +1,42 @@
|
||||
id = "test.private-dir"
|
||||
name = "Private directory fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = []
|
||||
optional = ["process:cat"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "directory"
|
||||
title = "Private directory"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "roundtrip"
|
||||
title = "Write fake private content"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "read"
|
||||
title = "Read file"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "write"
|
||||
title = "Write file"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "open"
|
||||
title = "Open fake private content"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "process"
|
||||
title = "Read private content with approved cat"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "large-cache"
|
||||
title = "Write private cache larger than storage"
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["completion"]
|
||||
languages = ["private-fixture"]
|
||||
@@ -0,0 +1,63 @@
|
||||
use openidea_plugin::{Context, Error, Invocation, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct PrivateDir;
|
||||
|
||||
impl Plugin for PrivateDir {
|
||||
fn activate(&mut self, _: &Context) -> Result<(), Error> {
|
||||
// Activation has no project or focused window.
|
||||
host::fs::plugin_dir().map(drop)
|
||||
}
|
||||
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
let dir = host::fs::plugin_dir()?;
|
||||
let path = format!("{dir}/fake-secret.txt");
|
||||
match id {
|
||||
"directory" => Ok(Some(dir)),
|
||||
"roundtrip" => {
|
||||
host::fs::write_file(&path, "fake private data\n")?;
|
||||
let bytes = host::fs::read_bytes(&path)?;
|
||||
host::fs::write_bytes(&format!("{dir}/cache.bin"), &bytes)?;
|
||||
if host::fs::stat(&path)?.size != bytes.len() as u64 || host::fs::read_dir(&dir)?.len() < 2 {
|
||||
return Err("private filesystem roundtrip failed".into());
|
||||
}
|
||||
host::fs::read_file(&path).map(Some)
|
||||
}
|
||||
"large-cache" => {
|
||||
let bytes = vec![b'x'; 8 << 20];
|
||||
let mut total = 0;
|
||||
for index in 0..10 {
|
||||
let file = format!("{dir}/cache-{index}.bin");
|
||||
host::fs::write_bytes(&file, &bytes)?;
|
||||
total += host::fs::stat(&file)?.size;
|
||||
}
|
||||
Ok(Some(total.to_string()))
|
||||
}
|
||||
"read" => host::fs::read_file(arg.unwrap_or(&path)).map(Some),
|
||||
"write" => host::fs::write_file(arg.unwrap_or(&path), "fake replacement\n").map(|()| None),
|
||||
"open" => host::open_location(&path, 0, 0).map(|()| Some(path)),
|
||||
"process" => host::run_process("cat", &[path], None).map(|out| Some(out.stdout)),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl openidea_plugin::providers::Providers for PrivateDir {
|
||||
fn completion(
|
||||
&mut self,
|
||||
_: &openidea_plugin::providers::Document,
|
||||
_: openidea_plugin::Position,
|
||||
_: Option<&str>,
|
||||
) -> Vec<openidea_plugin::providers::CompletionItem> {
|
||||
let Ok(dir) = host::fs::plugin_dir() else { return Vec::new() };
|
||||
let path = format!("{dir}/fake-secret.txt");
|
||||
let Ok(text) = host::fs::read_file(&path) else { return Vec::new() };
|
||||
// Providers may read their content but must not open an editor.
|
||||
if host::open_location(&path, 0, 0).is_ok() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![openidea_plugin::providers::CompletionItem::new(&text, openidea_plugin::providers::CompletionKind::Text)]
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(PrivateDir, providers);
|
||||
+3
@@ -98,6 +98,8 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
@@ -149,6 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6d7583de2f1ce90bec0078e9d3e5fe63db2f1c0fa1e564dbe3c65327c1c6b045
|
||||
3562bc253b0b4f0fa1cb15b9c8a7470a09719b28008aa8f5f459bbaba8eda598
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-settings-pages"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "openidea-test-settings-pages"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Multi-page settings example (API 1.3)
|
||||
|
||||
Two IDE-rendered pages demonstrate grouped string lists and an editable model
|
||||
profile table. `Test` first applies pending settings, then runs the declared
|
||||
`test` command. With no profiles it returns an inline error; after adding a row
|
||||
it reads the saved JSON array and reports success through a notification.
|
||||
This fixture checks the settings/action flow; it does not contact a provider.
|
||||
|
||||
Build with `plugins/build.sh crates/ide-plugin/tests/fixtures/settings-pages`.
|
||||
Install or link this directory with `openidea-plugin link` and open
|
||||
Settings › Plugins › Model Profiles. Edit without Apply and Cancel to discard;
|
||||
Apply or OK saves. Running Test also saves pending edits before the command,
|
||||
so Cancel after Test does not undo those already-applied edits.
|
||||
|
||||
API keys belong in `[[contributes.secrets]]`, never table cells. A real Test
|
||||
connection command can use `host::fetch` with the appropriate declared network
|
||||
capability and secret-backed headers.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
f28b40f3708ec4f828d04f7cec29208b66d0ab6158b26c60c0bd989cdc74f9b4
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"properties": {
|
||||
"profiles": {
|
||||
"type": "array", "title": "Model profiles", "group": "Provider",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "title": "Name", "default": "Local" },
|
||||
"provider": { "type": "string", "title": "Provider", "enum": ["local", "openai"], "default": "local" },
|
||||
"baseUrl": { "type": "string", "title": "Base URL", "default": "http://localhost:11434" },
|
||||
"model": { "type": "string", "title": "Model", "default": "example-model" },
|
||||
"enabled": { "type": "boolean", "title": "Enabled", "default": true },
|
||||
"temperature": { "type": "number", "title": "Temperature", "default": 0.7 },
|
||||
"limit": { "type": "integer", "title": "Token limit", "default": 2048 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
id = "settings-pages"
|
||||
name = "Model Profiles"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "test"
|
||||
title = "Test model profile"
|
||||
|
||||
[contributes.settings]
|
||||
title = "Model Profiles"
|
||||
|
||||
[[contributes.settings.pages]]
|
||||
id = "templates"
|
||||
title = "Commit Template"
|
||||
schema = "templates.json"
|
||||
|
||||
[[contributes.settings.pages]]
|
||||
id = "llm"
|
||||
title = "LLM Settings"
|
||||
schema = "llm.json"
|
||||
|
||||
[[contributes.settings.pages.actions]]
|
||||
title = "Test"
|
||||
command = "test"
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Settings action example. A real connection test can use host::fetch with a
|
||||
//! declared network capability and a secret-backed Authorization header.
|
||||
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Profiles;
|
||||
|
||||
impl Plugin for Profiles {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
if id != "test" {
|
||||
return Err(Error::Invalid("unknown command".into()));
|
||||
}
|
||||
let json = host::setting("profiles").unwrap_or_else(|| "[]".into());
|
||||
let profiles: serde_json::Value = serde_json::from_str(&json).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
let rows = profiles.as_array().ok_or("model profiles must be an array")?;
|
||||
if rows.is_empty() {
|
||||
return Err(Error::Invalid("Add a model profile before testing".into()));
|
||||
}
|
||||
host::notify(NotificationLevel::Info, "Model Profiles", &format!("Read {} saved profile(s)", rows.len()));
|
||||
Ok(Some(serde_json::json!({ "profiles": profiles, "hasProject": ctx.project().is_some() }).to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Profiles);
|
||||
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user