Compare commits

..
Author SHA1 Message Date
l0ng-ai 9e46d61dc0 feat(agents): incremental streaming, folded turns, approval tray and one Settings › Agents page (#2511)
* feat(agents): stream reply text as appends and repaint only changed blocks

The backend sends only the text added to a streaming message (AgentEvent::Append)
instead of the whole entry every flush. Replies render per top-level Markdown
block, keeping the DOM of blocks that didn't change, and streaming text is
revealed at a steady pace.

* feat(agents): fold finished turns and summarize work by what it did

A finished turn's work before its reply folds into one line under its message
("Reasoning · Ran 1 command · Read 4 files · 12s"), runs of tool calls say what
they did instead of how many there were, and reasoning stays open and bounded
while it streams. New Settings › Agents values choose turn folding, smooth text,
reasoning and tool call display, tool output expansion, the send key and what
sending does while the agent works; per-agent defaults are stored for new tasks.

* feat(agents): ask approvals and show progress above the composer

An approval the agent waits for is asked above the composer, answered with
1, 2, 3… when the focus isn't in a text field, and recorded in the
conversation once answered. Beside it: the plan's progress, the task's changed
files (opening the changes), running subagents and background work, queued
messages, and a line saying what the agent does now and for how long.

* feat(agents): one Settings › Agents page for every agent

Agents get their own settings group. Its page lists every agent this machine
runs, built in or installed from the ACP Registry, with what each needs
(update, not found, custom) beside the selected one's status, sign-in, the
settings its new tasks start with, and how it launches. Add Agent… opens the
ACP Registry in a dialog; Remove and Update act on registry agents in place.
Chat and MCP Server move under it, and Chat gains the sending and conversation
options.

* test(e2e): follow approvals to the tray and work into turn folds

* fix(agents): lay out agent defaults in rows and name reasoning in work summaries

* fix(settings): keep ⌘Enter as written in every locale

* feat(agents): show the ACP Registry as a list beside the selected agent

Add Agent lists registry agents compactly (name, and Installed or Update) and
shows the selected one's version, runtime, authors, description, links and
Install / Update / Remove beside the list, instead of a card per agent with
badges and a button on every row.

* fix(agents): open Add Agent at a fixed size with the list scrolling inside

* feat(agents): list every agent in one searchable list, installed first

Registry agents that can be installed now sit under the installed ones
instead of behind Add Agent. The selected agent's page puts Install,
Update and Remove next to its name and lays out Sign In, New Tasks and
Launch as titled sections with label rows.

* feat(settings): make Agents a group of Coding Agents, Chat and MCP Server

* feat(agents): show each task's agent icon and move chat placement to the options menu

Task rows and session tabs show the icon of the agent the task runs;
rows mark only tasks that are still working or need attention, since
their group already says the rest are ready. Chat in Tool Window and
Chat in Editor move from the header into the tool window's options
menu as a checked pair.

* fix(agents): put actions under a turn's final reply only, and keep selections clean

Interim messages of a turn no longer carry a time and action row, and
the final reply gets one once the turn ends. Code in replies uses a
tint of the text colour instead of an opaque fill, so a selection no
longer leaves light boxes around it, and diagram labels are skipped
when selecting across a reply.

* feat(toolwindows): let a window keep its options out of the title

A tool window can set plainTitle so its own options stay in the "⋮"
menu and its title gets no "▾". The Agents window uses it.

* fix(agents): line up the settings form of an agent

The selects of New Tasks share one width, and the label column is as
wide in every section, so the controls of New Tasks and Launch start
at the same place.

* fix(agents): call copying a task into a new one Fork everywhere

The task menu, the user message and the reply offered the same
operation as Copy to a New Task, Copy to a New Task From Here and
Branch Conversation from Here. They now read Fork Task and Fork from
Here.

* fix(agents): label the fork buttons and menu item just Fork

* fix(agents): label the task's terminal entry just Terminal

* fix(agents): fold the session header into the status line and quiet the chat chrome

- The task's terminal and changes buttons move to the end of the status line above
  the composer; the header row that repeated the tab's title and status is gone.
- The status line leaves out the model when the composer's picker shows it.
- Your messages show their actions and send time beside "You" on hover instead of
  in a row under them, so turns sit closer together.
- Session tabs show a dot only while something waits for you: an unread reply, a
  question or an error. A spinner still marks a running task.
- The composer's attach buttons are plain toolbar icons, the image one monochrome;
  in a narrow composer the model button drops the effort so the model stays readable.

* fix(agents): answer approvals only from the focused session and never cut a turn short to steer

Number keys answer an approval only while the focus is in its session.
With "Sends it into the turn", a message to an agent that can't take it
mid-turn (or a turn waiting for input) is queued instead of cancelling
the turn. A task waiting for you shows a dot instead of a spinner, and a
turn that ran into an error before its reply stays unfolded.

* fix(agents): keep text that streams while a conversation loads

Entry events that arrive while a conversation loads are applied on top
of the loaded copy instead of being lost to it, so a long reply no
longer reloads the whole conversation over and over. A reply that
streams on as appends marks its task unread even when its conversation
isn't open. The backend keeps only the length and end of the text it
sent, rather than a copy it compares in full on every flush.

* fix(agents): render HTML blocks whole and leave drawn diagrams alone while a reply streams

A reply with an HTML block (such as <details> around Markdown) renders
as one piece, so the block wraps what it should. While a reply streams,
only the text after its finished blocks is cut again, and a diagram
already drawn stays in place with its zoom and pan.

* fix(settings): read agent setting values it doesn't know as their defaults

A hand-edited agent setting with an unknown value, or a non-text value
in agent defaults, no longer makes the whole settings file be ignored.

* fix(agents): drop Open from a task's context menu

Clicking a task already opens it.
2026-11-10 19:50:46 +08:00
Deng Liu dba85e8716 perf(vcs): speed up git log indexing, commit details, and blame commit reveal (#2483) 2026-11-10 18:58:09 +08:00
arookieofc c25459317b feat(scheme): support a selection foreground colour (#2464)
* feat(scheme): support a selection foreground colour (#2446)

Settings › Editor › Color Scheme › General › Editor gains a
Selection foreground attribute next to Selection background, wired
to a new --fg-editor-selection token. The editor paints selected
text with it (.cm-openidea-selected, including the diff viewer);
unset it keeps the previous rendering, with syntax colours showing
through (token default: inherit). .icls import/export round trips
it via SELECTION_FOREGROUND.

* test(themes): palette themes inherit selection foreground from base

Solarized/One keep the base default (preserve syntax colours on
selection); record that intent in INHERITED_FROM_BASE.
2026-11-10 18:32:39 +08:00
arookieofc 0d8ad0370a feat(mybatis): navigate namespace, resultMap and sql/include refs (#2461) (#2473)
Mapper XML gains Go to Definition and gutter markers for
<mapper namespace> (the interface), resultMap=/extends= (resultMap
ids, namespace-qualified or same-file) and <include refid=>
(<sql> ids, with refid completion). Links resolve even when the
interface is missing; unresolvable refs link nowhere.

This covers the XML-side navigation of #2461; annotation SQL,
OGNL, type aliases and Java type refs remain open.
2026-11-10 18:23:33 +08:00
arookieofc 3e3088c525 fix(maven): recognize POMs with non-UTF-8 declared encodings (#2495)
Issue #2439's project declares encoding="GBK"; its pom.xml is GBK bytes,
so read_to_string failed and the project was silently skipped (Maven tool
window showed 'No maven project', reload/add did nothing) while CLI Maven
reads it fine. Decode XML files via BOM, the encoding declaration, UTF-8,
then a chardetng guess; use it for POM, parent-chain, source-root and
settings.xml reads.

Refs #2439
2026-11-10 18:12:57 +08:00
arookieofc 331987fc17 fix(vcs): dedupe recent date filters by label (#2454)
* fix(vcs): dedupe recent date filters by label (#2443)

The remembered date preset was compared by JSON, but its rolling
after/before timestamps are recomputed on every selection, so each
switch of the date filter appended a look-alike duplicate that
localStorage kept across restarts. Compare date values (and whole
snapshots) by label instead, and dedupe on read so already-corrupted
state heals itself.

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:46:10 +08:00
arookieofc e23073c1d4 feat(vcs): show worktree diff in the Compare tool window with Jump to Source and Swap Sides (#2486)
Branch and tag menus' Show Diff with Working Tree opened a dialog (or a
separate comparison window under Tauri). It now shows the worktree diff in
a sidebar Compare tool window instead, with Jump to Source opening the
selected file in the editor and Swap Sides exchanging the two diff sides.

Closes #2442
2026-11-10 17:46:00 +08:00
arookieofc 1699306eaa fix(vcs): make side-by-side diff divider draggable (#2441)
* fix(vcs): make side-by-side diff divider draggable

Allow dragging the middle band between unaligned panes to resize
left/right share (10-90%), with keyboard arrows, double-click
reset and localStorage persistence. Fixes #2437.

* test(vcs): cover diff divider drag, keys and reset

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:43:29 +08:00
arookieofc c01a538ea4 fix(db): connect to StarRocks via a MySQL-compatible preset (#2463)
* fix(db): connect to StarRocks via a MySQL-compatible preset (#2452)

StarRocks speaks the MySQL wire protocol but rejects the non-constant
SET sql_mode=(SELECT ...) handshake sqlx sends by default. Add a
StarRocks data source preset (MySQL driver, port 9030) that turns off
pipes_as_concat/no_engine_substitution, and recognize the starrocks URL
scheme.

* test(i18n): allowlist StarRocks product name in text catalog
2026-11-10 17:43:18 +08:00
arookieofc 7cc0d5769d fix(boot): ignore ResizeObserver loop also when delivered as an Error (#2474)
* fix(boot): ignore ResizeObserver loop also when delivered as an Error (#2468)

WebKit (Tauri on Linux) can report the benign loop notification with
an Error object attached, which bypassed the bare-message guard and
showed a fatal startup dialog. Match on the engine-generated message
instead; no genuine startup failure carries these strings.

* chore: fix README formatting for oxfmt check
2026-11-10 17:43:08 +08:00
arookieofc 9828f18300 fix(vcs): let Git Log ref labels size to content (#2450)
* fix(vcs): let Git Log ref labels size to content (#2447)

Remove the unconditional 180px per-label cap and let the refs row use
the full subject cell when space allows, shrinking with ellipsis (full
name kept in the title tooltip) only when space truly runs out.

* chore: fix README formatting (pre-existing oxfmt violation blocking CI)
2026-11-10 17:30:16 +08:00
arookieofc c580dd7c69 fix(editor): make Ctrl+wheel font zoom apply to all open editors (#2488)
Ctrl/Cmd+wheel (and the Increase/Decrease Font Size actions) only resized
the focused editor. They now step every open editor to the same value, and
newly opened editors inherit the shared zoom; Reset Font Size resets it.

Closes #2480
2026-11-10 17:30:06 +08:00
arookieofc da3db5cbd6 fix(build): scope Build Artifacts to the active file's module (#2491)
* fix(build): scope Build Artifacts to the active file's module

Build Artifacts always packaged the whole reactor (all 57 modules in the
reporter's project) because the frontend passed a null file. Route it
through moduleTarget like Build Module does, so Maven runs
'-pl <module> -am package' and Gradle runs '<module>:assemble'; with no
active file it still falls back to the whole project. Backend already
supported module-scoped artifacts for Maven and Gradle, no Rust change.

Refs #2476

* fix(build): translate the module-scoped Build Artifacts description

The new action description needs a zh catalog entry (the only complete
locale) and the old text must go from every catalog (the stale-entry
check covers all locales); otherwise textCatalog.test.ts fails.

Refs #2476
2026-11-10 17:29:54 +08:00
arookieofc d1bef52041 fix(keymap): keep Shift+F6 as a rename fallback in the Eclipse preset (#2498)
Alt+Shift+R is swallowed by overlays that hook the Alt+R family (NVIDIA
performance overlay, Xbox Game Bar), leaving Eclipse-preset users with no
way to trigger refactor/project/db rename. Shift+F6 (IntelliJ's rename,
free in this preset) now works as an alternative entry point.

Refs #1876
2026-11-10 17:29:45 +08:00
l0ng-ai 1d4c0e1cd4 docs: remove docs/ and keep plugin docs in plugins/ (#2503)
* docs: remove unreferenced user guides

Drop docs/command-line.md, databases.md, java.md, unity-external-editor.md
and docs/media/debug.jpg. Nothing in the repository links to them.
Plugin, theme and README assets under docs/ stay.

* docs: keep only plugin docs under docs/

Remove docs/themes.md and docs/data-directories.md. Move the upstream
Atom One source revisions into THIRD_PARTY_LICENSES.md, drop the links
to the removed page, and move the README's WeChat QR code to
.github/assets/.

* docs: move plugin docs next to plugins/README.md

docs/plugins/themes.md and indexing.md move to plugins/. Update the
theme doc generator, its test, the plugin template, AGENTS.md and every
link to the new paths.
2026-11-10 16:16:32 +08:00
l0ng-ai 2b80c75317 refactor(plugins): run only programs in plugin run targets (#2490)
Plugin run targets keep running programs, tests and package scripts inside
the target. Language servers and debuggers run on the host again:

- Remove the Language server targets settings page, the project-only
  `languageServerTargets` setting and the PHP / Node.js "Language server:
  run in target" option.
- Remove debugging through plugin targets; Debug on a plugin target is
  refused again. PHP Remote Debug and Python remote configurations with a
  path mapping debug programs in containers.
- Remove LSP / DAP message path mapping, read-only container documents and
  the `ide-core` path-mapping module that only these used.
- Run-target validation (API 1.5, unreleased) no longer has a `debug`
  operation, and debug start no longer takes a request id.
2026-11-10 15:27:31 +08:00
298 changed files with 5828 additions and 4650 deletions

No files matched your search

File renamed without changes.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"docs/plugins/themes.md",
"plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
+1 -1
View File
@@ -21,7 +21,7 @@ Visual style: dark, low-contrast greys, one cool accent
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
Generated
+2 -4
View File
@@ -4232,8 +4232,10 @@ name = "ide-build"
version = "0.1.40"
dependencies = [
"blake3",
"chardetng",
"dirs 6.0.0",
"dunce",
"encoding_rs",
"ide-core",
"serde",
"serde_json",
@@ -4274,7 +4276,6 @@ dependencies = [
"dunce",
"encoding_rs",
"libc",
"percent-encoding",
"serde",
"serde_json",
"specta",
@@ -4307,8 +4308,6 @@ dependencies = [
"ide-core",
"ide-jsonrpc",
"ide-language",
"ide-remote",
"ide-run",
"libc",
"memchr",
"parking_lot",
@@ -4456,7 +4455,6 @@ dependencies = [
"ignore",
"jsonschema",
"parking_lot",
"percent-encoding",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
+1 -1
View File
@@ -64,7 +64,7 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
### Contributors
+6 -2
View File
@@ -262,7 +262,7 @@ END OF TERMS AND CONDITIONS
The Solarized palettes in `src/styles/tokens.css` are adapted from
[Ethan Schoonover’s Solarized](https://github.com/altercation/solarized/tree/62f656a02f93c5190a8753159e34b385588d5ff3).
See [theme sources and mapping](docs/themes.md). MIT license:
MIT license:
```text
Copyright (c) 2011 Ethan Schoonover
@@ -290,7 +290,11 @@ THE SOFTWARE.
The One palettes and semantic syntax mapping in `src/styles/tokens.css` are
adapted from GitHub’s Atom One syntax and UI themes.
Source revisions are listed in [theme sources and mapping](docs/themes.md).
Source revisions:
[one-light-syntax d845790](https://github.com/atom/one-light-syntax/tree/d84579027410c576086dfca14d934c4bd74b0438),
[one-dark-syntax 9c96f44](https://github.com/atom/one-dark-syntax/tree/9c96f4454362267ac45322063e193ccf9d2debb1),
[one-light-ui a27bce3](https://github.com/atom/one-light-ui/tree/a27bce35088ecd323e44f5c2510bcf2d4e9efcf8),
[one-dark-ui 18c2143](https://github.com/atom/one-dark-ui/tree/18c2143d41e17b337fff6dc7024562c4d999be17).
Both syntax themes use the following MIT notice:
```text
+82 -2
View File
@@ -345,6 +345,8 @@ struct Task {
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
config_sent: Mutex<HashSet<(String, String)>>,
dirty: Mutex<BTreeSet<(Session, usize)>>,
/// How much of each session's streaming message was last sent, so the next flush sends the rest.
streamed: Mutex<HashMap<Session, Sent>>,
flush_scheduled: AtomicBool,
stderr: Mutex<VecDeque<String>>,
/// Set by `delete`; held while saving.
@@ -852,6 +854,7 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
task.emit_task();
@@ -877,6 +880,7 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
task.emit_task();
@@ -1129,6 +1133,7 @@ impl Task {
idle_epoch: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
streamed: Mutex::new(HashMap::new()),
flush_scheduled: AtomicBool::new(false),
stderr: Mutex::new(VecDeque::new()),
deleted: Mutex::new(false),
@@ -1250,11 +1255,12 @@ impl Task {
let indices = std::mem::take(&mut *self.dirty.lock());
let events: Vec<AgentEvent> = {
let s = self.state.lock();
let mut streamed = self.streamed.lock();
indices
.into_iter()
.filter_map(|(subagent, i)| {
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?.clone();
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?;
Some(entry_event(&self.id, &mut streamed, subagent, i, entry))
})
.collect()
};
@@ -2316,3 +2322,77 @@ fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root
let command = ide_mcp::stdio_command(executable, root);
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
}
/// What was sent of a streaming message: its first `bytes` (`utf16` UTF-16 units), which
/// ended with `tail`. A message only grows while it streams, so its tail tells a message
/// that went on from one that was replaced without comparing (or keeping) all of it.
struct Sent {
index: usize,
bytes: usize,
utf16: usize,
tail: String,
}
const SENT_TAIL: usize = 64;
impl Sent {
fn new(index: usize, text: &str) -> Self {
Self::after(index, text, 0, 0)
}
/// `text` as sent, of which the first `bytes` (`utf16` units) were sent before.
fn after(index: usize, text: &str, bytes: usize, utf16: usize) -> Self {
let mut start = text.len().saturating_sub(SENT_TAIL);
while !text.is_char_boundary(start) {
start -= 1;
}
let utf16 = utf16 + text[bytes..].encode_utf16().count();
Self { index, bytes: text.len(), utf16, tail: text[start..].to_owned() }
}
/// Whether `text` at `index` is this message, gone on.
fn extended_by(&self, index: usize, text: &str) -> bool {
self.index == index
&& text.len() > self.bytes
&& text.is_char_boundary(self.bytes)
&& text[..self.bytes].ends_with(&self.tail)
}
}
/// The event that brings the UI's copy of entry `i` up to date: only the new text when
/// it extends the message last sent for the session, else the whole entry.
fn entry_event(
task: &str,
streamed: &mut HashMap<Session, Sent>,
subagent: Session,
i: usize,
entry: &AgentEntry,
) -> AgentEvent {
let text = match entry {
AgentEntry::Agent { text } | AgentEntry::Thought { text } => Some(text),
_ => None,
};
let Some(text) = text else {
if streamed.get(&subagent).is_some_and(|sent| sent.index == i) {
streamed.remove(&subagent);
}
return AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() };
};
match streamed.get(&subagent).filter(|sent| sent.extended_by(i, text)) {
Some(sent) => {
let (bytes, from) = (sent.bytes, sent.utf16);
streamed.insert(subagent.clone(), Sent::after(i, text, bytes, from));
AgentEvent::Append {
task: task.to_owned(),
subagent,
index: i as u32,
from: from as u32,
text: text[bytes..].to_owned(),
}
}
None => {
streamed.insert(subagent.clone(), Sent::new(i, text));
AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() }
}
}
}
+9
View File
@@ -518,6 +518,15 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// Text was appended to entry `index` (an agent message or a thought): it read the first
/// `from` UTF-16 units of its text when the previous event for it was sent.
Append {
task: String,
subagent: Option<String>,
index: u32,
from: u32,
text: String,
},
/// An agent reported the usage limits of its account (shared by every task of `provider`).
Limits {
provider: String,
+36
View File
@@ -2532,3 +2532,39 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
}
}
}
#[test]
fn streamed_text_is_sent_as_appends() {
let mut streamed = HashMap::new();
let agent = |t: &str| AgentEntry::Agent { text: t.into() };
let first = entry_event("t", &mut streamed, None, 3, &agent("Hé"));
assert!(matches!(first, AgentEvent::Entry { index: 3, .. }), "the first sight of a message is whole");
let next = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there"));
assert!(
matches!(&next, AgentEvent::Append { subagent: None, index: 3, from: 2, text, .. } if text == " 👋 there"),
"{next:?}"
);
let sub = entry_event("t", &mut streamed, Some("s".into()), 3, &agent("Hé 👋 there!"));
assert!(matches!(sub, AgentEvent::Entry { .. }), "each session streams on its own");
let after = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there."));
assert!(
matches!(&after, AgentEvent::Append { from: 11, text, .. } if text == "."),
"from counts UTF-16 units: {after:?}"
);
let replaced = entry_event("t", &mut streamed, None, 3, &agent("Other"));
assert!(matches!(replaced, AgentEvent::Entry { .. }), "text that doesn't extend the sent text is sent whole");
let thought = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm…".into() });
assert!(matches!(thought, AgentEvent::Entry { index: 4, .. }), "a new message starts whole");
let tool = AgentEntry::Error { message: "x".into() };
entry_event("t", &mut streamed, None, 4, &tool);
let again = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm… ok".into() });
assert!(matches!(again, AgentEvent::Entry { .. }), "an entry replaced by another kind forgets its text");
let long = "é".repeat(5000);
entry_event("t", &mut streamed, None, 5, &agent(&long));
assert!(streamed[&None].tail.len() <= 64, "only the end of what was sent is kept");
let on = entry_event("t", &mut streamed, None, 5, &agent(&format!("{long}!")));
assert!(matches!(&on, AgentEvent::Append { from: 5000, text, .. } if text == "!"), "{on:?}");
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
}
+2
View File
@@ -7,7 +7,9 @@ license.workspace = true
[dependencies]
blake3.workspace = true
chardetng.workspace = true
dunce.workspace = true
encoding_rs.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
+23 -5
View File
@@ -499,7 +499,7 @@ pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSetti
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
/// `activeByDefault`.
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
let Some(root) = xml::read_text(settings).as_deref().and_then(xml::parse) else {
return Vec::new();
};
let active: Vec<String> =
@@ -519,7 +519,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(settings).ok()?;
let text = xml::read_text(settings)?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
@@ -545,7 +545,7 @@ impl Resolver {
fn pom(&mut self, path: &Path) -> Option<RawPom> {
self.poms
.entry(path.to_path_buf())
.or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
.or_insert_with(|| xml::read_text(path).as_deref().and_then(parse_pom))
.clone()
}
@@ -736,7 +736,7 @@ pub(crate) fn launch_inputs(
return None;
}
out.push(path.clone());
let text = std::fs::read_to_string(&path).ok()?;
let text = xml::read_text(&path)?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
@@ -1640,7 +1640,7 @@ fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(Pa
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
let Some(next) = xml::read_text(&pp) else { return chain };
pom = resolver.pom(&pp);
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
chain.push((pp, next));
@@ -2297,6 +2297,24 @@ mod tests {
assert!(!is_maven_project(&root.join("c")));
}
#[test]
fn non_utf8_pom_with_declared_encoding_is_a_build() {
// Issue #2439: a GBK `pom.xml` is not valid UTF-8; it must still load
// instead of vanishing as "No maven project".
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (bytes, _, _) = encoding_rs::GBK.encode(
"<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><groupId>g</groupId>\
<artifactId>应用</artifactId><version>1</version><name>演示</name></project>",
);
std::fs::write(root.join("pom.xml"), bytes.as_ref()).unwrap();
let model = load_with_repo(root, None);
assert_eq!(model.projects.len(), 1);
assert_eq!(model.projects[0].artifact_id, "应用");
assert_eq!(model.projects[0].display_name, "演示");
assert!(is_maven_project(root));
}
#[test]
fn aggregate_pom_in_src_is_a_build() {
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
+66
View File
@@ -3,6 +3,57 @@
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
use encoding_rs::Encoding;
use std::path::Path;
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
/// whole project as "No maven project" while CLI Maven reads it fine.
pub fn read_text(path: &Path) -> Option<String> {
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
}
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
/// every single-byte encoding, so the head scans before decoding), then
/// UTF-8, then a chardetng guess.
pub fn decode_bytes(bytes: &[u8]) -> String {
if let Some((enc, len)) = Encoding::for_bom(bytes) {
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
return text.into_owned();
}
if let Some(enc) = declared_encoding(bytes) {
let (text, _) = enc.decode_without_bom_handling(bytes);
return text.into_owned();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_owned();
}
let mut detector = chardetng::EncodingDetector::new();
detector.feed(bytes, true);
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
text.into_owned()
}
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
let mut rest = head.split("?>").next().unwrap_or(head);
while let Some(i) = rest.find("encoding") {
rest = &rest[i + "encoding".len()..];
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
let value = value.trim_start();
let (&quote, after) = value.as_bytes().split_first()?;
if quote != b'"' && quote != b'\'' {
continue;
}
let after = std::str::from_utf8(after).ok()?;
let end = after.find(quote as char)?;
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
return Some(enc);
}
}
None
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
@@ -210,4 +261,19 @@ mod tests {
assert!(parse("").is_none());
assert!(parse("just text").is_none());
}
#[test]
fn decodes_the_declared_encoding() {
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
let (bytes, _, _) =
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
let text = decode_bytes(&bytes);
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
// An unknown label falls back to UTF-8 rather than mis-decoding.
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
// No declaration: plain UTF-8 passes through untouched.
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
}
}
-1
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
dunce.workspace = true
percent-encoding = "2"
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
-1
View File
@@ -16,7 +16,6 @@ pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod path_mapping;
pub mod platform;
pub mod process;
pub mod project_dir;
-193
View File
@@ -1,193 +0,0 @@
//! Lexical path translation at protocol boundaries, independent of LSP or DAP.
use percent_encoding::{CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
const ESCAPES: &percent_encoding::AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b']')
.add(b'\\')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathMapping {
host: String,
target: String,
windows: bool,
}
#[derive(Clone, Copy)]
pub enum Direction {
ToTarget,
ToHost,
}
fn normalized(path: &str) -> String {
let path = path
.strip_prefix(r"\\?\UNC\")
.map(|p| format!("//{p}"))
.unwrap_or_else(|| path.strip_prefix(r"\\?\").unwrap_or(path).to_owned());
let path = path.replace('\\', "/");
if path == "/" { path } else { path.trim_end_matches('/').to_owned() }
}
pub fn file_path(uri: &str) -> Option<String> {
if !uri.get(..7)?.eq_ignore_ascii_case("file://") {
return None;
}
let rest = &uri[7..];
let path = if rest.starts_with('/') {
rest.to_owned()
} else {
let (authority, path) = rest.split_once('/')?;
if authority.eq_ignore_ascii_case("localhost") { format!("/{path}") } else { format!("//{authority}/{path}") }
};
let decoded = percent_decode_str(path.split(['?', '#']).next()?).decode_utf8().ok()?;
let path = decoded.strip_prefix('/').filter(|p| p.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
Some(path.to_owned())
}
pub fn file_uri(path: &str) -> String {
let path = normalized(path);
format!("file://{}{}", if path.starts_with('/') { "" } else { "/" }, utf8_percent_encode(&path, ESCAPES))
}
impl PathMapping {
pub fn new(host: &str, target: &str) -> Self {
let host = normalized(host);
let windows = host.as_bytes().get(1) == Some(&b':') || host.starts_with("//");
Self { host, target: normalized(target), windows }
}
pub fn map(&self, value: &str, direction: Direction) -> Option<String> {
let uri_path = file_path(value);
let is_uri = uri_path.is_some();
let path = normalized(uri_path.as_deref().unwrap_or(value));
let (from, to) = match direction {
Direction::ToTarget => (&self.host, &self.target),
Direction::ToHost => (&self.target, &self.host),
};
let prefix = path.get(..from.len())?;
let same = if self.windows && matches!(direction, Direction::ToTarget) {
prefix.eq_ignore_ascii_case(from)
} else {
prefix == from
};
if !same {
return None;
}
let suffix = &path[from.len()..];
if !suffix.is_empty() && !suffix.starts_with('/') && from != "/" {
return None;
}
// Never let lexical parent segments escape either mapped root.
if suffix.split('/').any(|p| p == ".." || p == ".") {
return None;
}
let mapped = format!("{}/{}", to.trim_end_matches('/'), suffix.trim_start_matches('/'));
let mapped = if suffix.is_empty() { to.clone() } else { mapped };
Some(if is_uri {
let suffix = value.find(['?', '#']).map(|i| &value[i..]).unwrap_or("");
format!("{}{suffix}", file_uri(&mapped))
} else if self.windows && matches!(direction, Direction::ToHost) {
mapped.replace('/', "\\")
} else {
mapped
})
}
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
rewrite_strings(value, &|s| self.map(s, direction));
}
}
/// Rewrites strings and object keys (WorkspaceEdit.changes uses URIs as keys).
/// Resolve tokens and command arguments remain opaque; source text is never a path.
pub fn rewrite_strings(value: &mut Value, map: &dyn Fn(&str) -> Option<String>) {
match value {
Value::String(s) => {
if let Some(mapped) = map(s) {
*s = mapped;
}
}
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_strings(v, map)),
Value::Object(fields) => {
let old = std::mem::take(fields);
for (key, mut value) in old {
if !matches!(key.as_str(), "data" | "arguments" | "text" | "newText" | "insertText") {
rewrite_strings(&mut value, map);
}
fields.insert(map(&key).unwrap_or(key), value);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn roots_encoding_boundaries_and_windows() {
for host in ["C:/Users/me/项目", r"C:\Users\me\项目", r"\\?\C:\Users\me\项目"] {
let m = PathMapping::new(host, "/app");
assert_eq!(m.map(r"\\?\c:\Users\me\项目\a b#%.php", Direction::ToTarget).unwrap(), "/app/a b#%.php");
assert_eq!(
m.map("file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php", Direction::ToTarget).unwrap(),
"file:///app/a%20b%23%25.php"
);
assert_eq!(
m.map("file:///app/a%20b%23%25.php", Direction::ToHost).unwrap(),
"file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php"
);
assert_eq!(m.map("/app/a.php", Direction::ToHost).unwrap(), r"C:\Users\me\项目\a.php");
for outside in ["/app2/a", "/vendor/a", "/app/../outside", "jdt://app/a"] {
assert!(m.map(outside, Direction::ToHost).is_none());
}
}
let unc = PathMapping::new(r"\\?\UNC\server\share\project", "/app");
assert_eq!(unc.map("file://server/share/project/a.php", Direction::ToTarget).unwrap(), "file:///app/a.php");
assert_eq!(unc.map("/app/a.php", Direction::ToHost).unwrap(), r"\\server\share\project\a.php");
let m = PathMapping::new("/home/me/project", "/app");
assert_eq!(m.map("/home/me/project", Direction::ToTarget).unwrap(), "/app");
assert_eq!(m.map("file://localhost/app/a", Direction::ToHost).unwrap(), "file:///home/me/project/a");
assert!(m.map("/home/me/project2/a", Direction::ToTarget).is_none());
assert_eq!(
m.map("file:///app/a%23b?query#fragment", Direction::ToHost).unwrap(),
"file:///home/me/project/a%23b?query#fragment"
);
}
#[test]
fn nested_messages_resource_operations_and_opaque_payloads() {
let m = PathMapping::new("/host/project", "/app");
let mut v = json!({"rootUri":"file:///host/project", "rootPath":"/host/project", "workspaceFolders":[{"uri":"file:///host/project"}],
"edit":{"changes":{"file:///host/project/a":[]}, "documentChanges":[{"oldUri":"file:///host/project/a", "newUri":"file:///host/project/b"}]},
"relatedInformation":[{"location":{"uri":"file:///host/project/a"}}], "target":"file:///host/project/b",
"configuration":[{"includePath":"/host/project/vendor"}], "text":"/host/project/a", "newText":"/host/project/b", "data":{"uri":"file:///host/project/a"}, "command":{"arguments":["/host/project/a"]}});
let original = v.clone();
m.rewrite(&mut v, Direction::ToTarget);
assert_eq!(v["rootUri"], "file:///app");
assert_eq!(v["rootPath"], "/app");
assert!(v["edit"]["changes"].get("file:///app/a").is_some());
assert_eq!(v["edit"]["documentChanges"][0]["newUri"], "file:///app/b");
assert_eq!(v["relatedInformation"][0]["location"]["uri"], "file:///app/a");
assert_eq!(v["configuration"][0]["includePath"], "/app/vendor");
assert_eq!(v["data"], original["data"]);
assert_eq!(v["command"], original["command"]);
m.rewrite(&mut v, Direction::ToHost);
assert_eq!(v, original);
}
}
+131 -16
View File
@@ -48,8 +48,6 @@ pub struct Settings {
/// Per language server, by name (`rust-analyzer`): launch and protocol
/// options on top of the language definition, or a server defined here.
pub language_servers: BTreeMap<String, LanguageServerSettings>,
/// Project language id → server and plugin target. Empty target uses the host.
pub language_server_targets: BTreeMap<String, LanguageServerTarget>,
/// Editor › Inspections profiles.
pub inspections: InspectionSettings,
/// Editor › Natural Languages › Spelling.
@@ -61,16 +59,16 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Tools › Agents › Chat.
/// Settings › Agents.
pub agents: AgentSettings,
/// Tools › MCP Server (global only).
/// Agents › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
}
/// What the meters in an agent task's header show.
/// Settings › Agents: what the conversation shows and how messages are sent.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSettings {
@@ -82,15 +80,125 @@ pub struct AgentSettings {
pub show_usage_limits: bool,
/// What the session has cost so far, as the agent reports it.
pub show_cost: bool,
/// A finished turn's work folds into one line under its message, leaving the reply.
pub fold_turns: bool,
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
pub smooth_text: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = ThinkingDisplay)]
pub thinking: ThinkingDisplay,
#[serde(deserialize_with = "or_default")]
#[specta(type = ToolCallDisplay)]
pub tool_calls: ToolCallDisplay,
/// A tool call's output (a command's, a diff) is shown without opening it.
pub expand_tool_output: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = SendKey)]
pub send_with: SendKey,
/// What sending does while the agent works: queue the message for after the turn, or
/// send it into the turn.
#[serde(deserialize_with = "or_default")]
#[specta(type = WhileRunning)]
pub while_running: WhileRunning,
/// Settings new tasks of an agent start with: agent id → option id → value.
#[serde(deserialize_with = "agent_defaults")]
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
pub defaults: BTreeMap<String, BTreeMap<String, String>>,
}
impl Default for AgentSettings {
fn default() -> Self {
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
Self {
show_context_usage: true,
show_work_time: true,
show_usage_limits: true,
show_cost: false,
fold_turns: true,
smooth_text: true,
thinking: ThinkingDisplay::Auto,
tool_calls: ToolCallDisplay::Grouped,
expand_tool_output: false,
send_with: SendKey::Enter,
while_running: WhileRunning::Queue,
defaults: BTreeMap::new(),
}
}
}
/// Tools › MCP Server: agents use the IDE's built-in tools.
/// A hand-edited value these settings don't know reads as the default, rather than
/// failing the whole settings file.
fn or_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + Default,
{
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
}
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let serde_json::Value::Object(agents) = serde_json::Value::deserialize(d)? else { return Ok(BTreeMap::new()) };
Ok(agents
.into_iter()
.filter_map(|(agent, options)| match options {
serde_json::Value::Object(options) => Some((
agent,
options
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(v) => Some((k, v)),
_ => None,
})
.collect(),
)),
_ => None,
})
.collect())
}
/// How the agent's reasoning shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ThinkingDisplay {
/// Open while it streams, folded once it ends.
#[default]
Auto,
Expanded,
Collapsed,
}
/// How tool calls between messages show.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ToolCallDisplay {
/// A run of calls is one line saying what they did ("Read 4 files · Edited 2 files").
#[default]
Grouped,
/// Each call on its own line.
Each,
}
/// The key that sends a message; the other one starts a new line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum SendKey {
#[default]
Enter,
ModEnter,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum WhileRunning {
#[default]
Queue,
Steer,
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
@@ -141,14 +249,6 @@ pub struct ToolSettings {
pub path: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerTarget {
pub target: String,
/// Empty selects the first configured server for the language.
pub server: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerSettings {
@@ -243,7 +343,6 @@ impl Default for Settings {
external_tools: Vec::new(),
tools: BTreeMap::new(),
language_servers: BTreeMap::new(),
language_server_targets: BTreeMap::new(),
inspections: InspectionSettings::default(),
spelling: SpellingSettings::default(),
ssh: SshSettings::default(),
@@ -1361,6 +1460,22 @@ mod tests {
assert_eq!(restored.agents, settings.agents);
}
#[test]
fn hand_edited_agent_values_it_does_not_know_keep_the_rest() {
let settings: Settings = serde_json::from_str(
r#"{"editor":{"fontSize":17},"agents":{"thinking":"open","sendWith":"modEnter",
"defaults":{"claude":{"model":"opus","effort":3},"codex":"x"}}}"#,
)
.unwrap();
assert_eq!(settings.editor.font_size, 17.0);
assert_eq!(settings.agents.thinking, ThinkingDisplay::Auto);
assert_eq!(settings.agents.send_with, SendKey::ModEnter);
assert_eq!(
settings.agents.defaults,
BTreeMap::from([("claude".into(), BTreeMap::from([("model".into(), "opus".into())]))])
);
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
-2
View File
@@ -24,7 +24,5 @@ tracing.workspace = true
libc = "0.2"
[dev-dependencies]
ide-remote.workspace = true
ide-run.workspace = true
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
+2 -5
View File
@@ -31,7 +31,6 @@ pub enum Transport {
/// A fully resolved adapter command line.
#[derive(Clone, Debug)]
pub struct AdapterSpec {
pub target: Option<crate::target::TargetMapping>,
pub adapter: String,
pub command: String,
pub args: Vec<String>,
@@ -52,7 +51,6 @@ pub struct AdapterSpec {
/// Built-in adapters used when no language definition names `adapter`.
fn builtin(adapter: &str) -> Option<DebuggerConfig> {
let cfg = |command: &str, args: &[&str], transport: &str, hint: &str| DebuggerConfig {
target_placement: None,
adapter: adapter.to_owned(),
command: command.to_owned(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -145,7 +143,6 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
AdapterSpec {
target: None,
adapter: config.adapter.clone(),
command: config.command.clone(),
args: config.args.clone(),
@@ -546,7 +543,7 @@ impl AdapterLauncher for ProcessLauncher {
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
if spec.adapter == DEBUGPY_TOOL {
prepare_debugpy(&mut spec).await?;
}
if is_plugin_bundle(&spec.command) {
@@ -567,7 +564,7 @@ impl AdapterLauncher for ProcessLauncher {
}
};
let launch_after_configuration =
if spec.adapter == "gdb" && spec.target.is_none() { prepare_gdb(&spec, &binary).await? } else { false };
if spec.adapter == "gdb" { prepare_gdb(&spec, &binary).await? } else { false };
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => {
+2 -24
View File
@@ -71,7 +71,6 @@ pub enum Incoming {
type Pending = HashMap<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
struct Inner {
mapping: Option<crate::target::TargetMapping>,
out: mpsc::UnboundedSender<Vec<u8>>,
pending: Mutex<Pending>,
seq: AtomicI64,
@@ -107,22 +106,9 @@ impl Connection {
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
/// own output on stdout, decoded) to `on_output`.
pub fn start_with_output<R, W>(
reader: R,
writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start_with_target(reader, writer, on_output, None)
}
pub fn start_with_target<R, W>(
reader: R,
mut writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
target: Option<crate::target::TargetMapping>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
@@ -131,7 +117,6 @@ impl Connection {
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (in_tx, in_rx) = mpsc::unbounded_channel();
let inner = Arc::new(Inner {
mapping: target,
out: out_tx,
pending: Mutex::new(HashMap::new()),
seq: AtomicI64::new(1),
@@ -202,11 +187,7 @@ impl Connection {
if self.is_closed() {
return Err(DapError::Closed);
}
let mut value = value.clone();
if let Some(mapping) = self.inner.mapping.as_ref() {
mapping.rewrite(&mut value, ide_core::path_mapping::Direction::ToTarget);
}
let body = serde_json::to_vec(&value).map_err(|e| DapError::Protocol(e.to_string()))?;
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
self.inner.out.send(body).map_err(|_| DapError::Closed)
}
@@ -265,16 +246,13 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: unparsable message: {e}");
return;
}
};
if let Some(mapping) = inner.mapping.as_ref() {
mapping.rewrite(&mut msg, ide_core::path_mapping::Direction::ToHost);
}
match msg.get("type").and_then(Value::as_str) {
Some("response") => {
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
+4 -57
View File
@@ -16,7 +16,6 @@ pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
pub mod target;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
@@ -697,17 +696,6 @@ impl DebugManager {
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
self.start_prepared(config, owner, custom_command, |_, _| Ok(())).await
}
/// Configure a target adapter before any host tool resolution or launch.
pub async fn start_prepared(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
prepare: impl FnOnce(&mut AdapterSpec, &mut Value) -> IdeResult<()>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
@@ -722,22 +710,6 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
prepare(&mut spec, &mut arguments)?;
if spec.target.is_some() {
return self
.start_session(
DebugSessionId::next(),
owner,
config.name,
config.adapter,
None,
config.request,
arguments,
spec,
None,
)
.await;
}
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
@@ -902,23 +874,12 @@ impl DebugManager {
if request != "launch" && request != "attach" {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await.map_err(|e| {
if spec.target.is_some() {
IdeError::new(e.kind, format!("{}\nargv: {:?} {:?}", e.message, spec.command, spec.args))
} else {
e
}
})?;
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let launch_after_configuration = io.launch_after_configuration && request == "launch";
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_target(
io.reader,
io.writer,
Box::new(move |line| adapter::push_line(&stderr, line)),
spec.target.clone(),
)
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
@@ -959,9 +920,6 @@ impl DebugManager {
Err(e) => {
let e = if session.conn.is_closed() {
let mut msg = format!("debug adapter `{}` exited during startup", session.adapter);
if session.spec.target.is_some() {
msg.push_str(&format!("\nargv: {:?} {:?}", session.spec.command, session.spec.args));
}
let tail = stderr.lock().join("\n");
if !tail.is_empty() {
msg.push_str(":\n");
@@ -972,17 +930,6 @@ impl DebugManager {
msg.push_str(hint);
}
IdeError::unavailable(msg)
} else if session.spec.target.is_some() {
IdeError::new(
e.kind,
format!(
"{}\nargv: {:?} {:?}\n{}",
e.message,
session.spec.command,
session.spec.args,
stderr.lock().join("\n")
),
)
} else {
e
};
@@ -1020,8 +967,8 @@ impl DebugManager {
"pathFormat": "path",
"supportsVariableType": true,
"supportsVariablePaging": false,
"supportsRunInTerminalRequest": s.spec.target.is_none(),
"supportsStartDebuggingRequest": s.spec.target.is_none(),
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsProgressReporting": true,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
-4
View File
@@ -700,10 +700,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, command: &str, args: Value) {
if s.spec.target.is_some() {
let _ = s.conn.respond(seq, command, Err("This debugger does not support plugin run targets".into()));
return;
}
match command {
"runInTerminal" => {
let result = run_in_terminal(s, &args);
-72
View File
@@ -1,72 +0,0 @@
//! DAP path fields only: output, expressions, environment and program arguments are opaque.
use ide_core::path_mapping::{Direction, PathMapping, file_path, file_uri};
use serde_json::Value;
#[derive(Clone, Debug)]
pub struct TargetMapping {
pub paths: PathMapping,
pub external_prefix: String,
}
impl TargetMapping {
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
match value {
Value::Array(items) => items.iter_mut().for_each(|v| self.rewrite(v, direction)),
Value::Object(fields) => {
for (key, value) in fields {
if key == "pathMappings" {
continue;
}
if matches!(key.as_str(), "path" | "program" | "cwd" | "localRoot" | "remoteRoot") {
if let Some(path) = value.as_str() {
if matches!(direction, Direction::ToTarget) && path.starts_with(&self.external_prefix) {
if let Some(decoded) =
file_path(&format!("file:///{}", path.trim_start_matches(&self.external_prefix)))
{
*value = decoded.into();
}
} else if let Some(mapped) = self.paths.map(path, direction) {
*value = mapped.into();
} else if matches!(direction, Direction::ToHost) && key == "path" && path.starts_with('/') {
*value = format!(
"{}{}",
self.external_prefix,
file_uri(path).trim_start_matches("file:///")
)
.into();
}
}
} else if !matches!(key.as_str(), "args" | "env" | "output" | "expression" | "value") {
self.rewrite(value, direction);
}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_protocol_paths_without_changing_user_data_or_adapter_mappings() {
let m = TargetMapping {
paths: PathMapping::new("C:/project", "/app"),
external_prefix: "openidea-target://test/0/".into(),
};
let mut request = json!({"arguments":{"source":{"path":"C:/project/a.py"},"program":"C:/project/a.py","cwd":"C:/project", "args":["C:/project/text"],"env":{"path":"C:/project/text"},"pathMappings":[{"localRoot":"/app","remoteRoot":"/app"}]}});
m.rewrite(&mut request, Direction::ToTarget);
assert_eq!(request["arguments"]["source"]["path"], "/app/a.py");
assert_eq!(request["arguments"]["cwd"], "/app");
assert_eq!(request["arguments"]["args"][0], "C:/project/text");
assert_eq!(request["arguments"]["env"]["path"], "C:/project/text");
let mut response = json!({"body":{"stackFrames":[{"source":{"path":"/app/a.py"}},{"source":{"path":"/usr/lib/a.py"}}],"sources":[{"path":"/app/b.py"}],"output":"/app/a.py"}});
m.rewrite(&mut response, Direction::ToHost);
assert_eq!(response["body"]["stackFrames"][0]["source"]["path"], "C:\\project\\a.py");
assert_eq!(response["body"]["sources"][0]["path"], "C:\\project\\b.py");
assert_eq!(response["body"]["stackFrames"][1]["source"]["path"], "openidea-target://test/0/usr/lib/a.py");
assert_eq!(response["body"]["output"], "/app/a.py");
}
}
-1
View File
@@ -1369,7 +1369,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
target_placement: None,
adapter: "godot".into(),
command: command.into(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
-30
View File
@@ -1,30 +0,0 @@
import sys,json,os,socket
root=os.environ.get("FAKE_ROOT","/app")
r=sys.stdin.buffer; w=sys.stdout.buffer
seq=0
def send(v):
global seq
seq+=1; v['seq']=seq
b=json.dumps(v).encode(); w.write(('Content-Length: %d\r\n\r\n'%len(b)).encode()+b); w.flush()
while True:
line=r.readline()
if not line: break
n=int(line.split(b':')[1]); assert r.readline()==b'\r\n'
q=json.loads(r.read(n)); c=q['command']; a=q.get('arguments',{}); body={}
if c=='initialize': body={'supportsConfigurationDoneRequest':True}
if c=='launch':
if 'program' in a: assert a['program']==root+'/main.py',a
if os.environ.get('FAKE_CONNECT_BACK'):
listener=socket.socket(); listener.bind(('127.0.0.1',a['port'])); listener.listen()
assert a['cwd']==root,a
send({'type':'event','event':'initialized'})
if c=='setBreakpoints':
assert a['source']['path']==root+'/main.py',a
body={'breakpoints':[{'verified':True,'line':3}]}
if c=='threads': body={'threads':[{'id':1,'name':'target main'}]}
if c=='configurationDone': send({'type':'event','event':'stopped','body':{'reason':'breakpoint','threadId':1,'allThreadsStopped':True}})
if c=='stackTrace' and os.environ.get('FAKE_CONNECT_BACK'):
client,_=listener.accept(); assert client.recv(4096).decode()==root+'/main.py'; client.close()
if c=='stackTrace': body={'stackFrames':[{'id':1,'name':'main','line':3,'column':1,'source':{'path':root+'/main.py'}}]}
send({'type':'response','request_seq':q['seq'],'command':c,'success':True,'body':body})
if c=='disconnect': break
+3 -17
View File
@@ -93,23 +93,9 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
.await
.unwrap();
let id = manager
.start_prepared(
listen_config(port, &served, &served),
SessionOwner::new("main", sink.clone()),
|_| None,
|spec, _| {
spec.command = adapter.clone();
spec.cwd = local.clone();
spec.target = Some(ide_dap::target::TargetMapping {
paths: ide_core::path_mapping::PathMapping::new(
&local.to_string_lossy(),
&served.to_string_lossy(),
),
external_prefix: "openidea-target://php/0/".into(),
});
Ok(())
},
)
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
-130
View File
@@ -1,130 +0,0 @@
//! Real stdio transport through a target wrapper, without Docker or installed adapters.
#![cfg(unix)]
use ide_core::{SharedSink, path_mapping::PathMapping};
use ide_dap::{target::TargetMapping, *};
use ide_language::LanguageRegistry;
use std::{collections::BTreeMap, sync::Arc};
const ADAPTER: &str = include_str!("fixtures/target_adapter.py");
#[tokio::test]
async fn stdio_adapter_through_target_maps_launch_breakpoints_and_frames() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let adapter = root.join("adapter.py");
std::fs::write(&adapter, ADAPTER).unwrap();
let wrapper = root.join("wrapper");
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' \"$@\" > wrapper-argv\nexec \"$@\"\n").unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = ide_core::remote_settings::PluginRunTarget {
id: "fake".into(),
name: "Fake".into(),
program: "wrapper".into(),
args: vec![],
workdir_args: vec![],
container_root: Some("/app".into()),
commands: vec![],
};
// Keep the fixture adapter outside mapped argv; this is the container's executable.
let run: ide_run::RunConfig = serde_json::from_value(serde_json::json!({"id":"adapter","name":"Adapter","kind":"shell","command":"/usr/bin/python3","args":["-u","-c",ADAPTER],"cwd":".","env":{},"detected":false,"isTest":false})).unwrap();
let run = ide_remote::plugin_target::prepare(&root, &target, &wrapper, &run).unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Target".into(),
adapter: "debugpy".into(),
request: "launch".into(),
program: Some(root.join("main.py").to_string_lossy().into_owned()),
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: None,
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = run.command;
spec.args = run.args;
spec.cwd = root;
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(
frames[0].path.as_ref().unwrap().to_string_lossy(),
tmp.path().canonicalize().unwrap().join("main.py").to_string_lossy()
);
assert_eq!(frames[0].line, 2);
assert!(std::fs::read_to_string(tmp.path().join("wrapper-argv")).unwrap().starts_with("python3\n-u\n-c\n"));
manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn host_adapter_accepts_connect_back_from_target_with_mapped_sources() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let port = ide_dap::adapter::free_port().unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Connect back".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({"port":port,"pathMappings":{"/app":"/app"}}).to_string()),
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = "/usr/bin/python3".into();
spec.args = vec!["-u".into(), "-c".into(), ADAPTER.into()];
spec.env.push(("FAKE_CONNECT_BACK".into(), "1".into()));
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let code =
format!("import socket;s=socket.create_connection(('127.0.0.1', {port}));s.sendall(b'/app/main.py');s.close()");
let mut child = tokio::process::Command::new("/usr/bin/python3").args(["-c", &code]).spawn().unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
assert!(child.wait().await.unwrap().success());
manager.stop(id).await.unwrap();
}
+18
View File
@@ -594,6 +594,13 @@ fn is_identifier(name: &str) -> bool {
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
if vendor == "starrocks" { (false, false) } else { (true, true) }
}
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
fn mysql_value(v: &str) -> String {
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
@@ -839,6 +846,8 @@ impl Pool {
if let Some(c) = &setup.collation {
opts = opts.collation(c);
}
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
@@ -1465,6 +1474,15 @@ mod tests {
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
#[test]
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
// MySQL and its other wire-compatible presets keep sqlx's defaults.
for vendor in ["", "mysql", "mariadb", "tidb"] {
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
}
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
}
#[test]
fn sqlite_properties_become_pragmas() {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
+2 -1
View File
@@ -270,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
@@ -559,6 +559,7 @@ mod tests {
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
-1
View File
@@ -10,7 +10,6 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
percent-encoding = "2"
encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
@@ -208,7 +208,7 @@ authors = []
engines.openidea = "^1.0"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: plugins/themes.md.
[[contributes.themes]]
path = "themes/__SLUG__.theme.json"
editor-scheme = "themes/__SLUG__.scheme.json"
+2 -2
View File
@@ -56,7 +56,7 @@ pub struct HostConfig {
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: Arc<RwLock<std::sync::Weak<plugins::PluginHost>>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
@@ -125,7 +125,7 @@ impl Host {
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: Arc::default(),
plugin_host: RwLock::default(),
mcp_executable: RwLock::default(),
mcp_reports: Default::default(),
mcp_requests: Default::default(),
+1 -39
View File
@@ -1,5 +1,4 @@
mod output;
mod target;
use crate::Host;
use crate::rpc::Dispatcher;
@@ -9,42 +8,7 @@ use std::path::PathBuf;
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
debug_start_request(host, window, config, None).await
}
pub async fn debug_start_request(
host: &Host,
window: &str,
config: LaunchConfig,
request: Option<ide_core::RequestId>,
) -> IdeResult<DebugSessionId> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let result = debug_start_inner(host, window, config, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
result
}
async fn debug_start_inner(
host: &Host,
window: &str,
config: LaunchConfig,
cancel: &ide_core::CancellationToken,
) -> IdeResult<DebugSessionId> {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
host.require_trust(window)?;
let target_id = config
.extra_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("__runTarget").and_then(|v| v.as_str()).map(str::to_owned));
if let Some(id) = target_id {
let owner = SessionOwner::new(host.owner_window(window), host.window_sink(window));
return target::start(host, window, config, owner, id, cancel).await;
}
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
@@ -419,9 +383,7 @@ pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSe
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("debug_start", |c| async move {
debug_start_request(&c.host, &c.window, c.arg("config")?, c.arg("request")?).await
});
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
d.add("debug_set_breakpoints", |c| async move {
debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await
});
-121
View File
@@ -1,121 +0,0 @@
use crate::Host;
use ide_core::path_mapping::PathMapping;
use ide_core::{IdeError, IdeResult};
use ide_dap::target::TargetMapping;
use ide_dap::{LaunchConfig, SessionOwner};
use ide_language::ProgramPin;
use serde_json::json;
pub(super) async fn start(
host: &Host,
window: &str,
mut config: LaunchConfig,
owner: SessionOwner,
id: String,
cancel: &ide_core::CancellationToken,
) -> IdeResult<ide_core::DebugSessionId> {
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let registration = plugins
.run_targets(&project.root)
.into_iter()
.find(|t| t.id == id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let container = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("Debugging a plugin target requires a container root"))?;
let dbg = ide_dap::adapter::find_config(&host.languages, &config.adapter)
.ok_or_else(|| IdeError::invalid("unknown debug adapter"))?;
let placement = dbg.target_placement.as_deref().unwrap_or("");
if !matches!(placement, "connect-back" | "adapter-in-target")
|| (placement == "adapter-in-target" && dbg.transport != "stdio")
{
return Err(IdeError::invalid("This debugger does not support plugin run targets"));
}
let program = config
.extra_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("__runTargetProgram").and_then(|p| p.as_str()).map(str::to_owned))
.or_else(|| config.program.clone())
.unwrap_or_else(|| dbg.command.clone());
let metadata = serde_json::from_value(json!({"id":"debug", "name":config.name, "kind":"shell", "command":program, "args":[], "cwd":config.cwd, "env":{}, "detected":false, "isTest":false}))
.map_err(|e| IdeError::invalid(e.to_string()))?;
let (validator, root, target, token) = (plugins.clone(), project.root.clone(), id.clone(), cancel.clone());
host.blocking(move || {
validator.prepare_run_target_launch(&root, &target, &metadata, ide_plugin::RunTargetOperation::Debug, &token)
})
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mapping = TargetMapping {
paths: PathMapping::new(&project.root.to_string_lossy(), &container),
external_prefix: format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC)
),
};
let mut extra: serde_json::Value = config
.extra_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| IdeError::invalid(e.to_string()))?
.unwrap_or(json!({}));
extra.as_object_mut().ok_or_else(|| IdeError::invalid("invalid debug launch arguments"))?.remove("__runTarget");
extra.as_object_mut().unwrap().remove("__runTargetProgram");
// DAP paths already use target coordinates; adapter-native mappings stay identity.
if placement == "connect-back" {
config.program = None;
config.args.clear();
for key in ["program", "runtimeExecutable", "runtimeArgs"] {
extra.as_object_mut().unwrap().remove(key);
}
if extra.get("port").and_then(|v| v.as_u64()).unwrap_or(0) == 0 {
extra["port"] = json!(9003);
}
extra["hostname"] = json!("0.0.0.0");
extra["pathMappings"] = json!({container.clone(): container.clone()});
} else {
for key in ["debugLauncherPython", "pythonPath"] {
extra.as_object_mut().unwrap().remove(key);
}
extra["console"] = json!("internalConsole");
extra["python"] = json!(dbg.command);
extra["pathMappings"] = json!([{"localRoot":container,"remoteRoot":container}]);
}
config.extra_json = Some(extra.to_string());
let wrapped = if placement == "adapter-in-target" {
let run = serde_json::from_value(json!({"id":"debug-adapter","name":"Debug adapter","kind":"shell","command":dbg.command,"args":dbg.args,"cwd":config.cwd,"env":config.env,"detected":false,"isTest":false})).map_err(|e| IdeError::invalid(e.to_string()))?;
let root = project.root.clone();
Some(host.blocking(move || plugins.prepare_run_target(&root, &id, &run)).await?)
} else {
None
};
host.require_trust(window)?;
let root = project.root.clone();
let settings = project.settings.effective();
host.debugger
.start_prepared(
config,
owner,
|_| None,
move |spec, _| {
if let Some(run) = wrapped {
spec.command = run.command;
spec.args = run.args;
spec.cwd = run.cwd.map(Into::into).unwrap_or(root);
spec.env = run.env.into_iter().collect();
spec.pinned = Some(ProgramPin::approve(&spec.command)?);
} else if let Some(command) = ide_sdk::apply::tool_command(&root, &settings, &spec.command) {
spec.command = command.to_string_lossy().into_owned();
}
spec.target = Some(mapping);
Ok(())
},
)
.await
}
-36
View File
@@ -546,42 +546,6 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
if uri.starts_with("openidea-target:") && uri.contains("/0/") {
host.require_trust(window)?;
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
for registration in plugins.run_targets(&project.root) {
let prefix = format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&registration.id, percent_encoding::NON_ALPHANUMERIC)
);
if let Some(suffix) = uri.strip_prefix(&prefix) {
let path = ide_core::path_mapping::file_path(&format!("file:///{suffix}"))
.filter(|p| p.starts_with('/') && !p.split('/').any(|s| s == ".." || s == "."))
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let container_root = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let target = ide_language::ServerTarget {
id: registration.id,
origin: None,
project_root: project.root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&project.root.to_string_lossy(), &container_root),
container_root,
};
let title = std::path::Path::new(&path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let language_id = host.languages.detect(std::path::Path::new(&path), None);
let text = host.blocking(move || super::lsp_target::read(&plugins, &target, &path)).await?;
return Ok(VirtualDocument { uri, text, title, language_id, description: None, decompiled: false });
}
}
return Err(IdeError::unavailable("plugin run target is no longer available"));
}
host.project(window)?.lsp.virtual_document(&uri).await
}
-120
View File
@@ -1,120 +0,0 @@
//! Project language-server targets, shared by desktop and openidea-server.
use crate::plugins::PluginHost;
use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageServerConfig, ServerTarget};
use std::path::Path;
pub(crate) fn configure(
plugins: &PluginHost,
root: &Path,
id: &str,
cfg: &mut LanguageServerConfig,
) -> IdeResult<bool> {
let Some(registration) = plugins.run_targets(root).into_iter().find(|t| t.id == id) else { return Ok(false) };
let container_root = registration.target.container_root.clone().unwrap_or_default();
cfg.target = Some(ServerTarget {
id: id.into(),
origin: None,
project_root: root.into(),
container_root: container_root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), &container_root),
});
if container_root.is_empty() {
return Err(IdeError::invalid("language server targets require a container root"));
}
let run = command(&cfg.command, cfg.args.clone());
let wrapped = plugins.prepare_run_target(root, id, &run)?;
cfg.command = wrapped.command;
cfg.args = wrapped.args;
cfg.connect = None;
cfg.pinned = Some(ide_language::ProgramPin::approve(&cfg.command)?);
Ok(true)
}
fn command(command: &str, args: Vec<String>) -> ide_run::RunConfig {
serde_json::from_value(serde_json::json!({
"id":"language-server", "name":"Language server", "kind":"shell",
"command":command, "args":args, "cwd":".", "env":{}, "detected":false, "isTest":false
}))
.expect("valid internal run configuration")
}
pub(crate) fn read(plugins: &PluginHost, target: &ServerTarget, path: &str) -> IdeResult<String> {
let registration = plugins
.run_targets(&target.project_root)
.into_iter()
.find(|t| t.id == target.id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
if registration.target.container_root.as_deref() != Some(&target.container_root) {
return Err(IdeError::unavailable("plugin run target changed; navigate again"));
}
let mut wrapped = plugins.prepare_run_target(&target.project_root, &target.id, &command("cat", Vec::new()))?;
wrapped.args.extend(["--".into(), path.into()]);
capture(&wrapped, &target.project_root)
}
/// Bounds both output and time, and always kills/reaps the wrapper on failure.
fn capture(run: &ide_run::RunConfig, root: &Path) -> IdeResult<String> {
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
const LIMIT: u64 = 4 * 1024 * 1024;
let mut cmd = Command::new(&run.command);
ide_core::process::prepare(&mut cmd);
ide_core::process::own_group(&mut cmd);
let mut child = cmd
.args(&run.args)
.current_dir(root)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let overflow = Arc::new(AtomicBool::new(false));
let large = overflow.clone();
let output = std::thread::spawn(move || {
let mut bytes = Vec::new();
let result = stdout.take(LIMIT + 1).read_to_end(&mut bytes);
large.store(bytes.len() as u64 > LIMIT, Ordering::Release);
result.map(|_| bytes)
});
let errors = std::thread::spawn(move || {
let mut stderr = stderr;
let mut bytes = Vec::new();
let _ = (&mut stderr).take(65536).read_to_end(&mut bytes);
let _ = std::io::copy(&mut stderr, &mut std::io::sink());
bytes
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let status = loop {
if output.is_finished()
&& errors.is_finished()
&& let Ok(Some(status)) = child.try_wait()
{
break Ok(status);
}
match (std::time::Instant::now() < deadline, overflow.load(Ordering::Acquire)) {
(true, false) => std::thread::sleep(std::time::Duration::from_millis(10)),
_ => {
ide_core::process::kill_tree_of(child.id());
let _ = child.kill();
let _ = child.wait();
break Err(IdeError::unavailable("container document exceeds the size or time limit"));
}
}
};
let bytes = output.join().map_err(|_| IdeError::unavailable("container document reader failed"))??;
let stderr = errors.join().unwrap_or_default();
if !status?.success() {
return Err(IdeError::unavailable(format!("container document: {}", String::from_utf8_lossy(&stderr))));
}
if bytes.len() as u64 > LIMIT {
return Err(IdeError::invalid("container document exceeds 4 MiB"));
}
String::from_utf8(bytes).map_err(|_| IdeError::invalid("container document is not UTF-8 text"))
}
-2
View File
@@ -98,5 +98,3 @@ impl Host {
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
}
pub(crate) mod lsp_target;
+1 -1
View File
@@ -222,7 +222,7 @@ pub fn set_settings(
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let toolchain = ["languages", "tools", "languageServers", "languageServerTargets", "projectStructure"]
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
+5 -8
View File
@@ -280,14 +280,11 @@ async fn start_inner(
let token = cancel.clone();
let config = host
.blocking(move || {
let operation =
if config.extras.params.get("plugin.targetOperation").is_some_and(|op| op == "debug") {
ide_plugin::RunTargetOperation::Debug
} else if config.is_test {
ide_plugin::RunTargetOperation::Test
} else {
ide_plugin::RunTargetOperation::Run
};
let operation = if config.is_test {
ide_plugin::RunTargetOperation::Test
} else {
ide_plugin::RunTargetOperation::Run
};
plugins.prepare_run_target_launch(&root, &id, &config, operation, &token)
})
.await?;
+2 -1
View File
@@ -426,7 +426,8 @@ pub async fn vcs_log_details_batch(
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
r.enable_log_index(&project.cache);
r.log_details_batch_cancellable(&revisions, &cancel)
})
})
.await;
+4 -35
View File
@@ -21,15 +21,6 @@ fn selector(plugin: &str, id: &str) -> String {
format!("plugin-target:{}:{plugin}{id}", plugin.len())
}
impl Shared {
fn targets_changed(&self) {
if let Some(host) = self.workspace().and_then(|w| w.database_host()) {
for project in host.projects() {
project.lsp.reconfigure_soon();
}
}
self.sink.emit(None, PluginEvent::RunTargetsChanged);
}
fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf {
let key = format!("{}\0{id}", root.display());
self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str())
@@ -40,7 +31,7 @@ impl Shared {
targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r)));
if before != targets.len() {
drop(targets);
self.targets_changed();
self.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
@@ -79,12 +70,12 @@ impl PluginApi {
);
drop(targets);
drop(blocked);
self.shared.targets_changed();
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
Ok(())
}
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
self.shared.targets_changed();
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
}
}
}
@@ -96,15 +87,6 @@ impl PluginHost {
pub fn run_targets(&self, root: &Path) -> Vec<RegisteredRunTarget> {
self.shared.run_targets.lock().iter().filter(|((r, _), _)| r == root).map(|(_, t)| t.clone()).collect()
}
pub fn prepare_run_target(
&self,
root: &Path,
id: &str,
config: &ide_run::RunConfig,
) -> IdeResult<ide_run::RunConfig> {
self.prepare_target(root, id, config, None)
}
/// Prepares a user launch, including the optional plugin validation gate.
pub fn prepare_run_target_launch(
&self,
@@ -113,16 +95,6 @@ impl PluginHost {
config: &ide_run::RunConfig,
operation: ide_plugin::RunTargetOperation,
cancel: &ide_core::CancellationToken,
) -> IdeResult<ide_run::RunConfig> {
self.prepare_target(root, id, config, Some((operation, cancel)))
}
fn prepare_target(
&self,
root: &Path,
id: &str,
config: &ide_run::RunConfig,
launch: Option<(ide_plugin::RunTargetOperation, &ide_core::CancellationToken)>,
) -> IdeResult<ide_run::RunConfig> {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
@@ -136,10 +108,7 @@ impl PluginHost {
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
let wrapped = ide_remote::plugin_target::prepare(root, &target.target, &program, config)?;
let validates = self.get(&target.plugin_id)?.meta.lock().has_run_target_validation;
if let Some((operation, cancel)) = launch
&& validates
{
if self.get(&target.plugin_id)?.meta.lock().has_run_target_validation {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
+3 -57
View File
@@ -139,49 +139,11 @@ impl Host {
let settings = Arc::new(settings);
{
let settings = settings.clone();
// Workspace members have independent settings; target servers have their own path mappings.
// Workspace members have independent settings.
lsp.set_jdtls_shared_hook(Box::new(move || {
let effective = settings.effective();
!is_workspace
&& effective.languages.java.jdtls_shared
&& effective.language_server_targets.get("java").is_none_or(|target| target.target.is_empty())
!is_workspace && settings.effective().languages.java.jdtls_shared
}));
}
{
let settings = settings.clone();
let root = root.clone();
let plugins = self.plugin_host.clone();
let reader_plugins = plugins.clone();
lsp.set_target_hooks(
Box::new(move |language, cfg| {
let effective = settings.effective();
let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
else {
return false;
};
let Some(plugins) = plugins.read().upgrade() else { return false };
// A withdrawn target returns the server to the host. An invalid active
// target stays visibly failed; it must never silently fall back.
if !plugins.run_targets(&root).iter().any(|t| t.id == selection.target) {
return false;
}
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
if let Err(e) = crate::ops::lsp_target::configure(&plugins, &root, &selection.target, cfg) {
cfg.launch_error = Some(e.to_string());
}
true
}),
Box::new(move |target, path| {
let plugins = reader_plugins
.read()
.upgrade()
.ok_or_else(|| IdeError::unavailable("plugin host is unavailable"))?;
crate::ops::lsp_target::read(&plugins, target, path)
}),
);
}
{
let settings = settings.clone();
let root = root.clone();
@@ -319,23 +281,7 @@ impl Host {
{
let settings = settings.clone();
lsp.set_servers_hook(Box::new(move |language, defined| {
let effective = settings.effective();
let mut servers = ide_sdk::apply::language_servers(language.as_str(), defined, &effective);
if let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
{
let chosen = if selection.server.is_empty() {
servers.first().map(|s| s.name.clone())
} else {
Some(selection.server.clone())
};
servers.retain(|s| Some(&s.name) == chosen.as_ref());
for server in &mut servers {
server.fallback = false;
server.target_key = Some(selection.target.clone());
}
}
servers
ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective())
}));
}
{
+11 -19
View File
@@ -410,7 +410,16 @@ async fn run_target_api15_overrides_and_frozen_api14_registration() {
assert_eq!(wrapped.cwd.unwrap(), f.root);
let php = ide_run::RunConfig { command: "php".into(), args: vec!["x.php".into()], ..config.clone() };
assert_eq!(
plugins.prepare_run_target(&f.root, &target.id, &php).unwrap().args,
plugins
.prepare_run_target_launch(
&f.root,
&target.id,
&php,
ide_plugin::RunTargetOperation::Run,
&Default::default()
)
.unwrap()
.args,
["wrapped", "--cwd=/container", "php", "x.php"]
);
}
@@ -472,7 +481,7 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
}
let targets = plugins.run_targets(&f.root);
let id = |name: &str| targets.iter().find(|t| t.target.id == name).unwrap().id.clone();
for operation in [RunTargetOperation::Run, RunTargetOperation::Test, RunTargetOperation::Debug] {
for operation in [RunTargetOperation::Run, RunTargetOperation::Test] {
plugins.prepare_run_target_launch(&f.root, &id("allow"), &config, operation, &Default::default()).unwrap();
let snapshot = plugins.run_command("execution", "validation", None, invocation()).unwrap().unwrap();
assert_eq!(snapshot, format!("{}|allow|{operation:?}|composer|{}", f.root.display(), f.root.display()));
@@ -484,8 +493,6 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &Default::default())
.unwrap();
assert!(plugins.info_of("execution").unwrap().stats.reclaims > 0);
// Non-launch consumers (LSP and document reads) do not invoke validation.
assert!(plugins.prepare_run_target(&f.root, &id("refuse"), &config).is_ok());
let selected = id("refuse");
ide_host::plugins::run_targets::answer(&f.host, &plugins, "main", &selected, true).unwrap();
for is_test in [false, true] {
@@ -499,21 +506,6 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
assert_eq!(f.host.project("main").unwrap().settings.effective().run.default_target, selected);
assert_eq!(plugins.run_targets(&f.root).len(), targets.len());
}
for adapter in ["php-debug", "debugpy"] {
let launch = ide_dap::LaunchConfig {
name: "Refused debug".into(),
adapter: adapter.into(),
request: "launch".into(),
program: Some("app.php".into()),
args: Vec::new(),
cwd: Some(f.root.clone().into()),
env: Default::default(),
extra_json: Some(serde_json::json!({"__runTarget":selected, "__runTargetProgram":"php"}).to_string()),
};
let error = ide_host::ops::debug::debug_start(&f.host, "main", launch).await.unwrap_err();
assert!(error.message.contains("Project runtime is stopped"), "{adapter}: {error}");
assert!(f.host.debugger.sessions(None).is_empty());
}
let error = plugins
.prepare_run_target_launch(&f.root, &id("long-refusal"), &config, RunTargetOperation::Run, &Default::default())
.unwrap_err();
-102
View File
@@ -1,102 +0,0 @@
//! Target debugging uses the same trusted plugin wrapper on desktop and the SSH server.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::{
Host, HostConfig,
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
};
use ide_language::LanguageRegistry;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde_json::json;
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
#[tokio::test(flavor = "multi_thread")]
async fn registered_target_debugs_over_stdio_and_rechecks_program_grant() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let adapter = base.join("adapter.py");
std::fs::write(&adapter, include_str!("../../ide-dap/tests/fixtures/target_adapter.py")).unwrap();
let wrapper = base.join("wrapper");
std::fs::write(
&wrapper,
format!(
r#"#!/bin/sh
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
shift 2
if [ "$1" = cat ]; then exec cat "$2" "$3"; fi
export FAKE_ROOT=/container
exec /usr/bin/python3 -u '{}'
"#,
adapter.display()
),
)
.unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let e = sink_events.clone();
Arc::new(move |event: IdeEvent| e.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
plugins.run_command("execution", "target", None, invocation()).unwrap();
let id = plugins.run_targets(&root)[0].id.clone();
let launch: ide_dap::LaunchConfig=serde_json::from_value(json!({"name":"Fake target","adapter":"debugpy","request":"launch","program":root.join("main.py"),"args":[],"cwd":root,"env":{},"extraJson":json!({"__runTarget":id}).to_string()})).unwrap();
host.debugger
.set_breakpoints(
root.join("main.py"),
vec![ide_dap::Breakpoint {
line: 2,
enabled: true,
condition: None,
hit_condition: None,
log_message: None,
}],
)
.await
.unwrap();
let session = ide_host::ops::debug::debug_start(&host, "main", launch.clone()).await.unwrap();
let frames = host.debugger.stack_trace(session, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
host.debugger.stop(session).await.unwrap();
// External debug sources use the container reader even with no LSP running.
let library = base.join("library.py");
std::fs::write(&library, "container library").unwrap();
let uri = format!(
"openidea-target://{}/0/{}",
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC),
library.to_string_lossy().trim_start_matches('/')
);
let doc = ide_host::ops::doc::doc_open_virtual(&host, "main", uri).await.unwrap();
assert!(doc.read_only);
assert_eq!(doc.text, "container library");
std::fs::write(&wrapper, "#!/bin/sh\nexit 99\n").unwrap();
let error = ide_host::ops::debug::debug_start(&host, "main", launch).await.unwrap_err();
assert!(error.message.contains("changed") || error.message.contains("approved"), "{error:?}");
host.close_project("main");
}
-224
View File
@@ -1,224 +0,0 @@
//! Real stdio LSP through a registered plugin wrapper, without a container daemon.
#![cfg(unix)]
use ide_core::{DocId, IdeEvent, LanguageId, Pos};
use ide_host::{
Host, HostConfig,
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
};
use ide_language::{LanguageConfig, LanguageRegistry};
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde_json::json;
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc, time::Duration};
const SERVER: &str = r#"import sys,json,os
root = 'file:///container' if os.environ.get('FAKE_IN_TARGET') else sys.argv[3]
def send(v):
body=json.dumps(v).encode(); sys.stdout.buffer.write(('Content-Length: %d\r\n\r\n'%len(body)).encode()+body); sys.stdout.buffer.flush()
while True:
headers={}
while True:
line=sys.stdin.buffer.readline()
if not line: sys.exit(0)
if line in (b'\r\n',b'\n'): break
k,v=line.decode().split(':',1); headers[k.lower()]=v.strip()
msg=json.loads(sys.stdin.buffer.read(int(headers['content-length'])))
method=msg.get('method'); params=msg.get('params',{})
if method=='initialize':
assert params['rootUri']==root,params
assert params['rootPath']==root.removeprefix('file://'),params
assert params['workspaceFolders'][0]['uri']==root,params
open(sys.argv[1],'a').write(json.dumps(params)+'\n')
result={'capabilities':{'textDocumentSync':1,'definitionProvider':True,'documentLinkProvider':True}}
elif method=='textDocument/didOpen':
if params['textDocument']['uri'] != root+'/main.mock': continue
send({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':root+'/main.mock','diagnostics':[{'range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}},'message':'mapped diagnostic','severity':2}]}})
continue
elif method=='textDocument/documentLink':
result=[{'target':sys.argv[2]+'#L2','range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}}}]
elif method=='textDocument/definition':
assert params['textDocument']['uri']==root+'/main.mock',params
r={'start':{'line':0,'character':0},'end':{'line':0,'character':1}}
result=[{'uri':root+'/definition.mock','range':r},{'uri':sys.argv[2],'range':r}]
elif method=='shutdown': result=None
elif method=='exit': sys.exit(0)
else:
if 'id' not in msg: continue
result=None
send({'jsonrpc':'2.0','id':msg['id'],'result':result})
"#;
#[tokio::test(flavor = "multi_thread")]
async fn initialize_definition_diagnostics_external_read_only_and_withdrawal() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let source = root.join("main.mock");
std::fs::write(&source, "x").unwrap();
std::fs::write(root.join("definition.mock"), "x").unwrap();
let library = base.join("library with space.mock");
std::fs::write(&library, "read only library").unwrap();
let log = base.join("initialize.json");
let reads = base.join("reads");
let server = base.join("server.py");
std::fs::write(&server, SERVER).unwrap();
let wrapper = base.join("wrapper");
std::fs::write(
&wrapper,
format!(
r#"#!/bin/sh
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
shift 2
if [ "$1" = cat ]; then printf '%s' "$3" > '{}'; fi
export FAKE_IN_TARGET=1
exec "$@"
"#,
reads.display()
),
)
.unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut language: LanguageConfig = toml::from_str(
"id='mock'\nname='Mock'\nextensions=['mock']\n[[language-servers]]\nname='mock-ls'\ncommand='python3'",
)
.unwrap();
language.language_servers[0].args = vec![
server.to_string_lossy().into(),
log.to_string_lossy().into(),
ide_lsp::path_to_uri_string(&library),
ide_lsp::path_to_uri_string(&root),
];
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(LanguageRegistry::from_configs(vec![language])),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let e = sink_events.clone();
Arc::new(move |event: IdeEvent| e.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
plugins.run_command("execution", "target", None, invocation()).unwrap();
let id = plugins.run_targets(&root)[0].id.clone();
let project = host.project("main").unwrap();
project
.settings
.set(
ide_workspace::SettingsScope::Project,
"languageServerTargets",
json!({"mock":{"target":id,"server":"mock-ls"}}),
)
.unwrap();
let doc = DocId::next();
project.lsp.did_open(doc, &source, &LanguageId::new("mock"), 1, "x");
// Links have no previously recorded location origin. Their URI must identify
// the right server by itself, including when its fragment is stripped.
let links = project.lsp.document_links(doc).await.unwrap();
let link = &links[0];
assert_eq!(link.fragment.as_deref(), Some("L2"));
assert!(link.url.is_none());
let linked =
ide_host::ops::doc::doc_open_virtual(&host, "main", link.path.as_ref().unwrap().to_string_lossy().into_owned())
.await
.unwrap();
assert!(linked.read_only);
let locations = tokio::time::timeout(
Duration::from_secs(15),
project.lsp.goto(doc, Pos::new(0, 0), ide_lsp::types::GotoKind::Definition),
)
.await
.unwrap()
.unwrap();
assert_eq!(locations[0].path.to_path_buf(), root.join("definition.mock"));
assert!(log.exists(), "initialize reached the fake server with the mapped root");
for _ in 0..200 {
if events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))) { break; }
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))));
let uri = locations[1].uri.clone().expect("external paths are virtual documents");
assert!(uri.starts_with("openidea-target:"));
let opened = ide_host::ops::doc::doc_open_virtual(&host, "main", uri.clone()).await.unwrap();
assert!(opened.read_only);
assert_eq!(project.documents.text_of(std::path::Path::new(&uri)).unwrap(), "read only library");
assert_eq!(std::fs::read_to_string(&reads).unwrap(), library.to_string_lossy());
assert!(
project
.documents
.apply_edits(opened.id, &[ide_core::TextEdit { range: Default::default(), new_text: "bad".into() }])
.is_err()
);
for operation in [
ide_core::FileEdit::Create { path: uri.clone().into(), overwrite: true },
ide_core::FileEdit::Delete { path: uri.clone().into(), recursive: false },
ide_core::FileEdit::Rename { from: uri.clone().into(), to: root.join("copy.mock").into() },
ide_core::FileEdit::Edit { path: uri.clone().into(), version: None, edits: Vec::new() },
] {
let mut edit = ide_core::WorkspaceEdit { files: vec![operation], ..Default::default() };
assert!(project.documents.prepare_workspace_edit(&mut edit).is_err());
}
// A library tab must not prevent ordinary project refactorings.
project
.documents
.apply_workspace_edit(&ide_core::WorkspaceEdit {
files: vec![ide_core::FileEdit::Create { path: root.join("created.mock").into(), overwrite: false }],
..Default::default()
})
.unwrap();
assert!(root.join("created.mock").exists());
// Changing the project setting restarts on the host, then back inside the target.
let selection = json!({"mock":{"target":id,"server":"mock-ls"}});
for value in [json!({}), selection.clone()] {
project.settings.set(ide_workspace::SettingsScope::Project, "languageServerTargets", value.clone()).unwrap();
project.lsp.reconfigure().await;
let expected = if value == json!({}) { ide_lsp::path_to_uri_string(&root) } else { "file:///container".into() };
wait_root(&log, &expected).await;
}
// Withdrawal invalidates reads, even when contents were cached earlier.
plugins.run_command("execution", "untarget", None, invocation()).unwrap();
assert!(project.lsp.virtual_document(&uri).await.is_err());
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
plugins.run_command("execution", "target", None, invocation()).unwrap();
wait_root(&log, "file:///container").await;
plugins.set_enabled("execution", false).unwrap();
assert!(project.lsp.virtual_document(&uri).await.is_err());
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
host.shutdown().await;
plugins.shutdown();
}
async fn wait_root(log: &std::path::Path, expected: &str) {
for _ in 0..500 {
let text = std::fs::read_to_string(log).unwrap_or_default();
if text
.lines()
.last()
.and_then(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.is_some_and(|v| v["rootUri"] == expected)
{
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("server did not initialize with {expected}: {}", std::fs::read_to_string(log).unwrap_or_default());
}
+6 -52
View File
@@ -248,7 +248,6 @@ struct Inner {
/// Fired by [`Peer::kill`]; stops tasks and kills the child.
kill: CancellationToken,
pid: Option<u32>,
transform: Option<Arc<dyn MessageTransform>>,
/// Fired when the reader task has stopped (EOF, error or kill).
reader_done: CancellationToken,
/// Fired once the child process has exited and been reaped (after [`Peer::kill`],
@@ -256,38 +255,11 @@ struct Inner {
exited: CancellationToken,
}
/// Translates protocol values before serialization and after parsing, including
/// responses to requests initiated by the server.
pub trait MessageTransform: Send + Sync + 'static {
fn outgoing(&self, value: &mut Value);
fn incoming(&self, value: &mut Value);
}
#[derive(Default)]
pub struct TransportOptions {
pub framing: Framing,
pub transform: Option<Arc<dyn MessageTransform>>,
}
impl From<Framing> for TransportOptions {
fn from(framing: Framing) -> Self {
Self { framing, transform: None }
}
}
impl Inner {
fn send_value(&self, value: &Value) -> Result<(), RpcError> {
if self.transport_closed.load(Ordering::Acquire) {
return Err(RpcError::Closed);
}
let mut translated;
let value = if let Some(transform) = &self.transform {
translated = value.clone();
transform.outgoing(&mut translated);
&translated
} else {
value
};
let body = serde_json::to_vec(value).map_err(|e| RpcError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_jsonrpc::wire", "--> {}", String::from_utf8_lossy(&body));
self.out.send(body).map_err(|_| RpcError::Closed)
@@ -415,19 +387,6 @@ impl Peer {
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
framing: Framing,
) -> std::io::Result<Self> {
Self::spawn_transport(command, args, cwd, env, handler, on_stderr, framing.into())
}
/// Starts a stdio peer with message translation installed before any IO.
pub fn spawn_transport(
command: &str,
args: &[String],
cwd: &std::path::Path,
env: &[(String, String)],
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
options: TransportOptions,
) -> std::io::Result<Self> {
use std::process::Stdio;
// Windows resolves a bare name only as `.exe`; find the file a child
@@ -453,7 +412,7 @@ impl Peer {
let pid = child.id();
let on_output: Arc<dyn Fn(String) + Send + Sync> = Arc::from(on_stderr);
let peer = Self::start(stdout, stdin, handler, false, pid, options, Some(on_output.clone()));
let peer = Self::start(stdout, stdin, handler, false, pid, framing, Some(on_output.clone()));
let stderr_task = stderr.map(|stderr| {
let name = command.to_owned();
@@ -507,7 +466,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, Framing::ContentLength.into(), None)
Self::start(reader, writer, handler, true, None, Framing::ContentLength, None)
}
/// [`connect`](Self::connect) with the given wire framing.
@@ -516,7 +475,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, framing.into(), None)
Self::start(reader, writer, handler, true, None, framing, None)
}
/// [`Peer::connect`] over streams served by `child` (a server reached over a socket
@@ -533,7 +492,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength.into(), None);
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength, None);
peer.supervise(child, None);
peer
}
@@ -544,14 +503,13 @@ impl Peer {
handler: Arc<dyn Handler>,
close_on_eof: bool,
pid: Option<u32>,
options: TransportOptions,
framing: Framing,
on_noise: Option<Arc<dyn Fn(String) + Send + Sync>>,
) -> Self
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let TransportOptions { framing, transform } = options;
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (closed, _) = watch::channel(None);
let inner = Arc::new(Inner {
@@ -562,7 +520,6 @@ impl Peer {
closed,
kill: CancellationToken::new(),
pid,
transform,
reader_done: CancellationToken::new(),
exited: CancellationToken::new(),
});
@@ -828,16 +785,13 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("jsonrpc: unparsable message: {e}");
return;
}
};
if let Some(transform) = &inner.transform {
transform.incoming(&mut msg);
}
let Value::Object(mut obj) = msg else {
tracing::warn!("jsonrpc: ignoring non-object message");
return;
-19
View File
@@ -176,30 +176,11 @@ pub struct LanguageServerConfig {
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
/// Host-only transport configuration; never accepted from language definitions.
#[serde(skip)]
pub target: Option<ServerTarget>,
/// Separates instances when languages sharing a server select different targets.
#[serde(skip)]
pub target_key: Option<String>,
}
/// A prepared plugin wrapper. The LSP client never resolves container binaries on the host.
#[derive(Clone, Debug, PartialEq)]
pub struct ServerTarget {
pub id: String,
pub origin: Option<ide_core::ServerId>,
pub project_root: PathBuf,
pub container_root: String,
pub mapping: ide_core::path_mapping::PathMapping,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct DebuggerConfig {
/// Placement in plugin run targets; absent means unsupported.
#[serde(default)]
pub target_placement: Option<String>,
/// DAP adapter name, e.g. `codelldb`, `debugpy`, `js-debug`, `delve`.
pub adapter: String,
pub command: String,
-6
View File
@@ -998,7 +998,6 @@ pub fn document_link(conv: &Conv, path: &Path, l: lsp::DocumentLink) -> Option<D
let target = target.as_str();
let (file, url) = match uri_to_path(target) {
Some(p) => (Some(p), None),
None if target.starts_with("openidea-target:") => (Some(PathBuf::from(target.split('#').next()?)), None),
None => (None, Some(target.to_owned())),
};
let fragment = file
@@ -1560,11 +1559,6 @@ mod tests {
assert_eq!(file.path.as_deref(), Some(Path::new("/p/a b.md")));
assert_eq!(file.fragment.as_deref(), Some("L12"));
assert_eq!(file.range, Range::new(p(0, 6), p(0, 15)));
let virtual_link =
document_link(&conv, here, link(Some("openidea-target://target/7/usr/lib/a.php#L2"))).unwrap();
assert_eq!(virtual_link.path.as_deref(), Some(Path::new("openidea-target://target/7/usr/lib/a.php")));
assert_eq!(virtual_link.fragment.as_deref(), Some("L2"));
assert!(virtual_link.url.is_none());
let web = document_link(&conv, here, link(Some("https://example.com/#top"))).unwrap();
assert_eq!((web.path, web.url.as_deref(), web.fragment), (None, Some("https://example.com/#top"), None));
assert!(document_link(&conv, here, link(None)).is_none());
-30
View File
@@ -95,33 +95,6 @@ impl Launcher for ProcessLauncher {
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
if let Some(target) = &config.target {
let argv =
format!("argv: {:?}", std::iter::once(&config.command).chain(config.args.iter()).collect::<Vec<_>>());
on_stderr(argv.clone());
let binary = config
.pinned
.as_ref()
.ok_or_else(|| LaunchError::Config("target wrapper has no executable pin".into()))?
.verify()
.map_err(LaunchError::Failed)?
.to_owned();
let env = config.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<Vec<_>>();
return Peer::spawn_transport(
&binary.to_string_lossy(),
&config.args,
&target.project_root,
&env,
handler,
on_stderr,
ide_jsonrpc::TransportOptions {
transform: Some(Arc::new(crate::target::TargetTransform(target.clone()))),
..Default::default()
},
)
.map(|peer| (peer, Some(binary)))
.map_err(|e| LaunchError::Failed(format!("{argv}: {e}")));
}
let search = server_search_path(config);
let binary = match &config.pinned {
// A plugin's server: the approved program, unchanged since.
@@ -152,9 +125,6 @@ impl Launcher for ProcessLauncher {
}
fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool {
if config.target.is_some() {
return true;
}
if let Some(pin) = &config.pinned {
return pin.path.is_file();
}
+17 -95
View File
@@ -1,6 +1,6 @@
//! Language server manager.
//!
//! Servers start lazily per `(server name, workspace root, target)` when a document of
//! Servers start lazily per `(server name, workspace root)` when a document of
//! a matching language opens. Several servers may serve one language (e.g.
//! `vtsls` + `eslint`); results are merged. Every request takes positions in
//! IDE coordinates (UTF-16, 0-based) and returns IDE types from [`types`],
@@ -25,7 +25,6 @@ mod loading;
mod pull;
mod scope;
mod server;
mod target;
mod tcp;
mod text;
mod ts_file_moves;
@@ -87,10 +86,6 @@ pub type ApplyEditHook = Arc<dyn Fn(WorkspaceEdit) -> bool + Send + Sync>;
/// settings before each launch; receives the server's workspace root.
pub type ConfigFn = dyn Fn(&mut LanguageServerConfig, &Path) + Send + Sync;
pub type ConfigHook = Arc<ConfigFn>;
/// Returns true when target configuration replaces host toolchain configuration.
pub type TargetConfigFn = dyn Fn(&LanguageId, &mut LanguageServerConfig) -> bool + Send + Sync;
/// Fetches a bounded read-only container document after rechecking grants and trust.
pub type TargetReadFn = dyn Fn(&ide_language::ServerTarget, &str) -> IdeResult<String> + Send + Sync;
/// Prepares dependencies on a blocking worker immediately before launching an installed server.
pub type PrepareFn = dyn Fn(&LanguageServerConfig, &Path, &CancellationToken) + Send + Sync;
/// The servers a language uses, from its definition's list (user settings
@@ -187,7 +182,7 @@ struct JdtlsWorkspace {
}
fn is_local_jdtls(config: &LanguageServerConfig) -> bool {
config.name == "jdtls" && config.target_key.is_none() && config.target.is_none()
config.name == "jdtls"
}
pub struct LspManager {
@@ -203,8 +198,6 @@ pub struct LspManager {
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
applied_edits: tokio::sync::broadcast::Sender<(ide_core::ServerId, WorkspaceEdit)>,
config_hook: RwLock<Option<ConfigHook>>,
target_hook: RwLock<Option<Arc<TargetConfigFn>>>,
target_read_hook: RwLock<Option<Arc<TargetReadFn>>>,
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
servers_hook: RwLock<Option<Arc<ServersFn>>>,
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
@@ -413,8 +406,6 @@ impl LspManager {
apply_edit_hook: RwLock::new(None),
applied_edits: tokio::sync::broadcast::channel(64).0,
config_hook: RwLock::new(None),
target_hook: RwLock::new(None),
target_read_hook: RwLock::new(None),
prepare_hook: RwLock::new(None),
servers_hook: RwLock::new(None),
candidates_hook: RwLock::new(None),
@@ -512,15 +503,13 @@ impl LspManager {
// Outside the project (library sources, …): reuse a running instance.
servers
.iter()
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
.filter(|s| s.name == cfg.name)
.min_by_key(|s| s.root != self.project_root)
.map(|s| s.root.clone())
.unwrap_or_else(|| self.project_root.clone())
})
};
if let Some(s) =
servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key)
{
if let Some(s) = servers.iter().find(|s| s.name == cfg.name && s.root == root) {
s.add_language(language);
return s.clone();
}
@@ -571,13 +560,8 @@ impl LspManager {
/// enabled in settings.
fn attach_fallbacks(&self) {
let _gate = self.injections.gate.lock();
let open: Vec<(DocId, LanguageId, PathBuf)> = self
.docs
.lock()
.iter()
.filter(|(_, d)| !d.uri.as_str().starts_with("openidea-target:"))
.map(|(id, d)| (*id, d.language.clone(), d.path.clone()))
.collect();
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
let wanted: Vec<(DocId, Vec<Arc<Server>>)> =
open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect();
let mut start = Vec::new();
@@ -679,8 +663,7 @@ impl LspManager {
}
tail.set_effective_config(generation, cfg.clone());
let prepare = me.prepare_hook.read().clone();
if cfg.target.is_none()
&& !launch_cancel.is_cancelled()
if !launch_cancel.is_cancelled()
&& me.launcher.available(&cfg, &tail.root)
&& let Some(prepare) = prepare
{
@@ -691,7 +674,7 @@ impl LspManager {
let root = tail.root.clone();
if let Some(message) = &cfg.launch_error {
// A missing binary still asks to be installed first.
let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root);
let missing = !me.launcher.available(&cfg, &root);
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
}
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
@@ -2941,15 +2924,8 @@ impl LspManager {
}
fn origin_server(&self, uri: &str) -> Option<Arc<Server>> {
let id = self.virtual_origins.lock().get(uri).copied();
self.servers
.lock()
.iter()
.find(|s| {
Some(s.id) == id
|| s.effective_config().target.as_ref().is_some_and(|t| target::external_path(t, uri).is_some())
})
.cloned()
let id = *self.virtual_origins.lock().get(uri)?;
self.servers.lock().iter().find(|s| s.id == id).cloned()
}
/// The binary archive behind a library document, never its displayed tab title.
@@ -3016,29 +2992,6 @@ impl LspManager {
/// is read from its archive, a `jar:` class shown as its library source
/// (see [`library_source`]) or else decompiled by the server. Cached.
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
if uri.starts_with("openidea-target:") {
let server = self
.origin_server(uri)
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
let target = server
.effective_config()
.target
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
let path = target::external_path(&target, uri)
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let read = self
.target_read_hook
.read()
.clone()
.ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
let languages = server.languages();
let title = Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned());
let description = Some(path.clone());
let text = tokio::task::spawn_blocking(move || read(&target, &path))
.await
.map_err(|e| IdeError::unavailable(e.to_string()))??;
return Ok(self.store_virtual(uri, text, title, description, &languages, Some(server.id)));
}
if let Some(d) = self.virtual_docs.lock().get(uri) {
return Ok(d.clone());
}
@@ -3193,11 +3146,6 @@ impl LspManager {
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
/// so edits flow through `ide-workspace::Documents`.
pub fn set_target_hooks(&self, configure: Box<TargetConfigFn>, read: Box<TargetReadFn>) {
*self.target_hook.write() = Some(Arc::from(configure));
*self.target_read_hook.write() = Some(Arc::from(read));
}
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
*self.config_hook.write() = Some(Arc::from(hook));
}
@@ -3218,23 +3166,9 @@ impl LspManager {
cfg.launch_error = Some("Project is in Restricted Mode".into());
return cfg;
}
let in_target = self.target_hook.read().clone().is_some_and(|hook| {
server
.languages()
.iter()
.filter(|language| {
self.language_servers(language)
.iter()
.any(|c| c.name == server.name && c.target_key == server.config.target_key)
})
.any(|language| hook(language, &mut cfg))
});
if !in_target && let Some(hook) = self.config_hook.read().clone() {
if let Some(hook) = self.config_hook.read().clone() {
hook(&mut cfg, &server.root);
}
if let Some(target) = &mut cfg.target {
target.origin = Some(server.id);
}
cfg
}
@@ -3308,11 +3242,6 @@ impl LspManager {
}
continue;
}
if next.target != current.target {
for doc in self.docs.lock().values_mut().filter(|d| d.uri.as_str().starts_with("openidea-target:")) {
doc.servers.retain(|a| a.server.id != s.id);
}
}
match s.status() {
ide_core::event::ServerStatus::Ready
| ide_core::event::ServerStatus::Busy
@@ -3333,7 +3262,7 @@ impl LspManager {
}
}
self.attach_fallbacks();
self.detach_shadowed_servers().await;
self.detach_shadowed_fallbacks().await;
self.injection_providers_changed();
}
@@ -3370,9 +3299,9 @@ impl LspManager {
}
}
/// Detaches fallbacks shadowed by enabled servers and instances whose target
/// no longer matches the document's language. Stop them once unused.
async fn detach_shadowed_servers(&self) {
/// A server enabled again shadows the fallbacks attached in its place: they
/// leave the open documents, and stop once no document uses them.
async fn detach_shadowed_fallbacks(&self) {
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
let mut shadowed: Vec<(DocId, Vec<ide_core::ServerId>)> = Vec::new();
@@ -3387,10 +3316,7 @@ impl LspManager {
.filter(|a| {
!wanted.iter().any(|w| w.id == a.server.id)
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER))
|| !configs
.iter()
.any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key))
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER)))
})
.map(|a| a.server.id)
.collect();
@@ -3446,11 +3372,7 @@ impl LspManager {
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
let unlisted: Vec<Arc<Server>> = servers
.into_iter()
.filter(|s| {
!s.languages().iter().any(|l| {
self.language_servers(l).iter().any(|c| c.name == s.name && c.target_key == s.config.target_key)
})
})
.filter(|s| !s.languages().iter().any(|l| self.language_servers(l).iter().any(|c| c.name == s.name)))
.collect();
if unlisted.is_empty() {
return;
-6
View File
@@ -407,12 +407,6 @@ impl Server {
message.push_str(":\n");
message.push_str(&tail);
}
if cfg.target.is_some() {
message.push_str(&format!(
"\nargv: {:?}",
std::iter::once(&cfg.command).chain(cfg.args.iter()).collect::<Vec<_>>()
));
}
let delay = self.record_crash();
message.push_str(if delay.is_some() {
"\nRestarting…"
-95
View File
@@ -1,95 +0,0 @@
//! Protocol mapping for a stdio server inside a plugin target.
use ide_core::path_mapping::{Direction, file_path, file_uri, rewrite_strings};
use ide_jsonrpc::MessageTransform;
use ide_language::ServerTarget;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde_json::Value;
pub(crate) fn virtual_prefix(target: &ServerTarget) -> String {
format!(
"openidea-target://{}/{}/",
utf8_percent_encode(&target.id, NON_ALPHANUMERIC),
target.origin.map_or(0, |id| id.0)
)
}
pub(crate) fn external_path(target: &ServerTarget, uri: &str) -> Option<String> {
let path = file_path(&format!("file:///{}", uri.strip_prefix(&virtual_prefix(target))?))?;
(path.starts_with('/')
&& !path.split('/').any(|s| s == ".." || s == ".")
&& target.mapping.map(&file_uri(&path), Direction::ToHost).is_none())
.then_some(path)
}
fn suffix(uri: &str) -> &str {
uri.find(['?', '#']).map(|i| &uri[i..]).unwrap_or("")
}
pub(crate) struct TargetTransform(pub ServerTarget);
impl MessageTransform for TargetTransform {
fn outgoing(&self, value: &mut Value) {
rewrite_strings(value, &|s| {
// A container library path may happen to have the host root's spelling.
external_path(&self.0, s)
.map(|p| format!("{}{}", file_uri(&p), suffix(s)))
.or_else(|| self.0.mapping.map(s, Direction::ToTarget))
});
}
fn incoming(&self, value: &mut Value) {
rewrite_strings(value, &|s| {
if let Some(mapped) = self.0.mapping.map(s, Direction::ToHost) {
return Some(mapped);
}
let path = file_path(s)?;
if !path.starts_with('/') || path.split('/').any(|p| p == ".." || p == ".") {
return None;
}
let uri = file_uri(&path);
Some(format!("{}{}{}", virtual_prefix(&self.0), uri.strip_prefix("file:///")?, suffix(s)))
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::path_mapping::PathMapping;
use serde_json::json;
#[test]
fn external_uris_are_virtual_but_plain_outside_paths_and_opaque_values_stay_intact() {
let target = ServerTarget {
id: "plugin-target:example".into(),
origin: Some(ide_core::ServerId(7)),
project_root: "/host/project".into(),
container_root: "/container".into(),
mapping: PathMapping::new("/host/project", "/container"),
};
let mapper = TargetTransform(target.clone());
let mut value = json!({"uri":"file:///container/a%20b.php", "relatedInformation":[{"location":{"uri":"file:///usr/lib/a%20b.php"}}],
"data":{"uri":"file:///container/a.php"}, "command":{"arguments":["/container/a.php"]}, "path":"/usr/lib/a.php", "link":"file:///usr/lib/a.php#L12", "alias":"file:///host/project/std.php"});
mapper.incoming(&mut value);
assert_eq!(value["uri"], "file:///host/project/a%20b.php");
let external = value["relatedInformation"][0]["location"]["uri"].as_str().unwrap();
assert!(external.starts_with("openidea-target:"));
assert_eq!(external_path(&target, external).unwrap(), "/usr/lib/a b.php");
let other = ServerTarget { origin: Some(ide_core::ServerId(8)), ..target.clone() };
assert!(external_path(&other, external).is_none());
assert!(value["link"].as_str().unwrap().ends_with("#L12"));
assert_eq!(value["path"], "/usr/lib/a.php");
assert_eq!(value["data"]["uri"], "file:///container/a.php");
assert_eq!(value["command"]["arguments"][0], "/container/a.php");
mapper.outgoing(&mut value);
assert_eq!(value["uri"], "file:///container/a%20b.php");
assert_eq!(value["relatedInformation"][0]["location"]["uri"], "file:///usr/lib/a%20b.php");
assert_eq!(value["link"], "file:///usr/lib/a.php#L12");
assert_eq!(value["alias"], "file:///host/project/std.php");
let mut configuration =
json!({"result":[{"path":"/host/project/vendor","uris":["file:///host/project/vendor/a.php"]}]});
mapper.outgoing(&mut configuration);
assert_eq!(configuration["result"][0]["path"], "/container/vendor");
assert_eq!(configuration["result"][0]["uris"][0], "file:///container/vendor/a.php");
}
}
-120
View File
@@ -268,8 +268,6 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
}
}
@@ -831,52 +829,6 @@ async fn java_sharing_preserves_other_servers_and_marker_scoped_fallbacks() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_sharing_keeps_target_instances_separate_and_preserves_them_on_toggle() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let shared = Arc::new(std::sync::atomic::AtomicBool::new(true));
h.mgr.set_jdtls_shared_hook(Box::new({
let shared = shared.clone();
move || shared.load(Ordering::SeqCst)
}));
h.mgr.set_servers_hook(Box::new(|_, configs| {
let mut target = configs[0].clone();
target.target_key = Some("container".into());
target
.initialization_options
.as_mut()
.unwrap()
.as_table_mut()
.unwrap()
.insert("selectedTarget".into(), toml::Value::Boolean(true));
vec![configs[0].clone(), target]
}));
h.open("Main.java", "class Main {}");
for path in java_siblings(&h) {
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
}
let state = h.launcher.state("jdtls");
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 4).await;
let initial = state.requested("initialize");
assert_eq!(initial.len(), 4);
let targets: Vec<_> = initial.iter().filter(|v| v["initializationOptions"]["selectedTarget"] == true).collect();
assert_eq!(targets.len(), 3, "target roots retain their marker-based identity");
assert!(targets.iter().all(|v| v["initializationOptions"].get("workspaceFolders").is_none()));
let local = initial.iter().find(|v| v["initializationOptions"]["selectedTarget"] != true).unwrap();
assert_eq!(local["rootUri"], uri_of(&h.root));
assert_eq!(local["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
let old: HashSet<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
shared.store(false, Ordering::SeqCst);
h.mgr.reconfigure().await;
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 6).await;
assert_eq!(
h.mgr.servers().iter().filter(|s| old.contains(&s.id)).count(),
3,
"local sharing does not retire targets"
);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn virtual_java_documents_opened_during_a_toggle_attach_to_the_successor() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
@@ -5991,75 +5943,3 @@ async fn missing_injected_server_uses_the_normal_single_install_notification() {
h.mgr.did_close(host);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn languages_sharing_a_server_keep_independent_target_choices() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let language = |id: &str| {
let mut lang: LanguageConfig = toml::from_str(&format!("id='{id}'\nname='{id}'\nextensions=['{id}']")).unwrap();
lang.language_servers = vec![server_cfg("shared")];
lang
};
let registry = Arc::new(LanguageRegistry::from_configs(vec![language("one"), language("two")]));
struct ScopedLauncher(Arc<MockLauncher>);
impl Launcher for ScopedLauncher {
fn launch(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
stderr: Box<dyn Fn(String) + Send + Sync>,
) -> Result<Peer, LaunchError> {
let mut config = config.clone();
if let Some(key) = &config.target_key {
config.name = format!("{}@{key}", config.name);
}
self.0.launch(&config, root, handler, stderr)
}
}
let launcher = Arc::new(MockLauncher::default());
for name in ["shared", "shared@container"] {
*launcher.state(name).caps.lock() = json!({"textDocumentSync":1,"hoverProvider":true});
}
let manager = LspManager::with_launcher(
root.clone(),
registry,
Arc::new(ide_core::NullSink),
Arc::new(ScopedLauncher(launcher.clone())),
);
let selected = Arc::new(std::sync::atomic::AtomicBool::new(false));
let selected_hook = selected.clone();
manager.set_servers_hook(Box::new(move |lang, defined| {
let mut configs = defined.to_vec();
if lang.as_str() == "one" && selected_hook.load(Ordering::Acquire) {
configs[0].target_key = Some("container".into());
}
configs
}));
let one = DocId::next();
let two = DocId::next();
manager.did_open(one, &root.join("a.one"), &LanguageId::new("one"), 1, "one");
manager.did_open(two, &root.join("b.two"), &LanguageId::new("two"), 1, "two");
eventually(|| manager.servers().iter().any(|s| s.status == ServerStatus::Ready)).await;
assert_eq!(manager.servers().len(), 1);
let host = manager.servers()[0].id;
selected.store(true, Ordering::Release);
manager.reconfigure().await;
eventually(|| manager.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 2).await;
assert!(manager.servers().iter().any(|s| s.id == host));
// Each document has one server. Hover must not send to both after splitting.
let state = launcher.state("shared");
state.requests.lock().clear();
let target_state = launcher.state("shared@container");
target_state.requests.lock().clear();
let _ = manager.hover(one, Pos::new(0, 0), CancellationToken::new()).await;
let _ = manager.hover(two, Pos::new(0, 0), CancellationToken::new()).await;
assert_eq!(state.requested("textDocument/hover").len(), 1);
assert_eq!(target_state.requested("textDocument/hover").len(), 1);
selected.store(false, Ordering::Release);
manager.reconfigure().await;
assert_eq!(manager.servers().len(), 1);
assert_eq!(manager.servers()[0].id, host);
manager.shutdown_all().await;
}
-6
View File
@@ -425,8 +425,6 @@ async fn missing_binary_is_reported_with_install_hint() {
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
};
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
let path = dir.path().join("a.nope");
@@ -698,8 +696,6 @@ async fn typescript_language_server_end_to_end() {
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
};
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
let doc = DocId::next();
@@ -751,8 +747,6 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
}
}
-51
View File
@@ -102,54 +102,3 @@ async fn a_request_that_needs_a_crashed_jdtls_reports_why_it_crashed() {
assert!(error.message.contains("'python' is not recognized"), "{}", error.message);
mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn target_launch_failure_shows_exact_argv_and_stderr_tail() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let wrapper = root.join("target wrapper");
std::fs::write(&wrapper, "#!/bin/sh\ni=0\nwhile [ $i -lt 60 ]; do echo stderr-$i >&2; i=$((i+1)); done\nexit 37\n")
.unwrap();
std::fs::set_permissions(&wrapper, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let mut server: LanguageServerConfig = toml::from_str("name='target-ls'\ncommand='unused'").unwrap();
server.command = wrapper.to_string_lossy().into_owned();
server.args = vec!["literal prefix".into(), "--cwd=/container".into(), "missing-lsp".into()];
server.pinned = Some(ide_language::ProgramPin::approve(&wrapper).unwrap());
server.target = Some(ide_language::ServerTarget {
id: "test-target".into(),
origin: None,
project_root: root.clone(),
container_root: "/container".into(),
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), "/container"),
});
let expected =
format!("argv: {:?}", std::iter::once(&server.command).chain(server.args.iter()).collect::<Vec<_>>());
let mut language: LanguageConfig = toml::from_str("id='mock'\nname='Mock'\nextensions=['mock']").unwrap();
language.language_servers = vec![server];
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let manager = LspManager::new(
root.clone(),
Arc::new(LanguageRegistry::from_configs(vec![language])),
Arc::new(move |event: IdeEvent| sink_events.lock().push(event)),
);
manager.did_open(DocId::next(), &root.join("main.mock"), &LanguageId::new("mock"), 1, "x");
let message = tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let Some(message) = events.lock().iter().find_map(|event| match event {
IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(),
_ => None,
}) {
break message;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(message.contains("exited with code 37"), "{message}");
assert!(message.contains("stderr-59"), "{message}");
assert!(message.contains(&expected), "{message}");
assert!(!message.contains("stderr-0\n"), "{message}");
manager.shutdown_all().await;
}
+1 -1
View File
@@ -105,7 +105,7 @@ fn requests(message: &Value) -> Vec<Value> {
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
let not_running = || {
format!(
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Tools › MCP Server",
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
project.display()
)
};
-1
View File
@@ -156,7 +156,6 @@ pub enum PluginTestEvent {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunTargetOperation {
Run,
Debug,
Test,
}
-1
View File
@@ -1196,7 +1196,6 @@ impl Runtime {
let launch = wit_rtv::LaunchInfo {
operation: match launch.operation {
crate::RunTargetOperation::Run => wit_rtv::Operation::Run,
crate::RunTargetOperation::Debug => wit_rtv::Operation::Debug,
crate::RunTargetOperation::Test => wit_rtv::Operation::Test,
},
program: launch.program,
Binary file not shown.
@@ -1 +1 @@
daecb28adad6f82f932ef70e042f520959d3429e9e2a23cda7df7ec05ba5f259
163429536090fe11286cce425fabad3e84c14e1ea4e4b678be88a0bad0a1285a
Binary file not shown.
@@ -1 +1 @@
ef4cedb7ab9caa7a5a597fa8bd302e4e10607989c575af1ca0c237f2d8bb06c8
bfdd78a242472a1279e0e393f66f0d96a856338e103aad454239a451df22cd05
Binary file not shown.
@@ -1 +1 @@
c215812502ccbb85b08efb9da234e6e9709deb84a492b41e771c61d4c38a05fb
37276095967174307db4c5113d85b777138f60ee82d26b4af8ef3f2acd90aafa
Binary file not shown.
@@ -1 +1 @@
52efdb99877cded6fc5357d80f722975e314610b9d2bc91979b4b4cb05d8cdb8
b9597de10e7182e1ed488f80b6c1deb9f810912797b2bf02c9cae0347ee38bc8
Binary file not shown.
@@ -1 +1 @@
0fb78e5c48f267e529ceb719ceb1e5aca82f2288071e9a0cc14809c27e7cd15a
3ee642e6d1f983ba5e186007e2115b0a104383510cc8acc13f3e0a83fe1ca5b6
Binary file not shown.
@@ -1 +1 @@
5a4c7a37b5ff83d62dcc3da616da3712113fa6257e834f51705a4f7e417e519a
43f7af5d824853a3fbaa61ef408127cd2aac0134e6ec02ca2fb35813db4b0bd9
Binary file not shown.
@@ -1 +1 @@
e5598d719f6e66790858758718842e4474747c6e14eb3e1c29c4584b26279ef6
d85df5d6fb5e9d746fe4b28e1f8546c30fe6dc53c1b9dfd9201bc6a9dacf2306
Binary file not shown.
@@ -1 +1 @@
18a37beb12a0a3da68345314f7bfbb9521e8819702ef50a1c81f29bc3112c997
17fcb4251b6bdd055e4e856bd8d69d7eca52693cefb818b547787907022b7943
Binary file not shown.
@@ -1 +1 @@
c464178ead4043d49d10e63430436f9e261b211b1a7ff18d146ed244975baf7d
50d8a7304714e0810da7f8a2f723155287c26807848049b00492cb9def6c7763
Binary file not shown.
@@ -1 +1 @@
655bbe70bf3280a6e301255955d3e99e68b29242538b60060f61082977019212
8442bac7628c5e3ddbf2af2337781016197028054c83d4589dbbcf479c77ff87
Binary file not shown.
@@ -1 +1 @@
2987ca7af6f5ae800ed3bcf826b7f85b713371cb6efa483953e0a4974248f61f
054bdb7b732f74b0266880bfea3699bb9994bbdba4054dc2935a6cc0bcdf57de
Binary file not shown.
@@ -1 +1 @@
ff914b1c3ae4709c86e8138cdc84ea500ebde77ac09dcd3722faeffeb25b3454
fb3097d0429782d24ac647433bb7146a29f9b28a505bd2e7693c0244dac3c1e1
Binary file not shown.
@@ -1 +1 @@
8db8367b3e0872ebcb03b7f37a55fa1510f52e7f40eb07e401a40a0019349eb8
e6da5f8b4c30861c17c7da1b62992de6132ed6ab99f404292193e39a40a68637
Binary file not shown.
@@ -1 +1 @@
8ec23043c138a4fe9ba13cdaf13b78b56032eea14f48caddde6e5016226e3d28
514f87032622056e8b89f1c4d7950bb0a466c9c857fe27d65a523ea7d3c35911
Binary file not shown.
@@ -1 +1 @@
cde70a51050b29cbf553df08c6aadb660be51c9146ec1d3e186de55a3130a7c1
8908a092da5958e55fab506e081695fc7b48ea3bf287832aaac3e27e4c2adb21
@@ -1 +1 @@
21ac46f028c4a151b1f89eac12ad8894d1dbc84a3489f3054dec239c87de5e4a
2a4996cbd60e29f4c3c47748d9ac7a4ca05ff4d8c55519fb6e06cec88b868be5
@@ -1 +1 @@
7981067231363be183e28e9aa0dd8cffb430783080c702a1843c361187b3f267
4385c1553a4aaed9030d462a72677d07b02eaa0d08b8c1770f42116efca95d94
Binary file not shown.
@@ -1 +1 @@
e4fafd8b7ea6d9bb6d10836c8fc133a58277f68e293410d221372bced719a192
777d722fbe5673ca37165c2032389ea7c9518f7b0b3b51953e26ba01b50b3ec4
Binary file not shown.
@@ -1 +1 @@
c6d9a0c72cf63c1a4ddb9cb45fa7eaf2cd4d6e14d6977b6e834196ad526f0a2a
e9b3cc0b1bf82b06ace741ccb9f9e23d0dab2f61a5695d786dec08b7839f77aa
Binary file not shown.
@@ -1 +1 @@
004d552137bc2d2654b7343a9368c14e8b8f07a03f31fb70b7ac42481bdfac8c
fac47c0d2e74cb236b063e8edc617a991aaf7eecf6cf71a75404310ed270f75e
Binary file not shown.
@@ -1 +1 @@
bfbadd644f0b65d36817177a935f1262baf136ea26378902d01e9e3b6eec6412
ba6c1563853158291b0524146031363c45288757cf959cf143db60437beb4b93
Binary file not shown.
@@ -1 +1 @@
691d78767dadaa3bb8892b15592072cd343ca618d59e82653012de78e5072b22
57e4765ec72da0bb4768b5c097fead43e9484449abfc80c925bccd7ecc91af7e
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff. Show more