Compare commits

...
Author SHA1 Message Date
l0ng-ai e81b7c1f36 fix(github): expose review settings in the native settings window 2026-11-08 15:59:58 +08:00
l0ng-ai 6d37f0876c fix(agents): restore the conversation card layout (#2089)
* fix(agents): restore the conversation card layout

* fix(agents): group consecutive author rows and anchor message footers
2026-11-08 12:37:57 +08:00
l0ng-ai 06a7aedb66 fix(agents): show the script inside a shell wrapper for every command approval (#2088) 2026-11-08 12:04:16 +08:00
l0ng-ai b746a0a3a9 fix(agents): flatten the conversation and tidy command approvals (#2083)
Flatten the agent conversation into a dense tool-window layout and show command approvals as an inline strip with the request's own command (shell wrapper unwrapped), standard buttons and no horizontal overflow.
2026-11-08 10:36:40 +08:00
l0ng-ai 2bf412ff0d feat(plugins): let plugin code open streaming log tabs (#2082)
views.open-log-tab(key, title, process-log) (SDK host::open_log_tab) opens the read-only log tab a web view opens with open-log-tab, from a command or event of a window, reusing the Services pane's process-log.

The host checks the process grant and limits when called and remembers what each key runs in that window; the tab starts it by key (plugin_log_tab_spawn) through the web-view PTY checks: pinned program, trusted project, session cap, lifecycle and grant revocation. Remote windows report that these tabs aren't available there yet.
2026-11-08 10:01:35 +08:00
l0ng-ai ec171c3188 feat(plugins): tidy the unreleased 1.3 API before it freezes (#2079)
- workspace.symbols becomes the project method project.symbols(query, limit), like find-files; the Rust SDK's Project::symbols is unchanged, components built against the previous main need a rebuild.
- The open-log-tab web message names its arguments args, as pty-spawn and the services process-log do; baseArgs is refused with log-error rather than ignored.
- Document the hover, semantic-tokens and diagnostics provider exports and their worlds.
2026-11-08 09:43:29 +08:00
Vadim LazukandInf1mum 32a778d28d feat(interpreters): add an existing Python virtual environment as an interpreter (#2076)
Refs #1907

Add Python Interpreter → Existing environment: Browse picks the environment folder, which is resolved right away (pyvenv.cfg, bin/python or Scripts\python.exe) and added to the interpreter list as "Python X.Y (<env name>)", then remembered. Picking the folder rather than the executable keeps the environment's identity, since the native file picker resolves the venv's python symlink to the base interpreter. Works for `python -m venv` and uv environments; a broken environment shows an error instead of being added. Probing runs off the command thread.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 09:32:37 +08:00
lleviathe 9673ccc687 fix(editor): keep indent guides straight and drop the column-0 guide (#1525)
Indent guides are drawn as one 1px strip per level in the `::after` overlay (so selections and mark backgrounds don't cover them), stay straight across wrapped and empty lines, and start at the first nested level: the column-0 guide is gone. Code lens rows keep the guides of the line they annotate.

Co-authored-by: lleviathe <[email protected]>
2026-11-08 09:08:38 +08:00
Vadim LazukandInf1mum fba893b36d fix(vcs): keep history revision lists off the git command line (#2075)
Refs #1538

Show History on a repository with many branches and tags failed on Windows with os error 206 ("文件名或扩展名过长"): default-scope history passed HEAD and every branch/tag tip as argv revisions, and the command line overflowed the ~32K limit. Revisions now count toward the same argv budget as pathspecs; when they overflow, revisions, `--` and paths go through `git log --stdin`. The same bound applies to every history walk in ide-vcs, including the shallow-clone `rev-list`. Small inputs produce the same command as before. Verified on macOS with 1500 tags on a deeply nested file and folder; Windows wasn't run natively.

Co-authored-by: Inf1mum <[email protected]>
2026-11-08 09:06:00 +08:00
Arnaud Ritti 905a672bad feat(plugins): add declarative multi-page settings (#2074)
Closes #1720

Plugins can declare searchable multi-page settings (API 1.3): pages under the plugin in Settings, groups, string lists, editable tables and command buttons, while single-schema plugins keep working. A command button applies pending edits first and runs against the selected Global/Project layer (also remotely); Reset keeps other pages' edits, and invalid table/list edits never replace saved rows. Field and page order come from the schema without changing serde_json's map ordering elsewhere in the app.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 08:38:42 +08:00
Arnaud Ritti 99102d8a40 fix(plugins): preserve uninstall retries and bound mock private paths (#2073)
Related #1947

Uninstall keeps a plugin registered but stopped until its private data, secrets and package are cleaned up; a failure is shown and Uninstall can be retried, also after a restart (a durable cleanup record outside the package restores the target). While cleanup is pending, enable, reload, consent, permission changes, reinstall, replacement and rollback are refused. Secret-deletion errors are reported, and cleanup never follows a symlinked ancestor outside plugin storage. The SDK mock checks private-path components and rejects traversal independently of filesystem grants.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 08:29:19 +08:00
Deng Liuanddengliu a2599a6f79 feat(plugins): plugin-backed content editor tabs with custom actions (#2045)
A plugin web view can open an editor tab with its own title, language (or file name for detection), content and toolbar actions (API 1.3). Clicking an action sends `{ action, content }` back to the plugin, which replies with new content and/or a status message, or left/right content shown in the IDE's diff viewer. The tab follows the editor document lifecycle: modified marker, close-with-changes prompt, read-only, and saves whose reply arrives after newer edits (or that the plugin rejects) keep the document dirty.

Co-authored-by: dengliu <[email protected]>
2026-11-08 08:09:40 +08:00
Deng Liuanddengliu bd1a3ce1e3 feat(plugins): tool-window web views can draw their own header (#2047)
A tool-window web view can declare `header = "none"` (API 1.3) to render its own header. The host then replaces the standard header with corner actions (Options, Hide) once the frame has loaded, and tells the frame the corner inset to keep free (CSS variables) so the actions don't cover the plugin's controls. Views without the declaration, and the loading and error states of every view, keep the standard header.

Co-authored-by: dengliu <[email protected]>
2026-11-08 08:02:21 +08:00
Arnaud Ritti 2329f1e955 feat(plugins): use indexed anchors for usages and rename (#2050)
Closes #1943

Find Usages and Rename use document-indexer definitions and references next to language-server results. An indexed rename previews every name anchor and applies through the existing atomic workspace edit with grouped undo, guarded by index synchronization, canonical paths and document snapshots; a superseded rename preparation can't revive an obsolete session. Language-server Find Usages and Rename are unchanged. Released WIT is unchanged.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 07:40:08 +08:00
Arnaud Ritti 63065bab64 feat(plugins): deliver batched filesystem events (#2049)
Closes #1948

Plugins receive debounced batches of files created, changed and removed outside the editor in open projects, as SDK `Event::FilesChanged` (delivered through `other("files-changed", …)`, so released WIT is unchanged), under the `events` capability and the plugin's path filters. Activation on these events requires API 1.3. Batches are coalesced per plugin through its actor, survive cancellation without losing deltas, and are dropped when the project or host shuts down.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 07:25:01 +08:00
Andrea Basileandandreabasile 7ecb452350 feat(remote): forward ports of a dev container (#2072)
Refs #1083

A dev container's ports can be forwarded: the Ports tool window shows in a dev container window, `forwardPorts` of devcontainer.json are forwarded on open (keeping the local number when free, falling back otherwise), and ports forwarded by hand are restored per folder and configuration. Each forwarded connection is a loopback listener whose connections run `docker exec … openidea-server --connect localhost <port>` through the container's own network, so no published ports or extra tools in the image are needed. The container tunnel lives in `ide-remote::container_tunnel`; tunnels own their connections and Docker children, so stopping a forward, closing the window or losing the connection leaves no listeners or processes behind (the same ownership fix applies to SSH forwards).

Co-authored-by: andreabasile <[email protected]>
2026-11-08 06:57:19 +08:00
826e5837b1 feat(agents): chat-style conversation view with folded tool runs, tags, jump-to-latest and retract (#2067)
* feat(agents): chat-style conversation view with folded tool runs, tags, jump-to-latest and retract (#1980)

- Lay the conversation out as chat: user messages on the right, agent replies on the left, each with an author row, a card, actions in the header and a time/duration footer.
- Fold consecutive reasoning and finished tool calls into one collapsible 'Reasoning - N tool calls' row with +added / -removed counts; running tools stay expanded.
- Render @file and /skill references in message text as distinct tags.
- Load earlier entries in batches without moving the viewport, offer 'Jump to Latest' when scrolled away, and defer loading while the scrollbar is dragged.
- Add 'Retract Message': stop the agent, drop that turn and every later one, and put the original text/images back in the composer.
- Drop the catalog entry for the replaced duration tooltip.

* fix(agents): preserve conversation scrolling and retract state

---------

Co-authored-by: SunZhiQiang <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-08 06:57:09 +08:00
Arnaud Ritti 04b1b863df feat(plugins): add persistent private filesystem directories (#2051)
Closes #1947

Adds API 1.3 `fs.plugin-dir`: a persistent owner-only directory per plugin (0700 directories / 0600 files on Unix, owner-only ACL on Windows), separate from the packaged code, storage and secrets. The plugin reads and writes under it without fs.* grants, can open files there in the editor and pass their paths to approved programs; editor saves keep the protection, restart keeps the content, uninstall removes it. Traversal and symlink escapes into other plugins' directories are rejected (real-Wasm regressions).

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 06:53:36 +08:00
Arnaud Ritti a5db5cdda8 fix(smoke): allow graceful server shutdown before forced cleanup (#2054)
The smoke harness killed the server's process group right after closing its stdin, so the host never got to shut down and tool processes it had spawned in their own groups could be left running. Close now waits for the server's graceful EOF shutdown (bounded at 15 s), then kills the server's own group and every independent tool group it owned, reporting group-kill errors only after cleaning the rest. Covered by `smoke/server.test.ts` (graceful, failed initialize, hung shutdown, signal exit, spawn failure, missing `ps`, coalesced frames, kill errors).

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 06:39:34 +08:00
l0ng-ai 0630ed0729 feat(editor): clipboard: whole-line paste, paste history dialog, copy path chooser, escape and imports on paste (#2014)
Whole-line copies and cuts paste above the caret line from every paste entry point (shortcut, menu, history, plain text), including a copied blank line. Paste from History opens a dialog with filter, multi-select, delete and preview. Copy Path/Reference (⇧⌘C) opens a chooser for absolute path, file name, content-root/source-root paths, path with line and reference. Text pasted into a string literal is escaped (Smart Keys setting; Paste as Plain Text bypasses it), and missing imports are added after a paste (Auto Import setting: Ask / Always / Never).
2026-11-08 06:13:21 +08:00
SunZhiQiang b2d30e0c95 fix(editor): keep tab titles in sync with renamed agent tasks (#2062)
Closes #1983

Tab labels read an editor kind's title only when the tab state changed, so renaming an agent task left its open tab with the old title. Agents now signal title changes through the workbench (`notifyTabTitlesChanged`), and tab labels re-render on it; tabs restored before the agent tasks loaded refresh once the tasks arrive.

Co-authored-by: panda-student <[email protected]>
2026-11-08 06:01:40 +08:00
SunZhiQiang ff7473a277 feat(composer): inline @file and /skill tags, commands anywhere, project drag-and-drop (#2066)
Closes #1981

@file references and /skill commands become inline tags anywhere in the prompt, in Home and in a conversation. Tags are edited as a unit (Backspace/Delete, arrows) and are not committed mid-IME composition. Files and folders dragged from the Project view become references, mapped into the task worktree; paths outside the project, traversal and relative paths are rejected, and a deleted reference can be dropped again.

Co-authored-by: panda-student <[email protected]>
2026-11-08 05:54:24 +08:00
SunZhiQiang 45a1132ece feat(vcs): group the Worktrees panel by repository and merge linked roots (#2063)
Closes #1984

Worktrees are grouped under their repository; a linked worktree opened as its own root joins its repository's group instead of a separate list. The current marker compares against the roots actually opened, so a project opened from a linked worktree no longer marks the main worktree as current. A root's listing error is still shown when its repository isn't listed by another root.

Co-authored-by: panda-student <[email protected]>
2026-11-08 05:51:25 +08:00
l0ng-ai 704001fd8a fix(i18n): translate the CTest, URL and JRE labels in Chinese (#2071)
The catalog test rejects Chinese text equal to its English source; CTest (CMake test configuration name) and the Tomcat dialog's URL: and JRE: labels were left as English.
2026-11-08 05:49:21 +08:00
347 changed files with 18043 additions and 1374 deletions

No files matched your search

Generated
+2
View File
@@ -4783,12 +4783,14 @@ dependencies = [
"ignore",
"notify",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"trash",
"windows-sys 0.61.2",
"zip",
]
+60 -6
View File
@@ -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 = &params["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
+1 -1
View File
@@ -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,
+28 -2
View File
@@ -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": [
+10 -9
View File
@@ -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(),
}
}
+22
View File
@@ -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 {
+33
View File
@@ -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);
}
}
}
+2 -2
View File
@@ -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()
+22 -1
View File
@@ -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"] {
+1 -1
View File
@@ -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 -1
View File
@@ -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.
+5
View File
@@ -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
View File
@@ -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 {
+79
View File
@@ -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");
}
}
+69 -2
View File
@@ -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))
}
}
}
+39 -14
View File
@@ -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(&copy, &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(&copy, &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");
}
}
+5 -13
View File
@@ -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.
+221
View File
@@ -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();
+358 -42
View File
@@ -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));
+89 -1
View File
@@ -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
}
+4
View File
@@ -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,
+272 -10
View File
@@ -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();
+67 -2
View File
@@ -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(
+7
View File
@@ -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,
});
+191
View File
@@ -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());
}
}
+7 -1
View File
@@ -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 {
+55
View File
@@ -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);
}
}
+394
View File
@@ -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();
}
}
+762 -2
View File
@@ -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");
}
+22 -3
View File
@@ -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();
}
+3 -3
View File
@@ -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`) ----
+6
View File
@@ -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>,
}
+13 -2
View File
@@ -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,
+16 -1
View File
@@ -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,
+104 -38
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]]
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+20
View File
@@ -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"]
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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