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

..
Автор SHA1 Повідомлення Дата
l0ng-ai ac7139c94d feat(plugins): open two texts in the diff viewer (API 1.7)
Plugins could show their own content in an editor tab (open-content-tab) but
could open the diff viewer only as the reply to one of that tab's actions. A
review plugin (a merge request's files, say) had no way to show a file's
before and after.

views.open-diff opens two texts in the invoking window's diff viewer, with
optional labels and title and a file name that picks the syntax highlighting;
web views send { op: "open-diff", ... }. The SDK wraps it as host::open_diff
with a mock. The plugin API moves to 1.7.0 (1.6.0 is released) and the checked-in
components are rebuilt.
2026-11-13 11:47:06 +08:00
1017 змінених файлів з 4951 додано та 48654 видалено

No files matched your search

+1 -1
Переглянути файл
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=271.2085.0
KOTLIN_LSP=263.7084.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
+1 -1
Переглянути файл
@@ -119,7 +119,7 @@ jobs:
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
+2 -11
Переглянути файл
@@ -78,7 +78,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}", cache-on-failure: true }
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
@@ -151,13 +151,6 @@ jobs:
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($env:TAURI_SIGNING_PRIVATE_KEY) {
pnpm exec tauri signer sign --app-version $version $asset
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
gh release upload $env:TAG "$asset.sig" --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
@@ -181,7 +174,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}", cache-on-failure: true }
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
@@ -240,8 +233,6 @@ jobs:
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
[/_windows_arm64_portable\.zip$/, ["windows-aarch64-portable"]],
[/_windows_x64_portable\.zip$/, ["windows-x86_64-portable"]],
];
const platforms = {};
for (const sig of sigs) {
Створено
+44 -46
Переглянути файл
@@ -4144,9 +4144,8 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
"encoding_rs",
"ide-core",
@@ -4169,7 +4168,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4183,7 +4182,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"serde",
@@ -4194,7 +4193,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"blake3",
"chardetng",
@@ -4212,7 +4211,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4226,7 +4225,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4236,7 +4235,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4253,7 +4252,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-run",
@@ -4267,7 +4266,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"futures",
"ide-core",
@@ -4289,12 +4288,11 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
name = "ide-db"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"bb8",
@@ -4330,7 +4328,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"serde",
@@ -4343,7 +4341,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4364,7 +4362,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4376,7 +4374,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4389,7 +4387,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"criterion",
"ide-core",
@@ -4407,7 +4405,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4478,7 +4476,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4497,7 +4495,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"blake3",
"criterion",
@@ -4519,7 +4517,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4531,7 +4529,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"encoding_rs",
"futures",
@@ -4548,7 +4546,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"libc",
"tempfile",
@@ -4557,7 +4555,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"futures",
"serde",
@@ -4568,7 +4566,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"blake3",
"criterion",
@@ -4625,7 +4623,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4653,7 +4651,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"bytes",
"dunce",
@@ -4672,7 +4670,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"futures",
"ide-core",
@@ -4695,7 +4693,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -4718,7 +4716,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-jvm-attach",
@@ -4732,7 +4730,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"dunce",
"futures",
@@ -4752,7 +4750,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"dunce",
"encoding_rs",
@@ -4776,7 +4774,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-tools",
@@ -4792,7 +4790,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"dunce",
"ide-core",
@@ -4813,7 +4811,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"criterion",
"encoding_rs",
@@ -4834,7 +4832,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"parking_lot",
@@ -4847,7 +4845,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4872,7 +4870,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"ide-language",
@@ -4887,7 +4885,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"ide-core",
"libc",
@@ -4900,7 +4898,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"criterion",
"ide-core",
@@ -4913,7 +4911,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"flate2",
"hex",
@@ -4934,7 +4932,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -4955,7 +4953,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"chardetng",
"dunce",
@@ -4979,7 +4977,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"dirs 6.0.0",
"ide-core",
@@ -4995,7 +4993,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"blake3",
"dunce",
@@ -6596,7 +6594,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.45"
version = "0.1.43"
dependencies = [
"base64 0.22.1",
"blake3",
+1 -1
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.45"
version = "0.1.43"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+13 -4
Переглянути файл
@@ -9,10 +9,6 @@
The IDE you know, rebuilt in Rust.
</p>
<p align="center">
English · <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
&nbsp;·&nbsp;
@@ -23,6 +19,8 @@
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
&nbsp;·&nbsp;
<a href="#sponsor">Sponsor</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -72,6 +70,17 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
## Sponsor
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
<table>
<tr>
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
</tr>
</table>
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
-88
Переглянути файл
@@ -1,88 +0,0 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="src-tauri/icons/source/openidea-wordmark-dark.svg">
<img src="src-tauri/icons/source/openidea-wordmark.svg" alt="OpenIDEA" height="56">
</picture>
</p>
<p align="center">
你熟悉的 IDE,用 Rust 重新打造。
</p>
<p align="center">
<a href="README.md">English</a> · 简体中文
</p>
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>下载</b></a>
&nbsp;·&nbsp;
<a href="https://openidea.sh/zh">文档</a>
&nbsp;·&nbsp;
<a href="#性能对比">性能对比</a>
&nbsp;·&nbsp;
<a href="#插件">插件</a>
&nbsp;·&nbsp;
<a href="#社区">社区</a>
&nbsp;·&nbsp;
<a href="#赞助">赞助</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
OpenIDEA 把 IntelliJ IDEA 的使用方式(快捷键、工具窗口、随处搜索的习惯)带到一个以 Rust
为核心的原生桌面应用里。
编辑器、代码洞察、调试器、Git、终端、数据库客户端和 HTTP 客户端,支持 60 多种语言。
提供 macOS、Windows 和 Linux 版本。
## 性能对比
在缓存已预热的情况下打开 [freqtrade](https://github.com/freqtrade/freqtrade)(Python,约 770 个文件),
取 5 次运行的中位数:
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
| --------------------- | -------------- | ---------------------- |
| 显示项目窗口 | **0.9 s** | 3.1 s |
| 就绪(CPU 平稳) | **1.1 s** | 13.2 s |
| 内存(USS,所有进程) | **169 MB** | 2011 MB |
| 就绪前消耗的 CPU 时间 | **2.4 s** | 47.9 s |
| 空闲 CPU | 单核的 **4 %** | 单核的 7 % |
| 退出 | **0.3 s** | 5.9 s |
测试使用 `main` 分支的 OpenIDEA release 构建,以及默认设置和插件的 IDEA,环境为 Windows 11、
32 核 AMD、62 GB 内存。IDEA 对 Python 的分析更深入,所以两边的“就绪”并不代表完成了同样多的工作。
两者都以相同方式从外部测量:可以用 [`perf/compare`](perf/compare) 在自己的机器和项目上复现。
## 插件
插件可以添加操作、补全、快速修复、工具窗口、自定义编辑器、主题、文件图标和语言。插件以沙箱化的
WebAssembly 组件运行;主题、图标主题和语言无需编写代码。
在“设置 › 插件”中安装,或用 `openidea-plugin new` 创建自己的插件:参见
[编写 OpenIDEA 插件](https://openidea.sh/zh/plugins)和[官方插件](plugins/official)。插件 API 目前仍是预览版。
## 社区
- [Discord](https://discord.gg/2Zy4yn7gP):提问、反馈和版本发布消息。
- 微信群,扫码加入:
<img src=".github/assets/wechat-group.png" alt="微信群二维码" width="200">
- 参与贡献:参见 [CONTRIBUTING.md](CONTRIBUTING.md) 和[贡献者文档](https://openidea.sh/zh/contribute/setup)。
## 赞助
如果 OpenIDEA 帮你节省了时间,可以通过支付宝或微信支付支持它的开发。
<table>
<tr>
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="支付宝收款码" width="200"><br>支付宝</td>
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="微信支付收款码" width="200"><br>微信支付</td>
</tr>
</table>
## 许可证
[Apache 2.0](LICENSE)。随附的第三方资源列在 [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) 中。
<sub>与 JetBrains 无关联。IntelliJ IDEA 是 JetBrains s.r.o. 的商标。</sub>
-4
Переглянути файл
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
encoding_rs.workspace = true
blake3.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
@@ -25,6 +24,3 @@ dunce.workspace = true
uuid.workspace = true
tempfile.workspace = true
which = "8"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+27 -35
Переглянути файл
@@ -3,7 +3,6 @@
//! `initialize` alone).
use super::{Conn, Dialect};
use crate::catalogs::Discovered;
use crate::config;
use crate::model::AgentConfigOption;
use crate::provider::ProviderSpec;
@@ -16,7 +15,7 @@ use std::time::Duration;
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Discovered, String> {
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
let root = shared.paths.project_root.clone();
let launch = spec.clone();
let launch = tokio::task::spawn_blocking({
@@ -26,12 +25,12 @@ pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Dis
.await
.map_err(|e| e.to_string())?;
let cwd = dunce::canonicalize(&root).unwrap_or(root);
let updates = Arc::new(DiscoveryUpdates { provider: spec.id.clone(), commands: Mutex::new(None) });
let client = Dialect::wrap(updates.clone());
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(|_| {});
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
let lines = stderr.clone();
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
let _stop = crate::catalogs::KillPeer(peer.clone());
let conn = Conn::new(peer.clone(), client);
let work = async {
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
@@ -52,41 +51,34 @@ pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<Dis
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
_ => Vec::new(),
};
let commands = updates
.commands
.lock()
.clone()
.or_else(|| new.get("availableCommands").map(|_| config::commands_of(&new, &launch.id)));
Ok::<_, String>(Discovered { config: options, commands, levels: levels.into_iter().collect() })
let option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
tracing::info!(
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
launch.id,
options.len(),
option_ids,
levels.len()
);
shared.remember_discovered(&launch.id, &options, &[], &levels);
Ok::<(), String>(())
};
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
peer.kill();
match result {
Ok(result) => result,
Err(_) => Err("the agent did not answer in time".into()),
}
}
#[derive(Default)]
struct DiscoveryUpdates {
provider: String,
commands: Mutex<Option<Vec<crate::AgentCommand>>>,
}
impl ide_jsonrpc::Handler for DiscoveryUpdates {
fn on_notification(&self, method: &str, params: Value) {
if method == "session/update"
&& let Some(update) = params.get("update")
&& update["sessionUpdate"] == "available_commands_update"
{
// Commands are protocol data, not stderr (which may contain credentials).
*self.commands.lock() = Some(config::commands_of(update, &self.provider));
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(match stderr.lock().last() {
Some(line) => format!("{e}: {line}"),
None => e,
}),
Err(_) => {
let tail = stderr.lock().join("\n");
if tail.is_empty() {
Err("the agent did not answer in time".into())
} else {
Err(format!("the agent did not answer in time:\n{tail}"))
}
}
}
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unsupported discovery request {method}")))
}
}
fn is_thought_level(o: &AgentConfigOption) -> bool {
-1
Переглянути файл
@@ -108,7 +108,6 @@ fn manager(dir: &Path, agent: Arc<FakeV2Agent>) -> AgentManager {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
-1
Переглянути файл
@@ -155,7 +155,6 @@ async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent()
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
-427
Переглянути файл
@@ -1,427 +0,0 @@
//! Model availability for new tasks. Sessions own selections; discovery owns catalogs.
use crate::{
AgentCommand, AgentConfigOption, AgentEvent, AgentModelCatalog, Known, Shared, acp, config, native, provider,
};
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use std::time::Duration;
use tokio::sync::watch;
use tokio::time::Instant;
const FRESH_FOR: Duration = Duration::from_secs(15 * 60);
const COOLDOWN: Duration = Duration::from_secs(30);
type Store = Arc<Mutex<HashMap<String, Known>>>;
type Key = (PathBuf, PathBuf, String);
/// Backend-wide single-flight model lookups, scoped to project and effective launch configuration.
#[derive(Default)]
pub struct AgentCatalogs {
state: Mutex<State>,
#[cfg(test)]
clock_ms: std::sync::atomic::AtomicU64,
}
#[derive(Default)]
struct State {
stores: HashMap<PathBuf, Store>,
entries: HashMap<Key, Entry>,
subscribers: Vec<Weak<Shared>>,
generation: u64,
}
struct Entry {
// Launch credentials remain only in memory, never serialized or logged.
context: LaunchContext,
generation: u64,
completed: Option<Instant>,
successful: Option<Instant>,
retry_at_ms: Option<f64>,
error: Option<String>,
flight: Option<watch::Receiver<bool>>,
abort: Option<tokio::task::AbortHandle>,
catalog: Option<CatalogData>,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CatalogData {
pub config: Vec<AgentConfigOption>,
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
pub refreshed_at_ms: Option<f64>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SavedCatalog {
/// An opaque cryptographic digest, never raw launch arguments or credentials.
fingerprint: String,
#[serde(flatten)]
catalog: CatalogData,
}
#[derive(PartialEq)]
struct LaunchContext {
spec: provider::ProviderSpec,
executable: PathBuf,
modified: Option<std::time::SystemTime>,
len: Option<u64>,
}
impl LaunchContext {
fn new(spec: provider::ProviderSpec, root: &std::path::Path) -> Self {
let resolved = spec.clone().resolve_for_launch(root);
let executable = dunce::canonicalize(&resolved.command).unwrap_or_else(|_| PathBuf::from(&resolved.command));
let metadata = std::fs::metadata(&executable).ok();
Self {
spec,
executable,
modified: metadata.as_ref().and_then(|m| m.modified().ok()),
len: metadata.as_ref().map(std::fs::Metadata::len),
}
}
fn fingerprint(&self) -> String {
let mut hash = blake3::Hasher::new();
fn field(hash: &mut blake3::Hasher, value: &[u8]) {
hash.update(&(value.len() as u64).to_le_bytes());
hash.update(value);
}
field(&mut hash, b"openidea-model-catalog-v1");
field(
&mut hash,
match self.spec.driver {
provider::Driver::Acp => b"acp",
provider::Driver::Codex => b"codex",
provider::Driver::Claude => b"claude",
},
);
field(&mut hash, self.spec.command.as_bytes());
hash.update(&(self.spec.args.len() as u64).to_le_bytes());
for arg in &self.spec.args {
field(&mut hash, arg.as_bytes());
}
hash.update(&(self.spec.env.len() as u64).to_le_bytes());
for (name, value) in &self.spec.env {
field(&mut hash, name.as_bytes());
field(&mut hash, value.as_bytes());
}
field(&mut hash, self.executable.to_string_lossy().as_bytes());
if let Some(modified) = self.modified.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) {
hash.update(&modified.as_nanos().to_le_bytes());
}
if let Some(len) = self.len {
hash.update(&len.to_le_bytes());
}
hash.finalize().to_hex().to_string()
}
}
pub(crate) struct Discovered {
pub config: Vec<AgentConfigOption>,
pub commands: Option<Vec<AgentCommand>>,
pub levels: BTreeMap<String, Vec<AgentConfigOption>>,
}
pub(crate) struct KillPeer(pub ide_jsonrpc::Peer);
impl Drop for KillPeer {
fn drop(&mut self) {
self.0.kill();
}
}
impl AgentCatalogs {
fn now(&self) -> Instant {
let now = Instant::now();
#[cfg(test)]
let now = now + Duration::from_millis(self.clock_ms.load(std::sync::atomic::Ordering::Relaxed));
now
}
fn epoch_ms(&self) -> f64 {
let now = crate::now_ms();
#[cfg(test)]
let now = now + self.clock_ms.load(std::sync::atomic::Ordering::Relaxed) as f64;
now
}
pub(crate) fn store(&self, path: &std::path::Path) -> Store {
self.state
.lock()
.stores
.entry(path.to_path_buf())
.or_insert_with(|| {
Arc::new(Mutex::new(
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(),
))
})
.clone()
}
pub(crate) fn subscribe(&self, shared: &Arc<Shared>) {
let mut state = self.state.lock();
state.subscribers.retain(|s| s.strong_count() > 0);
state.subscribers.push(Arc::downgrade(shared));
}
fn key(shared: &Shared, provider: &str) -> Key {
(shared.paths.models_file.clone(), shared.paths.project_root.clone(), provider.to_owned())
}
fn fallback(shared: &Shared, provider: &str) -> CatalogData {
let cache = shared.known.lock();
let Some(known) = cache.get(provider) else { return CatalogData::default() };
known.catalogs.get(&shared.root_key()).map(|saved| saved.catalog.clone()).unwrap_or_else(|| CatalogData {
config: known.config.clone(),
thought_levels: known.thought_levels.clone(),
refreshed_at_ms: None,
})
}
pub(crate) fn cached(&self, shared: &Shared, provider: &str) -> CatalogData {
self.state
.lock()
.entries
.get(&Self::key(shared, provider))
.and_then(|e| e.catalog.clone())
.unwrap_or_else(|| Self::fallback(shared, provider))
}
fn snapshot(&self, shared: &Shared, provider: &str, entry: &Entry) -> AgentModelCatalog {
let catalog = entry.catalog.clone().unwrap_or_else(|| Self::fallback(shared, provider));
AgentModelCatalog {
provider: provider.to_owned(),
config: shared.config_for_new_task(catalog.config, provider),
thought_levels: catalog.thought_levels,
refreshing: entry.flight.is_some(),
refreshed_at_ms: catalog.refreshed_at_ms,
retry_at_ms: entry
.retry_at_ms
.filter(|_| entry.completed.is_some_and(|at| self.now().duration_since(at) < COOLDOWN)),
error: entry.error.clone(),
}
}
/// Start or join a lookup. No queued refresh can outlive the request that asked for it.
pub(crate) fn request(
self: &Arc<Self>,
shared: &Arc<Shared>,
spec: provider::ProviderSpec,
force: bool,
) -> (AgentModelCatalog, Option<watch::Receiver<bool>>) {
let key = Self::key(shared, &spec.id);
let context = LaunchContext::new(spec.clone(), &shared.paths.project_root);
let now = self.now();
let mut state = self.state.lock();
let changed = state.entries.get(&key).is_none_or(|e| e.context != context);
if changed {
if let Some(abort) = state.entries.get_mut(&key).and_then(|entry| entry.abort.take()) {
abort.abort();
}
state.generation += 1;
let generation = state.generation;
let saved =
shared.known.lock().get(&spec.id).and_then(|known| known.catalogs.get(&shared.root_key())).cloned();
let matching = saved.as_ref().is_some_and(|saved| saved.fingerprint == context.fingerprint());
let age = saved
.as_ref()
.filter(|_| matching)
.and_then(|saved| saved.catalog.refreshed_at_ms)
.map(|at| self.epoch_ms() - at)
.filter(|age| *age >= 0.0 && *age < FRESH_FOR.as_millis() as f64)
.map(|age| Duration::from_secs_f64(age / 1000.0));
let successful = age.and_then(|age| now.checked_sub(age));
let completed = age.filter(|age| *age < COOLDOWN).and_then(|age| now.checked_sub(age));
let retry_at_ms = completed
.and_then(|_| saved.as_ref().and_then(|s| s.catalog.refreshed_at_ms))
.map(|at| at + COOLDOWN.as_millis() as f64);
let catalog = saved.map(|saved| {
let mut catalog = saved.catalog;
if !matching {
catalog.refreshed_at_ms = None;
}
catalog
});
state.entries.insert(
key.clone(),
Entry {
context,
generation,
completed,
successful,
retry_at_ms,
catalog,
error: None,
flight: None,
abort: None,
},
);
}
let entry = state.entries.get_mut(&key).expect("catalog entry");
let cooling = entry.completed.is_some_and(|at| now.duration_since(at) < COOLDOWN);
let fresh = entry.successful.is_some_and(|at| now.duration_since(at) < FRESH_FOR);
let mut start = None;
if entry.flight.is_none() && !cooling && (force || !fresh) {
let (done, ready) = watch::channel(false);
entry.flight = Some(ready);
entry.error = None;
entry.retry_at_ms = None;
start = Some((entry.generation, done));
}
let snapshot = self.snapshot(shared, &spec.id, entry);
let flight = entry.flight.clone();
drop(state);
if let Some((generation, done)) = start {
self.broadcast(shared, &snapshot);
let coordinator = self.clone();
let shared = shared.clone();
let flight_key = key.clone();
let task = shared.runtime.clone().spawn(async move {
let result = discover(&shared, spec).await;
coordinator.complete(&shared, flight_key, generation, result);
let _ = done.send(true);
});
// A subscriber can re-enter and change configuration during the start event.
let mut state = self.state.lock();
if let Some(entry) = state.entries.get_mut(&key).filter(|entry| entry.generation == generation) {
if entry.flight.is_some() {
entry.abort = Some(task.abort_handle());
}
} else {
task.abort();
}
}
(snapshot, flight)
}
fn complete(&self, shared: &Shared, key: Key, generation: u64, result: Result<Discovered, String>) {
let current = provider::load(shared.paths.custom_agents_dir.as_deref(), shared.env_secrets())
.into_iter()
.find(|spec| spec.id == key.2)
.map(|spec| LaunchContext::new(spec, &shared.paths.project_root));
let mut state = self.state.lock();
let Some(entry) = state.entries.get_mut(&key).filter(|e| e.generation == generation) else { return };
// Configuration may have changed without another refresh request arriving.
if current.as_ref() != Some(&entry.context) {
entry.flight = None;
entry.abort = None;
entry.completed = None;
entry.retry_at_ms = None;
entry.error = None;
let snapshot = self.snapshot(shared, &key.2, entry);
state.entries.remove(&key);
drop(state);
self.broadcast(shared, &snapshot);
return;
}
let now = self.now();
entry.completed = Some(now);
entry.retry_at_ms = Some(self.epoch_ms() + COOLDOWN.as_millis() as f64);
entry.flight = None;
entry.abort = None;
let success = match result {
Ok(found) => {
let mut cache = shared.known.lock();
let known = cache.entry(key.2.clone()).or_default();
let catalog = CatalogData {
config: found.config,
thought_levels: found.levels,
refreshed_at_ms: Some(self.epoch_ms()),
};
known.config = catalog.config.clone();
known.thought_levels = catalog.thought_levels.clone();
if let Some(commands) = found.commands {
known.commands.insert(shared.root_key(), commands);
}
known.authoritative = true;
known.refreshed_at_ms = catalog.refreshed_at_ms;
known.catalogs.insert(
shared.root_key(),
SavedCatalog { fingerprint: entry.context.fingerprint(), catalog: catalog.clone() },
);
entry.catalog = Some(catalog);
shared.persist_known(&cache);
entry.successful = Some(now);
entry.error = None;
true
}
Err(error) => {
entry.error = Some(error);
false
}
};
let snapshot = self.snapshot(shared, &key.2, entry);
drop(state);
self.broadcast(shared, &snapshot);
if success {
for subscriber in self.subscribers(shared) {
subscriber.emit(AgentEvent::Providers);
}
}
}
fn subscribers(&self, shared: &Shared) -> Vec<Arc<Shared>> {
self.state
.lock()
.subscribers
.iter()
.filter_map(Weak::upgrade)
.filter(|s| {
s.paths.models_file == shared.paths.models_file && s.paths.project_root == shared.paths.project_root
})
.collect()
}
fn broadcast(&self, shared: &Shared, catalog: &AgentModelCatalog) {
for subscriber in self.subscribers(shared) {
subscriber.emit(AgentEvent::ModelCatalog { catalog: catalog.clone() });
}
}
}
impl crate::AgentManager {
/// Return cached choices immediately and schedule discovery when eligible.
pub fn refresh_models(&self, provider: &str, force: bool) -> IdeResult<AgentModelCatalog> {
// Settings and environment secrets can change while this window is open.
*self.shared.providers.write() =
provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets());
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
Ok(self.shared.paths.catalogs.request(&self.shared, spec, force).0)
}
}
async fn discover(shared: &Arc<Shared>, spec: provider::ProviderSpec) -> Result<Discovered, String> {
match spec.driver {
provider::Driver::Acp => acp::discover::run(shared, &spec).await,
provider::Driver::Codex => native::codex::discover(spec, shared.paths.project_root.clone()).await,
provider::Driver::Claude => {
let found = native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await?;
Ok(Discovered {
commands: Some(config::commands_of(
&serde_json::json!({ "availableCommands": found.commands }),
&spec.id,
)),
config: config::options_of(&serde_json::json!({ "configOptions": found.options })),
levels: found
.efforts
.into_iter()
.map(|(model, option)| {
let levels = option
.map(|o| config::options_of(&serde_json::json!({ "configOptions": [o] })))
.unwrap_or_default();
(model, levels)
})
.collect(),
})
}
}
}
#[cfg(test)]
mod tests;
-501
Переглянути файл
@@ -1,501 +0,0 @@
use super::*;
use crate::{AgentManager, AgentPaths, Connector};
use ide_jsonrpc::{Framing, Peer};
use serde_json::{Value, json};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot};
const PROVIDER: &str = "catalog-test";
#[derive(Debug)]
struct Pending {
params: Value,
reply: oneshot::Sender<Result<Value, String>>,
}
struct Harness {
connector: Connector,
pending: mpsc::UnboundedReceiver<Pending>,
launches: Arc<AtomicUsize>,
live: Arc<AtomicUsize>,
}
struct Live(Arc<AtomicUsize>);
impl Drop for Live {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
impl Harness {
fn new() -> Self {
let (pending_tx, pending) = mpsc::unbounded_channel();
let launches = Arc::new(AtomicUsize::new(0));
let live = Arc::new(AtomicUsize::new(0));
let connector: Connector = Arc::new({
let launches = launches.clone();
let live = live.clone();
move |_, _, client, _| {
launches.fetch_add(1, Ordering::SeqCst);
live.fetch_add(1, Ordering::SeqCst);
let guard = Live(live.clone());
let pending = pending_tx.clone();
let (client_io, server_io) = tokio::io::duplex(1 << 16);
let (read, mut write) = tokio::io::split(server_io);
tokio::spawn(async move {
let _guard = guard;
let mut lines = BufReader::new(read).lines();
while let Some(line) = lines.next_line().await.unwrap() {
let request: Value = serde_json::from_str(&line).unwrap();
let id = &request["id"];
if id.is_null() {
continue;
}
let response = match request["method"].as_str().unwrap() {
"initialize" => json!({ "id": id, "jsonrpc": "2.0", "result": {
"protocolVersion": 1, "agentCapabilities": {}, "authMethods": [] } }),
"session/new" => {
let (reply, result) = oneshot::channel();
pending.send(Pending { params: request["params"].clone(), reply }).unwrap();
let result = tokio::select! {
result = result => result.unwrap(),
_ = lines.next_line() => break,
};
match result {
Ok(result) => json!({ "id": id, "jsonrpc": "2.0", "result": result }),
Err(error) => {
json!({ "id": id, "jsonrpc": "2.0", "error": { "code": -32000, "message": error } })
}
}
}
method => panic!("discovery must not call {method}"),
};
if write.write_all(format!("{response}\n").as_bytes()).await.is_err() {
break;
}
}
});
let (read, write) = tokio::io::split(client_io);
Ok(Peer::connect_framed(read, write, client, Framing::Lines))
}
});
Self { connector, pending, launches, live }
}
async fn next(&mut self) -> Pending {
tokio::time::timeout(Duration::from_secs(5), self.pending.recv()).await.unwrap().unwrap()
}
}
fn configure(root: &Path, version: &str) {
std::fs::create_dir_all(root.join("agents")).unwrap();
let executable = root.join("catalog-test-bin");
if !executable.exists() {
std::fs::write(&executable, "first executable").unwrap();
}
std::fs::write(
root.join("agents/catalog-test.toml"),
format!(
"name = \"Catalog Test\"\ncommand = {}\n[env]\nTOKEN = \"secret-{version}\"\n",
serde_json::to_string(&executable.to_string_lossy()).unwrap()
),
)
.unwrap();
}
fn make_manager(root: &Path, catalogs: Arc<AgentCatalogs>, harness: &Harness) -> AgentManager {
make_manager_at(root, &root.join("models.json"), catalogs, harness)
}
fn make_manager_at(root: &Path, models_file: &Path, catalogs: Arc<AgentCatalogs>, harness: &Harness) -> AgentManager {
AgentManager::with_connector(
AgentPaths {
project_root: root.to_path_buf(),
store_dir: root.join("store"),
worktrees_dir: root.join("worktrees"),
models_file: models_file.to_path_buf(),
catalogs,
custom_agents_dir: Some(root.join("agents")),
home_dir: Some(root.to_path_buf()),
env_secrets: None,
},
tokio::runtime::Handle::current(),
harness.connector.clone(),
)
}
fn events(manager: &AgentManager) -> mpsc::UnboundedReceiver<AgentEvent> {
let (tx, rx) = mpsc::unbounded_channel();
manager.subscribe(Arc::new(move |event| {
let _ = tx.send(event);
}));
rx
}
async fn completed(events: &mut mpsc::UnboundedReceiver<AgentEvent>) -> AgentModelCatalog {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let AgentEvent::ModelCatalog { catalog } = events.recv().await.unwrap()
&& !catalog.refreshing
{
return catalog;
}
}
})
.await
.unwrap()
}
fn response(models: &[&str], effort: &[&str]) -> Value {
json!({ "sessionId": "discovery-only", "configOptions": [
{ "id": "model", "type": "select", "category": "model", "currentValue": models.first(),
"options": models.iter().map(|m| json!({ "value": m, "name": m })).collect::<Vec<_>>() },
{ "id": "effort", "type": "select", "category": "thought_level", "currentValue": effort.first(),
"options": effort.iter().map(|e| json!({ "value": e })).collect::<Vec<_>>() }
] })
}
fn model_values(catalog: &AgentModelCatalog) -> Vec<&str> {
catalog
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect()
}
async fn stopped(harness: &Harness) {
tokio::time::timeout(Duration::from_secs(5), async {
while harness.live.load(Ordering::SeqCst) != 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
fn advance(catalogs: &AgentCatalogs, seconds: u64) {
catalogs.clock_ms.fetch_add(seconds * 1000, Ordering::Relaxed);
}
#[tokio::test]
async fn successful_refresh_replaces_models_and_efforts_and_old_sessions_cannot_revert_it() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
let old = config::options_of(&response(&["old", "retained"], &["low", "high"]));
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
let cached = manager.refresh_models(PROVIDER, false).unwrap();
assert!(cached.refreshing);
assert_eq!(model_values(&cached), ["old", "retained"]);
assert!(cached.refreshed_at_ms.is_none(), "session reports are not fresh discovery");
let pending = harness.next().await;
assert_eq!(pending.params["mcpServers"], json!([]));
pending.reply.send(Ok(response(&["retained", "new"], &["medium"]))).unwrap();
let fresh = completed(&mut events).await;
assert_eq!(model_values(&fresh), ["retained", "new"]);
assert_eq!(fresh.thought_levels.keys().map(String::as_str).collect::<Vec<_>>(), ["new", "retained"]);
assert_eq!(fresh.thought_levels["retained"][0].values[0].value, "medium");
assert!(fresh.refreshed_at_ms.is_some() && fresh.retry_at_ms.is_some());
manager.shared.remember(PROVIDER, Some(&old), None, Some("old"));
assert_eq!(manager.refresh_models(PROVIDER, false).unwrap().config, fresh.config);
let provider = manager.providers().into_iter().find(|p| p.id == PROVIDER).unwrap();
assert_eq!(provider.config, fresh.config);
let persisted = std::fs::read_to_string(tmp.path().join("models.json")).unwrap();
assert!(!persisted.contains("secret-one") && !persisted.contains("TOKEN"));
let restored = make_manager(tmp.path(), Arc::default(), &harness);
let restored_snapshot = restored.refresh_models(PROVIDER, false).unwrap();
assert!(!restored_snapshot.refreshing, "matching persisted launch context remains fresh");
assert_eq!(restored_snapshot.refreshed_at_ms, fresh.refreshed_at_ms);
let provider = restored.providers().into_iter().find(|p| p.id == PROVIDER).unwrap();
assert_eq!(provider.config, fresh.config);
restored.shared.remember(PROVIDER, Some(&old), None, Some("old"));
assert_eq!(restored.shared.known_config(PROVIDER), fresh.config);
assert_eq!(restored.shared.known.lock()[PROVIDER].refreshed_at_ms, fresh.refreshed_at_ms);
assert!(!tmp.path().join("store").exists(), "refresh never creates tasks");
}
#[tokio::test]
async fn legacy_cache_loads_without_claiming_authoritative_freshness() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let options = config::options_of(&response(&["legacy"], &["low"]));
std::fs::write(
tmp.path().join("models.json"),
serde_json::to_vec(&json!({
(PROVIDER): { "config": options, "thoughtLevels": {}, "projectCommands": {} }
}))
.unwrap(),
)
.unwrap();
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), Arc::default(), &harness);
let mut events = events(&manager);
let cached = manager.refresh_models(PROVIDER, false).unwrap();
assert_eq!(model_values(&cached), ["legacy"]);
assert!(cached.refreshing && cached.refreshed_at_ms.is_none());
harness.next().await.reply.send(Err("Sign in required".into())).unwrap();
let failed = completed(&mut events).await;
assert_eq!(failed.config, cached.config);
assert!(failed.error.as_deref().unwrap().contains("Sign in required"));
}
#[tokio::test]
async fn concurrent_windows_commands_and_manual_requests_share_one_flight_and_cooldown() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let first = Arc::new(make_manager(tmp.path(), catalogs.clone(), &harness));
let second = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut first_events = events(&first);
let mut second_events = events(&second);
assert!(first.refresh_models(PROVIDER, false).unwrap().refreshing);
let pending = harness.next().await;
for _ in 0..10 {
assert!(second.refresh_models(PROVIDER, true).unwrap().refreshing);
assert!(first.refresh_models(PROVIDER, true).unwrap().refreshing);
}
let commands = tokio::spawn({
let first = first.clone();
async move { first.commands(PROVIDER, None).await }
});
tokio::task::yield_now().await;
assert_eq!(harness.launches.load(Ordering::SeqCst), 1);
pending.reply.send(Ok(response(&["one"], &["low"]))).unwrap();
let done = completed(&mut first_events).await;
assert_eq!(completed(&mut second_events).await, done);
commands.await.unwrap().unwrap();
for force in [false, true, false, true] {
assert!(!second.refresh_models(PROVIDER, force).unwrap().refreshing);
}
advance(&catalogs, 29);
assert!(!first.refresh_models(PROVIDER, true).unwrap().refreshing);
advance(&catalogs, 1);
assert!(!first.refresh_models(PROVIDER, false).unwrap().refreshing, "automatic refresh remains fresh");
assert!(first.refresh_models(PROVIDER, true).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["two"], &["high"]))).unwrap();
let newer = completed(&mut first_events).await;
assert_eq!(model_values(&newer), ["two"]);
assert_eq!(completed(&mut second_events).await, newer);
advance(&catalogs, 15 * 60 - 1);
assert!(!second.refresh_models(PROVIDER, false).unwrap().refreshing);
advance(&catalogs, 1);
assert!(second.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["three"], &["low"]))).unwrap();
completed(&mut first_events).await;
assert_eq!(harness.launches.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn failure_keeps_successful_choices_and_throttles_manual_and_auto_retries() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&["cached"], &["low"]))).unwrap();
let success = completed(&mut events).await;
let disk = std::fs::read(tmp.path().join("models.json")).unwrap();
advance(&catalogs, 15 * 60);
manager.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Err("Authentication expired".into())).unwrap();
let failed = completed(&mut events).await;
assert_eq!(failed.config, success.config);
assert_eq!(failed.thought_levels, success.thought_levels);
assert_eq!(failed.refreshed_at_ms, success.refreshed_at_ms);
assert!(failed.error.as_deref().unwrap().contains("Authentication expired"));
assert_eq!(std::fs::read(tmp.path().join("models.json")).unwrap(), disk);
assert!(!manager.refresh_models(PROVIDER, false).unwrap().refreshing);
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
advance(&catalogs, 29);
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
advance(&catalogs, 1);
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["recovered"], &["high"]))).unwrap();
let recovered = completed(&mut events).await;
assert_eq!(model_values(&recovered), ["recovered"]);
assert!(recovered.error.is_none());
}
#[tokio::test]
async fn timeout_cleans_up_and_enters_the_same_failure_cooldown() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
let pending = harness.next().await;
tokio::time::pause();
tokio::time::advance(Duration::from_secs(4)).await;
let failed = completed(&mut events).await;
assert!(failed.error.as_deref().unwrap().contains("time"));
assert!(failed.retry_at_ms.is_some());
assert!(!manager.refresh_models(PROVIDER, true).unwrap().refreshing);
assert!(!manager.refresh_models(PROVIDER, false).unwrap().refreshing);
tokio::time::resume();
stopped(&harness).await;
assert_eq!(harness.live.load(Ordering::SeqCst), 0, "timed-out connection must close");
assert!(pending.reply.is_closed());
advance(&catalogs, 30);
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["retry"], &["low"]))).unwrap();
completed(&mut events).await;
}
#[tokio::test]
async fn changed_effective_config_supersedes_old_completion_without_queueing() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let first = make_manager(tmp.path(), catalogs.clone(), &harness);
let second = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&second);
first.refresh_models(PROVIDER, false).unwrap();
let obsolete = harness.next().await;
configure(tmp.path(), "two");
assert!(second.refresh_models(PROVIDER, false).unwrap().refreshing);
let latest = harness.next().await;
latest.reply.send(Ok(response(&["latest"], &["high"]))).unwrap();
let fresh = completed(&mut events).await;
let _ = obsolete.reply.send(Ok(response(&["obsolete"], &["low"])));
stopped(&harness).await;
let shown = first.refresh_models(PROVIDER, false).unwrap();
assert_eq!(shown.config, fresh.config);
assert_eq!(shown.refreshed_at_ms, fresh.refreshed_at_ms);
assert!(!shown.refreshing);
assert_eq!(harness.launches.load(Ordering::SeqCst), 2);
assert!(!std::fs::read_to_string(tmp.path().join("models.json")).unwrap().contains("obsolete"));
// A changed configuration also invalidates successful freshness/cooldown.
configure(tmp.path(), "three");
assert!(first.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["new-config"], &["medium"]))).unwrap();
completed(&mut events).await;
}
#[tokio::test]
async fn invalid_provider_is_a_request_error_and_does_not_launch() {
let tmp = tempfile::tempdir().unwrap();
let harness = Harness::new();
let manager = make_manager(tmp.path(), Arc::default(), &harness);
assert!(manager.refresh_models("missing-provider", true).is_err());
assert_eq!(harness.launches.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn replacing_the_executable_at_the_same_path_invalidates_freshness_and_obsolete_responses() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "unchanged-environment");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs, &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
let obsolete = harness.next().await;
std::fs::write(tmp.path().join("catalog-test-bin"), "replacement executable with different size").unwrap();
manager.refresh_models(PROVIDER, false).unwrap();
let latest = harness.next().await;
latest.reply.send(Ok(response(&["updated"], &["high"]))).unwrap();
let fresh = completed(&mut events).await;
let _ = obsolete.reply.send(Ok(response(&["obsolete"], &["low"])));
stopped(&harness).await;
assert_eq!(manager.refresh_models(PROVIDER, false).unwrap().config, fresh.config);
std::fs::write(tmp.path().join("catalog-test-bin"), "another executable").unwrap();
assert!(manager.refresh_models(PROVIDER, false).unwrap().refreshing);
harness.next().await.reply.send(Ok(response(&["new-install"], &["medium"]))).unwrap();
assert_eq!(model_values(&completed(&mut events).await), ["new-install"]);
assert_eq!(harness.launches.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn project_catalogs_and_persisted_freshness_do_not_leak_between_contexts() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("project-a");
let b = tmp.path().join("project-b");
configure(&a, "account-a");
configure(&b, "account-b");
let models_file = tmp.path().join("shared-models.json");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let first = make_manager_at(&a, &models_file, catalogs.clone(), &harness);
let second = make_manager_at(&b, &models_file, catalogs, &harness);
let mut first_events = events(&first);
let mut second_events = events(&second);
first.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["account-a-model"], &["low"]))).unwrap();
let a_catalog = completed(&mut first_events).await;
second.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["account-b-model"], &["high"]))).unwrap();
let b_catalog = completed(&mut second_events).await;
assert_eq!(first.refresh_models(PROVIDER, false).unwrap().config, a_catalog.config);
assert_eq!(second.refresh_models(PROVIDER, false).unwrap().config, b_catalog.config);
assert_eq!(first.providers().into_iter().find(|p| p.id == PROVIDER).unwrap().config, a_catalog.config);
for (root, expected) in [(&a, &a_catalog), (&b, &b_catalog)] {
let restored = make_manager_at(root, &models_file, Arc::default(), &harness);
let cached = restored.refresh_models(PROVIDER, false).unwrap();
assert!(!cached.refreshing, "matching persisted context should retain freshness");
assert_eq!(cached.config, expected.config);
assert_eq!(cached.refreshed_at_ms, expected.refreshed_at_ms);
}
assert_eq!(harness.launches.load(Ordering::SeqCst), 2);
configure(&a, "new-account");
let changed = make_manager_at(&a, &models_file, Arc::default(), &harness);
let mut events = events(&changed);
let stale = changed.refresh_models(PROVIDER, false).unwrap();
assert!(stale.refreshing, "a different launch environment cannot reuse persisted freshness");
assert!(stale.refreshed_at_ms.is_none());
assert_eq!(stale.config, a_catalog.config, "stale cached choices remain selectable");
harness.next().await.reply.send(Ok(response(&["new-account-model"], &["medium"]))).unwrap();
completed(&mut events).await;
let persisted = std::fs::read_to_string(models_file).unwrap();
assert!(!persisted.contains("secret-") && !persisted.contains("TOKEN"));
}
#[tokio::test]
async fn configuration_changed_without_another_request_also_discards_old_discovery() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "before");
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), Arc::default(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, false).unwrap();
let obsolete = harness.next().await;
configure(tmp.path(), "after");
obsolete.reply.send(Ok(response(&["obsolete"], &["low"]))).unwrap();
let discarded = completed(&mut events).await;
assert!(model_values(&discarded).is_empty());
assert!(discarded.refreshed_at_ms.is_none());
assert!(!tmp.path().join("models.json").exists());
manager.refresh_models(PROVIDER, false).unwrap();
harness.next().await.reply.send(Ok(response(&["current"], &["high"]))).unwrap();
assert_eq!(model_values(&completed(&mut events).await), ["current"]);
}
#[tokio::test]
async fn successful_empty_catalog_removes_all_previous_models_and_efforts() {
let tmp = tempfile::tempdir().unwrap();
configure(tmp.path(), "one");
let catalogs = Arc::new(AgentCatalogs::default());
let mut harness = Harness::new();
let manager = make_manager(tmp.path(), catalogs.clone(), &harness);
let mut events = events(&manager);
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&["removed"], &["low"]))).unwrap();
completed(&mut events).await;
advance(&catalogs, 30);
manager.refresh_models(PROVIDER, true).unwrap();
harness.next().await.reply.send(Ok(response(&[], &[]))).unwrap();
let empty = completed(&mut events).await;
assert!(model_values(&empty).is_empty() && empty.thought_levels.is_empty());
assert!(empty.refreshed_at_ms.is_some() && empty.error.is_none());
}
+3 -43
Переглянути файл
@@ -3,13 +3,11 @@
//!
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line, named in
//! `<home>/.codex/session_index.jsonl`).
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
use super::native::claude::history;
use serde::Serialize;
use specta::Type;
use std::collections::HashMap;
use std::io::BufRead;
use std::path::{Path, PathBuf};
@@ -208,39 +206,11 @@ fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
}
}
/// Thread names Codex keeps in `session_index.jsonl`, by thread id. A rename adds a line,
/// so the last one wins.
fn codex_names(codex_home: &Path) -> HashMap<String, String> {
let Ok(file) = std::fs::File::open(codex_home.join("session_index.jsonl")) else { return HashMap::new() };
let mut names = HashMap::new();
for line in std::io::BufReader::new(file).lines() {
let Ok(line) = line else { break };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
let id = v.get("id").and_then(serde_json::Value::as_str);
let name = v.get("thread_name").and_then(serde_json::Value::as_str).map(str::trim).filter(|n| !n.is_empty());
if let (Some(id), Some(name)) = (id, name) {
names.insert(id.to_owned(), name.to_owned());
}
}
names
}
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`, titled by their
/// thread names, else by their first prompts.
/// Codex sessions of the project at `root`: `<home>/.codex/sessions/**`.
pub fn codex_sessions(codex_home: &Path, root: &Path) -> Vec<ExternalSession> {
let mut files = Vec::new();
rollout_files(&codex_home.join("sessions"), 4, &mut files);
let mut sessions: Vec<ExternalSession> =
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect();
if !sessions.is_empty() {
let names = codex_names(codex_home);
for s in &mut sessions {
if let Some(name) = names.get(&s.session_id) {
s.title = short(name);
}
}
}
sessions
files.iter().filter_map(|p| codex_file(p)).filter(|s| in_project(s.cwd.as_deref(), root)).collect()
}
#[cfg(test)]
@@ -312,16 +282,6 @@ mod tests {
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
assert_eq!(s.title, "add tests");
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
write(
&tmp.path().join("session_index.jsonl"),
&[
json!({"id": "thread-1", "thread_name": "Add tests", "updated_at": "t1"}),
json!({"id": "thread-2", "thread_name": "Other", "updated_at": "t1"}),
json!({"id": "thread-1", "thread_name": "Add parser tests", "updated_at": "t2"}),
],
);
assert_eq!(codex_sessions(tmp.path(), Path::new("/work"))[0].title, "Add parser tests");
}
#[test]
+149 -67
Переглянути файл
@@ -8,7 +8,6 @@
//! next prompt when the agent supports `session/load`.
mod acp;
mod catalogs;
mod config;
mod conversation;
mod external;
@@ -22,7 +21,6 @@ mod skills;
mod timeline;
mod worktree;
pub use catalogs::AgentCatalogs;
pub use external::ExternalSession;
pub use model::*;
@@ -90,8 +88,6 @@ pub struct AgentPaths {
pub worktrees_dir: PathBuf,
/// Settings each agent offered last time (shared by all projects), and its commands per project.
pub models_file: PathBuf,
/// Shared by every project/window on this backend.
pub catalogs: Arc<AgentCatalogs>,
/// User-defined agents, one `<id>.toml` each (`name`, `command`, `args`, `env`).
pub custom_agents_dir: Option<PathBuf>,
/// The user's home: its `.claude` holds their own skills and commands.
@@ -127,10 +123,12 @@ struct Shared {
sink: RwLock<Option<AgentSink>>,
security: RwLock<Option<SecurityHook>>,
new_task_approvals: RwLock<Option<NewTaskApprovalsHook>>,
known: Arc<Mutex<HashMap<String, Known>>>,
known: Mutex<HashMap<String, Known>>,
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// One pre-session lookup of commands and settings per provider, including failed attempts.
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
mcp_servers_hook: RwLock<Option<Arc<McpServersFn>>>,
@@ -147,14 +145,6 @@ struct Known {
/// By project root: skills and custom commands differ per project.
#[serde(default, rename = "projectCommands")]
commands: HashMap<String, Vec<AgentCommand>>,
/// Successful discovery, unlike a session report, owns availability.
#[serde(default)]
authoritative: bool,
#[serde(default, rename = "refreshedAtMs")]
refreshed_at_ms: Option<f64>,
/// Authoritative availability and freshness are scoped to project and launch context.
#[serde(default, rename = "projectCatalogs")]
catalogs: HashMap<String, catalogs::SavedCatalog>,
}
impl Shared {
@@ -181,11 +171,7 @@ impl Shared {
}
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
let config = self.paths.catalogs.cached(self, provider).config;
self.config_for_new_task(config, provider)
}
fn config_for_new_task(&self, mut config: Vec<AgentConfigOption>, provider: &str) -> Vec<AgentConfigOption> {
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
// Model-specific options are offered separately, never as provider-wide defaults.
config.retain(|o| o.category.as_deref() != Some("thought_level"));
config::add_client_options(provider, &mut config);
@@ -211,6 +197,80 @@ impl Shared {
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
}
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
let mut ready = {
let mut fetches = self.command_fetches.lock();
fetches
.entry(spec.id.clone())
.or_insert_with(|| {
let (done, ready) = tokio::sync::watch::channel(false);
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
tracing::info!("agent {}: settings discovery started", spec.id);
let discovered = if spec.driver == provider::Driver::Acp {
acp::discover::run(&shared, &spec).await.map(|_| None)
} else if spec.driver == provider::Driver::Claude {
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
} else {
return;
};
if let Ok(Some(found)) = discovered {
let commands =
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": found.options }));
let levels: Vec<_> = found
.efforts
.into_iter()
.map(|(model, o)| {
let options = o.map(|o| config::options_of(&json!({ "configOptions": [o] })));
(model, options.unwrap_or_default())
})
.collect();
let _ = tokio::task::spawn_blocking(move || {
// Notifies subscribers itself when the discovery
// changed the cached config; unchanged results
// stay silent.
shared.remember_discovered(&spec.id, &options, &commands, &levels);
})
.await;
} else if let Err(e) = discovered {
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
}
let _ = done.send(true);
});
ready
})
.clone()
};
while !*ready.borrow_and_update() {
if ready.changed().await.is_err() {
break;
}
}
}
fn has_config(&self, provider: &str) -> bool {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
/// Whether the thinking levels of every model the agent offers are known (#1686).
fn knows_all_levels(&self, provider: &str) -> bool {
let cache = self.known.lock();
let Some(known) = cache.get(provider) else { return false };
let models: Vec<&str> = known
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
// No advertised models yet: nothing to attach effort levels to.
if models.is_empty() {
return false;
}
models.iter().all(|id| known.thought_levels.contains_key(*id))
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -223,48 +283,78 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
self.remember_inner(provider, config, commands, model, &[], false);
}
/// `levels`: each model's thinking-level options, so a new task offers them for any model
/// it picks (#1686). A session's report for a model wins over these.
fn remember_discovered(
&self,
provider: &str,
config: &[AgentConfigOption],
commands: &[AgentCommand],
levels: &[(String, Vec<AgentConfigOption>)],
) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), levels, true);
}
fn remember_inner(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered_levels: &[(String, Vec<AgentConfigOption>)],
discovered: bool,
) {
// Keep the update and its disk write ordered; a session report wins over discovery.
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
// Running tasks retain their own reported settings. Once discovery has
// succeeded, a session's older list/defaults cannot change New Task choices.
if !known.authoritative
&& let Some(config) = config.filter(|c| !c.is_empty())
{
for (model, levels) in discovered_levels {
if !known.thought_levels.contains_key(model) {
known.thought_levels.insert(model.clone(), levels.clone());
changed = true;
}
}
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
if let Some(model) = model {
let levels: Vec<_> =
config.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if known.config != config {
known.config = config.to_vec();
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
}
if let Some(commands) = commands.filter(|c| !c.is_empty())
&& known.commands.get(&self.root_key()).map(Vec::as_slice) != Some(commands)
{
known.commands.insert(self.root_key(), commands.to_vec());
let root = self.root_key();
if let Some(c) = commands.filter(|c| {
!c.is_empty()
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
}) {
known.commands.insert(root, c.to_vec());
changed = true;
}
if changed {
self.persist_known(&cache);
if !changed {
return;
}
drop(cache);
if changed {
self.emit(AgentEvent::Providers);
}
}
fn persist_known(&self, cache: &HashMap<String, Known>) {
if let Ok(bytes) = serde_json::to_vec_pretty(cache)
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
{
tracing::warn!("saving agent settings: {e}");
}
// The cached provider config actually changed (a session report, discovery,
// or a turn update): tell subscribers to re-list, so a changed model list
// reaches the composer without waiting for the next task (#1490). Unchanged
// reports stay silent, so routine session traffic emits nothing. The cache
// lock is released first; the sink never runs under it.
drop(cache);
self.emit(AgentEvent::Providers);
}
}
@@ -379,7 +469,8 @@ impl AgentManager {
}
fn with_connector(paths: AgentPaths, runtime: tokio::runtime::Handle, connector: Connector) -> Self {
let known = paths.catalogs.store(&paths.models_file);
let known =
std::fs::read(&paths.models_file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref(), paths.env_secrets.as_deref()));
let shared = Arc::new(Shared {
paths,
@@ -388,13 +479,13 @@ impl AgentManager {
sink: RwLock::new(None),
security: RwLock::new(None),
new_task_approvals: RwLock::new(None),
known,
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
command_fetches: Mutex::new(HashMap::new()),
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
mcp_servers_hook: RwLock::new(None),
});
shared.paths.catalogs.subscribe(&shared);
let mut tasks = HashMap::new();
for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() {
let path = file.path();
@@ -451,9 +542,9 @@ impl AgentManager {
.filter(|p| !provider::is_unlisted(p) || in_use.contains(&p.id))
.map(|p| {
let mut info = provider::describe(p);
let catalog = self.shared.paths.catalogs.cached(&self.shared, &p.id);
info.config = self.shared.config_for_new_task(catalog.config, &p.id);
info.thought_levels = catalog.thought_levels;
info.config = self.shared.known_config(&p.id);
info.thought_levels =
self.shared.known.lock().get(&p.id).map(|k| k.thought_levels.clone()).unwrap_or_default();
info.commands = self.shared.known_commands(&p.id);
info
})
@@ -541,20 +632,17 @@ impl AgentManager {
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
if task.is_none()
&& let Some(spec) =
provider::load(self.shared.paths.custom_agents_dir.as_deref(), self.shared.env_secrets())
.into_iter()
.find(|spec| spec.id == provider)
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
&& let Some(spec) = self
.shared
.spec(&provider)
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
{
let (_, flight) = self.shared.paths.catalogs.request(&self.shared, spec, false);
if let Some(mut ready) = flight {
while !*ready.borrow_and_update() {
if ready.changed().await.is_err() {
break;
}
}
let claude = spec.driver == provider::Driver::Claude;
self.shared.fetch_settings(spec).await;
if claude {
offered = self.shared.known_commands(&provider);
}
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
@@ -706,18 +794,16 @@ impl AgentManager {
}
};
// Until the session reports its own, show what the agent offered last time with the choices applied.
let catalog = self.shared.paths.catalogs.cached(&self.shared, &spec.id);
let mut levels = catalog.thought_levels;
let mut config = self.shared.config_for_new_task(catalog.config, &spec.id);
let mut config = self.shared.known_config(&spec.id);
for choice in &req.config {
if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) {
o.current = Some(choice.value.clone());
}
}
if let Some(model) = model_of(&config)
&& let Some(levels) = levels.remove(&model)
&& let Some(levels) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model))
{
config.extend(levels.into_iter().map(|mut o| {
config.extend(levels.iter().cloned().map(|mut o| {
if let Some(choice) =
req.config.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value))
{
@@ -1011,8 +1097,6 @@ impl AgentManager {
title_renamed: s.title_renamed,
}
};
// Copying may have started the source's agent: it stops again once idle.
source.release_when_idle();
let task = Task::restore(file, self.shared.clone());
self.tasks.write().insert(task.id.clone(), task.clone());
task.persist();
@@ -1082,8 +1166,6 @@ impl AgentManager {
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
task.emit_task();
// Undoing may have started the agent: it stops again once idle.
task.release_when_idle();
Ok(first)
}
-17
Переглянути файл
@@ -538,19 +538,6 @@ pub struct AgentChanges {
pub commits: Vec<AgentCommit>,
}
/// Authoritative choices for new tasks, independent of any running session's settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentModelCatalog {
pub provider: String,
pub config: Vec<AgentConfigOption>,
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
pub refreshing: bool,
pub refreshed_at_ms: Option<f64>,
pub retry_at_ms: Option<f64>,
pub error: Option<String>,
}
/// Streamed to the window that subscribed (`AgentManager::subscribe`).
#[derive(Clone, Debug, Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
@@ -588,10 +575,6 @@ pub enum AgentEvent {
provider: String,
limits: Vec<AgentLimit>,
},
/// A model lookup started or completed, including unsuccessful lookups.
ModelCatalog {
catalog: AgentModelCatalog,
},
/// The installed or configured agent providers changed.
Providers,
}
-59
Переглянути файл
@@ -158,42 +158,6 @@ pub(crate) fn title(entries: &[Value]) -> Option<String> {
.map(str::to_owned)
}
/// Claude Code titles a session from the last 1000 characters of its text.
const TITLE_SOURCE_CHARS: usize = 1000;
/// Below this Claude Code gives no title without asking the model.
const TITLE_SOURCE_MIN_CHARS: usize = 10;
/// The conversation's own text to title the session after: the prompts and the replies,
/// without the files attached to the prompts. `None` while there is too little.
pub(super) fn title_source(entries: &[Value]) -> Option<String> {
let mut text = String::new();
for e in chain(entries, None) {
if !(is_turn_start(e) || kind(e) == "assistant") {
continue;
}
let blocks = match e.pointer("/message/content") {
Some(Value::String(s)) => vec![s.as_str()],
Some(Value::Array(blocks)) => blocks
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(Value::as_str))
.filter(|t| !t.starts_with("\n<context ref="))
.collect(),
_ => Vec::new(),
};
for block in blocks {
text.push_str(block);
text.push('\n');
}
}
let text = text.trim();
let len = text.chars().count();
if len < TITLE_SOURCE_MIN_CHARS {
return None;
}
Some(text.chars().skip(len.saturating_sub(TITLE_SOURCE_CHARS)).collect())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -255,29 +219,6 @@ mod tests {
assert_eq!(cut(&c, 1), Ok(None));
}
#[test]
fn titles_come_from_prompts_and_replies() {
let mut prompt = user("u1", None, "Fix the login form");
prompt["message"]["content"]
.as_array_mut()
.unwrap()
.push(json!({"type": "text", "text": "\n<context ref=\"file:///p/a.rs\">\nfn a() {}\n</context>"}));
let entries = vec![
prompt,
assistant(
"a1",
"u1",
json!([{"type": "thinking", "thinking": "hm"}, {"type": "text", "text": "Done."},
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]),
),
json!({"type": "user", "uuid": "r1", "parentUuid": "a1", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "out"}]}}),
];
assert_eq!(title_source(&entries).as_deref(), Some("Fix the login form\nDone."));
assert_eq!(title_source(&[user("u1", None, "hi")]), None);
let long = "x".repeat(TITLE_SOURCE_CHARS + 5);
assert_eq!(title_source(&[user("u1", None, &long)]).map(|t| t.chars().count()), Some(TITLE_SOURCE_CHARS));
}
#[test]
fn project_directory_names() {
assert_eq!(escape(Path::new("/private/tmp/a.b/c_d")), "-private-tmp-a-b-c-d");
+10 -59
Переглянути файл
@@ -85,8 +85,6 @@ pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discove
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
let mut args = spec.args;
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
// Metadata discovery must not launch project-configured MCP servers.
args.extend(["--strict-mcp-config".into(), "--mcp-config".into(), "{\"mcpServers\":{}}".into()]);
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
@@ -193,8 +191,6 @@ struct State {
limit_resets_ms: Option<f64>,
terminal_only: Vec<String>,
titled: bool,
/// A title is being read or generated.
titling: bool,
}
struct Turn {
@@ -369,7 +365,6 @@ impl Shim {
limit_resets_ms: None,
terminal_only: Vec::new(),
titled: false,
titling: false,
}),
cwd,
starting: tokio::sync::Mutex::new(()),
@@ -588,12 +583,11 @@ impl Shim {
impl super::Shim for Shim {
fn attach(&self, client: Peer) {
let _ = self.client.set(client.clone());
// The client went away: so do the processes. Held until then: the connection drops
// its handler (this shim) as it closes, and the sessions would go without stopping.
let me = self.me.upgrade();
// The client went away: so do the processes.
let weak = self.me.clone();
tokio::spawn(async move {
client.closed().await;
if let Some(me) = me {
if let Some(me) = weak.upgrade() {
me.closed.store(true, Ordering::Release);
let sessions: Vec<Arc<Session>> = me.sessions.lock().drain().map(|(_, s)| s).collect();
for s in sessions {
@@ -604,15 +598,6 @@ impl super::Shim for Shim {
}
}
impl Drop for Session {
/// A session dropped without being stopped (the shim went away) takes its process along.
fn drop(&mut self) {
if let Some(p) = self.st.get_mut().proc.take() {
p.kill.cancel();
}
}
}
impl Session {
fn shim(&self) -> Option<Arc<Shim>> {
self.shim.upgrade()
@@ -1063,8 +1048,7 @@ impl Session {
} else if matches!(st.start, Start::New { .. }) {
st.start = Start::Resume { id: self.id.clone(), at: None };
}
read_title = !st.titled && !st.titling;
st.titling |= read_title;
read_title = !st.titled;
if let Some(turn) = st.turn.as_ref()
&& belongs(turn, &msg)
{
@@ -1089,29 +1073,13 @@ impl Session {
}
}
/// Sends the session's title once it has one: the one in its transcript, or else one
/// Claude Code is asked for, as it titles no session run the way the Agent SDK runs it.
/// Without a title the next turn tries again.
/// Sends the title Claude Code gave the session, once it has one.
fn read_title(self: &Arc<Self>) {
let me = self.clone();
tokio::spawn(async move {
let reader = me.clone();
let entries = tokio::task::spawn_blocking(move || transcript_entries(&reader)).await.ok().flatten();
let mut title = entries.as_deref().and_then(history::title);
if title.is_none()
&& let Some(source) = entries.as_deref().and_then(history::title_source)
{
title = me.generate_title(source).await;
}
{
let mut st = me.st.lock();
st.titling = false;
if title.is_none() {
return;
}
st.titled = true;
}
if let (Some(title), Some(shim)) = (title, me.shim()) {
tokio::task::spawn_blocking(move || {
let Some(title) = chain_title(&me) else { return };
me.st.lock().titled = true;
if let Some(shim) = me.shim() {
shim.notify(vec![Update {
session: me.id.clone(),
update: json!({ "sessionUpdate": "session_info_update", "title": title }),
@@ -1120,19 +1088,6 @@ impl Session {
});
}
/// A title Claude Code generates from `source`, also written to the transcript.
async fn generate_title(&self, source: String) -> Option<String> {
let proc = self.st.lock().proc.clone()?;
let request = json!({ "subtype": "generate_session_title", "description": source, "persist": true });
match proc.control(request).await {
Ok(r) => r.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned),
Err(e) => {
tracing::debug!("generating a claude session title: {e}");
None
}
}
}
async fn on_cli_request(self: &Arc<Self>, proc: &Arc<Proc>, msg: Value) {
let id = msg.get("request_id").and_then(Value::as_str).unwrap_or_default().to_owned();
let request = msg.get("request").cloned().unwrap_or_default();
@@ -1462,13 +1417,9 @@ fn strings(v: Option<&Value>) -> Vec<String> {
/// The title in a session's transcript (or that of the session it continues).
fn chain_title(session: &Session) -> Option<String> {
history::title(&transcript_entries(session)?)
}
fn transcript_entries(session: &Session) -> Option<Vec<Value>> {
let shim = session.shim()?;
let path = shim.transcript(&session.cwd, &session.id).filter(|p| p.exists())?;
history::read(&path).ok()
history::title(&history::read(&path).ok()?)
}
fn start_json(start: &Start) -> Value {
+4 -76
Переглянути файл
@@ -311,8 +311,6 @@ struct Replay {
sent: Arc<Mutex<Vec<Vec<Value>>>>,
launched: Mutex<Vec<Vec<String>>>,
errors: Arc<Mutex<Vec<String>>>,
/// Each process's kill token.
kills: Mutex<Vec<CancellationToken>>,
}
impl Cli for Replay {
@@ -333,7 +331,6 @@ impl Cli for Replay {
let (shim_in, cli_in) = tokio::io::duplex(1 << 20);
let (mut cli_out, shim_out) = tokio::io::duplex(1 << 20);
let kill = CancellationToken::new();
self.kills.lock().push(kill.clone());
let (sent, errors, token) = (self.sent.clone(), self.errors.clone(), kill.clone());
sent.lock().push(Vec::new());
tokio::spawn(async move {
@@ -424,18 +421,14 @@ impl Replayed {
}
}
fn replay_cli(processes: Vec<Vec<Frame>>) -> Arc<Replay> {
Arc::new(Replay {
async fn replay(name: &str) -> Replayed {
let processes = load_fixture(name);
let cli = Arc::new(Replay {
processes: Mutex::new(processes.into()),
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
})
}
async fn replay(name: &str) -> Replayed {
let cli = replay_cli(load_fixture(name));
});
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
let h =
@@ -468,69 +461,6 @@ async fn replay_simple() {
assert_eq!(user["message"]["content"][0]["text"], "Reply with exactly: hello there");
}
/// The app closing the connection (an idle task released, archived or deleted) stops the
/// CLI processes of its sessions, idle ones included.
#[tokio::test(flavor = "multi_thread")]
async fn closing_the_connection_stops_the_processes() {
let r = replay("simple").await;
assert!(r.cli.kills.lock().iter().all(|k| !k.is_cancelled()));
r.h.client.kill();
let kills = r.cli.kills.lock().clone();
tokio::time::timeout(Duration::from_secs(5), async {
for k in kills {
k.cancelled().await;
}
})
.await
.expect("every process was stopped");
}
#[tokio::test(flavor = "multi_thread")]
async fn sessions_without_a_title_get_one_generated() {
let mut processes = load_fixture("simple");
processes[0].extend([
Frame::Shim(json!({"type": "control_request", "request_id": "req_3",
"request": {"subtype": "generate_session_title"}})),
Frame::Cli(json!({"type": "control_response",
"response": {"subtype": "success", "request_id": "req_3", "response": {"title": "Say hello"}}})),
]);
let cli = replay_cli(processes);
let (config, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let cwd_path = std::fs::canonicalize(cwd.path()).unwrap();
let h = Harness::connect(
cli.clone(),
config.path().into(),
config.path().join("pending"),
cwd_path.clone(),
Vec::new(),
)
.await;
let session = h.new_session().await;
// The transcript Claude Code writes, without the title it gives sessions in a terminal.
let dir = config.path().join("projects").join(history::escape(&cwd_path));
std::fs::create_dir_all(&dir).unwrap();
let prompt = json!({"type": "user", "uuid": "u1", "parentUuid": null,
"message": {"role": "user", "content": "Reply with exactly: hello there"}});
std::fs::write(dir.join(format!("{session}.jsonl")), format!("{prompt}\n")).unwrap();
h.prompt(&session, "Reply with exactly: hello there").await.unwrap();
let mut titles = Vec::new();
for _ in 0..100 {
titles = h.updates(&session, "session_info_update");
if !titles.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(cli.errors.lock().is_empty(), "{:?}", cli.errors.lock());
assert_eq!(titles.iter().map(|t| t["title"].clone()).collect::<Vec<_>>(), [json!("Say hello")]);
let sent = cli.sent.lock()[0].clone();
let request =
sent.iter().find(|m| m.pointer("/request/subtype") == Some(&json!("generate_session_title"))).unwrap();
assert_eq!(request["request"]["description"], "Reply with exactly: hello there");
assert_eq!(request["request"]["persist"], true);
}
#[tokio::test(flavor = "multi_thread")]
async fn replay_multi_turn() {
let r = replay("multi_turn").await;
@@ -797,7 +727,6 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h = Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await;
@@ -841,7 +770,6 @@ async fn a_failed_turn_reports_its_failure() {
sent: Arc::default(),
launched: Mutex::default(),
errors: Arc::default(),
kills: Mutex::default(),
});
let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let h =
+41 -141
Переглянути файл
@@ -28,9 +28,6 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
const EARLY_CANCEL: Duration = Duration::from_secs(1);
/// How long the client's connection outlives codex.
const EXIT_GRACE: Duration = Duration::from_millis(100);
/// What a thread's name is asked for with, after its first turn.
const NAME_PROMPT: &str = "Name this conversation for a list of tasks: a short title of at most six words, in the \
language the user writes in. Reply with the title only, without quotes or a closing period.";
pub(crate) fn connect(
spec: &ProviderSpec,
@@ -44,92 +41,6 @@ pub(crate) fn connect(
})
}
/// A model lookup uses app-server directly: no thread, task, prompt or project MCP.
pub(crate) async fn discover(
spec: ProviderSpec,
cwd: std::path::PathBuf,
) -> Result<crate::catalogs::Discovered, String> {
let spec = tokio::task::spawn_blocking({
let cwd = cwd.clone();
move || spec.resolve_for_launch(&cwd)
})
.await
.map_err(|e| e.to_string())?;
let peer = Peer::spawn_framed(
&spec.command,
&spec.args,
&cwd,
&spec.env,
Arc::new(ide_jsonrpc::NoopHandler),
Box::new(|_| {}),
Framing::Lines,
)
.map_err(|e| e.to_string())?;
let _stop = crate::catalogs::KillPeer(peer.clone());
tokio::time::timeout(Duration::from_secs(30), async {
let _: Value = peer.request("initialize", initialize_params(), None).await.map_err(|e| e.to_string())?;
peer.notify("initialized", Value::Null).map_err(|e| e.to_string())?;
let mut models = list_models(&peer).await.map_err(|e| e.message)?;
models.retain(|model| !model.hidden);
let mut settings = Settings::of(&json!({}), &models);
if settings.model.is_empty()
&& let Some(model) = models.first()
{
settings.model = model.id.clone();
settings.fit_effort(&models);
}
let options = translate::config_options(&models, &settings);
let config = crate::config::options_of(&json!({ "configOptions": options }));
let levels = models
.iter()
.map(|model| {
let mut settings = settings.clone();
settings.model = model.id.clone();
settings.fit_effort(&models);
let options = translate::config_options(&models, &settings);
let levels = crate::config::options_of(&json!({ "configOptions": options }))
.into_iter()
.filter(|o| o.category.as_deref() == Some("thought_level"))
.collect();
(model.id.clone(), levels)
})
.collect();
Ok(crate::catalogs::Discovered { config, commands: None, levels })
})
.await
.map_err(|_| "Codex model discovery timed out".to_owned())?
}
fn initialize_params() -> Value {
json!({
"clientInfo": { "name": "openidea", "title": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
"capabilities": { "experimentalApi": true, "optOutNotificationMethods": OPT_OUT },
})
}
async fn list_models(peer: &Peer) -> Result<Vec<ModelInfo>, ResponseError> {
let mut all = Vec::new();
let mut cursor = Value::Null;
let mut seen = HashSet::new();
loop {
let page: Value = peer.request("model/list", json!({ "cursor": cursor }), None).await.map_err(from_codex)?;
let Some(data) = page.get("data").and_then(Value::as_array) else {
return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model list"));
};
let models = translate::models_of(&page);
if models.len() != data.len() {
return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model list"));
}
all.extend(models);
match page.get("nextCursor") {
None | Some(Value::Null) => return Ok(all),
Some(Value::String(next)) if next.is_empty() => return Ok(all),
Some(Value::String(next)) if seen.insert(next.clone()) => cursor = json!(next),
_ => return Err(error(codes::INTERNAL_ERROR, "Codex returned an invalid model-list cursor")),
}
}
}
/// Connects `client` to a shim driving the codex connection `connect_codex` opens (it
/// gets the handler for what codex sends). Each side's end closes the other.
fn attach(
@@ -215,10 +126,6 @@ struct Thread {
early_cancel: Option<Instant>,
/// The latest agent message (a subagent's answer).
answer: String,
/// The thread has a name.
named: bool,
/// A name is being asked for.
naming: bool,
}
struct PendingPrompt {
@@ -336,7 +243,15 @@ impl CodexShim {
}
async fn initialize(&self) -> Result<Value, ResponseError> {
let info = self.call("initialize", initialize_params()).await?;
let info = self
.call(
"initialize",
json!({
"clientInfo": { "name": "openidea", "title": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") },
"capabilities": { "experimentalApi": true, "optOutNotificationMethods": OPT_OUT },
}),
)
.await?;
self.codex().notify("initialized", Value::Null).map_err(from_codex)?;
self.state.lock().initialized = true;
// `<client>/<codex version> (<platform>) …`
@@ -360,15 +275,32 @@ impl CodexShim {
}))
}
/// Cache only complete, successful listings; a failed page must never become availability.
async fn models(&self) -> Result<Arc<Vec<ModelInfo>>, ResponseError> {
/// The models codex offers; empty when it can't list them.
async fn models(&self) -> Arc<Vec<ModelInfo>> {
let mut cached = self.models.lock().await;
if let Some(models) = cached.as_ref() {
return Ok(models.clone());
if let Some(m) = cached.as_ref() {
return m.clone();
}
let models = Arc::new(list_models(self.codex()).await?);
let mut all = Vec::new();
let mut cursor = Value::Null;
loop {
match self.call("model/list", json!({ "cursor": cursor })).await {
Ok(page) => {
all.extend(translate::models_of(&page));
match page.get("nextCursor").filter(|c| c.is_string()) {
Some(next) if *next != cursor => cursor = next.clone(),
_ => break,
}
}
Err(e) => {
tracing::warn!("codex: listing models failed: {}", e.message);
return Arc::new(all);
}
}
}
let models = Arc::new(all);
*cached = Some(models.clone());
Ok(models)
models
}
async fn publish_skills(&self, session: &str) {
@@ -397,12 +329,11 @@ impl CodexShim {
t.parent = None;
t.settings = Some(settings);
t.cwd = cwd;
t.named = thread.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty());
(id, options)
}
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
let models = self.models().await?;
let models = self.models().await;
let mut request = json!({ "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
@@ -419,7 +350,7 @@ impl CodexShim {
/// Resumes the thread and replays its history before answering.
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
let id = str_param(params, "sessionId")?;
let models = self.models().await?;
let models = self.models().await;
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
@@ -437,7 +368,7 @@ impl CodexShim {
async fn fork_session(&self, params: &Value) -> Result<Value, ResponseError> {
let source = str_param(params, "sessionId")?;
let models = self.models().await?;
let models = self.models().await;
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
@@ -458,7 +389,7 @@ impl CodexShim {
let session = str_param(params, "sessionId")?;
let option = str_param(params, "configId")?;
let value = str_param(params, "value")?;
let models = self.models().await?;
let models = self.models().await;
let mut state = self.state.lock();
let settings = state
.threads
@@ -475,7 +406,7 @@ impl CodexShim {
if input.is_empty() {
return Err(invalid("empty prompt"));
}
let models = self.models().await?;
let models = self.models().await;
let (done, ended) = oneshot::channel();
let settings = {
let mut state = self.state.lock();
@@ -694,9 +625,8 @@ impl CodexShim {
Some(translate::plan(params))
}),
"thread/tokenUsage/updated" => self.with_thread(thread, |_, _| translate::usage(params)),
"thread/name/updated" => self.with_thread(thread, |t, _| {
"thread/name/updated" => self.with_thread(thread, |_, _| {
let title = params.get("threadName").and_then(Value::as_str)?;
t.named = true;
Some(json!({ "sessionUpdate": "session_info_update", "title": title }))
}),
"thread/started" => self.thread_started(params.get("thread").unwrap_or(&Value::Null)),
@@ -882,10 +812,9 @@ impl CodexShim {
}
}
fn turn_completed(self: &Arc<Self>, thread: &str, turn: &Value) {
fn turn_completed(&self, thread: &str, turn: &Value) {
let id = turn.get("id").and_then(Value::as_str).unwrap_or_default();
let completed = turn.get("status").and_then(Value::as_str) == Some("completed");
let (parent, answer, name) = {
let (parent, answer) = {
let mut state = self.state.lock();
let Some(t) = state.threads.get_mut(thread) else { return };
if t.turn.as_deref().is_none_or(|r| r == id) {
@@ -901,15 +830,8 @@ impl CodexShim {
let _ = p.done.send((turn.clone(), error.clone()));
}
let answer = std::mem::take(&mut t.answer);
let name =
completed && !id.is_empty() && t.parent.is_none() && t.settings.is_some() && !t.named && !t.naming;
t.naming |= name;
(t.parent.clone(), (answer, error), name)
(t.parent.clone(), (answer, error))
};
if name {
let (shim, thread, turn) = (self.clone(), thread.to_owned(), id.to_owned());
self.runtime.spawn(async move { shim.name_thread(&thread, &turn).await });
}
let Some(parent) = parent else { return };
let (answer, error) = answer;
let mut state_of = match turn.get("status").and_then(Value::as_str) {
@@ -931,28 +853,6 @@ impl CodexShim {
}
}
/// Names a thread from what it holds after `turn`, as codex leaves naming to its
/// clients; `thread/name/updated` brings the name back. Unnamed, the next turn tries again.
async fn name_thread(&self, thread: &str, turn: &str) {
let sample = json!({ "threadId": thread, "expectedTurnId": turn, "prompt": NAME_PROMPT, "effort": "low" });
let named = match self.call("thread/sample", sample).await {
Ok(r) => match r.get("text").and_then(Value::as_str).and_then(translate::thread_name) {
Some(name) => {
self.call("thread/name/set", json!({ "threadId": thread, "name": name })).await.map(|_| ())
}
None => Err(error(codes::INTERNAL_ERROR, "codex gave no name")),
},
Err(e) => Err(e),
};
if let Err(e) = &named {
tracing::debug!("naming codex thread {thread}: {}", e.message);
}
if let Some(t) = self.state.lock().threads.get_mut(thread) {
t.naming = false;
t.named |= named.is_ok();
}
}
/// Requests from codex: approvals and questions, asked as ACP permission requests.
fn on_codex_request(&self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default().to_owned();
+5 -199
Переглянути файл
@@ -11,155 +11,6 @@ use std::collections::VecDeque;
use std::path::PathBuf;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
struct ModelPages {
pages: Mutex<VecDeque<Result<Value, (i64, String)>>>,
cursors: Mutex<Vec<Value>>,
}
impl Handler for ModelPages {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
assert_eq!(method, "model/list", "listing models must never start a thread or turn");
self.cursors.lock().push(params["cursor"].clone());
self.pages.lock().pop_front().expect("another expected page")
}
}
fn model_peer(pages: Arc<ModelPages>) -> (Peer, Peer) {
let (ours, theirs) = tokio::io::duplex(1 << 16);
let (read, write) = tokio::io::split(theirs);
let server = Peer::connect_framed(read, write, pages, Framing::Lines);
let (read, write) = tokio::io::split(ours);
(Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines), server)
}
#[tokio::test]
async fn model_pagination_failure_does_not_cache_an_incomplete_catalog() {
let first = json!({ "data": [{ "id": "first" }], "nextCursor": "next" });
let pages = Arc::new(ModelPages {
pages: Mutex::new(VecDeque::from([
Ok(first.clone()),
Err((-32000, "authentication expired".into())),
Ok(first),
Ok(json!({ "data": [{ "id": "second" }], "nextCursor": null })),
])),
cursors: Mutex::default(),
});
let (peer, server) = model_peer(pages.clone());
let shim = CodexShim {
runtime: tokio::runtime::Handle::current(),
codex: OnceLock::from(peer.clone()),
client: OnceLock::new(),
state: Mutex::default(),
models: tokio::sync::Mutex::new(None),
skills: tokio::sync::Mutex::new(()),
};
assert_eq!(shim.models().await.unwrap_err().message, "authentication expired");
let models = shim.models().await.unwrap();
assert_eq!(models.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["first", "second"]);
assert_eq!(shim.models().await.unwrap().len(), 2);
assert_eq!(*pages.cursors.lock(), [Value::Null, json!("next"), Value::Null, json!("next")]);
peer.kill();
server.kill();
}
#[tokio::test]
async fn repeated_or_invalid_model_cursors_and_malformed_pages_are_failures() {
for pages in [
vec![json!({ "data": [], "nextCursor": "a" }), json!({ "data": [], "nextCursor": "a" })],
vec![
json!({ "data": [], "nextCursor": "a" }),
json!({ "data": [], "nextCursor": "b" }),
json!({ "data": [], "nextCursor": "a" }),
],
vec![json!({ "data": [], "nextCursor": 7 })],
vec![json!({ "nextCursor": null })],
vec![json!({ "data": [{}], "nextCursor": null })],
] {
let pages =
Arc::new(ModelPages { pages: Mutex::new(pages.into_iter().map(Ok).collect()), cursors: Mutex::default() });
let (peer, server) = model_peer(pages);
assert!(list_models(&peer).await.is_err());
peer.kill();
server.kill();
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn native_discovery_only_initializes_and_pages_models_then_stops_the_process() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let script = tmp.path().join("fake-codex");
std::fs::write(
&script,
r#"#!/usr/bin/env python3
import json, os, pathlib, sys
pathlib.Path('pid').write_text(str(os.getpid()))
for line in sys.stdin:
request = json.loads(line)
with pathlib.Path('requests').open('a') as f:
f.write(json.dumps(request) + '\n')
method = request['method']
if method == 'initialized':
continue
if method == 'initialize':
result = {}
elif method == 'model/list':
cursor = request['params']['cursor']
model = 'one' if cursor is None else 'two'
result = {'data': [{'id': model, 'isDefault': cursor is None,
'defaultReasoningEffort': 'low', 'supportedReasoningEfforts': [{'reasoningEffort': 'low'}]}],
'nextCursor': 'second-page' if cursor is None else None}
else:
raise RuntimeError('must not create a thread or prompt: ' + method)
print(json.dumps({'jsonrpc': '2.0', 'id': request['id'], 'result': result}), flush=True)
"#,
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let spec = ProviderSpec {
id: "codex".into(),
name: "Codex".into(),
driver: crate::provider::Driver::Codex,
command: script.to_string_lossy().into_owned(),
args: Vec::new(),
env: Vec::new(),
login_hint: String::new(),
source: crate::AgentSource::Builtin,
};
let found = discover(spec, tmp.path().to_path_buf()).await.unwrap();
let models: Vec<_> = found
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
assert_eq!(models, ["one", "two"]);
assert_eq!(found.levels.keys().map(String::as_str).collect::<Vec<_>>(), ["one", "two"]);
let requests = std::fs::read_to_string(tmp.path().join("requests")).unwrap();
let requests: Vec<Value> = requests.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(
requests.iter().map(|r| r["method"].as_str().unwrap()).collect::<Vec<_>>(),
["initialize", "initialized", "model/list", "model/list"]
);
assert_eq!(requests[2]["params"]["cursor"], Value::Null);
assert_eq!(requests[3]["params"]["cursor"], "second-page");
let pid = std::fs::read_to_string(tmp.path().join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success();
if !alive {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("discovery must kill and reap its child");
}
// ---- Pure translations -------------------------------------------------------------
fn models() -> Vec<ModelInfo> {
@@ -684,10 +535,8 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
.unwrap()
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
let actual: Value = serde_json::from_str(&line).unwrap();
// Older recordings predate skill discovery and thread naming; play an
// unsupported server.
let method = actual["method"].as_str().unwrap_or_default();
if matches!(method, "skills/list" | "thread/sample") && frame["method"] != method {
// Older recordings predate skill discovery; play an unsupported server.
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
continue;
@@ -725,14 +574,7 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
/// Runs `scenario` against its transcript; returns the ACP results, the client and
/// the frames the shim sent.
async fn replay(scenario: Scenario) -> (Results, Arc<Client>, Vec<Value>, Value) {
let (results, client, sent, meta, _codex) = replay_transcript(scenario, load(scenario.file())).await;
(results, client, sent, meta)
}
/// Both connections of a replay, which stay open while it is held.
type CodexEnd = Box<dyn std::any::Any>;
async fn replay_transcript(scenario: Scenario, t: Transcript) -> (Results, Arc<Client>, Vec<Value>, Value, CodexEnd) {
let t = load(scenario.file());
let (ours, theirs) = tokio::io::duplex(1 << 20);
let (tr, tw) = tokio::io::split(theirs);
let client = Arc::new(Client::default());
@@ -748,8 +590,8 @@ async fn replay_transcript(scenario: Scenario, t: Transcript) -> (Results, Arc<C
.await
.expect("the scenario finished");
// Codex stays up until the scenario is over.
let (sent, read, write) = player.await.unwrap();
(results, client, sent, meta, Box::new((acp, read, write)))
let (sent, _read, _write) = player.await.unwrap();
(results, client, sent, meta)
}
fn sent<'a>(frames: &'a [Value], method: &str) -> Vec<&'a Value> {
@@ -959,42 +801,6 @@ async fn a_simple_turn_streams_reasoning_text_and_usage() {
assert_eq!(info["title"], "Say hi");
}
#[tokio::test(flavor = "multi_thread")]
async fn an_unnamed_thread_is_named_after_its_first_turn() {
let mut t = load(Scenario::Simple.file());
t.frames.retain(|(_, f)| f["method"] != "thread/name/updated");
let thread = "01a18300-0000-7000-8000-000000000001";
t.frames.extend([
(true, json!({"id": 90, "method": "thread/sample"})),
(false, json!({"id": 90, "result": {"responseId": "r", "sourceTurnId": thread, "text": "\"Say hi.\"\n"}})),
(true, json!({"id": 91, "method": "thread/name/set"})),
(false, json!({"id": 91, "result": {}})),
(false, json!({"method": "thread/name/updated", "params": {"threadId": thread, "threadName": "Say hi"}})),
]);
let (results, client, frames, _, _codex) = replay_transcript(Scenario::Simple, t).await;
let session = session_of(&results, "new");
let sample = &sent(&frames, "thread/sample")[0]["params"];
assert_eq!((sample["threadId"].as_str(), sample["expectedTurnId"].as_str()), (Some(thread), Some(thread)));
assert_eq!(sent(&frames, "thread/name/set")[0]["params"], json!({"threadId": thread, "name": "Say hi"}));
let mut info = Vec::new();
for _ in 0..100 {
info = client.of(&session).into_iter().filter(|u| u["sessionUpdate"] == "session_info_update").collect();
if !info.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(info.iter().map(|u| u["title"].clone()).collect::<Vec<_>>(), [json!("Say hi")]);
}
#[test]
fn thread_names_are_the_first_line_of_the_reply() {
assert_eq!(thread_name(" \n“修复登录表单。”\nmore").as_deref(), Some("修复登录表单"));
assert_eq!(thread_name("**Fix the login form.**").as_deref(), Some("Fix the login form"));
assert_eq!(thread_name(" \n\"\" "), None);
assert_eq!(thread_name(&"x".repeat(100)).map(|n| n.chars().count()), Some(80));
}
#[tokio::test(flavor = "multi_thread")]
async fn settings_apply_to_the_next_turn() {
let (results, client, frames, _) = replay(Scenario::MultiTurn).await;
-10
Переглянути файл
@@ -612,16 +612,6 @@ pub(super) fn usage(params: &Value) -> Option<Value> {
Some(json!({ "sessionUpdate": "usage_update", "used": used, "size": size }))
}
/// A thread name from the model's reply to the naming prompt: its first line, without
/// quotes or a closing period. `None` when nothing is left.
pub(super) fn thread_name(reply: &str) -> Option<String> {
const MAX_NAME: usize = 80;
let line = reply.lines().map(str::trim).find(|l| !l.is_empty())?;
let line = line.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | '“' | '”' | '「' | '」' | '*'));
let line = line.trim_end_matches(['.', '。']).trim();
(!line.is_empty()).then(|| line.chars().take(MAX_NAME).collect())
}
/// How a failed turn is reported.
#[derive(Debug, PartialEq)]
pub(super) enum Failure {
-1
Переглянути файл
@@ -25,7 +25,6 @@ fn paths(dir: &Path) -> AgentPaths {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
}
}
+62 -39
Переглянути файл
@@ -405,7 +405,6 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
// user's profile, when temp lives under it) leaks into the test.
home_dir: Some(dir.to_path_buf()),
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
@@ -443,7 +442,7 @@ async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn claude_commands_share_catalog_discovery_and_persist_results() {
async fn claude_commands_before_session_are_fetched_once_and_saved() {
use std::os::unix::fs::PermissionsExt;
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
@@ -503,18 +502,14 @@ for line in sys.stdin:
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
.unwrap();
let mgr = manager(root, Arc::new(FakeAgent::default()));
if outcome != "missing" {
provider::set_builtin_command(&root.join("agents"), None, "claude", Some(&script.to_string_lossy()))
.unwrap();
{
let mut providers = mgr.shared.providers.write();
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
if outcome != "missing" {
spec.command = script.to_string_lossy().into_owned();
}
spec.env = vec![("OUTCOME".into(), outcome.into())];
}
provider::set_builtin_env(
&root.join("agents"),
None,
"claude",
BTreeMap::from([("OUTCOME".into(), outcome.into())]),
)
.unwrap();
mgr.reload_providers();
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(10), async {
@@ -558,6 +553,7 @@ for line in sys.stdin:
effort.unwrap().current.as_deref(),
Some(match outcome {
"settings_timeout" => "default",
"session_report" => "low",
_ => "high",
})
);
@@ -575,8 +571,6 @@ for line in sys.stdin:
assert_eq!(launches.lines().count(), 1, "{outcome}");
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
assert!(args.iter().any(|a| a == "--strict-mcp-config"));
assert!(args.windows(2).any(|a| a == ["--mcp-config", "{\"mcpServers\":{}}"]));
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
let subtypes: Vec<String> = requests
.lines()
@@ -1437,7 +1431,6 @@ async fn real_claude_task_in_a_worktree() {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
);
@@ -2272,7 +2265,6 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
custom_agents_dir: Some(agents_dir.clone()),
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
@@ -2399,27 +2391,6 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
assert!(live(&mgr, &task.id));
mgr.shutdown();
// Undoing turns or copying the conversation starts a stopped agent: it stops again too.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
mgr.set_idle_release(Duration::from_millis(200));
let task = mgr.create(new()).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.prompt(&task.id, text("again"), false).await.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id));
mgr.rollback(&task.id, 1).await.unwrap();
assert!(live(&mgr, &task.id));
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id), "an agent started to undo turns stops too");
mgr.fork(&task.id, None).await.unwrap();
assert!(live(&mgr, &task.id));
tokio::time::sleep(Duration::from_millis(600)).await;
assert!(!live(&mgr, &task.id), "an agent started to copy the conversation stops too");
mgr.shutdown();
// An agent that can't reload its session keeps running: stopping it would lose the conversation.
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent { no_load: true, ..FakeAgent::default() });
@@ -2605,7 +2576,6 @@ async fn delete_running_agent(no_load: bool) {
custom_agents_dir: None,
home_dir: None,
env_secrets: None,
catalogs: Arc::default(),
},
tokio::runtime::Handle::current(),
connector,
@@ -2668,6 +2638,34 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
);
}
#[tokio::test(flavor = "multi_thread")]
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
for discovery_first in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let options = |model: &str, effort: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
"options": [{ "value": "saved" }, { "value": "discovered" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
"options": [{ "value": "low" }, { "value": "high" }] }
] }))
};
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[], &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn provider_reload_does_not_start_acp_discovery() {
let agent = Arc::new(FakeAgent::default());
@@ -2754,6 +2752,31 @@ async fn discovery_times_out_when_session_new_hangs() {
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
}
#[tokio::test(flavor = "multi_thread")]
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let effort = |values: &[&str], current: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": current,
"options": values.iter().map(|v| json!({ "value": v })).collect::<Vec<_>>() }
] }))
};
let model = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "sonnet",
"options": [{ "value": "sonnet" }, { "value": "opus" }] }
] }));
// A session already reported sonnet's levels; discovery adds opus's.
let reported = [model.clone(), effort(&["low", "high"], "high")].concat();
mgr.shared.remember("claude", Some(&reported), None, Some("sonnet"));
let levels =
[("sonnet".to_owned(), effort(&["low"], "low")), ("opus".to_owned(), effort(&["low", "max"], "default"))];
mgr.shared.remember_discovered("claude", &model, &[], &levels);
let known = mgr.shared.known.lock();
assert_eq!(known["claude"].thought_levels["sonnet"], effort(&["low", "high"], "high"), "the session's report wins");
assert_eq!(known["claude"].thought_levels["opus"], effort(&["low", "max"], "default"));
}
#[test]
fn streamed_text_is_sent_as_appends() {
let mut streamed = HashMap::new();
+24 -79
Переглянути файл
@@ -225,15 +225,6 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
}
/// The bytes of `path` (`Some(None)`: no file there), or `None` when it is there but can't be read.
fn existing(path: &Path) -> Option<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(bytes) => Some(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(None),
Err(_) => None,
}
}
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
for (i, &b) in bytes.iter().enumerate() {
@@ -420,31 +411,25 @@ pub(crate) fn accept(
let copyable = |rel: &str| task_side.allows(rel) && checkout_side.allows(rel);
let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
// The task's version of one path of the checkout (`Some(None)`: absent there), or `None` when
// the checkout changed it itself.
let target = |rel: &str| -> IdeResult<Option<Option<Vec<u8>>>> {
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let local = local_root.join(rel);
let in_task = repo.root().join(rel);
let current = std::fs::read(&in_task).ok();
// A file there that can't be read is the checkout's own: never written over.
let Some(local_bytes) = existing(&local_root.join(rel)) else { return Ok(None) };
if local_bytes == current {
return Ok(Some(current));
}
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
let current = std::fs::read(&in_task).ok();
let local_bytes = std::fs::read(&local).ok();
if local_bytes == current {
return Ok(true);
}
// The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
let unchanged = match (&local_bytes, &base_bytes) {
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
(now, at_base) => now == at_base,
};
Ok(unchanged.then_some(current))
};
// Puts `bytes` (`None`: nothing) at `rel` of the checkout.
let write = |rel: &str, bytes: Option<Vec<u8>>| -> IdeResult<()> {
let local = local_root.join(rel);
if std::fs::read(&local).ok() == bytes {
return Ok(());
if !unchanged {
return Ok(false);
}
match bytes {
match current {
Some(bytes) => {
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?;
@@ -456,12 +441,6 @@ pub(crate) fn accept(
_ => {}
},
}
Ok(())
};
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let Some(bytes) = target(rel)? else { return Ok(false) };
write(rel, bytes)?;
Ok(true)
};
let mut accepted = Vec::with_capacity(changes.len());
@@ -487,9 +466,8 @@ pub(crate) fn accept(
// A case-only rename (`a.txt` → `A.txt`). Where the disk resolves both
// spellings to one file it is renamed in place and `apply` then refreshes
// the content. On a case-sensitive disk the two names are different files:
// both are checked before either changes — the old one must be the file the
// checkout had, the new one absent or already the task's — then the task's
// version lands at the new name before the old one goes.
// the old one's unchanged-ness (it is the file the checkout had) decides —
// then the task's version lands at the new name and the old one goes.
let case_only =
old.is_some_and(|o| o != &change.rel_path && o.to_lowercase() == change.rel_path.to_lowercase());
if case_only {
@@ -498,18 +476,20 @@ pub(crate) fn accept(
if same_file(&old_path, &new_path) {
std::fs::rename(&old_path, &new_path)?;
} else {
let Some(old_target) = target(old)? else {
if !apply(old)? {
skipped.push(skip(old));
continue;
};
let task_bytes = std::fs::read(repo.root().join(&change.rel_path)).ok();
let at_new = existing(&new_path);
if at_new.as_ref().is_none_or(|at_new| at_new.is_some() && *at_new != task_bytes) {
skipped.push(skip(&change.rel_path));
continue;
}
write(&change.rel_path, task_bytes)?;
write(old, old_target)?;
// `apply(old)` removed the old file (the task's tree has it only at
// the new name); now write that version under the new name. `apply`
// can't do it — it reads the new name's own base/absence — so the
// write is open-coded from the same task bytes `apply` would use.
if let Ok(bytes) = std::fs::read(repo.root().join(&change.rel_path)) {
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&new_path, bytes)?;
}
accepted.push(change.rel_path.clone());
continue;
}
@@ -980,41 +960,6 @@ mod tests {
assert_eq!(names, ["A.txt"]);
}
#[test]
fn a_case_only_rename_keeps_a_file_the_checkout_has_at_the_new_name() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(repo.join("A.txt"), "mine\n").unwrap();
if std::fs::read_to_string(repo.join("a.txt")).unwrap() == "mine\n" {
eprintln!("this disk doesn't tell a.txt from A.txt; nothing to check");
return;
}
git(&created.path, &["mv", "a.txt", "A.txt"]);
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["A.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("A.txt")).unwrap(), "mine\n");
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\ntwo\n");
}
#[cfg(unix)]
#[test]
fn accept_leaves_a_checkout_file_it_cannot_read() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("new.txt"), "task\n").unwrap();
std::fs::write(repo.join("new.txt"), "mine\n").unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o200)).unwrap();
if std::fs::read(repo.join("new.txt")).is_ok() {
eprintln!("running as a user that reads any file; nothing to check");
return;
}
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
std::fs::set_permissions(repo.join("new.txt"), std::fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(outcome.skipped.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), ["new.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "mine\n");
}
#[cfg(windows)]
#[test]
fn accept_does_not_write_into_the_checkouts_git_folder_through_its_short_name() {
+1 -2
Переглянути файл
@@ -12,8 +12,7 @@ mod up_to_date;
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
pub use launch_resources::process_launch_resources;
pub use single::{
SingleFileTarget, compile_file_plan, find_javac, javac_step, module_classpath, single_file_classpath,
single_file_target,
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
};
pub use up_to_date::{menu_up_to_date, record_menu_build};
+4 -23
Переглянути файл
@@ -144,13 +144,6 @@ fn bracket_pos(s: &str) -> Option<(u32, Option<u32>, &str)> {
Some((line, col, rest.trim_start()))
}
/// `/C:/p/A.java` → `C:/p/A.java`: Maven and file URIs put a `/` before the drive letter on Windows.
/// Joined as is, it would become the drive-relative `C:p/A.java`.
fn drive_path(p: &str) -> &str {
let b = p.as_bytes();
if b.len() > 2 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { &p[1..] } else { p }
}
/// `file:///p/A.kt` → `/p/A.kt` (percent-decoded).
fn from_file_uri(s: &str) -> String {
let Some(rest) = s.strip_prefix("file://") else { return s.to_owned() };
@@ -169,7 +162,9 @@ fn from_file_uri(s: &str) -> String {
out.push(bytes[i]);
i += 1;
}
drive_path(&String::from_utf8_lossy(&out)).to_owned()
let path = String::from_utf8_lossy(&out).into_owned();
// `file:///C:/x` on Windows.
if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' { path[1..].to_owned() } else { path }
}
/// Kotlin: `file:///p/A.kt:12:5 msg` or `/p/A.kt: (12, 5): msg`, after the `e: ` / `w: ` prefix.
@@ -227,7 +222,7 @@ fn maven_line(line: &str) -> Option<RawMessage> {
&& looks_like_source_path(path)
&& let Some((ln, col, msg)) = bracket_pos(&format!("[{pos}"))
{
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(drive_path(path), ln, col));
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(path, ln, col));
}
if let Some(m) = kotlin_line(rest) {
return Some(m);
@@ -461,20 +456,6 @@ mod tests {
assert_eq!(out[1].unit, ColUnit::Char);
}
#[test]
fn maven_windows_paths_drop_the_slash_before_the_drive() {
let out = parse(
Format::Maven,
"[ERROR] /C:/dev/p/src/main/java/a/A.java:[18,41] package a.b does not exist\n\
[ERROR] C:\\dev\\p\\B.java:[3,8] cannot find symbol\n\
[ERROR] file:///C:/dev/p/K.kt:4:9 Unresolved reference 'x'.\n",
);
assert_eq!(out.len(), 3, "{out:?}");
assert_eq!(pos(&out[0]), (Severity::Error, "C:/dev/p/src/main/java/a/A.java", 18, Some(41)));
assert_eq!(pos(&out[1]), (Severity::Error, "C:\\dev\\p\\B.java", 3, Some(8)));
assert_eq!(pos(&out[2]), (Severity::Error, "C:/dev/p/K.kt", 4, Some(9)));
}
#[test]
fn maven_detail_lines_with_level_prefix_and_kotlin() {
let out = parse(
+3 -210
Переглянути файл
@@ -4,21 +4,12 @@
//! walks the whole reactor; compiling one file only needs that file's module
//! outputs plus its dependency classpath, which is much faster to assemble.
use super::*;
use serde::{Deserialize, Serialize};
use std::fs;
use std::process::Command as ProcCommand;
/// Output file of the `dependency:build-classpath` probe, relative to each module.
const CP_FILE: &str = "target/ide-compile-cp.txt";
/// The resolved dependency classpath for one module and scope, tied to the same
/// inputs as its launch build so POM, JDK, settings and profile changes invalidate it.
#[derive(Debug, Deserialize, Serialize)]
struct CachedClasspath {
fingerprint: LaunchFingerprint,
classpath: String,
}
/// A `.java` source and the module build that compiles it.
pub struct SingleFileTarget {
/// Absolute module directory.
@@ -73,28 +64,6 @@ pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeRe
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// The Maven reactor target for a launch module whose source file is not known yet.
fn module_target(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<SingleFileTarget> {
let Some(m) = &model.maven else {
return Err(IdeError::not_found("no linked Maven project found for module"));
};
let module = if module.is_absolute() { module.to_path_buf() } else { root.join(module) };
let project = m
.projects
.iter()
.find(|p| root.join(&p.dir) == module)
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
if project.packaging == "pom" {
return Err(IdeError::not_found("the launch module is a Maven aggregator without sources"));
}
let top = maven_reactor(m, &project.dir)
.ok_or_else(|| IdeError::not_found("no linked Maven reactor found for module"))?;
let top_dir = subdir(root, &top.dir);
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
}
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
let home = env.get("JAVA_HOME").map(Path::new);
@@ -109,88 +78,6 @@ pub fn find_javac(env: &BTreeMap<String, String>) -> String {
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
classpath(root, model, target, "test", false)
}
/// Runtime (or test) dependency classpath for a launch module.
///
/// The Java language server runs on its own JDK, so its Maven model can select a
/// different JDK profile and dependency classifier than the project launch. Use the
/// same Maven process that builds the module instead.
pub fn module_classpath(
root: &Path,
model: &BuildModel,
module: &Path,
test: bool,
local_repo: Option<PathBuf>,
cache: &Path,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
let target = module_target(root, model, module, test)?;
let fingerprint = launch_fingerprint(root, model, &target.module, test, local_repo, cancel)?;
let cache_path = classpath_cache_path(root, cache, &target.module, test);
if let (Some(fingerprint), Some(path)) = (&fingerprint, &cache_path)
&& let Ok(bytes) = fs::read(path)
&& let Ok(cached) = serde_json::from_slice::<CachedClasspath>(&bytes)
&& cached.fingerprint == *fingerprint
&& classpath_entries_exist(root, &cached.classpath)
{
return Ok(cached.classpath);
}
let classpath = classpath(root, model, &target, if test { "test" } else { "runtime" }, true)?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let (Some(fingerprint), Some(path)) = (fingerprint, cache_path) {
let cached = CachedClasspath { fingerprint, classpath: classpath.clone() };
if let Err(error) = cache_classpath(&path, &cached) {
tracing::debug!(path = %path.display(), %error, "Cannot cache the Java launch classpath");
}
}
Ok(classpath)
}
/// One cache file per module and scope. Mirroring the module path keeps nested
/// modules and their different dependency scopes from overwriting each other.
fn classpath_cache_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
let relative = module.strip_prefix(root).ok()?;
let mut path = cache.join("java-launch/classpaths");
path.push(if relative.as_os_str().is_empty() { Path::new("_root") } else { relative });
path.push(if test { "test.json" } else { "runtime.json" });
Some(path)
}
/// Best-effort cache persistence. A failed write only costs one later Maven
/// probe, so the launch still succeeds even when the cache directory is unusable.
fn cache_classpath(path: &Path, cached: &CachedClasspath) -> IdeResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_vec(cached).map_err(|e| IdeError::internal(e.to_string()))?)?;
Ok(())
}
/// A cache entry stays valid while every file exists. Maven also emits reactor
/// output directories for modules with no classes, which may legitimately never be
/// created; the launch snapshot already supplies the module's classes in that case.
fn classpath_entries_exist(root: &Path, classpath: &str) -> bool {
// Temp directories on macOS are commonly reached through `/var`, while Maven
// reports the canonical `/private/var` path. Compare against both spellings.
let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
std::env::split_paths(classpath).all(|entry| {
entry.exists()
|| ((entry.starts_with(root) || entry.starts_with(&canonical_root))
&& (entry.ends_with(Path::new("target/classes")) || entry.ends_with(Path::new("target/test-classes"))))
})
}
fn classpath(
root: &Path,
model: &BuildModel,
target: &SingleFileTarget,
scope: &str,
compile_first: bool,
) -> IdeResult<String> {
let mut entries: Vec<String> = Vec::new();
// The module's own classes first: the file almost always uses classes next to it.
let own: &[&str] = if target.test { &["target/test-classes", "target/classes"] } else { &["target/classes"] };
@@ -223,14 +110,7 @@ fn classpath(
args.extend(["-pl", target.pl.as_str(), "-am"]);
}
let output_arg = format!("-Dmdep.outputFile={probe}");
let scope_arg = format!("-Dmdep.includeScope={scope}");
// A launch build may only have reached `compile`. Running `compile` in this
// same Maven session lets the dependency plugin resolve Reactor siblings from
// their target/classes without paying for package-phase plugins.
if compile_first {
args.push("compile");
}
args.extend(["dependency:build-classpath", output_arg.as_str(), scope_arg.as_str(), "-q"]);
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
let top_dir = subdir(root, &target.top.dir);
let status = ProcCommand::new(&target.top.command)
.args(&args)
@@ -239,10 +119,8 @@ fn classpath(
.output()
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
if !status.status.success() {
let stdout = String::from_utf8_lossy(&status.stdout);
let stderr = String::from_utf8_lossy(&status.stderr);
// Maven reports reactor/dependency failures on stdout, not only stderr.
let tail: Vec<&str> = stdout.lines().chain(stderr.lines()).filter(|l| !l.trim().is_empty()).collect();
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
}
@@ -427,7 +305,7 @@ mod tests {
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
@@ -449,90 +327,5 @@ mod tests {
assert!(cp.contains("/repo/a.jar"), "{cp}");
assert!(cp.contains("/repo/b.jar"), "{cp}");
assert!(cp.split(separator).count() >= 3, "{cp}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(!args.lines().any(|a| a == "compile" || a == "package"), "{args}");
}
/// A launch uses runtime dependencies, while tests additionally see test-scope ones.
#[cfg(unix)]
#[test]
fn launch_module_classpath_uses_the_matching_dependency_scope() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
fs::write(
&script,
"#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/runtime.jar:/repo/test.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let model = crate::load(root, &root.join("cache"), None);
let cache = root.join("cache");
let runtime =
module_classpath(root, &model, &root.join("app"), false, None, &cache, &CancellationToken::new()).unwrap();
assert!(runtime.contains("/repo/runtime.jar"), "{runtime}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "compile"), "{args}");
assert!(!args.lines().any(|a| a == "package"), "{args}");
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=runtime"), "{args}");
let test =
module_classpath(root, &model, &root.join("app"), true, None, &cache, &CancellationToken::new()).unwrap();
assert!(test.contains("/repo/test.jar"), "{test}");
let args = fs::read_to_string(root.join("args.txt")).unwrap();
assert!(args.lines().any(|a| a == "-Dmdep.includeScope=test"), "{args}");
}
/// The launch classpath follows the Maven runner JDK and is reused until that
/// build input changes; the probe does not run packaging plugins.
#[cfg(unix)]
#[test]
fn launch_classpath_cache_follows_the_runner_jdk() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fixture(root);
let script = root.join("mvnw");
// `dependency:build-classpath` reports reactor outputs as absolute paths. Keep
// the fake probe faithful so the cache's existence check sees the output.
fs::write(
&script,
"#!/bin/sh\nprintf x >> calls.txt\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target/classes\" \"$JAVA_HOME/classpath\"\n: > \"$JAVA_HOME/classpath/jdk.jar\"\nprintf '%s/classpath/jdk.jar:%s/%s/target/classes:%s/empty/target/classes' \"$JAVA_HOME\" \"$(pwd)\" \"$pl\" \"$(pwd)\" > \"$pl/target/ide-compile-cp.txt\"\n",
)
.unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let mut model = crate::load(root, &root.join("cache"), None);
for (name, version) in [("jdk21", "21"), ("jdk8", "8")] {
let home = root.join(name);
fs::create_dir_all(home.join("bin")).unwrap();
fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
fs::write(home.join("bin/java"), "").unwrap();
}
let cache = root.join("classpath-cache");
let cancel = CancellationToken::new();
let jdk21 = root.join("jdk21");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk21.to_string_lossy().into_owned());
}
let first = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(first.contains("jdk21/classpath/jdk.jar"), "{first}");
assert!(!first.contains("jdk8/classpath/jdk.jar"), "{first}");
let second = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert_eq!(second, first);
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "x");
let jdk8 = root.join("jdk8");
for project in &mut model.maven.as_mut().unwrap().projects {
project.env.insert("JAVA_HOME".into(), jdk8.to_string_lossy().into_owned());
}
let switched = module_classpath(root, &model, &root.join("app"), false, None, &cache, &cancel).unwrap();
assert!(switched.contains("jdk8/classpath/jdk.jar"), "{switched}");
assert!(!switched.contains("jdk21/classpath/jdk.jar"), "{switched}");
assert_eq!(fs::read_to_string(root.join("calls.txt")).unwrap(), "xx");
}
}
+21 -206
Переглянути файл
@@ -712,27 +712,18 @@ pub(crate) fn launch_inputs(
// Profile activation may depend on the presence of an otherwise unrelated file.
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
if input || activation {
// A source root under Maven's build directory is generated by the build itself.
// Skip it without resolving the property: `<build><directory>` may be custom, and
// hashing its rewritten files would make every launch look changed.
if !(input && el.text.contains("${project.build.directory}")) {
// Other properties can be overridden by -D, profiles or settings. Without
// Maven's effective model, leave them unresolved to conservatively disable reuse.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
let path = dir.join(value);
// Conventional generated sources under target are also build outputs.
if !(input && path.starts_with(dir.join("target"))) {
out.push(path);
}
}
// Other properties can be overridden by -D, profiles or settings.
// Without Maven's effective roots, conservatively disable reuse for them.
let value = el
.text
.trim()
.replace("${project.basedir}", &dir.to_string_lossy())
.replace("${basedir}", &dir.to_string_lossy());
if value.contains("${") {
return None;
}
if !value.is_empty() {
out.push(dir.join(value));
}
}
for child in &el.children {
@@ -1689,15 +1680,6 @@ pub enum PomGotoKind {
ManagedDependency,
/// A `<pluginManagement>` entry providing the plugin's version.
ManagedPlugin,
/// The POM of the artifact itself: a project module's, else the local repository's.
ArtifactPom,
}
/// A Maven project of the workspace, for going to a dependency on it.
pub struct ModulePom {
pub group_id: String,
pub artifact_id: String,
pub pom: PathBuf,
}
/// Byte offset of `pos` (0-based line / UTF-16 column), clamped to `text`.
@@ -1777,20 +1759,6 @@ fn coordinates(e: &Element, props: &HashMap<String, String>) -> Option<(String,
/// the local repository when it is not in the project). `None` when there is no
/// resolvable declaration here.
pub fn goto_declaration(text: &str, path: &Path, pos: Pos, local_repo: Option<PathBuf>) -> Option<PomGoto> {
goto_declaration_in(text, path, pos, local_repo, Vec::new)
}
/// [`goto_declaration`], where a `<parent>` and a dependency or plugin whose version no
/// management entry provides (or a management entry itself) go to the artifact's own
/// POM: the project module among `modules` with its coordinates, else the local
/// repository's POM of its version.
pub fn goto_declaration_in(
text: &str,
path: &Path,
pos: Pos,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let root = xml::parse(text).filter(|e| e.name == "project")?;
let at = offset_at(text, pos);
if let Some((name, _, _)) = property_ref_at(text, at)
@@ -1801,56 +1769,15 @@ pub fn goto_declaration_in(
// A `<dependency>` / `<plugin>` in a declaration position: its version may come
// from a management entry of this POM or an ancestor's.
let elements = element_path(&root, at);
if elements.get(1).is_some_and(|e| e.name == "parent") {
let chain = parent_chain(path, text, local_repo);
let (pom, parent_text) = chain.get(1)?;
let label = elements[1].text_of("artifactId").unwrap_or_default();
return Some(pom_goto(pom.clone(), parent_text, label));
}
let e = elements.iter().rev().find(|e| e.name == "dependency" || e.name == "plugin")?;
// A management entry declares the version: it goes to the artifact (a BOM import, say).
if !elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
if let Some(found) = managed_target(text, path, e, kind, local_repo.clone()) {
return Some(found);
}
if elements.iter().any(|a| a.name == "dependencyManagement" || a.name == "pluginManagement") {
return None; // Management entries are declarations; there is nothing to go to.
}
artifact_target(text, path, e, local_repo, modules)
}
/// The artifact's own POM for a dependency or plugin element `e`.
fn artifact_target(
text: &str,
path: &Path,
e: &Element,
local_repo: Option<PathBuf>,
modules: impl FnOnce() -> Vec<ModulePom>,
) -> Option<PomGoto> {
let mut resolver = Resolver::new(local_repo.clone(), &[]);
let chain = parent_chain(path, text, local_repo);
let props = properties_at(&mut resolver, &chain, 0);
let (group, artifact) = coordinates(e, &props)?;
let label = format!("{group}:{artifact}");
if let Some(module) = modules().into_iter().find(|m| m.group_id == group && m.artifact_id == artifact) {
let module_text = xml::read_text(&module.pom)?;
return Some(pom_goto(module.pom, &module_text, label));
}
let version = interpolate(&e.text_of("version")?, &props);
let pom = resolver.repo_pom(&group, &artifact, &version)?;
let pom_text = xml::read_text(&pom)?;
Some(pom_goto(pom, &pom_text, label))
}
/// The POM at `path` as a target, at its `<artifactId>`.
fn pom_goto(path: PathBuf, text: &str, label: String) -> PomGoto {
let range = xml::parse(text)
.filter(|e| e.name == "project")
.and_then(|root| root.child("artifactId").map(|a| range_of(text, a)))
.unwrap_or_default();
PomGoto { path, range, kind: PomGotoKind::ArtifactPom, label }
let kind = match e.name.as_str() {
"dependency" => PomGotoKind::ManagedDependency,
_ => PomGotoKind::ManagedPlugin,
};
managed_target(text, path, e, kind, local_repo)
}
/// The parent POMs of the POM in `text` at `path`, child-first (its `<parent>` refs,
@@ -2149,52 +2076,6 @@ mod tests {
assert_eq!(known.goals, ["compile", "help", "testCompile"], "the common plugins keep their table");
}
#[test]
fn launch_inputs_skip_generated_sources_under_the_project_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>\
<packaging>pom</packaging><modules><module>app</module></modules></project>",
);
write(
root,
"app/pom.xml",
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
<artifactId>app</artifactId><build><plugins><plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>build-helper-maven-plugin</artifactId>
<executions><execution><configuration><sources>
<source>${project.build.directory}/generated-sources/java</source>
</sources></configuration></execution></executions>
</plugin></plugins></build></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
let inputs = launch_inputs(root, &projects, None).expect("portable launch inputs");
assert!(!inputs.contains(&root.join("app/target/generated-sources/java")), "{inputs:?}");
assert!(inputs.contains(&root.join("app/src")), "conventional sources stay fingerprinted");
}
#[test]
fn launch_inputs_disable_reuse_for_file_activation_on_the_build_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
root,
"pom.xml",
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version>
<build><directory>${project.basedir}/custom-build</directory></build>
<profiles><profile><id>generated</id><activation><file>
<exists>${project.build.directory}/enable-profile</exists>
</file></activation></profile></profiles></project>"#,
);
let model = load_with_repo(root, None);
let projects: Vec<&MavenProject> = model.projects.iter().collect();
assert!(launch_inputs(root, &projects, None).is_none());
}
fn write(dir: &Path, rel: &str, text: &str) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
@@ -3206,9 +3087,9 @@ mod tests {
let text = std::fs::read_to_string(&path).unwrap();
// The property is declared nowhere along the chain.
assert_eq!(goto_declaration(&text, &path, at(&text, "${missing.key}"), None), None);
// The dependency names its own version and no local repository has its POM.
// The dependency names its own version: it is the declaration.
assert_eq!(goto_declaration(&text, &path, at(&text, "<artifactId>other-lib"), None), None);
// A management entry declares the version; its artifact's POM is not at hand either.
// A management entry is a declaration itself, not a reference.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
assert_eq!(
@@ -3220,72 +3101,6 @@ mod tests {
assert_eq!(goto_declaration("<project></project>", &path, Pos::new(0, 3), None), None);
}
#[test]
fn artifacts_go_to_their_own_pom_in_the_project_or_the_local_repository() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let bom_dir = repo.join("com/alibaba/cloud/spring-cloud-alibaba-dependencies/2.2.6.RELEASE");
std::fs::create_dir_all(&bom_dir).unwrap();
let bom = "<project>\n <groupId>com.alibaba.cloud</groupId>\n <artifactId>spring-cloud-alibaba-dependencies</artifactId>\n</project>";
std::fs::write(bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"), bom).unwrap();
let parent = format!(
r#"{PARENT_HEADER}<properties><alibaba.version>2.2.6.RELEASE</alibaba.version></properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${{alibaba.version}}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#
);
let child = format!(
r#"{CHILD_HEADER}<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>core</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#
);
write_parent_child(tmp.path(), &parent, &child);
let core = tmp.path().join("core/pom.xml");
std::fs::create_dir_all(core.parent().unwrap()).unwrap();
std::fs::write(
&core,
"<project>\n <groupId>com.example</groupId>\n <artifactId>core</artifactId>\n</project>",
)
.unwrap();
let modules =
|| vec![ModulePom { group_id: "com.example".into(), artifact_id: "core".into(), pom: core.clone() }];
// A BOM import in <dependencyManagement> opens the BOM from the local repository.
let parent_path = tmp.path().join("pom.xml");
let parent_text = std::fs::read_to_string(&parent_path).unwrap();
let at_bom = at(&parent_text, "spring-cloud-alibaba-dependencies<");
let goto = goto_declaration_in(&parent_text, &parent_path, at_bom, Some(repo.clone()), modules).unwrap();
assert_eq!(goto.path, bom_dir.join("spring-cloud-alibaba-dependencies-2.2.6.RELEASE.pom"));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
assert_eq!(goto.range.start, pos_at(bom, bom.find("<artifactId>").unwrap()));
// A dependency on a project module opens the module's POM.
let path = tmp.path().join("app/pom.xml");
let text = std::fs::read_to_string(&path).unwrap();
let goto =
goto_declaration_in(&text, &path, at(&text, "core</artifactId>"), Some(repo.clone()), modules).unwrap();
assert_eq!((goto.path.as_path(), goto.label.as_str()), (core.as_path(), "com.example:core"));
// The <parent> section opens the parent POM.
let goto = goto_declaration_in(&text, &path, at(&text, "parent</artifactId>"), Some(repo), modules).unwrap();
assert_eq!(goto.path, canon(&parent_path));
assert_eq!(goto.kind, PomGotoKind::ArtifactPom);
}
#[test]
fn a_property_reference_after_non_ascii_text_is_still_found() {
let text = format!(
+7 -24
Переглянути файл
@@ -393,13 +393,11 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletion
out
}
/// The repository directory of `coordinates` (a group id, or a group and an artifact
/// id): the group's dots are directories, an artifact id is one directory whatever dots
/// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated
/// against the layout's alphabet so POM text cannot walk outside `repo`.
/// The repository directory of the `/`-separated `coordinates` (a group id, or
/// a group and an artifact id), each segment validated against the layout's
/// alphabet so POM text cannot walk outside `repo`.
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
let (group, artifact) = coordinates.split_first()?;
let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect();
let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect();
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
}
@@ -462,7 +460,6 @@ mod tests {
"org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar",
"org/example/lib/2.0/lib-2.0.pom",
"net/nu/netty/4.1.100/netty-4.1.100.jar",
"org/eclipse/platform/org.eclipse.core.runtime/3.31.0/org.eclipse.core.runtime-3.31.0.jar",
// Failed downloads (a version typed while editing): tracking files only, not installed.
"org/example/lib/2/lib-2.jar.lastUpdated",
"org/example/lib/3.0/lib-3.0.pom.lastUpdated",
@@ -573,10 +570,9 @@ mod tests {
};
// Only directories holding artifacts are group ids: `net` and `org`
// alone never receive an install, their deeper levels do.
let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"];
assert_eq!(options(""), [&["net.nu"][..], &org].concat());
assert_eq!(options("org"), org);
assert_eq!(options("org."), org);
assert_eq!(options(""), ["net.nu", "org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org"), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org."), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]);
assert_eq!(options("org.spr"), Vec::<String>::new());
assert_eq!(options("net."), ["net.nu"]);
}
@@ -627,19 +623,6 @@ mod tests {
};
assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]);
assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]);
// An artifact id with dots is one directory.
let dotted = local_options(
&repo,
&PomCompletionTarget {
from: 0,
to: 0,
typed: "".into(),
kind: PomCompletionKind::Version,
group: Some("org.eclipse.platform".into()),
artifact: Some("org.eclipse.core.runtime".into()),
},
);
assert_eq!(dotted.into_iter().map(|o| o.label).collect::<Vec<_>>(), ["3.31.0"]);
}
#[test]
-9
Переглянути файл
@@ -270,13 +270,6 @@ fn run(
/// The first error lines of a failed build, for the tool window.
pub(crate) fn error_summary(text: &str) -> String {
// The JVM's "could not find or load main class" (localized) when `gradlew` has no jar.
if text.contains("org.gradle.wrapper.GradleWrapperMain") {
return "gradle/wrapper/gradle-wrapper.jar is missing (often excluded by a `*.jar` rule in .gitignore). \
Restore it, run `gradle wrapper` with an installed Gradle, or choose Distribution: Local installation \
in Settings › Build, Execution, Deployment › Gradle."
.to_owned();
}
let lines: Vec<&str> = text
.lines()
.filter(|l| {
@@ -916,8 +909,6 @@ mod tests {
let text = "[INFO] x\n[ERROR] Failed to execute goal\n[ERROR] \n[ERROR] Re-run Maven using -X\n";
assert_eq!(error_summary(text), "Failed to execute goal");
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
let missing_jar = "错误: 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain\n";
assert!(error_summary(missing_jar).starts_with("gradle/wrapper/gradle-wrapper.jar is missing"));
}
#[test]
-15
Переглянути файл
@@ -27,13 +27,6 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
#[cfg(target_os = "macos")]
pub fn text() -> Option<String> {
imp::text()
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -380,14 +373,6 @@ mod imp {
})
}
pub fn text() -> Option<String> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
-3
Переглянути файл
@@ -270,8 +270,6 @@ pub struct SmartKeysSettings {
pub pair_quotes: bool,
pub surround_selection: bool,
pub tab_out_of_pair: bool,
/// Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag.
pub simultaneous_tag_editing: bool,
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub escape_on_paste: bool,
@@ -291,7 +289,6 @@ impl Default for SmartKeysSettings {
pair_quotes: true,
surround_selection: true,
tab_out_of_pair: true,
simultaneous_tag_editing: true,
reformat_on_paste: "indentEachLine".into(),
escape_on_paste: true,
smart_indent: true,
-58
Переглянути файл
@@ -8,18 +8,13 @@
use crate::{DebugSessionId, Diagnostic, DocId, RunId, ServerId, TaskId, TerminalId, TextChange};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
use std::sync::Arc;
/// What a built-in MCP tool asks the project window for. `AgentRun` runs `command` (a shell
/// line) in the Run tool window's agent tab `name`, on pipes, answered once the process
/// started or failed to.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
AgentRun { name: String, command: String, cwd: crate::IpcPath, env: BTreeMap<String, String> },
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
@@ -77,9 +72,6 @@ pub enum IdeEvent {
WorkspaceEditApplied {
id: u32,
paths: Vec<crate::IpcPath>,
/// Made by an agent through the MCP server's tools: undoable like a refactoring, but
/// not announced as one.
by_agent: bool,
},
/// An open document's language changed (Settings › File Types, Override File
/// Type), or its UI mode (a framework's root marker appeared or went).
@@ -291,56 +283,6 @@ pub enum DebugEvent {
JvmBackend {
state: JvmBackendState,
},
/// A step in the life of one request of a debugged page.
JsNetwork {
event: JsNetworkEvent,
},
/// The adapter does not report the requests of this page: there will be no `JsNetwork`.
JsNetworkUnavailable,
}
/// Which step of a request a [`JsNetworkEvent`] reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum JsNetworkKind {
Request,
Response,
Finished,
Failed,
}
/// The script frame that made a request, where the browser ran it (0-based line and column).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsInitiator {
pub function: String,
pub url: String,
pub line: u32,
pub column: u32,
}
/// What a debugged page's browser reports about a request, reduced to what the Network view
/// shows. Events of one request share `request_id`; each kind fills its own fields.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsNetworkEvent {
pub kind: JsNetworkKind,
pub request_id: String,
/// The browser's monotonic clock, comparable between the events of a page.
pub timestamp_ms: u64,
/// Request and response.
pub url: Option<String>,
pub method: Option<String>,
/// `Fetch`, `XHR`, `Script`, `Document`, …
pub resource_type: Option<String>,
pub status: Option<u16>,
pub mime_type: Option<String>,
/// The request's headers, or the response's.
pub headers: BTreeMap<String, String>,
/// Bytes received over the network, once finished.
pub encoded_length: Option<u64>,
pub error_text: Option<String>,
pub initiator: Option<JsInitiator>,
}
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
+1 -52
Переглянути файл
@@ -76,8 +76,7 @@ fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
text
}
/// The `encoding_rs` encoding of a Windows code page, when it has one.
pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
Some(match cp {
65001 => encoding_rs::UTF_8,
936 => encoding_rs::GBK,
@@ -102,56 +101,6 @@ pub fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding>
})
}
/// The code page Windows console programs write to a pipe in: the OEM code page (GBK on a
/// Chinese system, Shift_JIS on a Japanese one, EUC-KR on a Korean one, 437 / 850 / 866…
/// elsewhere). `None` when it is UTF-8 (Windows' "Use Unicode UTF-8" option) or off Windows.
pub fn console_code_page() -> Option<u32> {
#[cfg(windows)]
{
// SAFETY: GetOEMCP is an argument-less query with no preconditions.
let cp = unsafe { windows_sys::Win32::Globalization::GetOEMCP() };
(cp != 65001).then_some(cp)
}
#[cfg(not(windows))]
{
None
}
}
/// Whether every character of code page `cp` is one byte, so text in it can be decoded
/// piece by piece.
#[cfg(windows)]
pub fn is_single_byte_code_page(cp: u32) -> bool {
use windows_sys::Win32::Globalization::{CPINFO, GetCPInfo};
// SAFETY: CPINFO is plain data, which GetCPInfo fills in.
unsafe {
let mut info: CPINFO = std::mem::zeroed();
GetCPInfo(cp, &mut info) != 0 && info.MaxCharSize == 1
}
}
/// `bytes` in code page `cp`, decoded by Windows (for the code pages `encoding_rs` lacks,
/// like the OEM pages 437, 850 or 852).
#[cfg(windows)]
pub fn decode_code_page(bytes: &[u8], cp: u32) -> String {
use windows_sys::Win32::Globalization::MultiByteToWideChar;
let Ok(len) = i32::try_from(bytes.len()) else {
return String::from_utf8_lossy(bytes).into_owned();
};
if len == 0 {
return String::new();
}
// SAFETY: `bytes` holds `len` bytes; no output buffer asks for the length needed.
let needed = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, std::ptr::null_mut(), 0) };
if needed <= 0 {
return String::from_utf8_lossy(bytes).into_owned();
}
let mut wide = vec![0u16; needed as usize];
// SAFETY: `wide` holds `needed` units.
let written = unsafe { MultiByteToWideChar(cp, 0, bytes.as_ptr(), len, wide.as_mut_ptr(), needed) };
String::from_utf16_lossy(&wide[..written.max(0) as usize])
}
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
const FILE_ENCODING: &str = "file.encoding";
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
-13
Переглянути файл
@@ -77,8 +77,6 @@ pub enum AutoUpload {
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentSettings {
pub servers: Vec<DeploymentServer>,
/// Server groups: Upload to… on a group uploads to each of its servers.
pub groups: Vec<DeploymentGroup>,
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
pub default_server: String,
pub auto_upload: AutoUpload,
@@ -94,7 +92,6 @@ impl Default for DeploymentSettings {
fn default() -> Self {
Self {
servers: Vec::new(),
groups: Vec::new(),
default_server: String::new(),
auto_upload: AutoUpload::Never,
delete_remote: false,
@@ -106,16 +103,6 @@ impl Default for DeploymentSettings {
}
}
/// Named set of deployment servers that files are uploaded to together.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentGroup {
pub id: String,
pub name: String,
/// [`DeploymentServer::id`]s, in upload order.
pub servers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DeploymentType {
+3 -68
Переглянути файл
@@ -288,49 +288,14 @@ pub enum NewTaskApprovals {
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on [`Self::address`] while the IDE runs.
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
pub run_without_confirmation: bool,
/// Finished agent tabs (`run_command`) the Run tool window shows; older ones are archived.
pub agent_tab_limit: u32,
/// Archived agent tabs kept; older ones are closed.
pub agent_archive_limit: u32,
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
/// address of this computer, or `0.0.0.0` / `::` for every address.
pub address: String,
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
/// port forwards keep working after a restart.
pub fixed_port: bool,
/// The port of [`Self::fixed_port`].
pub port: u16,
/// Keep the bearer token across runs (in the app data, readable by this user only) rather than
/// make a new one each run.
pub fixed_token: bool,
}
/// The default of [`McpServerSettings::port`].
pub const DEFAULT_MCP_PORT: u16 = 26371;
/// The default of [`McpServerSettings::address`]: this computer only.
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
impl Default for McpServerSettings {
fn default() -> Self {
Self {
enabled: false,
run_without_confirmation: false,
agent_tab_limit: 1,
agent_archive_limit: 999,
address: DEFAULT_MCP_ADDRESS.to_owned(),
fixed_port: true,
port: DEFAULT_MCP_PORT,
fixed_token: false,
}
}
}
/// Absent profiles retain automatic discovery for existing projects.
@@ -679,28 +644,6 @@ pub struct AppearanceSettings {
pub status_bar_widgets: BTreeMap<String, bool>,
/// Customize Main Toolbar; `None` = the default toolbar.
pub main_toolbar: Option<MainToolbarSettings>,
/// Appearance › Background Image: an image shown through the IDE window.
pub background_image: BackgroundImageSettings,
}
/// Appearance › Background Image. An empty `path` shows no image.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct BackgroundImageSettings {
pub path: String,
/// Percent, 0–100.
pub opacity: u8,
/// `scale` (cover the window) | `plain` (original size) | `tile`.
pub fill: String,
/// Where the image is anchored: `center`, `top-left`, `top`, `top-right`, `left`, `right`,
/// `bottom-left`, `bottom`, `bottom-right`.
pub anchor: String,
}
impl Default for BackgroundImageSettings {
fn default() -> Self {
Self { path: String::new(), opacity: 15, fill: "scale".into(), anchor: "center".into() }
}
}
/// Main toolbar entries per side, in order: title widget ids (`project`,
@@ -771,7 +714,6 @@ impl Default for AppearanceSettings {
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
background_image: BackgroundImageSettings::default(),
}
}
}
@@ -1159,9 +1101,6 @@ pub struct NodeSettings {
pub run_target: String,
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
pub package_manager: String,
/// Runtime for running JavaScript / TypeScript files: `node` | `bun` | `deno`; empty = from the
/// project (`deno.json` → Deno, `bun.lock` → Bun, else Node.js).
pub runtime: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -1358,15 +1297,11 @@ pub struct TypeScriptSettings {
/// `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
/// or a path to a TypeScript `lib` directory.
pub version: String,
/// tsserver heap limit in MB. `0` = automatic: 8192 when vtsls hosts the Vue
/// plugin (hybrid mode; a Vue project's program alone needs ~3 GiB, and the
/// vtsls default 3072 dies in a GC death spiral), else unset (vtsls default).
pub tsserver_max_memory: u32,
}
impl Default for TypeScriptSettings {
fn default() -> Self {
Self { version: "bundled".into(), tsserver_max_memory: 0 }
Self { version: "bundled".into() }
}
}
-9
Переглянути файл
@@ -260,24 +260,18 @@ pub struct DebuggerSettings {
pub show_on_breakpoint: bool,
pub focus_app_on_breakpoint: bool,
pub hide_on_termination: bool,
/// A breakpoint or exception hit brings the Debug tool window's Threads & Variables tab to the front.
pub variables_on_breakpoint: bool,
pub breakpoints_any_file: bool,
/// While suspended, hovering the start of a line shows a Run to Cursor button.
pub run_to_cursor_inlay: bool,
pub inline_values: bool,
/// Data Views: list variables by name instead of the adapter's order.
pub sort_values: bool,
/// Data Views: evaluate watches while the program runs, where the debugger can (JavaScript).
pub live_watches: bool,
pub value_tooltip: bool,
/// While suspended, selecting an expression shows its value.
pub value_tooltip_on_selection: bool,
pub value_tooltip_delay: u32,
/// Stepping: do not step into library code (debugpy `justMyCode`, js-debug `skipFiles`).
pub skip_library_code: bool,
/// JavaScript: debugging a dev server (`vite`, `next dev`) also opens its page in a browser session.
pub debug_server_page: bool,
pub python: PythonDebuggerSettings,
}
@@ -287,17 +281,14 @@ impl Default for DebuggerSettings {
show_on_breakpoint: true,
focus_app_on_breakpoint: false,
hide_on_termination: false,
variables_on_breakpoint: true,
breakpoints_any_file: false,
run_to_cursor_inlay: true,
inline_values: true,
sort_values: false,
live_watches: false,
value_tooltip: true,
value_tooltip_on_selection: true,
value_tooltip_delay: 700,
skip_library_code: true,
debug_server_page: false,
python: PythonDebuggerSettings::default(),
}
}
-3
Переглянути файл
@@ -28,9 +28,6 @@ tracing.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
-72
Переглянути файл
@@ -9,7 +9,6 @@
use ide_core::process::executable_names;
use ide_core::{IdeError, IdeResult};
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
use serde_json::Value;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -213,50 +212,6 @@ pub fn tools_root() -> Option<PathBuf> {
TOOLS_ROOT.read().clone()
}
/// The browser js-debug launches for `ty`, or `None` when the launch starts no browser.
fn launched_browser(ty: &str) -> Option<&'static str> {
match ty {
"pwa-chrome" | "chrome" => Some("chrome"),
"pwa-msedge" | "msedge" => Some("msedge"),
_ => None,
}
}
/// Gives a browser launch that names no `userDataDir` a profile of `project`'s own in the
/// IDE's local data (`cache/browser-profiles`), so sign-ins and site data are still there in
/// the next session. js-debug's own default is one temporary profile for every project.
pub(crate) fn set_browser_profile(arguments: &mut Value, project: &Path) {
if let Some(local_data) = tools_root().and_then(|tools| tools.parent().map(Path::to_owned)) {
set_browser_profile_in(arguments, project, &local_data);
}
}
fn set_browser_profile_in(arguments: &mut Value, project: &Path, local_data: &Path) {
let Some(obj) = arguments.as_object_mut() else { return };
let browser = obj.get("type").and_then(Value::as_str).and_then(launched_browser);
let launch = obj.get("request").and_then(Value::as_str) == Some("launch");
let (Some(browser), true, false) = (browser, launch, obj.contains_key("userDataDir")) else { return };
let dir = local_data.join("cache").join("browser-profiles").join(profile_name(project)).join(browser);
obj.insert("userDataDir".into(), dir.to_string_lossy().into_owned().into());
}
/// `<folder name>-<hash of the path>`: readable, and distinct for projects whose folders share a name.
fn profile_name(project: &Path) -> String {
// FNV-1a: the name has to stay the same across toolchains, which `DefaultHasher` doesn't promise.
let hash = project
.to_string_lossy()
.bytes()
.fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
let name: String = project
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
format!("{name}-{hash:016x}")
}
/// Executable directories of managed installs, read at lookup time so tools installed
/// after startup are found without a restart: `<tool>/bin`, `<tool>/venv/bin`, npm's `.bin`.
fn managed_dirs(root: &Path) -> Vec<PathBuf> {
@@ -1085,33 +1040,6 @@ mod tests {
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn a_browser_launch_gets_a_profile_of_its_project() {
let local = Path::new("/data/local");
let profile = |args: serde_json::Value, project: &str| {
let mut args = args;
set_browser_profile_in(&mut args, Path::new(project), local);
args.get("userDataDir").cloned()
};
let chrome = serde_json::json!({ "type": "pwa-chrome", "request": "launch" });
let dir = profile(chrome.clone(), "/work/shop").unwrap();
let dir = Path::new(dir.as_str().unwrap());
assert!(dir.starts_with("/data/local/cache/browser-profiles"), "{dir:?}");
assert!(dir.ends_with("chrome"), "{dir:?}");
let name = dir.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
assert!(name.starts_with("shop-") && name.len() == "shop-".len() + 16, "{name}");
// The same project again: the same profile. Another folder of that name, or Edge: another one.
assert_eq!(profile(chrome.clone(), "/work/shop").unwrap(), dir.to_string_lossy().as_ref());
assert_ne!(profile(chrome.clone(), "/other/shop").unwrap(), dir.to_string_lossy().as_ref());
let edge = profile(serde_json::json!({ "type": "pwa-msedge", "request": "launch" }), "/work/shop").unwrap();
assert!(Path::new(edge.as_str().unwrap()).ends_with("msedge"), "{edge}");
// A chosen profile (or the default one, `false`) is kept; Node and attach requests get none.
let own = serde_json::json!({ "type": "pwa-chrome", "request": "launch", "userDataDir": false });
assert_eq!(profile(own, "/work/shop"), Some(false.into()));
assert_eq!(profile(serde_json::json!({ "type": "pwa-node", "request": "launch" }), "/work/shop"), None);
assert_eq!(profile(serde_json::json!({ "type": "pwa-chrome", "request": "attach" }), "/work/shop"), None);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
+12 -195
Переглянути файл
@@ -1,7 +1,6 @@
//! Attach to Process: the current user's processes, grouped by the debugger that can
//! attach to them (IDEA's `XAttachDebuggerProvider`s): JVMs with a JDWP agent, Node,
//! Python (debugpy injection), .NET (netcoredbg), Go binaries (Delve) and anything else
//! natively (LLDB).
//! Python (debugpy injection), Go binaries (Delve) and anything else natively (LLDB).
use crate::adapter;
use ide_core::{IdeError, IdeResult};
@@ -15,7 +14,6 @@ pub enum AttachKind {
Java,
Node,
Python,
Dotnet,
Go,
Native,
}
@@ -97,29 +95,13 @@ fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
}
/// A .NET app: the `dotnet` host, or an apphost next to its `<name>.runtimeconfig.json`.
pub fn is_dotnet(exe: &str) -> bool {
let path = Path::new(exe);
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { return false };
if stem == "dotnet" {
return true;
}
path.is_absolute() && path.with_file_name(format!("{stem}.runtimeconfig.json")).is_file()
}
fn is_python(name: &str) -> bool {
let n = name.strip_suffix(".exe").unwrap_or(name);
n == "Python" || n.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
}
/// Classifies a process; `is_dotnet` and `is_go` are asked only for processes no
/// interpreter claims.
pub fn classify(
p: &RawProcess,
is_dotnet: impl Fn(&str) -> bool,
is_go: impl Fn(&str) -> bool,
native_adapter: &str,
) -> Option<AttachableProcess> {
/// Classifies a process; `is_go` is asked only for processes no interpreter claims.
pub fn classify(p: &RawProcess, is_go: impl Fn(&str) -> bool, native_adapter: &str) -> Option<AttachableProcess> {
let name = file_name(&p.exe).to_owned();
let make = |kind, adapter: &str, addr: Option<(Option<String>, u16)>| {
let (host, port) = match addr {
@@ -147,9 +129,6 @@ pub fn classify(
if is_python(&name) {
return make(AttachKind::Python, "debugpy", debugpy_listen(&p.args));
}
if is_dotnet(&p.exe) {
return make(AttachKind::Dotnet, "netcoredbg", None);
}
if is_go(&p.exe) {
return make(AttachKind::Go, "delve", None);
}
@@ -164,7 +143,7 @@ const GO_SCAN_LIMIT: u64 = 96 << 20;
pub fn is_go_binary(exe: &str) -> bool {
use std::io::Read;
let skip = ["/System/", "/usr/", "/bin/", "/sbin/", "/Library/Apple/", "/Applications/"];
if skip.iter().any(|p| exe.starts_with(p)) || is_windows_system(exe) || !Path::new(exe).is_absolute() {
if skip.iter().any(|p| exe.starts_with(p)) || !Path::new(exe).is_absolute() {
return false;
}
let Ok(file) = std::fs::File::open(exe) else { return false };
@@ -186,17 +165,6 @@ pub fn is_go_binary(exe: &str) -> bool {
}
}
/// Windows' own and installed programs (`C:\Windows\…`, `C:\Program Files…`), like the
/// system locations skipped on Unix.
fn is_windows_system(exe: &str) -> bool {
let lower = exe.to_ascii_lowercase();
lower.get(1..).is_some_and(|rest| {
rest.starts_with(":\\windows\\")
|| rest.starts_with(":\\program files\\")
|| rest.starts_with(":\\program files (x86)\\")
})
}
/// The current user's processes (without this one).
#[cfg(unix)]
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
@@ -213,7 +181,7 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
let (pid, rest) = line.split_once(char::is_whitespace)?;
Some((pid.parse().ok()?, rest.trim().to_owned()))
};
// `comm` is the executable path as started on macOS; on Linux it is truncated, so read /proc.
// `comm` is the executable path on macOS; on Linux it is truncated, so read /proc.
let exes: HashMap<u32, String> =
if cfg!(target_os = "linux") { HashMap::new() } else { ps("pid=,comm=")?.lines().filter_map(split).collect() };
let me = std::process::id();
@@ -222,8 +190,9 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
.filter_map(split)
.filter(|(pid, _)| *pid != me)
.map(|(pid, args)| {
let exe = exe_path(pid)
.or_else(|| exes.get(&pid).cloned())
let exe = exes
.get(&pid)
.cloned()
.or_else(|| std::fs::read_link(format!("/proc/{pid}/exe")).ok().map(|p| p.display().to_string()))
.unwrap_or_else(|| args.split_whitespace().next().unwrap_or_default().to_owned());
RawProcess { pid, exe, args }
@@ -231,134 +200,9 @@ pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
.collect())
}
/// The processes this user may open (without this one): system and other users'
/// processes refuse `PROCESS_QUERY_LIMITED_INFORMATION` and are left out.
#[cfg(windows)]
#[cfg(not(unix))]
pub fn list_processes() -> IdeResult<Vec<RawProcess>> {
windows_processes::list()
}
#[cfg(windows)]
mod windows_processes {
use super::RawProcess;
use ide_core::{IdeError, IdeResult};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Wdk::System::Threading::{NtQueryInformationProcess, ProcessCommandLineInformation};
use windows_sys::Win32::Foundation::{INVALID_HANDLE_VALUE, UNICODE_STRING};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW,
};
pub fn list() -> IdeResult<Vec<RawProcess>> {
let me = std::process::id();
Ok(pids()?
.into_iter()
.filter(|&pid| pid != 0 && pid != me)
.filter_map(|pid| {
// SAFETY: plain call; a non-null result is a process handle owned here.
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
return None;
}
// SAFETY: as above.
let process = unsafe { OwnedHandle::from_raw_handle(handle) };
let exe = image_path(&process)?;
let args = command_line(&process).unwrap_or_else(|| exe.clone());
Some(RawProcess { pid, exe, args })
})
.collect())
}
fn pids() -> IdeResult<Vec<u32>> {
let mut out = Vec::new();
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
return Err(IdeError::unavailable(format!(
"cannot list processes: {}",
std::io::Error::last_os_error()
)));
}
let snapshot = OwnedHandle::from_raw_handle(snapshot);
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
while more != 0 {
out.push(entry.th32ProcessID);
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
}
}
Ok(out)
}
fn image_path(process: &OwnedHandle) -> Option<String> {
let mut buf = vec![0u16; 32 * 1024];
let mut len = buf.len() as u32;
// SAFETY: a live process handle with query access; `len` is the buffer's capacity.
let ok = unsafe {
QueryFullProcessImageNameW(process.as_raw_handle(), PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len)
};
(ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize]))
}
/// The command line (Windows 8.1+), read with the same limited query access.
fn command_line(process: &OwnedHandle) -> Option<String> {
let mut size = 0u32;
// SAFETY: a size query with no buffer; it fails and reports the size it needs.
unsafe {
NtQueryInformationProcess(
process.as_raw_handle(),
ProcessCommandLineInformation,
std::ptr::null_mut(),
0,
&mut size,
)
};
if (size as usize) < size_of::<UNICODE_STRING>() {
return None;
}
// u64 elements keep the UNICODE_STRING header aligned.
let mut buf = vec![0u64; (size as usize).div_ceil(8)];
// SAFETY: a buffer of at least `size` bytes.
let status = unsafe {
NtQueryInformationProcess(
process.as_raw_handle(),
ProcessCommandLineInformation,
buf.as_mut_ptr().cast(),
size,
&mut size,
)
};
if status < 0 {
return None;
}
// SAFETY: on success the buffer starts with a UNICODE_STRING whose text lies inside it.
let text = unsafe {
let s = &*buf.as_ptr().cast::<UNICODE_STRING>();
if s.Buffer.is_null() {
return None;
}
std::slice::from_raw_parts(s.Buffer, usize::from(s.Length) / 2)
};
Some(String::from_utf16_lossy(text))
}
}
/// The full executable path: `comm` keeps a relative path a process was started with.
#[cfg(target_os = "macos")]
fn exe_path(pid: u32) -> Option<String> {
let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
// SAFETY: the buffer holds `PROC_PIDPATHINFO_MAXSIZE` bytes, as the call requires.
let n = unsafe { libc::proc_pidpath(i32::try_from(pid).ok()?, buf.as_mut_ptr().cast(), buf.len() as u32) };
(n > 0).then(|| String::from_utf8_lossy(&buf[..n as usize]).into_owned())
}
#[cfg(all(unix, not(target_os = "macos")))]
fn exe_path(_pid: u32) -> Option<String> {
None
Err(IdeError::unavailable("Attach to Process is not supported on this platform yet"))
}
/// Node's default inspector port, which `SIGUSR1` opens.
@@ -400,7 +244,7 @@ pub fn attachable_processes() -> IdeResult<Vec<AttachableProcess>> {
let cwd = std::env::temp_dir();
let native = if adapter::resolve_binary("codelldb", &cwd).is_some() { "codelldb" } else { "lldb-dap" };
let mut out: Vec<AttachableProcess> =
list_processes()?.iter().filter_map(|p| classify(p, is_dotnet, is_go_binary, native)).collect();
list_processes()?.iter().filter_map(|p| classify(p, is_go_binary, native)).collect();
out.sort_by(|a, b| a.kind.cmp(&b.kind).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(out)
}
@@ -414,8 +258,7 @@ mod tests {
}
fn kind(exe: &str, args: &str) -> Option<(AttachKind, Option<u16>)> {
classify(&raw(exe, args), |e| e.ends_with("/Api"), |e| e.ends_with("/gosrv"), "codelldb")
.map(|p| (p.kind, p.port))
classify(&raw(exe, args), |e| e.ends_with("/gosrv"), "codelldb").map(|p| (p.kind, p.port))
}
#[test]
@@ -449,37 +292,11 @@ mod tests {
kind("/Library/Frameworks/Python.framework/Versions/3.12/Resources/Python.app/Contents/MacOS/Python", "x"),
Some((AttachKind::Python, None))
);
assert_eq!(kind("/home/me/Api", "./Api"), Some((AttachKind::Dotnet, None)));
assert_eq!(kind("/home/me/gosrv", "./gosrv"), Some((AttachKind::Go, None)));
assert_eq!(kind("/home/me/a.out", "./a.out"), Some((AttachKind::Native, None)));
assert!(!is_python("pythonista"));
}
#[test]
fn detects_dotnet_hosts_and_apphosts() {
let dir = std::env::temp_dir().join(format!("openidea-attach-dotnet-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let app = dir.join("Api");
std::fs::write(&app, b"").unwrap();
std::fs::write(dir.join("Api.runtimeconfig.json"), b"{}").unwrap();
let tool = dir.join("tool");
std::fs::write(&tool, b"").unwrap();
assert!(is_dotnet(app.to_str().unwrap()));
assert!(!is_dotnet(tool.to_str().unwrap()));
assert!(is_dotnet("/usr/local/share/dotnet/dotnet"));
assert!(is_dotnet("dotnet"));
assert!(!is_dotnet("Api"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn skips_windows_system_programs() {
assert!(is_windows_system(r"C:\Windows\System32\svchost.exe"));
assert!(is_windows_system(r"c:\program files (x86)\App\app.exe"));
assert!(!is_windows_system(r"D:\src\srv\srv.exe"));
assert!(!is_windows_system("/usr/bin/true"));
}
#[test]
fn detects_go_binaries_by_buildinfo() {
let dir = std::env::temp_dir().join(format!("openidea-attach-{}", std::process::id()));
-283
Переглянути файл
@@ -1,283 +0,0 @@
//! Requests of vscode-js-debug's own, beyond the Debug Adapter Protocol: what a page in a
//! browser offers a debugger besides stepping (breakpoints on DOM events and requests).
//! Each is a typed method; a js-debug build that lacks one answers with an error, which is
//! reported as the feature being unavailable.
use crate::session::Session;
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT};
use ide_core::event::{JsInitiator, JsNetworkEvent, JsNetworkKind};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
use std::collections::BTreeMap;
pub(crate) const ADAPTER: &str = "js-debug";
/// Whether `session` debugs a page: the child js-debug starts for a browser launch or attach.
/// The launch's own session only owns the browser.
pub(crate) fn is_page(session: &Session) -> bool {
session.adapter == ADAPTER
&& session.parent.is_some()
&& matches!(session.launch_type.get().map(String::as_str), Some("pwa-chrome" | "pwa-msedge"))
}
/// Waits until the adapter answered the `launch` of `session`. js-debug sets a page's session
/// up between `configurationDone` and that answer; a request of its own that arrives then is
/// answered and has no effect.
async fn launched(session: &Session) -> bool {
let mut launched = session.launched.subscribe();
let answered = tokio::time::timeout(REQUEST_TIMEOUT, launched.wait_for(|answered| *answered)).await;
matches!(answered, Ok(Ok(_)))
}
impl DebugManager {
/// One of js-debug's requests for pages, sent to the page `s` once its session has started.
async fn js_page_request(&self, s: DebugSessionId, command: &str, arguments: Value) -> IdeResult<Value> {
let session = self.session(s)?;
if !is_page(&session) {
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
}
if !launched(&session).await {
return Err(IdeError::unavailable("the page's debug session did not finish starting"));
}
session
.conn
.request_timeout(command, arguments, REQUEST_TIMEOUT)
.await
.map_err(|e| IdeError::unavailable(format!("the JavaScript debugger refused `{command}`: {e}")))
}
/// Breakpoints on events (`listener:click`, `instrumentation:setTimeout`) and on requests
/// whose URL contains one of `xhr`. The lists replace the page's previous ones.
pub async fn js_set_custom_breakpoints(
&self,
s: DebugSessionId,
ids: Vec<String>,
xhr: Vec<String>,
) -> IdeResult<()> {
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
Ok(())
}
}
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
pub(crate) const NETWORK_EVENTS: [&str; 4] =
["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"];
/// Response bodies shown are cut at this many bytes.
pub const RESPONSE_BODY_LIMIT: usize = 1_048_576;
/// The body of a response, as text or as the browser's base64 for binary content.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JsResponseBody {
pub body: String,
pub base64_encoded: bool,
/// The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there.
pub truncated: bool,
}
fn text(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn headers(v: &Value) -> BTreeMap<String, String> {
v.get("headers")
.and_then(Value::as_object)
.map(|h| h.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
.unwrap_or_default()
}
/// The frame a request is attributed to: the innermost one outside `node_modules`, looked
/// for through the stacks that scheduled this one (an `await`, a timer), since a request sent
/// through a library has the library innermost. A stack of library frames only gives its first.
fn initiator(data: &Value) -> Option<JsInitiator> {
let stack = data.pointer("/initiator/stack")?;
let frames = |stack: &Value| stack.get("callFrames").and_then(Value::as_array).cloned().unwrap_or_default();
let stacks = std::iter::successors(Some(stack), |stack| stack.get("parent")).take(32);
let all: Vec<Value> = stacks.flat_map(frames).collect();
let own = |frame: &&Value| {
frame.get("url").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && !url.contains("/node_modules/"))
};
let frame = all.iter().find(own).or(all.first())?;
let number = |key: &str| frame.get(key).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0);
Some(JsInitiator {
function: text(frame, "functionName").unwrap_or_default(),
url: text(frame, "url")?,
line: number("lineNumber"),
column: number("columnNumber"),
})
}
/// js-debug's `networkEvent` (`{event, data}`, `data` being the browser's own parameters of
/// that event) as a [`JsNetworkEvent`]; `None` for events the Network view has no use for.
pub(crate) fn network_event(body: &Value) -> Option<JsNetworkEvent> {
let data = body.get("data")?;
let kind = match body.get("event").and_then(Value::as_str)? {
"requestWillBeSent" => JsNetworkKind::Request,
"responseReceived" => JsNetworkKind::Response,
"loadingFinished" => JsNetworkKind::Finished,
"loadingFailed" => JsNetworkKind::Failed,
_ => return None,
};
let part = match kind {
JsNetworkKind::Request => data.get("request"),
JsNetworkKind::Response => data.get("response"),
_ => None,
};
let part = part.unwrap_or(&Value::Null);
Some(JsNetworkEvent {
kind,
request_id: text(data, "requestId")?,
// Seconds with a fraction.
timestamp_ms: (data.get("timestamp").and_then(Value::as_f64).unwrap_or(0.0).max(0.0) * 1000.0).round() as u64,
url: text(part, "url"),
method: text(part, "method"),
resource_type: text(data, "type"),
status: part.get("status").and_then(Value::as_u64).and_then(|s| u16::try_from(s).ok()),
mime_type: text(part, "mimeType"),
headers: headers(part),
encoded_length: data.get("encodedDataLength").and_then(Value::as_f64).map(|n| n.max(0.0) as u64),
error_text: text(data, "errorText"),
initiator: if kind == JsNetworkKind::Request { initiator(data) } else { None },
})
}
/// `networkCall getResponseBody`'s answer, cut at `limit` bytes on a character boundary.
fn response_body(answer: &Value, limit: usize) -> JsResponseBody {
let mut body = text(answer, "body").unwrap_or_default();
let truncated = body.len() > limit;
if truncated {
let mut end = limit;
while !body.is_char_boundary(end) {
end -= 1;
}
body.truncate(end);
}
JsResponseBody {
body,
base64_encoded: answer.get("base64Encoded").and_then(Value::as_bool).unwrap_or(false),
truncated,
}
}
/// The arguments of `enableNetworking`, which has a page report its requests
/// ([`ide_core::event::DebugEvent::JsNetwork`]). A page session sends it as it starts.
pub(crate) fn networking_arguments() -> Value {
json!({ "mirrorEvents": NETWORK_EVENTS })
}
impl DebugManager {
/// The body of the response to `request_id`, while the browser still has it.
pub async fn js_response_body(&self, s: DebugSessionId, request_id: String) -> IdeResult<JsResponseBody> {
let call = json!({ "method": "getResponseBody", "params": { "requestId": request_id } });
let answer = self.js_page_request(s, "networkCall", call).await?;
Ok(response_body(&answer, RESPONSE_BODY_LIMIT))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(kind: &str, data: Value) -> Option<ide_core::event::JsNetworkEvent> {
network_event(&json!({ "event": kind, "data": data }))
}
#[test]
fn a_request_carries_what_was_asked_and_who_asked() {
let e = event(
"requestWillBeSent",
json!({
"requestId": "76695.31", "timestamp": 611807.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://127.0.0.1:5195/src/main.ts", "headers": { "Accept": "*/*", "X-N": 1 } },
"initiator": { "type": "script", "stack": { "callFrames": [
{ "functionName": "load", "url": "http://127.0.0.1:5195/src/App.vue", "lineNumber": 9, "columnNumber": 2 }
] } }
}),
)
.unwrap();
assert_eq!(e.kind, JsNetworkKind::Request);
assert_eq!((e.request_id.as_str(), e.timestamp_ms), ("76695.31", 611_807_500));
assert_eq!(e.url.as_deref(), Some("http://127.0.0.1:5195/src/main.ts"));
assert_eq!((e.method.as_deref(), e.resource_type.as_deref()), (Some("GET"), Some("Fetch")));
// Only text headers are kept.
assert_eq!(e.headers.len(), 1);
assert_eq!(e.headers["Accept"], "*/*");
let from = e.initiator.unwrap();
assert_eq!((from.function.as_str(), from.line, from.column), ("load", 9, 2));
assert_eq!(from.url, "http://127.0.0.1:5195/src/App.vue");
}
#[test]
fn a_request_made_through_a_library_is_asked_by_the_code_that_called_the_library() {
let frame = |function: &str, url: &str| json!({ "functionName": function, "url": url, "lineNumber": 1, "columnNumber": 0 });
let lib = "http://localhost:5173/node_modules/.vite/deps/http-client.js";
let asked_by = |stack: Value| {
let data = json!({ "requestId": "1", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/api" },
"initiator": { "type": "script", "stack": stack } });
event("requestWillBeSent", data).unwrap().initiator.unwrap().function
};
// The innermost frame is the library's; the project's caller is further out.
let direct = json!({ "callFrames": [frame("dispatch", lib), frame("request", lib), frame("loadItems", "http://localhost:5173/src/api.ts")] });
assert_eq!(asked_by(direct), "loadItems");
// Across an `await`, the caller is in the stack that scheduled this one.
let scheduled = json!({ "callFrames": [frame("dispatch", lib)],
"parent": { "callFrames": [frame("then", lib)], "parent": { "callFrames": [frame("onMounted", "http://localhost:5173/src/App.vue")] } } });
assert_eq!(asked_by(scheduled), "onMounted");
// A library on its own: the frame that sent the request.
assert_eq!(asked_by(json!({ "callFrames": [frame("dispatch", lib), frame("poll", lib)] })), "dispatch");
}
#[test]
fn a_response_a_finish_and_a_failure_carry_their_outcome() {
let response = event(
"responseReceived",
json!({ "requestId": "7", "timestamp": 2.0, "type": "Script", "response": {
"url": "http://localhost/a.js", "status": 200, "mimeType": "text/javascript",
"headers": { "Content-Type": "text/javascript" }
} }),
)
.unwrap();
assert_eq!(response.kind, JsNetworkKind::Response);
assert_eq!((response.status, response.mime_type.as_deref()), (Some(200), Some("text/javascript")));
assert_eq!(response.headers["Content-Type"], "text/javascript");
let finished =
event("loadingFinished", json!({ "requestId": "7", "timestamp": 2.5, "encodedDataLength": 1234 })).unwrap();
assert_eq!((finished.kind, finished.encoded_length), (JsNetworkKind::Finished, Some(1234)));
let failed =
event("loadingFailed", json!({ "requestId": "8", "timestamp": 3.0, "errorText": "net::ERR_FAILED" }))
.unwrap();
assert_eq!((failed.kind, failed.error_text.as_deref()), (JsNetworkKind::Failed, Some("net::ERR_FAILED")));
}
#[test]
fn events_that_name_no_request_or_are_not_mirrored_are_dropped() {
assert!(event("requestWillBeSent", json!({ "timestamp": 1.0 })).is_none());
assert!(event("dataReceived", json!({ "requestId": "7", "timestamp": 1.0 })).is_none());
assert!(network_event(&json!({ "data": { "requestId": "7" } })).is_none());
// A request made by the page's parser has no script behind it.
let parser = event(
"requestWillBeSent",
json!({ "requestId": "9", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/" },
"initiator": { "type": "parser" } }),
)
.unwrap();
assert!(parser.initiator.is_none());
}
#[test]
fn a_response_body_is_cut_at_the_limit_on_a_character() {
let whole = response_body(&json!({ "body": "héllo", "base64Encoded": false }), 100);
assert_eq!((whole.body.as_str(), whole.base64_encoded, whole.truncated), ("héllo", false, false));
// `é` is two bytes: a cut inside it backs off to the character before.
let cut = response_body(&json!({ "body": "héllo" }), 2);
assert_eq!((cut.body.as_str(), cut.truncated), ("h", true));
let binary = response_body(&json!({ "body": "AAEC", "base64Encoded": true }), 100);
assert!(binary.base64_encoded && !binary.truncated);
assert_eq!(response_body(&json!({}), 100).body, "");
}
}
+2 -44
Переглянути файл
@@ -13,7 +13,6 @@ pub mod attach;
mod cargo;
pub mod connection;
pub mod jdwp;
mod js_debug;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
@@ -24,7 +23,6 @@ use connection::{Connection, DapError};
use futures::future::join_all;
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
use ide_language::LanguageRegistry;
pub use js_debug::JsResponseBody;
pub use jvm_memory::*;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -138,11 +136,6 @@ pub struct DebugCapabilities {
pub jvm_instances: bool,
/// The session can be asked for a heap dump ([`DebugManager::jvm_heap_dump`]).
pub jvm_heap: bool,
/// The session debugs a page in a browser: js-debug's requests for pages apply
/// ([`DebugManager::js_set_custom_breakpoints`]).
pub js_page: bool,
/// The adapter evaluates expressions while the program runs, without a stack frame.
pub live_evaluate: bool,
}
/// Result of [`DebugManager::redefine_classes`].
@@ -400,16 +393,6 @@ fn no_session(s: DebugSessionId) -> IdeError {
IdeError::not_found(format!("no debug session {}", s.0))
}
/// The name of a child session: the adapter's, or the parent's when it gives none. js-debug
/// names a browser page's session after the page the browser starts on, before the URL
/// loads (`about:blank`, or nothing in a new profile), which says nothing about what is debugged.
fn child_name(configuration: &Value, parent: &str) -> String {
match configuration.get("name").and_then(Value::as_str) {
Some(name) if !name.trim().is_empty() && name != "about:blank" => name.to_owned(),
_ => parent.to_owned(),
}
}
/// Builds the `launch`/`attach` arguments: our fields first, then `extra_json` merged on top.
pub fn launch_arguments(config: &LaunchConfig) -> IdeResult<Value> {
// VS Code puts `type`, `request` and `name` in the arguments; js-debug picks launch
@@ -597,8 +580,6 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
jvm_memory: false,
jvm_instances: false,
jvm_heap: false,
js_page: false,
live_evaluate: false,
}
}
@@ -608,8 +589,6 @@ pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.jvm_instances = session.jvm_inspector.lock().is_some();
caps.jvm_heap = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
caps.js_page = js_debug::is_page(session);
caps.live_evaluate = session.adapter == js_debug::ADAPTER;
caps
}
@@ -765,9 +744,6 @@ impl DebugManager {
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
if config.adapter == "js-debug" {
adapter::set_browser_profile(&mut arguments, &cwd);
}
let mut spec = adapter::resolve(&dbg, cwd.clone());
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
@@ -896,7 +872,8 @@ impl DebugManager {
request: String,
configuration: Value,
) -> IdeResult<()> {
let name = child_name(&configuration, &parent.name);
let name =
configuration.get("name").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| parent.name.clone());
let mut configuration = configuration;
if let Some(obj) = configuration.as_object_mut() {
obj.entry("request").or_insert_with(|| request.clone().into());
@@ -944,9 +921,6 @@ impl DebugManager {
.flatten();
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
if let Some(launch_type) = arguments.get("type").and_then(Value::as_str) {
let _ = session.launch_type.set(launch_type.to_owned());
}
// rdbg execs the debuggee in its own process and sends no DAP process event.
if let Some(pid) = rdbg_pid {
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
@@ -1181,22 +1155,6 @@ impl DebugManager {
reply.await.unwrap_or(Err(DapError::Closed))?;
}
}
s.launched.send_replace(true);
// A page reports its requests from here on. This is the earliest js-debug takes the
// request (before its answer to `launch` the page has no adapter and the request is
// never answered), and the page is already loading: its document and first scripts
// are not in the list.
if js_debug::is_page(s) {
let answer = s.conn.send_request("enableNetworking", js_debug::networking_arguments())?;
let session = s.clone();
// Not waited for here: the page runs on. A refusal is told to the Network view,
// which would otherwise wait for requests that never come.
tokio::spawn(async move {
if matches!(answer.await, Ok(Err(_))) {
session.emit(ide_core::event::DebugEvent::JsNetworkUnavailable);
}
});
}
if let Some(link) = s.jdwp_link.lock().as_ref() {
link.release();
}
+8 -72
Переглянути файл
@@ -57,8 +57,6 @@ pub(crate) struct Session {
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
/// Set once the adapter answered `launch` / `attach`, after the configuration.
pub launched: watch::Sender<bool>,
pub initialized: watch::Sender<bool>,
pub startup_cancel: ide_core::CancellationToken,
pub output_drained: watch::Sender<bool>,
@@ -91,9 +89,6 @@ pub(crate) struct Session {
progress: Mutex<HashMap<String, TaskId>>,
/// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets.
pub jdwp_link: parking_lot::Mutex<Option<ide_jdwp::JdwpLink>>,
/// The `type` of the launch or attach request (js-debug's `pwa-node`, `pwa-chrome`); a child
/// session's comes from the configuration the adapter started it with.
pub launch_type: std::sync::OnceLock<String>,
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
/// One inspector request per session at a time.
pub inspect_lock: tokio::sync::Mutex<()>,
@@ -235,7 +230,6 @@ impl Session {
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
launched: watch::channel(false).0,
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
output_drained: watch::channel(true).0,
@@ -254,7 +248,6 @@ impl Session {
resolved_paths: Mutex::new(HashMap::new()),
progress: Mutex::new(HashMap::new()),
jdwp_link: parking_lot::Mutex::new(None),
launch_type: std::sync::OnceLock::new(),
jvm_inspector: parking_lot::Mutex::new(None),
inspect_lock: tokio::sync::Mutex::default(),
ended: ide_core::CancellationToken::new(),
@@ -474,7 +467,10 @@ impl Session {
let _ = c.start_kill();
}
if let Some(mut program) = self.program.lock().take() {
program.kill();
let _ = program.child.kill();
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = program.child.wait();
});
}
}
}
@@ -571,7 +567,7 @@ fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
for child in s.debuggees.lock().iter() {
push(child.id());
}
push(s.program.lock().as_ref().and_then(ProgramTerminal::process_id));
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
groups.retain(|g| *g > 0);
groups
}
@@ -836,12 +832,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
// js-debug, after `enableNetworking`.
"networkEvent" => {
if let Some(event) = crate::js_debug::network_event(&body) {
s.emit(DebugEvent::JsNetwork { event });
}
}
_ => tracing::trace!("dap: ignored event {event}"),
}
}
@@ -871,10 +861,7 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: ide_core::input::InputWriter,
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
pid: Option<u32>,
/// Set once the program has exited and been reaped.
exited: Arc<AtomicBool>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
@@ -882,28 +869,6 @@ impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write(text.as_bytes())
}
/// The program's process id while it runs.
pub(crate) fn process_id(&self) -> Option<u32> {
if self.exited.load(Ordering::Acquire) { None } else { self.pid }
}
pub(crate) fn kill(&mut self) {
// After the reap the pid may belong to another process.
if !self.exited.load(Ordering::Acquire) {
let _ = self.killer.kill();
}
}
}
/// Reaps the program as soon as it exits. Until then it stays a zombie that `kill(pid, 0)` still
/// finds: js-debug polls the `processId` it was given and keeps the session (and its terminal)
/// open while that process looks alive, and Stop would wait for it as for a running program.
fn reap_on_exit(mut child: Box<dyn portable_pty::Child + Send + Sync>, exited: Arc<AtomicBool>) {
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = child.wait();
exited.store(true, Ordering::Release);
});
}
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
@@ -990,12 +955,8 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
#[cfg(unix)]
wait_readable,
);
let killer = child.clone_killer();
let exited = Arc::new(AtomicBool::new(false));
reap_on_exit(child, exited.clone());
let program = ProgramTerminal { writer, killer, pid, exited, _master: pair.master };
if let Some(mut previous) = s.program.lock().replace(program) {
previous.kill();
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
}
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
Ok(json!({ "processId": pid }))
@@ -1364,29 +1325,4 @@ mod tests {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
/// js-debug polls the `runInTerminal` pid with `kill(pid, 0)`; an unreaped zombie answers it as
/// a live process and keeps the session open after the program ends.
#[cfg(unix)]
#[test]
fn a_terminal_program_is_reaped_when_it_exits() {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let pair =
native_pty_system().openpty(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0 }).unwrap();
let mut cmd = CommandBuilder::new("/bin/sh");
cmd.args(["-c", "exit 0"]);
let child = pair.slave.spawn_command(cmd).unwrap();
let pid = child.process_id().unwrap() as libc::pid_t;
let exited = Arc::new(AtomicBool::new(false));
super::reap_on_exit(child, exited.clone());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !exited.load(Ordering::Acquire) {
assert!(std::time::Instant::now() < deadline, "program was not reaped");
std::thread::sleep(std::time::Duration::from_millis(10));
}
// SAFETY: signal 0 only checks whether the process exists.
assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
}
}
+5 -212
Переглянути файл
@@ -146,10 +146,6 @@ struct Script {
delay_initialize: Option<Duration>,
initialized_delay: Duration,
launch_delay: Duration,
/// Answer a child session's `launch` this long after its `configurationDone`, reading
/// requests meanwhile. Like js-debug, one of `accept` that arrives in between is answered
/// and has no effect: the log gets a `dropped` entry for it.
child_launch_delay: Duration,
/// Leave startup pending before `initialized` (false) or after configuration (true).
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
@@ -158,15 +154,8 @@ struct Script {
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
gdb15_starts_at_launch: bool,
launch_after_configuration: bool,
/// Requests of the adapter's own (js-debug's `setCustomBreakpoints`…) answered with an empty body.
accept: &'static [&'static str],
/// Requests of the adapter's own answered with a body and followed by events.
custom: Option<fn(&Value) -> Option<CustomReply>>,
}
/// The answer to a request of the adapter's own: the response body, then events (name, body).
type CustomReply = (Value, Vec<(&'static str, Value)>);
impl Default for Script {
fn default() -> Self {
Self {
@@ -192,14 +181,11 @@ impl Default for Script {
delay_initialize: None,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
child_launch_delay: Duration::ZERO,
pending_startup: None,
startup_error: None,
close_during_startup: false,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
accept: &[],
custom: None,
}
}
}
@@ -232,15 +218,10 @@ impl AdapterLauncher for FakeLauncher {
}
async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream: S, log: Log, script: Script) {
let (r, w) = tokio::io::split(stream);
// Shared with the task that answers a child's `launch` late (`child_launch_delay`).
let w = Arc::new(tokio::sync::Mutex::new(w));
let (r, mut w) = tokio::io::split(stream);
let mut r = BufReader::new(r);
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut child = false;
// Between `configurationDone` and the answer to `launch`.
let starting = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut held: Vec<Value> = Vec::new();
let mut stopped = false;
let mut running = false;
@@ -249,7 +230,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut v: Value = $v;
seq += 1;
v["seq"] = seq.into();
framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&v).unwrap()).await.unwrap();
framing::write_message(&mut w, &serde_json::to_vec(&v).unwrap()).await.unwrap();
}};
}
macro_rules! ok {
@@ -311,7 +292,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
continue;
}
pending_launch = msg["seq"].as_i64();
child = msg["arguments"]["isChild"] == true;
if let Some(args) = &script.run_in_terminal {
send!(
json!({"type": "request", "command": "runInTerminal", "arguments": {"kind": "integrated", "args": args, "cwd": std::env::temp_dir()}})
@@ -319,8 +299,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
if script.start_debugging && msg["arguments"]["isChild"] != true {
send!(
// Like js-debug, the child's configuration repeats the launch's `type`.
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true, "type": msg["arguments"]["type"]}}})
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
);
}
if script.pending_startup != Some(false) {
@@ -399,21 +378,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
tokio::time::sleep(script.launch_delay).await;
if let Some(l) = pending_launch.take() {
let answer = json!({"type": "response", "request_seq": l, "command": "launch", "success": true});
if child && !script.child_launch_delay.is_zero() {
starting.store(true, Ordering::Release);
let (w, starting, delay) = (w.clone(), starting.clone(), script.child_launch_delay);
tokio::spawn(async move {
tokio::time::sleep(delay).await;
let mut answer = answer;
answer["seq"] = 999_999.into();
let _ = framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&answer).unwrap())
.await;
starting.store(false, Ordering::Release);
});
} else {
send!(answer);
}
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
if let Some(pid) = script.debuggee_pid {
let event = if script.java_process_id {
@@ -509,25 +474,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
}
"disconnect" => {
ok!(msg, Value::Null);
let _ = w.lock().await.shutdown().await;
let _ = w.shutdown().await;
break;
}
other if script.accept.contains(&other) => {
if starting.load(Ordering::Acquire) {
log.lock().push(json!({"type": "dropped", "command": other}));
}
ok!(msg, json!({}))
}
other if script.custom.and_then(|custom| custom(&msg)).is_some() => {
if starting.load(Ordering::Acquire) {
log.lock().push(json!({"type": "dropped", "command": other}));
}
let (body, events) = script.custom.and_then(|custom| custom(&msg)).unwrap();
ok!(msg, body);
for (event, body) in events {
send!(json!({"type": "event", "event": event, "body": body}));
}
}
other => send!(
json!({"type": "response", "request_seq": msg["seq"], "command": other, "success": false, "message": "unsupported"})
),
@@ -956,162 +905,6 @@ async fn blocked_program_input_does_not_hold_up_shutdown() {
assert!(h.manager.send_input(session, "later").is_err());
}
fn js_config(launch_type: &str) -> LaunchConfig {
LaunchConfig {
adapter: "js-debug".into(),
program: None,
args: Vec::new(),
extra_json: Some(format!(r#"{{"type": "{launch_type}"}}"#)),
..config()
}
}
/// The session the adapter started under `parent`.
async fn child_of(h: &Harness, parent: DebugSessionId) -> DebugSessionId {
for _ in 0..200 {
if let Some(child) = h.manager.sessions(Some("main")).into_iter().find(|s| s.parent == Some(parent)) {
return child.id;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("no child session of {parent:?}");
}
#[tokio::test]
async fn a_browser_page_session_has_the_page_capability() {
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = h.manager.capabilities(child_of(&h, browser).await).unwrap();
assert!(page.js_page && page.live_evaluate);
assert!(!h.manager.capabilities(browser).unwrap().js_page, "the launch itself is not a page");
let node = h.start(js_config("pwa-node")).await.unwrap();
let process = h.manager.capabilities(child_of(&h, node).await).unwrap();
assert!(!process.js_page && process.live_evaluate);
let python = h.start(config()).await.unwrap();
let script = h.manager.capabilities(child_of(&h, python).await).unwrap();
assert!(!script.js_page && !script.live_evaluate);
}
#[tokio::test]
async fn custom_breakpoints_are_sent_to_a_page_and_refused_elsewhere() {
let h = harness(Script { start_debugging: true, accept: &["setCustomBreakpoints"], ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], vec!["api/".into()]).await.unwrap();
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": ["api/"]})]);
let not_a_page = h.manager.js_set_custom_breakpoints(browser, Vec::new(), Vec::new()).await.unwrap_err();
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
// A js-debug build without the request.
let old = harness(Script { start_debugging: true, ..Script::default() });
let browser = old.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&old, browser).await;
let refused = old.manager.js_set_custom_breakpoints(page, Vec::new(), Vec::new()).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
}
/// js-debug answers a request of its own that reaches a page after `configurationDone` and
/// before the page's `launch` is answered, and then does nothing with it.
#[tokio::test]
async fn a_page_gets_its_requests_once_its_session_has_started() {
let h = harness(Script {
start_debugging: true,
accept: &["setCustomBreakpoints"],
child_launch_delay: Duration::from_millis(150),
..Script::default()
});
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Past the page's `configurationDone`, with its `launch` still unanswered.
tokio::time::sleep(Duration::from_millis(50)).await;
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], Vec::new()).await.unwrap();
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": []})]);
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "the request reached the page before it took requests");
}
/// js-debug's network requests: `enableNetworking` is followed by one mirrored browser event.
fn network_adapter(msg: &Value) -> Option<CustomReply> {
match msg["command"].as_str()? {
"enableNetworking" => Some((
json!({}),
vec![(
"networkEvent",
json!({ "event": "requestWillBeSent", "data": {
"requestId": "7", "timestamp": 1.5, "type": "Fetch",
"request": { "method": "GET", "url": "http://localhost:5173/api/items", "headers": {} }
} }),
)],
)),
"networkCall" => Some((json!({ "body": "é".repeat(600_000), "base64Encoded": false }), Vec::new())),
_ => None,
}
}
#[tokio::test]
async fn a_page_reports_its_requests_and_their_bodies() {
let h = harness(Script {
start_debugging: true,
custom: Some(network_adapter),
child_launch_delay: Duration::from_millis(100),
..Script::default()
});
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
let page = child_of(&h, browser).await;
// Recording is asked for by the session itself, as soon as js-debug takes the request:
// once it has answered the page's `launch` (before that, the request is never answered).
let DebugEvent::JsNetwork { event } = h.wait_event(|e| matches!(e, DebugEvent::JsNetwork { .. })).await else {
unreachable!()
};
assert_eq!(
requests(&h, "enableNetworking"),
[json!({ "mirrorEvents": ["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"] })]
);
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
assert_eq!(dropped, 0, "recording was asked for before the page took requests");
assert_eq!((event.request_id.as_str(), event.timestamp_ms), ("7", 1500));
assert_eq!(event.url.as_deref(), Some("http://localhost:5173/api/items"));
let body = h.manager.js_response_body(page, "7".into()).await.unwrap();
assert_eq!(requests(&h, "networkCall"), [json!({ "method": "getResponseBody", "params": { "requestId": "7" } })]);
// 1.2 MB of two-byte characters: cut at the limit, on a character.
assert!(body.truncated && !body.base64_encoded);
assert_eq!(body.body.len(), 1_048_576);
assert!(body.body.chars().all(|c| c == 'é'));
assert_eq!(
h.manager.js_response_body(browser, "7".into()).await.unwrap_err().kind,
ide_core::error::ErrorKind::Unavailable
);
// A Node.js program is not a page: nothing is asked of it.
let node = h.start(js_config("pwa-node")).await.unwrap();
child_of(&h, node).await;
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(requests(&h, "enableNetworking").len(), 1);
}
#[tokio::test]
async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
// A js-debug build without `enableNetworking`.
let h = harness(Script { start_debugging: true, ..Script::default() });
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
child_of(&h, browser).await;
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
}
#[test]
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
assert_eq!(child_name(&json!({ "name": "about:blank" }), "Shop in Chrome"), "Shop in Chrome");
assert_eq!(child_name(&json!({ "name": "" }), "Shop in Chrome"), "Shop in Chrome");
assert_eq!(child_name(&json!({}), "Shop in Chrome"), "Shop in Chrome");
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
-11
Переглянути файл
@@ -557,17 +557,6 @@ async fn attach_to_processes() {
extra: |pid, _| serde_json::json!({"pid": pid}),
},
];
// A .NET app built into `dotnet/bin` (`<OutDir>bin</OutDir>`), attached by its apphost.
let dotnet_app = dir.join("dotnet/bin").join(if cfg!(windows) { "Server.exe" } else { "Server" });
if dotnet_app.is_file() {
cases.push(AttachCase {
kind: attach::AttachKind::Dotnet,
command: vec![s(&dotnet_app)],
source: dir.join("dotnet/Program.cs"),
line: 1,
extra: |pid, _| serde_json::json!({"processId": pid, "justMyCode": true}),
});
}
if java_server.is_some() {
cases.insert(
0,
+1 -1
Переглянути файл
@@ -753,7 +753,7 @@ impl Pool {
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
}
if source.driver == DbDriver::Snowflake {
return crate::snowflake::SnowflakePool::connect(source, password, setup, root).await.map(Pool::Snowflake);
return crate::snowflake::SnowflakePool::connect(source, password, setup).await.map(Pool::Snowflake);
}
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
let init = Arc::new(setup.statements);
+21 -58
Переглянути файл
@@ -324,17 +324,6 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
/// Connection target: a data source id (its configured database), or
/// `<id>:<database>` for another database of the same server. Data source ids
/// never contain `:`.
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
fn tunneled(driver: DbDriver) -> bool {
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
}
/// The database host and port the SSH host forwards to.
fn tunnel_target(source: &DataSource) -> (&str, u16) {
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
}
pub fn split_target(target: &str) -> (&str, Option<&str>) {
match target.split_once(':') {
Some((id, db)) if !db.is_empty() => (id, Some(db)),
@@ -666,7 +655,9 @@ impl DbManager {
ssh_secret: Option<String>,
shared: bool,
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
return Ok((source.clone(), None));
};
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
@@ -677,25 +668,8 @@ impl DbManager {
return Ok((local(&t), Some(t)));
}
}
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (target_host, target_port) = tunnel_target(source);
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
/// opened with the settings typed in the data source.
async fn ssh_session(
&self,
source: &DataSource,
ssh: &DbSsh,
ssh_secret: Option<String>,
) -> IdeResult<Arc<ide_ssh::Session>> {
let config = ssh.config.trim();
Ok(if config.is_empty() {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
@@ -714,22 +688,14 @@ impl DbManager {
self.ssh.set_secret(config, Some(s), save);
}
self.ssh.session(config).await?
})
}
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
/// the database host and port, without logging in to the database.
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
let source = &source.resolved()?;
let ssh = source
.ssh
.as_ref()
.filter(|s| s.enabled && tunneled(source.driver))
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
let session = self.ssh_session(source, ssh, ssh_secret).await?;
let (host, port) = tunnel_target(source);
drop(Tunnel::open(session.clone(), host, port).await?);
Ok(format!("{} reaches {host}:{port}", session.label()))
};
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
if shared {
self.tunnels.lock().insert(source.id.clone(), t.clone());
}
Ok((local(&t), Some(t)))
}
/// Connects with `source` as given (not necessarily saved): Test Connection.
@@ -1525,21 +1491,18 @@ impl DbManager {
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) };
if let Err(e) = conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
// Discarding a Snowflake connection doesn't end its session, which the data source's
// other targets share: the transaction stays open, so it stays held to be ended again.
if matches!(conn, conn::Held::Snowflake(..)) {
*held = Some(conn);
} else {
let result = async {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
conn.restore_default().await
}
.await;
match result {
Ok(()) => Ok(()),
Err(e) => {
conn.discard().await;
Err(e)
}
return Err(e);
}
if let Err(e) = conn.restore_default().await {
conn.discard().await;
return Err(e);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
-35
Переглянути файл
@@ -117,14 +117,6 @@ pub(crate) fn oracle_err(e: &oracledb::Error) -> IdeError {
// config first. Treat it as a logon failure, so the UI prompts for one.
let kind = match e.kind() {
oracledb::ErrorKind::NoCredentials => ErrorKind::PermissionDenied,
// The thin driver speaks the protocol of Oracle Database 12c and later only.
oracledb::ErrorKind::ServerVersionNotSupported => {
return IdeError::new(
ErrorKind::Unavailable,
"This Oracle Database is older than 12c. OpenIDEA connects to Oracle Database 12c and later; \
11g and earlier are not supported.",
);
}
_ => ErrorKind::InvalidInput,
};
return IdeError::new(kind, e.to_string());
@@ -579,33 +571,6 @@ mod tests {
assert_eq!(connect_string_with(&s("PROD=MYDB"), false).unwrap(), "PROD=MYDB");
}
#[test]
fn a_server_older_than_12c_is_named() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
let mut connect = [0u8; 1024];
let _ = socket.read(&mut connect);
// A TNS ACCEPT packet with protocol version 314, as Oracle Database 11g answers.
let mut accept = vec![0u8; 32];
accept[1] = 32;
accept[4] = 2;
accept[8..10].copy_from_slice(&314u16.to_be_bytes());
let _ = socket.write_all(&accept);
});
let config = oracledb::Config::default()
.set_credentials("scott", "tiger")
.set_connect_string(&format!("//127.0.0.1:{port}/ORCL"))
.unwrap()
.set_transport_connect_timeout(Some(std::time::Duration::from_secs(5)));
let err = oracle_err(&oracledb::connect(config).err().expect("an 11g server is refused"));
assert_eq!(err.kind, ErrorKind::Unavailable, "{err:?}");
assert!(err.message.contains("older than 12c"), "{}", err.message);
server.join().unwrap();
}
#[test]
fn a_tns_alias_resolves_when_tnsnames_exists() {
let alias = DataSource {
+24 -94
Переглянути файл
@@ -78,8 +78,7 @@ pub(crate) fn sso_seed(key: &str, token: &str) {
}
}
/// Live sessions by data source and connection settings, so the targets of one data source (its
/// databases, the tree, consoles) opened with the same settings share one.
/// Live sessions by their connection settings, so targets opened with the same settings share one.
static SESSIONS: LazyLock<parking_lot::Mutex<HashMap<String, Weak<State>>>> =
LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
@@ -107,15 +106,6 @@ struct Lane {
context: parking_lot::Mutex<Option<String>>,
}
/// What [`SnowflakePool::statement`] does when the session is gone and it signs in again.
#[derive(Clone, Copy)]
enum Lost<'a> {
/// Runs the statement again on the new session, after this `USE` when there is one.
Enter(Option<&'a str>),
/// Fails: the statement was part of a transaction that ended with the session.
Fail,
}
const CONSOLE: usize = 0;
const METADATA: usize = 1;
@@ -261,12 +251,7 @@ fn gunzip(bytes: Vec<u8>) -> IdeResult<Vec<u8>> {
}
impl SnowflakePool {
pub(crate) async fn connect(
source: &DataSource,
secret: Option<&str>,
setup: Setup,
root: Option<&std::path::Path>,
) -> IdeResult<Self> {
pub(crate) async fn connect(source: &DataSource, secret: Option<&str>, setup: Setup) -> IdeResult<Self> {
let settings = source.snowflake();
let endpoint = account::endpoint(source)?;
let user = source.user.trim();
@@ -317,15 +302,9 @@ impl SnowflakePool {
lane: CONSOLE,
};
// Everything that makes a session what it is, the secret included (hashed: the key is only compared).
// The data source and its project too: its databases share a session, but another data source
// with the same settings, or the same one in another project (a plugin's source has the same id
// in every project), gets its own: its statements and transactions are not this one's. A browser
// sign-in is still shared through the cached ID token.
let key = blake3::hash(
format!(
"{:?}\0{}\0{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
root,
source.id,
"{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
api.address(),
settings.auth,
secret.unwrap_or_default(),
@@ -657,18 +636,13 @@ impl SnowflakePool {
() = self.closed.cancelled() => return Err(closed()),
turn = self.lane().turn.lock() => turn,
};
// A `USE` needs no schema to run in, and may be what makes the schema reachable. Ending a
// transaction needs none either, and must not fail because the schema is gone meanwhile.
let context = match first_word(sql).as_str() {
"USE" | "COMMIT" | "ROLLBACK" => None,
_ => {
self.enter(schema, cancel).await?;
self.context(schema)
}
};
// A `USE` needs no schema to run in, and may be what makes the schema reachable.
if first_word(sql) != "USE" {
self.enter(schema, cancel).await?;
}
// These can move the session to another database or schema.
let moves = matches!(first_word(sql).as_str(), "USE" | "CALL" | "EXECUTE");
let result = self.statement(sql, skip, max_rows, Lost::Enter(context.as_deref()), cancel).await;
let result = self.statement(sql, skip, max_rows, cancel).await;
if moves {
*self.lane().context.lock() = None;
}
@@ -681,14 +655,16 @@ impl SnowflakePool {
&& self.lane().context.lock().as_deref() != Some(wanted.as_str())
{
*self.lane().context.lock() = None;
// Refused (the session's role may not use this database or schema): the statement
// does not run, since the session is still wherever it was, which may be another
// target's database. Tried again with the next statement.
if let Err(error) = self.statement(&wanted, 0, 0, Lost::Enter(None), cancel).await {
return Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
});
match self.statement(&wanted, 0, 0, cancel).await {
Ok(_) => {}
// The session's role may not use this database or schema (after `USE ROLE`, or a
// schema that is gone). The statement still runs, in the session's own context:
// it may be the `USE` that leads back, or name its objects in full. Asked again
// once a statement moves the session.
Err(refused) if refused.kind == ErrorKind::InvalidInput => {
tracing::warn!("Snowflake: the session did not enter its database and schema");
}
Err(error) => return Err(error),
}
*self.lane().context.lock() = Some(wanted);
}
@@ -704,11 +680,11 @@ impl SnowflakePool {
let _turn = self.lane().turn.lock().await;
let cancel = CancellationToken::new();
self.enter(None, &cancel).await?;
self.statement("BEGIN", 0, 0, Lost::Enter(self.context(None).as_deref()), &cancel).await?;
self.statement("BEGIN", 0, 0, &cancel).await?;
let changes = async {
let mut total = 0;
for (sql, expect_one) in statements {
let changed = self.statement(sql, 0, 0, Lost::Fail, &cancel).await?.affected.unwrap_or(0.0) as u64;
let changed = self.statement(sql, 0, 0, &cancel).await?.affected.unwrap_or(0.0) as u64;
if *expect_one && changed != 1 {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {changed}; nothing was saved"
@@ -721,24 +697,22 @@ impl SnowflakePool {
.await;
match changes {
Ok(total) => {
self.statement("COMMIT", 0, 0, Lost::Fail, &cancel).await?;
self.statement("COMMIT", 0, 0, &cancel).await?;
Ok(total)
}
Err(error) => {
let _ = self.statement("ROLLBACK", 0, 0, Lost::Fail, &cancel).await;
let _ = self.statement("ROLLBACK", 0, 0, &cancel).await;
Err(error)
}
}
}
/// One statement on the session; the caller holds the session's turn. `lost`: what to do when
/// the session is gone and a new one is signed in to.
/// One statement on the session; the caller holds the session's turn.
async fn statement(
&self,
sql: &str,
skip: usize,
max_rows: usize,
lost: Lost<'_>,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
let started = Instant::now();
@@ -769,24 +743,7 @@ impl SnowflakePool {
Ok(Outcome::Done(result)) => return Ok(*result),
Ok(Outcome::Expired { gone }) if !renewed => {
renewed = true;
if !gone {
tokens = self.renew(&tokens).await?;
continue;
}
if let Lost::Fail = lost {
// The transaction ended with the session: nothing of it was saved.
let _ = self.login().await;
return Err(IdeError::new(
ErrorKind::Io,
"Snowflake: the session was lost during the transaction; nothing was saved",
));
}
// The new session is under the login's database and schema, which may be another
// target's: back to this one's before the statement runs again.
tokens = self.login().await?;
if let Lost::Enter(Some(wanted)) = lost {
self.reenter(wanted, &tokens, cancel).await?;
}
tokens = if gone { self.login().await? } else { self.renew(&tokens).await? };
}
Ok(Outcome::Expired { .. }) => {
return Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"));
@@ -796,33 +753,6 @@ impl SnowflakePool {
}
}
/// [`enter`](Self::enter) on a session just signed in to (its `tokens`), within [`statement`](Self::statement).
async fn reenter(&self, wanted: &str, tokens: &Tokens, cancel: &CancellationToken) -> IdeResult<()> {
let request_id = uuid();
let outcome = tokio::select! {
biased;
() = cancel.cancelled() => return Err(IdeError::cancelled()),
() = self.closed.cancelled() => return Err(closed()),
outcome = self.run(wanted, 0, 0, tokens, &request_id, Instant::now()) => outcome,
};
match outcome {
Ok(Outcome::Done(_)) => {
*self.lane().context.lock() = Some(wanted.to_owned());
Ok(())
}
Ok(Outcome::Expired { .. }) => {
Err(IdeError::new(ErrorKind::Io, "Snowflake: the session expired; reconnect"))
}
Err(error) => {
let error = self.shared.api.scrub.clean(error);
Err(IdeError {
message: format!("{wanted} failed, so the statement was not run: {}", error.message),
..error
})
}
}
}
async fn run(
&self,
sql: &str,
+16 -106
Переглянути файл
@@ -407,64 +407,13 @@ async fn a_lost_session_signs_in_again() {
let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(status), ok(one()), ok(CLOSED_OK)]);
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(one()), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap();
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
// The new session enters the target's schema before the statement runs again.
assert_eq!(seen[3].json()["sqlText"], "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"");
assert_eq!(seen[4].json()["sqlText"], "select 1");
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
}
#[tokio::test]
async fn a_lost_session_runs_the_statement_in_its_own_database_again() {
let (listener, port) = bind().await;
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string()
};
// Raw Data entered, the statement finds the session gone, a new login (under ANALYTICS),
// Raw Data entered again, the statement.
let replies = vec![ok(LOGIN_OK), ok(status()), ok(gone), ok(again), ok(status()), ok(one()), ok(CLOSED_OK)];
let server = serve(listener, replies);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut other = source(port);
other.database = "Raw Data".into();
other.options.default_schema = String::new();
let second = Pool::connect(&other, None, None).await.unwrap();
second.execute("delete from t", 1, &CancellationToken::new()).await.unwrap();
first.close().await;
second.close().await;
let sent: Vec<String> =
server.await.unwrap()[1..6].iter().map(|r| r.json()["sqlText"].as_str().unwrap_or("").to_owned()).collect();
let raw = "USE DATABASE \"Raw Data\"";
assert_eq!(sent, [raw, "delete from t", "", raw, "delete from t"]);
}
#[tokio::test]
async fn a_transaction_ends_even_when_its_schema_is_gone() {
let (listener, port) = bind().await;
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
.to_string();
let server = serve(listener, vec![ok(LOGIN_OK), ok(status), ok(CLOSED_OK)]);
let pool = Pool::connect(&source(port), None, None).await.unwrap();
// The console's schema was dropped since it was picked: ending the transaction doesn't switch to it.
let mut console = crate::conn::Held::acquire(&pool).await.unwrap();
console.apply_context(Some("Dropped")).await.unwrap();
console.execute(&pool, "ROLLBACK", 0, &CancellationToken::new()).await.unwrap();
pool.close().await;
let seen = server.await.unwrap();
assert_eq!(seen[1].json()["sqlText"], "ROLLBACK");
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
}
#[tokio::test]
@@ -765,7 +714,7 @@ async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed()
}
#[tokio::test]
async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
let status = || {
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
@@ -773,12 +722,14 @@ async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
};
let refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#;
let (listener, port) = bind().await;
// use role | the schema is refused, the statement is not sent | asked again, refused again |
// use role back, the schema is entered, the statement runs
// use role | the schema is refused once, the statement runs | the next one does not ask again |
// a role that is not granted | use role back, the schema is entered again
let replies = vec![
ok(LOGIN_OK),
ok(status()),
ok(refused),
ok(one()),
ok(one()),
ok(refused),
ok(status()),
ok(status()),
@@ -789,22 +740,20 @@ async fn a_role_that_may_not_use_the_schema_runs_nothing_elsewhere() {
let pool = Pool::connect(&source(port), None, None).await.unwrap();
let cancel = CancellationToken::new();
pool.execute("use role auditor", 0, &cancel).await.unwrap();
let error = pool.execute("delete from t", 1, &cancel).await.unwrap_err();
assert!(
error.message.contains("USE SCHEMA")
&& error.message.contains("was not run")
&& error.message.contains("002043"),
"{}",
error.message
);
assert!(pool.execute("select 2", 1, &cancel).await.is_err());
pool.execute("select 1", 1, &cancel).await.unwrap();
pool.execute("select 2", 1, &cancel).await.unwrap();
let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err();
assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message);
pool.execute("use role analyst", 0, &cancel).await.unwrap();
pool.execute("select 3", 1, &cancel).await.unwrap();
pool.close().await;
let sent: Vec<String> =
server.await.unwrap()[1..7].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
server.await.unwrap()[1..9].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
let schema = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
assert_eq!(sent, ["use role auditor", schema, schema, "use role analyst", schema, "select 3"]);
assert_eq!(
sent,
["use role auditor", schema, "select 1", "select 2", "use role nobody", "use role analyst", schema, "select 3"]
);
}
#[tokio::test]
@@ -836,45 +785,6 @@ async fn databases_of_one_data_source_share_a_login() {
assert!(seen[4].line().starts_with("POST /session?delete=true"), "{}", seen[4].line());
}
#[tokio::test]
async fn data_sources_with_the_same_settings_have_their_own_sessions() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
let first = Pool::connect(&source(port), None, None).await.unwrap();
let mut copy = source(port);
copy.id = format!("{}-copy", copy.id);
let other = Pool::connect(&copy, None, None).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test]
async fn one_data_source_in_two_projects_has_a_session_in_each() {
let (listener, port) = bind().await;
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
let server = serve(listener, vec![ok(LOGIN_OK), ok(second), ok(one()), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
// A plugin's data source has the same id in every project.
let first = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/a"))).await.unwrap();
let other = Pool::connect(&source(port), None, Some(std::path::Path::new("/projects/b"))).await.unwrap();
let cancel = CancellationToken::new();
first.execute("select 1", 1, &cancel).await.unwrap();
other.execute("select 2", 1, &cancel).await.unwrap();
first.close().await;
other.close().await;
let seen = server.await.unwrap();
assert!(seen[1].line().starts_with("POST /session/v1/login-request?"), "{}", seen[1].line());
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"session-token-1\""));
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
}
#[tokio::test]
async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
let (listener, port) = bind().await;
-96
Переглянути файл
@@ -1,96 +0,0 @@
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
//! the SSH host reaching the database host and port, with no database login.
use ide_core::error::ErrorKind;
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
use ide_ssh::{SshFiles, SshManager};
use std::sync::Arc;
use tokio::net::TcpListener;
struct Env {
_tmp: tempfile::TempDir,
db: DbManager,
ssh_port: u16,
}
async fn env() -> Env {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
let ssh = Arc::new(SshManager::with_files(
root.join("ssh-secrets.json"),
SshFiles { known_hosts: root.join("known_hosts"), config: None },
));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
Env { _tmp: tmp, db, ssh_port }
}
impl Env {
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
fn source(&self, db_port: u16) -> DataSource {
DataSource {
id: "pg".into(),
name: "pg".into(),
driver: DbDriver::Postgres,
host: "127.0.0.1".into(),
port: Some(db_port),
user: "app".into(),
ssh: Some(DbSsh {
enabled: true,
host: "127.0.0.1".into(),
port: Some(self.ssh_port),
user: "dev".into(),
auth: DbSshAuth::Password,
..Default::default()
}),
..Default::default()
}
}
}
/// A port something listens on (and never speaks a database protocol).
async fn listening() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_tunnel_is_tested_without_a_database_login() {
let env = env().await;
let (_db, db_port) = listening().await;
let source = env.source(db_port);
// First contact: the host key has to be trusted, as with Test Connection.
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
env.db.trust_ssh_host("pg").await.unwrap();
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
let env = env().await;
let (listener, closed) = listening().await;
drop(listener);
let source = env.source(closed);
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
env.db.trust_ssh_host("pg").await.unwrap();
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
}
#[tokio::test]
async fn a_source_without_a_tunnel_has_nothing_to_test() {
let env = env().await;
let mut source = env.source(5432);
source.ssh.as_mut().unwrap().enabled = false;
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
}
+1 -7
Переглянути файл
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.8.0";
pub const ENGINE_VERSION: &str = "1.7.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
@@ -166,7 +166,6 @@ pub const EVENT_KINDS: &[&str] = &[
"editor-focused",
"action-performed",
"vcs-changed",
"problems-changed",
];
impl std::fmt::Display for ActivationEvent {
@@ -373,11 +372,6 @@ impl Manifest {
{
self.needs_api(&format!("`{event}`"), 6)?;
}
// Added after 1.7.0 shipped: a 1.7 host rejects the manifest, so the gate is the minor
// that carries it.
if matches!(event, ActivationEvent::Event(kind) if kind == "problems-changed") {
self.needs_api(&format!("`{event}`"), 8)?;
}
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
-78
Переглянути файл
@@ -1,78 +0,0 @@
//! Host-owned diagnostics snapshot: every `Diagnostics` event lands here, whoever
//! emitted it and whatever window it was addressed to. Plugins read this instead of
//! the UI-reported Problems reports, so what a plugin sees no longer depends on
//! window lifetime, the invoking window's project, or the report loop's liveness.
use ide_core::{Diagnostic, EventSink, IdeEvent, SharedSink};
use parking_lot::RwLock;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Default)]
pub struct DiagnosticsStore(RwLock<BTreeMap<PathBuf, BTreeMap<String, Vec<Diagnostic>>>>);
impl DiagnosticsStore {
/// Full replacement for one (file, producer), like the event itself.
pub fn replace(&self, path: &PathBuf, source: &str, items: Vec<Diagnostic>) {
let mut map = self.0.write();
let sources = map.entry(path.clone()).or_default();
if items.is_empty() {
sources.remove(source);
if sources.is_empty() {
map.remove(path);
}
} else {
sources.insert(source.to_owned(), items);
}
}
/// Every file's diagnostics as one entry per producer.
pub fn problems(&self) -> Vec<(PathBuf, String, Vec<Diagnostic>)> {
let map = self.0.read();
map.iter()
.flat_map(|(path, sources)| {
sources.iter().map(move |(source, items)| (path.clone(), source.clone(), items.clone()))
})
.collect()
}
pub fn file(&self, path: &PathBuf) -> BTreeMap<String, Vec<Diagnostic>> {
self.0.read().get(path).cloned().unwrap_or_default()
}
/// Drops the files under `root` (their project closed; the producers will re-report
/// on open). Returns the number of files removed.
pub fn forget_root(&self, root: &PathBuf) -> usize {
let mut map = self.0.write();
let before = map.len();
map.retain(|path, _| !path.starts_with(root));
before - map.len()
}
}
/// Records `Diagnostics` events, then delivers them as usual.
pub struct RecordingSink {
pub inner: SharedSink,
pub store: Arc<DiagnosticsStore>,
}
impl EventSink for RecordingSink {
fn emit(&self, event: IdeEvent) {
if let IdeEvent::Diagnostics { path, source, items } = &event {
self.store.replace(&path.0.clone(), source, items.clone());
}
self.inner.emit(event);
}
}
/// A sink whose diagnostics are recorded; the store outlives every project.
pub type RecordSinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
/// Wraps a sink factory so every sink it creates records diagnostics.
pub fn recording_sink_factory(store: Arc<DiagnosticsStore>, inner: crate::SinkFactory) -> crate::SinkFactory {
Box::new(move |window| {
let inner = inner(window);
Arc::new(RecordingSink { inner, store: store.clone() }) as SharedSink
})
}
-18
Переглянути файл
@@ -5,7 +5,6 @@
pub mod agent_secrets;
pub mod data_dirs;
pub mod diagnostics_store;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -59,14 +58,8 @@ pub struct HostConfig {
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
/// Every `Diagnostics` event the host ever emitted, per file and producer:
/// the snapshot plugins read (`problems`), independent of windows and reports.
pub diagnostics: Arc<diagnostics_store::DiagnosticsStore>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
/// Commands agents run in the Run tool window (`run_command`).
pub mcp_agent_runs: mcp::AgentRuns,
pub(crate) agent_catalogs: Arc<ide_agent::AgentCatalogs>,
pub trust: trust::TrustStore,
pub(crate) mcp_approvals: Arc<trust::McpServerApprovals>,
pub config_dir: PathBuf,
@@ -140,20 +133,14 @@ impl Host {
runtime,
project_environment,
} = config;
// Every sink the host hands out records diagnostics into the shared store first.
let diagnostics: Arc<diagnostics_store::DiagnosticsStore> = Arc::default();
let sink_for: SinkFactory = diagnostics_store::recording_sink_factory(diagnostics.clone(), sink_for);
let global_settings = SettingsStore::load(&config_dir, None);
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: RwLock::default(),
mcp_executable: RwLock::default(),
diagnostics,
mcp_reports: Default::default(),
mcp_requests: Default::default(),
mcp_agent_runs: Default::default(),
agent_catalogs: Arc::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
mcp_approvals: Arc::new(trust::McpServerApprovals::load(&data_dir)),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
@@ -190,7 +177,6 @@ impl Host {
open_locks: Default::default(),
};
host.apply_proxy();
ops::agent::move_user_mcp_values(&host);
host
}
@@ -283,7 +269,6 @@ impl Host {
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.mcp_reports.forget_window(window);
self.mcp_requests.forget_window(window);
self.mcp_agent_runs.forget_window(window);
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
@@ -298,9 +283,6 @@ impl Host {
}
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
// The producers will re-report when a project reopens; until then the snapshot
// must not answer for files whose project is gone.
self.diagnostics.forget_root(&project.root);
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
-176
Переглянути файл
@@ -1,176 +0,0 @@
//! `check_files`: fresh diagnostics from the language servers right after an edit, and why a
//! file could not be checked (no server for it, not installed, crashed, misconfigured…).
use super::editors::problem;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult, Severity};
use ide_lsp::DocumentCheck;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 30_000;
const MAX_FILES: usize = 20;
pub fn check_files() -> Tool {
Tool::new(
"check_files",
&format!(
"Checks files with the language servers now and returns their problems: call it after changing files \
(with any tool, inside or outside the IDE). Each file is synced from disk and saved to its servers, so checks that \
run on save (rust-analyzer's cargo check) run too; then the call waits until every server answered for the current \
text and finished the work it started, up to `timeout` milliseconds (default {DEFAULT_TIMEOUT_MS}) for all files. \
Unlike get_file_problems, this is a fresh analysis, before the inspection profile's filtering.
Per file, `status` says what the problems are worth:
- checked: every server answered; `problems` is complete (empty means no problems).
- incomplete: some server was still analysing when the wait ended, or could not check; call again or raise `timeout`.
- serverUnavailable: no server could check. `servers` gives each one's `status` (missing: not installed; \
misconfigured; crashed; starting: did not finish starting) and `message`; tell the user, since the IDE has to be \
fixed (Settings › Languages & Frameworks › Language Servers), and check another way meanwhile (a build or a compiler).
- noLanguageServer: no language server is configured for this kind of file.
- untrusted: language servers don't run in an untrusted project.
- failed: the file could not be opened (`error`).
`unsavedInEditor` is true when the file has unsaved changes in the IDE: the problems are for the editor's text, not \
the file on disk. Lines and UTF-16 columns are 1-based."
),
|call| async move {
let raw = call.str_list("filePaths")?;
if raw.is_empty() {
return Err(IdeError::invalid("filePaths must name at least one file"));
}
if raw.len() > MAX_FILES {
return Err(IdeError::invalid(format!("at most {MAX_FILES} files at a time")));
}
let errors_only = call.flag("errorsOnly", true)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let mut paths = Vec::new();
for p in raw {
paths.push(call.resolve_readable(&p).await?);
}
let checks = paths.into_iter().map(|path| check_one(&call, path, limit, errors_only));
let files = futures::future::join_all(checks).await;
Output::json(json!({ "files": files }))
},
)
.param(Param::required("filePaths", ParamType::StringList, "Files to check, relative to the project root"))
.param(Param::optional(
"errorsOnly",
ParamType::Boolean,
"Whether to return only errors (default) or warnings and hints too",
))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the servers at most"))
}
async fn check_one(call: &Call, path: PathBuf, limit: Duration, errors_only: bool) -> Value {
let mut out = json!({ "filePath": call.display(&path) });
if !call.project().is_trusted() {
out["status"] = "untrusted".into();
return out;
}
match check(call, &path, limit).await {
Ok((check, dirty, text)) => {
let servers: Vec<Value> = check
.servers
.iter()
.map(|s| {
let mut v = json!({ "name": s.name, "status": s.status, "checked": s.checked });
if let Some(m) = &s.message {
v["message"] = m.clone().into();
}
if s.incomplete {
v["incomplete"] = true.into();
}
if let Some(e) = &s.error {
v["error"] = e.clone().into();
}
v
})
.collect();
out["status"] = file_status(&check).into();
out["problems"] = check
.diagnostics
.into_iter()
.filter(|d| !errors_only || d.severity == Severity::Error)
.map(|d| problem(d, &text))
.collect::<Vec<_>>()
.into();
out["servers"] = servers.into();
if dirty {
out["unsavedInEditor"] = true.into();
}
}
Err(e) => {
out["status"] = "failed".into();
out["error"] = e.message.into();
}
}
out
}
fn file_status(check: &DocumentCheck) -> &'static str {
if check.servers.is_empty() {
"noLanguageServer"
} else if !check.servers.iter().any(|s| s.checked) {
"serverUnavailable"
} else if check.servers.iter().all(|s| s.checked && !s.incomplete) {
"checked"
} else {
"incomplete"
}
}
/// The check of `path`, whether it has unsaved changes in the IDE, and its text.
async fn check(call: &Call, path: &std::path::Path, limit: Duration) -> IdeResult<(DocumentCheck, bool, String)> {
let project = call.project().clone();
let docs = project.documents.clone();
let p = path.to_owned();
let at = Instant::now();
// Synced now: the file watcher's reload of a change made outside the IDE may not have run.
let (doc, changed, dirty) = call
.host()
.blocking(move || {
let before = docs.by_path(&p).and_then(|d| docs.version(d).ok().map(|v| (d, v)));
docs.on_disk_changed(std::slice::from_ref(&p));
let doc = docs.open(&p)?.id;
let changed = before.is_none_or(|(d, v)| d != doc || docs.version(d).is_ok_and(|now| now != v));
Ok((doc, changed, docs.is_dirty(doc).unwrap_or(false)))
})
.await?;
let result = project.lsp.check_document(doc, changed.then_some(at), limit, &call.cancel).await;
let text = project.documents.text_snapshot(doc).map(|t| t.text).unwrap_or_default();
let docs = project.documents.clone();
let _ = call
.host()
.blocking(move || {
docs.close(doc);
Ok(())
})
.await;
Ok((result?, dirty, text))
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::event::ServerStatus;
use ide_lsp::CheckedServer;
fn server(status: ServerStatus, checked: bool, incomplete: bool) -> CheckedServer {
CheckedServer { name: "s".into(), status, message: None, checked, incomplete, error: None }
}
#[test]
fn statuses_say_whether_the_problems_are_complete() {
let check = |servers| file_status(&DocumentCheck { diagnostics: Vec::new(), servers });
assert_eq!(check(vec![]), "noLanguageServer");
assert_eq!(check(vec![server(ServerStatus::Missing, false, false)]), "serverUnavailable");
assert_eq!(check(vec![server(ServerStatus::Ready, true, false)]), "checked");
assert_eq!(check(vec![server(ServerStatus::Busy, true, true)]), "incomplete");
assert_eq!(
check(vec![server(ServerStatus::Ready, true, false), server(ServerStatus::Crashed, false, false)]),
"incomplete"
);
}
}
+3 -3
Переглянути файл
@@ -87,7 +87,7 @@ async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, ove
return Ok(());
}
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&call.host, call.window(), edit).await
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await
}
/// The open document of `path`, or a transient one (`true`: close it after use).
@@ -317,7 +317,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
preconditions: Vec::new(),
};
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await
}
FileOp::Update { path: first, move_to, hunks, or } => {
// A plain diff's other names count when its `+++` path does not exist.
@@ -400,7 +400,7 @@ async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
}
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_agent_workspace_edit(&host, call.window(), edit).await?;
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?;
if dirty {
notes.push(format!("{shown}: patched the unsaved editor buffer"));
}
+1 -1
Переглянути файл
@@ -385,7 +385,7 @@ targetIndex, or narrow the call with a contextSnippet that shows the one you mea
_ => None,
});
let (h, w) = (host.clone(), window.to_owned());
let written = crate::ops::lsp::apply_agent_workspace_edit(&h, &w, edit).await;
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await;
if let Err(e) = written {
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
+1 -1
Переглянути файл
@@ -65,7 +65,7 @@ async fn current_file(call: Call) -> IdeResult<Output> {
})
}
pub(super) fn problem(d: Diagnostic, text: &str) -> Value {
fn problem(d: Diagnostic, text: &str) -> Value {
json!({
"severity": d.severity, "description": d.message,
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
+9 -34
Переглянути файл
@@ -30,14 +30,9 @@ fn read_file() -> Tool {
"Reads a file in the project directory or from any project dependency or other project source root.
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
Returns numbered lines (1-indexed) as text.
By default, reads up to 2000 lines starting from the beginning of the file; a longer file keeps its first and last
lines with a `…N lines truncated…` marker between them.
With `offset` or `limit`, returns exactly lines `offset` to `offset + limit - 1` (fewer at the end of the file),
followed by `…N more lines; read on with offset=M…` when the file goes on.
By default, reads up to 2000 lines starting from the beginning of the file.
The maximum accepted limit is 5000 lines.",
|call| async move {
// An explicit range is read as asked; the default read of a whole file keeps its head and tail.
let window = call.opt_i64("offset")?.is_some() || call.opt_i64("limit")?.is_some();
let offset = positive(&call, "offset", 1)?;
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
if limit > MAX_READ_LIMIT {
@@ -55,7 +50,7 @@ The maximum accepted limit is 5000 lines.",
)));
}
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
Ok(Output::text(render_slice(&text, offset as usize, limit as usize, window)?))
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
},
)
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
@@ -75,12 +70,10 @@ fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
}
}
/// Lines `start..` of `text` as `L<n>: <line>`, at most `max_lines` of them. A `window` is
/// exactly the lines from `start` on, then how many lines remain; otherwise, when more than
/// `max_lines` remain, the first and the last halves of the budget with a `…N lines truncated…`
/// marker between them.
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
/// Lines are the editor's: a trailing line break starts an empty last line.
fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> IdeResult<String> {
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
let count = lines.len();
if start > count {
@@ -93,15 +86,6 @@ fn render_slice(text: &str, start: usize, max_lines: usize, window: bool) -> Ide
if available <= max_lines {
return Ok(range(start, count));
}
if window {
let end = start + max_lines - 1;
return Ok(format!(
"{}\n\u{2026}{} more lines; read on with offset={}\u{2026}",
range(start, end),
count - end,
end + 1
));
}
let head = max_lines / 2;
let tail = max_lines - head;
let head_end = start + head - 1;
@@ -261,7 +245,7 @@ mod tests {
use super::*;
fn slice(text: &str, start: usize, max: usize) -> String {
render_slice(text, start, max, false).unwrap()
render_slice(text, start, max).unwrap()
}
#[test]
@@ -270,27 +254,18 @@ mod tests {
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
assert_eq!(slice("", 1, 10), "L1: ");
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
assert!(render_slice("", 2, 10, true).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10, true).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
}
#[test]
fn keeps_head_and_tail_of_a_long_default_read() {
fn keeps_head_and_tail_of_long_slices() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
}
#[test]
fn reads_an_explicit_range_exactly_and_says_where_to_read_on() {
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
let window = |start, max| render_slice(&text, start, max, true).unwrap();
assert_eq!(window(5, 3), "L5: line 5\nL6: line 6\nL7: line 7\n…4 more lines; read on with offset=8…");
assert_eq!(window(1, 1), "L1: line 1\n…10 more lines; read on with offset=2…");
assert_eq!(window(10, 3), "L10: line 10\nL11: line 11", "the end of the file needs no marker");
}
#[test]
fn cuts_long_lines_at_utf16_units() {
let long = "x".repeat(2100);
+1 -4
Переглянути файл
@@ -1,9 +1,8 @@
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
mod check;
mod edit;
mod editors;
mod requests;
pub(crate) mod run;
mod run;
mod vcs;
pub use requests::Requests;
mod files;
@@ -17,7 +16,6 @@ use crate::Host;
use ide_core::{CancellationToken, IdeError};
use ide_mcp::{ToolCallError, ToolRegistry};
pub use reports::{FileProblems, FocusedEditor, Reports};
pub use run::agent::{AGENT_PARAM, AgentRun, AgentRuns, NAME_PREFIX, SHELL_PARAM};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -32,7 +30,6 @@ fn tools() -> Vec<tool::Tool> {
let mut tools = files::tools();
tools.extend(search::tools());
tools.extend(editors::tools());
tools.push(check::check_files());
tools.push(symbols::get_symbol_info());
tools.push(symbols::find_references());
tools.extend(edit::tools());
File diff suppressed because it is too large. Load diff
-418
Переглянути файл
@@ -1,418 +0,0 @@
//! `run_command` and the tools that follow its tab up: `wait_for_run`, `read_run_output`,
//! `get_run_status`, `send_run_input`, `stop_run` and `list_runs` (see [`super::agent`]).
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
use super::output::RUN_PREVIEW_MAX_LENGTH;
use crate::mcp::run::timeout;
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
use ide_core::{IdeError, IdeResult};
use serde_json::{Map, Value};
use std::sync::Arc;
const RUN_TIMEOUT_MS: usize = 10_000;
const WAIT_TIMEOUT_MS: usize = 30_000;
const STOP_TIMEOUT_MS: usize = 10_000;
/// Lines of output returned at most, by default (as JetBrains' `execute_terminal_command`).
const DEFAULT_MAX_LINES: usize = 2000;
/// Bytes of the log read for one result at most.
const READ_CAP: usize = 1 << 20;
/// Lines of output quoted when a name is still running.
const BUSY_TAIL_LINES: usize = 20;
const NAME: &str = "Tab name. OpenIDEA shows the tab as `_agent_<name>`; passing the prefixed name works too.";
const SEQUENCE: &str = "\n\nUsing the same name for every command of one task reuses its tab: a name whose command \
has finished starts the next command in the same tab. A name whose command is still running is refused (with that \
command and its last output); stop it with `stop_run` first or use another name. Nothing is stopped implicitly.";
const INTERVENTIONS: &str = "\n\nThe user sees every tab and can type into it, rerun or stop its command, or close \
it. Results then carry `interventions` (for example `User input: …` or `The user reran this command at …`) and \
`advice`: unless the user asked otherwise, stop what you are doing, tell the user you received the intervention and \
wait for their confirmation before you continue.";
const OUTPUT: &str = "\n\nThe command runs on pipes, not a terminal, so programs print plain lines (no progress \
bars). `output` is that text cleaned of escape sequences, at most `maxLinesCount` lines (default 2000): beyond it, \
`truncateMode` START (default) keeps the end, END the start, MIDDLE both ends, NONE everything, with a \
`<<<...content truncated...>>>` line where lines were left out and `truncatedLines` saying how many. `outputStart` \
and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as `offset` to read on.";
const MAX_LINES: &str = "Lines of output to return at most (default 2000)";
const TRUNCATE_MODE: &str = "Which lines to leave out beyond maxLinesCount: START (default, keeps the end), END \
(keeps the start), MIDDLE (keeps both ends) or NONE";
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
let name = tab_name(&call.str("name")?)?;
call.host()
.mcp_agent_runs
.get(call.root(), &name)
.ok_or_else(|| IdeError::not_found(format!("no agent tab named `{name}` (see list_runs)")))
}
/// `status` of `run`, with interventions not told yet.
fn result(run: &AgentRun, extra: Map<String, Value>) -> IdeResult<Output> {
let mut out = run.status();
out.extend(extra);
let interventions = run.take_interventions();
if !interventions.is_empty() {
out.insert("interventions".into(), interventions.into());
out.insert("advice".into(), INTERVENTION_ADVICE.into());
}
Output::json(Value::Object(out))
}
/// The output from `from` as the agent reads it: cleaned, then cut to `maxLinesCount` lines
/// as `truncateMode` says.
fn output_view(call: &Call, run: &AgentRun, from: u64) -> IdeResult<Map<String, Value>> {
let max_lines = match call.opt_i64("maxLinesCount")? {
Some(n) if n < 1 => return Err(IdeError::invalid("maxLinesCount must be > 0")),
Some(n) => n as usize,
None => DEFAULT_MAX_LINES,
};
let mode = match call.opt_str("truncateMode")? {
Some(name) => TruncateMode::parse(&name)
.ok_or_else(|| IdeError::invalid("truncateMode must be START, MIDDLE, END or NONE"))?,
None => TruncateMode::Start,
};
let len = run.log_len();
let from = from.min(len);
// A long output is read from the end that stays.
let start = if mode == TruncateMode::Start && len - from > READ_CAP as u64 { len - READ_CAP as u64 } else { from };
let (raw, start, end) = run.read(start, READ_CAP)?;
let (text, truncated) = truncate_lines(&clean_output(&raw), max_lines, mode);
let mut out = Map::new();
out.insert("output".into(), text.into());
if truncated > 0 {
out.insert("truncatedLines".into(), truncated.into());
}
out.insert("outputStart".into(), start.into());
out.insert("nextOffset".into(), end.into());
Ok(out)
}
fn deadline(call: &Call, default: usize) -> IdeResult<tokio::time::Instant> {
Ok(tokio::time::Instant::now() + timeout(call, default)?)
}
fn pattern(call: &Call) -> IdeResult<Option<regex_lite::Regex>> {
call.opt_str("pattern")?
.filter(|p| !p.is_empty())
.map(|p| regex_lite::Regex::new(&p).map_err(|e| IdeError::invalid(format!("invalid pattern: {e}"))))
.transpose()
}
fn offset(call: &Call, name: &str) -> IdeResult<Option<u64>> {
match call.opt_i64(name)? {
Some(n) if n < 0 => Err(IdeError::invalid(format!("{name} must not be negative"))),
n => Ok(n.map(|n| n as u64)),
}
}
fn busy(run: &AgentRun) -> IdeError {
let tail = run.tail(run.process_offset(), BUSY_TAIL_LINES, RUN_PREVIEW_MAX_LENGTH).map(|t| t.0).unwrap_or_default();
IdeError::invalid(format!(
"`{}` is still running `{}`. Stop it with stop_run first, or use another name.\nLast output:\n{}",
run.name,
run.command(),
clean_output(&tail).trim_end()
))
}
pub fn run_command() -> Tool {
Tool::new(
"run_command",
&format!(
"Runs a shell command line in a new or reused tab of OpenIDEA's Run tool window, with the shell of \
Settings › Tools › Terminal. The user is asked first (unless the IDE runs tools without \
confirmation). Waits up to `timeout` milliseconds (default {RUN_TIMEOUT_MS}) for the command to finish; a command \
still running then keeps running, and `wait_for_run`, `read_run_output`, `get_run_status`, `send_run_input` and \
`stop_run` follow it up by name. Returns the tab's status (`state`: starting, running, stopping, succeeded, failed, \
crashed, stoppedByAgent, stoppedByUser or startFailed; `exitCode`; `crash` with the signal or exception of a crashed \
process) and its output so far.{OUTPUT}{SEQUENCE}{INTERVENTIONS}"
),
|call| async move {
let name = tab_name(&call.str("name")?)?;
let command = call.str("command")?;
if command.trim().is_empty() {
return Err(IdeError::invalid("command must not be empty"));
}
let cwd = match call.opt_str("workingDirectory")?.filter(|d| !d.trim().is_empty()) {
Some(dir) => {
let path = call.resolve_inside(&dir)?;
if !path.is_dir() {
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", dir)));
}
path
}
None => call.root().to_path_buf(),
};
let env = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let deadline = deadline(&call, RUN_TIMEOUT_MS)?;
let host = call.host();
let runs = &host.mcp_agent_runs;
if let Some(run) = runs.get(call.root(), &name).filter(|r| r.running()) {
return Err(busy(&run));
}
let shown_cwd = call.display(&cwd);
let mut detail = format!(
"{command}\n\nTab: {name}\nWorking directory: {}",
if shown_cwd.is_empty() { ".".to_owned() } else { shown_cwd.clone() }
);
for (key, value) in env.iter().flatten() {
detail.push_str(&format!("\n{key}={value}"));
}
call.confirm(&name, Some(detail)).await?;
let run = runs.prepare(call.root(), call.window(), &name, &command, &shown_cwd).map_err(|r| busy(&r))?;
let request = ide_core::event::McpUiRequest::AgentRun {
name: name.clone(),
command: command.clone(),
cwd: cwd.into(),
env: env.unwrap_or_default().into_iter().collect(),
};
if let Err(e) = call.ask(request).await {
let error = run.start_error();
runs.abandon(&run);
return Err(match error {
Some(error) => IdeError::invalid(format!("`{name}` did not start: {error}")),
None => e,
});
}
if let Some(error) = run.start_error() {
return Err(IdeError::invalid(format!("`{name}` did not start: {error}")));
}
let from = run.process_offset();
let end = run.wait(deadline, None, from, &call.cancel).await?;
let mut extra = output_view(&call, &run, from)?;
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::required(
"command",
ParamType::String,
"Shell command line, e.g. `npm test -- --watch=false` or `cargo build && ./target/debug/app`",
))
.param(Param::optional(
"workingDirectory",
ParamType::String,
"Directory inside the project to run in, relative to the project root; default: the project root",
))
.param(Param::optional("envs", ParamType::StringMap, "Environment variables added for this command"))
.param(Param::optional(
"timeout",
ParamType::Integer,
"Milliseconds to wait for the command to finish before returning (0 returns once it started); the command \
keeps running after it",
))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn wait_for_run() -> Tool {
Tool::new(
"wait_for_run",
&format!(
"Waits until the command of an agent tab finishes, its output matches `pattern`, the user intervenes, or \
`timeout` milliseconds pass (default {WAIT_TIMEOUT_MS}). Cancelling the wait leaves the command running. Returns \
`waitEnded` (exited, matched, userIntervened or timedOut), the tab's status and the output since `offset`. Use \
`pattern` to wait for a server to be ready, e.g. `Listening on|Started .* in`.{OUTPUT}{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
let deadline = deadline(&call, WAIT_TIMEOUT_MS)?;
let pattern = pattern(&call)?;
let from = offset(&call, "offset")?.unwrap_or_else(|| run.process_offset());
let end = run.wait(deadline, pattern.as_ref(), from, &call.cancel).await?;
let mut extra = output_view(&call, &run, from)?;
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait at most"))
.param(Param::optional(
"pattern",
ParamType::String,
"Regular expression to wait for in the output after `offset` (as the command wrote it)",
))
.param(Param::optional(
"offset",
ParamType::Integer,
"Where in the tab's output log to start: a `nextOffset` from an earlier result. Default: where the current \
command's output starts",
))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn read_run_output() -> Tool {
Tool::new(
"read_run_output",
&format!(
"Reads the output log of an agent tab, which goes on across the commands and reruns of the tab: from \
`offset` (a `nextOffset` from an earlier result), or from its start. Also returns the tab's status.\
{OUTPUT}{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
let from = offset(&call, "offset")?.unwrap_or(0);
let extra = output_view(&call, &run, from)?;
result(&run, extra)
},
)
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("offset", ParamType::Integer, "Where in the log to read from"))
.param(Param::optional("maxLinesCount", ParamType::Integer, MAX_LINES))
.param(Param::optional("truncateMode", ParamType::String, TRUNCATE_MODE))
}
pub fn get_run_status() -> Tool {
Tool::new(
"get_run_status",
&format!(
"Returns the status of an agent tab: its command, `state` (starting, running, stopping, succeeded, \
failed, crashed, stoppedByAgent, stoppedByUser or startFailed), `startedBy` (agent, or user when the user reran \
it), times, `exitCode`, `crash`, `outputLength`, `lastOutputAt` (a running command silent for long may be waiting \
for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
),
|call| async move {
let run = find(&call)?;
result(&run, Map::new())
},
)
.param(Param::required("name", ParamType::String, NAME))
}
pub fn list_runs() -> Tool {
Tool::new(
"list_runs",
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|call| async move {
let runs: Vec<Value> =
call.host().mcp_agent_runs.list(call.root()).iter().map(|r| Value::Object(r.status())).collect();
Output::json(serde_json::json!({ "runs": runs }))
},
)
}
/// What a key does to a command on pipes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Key {
/// Writes this text to its input.
Text(&'static str),
/// Ctrl+C: stops it, as `stop_run` does.
Interrupt,
/// Ctrl+D: ends its input.
EndOfInput,
}
fn key(name: &str) -> IdeResult<Key> {
Ok(match name.trim().to_ascii_lowercase().replace(' ', "").as_str() {
"enter" | "return" => Key::Text("\n"),
"tab" => Key::Text("\t"),
"ctrl+c" => Key::Interrupt,
"ctrl+d" => Key::EndOfInput,
"escape" | "esc" | "backspace" | "ctrl+z" | "ctrl+\\" | "ctrl+l" | "up" | "down" | "left" | "right" => {
return Err(IdeError::invalid(format!(
"`{name}` is a terminal key, and the command runs on pipes, not a terminal: send text and Enter, \
Ctrl+C to stop it or Ctrl+D to end its input"
)));
}
_ => return Err(IdeError::invalid(format!("unknown key `{name}`"))),
})
}
pub fn send_run_input() -> Tool {
Tool::new(
"send_run_input",
"Writes to the input of the running command of an agent tab, which runs on pipes: `text` as it is, then \
`keys` in order: Enter (a line break), Tab, Ctrl+C (stops the command, as `stop_run` does) and Ctrl+D (ends its \
input). Terminal keys (arrows, Escape, Backspace, Ctrl+Z…) are refused. To answer a prompt, pass the answer as \
`text` and `keys: [\"Enter\"]`. The user sees the command's output in the tab. Returns the tab's status.",
|call| async move {
let run = find(&call)?;
let text = call.opt_str("text")?.unwrap_or_default();
let keys = call.str_list("keys")?.iter().map(|k| key(k)).collect::<IdeResult<Vec<_>>>()?;
if text.is_empty() && keys.is_empty() {
return Err(IdeError::invalid("pass `text`, `keys` or both"));
}
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
call.project().require_trust()?;
let host = call.host();
let mut data = text;
for key in keys {
match key {
Key::Text(text) => data.push_str(text),
Key::EndOfInput | Key::Interrupt => {
if !data.is_empty() {
host.processes.write_stdin(id, &std::mem::take(&mut data))?;
}
if key == Key::EndOfInput {
crate::ops::run::close_stdin(&host, id)?;
} else {
run.agent_stopping();
crate::ops::run::stop(&host, &run.window(), id)?;
break;
}
}
}
}
if !data.is_empty() {
host.processes.write_stdin(id, &data)?;
}
result(&run, Map::new())
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("text", ParamType::String, "Text to type"))
.param(Param::optional("keys", ParamType::StringList, "Keys to press after `text`"))
}
pub fn stop_run() -> Tool {
Tool::new(
"stop_run",
&format!(
"Stops the running command of an agent tab, the way the Run tool window's Stop does: it asks the \
process tree to exit and, with `force`, kills it. Waits up to `timeout` milliseconds (default {STOP_TIMEOUT_MS}) \
for it to end and returns the tab's status (`stoppedByAgent` once it ended). A process that ignores the request \
keeps `stopping`: call again with `force`. The tab stays open with its output."
),
|call| async move {
let run = find(&call)?;
let id = run.live_process().ok_or_else(|| IdeError::invalid(format!("`{}` is not running", run.name)))?;
let force = call.flag("force", false)?;
let deadline = deadline(&call, STOP_TIMEOUT_MS)?;
let host = call.host();
run.agent_stopping();
crate::ops::run::stop(&host, &run.window(), id)?;
if force {
// A second Stop kills the tree.
crate::ops::run::stop(&host, &run.window(), id)?;
}
let end = run.wait(deadline, None, run.log_len(), &call.cancel).await?;
let mut extra = Map::new();
extra.insert("waitEnded".into(), end.as_str().into());
result(&run, extra)
},
)
.write()
.param(Param::required("name", ParamType::String, NAME))
.param(Param::optional("force", ParamType::Boolean, "Kill the process tree instead of asking it to exit"))
.param(Param::optional("timeout", ParamType::Integer, "Milliseconds to wait for the command to end"))
}
pub fn tools() -> Vec<Tool> {
vec![run_command(), wait_for_run(), read_run_output(), get_run_status(), send_run_input(), stop_run(), list_runs()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_what_pipes_can_take() {
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
assert_eq!(key("Ctrl+C").unwrap(), Key::Interrupt);
assert_eq!(key("ctrl+d").unwrap(), Key::EndOfInput);
assert!(key("Up").unwrap_err().message.contains("runs on pipes"));
assert!(key("F13").unwrap_err().message.contains("unknown key"));
}
}
+3 -2
Переглянути файл
@@ -15,6 +15,7 @@ use std::sync::Arc;
use std::time::Instant;
const DEFAULT_TIMEOUT_MS: usize = 60_000;
const MAX_TIMEOUT_MS: usize = 3_600_000;
const MAX_PROBLEMS: usize = 300;
/// Console lines attached to a failure whose messages were not parsed.
const CONSOLE_LINES: usize = 200;
@@ -83,13 +84,13 @@ You have to use this tool after performing edits to validate if the edits are va
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
modules containing those files. Asks the user first for every call. Problems have the project-relative \
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
is an ERROR. On timeout (0 times out at once) the build is stopped.",
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|call| async move {
let files: Vec<String> =
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
let rebuild = call.flag("rebuild", false)? && files.is_empty();
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
let limit = timeout(&call, DEFAULT_TIMEOUT_MS)?;
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
let mut targets: Vec<Option<PathBuf>> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
for f in &files {
+3 -1
Переглянути файл
@@ -19,6 +19,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT_MS: usize = 10_000;
/// No limit in JetBrains; the process keeps running after the timeout anyway.
const MAX_TIMEOUT_MS: usize = 1_800_000;
/// Output of a failed before-launch process kept for the error message.
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
/// A before-launch build gets this long.
@@ -539,7 +541,7 @@ tool window; output files are private and deleted after a day.",
)?;
let wait = call.flag("waitForExit", true)?;
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS)?) } else { None };
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
let configs = load_configs(&call).await?;
let mut config = match &target {
+5 -14
Переглянути файл
@@ -1,7 +1,5 @@
//! Run configurations, builds and agent commands; every start asks the user first.
//! Run configurations and builds; every call asks the user first.
pub(crate) mod agent;
mod agent_tools;
mod build;
mod configs;
mod output;
@@ -13,22 +11,15 @@ use ide_core::IdeResult;
use std::time::Duration;
pub fn tools() -> Vec<Tool> {
let mut tools =
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()];
tools.extend(agent_tools::tools());
tools
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
}
/// Longest wait: far enough to be no limit, near enough not to overflow a deadline.
const FOREVER_MS: u64 = 365 * 24 * 60 * 60 * 1000;
/// The `timeout` argument in milliseconds, `default` when absent: the agent decides how long
/// to wait (long waits are answered on an event stream, see `ide_mcp`). As in JetBrains
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
pub(crate) fn timeout(call: &Call, default: usize) -> IdeResult<Duration> {
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
Ok(match call.opt_i64("timeout")? {
None => Duration::from_millis(default as u64),
Some(n) if n <= 0 => Duration::ZERO,
Some(n) => Duration::from_millis((n as u64).min(FOREVER_MS)),
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
})
}
+2 -42
Переглянути файл
@@ -434,23 +434,10 @@ fn cwd_of(root: &Path, c: &RunConfig) -> String {
cwd.trim_end_matches('/').to_owned()
}
/// The directory a config covers: a Cargo workspace member's own directory (its `cargo test -p`
/// runs from the workspace root), else its working directory.
fn scope_of(root: &Path, c: &RunConfig) -> String {
match c.extras.module.as_deref() {
Some(module) if c.kind == "cargo" => {
let module = module.replace('\\', "/");
let module = module.strip_prefix("./").unwrap_or(&module);
if module == "." { String::new() } else { module.trim_end_matches('/').to_owned() }
}
_ => cwd_of(root, c),
}
}
/// Configs whose scope ([`scope_of`]) most specifically contains `rel` (ties in their original order).
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
let score = |c: &RunConfig| -> Option<usize> {
let cwd = scope_of(root, c);
let cwd = cwd_of(root, c);
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
};
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
@@ -935,33 +922,6 @@ mod tests {
assert_eq!(c.args, ["test", "--", "math::tests::"]);
}
#[test]
fn a_workspace_member_runs_its_own_tests() {
let root = Path::new("/p");
let member = |pkg: &str, dir: &str| {
let mut c = blank(
format!("cargo:test:{pkg}"),
"test",
"cargo",
"cargo".into(),
vec!["test".into(), "-p".into(), pkg.into()],
);
c.is_test = true;
c.extras.module = Some(dir.into());
c
};
let mut workspace = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
workspace.is_test = true;
let configs = vec![member("ide-agent", "crates/ide-agent"), member("ide-mcp", "crates/ide-mcp"), workspace];
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
let rel = "crates/ide-mcp/src/stdio.rs";
let ts = detect(src, rel);
let c = target_config(root, &ts[1], rel, &configs, src).unwrap();
assert_eq!(c.args, ["test", "-p", "ide-mcp", "--", "stdio::tests::adds", "--exact"]);
let c = target_config(root, &ts[1], "src/lib.rs", &configs, src).unwrap();
assert!(!c.args.iter().any(|a| a == "-p"), "outside every member: the workspace's tests, {:?}", c.args);
}
#[test]
fn cargo_test_args_get_one_separator() {
let root = Path::new("/p");
+2 -131
Переглянути файл
@@ -38,14 +38,6 @@ const LINE_WINDOW: usize = 500;
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
const CONTEXT_DESCRIPTION: &str = "Lines around each match to return besides the matched line (`lineText`): \"+N\" the \
N lines after it (`contextAfter`), \"-N\" the N lines before it (`contextBefore`), \"N\" (or the number N) N lines on \
each side. At most 50. Default: the matched line only.";
/// A matched line longer than this (UTF-16 units) is cut to the match and [`SNIPPET_AROUND`] units
/// on each side of it; a longer context line to its start.
const MAX_LINE_TEXT: usize = 300;
const SNIPPET_AROUND: usize = 100;
const MAX_CONTEXT_LINES: u32 = 50;
pub fn tools() -> Vec<Tool> {
vec![search_text(), search_regex(), search_symbol(), search_file()]
@@ -57,8 +49,6 @@ fn search_text() -> Tool {
"Searches for a text substring within project files.
Use this tool for fast text search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
@@ -67,7 +57,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Text to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -77,8 +66,6 @@ fn search_regex() -> Tool {
"Searches for regex matches within project files.
Use this tool when you need regex search with match coordinates.
Results include match coordinates when available (1-based line/column, end exclusive).
Each result has the matched line (`lineText`; a long line cut to the match and 100 characters on each side, marked
with …), and with `context` the lines around it.
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
Paths are glob patterns relative to the project root.
@@ -88,7 +75,6 @@ Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -98,7 +84,6 @@ fn search_symbol() -> Tool {
"Searches for symbols (classes, methods, fields).
Use this tool for semantic lookup by identifier fragments.
Results include match coordinates when available (1-based line/column, end exclusive).
Project results have their line (`lineText`), and with `context` the lines around it.
Paths are glob patterns relative to the project root.
By default this searches project symbols only.
@@ -114,7 +99,6 @@ If you don't find a suitable result, try again with include_external=true to sea
with include_external=true.",
))
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
.output(result_schema())
}
@@ -161,13 +145,6 @@ struct SearchItem {
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
container: Option<String>,
/// The text of `start_line` (cut when long).
#[serde(skip_serializing_if = "Option::is_none")]
line_text: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_before: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
context_after: Vec<String>,
}
impl SearchItem {
@@ -205,9 +182,7 @@ fn result_schema() -> Value {
"type": "object",
"properties": {
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
"name": string, "container": string, "lineText": string,
"contextBefore": { "type": "array", "items": string },
"contextAfter": { "type": "array", "items": string },
"name": string, "container": string,
},
"required": ["filePath"],
} },
@@ -232,87 +207,6 @@ fn query(call: &Call, empty: &str) -> IdeResult<String> {
Ok(q)
}
// --- matched lines -------------------------------------------------------------
/// The `context` argument as (lines before, lines after).
fn context(call: &Call) -> IdeResult<(u32, u32)> {
let invalid = || IdeError::invalid("context must be \"+N\", \"-N\" or N (lines after, before, or on each side)");
let (sign, n) = match call.args.get("context") {
None | Some(Value::Null) => return Ok((0, 0)),
Some(Value::Number(n)) => (None, n.as_u64().ok_or_else(invalid)?),
Some(Value::String(s)) => {
let s = s.trim();
match s.strip_prefix('+').map(|r| ('+', r)).or_else(|| s.strip_prefix('-').map(|r| ('-', r))) {
Some((sign, rest)) => (Some(sign), rest.trim().parse().map_err(|_| invalid())?),
None => (None, s.parse().map_err(|_| invalid())?),
}
}
Some(_) => return Err(invalid()),
};
let n = (n.min(MAX_CONTEXT_LINES as u64)) as u32;
Ok(match sign {
Some('+') => (0, n),
Some(_) => (n, 0),
None => (n, n),
})
}
/// `line`, or when longer than [`MAX_LINE_TEXT`] UTF-16 units, the part from [`SNIPPET_AROUND`]
/// units before 0-based UTF-16 column `start` to as many after `end`, with `…` where it was cut.
fn snippet(line: &str, start: usize, end: usize) -> String {
let units: usize = line.chars().map(char::len_utf16).sum();
if units <= MAX_LINE_TEXT {
return line.to_owned();
}
let from = start.saturating_sub(SNIPPET_AROUND);
let to = end.max(start).saturating_add(SNIPPET_AROUND).min(units);
let (mut out, mut at) = (String::new(), 0);
for c in line.chars() {
if at >= from && at < to {
out.push(c);
}
at += c.len_utf16();
}
let before = if from > 0 { "\u{2026}" } else { "" };
let after = if to < units { "\u{2026}" } else { "" };
format!("{before}{out}{after}")
}
/// A context line: its first [`MAX_LINE_TEXT`] UTF-16 units.
fn context_line(line: &str) -> String {
snippet(line, 0, MAX_LINE_TEXT.saturating_sub(SNIPPET_AROUND))
}
/// Fills `line_text` and the context of `items`, whose files are `absolute(item)` (none: left
/// as they are); each file is read once, with unsaved editor text.
async fn fill_lines(
call: &Call,
items: &mut [SearchItem],
absolute: impl Fn(&SearchItem) -> Option<PathBuf>,
(before, after): (u32, u32),
) {
let host = call.host();
let mut current: Option<(PathBuf, Option<Vec<String>>)> = None;
for item in items {
let (Some(path), Some(line)) = (absolute(item), item.start_line) else { continue };
if current.as_ref().is_none_or(|(p, _)| p != &path) {
let text = crate::ops::fs::read_text(&host, call.window(), path.clone()).await.ok();
let lines = text.map(|t| t.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).to_owned()).collect());
current = Some((path, lines));
}
let Some((_, Some(lines))) = &current else { continue };
let index = line as usize - 1;
let Some(text) = lines.get(index) else { continue };
let start = item.start_column.unwrap_or(1) as usize - 1;
let end = if item.end_line == Some(line) { item.end_column.map_or(start, |c| c as usize - 1) } else { start };
item.line_text = Some(snippet(text, start, end));
let first = index.saturating_sub(before as usize);
item.context_before = lines[first..index].iter().map(|l| context_line(l)).collect();
let last = (index + after as usize).min(lines.len() - 1);
item.context_after = lines[index + 1..=last].iter().map(|l| context_line(l)).collect();
}
}
// --- path globs ----------------------------------------------------------------
/// The `paths` argument: project-relative globs, `!` excludes.
@@ -522,7 +416,6 @@ struct TextHit {
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
let limit = limit(&call, DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
// Globs Find in Files cannot take are applied afterwards, so the engine must not
// stop at `limit` matches the filter may drop.
@@ -601,7 +494,7 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
let more = hits.len() > limit || summary.limit_hit || timed_out;
hits.truncate(limit);
extend_spanning(&call, &query, &mut hits, spanning).await;
let mut items: Vec<SearchItem> = hits
let items = hits
.into_iter()
.map(|h| SearchItem {
file_path: h.file_path,
@@ -612,9 +505,6 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
..Default::default()
})
.collect();
let absolute: std::collections::HashMap<&str, &Path> =
files.iter().map(|(path, file)| (path.as_str(), file.path.0.as_path())).collect();
fill_lines(&call, &mut items, |item| absolute.get(item.file_path.as_str()).map(|p| p.to_path_buf()), around).await;
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
Output::json(SearchResult { items, more, partial_result_reason })
}
@@ -836,7 +726,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
let started = Instant::now();
let q = query(&call, "Search query is empty")?;
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
let around = context(&call)?;
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
let external = call.flag("include_external", false)?;
let host = call.host();
@@ -886,13 +775,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
}
let more = items.len() > limit || timed_out;
items.truncate(limit);
// Project files (relative paths, or absolute ones of content roots); library URIs are left out.
let root = call.root().to_path_buf();
let absolute = |item: &SearchItem| {
let path = Path::new(&item.file_path);
(!item.file_path.contains("://")).then(|| if path.is_absolute() { path.to_path_buf() } else { root.join(path) })
};
fill_lines(&call, &mut items, absolute, around).await;
let indexing = indexing_before || project.index.status().indexing;
let partial_result_reason = if indexing {
Some(INDEXING_PARTIAL_RESULT_REASON)
@@ -987,17 +869,6 @@ mod tests {
assert_eq!(byte_at("ab", 5), None);
}
#[test]
fn long_lines_are_cut_around_the_match() {
assert_eq!(snippet("short line", 0, 5), "short line");
let line = format!("{}needle{}", "a".repeat(400), "b".repeat(400));
let cut = snippet(&line, 400, 406);
assert_eq!(cut, format!("\u{2026}{}needle{}\u{2026}", "a".repeat(100), "b".repeat(100)));
let start = snippet(&line, 0, 0);
assert!(start.starts_with('a') && start.ends_with('\u{2026}') && start.chars().count() == 101);
assert_eq!(context_line(&"x".repeat(1000)).chars().count(), MAX_LINE_TEXT + 1);
}
#[test]
fn path_scopes() {
let (_dir, root) = root();
+9 -114
Переглянути файл
@@ -2,13 +2,12 @@
//! window they run on the host, next to the project they work on.
use super::{Channel, Emit};
use crate::Host;
use crate::agent_secrets::{AgentSecrets, Kept};
use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
AgentEvent, AgentModelCatalog, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput,
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -28,12 +27,6 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
Ok(providers)
}
/// Refreshes the selected agent's model catalog without sending a prompt.
pub fn refresh_models(host: &Host, window: &str, provider: String, force: bool) -> IdeResult<AgentModelCatalog> {
host.require_trust(window)?;
host.project(window)?.agents.refresh_models(&provider, force)
}
/// Overrides built-in agent `id`'s command with `command` (its agent file keeps the
/// built-in's driver and arguments), or returns it to auto-detection with `None`.
pub fn set_command(host: &Host, window: &str, id: String, command: Option<String>) -> IdeResult<()> {
@@ -349,77 +342,8 @@ pub async fn accept_changes(
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
/// Where the values of the user's MCP servers are kept in [`AgentSecrets`]. A project's own
/// servers keep their values in the project's file, where the project shares them.
const USER_MCP_VALUES: &str = "mcp/user/";
/// Every environment variable and header value of `map`, by its key in [`AgentSecrets`].
fn mcp_values(map: &mut McpServerMap) -> Vec<(String, &mut String)> {
let part = |s: &str| s.replace('%', "%25").replace('/', "%2F");
let mut values = Vec::new();
for (server, cfg) in map.iter_mut() {
for (kind, entries) in [("env", &mut cfg.env), ("header", &mut cfg.headers)] {
for (name, value) in entries.iter_mut() {
values.push((format!("{USER_MCP_VALUES}{}/{kind}/{}", part(server), part(name)), value));
}
}
}
values
}
fn has_mcp_values(map: &McpServerMap) -> bool {
map.values().any(|s| s.env.values().chain(s.headers.values()).any(|v| !v.is_empty()))
}
/// Fills in the saved values. A value written in the settings file wins: an empty one stands for the saved one.
fn fill_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) {
for (key, value) in mcp_values(map) {
if value.is_empty()
&& let Some(saved) = secrets.get(&key)
{
*value = saved;
}
}
}
/// Saves the values of `map` and empties them in it; values of servers and names no
/// longer in it are forgotten.
fn take_mcp_values(secrets: &AgentSecrets, map: &mut McpServerMap) -> IdeResult<Kept> {
let mut kept = Kept::Store;
let mut current = HashSet::new();
for (key, value) in mcp_values(map) {
if value.is_empty() {
secrets.delete(&key);
} else if secrets.get(&key).as_deref() != Some(value.as_str()) && secrets.set(&key, value)? == Kept::LocalFile {
kept = Kept::LocalFile;
}
value.clear();
current.insert(key);
}
for key in secrets.keys(USER_MCP_VALUES) {
if !current.contains(&key) {
secrets.delete(&key);
}
}
Ok(kept)
}
/// Values written into the user settings (by hand, by an older version, by an import) move to [`AgentSecrets`].
pub fn move_user_mcp_values(host: &Host) {
let mut user = host.global_settings.effective().agents.mcp_servers;
if !has_mcp_values(&user) {
return;
}
fill_mcp_values(&host.agent_secrets, &mut user);
if let Err(e) = persist_mcp_map(host, None, McpScope::User, &user) {
tracing::warn!("MCP servers: could not move values out of the user settings: {}", e.message);
}
}
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
move_user_mcp_values(host);
let mut user = host.global_settings.effective().agents.mcp_servers;
fill_mcp_values(&host.agent_secrets, &mut user);
let user = host.global_settings.effective().agents.mcp_servers;
let project = host.project(window).ok().filter(|p| !p.light);
let workspace = project
.as_ref()
@@ -434,15 +358,10 @@ fn persist_mcp_map(
scope: McpScope,
map: &McpServerMap,
) -> IdeResult<()> {
let mut map = map.clone();
let kept = match scope {
McpScope::User => take_mcp_values(&host.agent_secrets, &mut map)?,
McpScope::Workspace => Kept::Store,
};
let value = if map.is_empty() {
serde_json::Value::Null
} else {
serde_json::to_value(&map).map_err(|e| IdeError::invalid(e.to_string()))?
serde_json::to_value(map).map_err(|e| IdeError::invalid(e.to_string()))?
};
let event = IdeEvent::SettingsChanged { key: "agents.mcpServers".into() };
match scope {
@@ -451,9 +370,6 @@ fn persist_mcp_map(
for p in host.projects() {
p.settings.reload();
p.sink.emit(event.clone());
if kept == Kept::LocalFile {
host.agent_secrets.notify_kept_locally(&p.sink);
}
}
}
McpScope::Workspace => {
@@ -502,17 +418,15 @@ fn listed(
ide_agent::mcp::servers_from_settings(user, &workspace)
}
/// The MCP servers agent sessions of the project at `root` get: the user's, values filled in, and
/// the project's the user turned on.
/// The MCP servers agent sessions of the project at `root` get: the user's, and the project's the
/// user turned on.
pub(crate) fn session_mcp_servers(
settings: &ide_workspace::SettingsStore,
approvals: &crate::trust::McpServerApprovals,
secrets: &AgentSecrets,
root: &std::path::Path,
) -> Vec<McpServer> {
use ide_workspace::settings::SettingsScope;
let mut user = settings.layer(SettingsScope::Global).agents.mcp_servers;
fill_mcp_values(secrets, &mut user);
let user = settings.layer(SettingsScope::Global).agents.mcp_servers;
let workspace = approvals.apply(root, settings.layer(SettingsScope::Project).agents.mcp_servers);
ide_agent::mcp::servers_from_settings(&user, &workspace)
}
@@ -563,9 +477,6 @@ pub fn mcp_import(host: &Host, window: &str) -> IdeResult<Vec<McpServer>> {
pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
d.add("agent_refresh_models", |c| async move {
refresh_models(&c.host, &c.window, c.arg("provider")?, c.arg("force")?)
});
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) });
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
@@ -682,7 +593,6 @@ mod tests {
#[test]
fn sessions_get_the_users_servers_and_the_projects_turned_on_ones() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let (config, root) = (tmp.path().join("config"), tmp.path().join("project"));
std::fs::create_dir_all(&config).unwrap();
@@ -695,7 +605,6 @@ mod tests {
.unwrap();
let settings = ide_workspace::SettingsStore::load(&config, Some(&root));
let approvals = crate::trust::McpServerApprovals::load(&tmp.path().join("data"));
let secrets = AgentSecrets::new(&tmp.path().join("data"));
let on = |servers: Vec<McpServer>| -> Vec<(McpScope, String, String)> {
servers
.into_iter()
@@ -704,27 +613,13 @@ mod tests {
.collect()
};
assert_eq!(
on(session_mcp_servers(&settings, &approvals, &secrets, &root)),
on(session_mcp_servers(&settings, &approvals, &root)),
[(McpScope::User, "shared".to_owned(), "user-cmd".to_owned())]
);
// A user server's saved values reach the session; the file keeps only the names.
std::fs::write(
config.join("settings.toml"),
"[agents.mcpServers.keyed]\ncommand = \"k\"\nenv = { TOKEN = \"\" }\n",
)
.unwrap();
secrets.set("mcp/user/keyed/env/TOKEN", "saved-user").unwrap();
settings.reload();
let keyed = session_mcp_servers(&settings, &approvals, &secrets, &root);
let keyed = keyed.iter().find(|s| s.name == "keyed").unwrap();
assert_eq!(keyed.env[0].value, "saved-user");
std::fs::write(config.join("settings.toml"), "[agents.mcpServers.shared]\ncommand = \"user-cmd\"\n").unwrap();
settings.reload();
let extra = settings.layer(ide_workspace::settings::SettingsScope::Project).agents.mcp_servers["extra"].clone();
approvals.approve(&root, "extra", &extra).unwrap();
assert!(on(session_mcp_servers(&settings, &approvals, &secrets, &root)).contains(&(
assert!(on(session_mcp_servers(&settings, &approvals, &root)).contains(&(
McpScope::Workspace,
"extra".to_owned(),
"extra".to_owned()
+4 -90
Переглянути файл
@@ -15,7 +15,6 @@ use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use std::path::Path;
use std::sync::Arc;
fn join_err(e: tokio::task::JoinError) -> IdeError {
IdeError::internal(e.to_string())
@@ -314,11 +313,10 @@ pub async fn build_sync(
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
let p = project.clone();
tokio::task::spawn_blocking(move || p.refresh_repositories());
}
// New source folders and dependency versions: the Project view and External Libraries reload.
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
@@ -331,61 +329,21 @@ pub async fn build_sync(
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let lsp = project.lsp.clone();
let documents = project.documents.clone();
tokio::spawn(async move {
if profiles_changed {
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
lsp.reconfigure().await;
}
let first = updates.first().cloned();
for file in updates {
let uri = ide_lsp::path_to_uri_string(&file);
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
}
let changed = {
let lsp = lsp.clone();
tokio::task::spawn_blocking(move || lsp.changed_library_archives()).await.unwrap_or_default()
};
if let Some(file) = first
&& let Err(e) = lsp.java_refresh_archives(&file, &changed).await
{
tracing::debug!("openidea.java.refreshArchives: {e}");
}
refresh_library_classes(&lsp, &documents).await;
});
Ok(model)
}
/// Open library classes whose jar was replaced in place (a SNAPSHOT installed
/// again keeps its path) show the new contents, like a reopened one.
async fn refresh_library_classes(lsp: &Arc<ide_lsp::LspManager>, documents: &Arc<ide_workspace::Documents>) {
let changed = {
let (lsp, documents) = (lsp.clone(), documents.clone());
tokio::task::spawn_blocking(move || {
documents
.open_documents()
.into_iter()
.filter_map(|doc| Some((doc, documents.virtual_uri(doc).ok().flatten()?)))
.filter(|(_, uri)| lsp.library_class_changed(uri))
.collect::<Vec<_>>()
})
.await
.unwrap_or_default()
};
for (doc, uri) in changed {
match lsp.virtual_document(&uri).await {
Ok(vd) => {
if let Err(e) = documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom) {
tracing::debug!("refreshing {uri}: {e}");
}
}
Err(e) => tracing::debug!("refreshing {uri}: {e}"),
}
}
}
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(
@@ -497,8 +455,7 @@ pub async fn maven_dependency_declaration(
/// absolute for a parent POM opened from the local repository): the definition of the
/// `${property}` at the caret, or the `<dependencyManagement>` / `<pluginManagement>`
/// entry providing the dependency's / plugin's version, along the parent chain (into
/// the local repository when a parent is not in the project), else the artifact's own
/// POM (a project module's, or the local repository's). `None` when nothing
/// the local repository when a parent is not in the project). `None` when nothing
/// resolvable is under the caret.
pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos: Pos) -> IdeResult<Option<PomGoto>> {
let project = host.project(window)?;
@@ -509,19 +466,7 @@ pub async fn maven_goto_declaration(host: &Host, window: &str, pom: String, pos:
let settings = project.settings.effective();
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
let modules = || {
let model = load(&project.root, &project.build_cache_dir(), &settings).maven.unwrap_or_default();
model
.projects
.into_iter()
.map(|p| ide_build::maven::ModulePom {
group_id: p.group_id,
artifact_id: p.artifact_id,
pom: project.root.join(&p.pom),
})
.collect()
};
Ok(ide_build::maven::goto_declaration_in(&text, &path, pos, repo, modules))
Ok(ide_build::maven::goto_declaration(&text, &path, pos, repo))
})
.await
.map_err(join_err)?
@@ -1026,38 +971,6 @@ mod build_tool_settings_tests {
}
}
/// Changing Project JDK updates the next Maven build without reopening the project.
#[test]
fn changing_project_jdk_updates_the_next_maven_build() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
for (name, version) in [("jdk8", "1.8"), ("jdk21", "21")] {
let home = root.join(name);
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("bin").join(if cfg!(windows) { "java.exe" } else { "java" }), "").unwrap();
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{version}\"\n")).unwrap();
}
let mut settings = Settings::default();
settings.languages.java.maven.runner_jre.clear();
for name in ["jdk8", "jdk21"] {
settings.languages.java.jdk = name.into();
let expected = root.join(name);
let model = load(&root, &root.join("cache"), &settings);
assert_eq!(model.maven.as_ref().unwrap().projects[0].env["JAVA_HOME"], expected.to_string_lossy());
let plan = compile::plan(&root, &model, BuildKind::Build, None, None).unwrap();
assert!(!plan.steps.is_empty());
for step in plan.steps {
assert_eq!(step.env["JAVA_HOME"], expected.to_string_lossy());
}
}
}
#[test]
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
@@ -1092,6 +1005,7 @@ mod maven_completion_tests {
use ide_build::maven::completion::PomCompletionKind;
use ide_core::CancellationToken;
use ide_workspace::dependencies::DependencyVersionSource;
use std::sync::Arc;
/// Cache answers the test plants before completing, so the op's Central
/// lookups hit the session cache and the network is never reached.
+6 -60
Переглянути файл
@@ -12,7 +12,6 @@ use ide_db::{
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
/// One statement of a console run.
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -43,10 +42,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
);
test(&c.host, &c.window, source, password, ssh_secret).await
});
d.add("db_test_tunnel", |c| async move {
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
test_tunnel(&c.host, &c.window, source, ssh_secret).await
});
d.add("db_set_ssh_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
@@ -180,7 +175,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
apply_edits(&c.host, &c.window, &id, &schema, &table, edits).await
});
d.add("db_console_path", |c| async move { console_path(&c.host, &c.arg::<String>("id")?) });
d.add("db_consoles", |c| async move { consoles(&c.host, &c.arg::<String>("id")?, c.arg("create")?) });
}
/// The data sources configured for the project (the tree's roots).
@@ -218,17 +212,6 @@ pub async fn test(
db.test(&source, password, ssh_secret).await
}
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
pub async fn test_tunnel(
host: &Host,
window: &str,
source: DataSource,
ssh_secret: Option<String>,
) -> IdeResult<String> {
let db = host.project(window)?.db.clone();
db.test_tunnel(&source, ssh_secret).await
}
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
@@ -474,52 +457,11 @@ pub async fn apply_edits(
/// created on first use. Consoles are files of the project, so a remote
/// window resolves it on the host the project runs on.
pub fn console_path(host: &Host, id: &str) -> IdeResult<IpcPath> {
let file = console_dir(host, id)?.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
/// The query consoles of data source `id`: the default `console.sql`, then
/// `console_1.sql`, `console_2.sql`… in order. `create` adds the next one first
/// (DataGrip's New › Query Console).
pub fn consoles(host: &Host, id: &str, create: bool) -> IdeResult<Vec<IpcPath>> {
let dir = console_dir(host, id)?;
let default = dir.join("console.sql");
let mut numbered: Vec<(u32, PathBuf)> = std::fs::read_dir(&dir)?
.filter_map(|e| {
let path = e.ok()?.path();
let n = path.file_name()?.to_str()?.strip_prefix("console_")?.strip_suffix(".sql")?.parse().ok()?;
Some((n, path))
})
.collect();
numbered.sort();
if create {
// The default console comes first; once it exists, the next number.
if !default.exists() {
std::fs::write(&default, "")?;
} else {
let n = numbered.last().map_or(1, |(n, _)| n + 1);
let file = dir.join(format!("console_{n}.sql"));
std::fs::write(&file, "")?;
numbered.push((n, file));
}
}
let mut out: Vec<IpcPath> = Vec::new();
if default.exists() {
out.push(default.into());
}
out.extend(numbered.into_iter().map(|(_, p)| p.into()));
Ok(out)
}
/// `<dataDir>/consoles/db/<id>`: `<id>` for the default database, `<id>.<database>` for the others.
fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
let (source, database) = ide_db::split_target(id);
if source.is_empty() || !source.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(IdeError::invalid("invalid data source id"));
}
// One console per database: `<id>` for the default one, `<id>.<database>` for the others.
let name = match database {
Some(db) => {
let safe: String =
@@ -530,5 +472,9 @@ fn console_dir(host: &Host, id: &str) -> IdeResult<PathBuf> {
};
let dir = host.data_dir.join("consoles").join("db").join(&name);
std::fs::create_dir_all(&dir)?;
Ok(dir)
let file = dir.join("console.sql");
if !file.exists() {
std::fs::write(&file, "")?;
}
Ok(file.into())
}
-25
Переглянути файл
@@ -372,25 +372,6 @@ pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessi
host.debugger.restart_frame(session, frame_id).await
}
pub async fn debug_js_custom_breakpoints(
host: &Host,
_window: &str,
session: DebugSessionId,
ids: Vec<String>,
xhr: Vec<String>,
) -> IdeResult<()> {
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
}
pub async fn debug_js_response_body(
host: &Host,
_window: &str,
session: DebugSessionId,
request_id: String,
) -> IdeResult<ide_dap::JsResponseBody> {
host.debugger.js_response_body(session, request_id).await
}
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
host.debugger.capabilities(session)
}
@@ -607,12 +588,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
});
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
d.add("debug_js_response_body", |c| async move {
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
});
d.add("debug_js_custom_breakpoints", |c| async move {
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
});
d.add("debug_set_function_breakpoints", |c| async move {
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
});
+3 -9
Переглянути файл
@@ -188,14 +188,8 @@ pub async fn remote_download_into(
r
}
/// `password`: an FTP password typed in the settings but not applied yet.
pub async fn deploy_test_connection(
state: &Host,
connection: FsConnection,
path: String,
password: Option<String>,
) -> IdeResult<()> {
state.remote.test_connection_with(&connection, &path, password).await
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
@@ -237,6 +231,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
d.add("deploy_test_connection", |c| async move {
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?, c.arg("password")?).await
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
});
}
+27 -97
Переглянути файл
@@ -96,19 +96,18 @@ async fn resolve_launch(
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
let local_repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
let built = if delegated {
let rebuild_on_jdt_errors = rebuild_on_jdt_errors(policy, prefer_maven, info.error_count);
let policy = delegated_policy(policy, prefer_maven, info.error_count);
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&model,
&module,
cache::BuildOptions { test, policy, rebuild_on_jdt_errors },
local_repo.clone(),
cache::BuildOptions { test, policy },
ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel,
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
apply_external_build_result(&mut info, outputs.failure, outputs.authoritative);
apply_external_build_result(&mut info, outputs.failure, policy == BuildPolicy::Rebuild);
copies
} else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
@@ -124,22 +123,10 @@ async fn resolve_launch(
Vec::new()
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
if delegated {
// m2e runs inside jdtls on the server JDK and can resolve a different
// Maven profile/classifier than the module build. Ask the same Maven
// process that built the module for its runtime/test dependencies.
let maven_cp = compile::module_classpath(
&project.root,
&model,
&module,
test,
local_repo,
&project.build_cache_dir(),
&cancel,
);
merge_maven_launch_classpath(&mut class_paths, &mut info, maven_cp)?;
} else {
append_unique_class_paths(&mut class_paths, std::mem::take(&mut info.class_paths));
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
}
info.class_paths = class_paths;
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
@@ -204,18 +191,21 @@ async fn resolve_launch(
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
/// compiler, so a launch JDT reports errors for recompiles everything.
fn rebuild_on_jdt_errors(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> bool {
prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make)
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
BuildPolicy::Rebuild
} else {
policy
}
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, authoritative: bool) {
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
JavaBuildStatus::Failed
} else if authoritative {
// A clean rebuild or a fingerprint-verified snapshot is authoritative. JDT diagnostics
// may describe an older output or its own classpath, so they must not mark this launch
// as failed after Maven has produced usable classes.
} else if clean {
// A clean external build recompiled every source; JDT's diagnostics still describe
// the previous output (and reject sources javac accepts, e.g. a package mismatch).
info.errors.clear();
info.error_count = 0;
JavaBuildStatus::Succeeded
@@ -296,44 +286,6 @@ fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
/// Merge the Maven launch classpath without turning a recoverable probe failure into a
/// failed launch. Cancellation still propagates; ordinary failures keep jdtls' classpath
/// and leave the reason in the run console.
fn merge_maven_launch_classpath(
class_paths: &mut Vec<String>,
info: &mut JavaLaunchInfo,
classpath: IdeResult<String>,
) -> IdeResult<()> {
match classpath {
Ok(classpath) => {
append_unique_class_paths(
class_paths,
std::env::split_paths(&classpath).map(|path| path.to_string_lossy().into_owned()),
);
Ok(())
}
Err(error) if error.kind == ErrorKind::Cancelled => Err(error),
Err(error) => {
info.hints.push(format!(
"Maven launch classpath resolution failed; using the Java language server classpath instead:\n{}",
error.message
));
append_unique_class_paths(class_paths, std::mem::take(&mut info.class_paths));
Ok(())
}
}
}
/// Append entries without changing their precedence; `same_entry` also catches
/// Windows case-insensitive duplicates that would otherwise lengthen the command.
fn append_unique_class_paths(class_paths: &mut Vec<String>, entries: impl IntoIterator<Item = String>) {
for entry in entries {
if !class_paths.iter().any(|existing| same_entry(existing, &entry, cfg!(windows))) {
class_paths.push(entry);
}
}
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows))
}
@@ -458,36 +410,14 @@ mod tests {
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert!(rebuild_on_jdt_errors(BuildPolicy::Make, true, 1));
assert!(rebuild_on_jdt_errors(BuildPolicy::Automatic, true, 2));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, true, 0));
assert!(!rebuild_on_jdt_errors(BuildPolicy::Make, false, 1));
assert!(!rebuild_on_jdt_errors(BuildPolicy::MakeAndContinueOnErrors, true, 1));
}
#[test]
fn maven_classpath_probe_failure_falls_back_to_jdtls_classpaths() {
let maven = std::env::join_paths(["maven.jar", "snapshot.jar"]).unwrap().to_string_lossy().into_owned();
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info =
JavaLaunchInfo { class_paths: vec!["snapshot.jar".into(), "jdtls.jar".into()], ..Default::default() };
merge_maven_launch_classpath(&mut class_paths, &mut info, Ok(maven)).unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar"]);
merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::internal("offline dependency")))
.unwrap();
assert_eq!(class_paths, ["snapshot.jar", "maven.jar", "jdtls.jar"]);
assert!(info.hints[0].contains("offline dependency"), "{:?}", info.hints);
}
#[test]
fn cancelled_maven_classpath_probe_propagates_without_fallback() {
let mut class_paths = vec!["snapshot.jar".to_owned()];
let mut info = JavaLaunchInfo { class_paths: vec!["jdtls.jar".into()], ..Default::default() };
let error = merge_maven_launch_classpath(&mut class_paths, &mut info, Err(IdeError::cancelled())).unwrap_err();
assert_eq!(error.kind, ErrorKind::Cancelled);
assert_eq!(class_paths, ["snapshot.jar"]);
assert!(info.hints.is_empty());
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
assert_eq!(
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
BuildPolicy::MakeAndContinueOnErrors
);
}
#[test]
@@ -531,7 +461,7 @@ mod tests {
}
#[test]
fn authoritative_external_result_clears_stale_jdt_errors() {
fn clean_external_build_clears_stale_jdt_errors() {
let mut info = JavaLaunchInfo {
error_count: 7,
errors: vec![ide_lsp::jdtls::JavaCompileError {
+4 -71
Переглянути файл
@@ -14,10 +14,6 @@ pub(super) struct Outputs {
pub originals: Vec<PathBuf>,
#[serde(skip)]
pub failure: Option<String>,
/// Whether this result replaces jdtls diagnostics with a clean Maven build or a
/// verified snapshot. It is runtime-only because reuse is determined per launch.
#[serde(skip)]
pub authoritative: bool,
}
impl Outputs {
@@ -46,9 +42,6 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
pub(super) struct BuildOptions {
pub test: bool,
pub policy: BuildPolicy,
/// A JDT error makes a cache miss rebuild, but it must not prevent an unchanged
/// snapshot from being reused on the next launch.
pub rebuild_on_jdt_errors: bool,
}
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
@@ -82,7 +75,7 @@ impl Session {
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let BuildOptions { test, policy, rebuild_on_jdt_errors } = options;
let BuildOptions { test, policy } = options;
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -122,14 +115,8 @@ impl Session {
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
let mut outputs = previous.unwrap();
outputs.authoritative = true;
return Ok(outputs);
return Ok(previous.unwrap());
}
// JDT can report errors from its own classpath while the last Maven snapshot is
// still valid. Reuse that snapshot first; only a cache miss needs the expensive
// clean rebuild that replaces JDT's error-stub classes.
let policy = if rebuild_on_jdt_errors { BuildPolicy::Rebuild } else { policy };
// Retain a non-reusable record even on failure, so removing the final mixed
// source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?;
@@ -169,13 +156,7 @@ impl Session {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs {
fingerprint: None,
snapshot,
originals,
failure: built.failure,
authoritative: policy == BuildPolicy::Rebuild,
};
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
if outputs.failure.is_none() && before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
@@ -353,48 +334,6 @@ mod tests {
);
}
#[test]
fn jdt_errors_reuse_an_unchanged_snapshot_and_rebuild_only_on_a_cache_miss() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cancel = CancellationToken::new();
let mut session = Session::new(root.join("cache"));
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert!(!first.authoritative);
let reused = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_eq!(reused.snapshot, first.snapshot);
assert!(reused.authoritative);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let rebuilt = session
.build(
root,
&model,
root,
BuildOptions { rebuild_on_jdt_errors: true, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot);
assert!(rebuilt.authoritative);
let calls = fs::read_to_string(root.join("calls")).unwrap();
assert_eq!(calls.lines().count(), 2);
assert!(calls.lines().last().unwrap().contains("clean"));
}
#[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -544,13 +483,7 @@ mod tests {
fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs {
fingerprint: None,
snapshot: retained.clone(),
originals: Vec::new(),
failure: None,
authoritative: false,
};
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
+14 -81
Переглянути файл
@@ -74,15 +74,9 @@ pub async fn lsp_completion(
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
.await
.unwrap_or_default();
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
if items.is_empty() {
return if covered {
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
} else {
result
};
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
}
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
@@ -315,13 +309,9 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
// editor to tell.
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
&& !on_declaration_name(host, window, doc, pos).await
{
let project = host.project(window)?;
// A notebook cell is a language-server document only: no plugin navigates in it.
@@ -362,30 +352,6 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
definition_targets(result, found, in_string)
}
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(false);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
let cancel = ide_core::CancellationToken::new();
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
Ok(is_declaration_name(&at))
})
.await
.unwrap_or(false)
}
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
at.node.field.as_deref() == Some("name")
&& at.ancestors.first().is_some_and(|parent| {
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
})
}
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
@@ -1517,38 +1483,23 @@ pub async fn lsp_restart_named(host: &Host, name: &str) -> IdeResult<()> {
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
record_before_multi_file_edit(&project, &edit);
// Multi-file refactorings leave a Local History point to go back to.
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
apply_edit(&project.documents, &edit)
})
.await
}
/// [`apply_workspace_edit`] for the MCP server's tools: undoable the same way, but the UI does
/// not announce it as a refactoring.
pub async fn apply_agent_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
record_before_multi_file_edit(&project, &edit);
project.documents.apply_agent_workspace_edit(&edit)
})
.await
}
/// Multi-file edits leave a Local History point to go back to.
fn record_before_multi_file_edit(project: &crate::Project, edit: &WorkspaceEdit) {
let paths: Vec<&std::path::Path> = edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
_ => None,
})
.collect();
if edit.files.len() > 1 {
project.record_before_refactoring(&paths, "Apply changes");
}
}
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
let project = host.project(window)?;
blocking(move || {
@@ -2170,24 +2121,6 @@ mod tests {
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
}
#[test]
fn declaration_names_are_told_apart_from_uses() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = ide_core::LanguageId::new("java");
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
let snap = ide_syntax::Snapshot::text(&java, text);
let cancel = ide_core::CancellationToken::new();
let at = |line, col| {
let pos = Pos::new(line, col);
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
};
assert!(at(1, 10), "the mapper method's name");
assert!(at(0, 12), "the interface's name");
assert!(!at(3, 45), "a call of the method");
assert!(!at(1, 4), "its return type");
}
#[test]
fn positions_inside_string_literals_are_told_apart() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
-2
Переглянути файл
@@ -31,7 +31,6 @@ pub mod scaffold;
pub mod sdk;
pub mod search;
pub mod spell;
mod ssh;
pub mod syntax;
pub mod term;
pub mod tools;
@@ -96,7 +95,6 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
deploy::register(d);
docker::register(d);
db::register(d);
ssh::register(d);
}
impl Host {
+1 -87
Переглянути файл
@@ -2,7 +2,7 @@ use super::doc::blocking;
use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, LanguageId, Location, Pos};
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
@@ -22,23 +22,6 @@ pub struct WebGoto {
const MAX_WEB_TARGETS: usize = 200;
/// A Python class definition and the base names written in its source. The UI builds a
/// name → locations index from every class in the project (attached dependency roots
/// included) to compute ↑ overrides / ↓ overridden gutter markers without a language
/// server's type-hierarchy provider.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ClassInheritance {
/// Start of the class definition's selection range (0-based line, UTF-16 column).
pub path: IpcPath,
pub line: u32,
pub col: u32,
pub name: String,
/// Base names as written in source (bare name, e.g. `AllToAllTokenDispatcher`; a
/// qualified name keeps only its last segment).
pub bases: Vec<String>,
}
/// The project root and its resource roots (served at `/`), for file references.
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
@@ -191,74 +174,6 @@ pub async fn nav_index_definitions(
.await
}
/// Every Python class in the project (attached dependency roots included) and its base
/// names. The index covers content roots, so this is the whole-project answer the UI uses
/// for inheritance gutter markers when no type-hierarchy provider exists.
pub async fn nav_inheritance(host: &Host, window: &str) -> IdeResult<Vec<ClassInheritance>> {
let project = host.project(window)?;
let syntax = host.syntax();
blocking(move || {
let python = LanguageId::new("python");
let files = project.index.files_with_extensions(&["py".into(), "pyi".into()], 20_000);
let mut cache = CLASS_BASES.lock();
let mut out = Vec::new();
for path in files {
// The open (possibly unsaved) buffer is keyed by its text, a file on disk by its
// size and modification time, so only changed files are parsed again.
let open = project.documents.text_of(&path);
let stamp = match &open {
Some(text) => Stamp::Text(text_hash(text)),
None => match std::fs::metadata(&path) {
Ok(m) => Stamp::Disk(m.len(), m.modified().ok()),
Err(_) => continue,
},
};
let classes = match cache.get(&path) {
Some((cached, classes)) if *cached == stamp => classes,
_ => {
let text = match open {
Some(text) => text,
None => match project.text_of(&path) {
Ok(text) => text,
Err(_) => continue,
},
};
let classes = syntax.class_bases(&python, &text);
&cache.entry(path.clone()).insert_entry((stamp, classes)).into_mut().1
}
};
out.extend(classes.iter().map(|class| ClassInheritance {
path: IpcPath::from(path.clone()),
line: class.line,
col: class.col,
name: class.name.clone(),
bases: class.bases.clone(),
}));
}
Ok(out)
})
.await
}
#[derive(Clone, PartialEq, Eq)]
enum Stamp {
Text(u64),
Disk(u64, Option<std::time::SystemTime>),
}
fn text_hash(text: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
h.finish()
}
type ClassBasesCache = std::collections::HashMap<PathBuf, (Stamp, Vec<ide_language::ClassBases>)>;
/// Class bases per Python file, reused while the file is unchanged.
static CLASS_BASES: std::sync::LazyLock<parking_lot::Mutex<ClassBasesCache>> =
std::sync::LazyLock::new(Default::default);
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
pub async fn fs_resolve_reference(
host: &Host,
@@ -334,7 +249,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("nav_index_definitions", |c| async move {
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
});
d.add("nav_inheritance", |c| async move { nav_inheritance(&c.host, &c.window).await });
d.add("fs_resolve_reference", |c| async move {
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
});
+1 -20
Переглянути файл
@@ -234,19 +234,7 @@ pub async fn start_request(
) -> IdeResult<RunInfo> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let operation = config.extras.params.get("plugin.operation").cloned();
let mut config = config;
let agent = match host.project(window) {
Ok(project) => host.mcp_agent_runs.launch(&project.root, window, &mut config),
Err(_) => None,
};
let on_output = match &agent {
Some(agent) => agent.wrap_output(on_output),
None => on_output,
};
let result = start_inner(host, window, config, on_output, agent.as_ref(), &cancel).await;
if let Some(agent) = &agent {
agent.started(&result);
}
let result = start_inner(host, window, config, on_output, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
@@ -261,7 +249,6 @@ async fn start_inner(
window: &str,
mut config: RunConfig,
on_output: Emit<OutputChunk>,
agent: Option<&crate::mcp::run::agent::Launch>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<RunInfo> {
if cancel.is_cancelled() {
@@ -295,9 +282,6 @@ async fn start_inner(
if let Some(plugins) = host.plugin_host() {
sink = plugins.run_operation_sink(window, &mut config, sink)?;
}
if let Some(agent) = agent {
sink = agent.wrap_sink(sink);
}
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
@@ -466,7 +450,6 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
if let Some(plugins) = host.plugin_host() {
plugins.run_operation_stopped(id);
}
host.mcp_agent_runs.stopping(id);
let settings = match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
@@ -478,9 +461,7 @@ pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
host.processes.stop_with_grace(id, grace)
}
/// Input the user typed into a run (the agent's goes to the process directly).
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
host.mcp_agent_runs.user_input(id, data);
if host.remote.runs.contains(id) {
return host.remote.runs.write_stdin(id, data);
}
+6 -13
Переглянути файл
@@ -61,16 +61,13 @@ pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeRe
host.require_trust(window)?;
let current = host.project(window).ok().map(|p| p.root.clone());
let config = host.config_dir.clone();
// Probing runs the project's interpreter (and its settings may name any program): only
// projects trusted in their own right, not ones last opened in Restricted Mode.
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
.list()
.into_iter()
.map(|p| p.path.into_path_buf())
.filter(|root| current.as_deref() != Some(root.as_path()))
.filter(|root| host.trust.window_trusted(root).unwrap_or(false))
.collect();
tokio::task::spawn_blocking(move || {
let roots: Vec<PathBuf> = ide_workspace::RecentProjects::load(&config)
.list()
.into_iter()
.map(|p| p.path.into_path_buf())
.filter(|root| current.as_deref() != Some(root.as_path()))
.collect();
// Each project resolves its configured interpreter, else its own virtualenv. The
// probes run `python`, so keep them off the async runtime and bound how many run at
// once: the recent list can hold ~30 projects, and each probe starts a process.
@@ -524,10 +521,6 @@ mod tests {
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
// A project last opened in Restricted Mode: its interpreter is never run.
host.trust.decide(&other, false, false).unwrap();
assert!(sdk_python_project_interpreters(&host, "main").await.unwrap().is_empty());
host.trust.decide(&other, true, false).unwrap();
let list = sdk_python_project_interpreters(&host, "main").await.unwrap();
assert_eq!(list.len(), 1, "{list:?}");
assert_eq!(&*list[0].project, other.as_path());
+4 -28
Переглянути файл
@@ -47,17 +47,14 @@ pub async fn spell_check(
let syntax = host.syntax();
let cancel = host.begin_request(request);
let result = blocking(move || {
let path = project.documents.snapshot(doc)?.path.0.clone();
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok((path, Vec::new()));
return Ok(Vec::new());
}
let regions = if PROSE.contains(&snap.language.as_str()) {
ide_spell::prose_regions(&snap.text)
} else {
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else {
return Ok((path, Vec::new()));
};
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
found
.into_iter()
.map(|r| Region {
@@ -75,32 +72,11 @@ pub async fn spell_check(
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let typos =
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)?;
Ok((path, typos))
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
})
.await;
host.end_request(request);
// Record the typos in the host's diagnostics snapshot so line-end annotations
// see them without waiting for a UI report; the editor still draws from its
// own call above, unchanged.
if let Ok((path, typos)) = &result {
let items: Vec<ide_core::Diagnostic> = typos
.iter()
.map(|t| ide_core::Diagnostic {
range: t.range,
severity: ide_core::Severity::Info,
message: format!("Typo: In word '{}'", t.word),
source: Some("Spelling".into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.collect();
host.diagnostics.replace(path, "Spelling", items);
}
result.map(|(_, typos)| typos)
result
}
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
-29
Переглянути файл
@@ -1,29 +0,0 @@
//! SSH configuration commands shared by the desktop shell's IPC commands and the remote
//! server ([`crate::rpc`]). A remote window connects from the server (deployment, database
//! tunnels, run targets, SSH sessions), so the host keys it trusts and the passwords it is
//! given belong to the server's [`ide_ssh::SshManager`], not the desktop's.
use crate::rpc::Dispatcher;
use ide_core::remote_settings::SshConfiguration;
pub(crate) fn register(d: &mut Dispatcher) {
d.add("ssh_test", |c| async move {
let (config, secret) = (c.arg::<SshConfiguration>("config")?, c.arg::<Option<String>>("secret")?);
c.host.ssh.test(&config, secret).await
});
d.add("ssh_set_secret", |c| async move {
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
c.host.ssh.set_secret(&id, secret, save);
Ok(())
});
d.add("ssh_password_saved", |c| async move { Ok(c.host.ssh.password_saved(&c.arg::<String>("key")?)) });
d.add("ssh_needs_secret", |c| async move {
let ssh = &c.host.ssh;
Ok(ssh.needs_secret(&ssh.config(&c.arg::<String>("id")?)?))
});
d.add("ssh_trust_host", |c| async move { c.host.ssh.trust_host(&c.arg::<String>("id")?).await });
d.add("ssh_connected", |c| async move { Ok(c.host.ssh.connected()) });
d.add("ssh_disconnect", |c| async move {
c.host.ssh.disconnect(&c.arg::<String>("id")?);
Ok(())
});
}
+1 -12
Переглянути файл
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
@@ -89,16 +89,6 @@ pub async fn tools_install(
Ok(info)
}
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
/// Cancel with `cancel_request(request)`.
pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult<Vec<ToolUpdate>> {
let tools = tools(host, window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err);
host.end_request(request);
result?
}
/// Removes a tool the IDE installed.
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
let tools = tools(host, window)?;
@@ -195,7 +185,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
});
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await });
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
d.add("jdk_download_releases", |c| async move {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
-21
Переглянути файл
@@ -22,27 +22,6 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
.await
}
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
/// to no repository are ignored, and an empty list is the full status.
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
let project = host.project(window)?;
host.blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status_paths(&paths)
})
.await
}
mod commands;
pub use commands::*;
+3 -29
Переглянути файл
@@ -585,21 +585,6 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
}
/// Adds `commit` to annotate's ignore list in `path`'s repository (the file `blame.ignoreRevsFile`
/// names, else `.git-blame-ignore-revs`, which the local config then names).
pub async fn vcs_ignore_blame_revision(host: &Host, window: &str, path: PathBuf, commit: String) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
let result = project.with_disk_sync(|| project.with_repo_of(&path, |r| r.ignore_blame_revision(&commit)));
if result.is_ok() {
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
}
result
})
.await
}
/// Project-host operation (also registered with the remote dispatcher).
/// Returns the ignore files that were updated, so the UI can show them.
pub async fn vcs_add_to_ignore(
@@ -807,19 +792,12 @@ pub async fn vcs_stash_push(
message: Option<String>,
include_untracked: bool,
keep_index: bool,
paths: Option<Vec<PathBuf>>,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
let paths = paths.unwrap_or_default();
blocking(move || {
if paths.is_empty() {
return project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index, &[])
});
}
per_repo(&project, &paths, Some("Git stash"), |r, paths| {
r.stash_push(message.as_deref(), include_untracked, keep_index, paths)
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked, keep_index)
})
})
.await
@@ -1484,9 +1462,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_add_to_ignore", |c| async move {
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
});
d.add("vcs_ignore_blame_revision", |c| async move {
vcs_ignore_blame_revision(&c.host, &c.window, c.arg("path")?, c.arg("commit")?).await
});
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
@@ -1532,7 +1507,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
@@ -1545,7 +1520,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("message")?,
c.arg("includeUntracked")?,
c.arg("keepIndex")?,
c.arg("paths")?,
)
.await
});
-96
Переглянути файл
@@ -2,106 +2,10 @@
use crate::Host;
use ide_core::IdeResult;
use ide_core::proxy_settings::{ProxyMode, ProxySettings};
use std::path::Path;
use std::time::Duration;
/// Whether `url` names this machine (`localhost` or a loopback address).
fn is_loopback(url: &reqwest::Url) -> bool {
let host = url.host_str().unwrap_or_default().trim_start_matches('[').trim_end_matches(']');
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
fn is_html(content_type: &str) -> bool {
let kind = content_type.split(';').next().unwrap_or_default().trim();
kind.eq_ignore_ascii_case("text/html") || kind.eq_ignore_ascii_case("application/xhtml+xml")
}
/// A client that reaches this machine only: no proxy, and a redirect is followed while it
/// stays here (a dev server sending `/` to `/app/`).
fn loopback_client() -> IdeResult<reqwest::Client> {
let direct = ProxySettings { mode: ProxyMode::None, ..ProxySettings::default() };
let stay = reqwest::redirect::Policy::custom(|attempt| {
if is_loopback(attempt.url()) && attempt.previous().len() < 5 { attempt.follow() } else { attempt.stop() }
});
ide_http::client::build_with_redirect(&direct, stay)
}
/// Whether `url`, a server on this machine, answers a browser with an HTML document: a page
/// to open, as a dev server has, and not an API endpoint. Other hosts are never asked.
pub async fn serves_page(url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(url) else { return false };
if !matches!(url.scheme(), "http" | "https") || !is_loopback(&url) {
return false;
}
let Ok(client) = loopback_client() else { return false };
// A dev server may compile the page on this first request.
let request = client.get(url).header(reqwest::header::ACCEPT, "text/html").timeout(Duration::from_secs(10));
let Ok(response) = request.send().await else { return false };
let html = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(is_html);
response.status().is_success() && html
}
/// The URL serving `file`, under the window's project when it holds the file.
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
let root = host.project(window).ok().map(|p| p.root.clone());
host.web.url_for(root.as_deref(), file).await
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A server on a free port that answers every request with `status` and `header`. It
/// listens on every interface, so that a test can reach it under a name that is not loopback.
async fn answering(status: &'static str, header: String) -> String {
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let mut request = [0u8; 2048];
let _ = socket.read(&mut request).await;
let response =
format!("HTTP/1.1 {status}\r\n{header}\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
let _ = socket.write_all(response.as_bytes()).await;
}
});
format!("http://127.0.0.1:{port}/")
}
async fn server(status: &'static str, content_type: &'static str) -> String {
answering(status, format!("Content-Type: {content_type}")).await
}
async fn redirecting_to(location: &str) -> String {
answering("302 Found", format!("Location: {location}")).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_redirect_is_followed_on_this_machine_only() {
let page = server("200 OK", "text/html").await;
assert!(serves_page(&redirecting_to(&page).await).await);
// The same page under an address that is not loopback: it is not asked.
let elsewhere = page.replace("127.0.0.1", "0.0.0.0");
assert!(!serves_page(&redirecting_to(&elsewhere).await).await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_page_is_an_html_answer_from_this_machine() {
assert!(serves_page(&server("200 OK", "text/html; charset=utf-8").await).await);
assert!(serves_page(&server("200 OK", "Text/HTML").await.replace("127.0.0.1", "localhost")).await);
// An API, an error page and a port nothing listens on are not pages.
assert!(!serves_page(&server("200 OK", "application/json").await).await);
assert!(!serves_page(&server("404 Not Found", "text/html").await).await);
assert!(!serves_page("http://127.0.0.1:1/").await);
// Other machines are not asked at all.
assert!(!serves_page("http://example.com/").await);
assert!(!serves_page("ftp://localhost/").await);
assert!(!serves_page("not a url").await);
}
}
+28 -149
Переглянути файл
@@ -1642,9 +1642,6 @@ impl HostApi for PluginApi {
) -> Result<(), HostError> {
self.set_decorations(path, set, Vec::new(), item, true)
}
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
self.set_file_icon_decorations(set, items)
}
fn vcs_log(
&self,
root: &Path,
@@ -3179,17 +3176,26 @@ impl PluginWorkspace for HostWorkspace {
Ok(out)
}
fn problems(&self, client: &str, path: Option<&Path>) -> Result<Vec<ide_plugin::FileProblems>, HostError> {
// The host records every `Diagnostics` event it emits (language servers,
// builds, spelling, plugin providers), so this sees a file's problems
// whatever window opened it — not just the ones its UI reported.
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
host.project(client).map_err(failed)?;
let root = host.project(client).map_err(failed)?.root.clone();
let windows = host.windows_for_root(&root);
let mut out = Vec::new();
for (file, source, diagnostics) in host.diagnostics.problems() {
if path.is_some_and(|p| p != file) {
for file in host.mcp_reports.problems(&windows) {
if path.is_some_and(|p| p != file.path.0) {
continue;
}
out.push(ide_plugin::FileProblems { path: to_ipc(&file), source, diagnostics });
if ide_plugin::resolve_new_in_root(&root, &to_ipc(&file.path.0)).is_err() {
continue;
}
let mut sources: BTreeMap<String, Vec<ide_core::Diagnostic>> = BTreeMap::new();
for d in file.items {
sources.entry(d.server.clone().or_else(|| d.source.clone()).unwrap_or_default()).or_default().push(d);
}
out.extend(sources.into_iter().map(|(source, diagnostics)| ide_plugin::FileProblems {
path: to_ipc(&file.path.0),
source,
diagnostics,
}));
}
Ok(out)
}
@@ -3315,8 +3321,6 @@ impl PluginWorkspace for HostWorkspace {
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
// Any open project may publish problems for any file: annotations follow the
// file, not the window (an editor can show a file outside every project root).
let projects = HostWorkspace::projects(self);
let project = projects
.iter()
@@ -3328,8 +3332,7 @@ impl PluginWorkspace for HostWorkspace {
.iter()
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| path.starts_with(m)))
})
.or_else(|| projects.first())
.ok_or_else(|| HostError::NotFound("no project is open".into()))?;
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
project.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin:{source}"),
@@ -4122,30 +4125,25 @@ impl PluginApi {
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
return Err(HostError::Invalid("decoration limit exceeded".into()));
}
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
if let Some(icon) = file.as_ref().and_then(|f| f.icon.as_ref()) {
ide_extension::validate_item_id(icon, "file icon id").map_err(HostError::Invalid)?;
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
}
let replacement = if let Some(path) = path {
// The decoration follows the file, not the window's project: editors render
// whatever they have open, including files outside every project root. The
// set's root stays the path's own (deepest open) project for tree filtering.
let root = self.decoration_root(path);
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project open".into()))?;
let (root, real) = workspace
.projects()
.into_iter()
.filter_map(|root| ide_plugin::resolve_new_in_root(&root, &to_ipc(path)).ok().map(|real| (root, real)))
.max_by_key(|(root, _)| root.components().count())
.ok_or_else(|| HostError::Invalid("path outside open projects".into()))?;
Some(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path.to_path_buf()),
path: real.into(),
set_id: set.into(),
tree,
items,
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
file: file.filter(|f| decoration_style(&f.style)),
})
} else {
None
@@ -4173,60 +4171,6 @@ impl PluginApi {
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
fn decoration_root(&self, path: &Path) -> PathBuf {
self.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
}
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
if set.is_empty() || set.len() > 128 {
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
}
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
}
let mut by_path = BTreeMap::new();
for (path, icon) in items {
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
by_path.insert(path, icon);
}
let mut all = self.shared.decorations.lock();
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
sets.retain(|s| !(s.tree && s.set_id == set));
for (path, icon) in by_path {
let root = self.decoration_root(&path);
sets.push(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path),
set_id: set.into(),
tree: true,
items: Vec::new(),
file: Some(ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: None,
note: None,
icon: Some(icon),
}),
});
}
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
}
all.insert(self.id().into(), sets.clone());
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
Ok(())
}
}
fn decoration_style(style: &str) -> bool {
matches!(
@@ -4239,8 +4183,6 @@ fn decoration_style(style: &str) -> bool {
| "warning"
| "error"
| "info"
| "typo"
| "weak"
| "underline"
| "strikethrough"
| "muted"
@@ -4361,11 +4303,8 @@ mod workbench_tests {
};
api.decorations(Some(&path), "set", vec![item("highlight"), item("unknown")]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"][0].items.len(), 1);
// Decorations follow the file: a path outside the open project is accepted too.
api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"].len(), 2);
assert!(api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).is_err());
api.decorations(None, "set", vec![]).unwrap();
assert!(api.shared.decoration_sets()["test"].is_empty());
for i in 0..50 {
api.decorations(Some(&path), &format!("s{i}"), vec![]).unwrap();
}
@@ -4376,73 +4315,13 @@ mod workbench_tests {
api.file_decorations(
Some(&path),
"tree",
Some(ide_plugin::FileDecoration {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None,
icon: None,
})
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
)
.is_err()
);
let note = |text: &str| ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
icon: None,
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "notes")
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
.collect::<Vec<_>>()
};
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.file_icon_decorations(
"icons",
vec![(path.clone(), "helm-chart".into()), (root.join("chart"), "helm-dir".into())],
)
.unwrap();
let icons = api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "icons")
.filter_map(|s| s.file.as_ref().and_then(|f| f.icon.clone()))
.collect::<Vec<_>>();
assert_eq!(icons.len(), 2);
assert!(api.file_icon_decorations("icons", vec![(path.clone(), "BAD ID".into())]).is_err());
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
#[test]
fn decoration_styles_keep_the_severity_palette_the_editor_renders() {
// Inspection Lens colours typos and weak warnings with their own styles; the
// host must keep them, not silently drop them the way an unknown style is dropped.
for style in [
"highlight",
"search",
"added",
"modified",
"removed",
"warning",
"error",
"info",
"typo",
"weak",
"underline",
"strikethrough",
"muted",
] {
assert!(decoration_style(style), "{style} should be a known style");
}
assert!(!decoration_style("unknown"));
}
fn git(root: &Path, args: &[&str]) {
let out = StdCommand::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+9 -19
Переглянути файл
@@ -129,7 +129,6 @@ impl PluginHost {
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
WorkbenchEvent::EditorFocused(Some(path)) => Path::new(path),
WorkbenchEvent::DocumentChanged(change) => Path::new(&change.path),
_ => return Some(event.clone()),
};
@@ -137,25 +136,16 @@ impl PluginHost {
return None;
}
let workspace = workspace.as_ref()?;
let real = ide_plugin::resolve_real(path);
// The file's own project, not the invoking window's: annotations follow
// the file, so a plugin hears about a file another window has open.
let root = match client {
Some(client) => workspace
.project_of(client)
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.or_else(|| {
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}),
None => workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count()),
Some(client) => workspace.project_of(client),
None => {
let real = ide_plugin::resolve_real(path);
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}
}?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
+2 -25
Переглянути файл
@@ -396,37 +396,15 @@ impl PluginHost {
}
}
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
self.provider_plugins(kind, &doc.language)
.into_iter()
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
}
pub fn provide_completion(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> Vec<(String, CompletionItem)> {
self.provide_completion_for_editor(doc, at, trigger).1
}
pub(crate) fn provide_completion_for_editor(
&self,
doc: &ProviderDocument,
at: ide_core::Pos,
trigger: Option<&str>,
) -> (bool, Vec<(String, CompletionItem)>) {
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
let items = self
.feature_documents(doc, Some(at))
self.feature_documents(doc, Some(at))
.into_iter()
.flat_map(|(doc, bounds)| {
if self.has_document_providers(ProviderKind::Completion, &doc) {
covered = true;
}
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
rt.provide_completion(&d, at, t.as_deref(), timeout)
@@ -441,8 +419,7 @@ impl PluginHost {
Some((p, i))
})
})
.collect();
(covered, items)
.collect()
}
pub fn provide_code_actions(
+5 -46
Переглянути файл
@@ -352,15 +352,6 @@ where
deserializer.deserialize_map(Properties)
}
/// A sample shown in a setting's own style (the Colour Scheme colours), e.g. the
/// message an Inspection Lens severity produces.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct SettingExample {
pub text: String,
/// `error`, `warning`, `weak`, `typo`, `info` or `muted`: which sample style to draw.
pub style: String,
}
/// One setting of a plugin.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -380,8 +371,6 @@ pub struct SettingSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub example: Option<SettingExample>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<Box<SettingSchema>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<SettingSchema>,
@@ -466,20 +455,6 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
}
};
let example = match o.get("example") {
None => None,
Some(v) => {
let e: SettingExample = serde_json::from_value(v.clone())
.map_err(|e| format!("setting {key:?}: `example` is {{\"text\", \"style\"}}: {e}"))?;
if e.text.is_empty() || e.text.len() > 300 {
return Err(format!("setting {key:?}: `example.text` is 1–300 bytes"));
}
if e.style.len() > 32 || !e.style.chars().all(|c| c.is_ascii_lowercase()) {
return Err(format!("setting {key:?}: `example.style` is a lowercase style name"));
}
Some(e)
}
};
let s = SettingSchema {
key: key.to_owned(),
kind: kind.to_owned(),
@@ -489,7 +464,6 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
group: text("group", MAX_TITLE)?,
example,
items,
properties,
};
@@ -743,7 +717,6 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct FileIconDecl {
id: String,
#[serde(default)]
patterns: Vec<String>,
icon: PathBuf,
#[serde(default)]
@@ -755,19 +728,13 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
}
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
/// icons still win).
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
/// whatever icon theme is in use (the user's own file type icons still win).
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
validate_item_id(&d.id, "file icon id")?;
let what = format!("file icon {:?}", d.id);
if d.patterns.is_empty() {
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
} else {
file_patterns(&d.patterns, &what)?;
}
file_patterns(&d.patterns, &what)?;
let mut files = Vec::new();
let mut assets = Vec::new();
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
@@ -776,16 +743,8 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
assets.push((file_icon_asset(&d.id, dark), svg));
files.push(path.clone());
}
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
let revision = blake3::hash(&bytes).to_hex().to_string();
let json = serde_json::json!({
"id": d.id,
"patterns": d.patterns,
"dark": d.icon_dark.is_some(),
"revision": revision,
});
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
let mut out = UiDecl::new(d.id.clone(), label, json);
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
out.files = files;
out.assets = assets;
Ok(out)
+10 -23
Переглянути файл
@@ -447,11 +447,6 @@ impl Host {
.collect();
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
});
// A repository folder moved or restored into the project: no `.git` event is
// written inside it before the watcher sees the new directory, its creation is
// the signal. A `git clone` / `git init` inside the project already arrives
// through the `.git` paths above; this covers the whole tree being brought in.
git_dirs |= batch.created.iter().any(|p| p.join(".git").exists());
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink, &build_cache);
}
@@ -664,7 +659,6 @@ impl Host {
.join(project_key)
}),
models_file: self.data_dir.join("agents").join("models.json"),
catalogs: self.agent_catalogs.clone(),
custom_agents_dir: Some(self.config_dir.join("agents")),
home_dir: dirs::home_dir(),
env_secrets: Some(Arc::new(AgentEnvSecrets {
@@ -681,10 +675,9 @@ impl Host {
agents.set_new_task_approvals_hook(Arc::new(move || settings.effective().agents.new_task_approvals));
}
let approvals = self.mcp_approvals.clone();
let secrets = self.agent_secrets.clone();
let root = root.to_path_buf();
agents.set_mcp_servers_hook(Box::new(move || {
crate::ops::agent::session_mcp_servers(&settings, &approvals, &secrets, &root)
crate::ops::agent::session_mcp_servers(&settings, &approvals, &root)
}));
(db, http_history, agents)
}
@@ -870,20 +863,6 @@ fn scan_repositories(
sink: &SharedSink,
build_cache: &Path,
) {
match discover_repositories(root, settings, build_cache) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
/// The repository containing `root` and those nested below it or below a content root.
/// The project-open scan and the watcher's rescan share these skip rules.
fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Path) -> IdeResult<Repos> {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
@@ -895,7 +874,15 @@ fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Pa
};
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
Repos::discover_roots(&roots, &skip)
match Repos::discover_roots(&roots, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
impl Project {
+1 -2
Переглянути файл
@@ -270,8 +270,7 @@ impl Dispatcher {
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
ops::register_all(&mut d);
d
}
-2
Переглянути файл
@@ -357,7 +357,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "docker_set_registry_password"
| "docker_run_env"
| "db_console_path"
| "db_consoles"
| "db_export_file"
| "vcs_write_patch"
| "app_metrics"
@@ -476,7 +475,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_clone"
| "vcs_init"
| "vcs_status"
| "vcs_status_paths"
| "vcs_roots"
| "vcs_branch_info"
| "vcs_line_hunks"
-98
Переглянути файл
@@ -1,98 +0,0 @@
//! Separate project windows share discovery and its backend-enforced cooldown.
#![cfg(unix)]
use ide_agent::AgentEvent;
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn windows_share_model_discovery_and_manual_refresh_cooldown() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let config = tmp.path().join("config");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(config.join("agents")).unwrap();
let script = tmp.path().join("catalog.sh");
std::fs::write(
&script,
r#"#!/bin/sh
printf 'launch\n' >> "${0%/*}/launches"
while IFS= read -r line; do
case "$line" in
*'"id":'*) rest=${line#*\"id\":}; id=${rest%%,*}; id=${id%%\}*} ;;
*) continue ;;
esac
case "$line" in
*'"method":"initialize"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{}}}\n' "$id" ;;
*'"method":"session/new"'*)
while [ ! -f "${0%/*}/release" ]; do sleep 0.01; done
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"catalog","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"new-model","options":[{"value":"new-model","name":"New model"}]}]}}\n' "$id" ;;
esac
done
"#,
)
.unwrap();
std::fs::write(
config.join("agents/catalog-test.toml"),
format!(
"name = \"Catalog test\"\ncommand = \"/bin/sh\"\nargs = [{}]\n",
serde_json::to_string(&script).unwrap()
),
)
.unwrap();
let host = Host::new(HostConfig {
config_dir: config,
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("first", &root).unwrap();
host.open_project("second", &root).unwrap();
let (send, mut events) = tokio::sync::mpsc::unbounded_channel();
for window in ["first", "second"] {
let send = send.clone();
host.project(window).unwrap().agents.subscribe(Arc::new(move |event| {
if let AgentEvent::ModelCatalog { catalog } = event {
let _ = send.send((window, catalog));
}
}));
}
for _ in 0..20 {
for window in ["first", "second"] {
let state = ops::agent::refresh_models(&host, window, "catalog-test".into(), true).unwrap();
assert!(state.refreshing);
}
}
std::fs::write(tmp.path().join("release"), "").unwrap();
let completed = tokio::time::timeout(Duration::from_secs(10), async {
let mut windows = std::collections::HashSet::new();
while windows.len() < 2 {
let (window, catalog) = events.recv().await.expect("catalog event stream closed");
if !catalog.refreshing && catalog.refreshed_at_ms.is_some() {
assert!(catalog.error.is_none(), "{:?}", catalog.error);
windows.insert(window);
}
}
})
.await;
assert!(completed.is_ok(), "both windows must receive the completed catalog");
for _ in 0..20 {
for window in ["first", "second"] {
let catalog = ops::agent::refresh_models(&host, window, "catalog-test".into(), true).unwrap();
assert!(!catalog.refreshing);
assert!(catalog.retry_at_ms.is_some());
let models = catalog.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
assert_eq!(models.values[0].value, "new-model");
}
}
assert_eq!(std::fs::read_to_string(tmp.path().join("launches")).unwrap(), "launch\n");
}
-37
Переглянути файл
@@ -1,37 +0,0 @@
use ide_core::NullSink;
use ide_host::ops::db;
use ide_host::{Host, HostConfig};
use std::sync::Arc;
#[tokio::test]
async fn new_query_consoles_are_numbered_after_the_default_one() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let names = |paths: Vec<ide_core::IpcPath>| -> Vec<String> {
paths.iter().map(|p| p.0.file_name().unwrap().to_string_lossy().into_owned()).collect()
};
assert!(db::consoles(&host, "pg", false).unwrap().is_empty());
// The first new console is the default one, then console_1, console_2…
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql"]);
assert_eq!(names(db::consoles(&host, "pg", true).unwrap()), ["console.sql", "console_1.sql"]);
let all = db::consoles(&host, "pg", true).unwrap();
assert_eq!(names(all.clone()), ["console.sql", "console_1.sql", "console_2.sql"]);
// Numbers sort numerically, and the default console path is the first one.
std::fs::write(all[0].0.with_file_name("console_10.sql"), "").unwrap();
assert_eq!(names(db::consoles(&host, "pg", false).unwrap())[3], "console_10.sql");
assert_eq!(db::console_path(&host, "pg").unwrap().0, all[0].0);
// Each database of a source has its own consoles.
assert!(db::consoles(&host, "pg:other", false).unwrap().is_empty());
assert!(db::consoles(&host, "../x", true).is_err());
}
+1 -178
Переглянути файл
@@ -112,16 +112,6 @@ async fn reads_unsaved_text_and_searches_the_overlay_with_a_limit() {
assert_eq!(found["more"], true);
let all = h.json("search_text", json!({"q":"fresh","limit":20})).await;
assert_eq!(all["items"][0]["filePath"], "src/a.txt");
// The matched line comes with the result (unsaved text included), and lines around it on request.
assert_eq!(all["items"][0]["lineText"], "fresh needle text");
assert!(all["items"][0].get("contextAfter").is_none());
let after = h.json("search_text", json!({"q":"fresh","context":"+1"})).await;
assert_eq!(after["items"][0]["contextAfter"], json!(["needle needle"]));
let before = h.json("search_regex", json!({"q":"needle needle","context":"-1"})).await;
assert_eq!(before["items"][0]["lineText"], "needle needle");
assert_eq!(before["items"][0]["contextBefore"], json!(["fresh needle text"]));
let bad = h.error("search_text", json!({"q":"fresh","context":"around"})).await;
assert!(bad.contains("context must be"), "{bad}");
let all = h.json("search_text", json!({"q":"needle","limit":20})).await;
assert!(all["items"].as_array().unwrap().iter().all(|v| !v["filePath"].as_str().unwrap().contains("ignored")));
let files = h.json("search_file", json!({"q":"*.txt","paths":["src/**"]})).await;
@@ -231,11 +221,7 @@ async fn mutations_preserve_unsaved_text_and_reject_metadata_and_escapes() {
.iter()
.rev()
.find_map(|event| match event {
// Undoable like a refactoring, but marked as the agent's, so it isn't announced as one.
IdeEvent::WorkspaceEditApplied { id, by_agent, .. } => {
assert!(*by_agent, "an MCP edit is the agent's");
Some(*id)
}
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
@@ -467,166 +453,3 @@ async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
assert!(!format!("{result:?}").contains("denied"), "{result:?}");
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked");
}
#[tokio::test(flavor = "multi_thread")]
async fn check_files_says_why_a_file_could_not_be_checked() {
let h = Harness::new();
let untrusted = h.json("check_files", json!({"filePaths": ["src/a.txt"]})).await;
assert_eq!(untrusted["files"][0]["status"], "untrusted", "{untrusted}");
h.trust().await;
let files = h.json("check_files", json!({"filePaths": ["src/a.txt", "src/gone.txt"], "timeout": 5000})).await;
assert_eq!(files["files"][0]["filePath"], "src/a.txt");
assert_eq!(files["files"][0]["status"], "noLanguageServer", "{files}");
assert_eq!(files["files"][0]["problems"], json!([]));
assert_eq!(files["files"][1]["status"], "failed", "{files}");
assert!(files["files"][1]["error"].is_string());
// No cap of the tool's own: a very long timeout is taken as given, without overflowing.
let long = h.json("check_files", json!({"filePaths": ["src/a.txt"], "timeout": 9_000_000_000_000_i64})).await;
assert_eq!(long["files"][0]["status"], "noLanguageServer", "{long}");
let none = h.error("check_files", json!({"filePaths": []})).await;
assert!(none.contains("at least one file"), "{none}");
}
/// Answers `run_command`'s window requests the way the Run tool window does: starts the
/// agent tab's configuration (shell from the default) and says whether it started. Returns
/// the configurations it started, for reruns.
#[cfg(unix)]
fn run_tool_window(h: &Harness) -> Arc<std::sync::Mutex<Vec<ide_run::RunConfig>>> {
let started = Arc::new(std::sync::Mutex::new(Vec::new()));
let (host, events, out) = (h.host.clone(), h.events.clone(), started.clone());
tokio::spawn(async move {
let mut handled = std::collections::HashSet::new();
loop {
let request = events.lock().unwrap().iter().find_map(|e| match e {
IdeEvent::McpRequest {
id,
request: ide_core::event::McpUiRequest::AgentRun { name, command, cwd, env },
} if !handled.contains(id) => Some((*id, name.clone(), command.clone(), cwd.clone(), env.clone())),
_ => None,
});
let Some((id, name, command, cwd, env)) = request else {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
continue;
};
handled.insert(id);
let mut config = ide_run::RunConfig {
id: format!("mcp-agent:{name}"),
name: name.clone(),
kind: "shell".into(),
command,
args: Vec::new(),
cwd: Some(cwd),
env,
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: true,
extras: Default::default(),
};
config.extras.params.insert(ide_host::mcp::AGENT_PARAM.into(), name);
config.extras.target = Some(String::new());
out.lock().unwrap().push(config.clone());
let result = ide_host::ops::run::start(&host, "main", config, Arc::new(|_| {})).await;
let error = result.err().map(|e| e.message);
let _ = host.mcp_requests.answer("main", id, error.is_none(), error);
}
});
started
}
#[cfg(unix)]
fn agent_process(h: &Harness, name: &str) -> ide_core::RunId {
let id = format!("mcp-agent:{name}");
h.host.processes.list().into_iter().filter(|r| r.config_id == id && r.running).map(|r| r.id).max().unwrap()
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn agent_commands_run_in_named_tabs_and_report_what_the_user_did() {
let h = Harness::new();
h.trust().await;
h.host.mcp_requests.set_ready("main", true);
h.host
.global_settings
.set(ide_workspace::SettingsScope::Global, "mcpServer.runWithoutConfirmation", json!(true))
.unwrap();
let started = run_tool_window(&h);
// A command that finishes within the wait, in a tab named with the prefix.
let done = h.json("run_command", json!({"name": "check", "command": "echo hel''lo; exit 3"})).await;
assert_eq!(done["name"], "_agent_check", "{done}");
assert_eq!(done["state"], "failed", "{done}");
assert_eq!(done["exitCode"], 3);
assert_eq!(done["startedBy"], "agent");
assert!(done["output"].as_str().unwrap().contains("hello"), "{done}");
assert!(done.get("interventions").is_none());
// The finished name runs the next command; a running one is refused, never stopped.
let running =
h.json("run_command", json!({"name": "_agent_check", "command": "echo ready; sleep 30", "timeout": 0})).await;
assert_eq!(running["state"], "running", "{running}");
let matched = h.json("wait_for_run", json!({"name": "check", "pattern": "(?m)^ready", "timeout": 10000})).await;
assert_eq!(matched["waitEnded"], "matched", "{matched}");
let refused = h.error("run_command", json!({"name": "check", "command": "true"})).await;
assert!(refused.contains("still running `echo ready; sleep 30`"), "{refused}");
assert!(refused.contains("Last output:\nready"), "the refusal quotes the last output: {refused}");
// What the user types (a line from the Run console's input) ends a wait at once, with advice for the agent.
let process = agent_process(&h, "_agent_check");
ide_host::ops::run::stdin(&h.host, process, "yes\n").unwrap();
let typed = h.json("wait_for_run", json!({"name": "check", "timeout": 10000})).await;
assert_eq!(typed["waitEnded"], "userIntervened", "{typed}");
assert_eq!(typed["interventions"][0]["message"], "User input: yes");
assert!(typed["advice"].as_str().unwrap().contains("Do not stop the process"));
let input = h.json("send_run_input", json!({"name": "check", "text": "abc", "keys": ["Enter"]})).await;
assert!(input.get("interventions").is_none(), "the agent's own input is no intervention: {input}");
let stopped = h.json("stop_run", json!({"name": "check", "force": true, "timeout": 10000})).await;
assert_eq!(stopped["state"], "stoppedByAgent", "{stopped}");
assert!(stopped.get("interventions").is_none());
// The user's Stop, rerun and closing the tab are reported with their time.
h.json("run_command", json!({"name": "server", "command": "sleep 30", "timeout": 0})).await;
ide_host::ops::run::stop(&h.host, "main", agent_process(&h, "_agent_server")).unwrap();
let stop = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
assert!(stop["interventions"][0]["message"].as_str().unwrap().starts_with("The user stopped this command at"));
let ended = h.json("wait_for_run", json!({"name": "server", "timeout": 10000})).await;
assert_eq!(ended["state"], "stoppedByUser", "{ended}");
let config = started.lock().unwrap().last().cloned().unwrap();
ide_host::ops::run::start(&h.host, "main", config, Arc::new(|_| {})).await.unwrap();
let status = h.json("get_run_status", json!({"name": "server"})).await;
assert_eq!(status["startedBy"], "user", "{status}");
assert!(status["interventions"][0]["message"].as_str().unwrap().starts_with("The user reran this command at"));
h.json("stop_run", json!({"name": "server", "force": true, "timeout": 10000})).await;
h.host.mcp_agent_runs.closed(&h.root, "_agent_server");
let closed = h.json("get_run_status", json!({"name": "server"})).await;
assert!(closed["tabClosedAt"].is_string(), "{closed}");
assert!(closed["interventions"][0]["message"].as_str().unwrap().starts_with("The user closed this tab at"));
let log = h.json("read_run_output", json!({"name": "check", "maxLinesCount": 20})).await;
assert!(log["output"].as_str().unwrap().contains("ready"), "{log}");
let from_start = h.json("read_run_output", json!({"name": "check", "offset": 0})).await;
assert!(from_start["output"].as_str().unwrap().contains("hello"), "one log across the tab's commands");
let runs = h.json("list_runs", json!({})).await;
assert_eq!(runs["runs"].as_array().unwrap().len(), 2, "{runs}");
let missing = h.error("get_run_status", json!({"name": "nothing"})).await;
assert!(missing.contains("no agent tab named `_agent_nothing`"), "{missing}");
// Pipes: plain lines, cleaned of escapes and cut to maxLinesCount (the end stays by default).
let printf = "printf '\\033[31mone\\033[0m\\ntwo\\nthree\\n'";
let lines =
h.json("run_command", json!({"name": "lines", "command": printf, "maxLinesCount": 2, "timeout": 10000})).await;
assert_eq!(lines["output"], "<<<...content truncated...>>>\ntwo\nthree\n", "{lines}");
assert_eq!(lines["truncatedLines"], 1);
let head = h.json("read_run_output", json!({"name": "lines", "maxLinesCount": 1, "truncateMode": "END"})).await;
assert_eq!(head["output"], "one\n<<<...content truncated...>>>\n", "{head}");
// Ctrl+C stops the command as stop_run does; terminal keys have no meaning on pipes.
h.json("run_command", json!({"name": "interrupt", "command": "sleep 30", "timeout": 0})).await;
let key = h.error("send_run_input", json!({"name": "interrupt", "keys": ["Up"]})).await;
assert!(key.contains("runs on pipes"), "{key}");
h.json("send_run_input", json!({"name": "interrupt", "keys": ["Ctrl+C"]})).await;
let interrupted = h.json("wait_for_run", json!({"name": "interrupt", "timeout": 10000})).await;
assert_eq!(interrupted["state"], "stoppedByAgent", "{interrupted}");
}
-133
Переглянути файл
@@ -1,133 +0,0 @@
//! Values of the user's MCP servers' environment variables and HTTP headers live in the
//! credential store; `settings.toml` keeps only their names. A project's own servers keep
//! their values in the project's file, where the project shares them.
use ide_agent::{McpKeyValue, McpScope, McpServer, McpServerInput};
use ide_core::NullSink;
use ide_core::settings::McpTransport;
use ide_host::{Host, HostConfig};
use ide_ssh::secrets::testing::{files_containing, use_memory_store};
use std::path::{Path, PathBuf};
use std::sync::Arc;
const VALUE: &str = "mcp-value-0f3c9a";
fn host(dir: &Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: dir.join("config"),
data_dir: dir.join("data"),
local_data_dir: dir.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn project(tmp: &tempfile::TempDir) -> PathBuf {
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
root
}
fn open(host: &Host, root: &Path) {
host.trust.decide(root, true, false).unwrap();
host.open_project("main", root).unwrap();
}
fn header(servers: &[McpServer], id: &str) -> String {
let server = servers.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("{id} in {servers:?}"));
server.headers.iter().find(|h| h.name == "Authorization").map(|h| h.value.clone()).unwrap_or_default()
}
fn input(name: &str, scope: McpScope) -> McpServerInput {
McpServerInput {
id: None,
name: name.into(),
scope,
transport: McpTransport::Http,
enabled: true,
command: None,
args: vec![],
env: vec![],
url: Some("https://mcp.example/v1".into()),
headers: vec![McpKeyValue { name: "Authorization".into(), value: format!("Bearer {VALUE}") }],
agents: vec![],
}
}
const SERVER_WITH_VALUE: &str = r#"
[agents.mcpServers.remote]
transport = "http"
enabled = true
url = "https://mcp.example/v1"
headers = { Authorization = "Bearer mcp-value-0f3c9a" }
"#;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_server_values_stay_out_of_the_user_settings() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = project(&tmp);
let host = host(tmp.path());
open(&host, &root);
for (name, scope) in [("user-server", McpScope::User), ("repo-server", McpScope::Workspace)] {
ide_host::ops::agent::mcp_save(&host, "main", input(name, scope)).unwrap();
}
host.close_project("main");
// Only the project's own file holds a value; nothing in the user's config or data does.
let found = files_containing(tmp.path(), VALUE);
assert!(found.len() == 1 && found[0].ends_with(Path::new(".openidea/settings.toml")), "{found:?}");
let host = self::host(tmp.path());
open(&host, &root);
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
assert_eq!(header(&servers, "user::user-server"), format!("Bearer {VALUE}"));
assert_eq!(header(&servers, "workspace::repo-server"), format!("Bearer {VALUE}"));
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn values_in_user_settings_move_to_the_credential_store() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = project(&tmp);
std::fs::create_dir_all(tmp.path().join("config")).unwrap();
let saved = SERVER_WITH_VALUE.replace("Authorization =", "X-Saved = \"\", Authorization =");
std::fs::write(tmp.path().join("config/settings.toml"), saved).unwrap();
ide_host::agent_secrets::AgentSecrets::new(&tmp.path().join("data"))
.set("mcp/user/remote/header/X-Saved", "saved-earlier")
.unwrap();
let host = host(tmp.path());
assert_eq!(files_containing(tmp.path(), VALUE), Vec::<PathBuf>::new());
open(&host, &root);
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
assert_eq!(header(&servers, "user::remote"), format!("Bearer {VALUE}"));
let remote = servers.iter().find(|s| s.id == "user::remote").unwrap();
assert!(remote.headers.iter().any(|h| h.name == "X-Saved" && h.value == "saved-earlier"), "{remote:?}");
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_projects_own_values_stay_in_its_file() {
use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = project(&tmp);
let file = root.join(".openidea/settings.toml");
std::fs::write(&file, SERVER_WITH_VALUE).unwrap();
let host = host(tmp.path());
open(&host, &root);
let servers = ide_host::ops::agent::mcp_list(&host, "main").unwrap();
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
assert_eq!(std::fs::read_to_string(&file).unwrap(), SERVER_WITH_VALUE, "reading leaves the project file alone");
// Saving the servers keeps the project's values in the project's file for everyone else.
let servers = ide_host::ops::agent::mcp_set_enabled(&host, "main", "workspace::remote".into(), false).unwrap();
assert_eq!(header(&servers, "workspace::remote"), format!("Bearer {VALUE}"));
assert!(std::fs::read_to_string(&file).unwrap().contains(VALUE));
assert_eq!(files_containing(&tmp.path().join("data"), VALUE), Vec::<PathBuf>::new());
host.close_project("main");
}
-141
Переглянути файл
@@ -1,141 +0,0 @@
//! The native `nav_inheritance` command: it must return every Python class definition
//! in the project — attached dependency roots included — together with the base names
//! written in its source, so the UI can compute inheritance gutter markers without a
//! language server's type-hierarchy provider.
use ide_host::{Host, HostConfig, ops};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
struct Opened {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
/// Opens a synthetic project whose Python files carry known class / base shapes, then
/// waits for the index to settle (it walks content roots, so dependency roots are covered).
async fn open() -> Opened {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("proj");
let src = root.join("src");
let dep = root.join("vendor");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dep).unwrap();
// Own root: a plain class, a class with two bases + a keyword arg, a qualified base,
// and a subscripted generic base. A comment / docstring that mentions `class X(Y)`
// must not be reported.
std::fs::write(
src.join("app.py"),
concat!(
"class Base:\n",
" pass\n",
"\n",
"class Mixin:\n",
" pass\n",
"\n",
"class Child(Base, Mixin, metaclass=Meta):\n",
" # class Ghost(Spook): must be ignored\n",
" \"\"\"class Phantom(Apparition): must be ignored\"\"\"\n",
" pass\n",
"\n",
"class Qualified(pkg.mod.Base):\n",
" pass\n",
"\n",
"class Generic(Generic[T]):\n",
" pass\n",
),
)
.unwrap();
// A second own-root file to prove the command scans more than one file.
std::fs::write(src.join("extra.pyi"), "class Stub(Base):\n ...\n").unwrap();
// An attached dependency root (content root) — must be covered by the index.
std::fs::write(dep.join("dep.py"), "class DepBase:\n pass\n").unwrap();
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
ops::project::open(&host, WINDOW, &root).await.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let s = ops::nav::index_status(&host, WINDOW).unwrap();
if !s.indexing && s.files > 0 {
break;
}
assert!(Instant::now() < deadline, "indexing timed out; status: {s:?}");
tokio::time::sleep(Duration::from_millis(100)).await;
}
Opened { host, root, _tmp: tmp }
}
fn find<'a>(all: &'a [ide_host::ops::nav::ClassInheritance], name: &str) -> &'a ide_host::ops::nav::ClassInheritance {
all.iter().find(|c| c.name == name).unwrap_or_else(|| panic!("no class {name:?} in {all:#?}"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn returns_all_python_classes_and_bases() {
let p = open().await;
let all = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
// Both own-root Python files are scanned (src/app.py + src/extra.pyi).
let base = find(&all, "Base");
assert!(base.path.0.ends_with("src/app.py"), "Base path: {:?}", base.path);
// The location is the class name identifier (selection-range start), i.e. after `class `.
assert_eq!(base.line, 0);
assert_eq!(base.col, 6);
assert!(base.bases.is_empty(), "a bare class has no bases: {:?}", base.bases);
let child = find(&all, "Child");
assert_eq!(child.line, 6);
assert_eq!(child.col, 6);
// The `metaclass=Meta` keyword argument is not a base.
assert!(child.bases.contains(&"Base".to_string()), "Child bases: {:?}", child.bases);
assert!(child.bases.contains(&"Mixin".to_string()), "Child bases: {:?}", child.bases);
assert!(!child.bases.iter().any(|b| b == "Meta"), "metaclass must be skipped: {:?}", child.bases);
// A qualified base keeps only its last segment.
let qualified = find(&all, "Qualified");
assert_eq!(qualified.bases, vec!["Base".to_string()], "qualified base: {:?}", qualified.bases);
// A subscripted generic base keeps the identifier.
let generic = find(&all, "Generic");
assert_eq!(generic.bases, vec!["Generic".to_string()], "generic base: {:?}", generic.bases);
// Comments / docstrings are not mistaken for class definitions.
assert!(!all.iter().any(|c| c.name == "Ghost" || c.name == "Phantom"), "parsed a comment: {all:#?}");
// The .pyi stub in the second own-root file is included.
let stub = find(&all, "Stub");
assert!(stub.path.0.ends_with("src/extra.pyi"), "Stub path: {:?}", stub.path);
assert_eq!(stub.bases, vec!["Base".to_string()]);
// The attached dependency root is covered by the project index.
let dep = find(&all, "DepBase");
assert!(dep.path.0.starts_with(&p.root), "DepBase path: {:?}", dep.path);
// Every returned entry is a Python file.
assert!(all.iter().all(|c| { matches!(c.path.0.extension().and_then(|e| e.to_str()), Some("py" | "pyi")) }));
// A file changed on disk is parsed again; the others come from the cache.
let extra = p.root.join("src/extra.pyi");
std::fs::write(&extra, "class Stub(Child): ...\nclass Added(Base): ...\n").unwrap();
let again = ops::nav::nav_inheritance(&p.host, WINDOW).await.unwrap();
assert_eq!(find(&again, "Stub").bases, vec!["Child".to_string()]);
assert_eq!(find(&again, "Added").bases, vec!["Base".to_string()]);
assert_eq!(find(&again, "Child").bases, child.bases);
}
-58
Переглянути файл
@@ -430,61 +430,3 @@ async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window
host.shutdown().await;
set_editor_providers(None);
}
/// A host with a project open: `problems` and `decorations` need a workspace.
fn harness(root: &Path) -> Arc<Host> {
let host = Arc::new(Host::new(HostConfig {
config_dir: root.join("config"),
data_dir: root.join("data"),
local_data_dir: root.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", root).unwrap();
host
}
fn plugins_on(host: &Arc<Host>, root: &Path) -> PluginHost {
let plugins = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(host)))));
plugins.install_from_dir(&fixture(), &Consent::all()).unwrap();
plugins
}
#[tokio::test(flavor = "multi_thread")]
async fn a_plugin_reads_back_the_diagnostics_the_host_recorded() {
let tmp = tempfile::tempdir().unwrap();
let host = harness(tmp.path());
let plugins = plugins_on(&host, tmp.path());
let path = tmp.path().join("a.txt");
std::fs::write(&path, "text").unwrap();
// `publish` goes through the host's recording sink into the snapshot a plugin reads.
let invocation = || Invocation { client: Some("main".into()), ..Invocation::default() };
plugins.run_command(ID, "publish", Some(path.to_str().unwrap()), invocation()).unwrap();
let problems = plugins.run_command(ID, "problems", None, invocation()).unwrap().unwrap();
assert!(problems.contains("a.txt"), "problems returned {problems:?}");
assert!(problems.ends_with(":1"), "one diagnostic from its producer, got {problems:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_line_end_annotation_takes_the_typo_and_weak_styles() {
let tmp = tempfile::tempdir().unwrap();
let host = harness(tmp.path());
let plugins = plugins_on(&host, tmp.path());
let path = tmp.path().join("a.txt");
std::fs::write(&path, "text").unwrap();
// `decorate` publishes both styles; the host drops an unknown style silently, so a
// rejection here means one of the two is not a known decoration style.
plugins
.run_command(
ID,
"decorate",
Some(path.to_str().unwrap()),
Invocation { client: Some("main".into()), ..Invocation::default() },
)
.unwrap();
}
-15
Переглянути файл
@@ -99,21 +99,6 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
let toml_path = root.join("a.toml");
std::fs::write(&toml_path, "k = 1\n").unwrap();
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
assert_eq!(
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
.await
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Unavailable
);
let sql_path = root.join("q.sql");
std::fs::write(&sql_path, "SELECT 1;").unwrap();
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
assert_eq!(
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
.parameters,
Loaded 100 of 1017 files, more files were not shown because too many files have changed in this diff. Show more