дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 22:25:48 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
953b51b8c6 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -98,10 +98,10 @@ jobs:
|
||||
# Test fixtures write LF files; keep Git from converting them on checkout.
|
||||
- run: git config --global core.autocrlf false
|
||||
- run: cargo nextest run --workspace --no-fail-fast
|
||||
# nextest ran export_bindings, which rewrites src/bindings.ts and the command snapshot.
|
||||
- name: Bindings and command snapshot are up to date
|
||||
# nextest ran export_bindings, which rewrites src/bindings.ts.
|
||||
- name: Bindings are up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: git diff --exit-code src/bindings.ts src-tauri/tests/commands.snap
|
||||
run: git diff --exit-code src/bindings.ts
|
||||
|
||||
# The remote development server ships as a static Linux binary (release-build.yml):
|
||||
# keep that build working.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: |
|
||||
@@ -181,7 +181,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
|
||||
|
||||
@@ -42,7 +42,6 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
shortcuts live in `src/platform/keymaps/`.
|
||||
- Styling: CSS Modules + tokens only (no hex literals in components). Inline SVG
|
||||
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
|
||||
`gap` uses the `--space-*` scale (`src/styles/tokens.css`, 4px grid) where a value is on it.
|
||||
- User-visible text goes through the text catalog: add every new string to both
|
||||
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
|
||||
Other locales may lack entries; their UI shows the English text until a translator adds it.
|
||||
|
||||
Створено
+44
-54
@@ -4144,9 +4144,8 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
@@ -4169,7 +4168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4183,7 +4182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4194,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
@@ -4212,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4226,18 +4225,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4254,7 +4252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4268,9 +4266,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-heap",
|
||||
@@ -4289,15 +4286,13 @@ dependencies = [
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bb8",
|
||||
@@ -4333,7 +4328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4346,7 +4341,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4367,7 +4362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4379,7 +4374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4392,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-heap"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4410,7 +4405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4481,7 +4476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
@@ -4500,7 +4495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4522,7 +4517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jdwp"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-jvm-inspect",
|
||||
"proptest",
|
||||
@@ -4534,7 +4529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4551,7 +4546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-attach"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
@@ -4560,7 +4555,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-inspect"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
@@ -4571,14 +4566,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
"ide-core",
|
||||
"ide-extension",
|
||||
"ide-text",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
@@ -4629,7 +4623,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4657,7 +4651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4676,7 +4670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4699,7 +4693,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4722,7 +4716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jvm-attach",
|
||||
@@ -4736,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4756,7 +4750,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4780,7 +4774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-scaffold"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-tools",
|
||||
@@ -4796,10 +4790,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"flate2",
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
"ide-run",
|
||||
@@ -4809,7 +4802,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
@@ -4819,7 +4811,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"encoding_rs",
|
||||
@@ -4840,7 +4832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4853,7 +4845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4878,7 +4870,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4893,7 +4885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4906,7 +4898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4919,7 +4911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4940,7 +4932,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4961,12 +4953,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"gix",
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4977,7 +4968,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"trash",
|
||||
@@ -4987,7 +4977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"ide-core",
|
||||
@@ -5003,7 +4993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6604,7 +6594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
|
||||
+1
-3
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.45"
|
||||
version = "0.1.44"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -90,8 +90,6 @@ dunce = "1"
|
||||
dirs = "6"
|
||||
memmap2 = "0.9"
|
||||
zip = { version = "9.2.3", default-features = false, features = ["deflate"] }
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
|
||||
# search, index, language
|
||||
grep-searcher = "0.1"
|
||||
|
||||
+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> · <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
|
||||
·
|
||||
@@ -23,6 +19,8 @@
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
·
|
||||
<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).
|
||||
|
||||
@@ -1,79 +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">
|
||||
Bildiğin IDE, Rust ile yeniden yazıldı.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> · <a href="README.zh-CN.md">简体中文</a> · Türkçe
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>İndir</b></a>
|
||||
·
|
||||
<a href="https://openidea.sh">Belgeler</a>
|
||||
·
|
||||
<a href="#performans-karşılaştırması">Performans</a>
|
||||
·
|
||||
<a href="#eklentiler">Eklentiler</a>
|
||||
·
|
||||
<a href="#topluluk">Topluluk</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
|
||||
OpenIDEA, IntelliJ IDEA'nın çalışma biçimini (kısayol tuşlarını, araç pencerelerini,
|
||||
her şeyi arama alışkanlığını) çekirdeği Rust ile yazılmış yerel bir uygulamaya taşır.
|
||||
|
||||
Editör, kod analizi, hata ayıklayıcı (debugger), Git, terminal, veritabanı ve HTTP istemcileri;
|
||||
60'tan fazla dil için. macOS, Windows ve Linux sürümleri var.
|
||||
|
||||
## Performans karşılaştırması
|
||||
|
||||
[freqtrade](https://github.com/freqtrade/freqtrade) projesini (Python, ~770 dosya)
|
||||
önbellekler hazırken açma, 5 çalıştırmanın ortancası:
|
||||
|
||||
| | OpenIDEA | IntelliJ IDEA 2026.2.3 |
|
||||
| ---------------------------------- | --------------------- | ---------------------- |
|
||||
| Projeyle pencerenin açılması | **0,9 sn** | 3,1 sn |
|
||||
| Hazır (CPU sakinleşti) | **1,1 sn** | 13,2 sn |
|
||||
| Bellek (USS, tüm işlemler) | **169 MB** | 2011 MB |
|
||||
| Hazır olana kadar CPU süresi | **2,4 sn** | 47,9 sn |
|
||||
| Boştayken CPU | Bir çekirdeğin **%4**'ü | Bir çekirdeğin %7'si |
|
||||
| Kapanma | **0,3 sn** | 5,9 sn |
|
||||
|
||||
`main` dalından derlenmiş bir OpenIDEA sürümü ve varsayılan ayar ve eklentilerle IDEA;
|
||||
Windows 11, 32 çekirdekli AMD, 62 GB RAM.
|
||||
IDEA, Python'u daha derinlemesine analiz eder; yani "hazır" iki tarafta aynı işi ifade etmez.
|
||||
İkisi de dışarıdan, aynı yöntemle ölçüldü: kendi makinende ve projelerinde denemek için
|
||||
[`perf/compare`](perf/compare) klasörüne bak.
|
||||
|
||||
## Eklentiler
|
||||
|
||||
Eklentiler; eylemler, kod tamamlama, hızlı düzeltmeler, araç pencereleri, özel editörler,
|
||||
temalar, dosya simgeleri ve diller ekler. Korumalı (sandbox) WebAssembly bileşenleri olarak çalışırlar;
|
||||
temalar, simge temaları ve diller için hiç kod yazmak gerekmez.
|
||||
|
||||
Eklentileri Ayarlar › Eklentiler (Settings › Plugins) bölümünden kur ya da
|
||||
`openidea-plugin new` ile kendininkini başlat. Ayrıntılar için [OpenIDEA eklentisi yazmak](https://openidea.sh/en/plugins) sayfasına ve
|
||||
[resmi eklentilere](plugins/official) bak. Eklenti API'si hâlâ önizleme aşamasında.
|
||||
|
||||
## Topluluk
|
||||
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP): sorular, geri bildirim ve sürüm haberleri.
|
||||
- WeChat grubu, katılmak için kodu okut:
|
||||
|
||||
<img src=".github/assets/wechat-group.png" alt="WeChat grubu QR kodu" width="200">
|
||||
|
||||
- Katkıda bulunmak için: [CONTRIBUTING.md](CONTRIBUTING.md) ve [katkıcı belgeleri](https://openidea.sh/en/contribute/setup).
|
||||
|
||||
## Lisans
|
||||
|
||||
[Apache 2.0](LICENSE). Pakete dahil üçüncü taraf varlıklar [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) dosyasında listelenir.
|
||||
|
||||
<sub>JetBrains ile bağlantılı değildir. IntelliJ IDEA, JetBrains s.r.o.'nun ticari markasıdır.</sub>
|
||||
@@ -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>
|
||||
·
|
||||
<a href="https://openidea.sh/zh">文档</a>
|
||||
·
|
||||
<a href="#性能对比">性能对比</a>
|
||||
·
|
||||
<a href="#插件">插件</a>
|
||||
·
|
||||
<a href="#社区">社区</a>
|
||||
·
|
||||
<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>
|
||||
@@ -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"] }
|
||||
@@ -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,41 +15,22 @@ 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> {
|
||||
let prefer_stored = crate::provider::prefer_stored_copilot_auth();
|
||||
match run_once(shared, spec, prefer_stored).await {
|
||||
Err(err)
|
||||
if !prefer_stored
|
||||
&& crate::provider::is_copilot_provider(&spec.id)
|
||||
&& crate::provider::has_unconfigured_copilot_ambient_token(&spec.env)
|
||||
&& super::auth_required(0, &err) =>
|
||||
{
|
||||
let retry = run_once(shared, spec, true).await;
|
||||
if retry.is_ok() {
|
||||
crate::provider::mark_prefer_stored_copilot_auth();
|
||||
}
|
||||
retry
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_once(shared: &Arc<Shared>, spec: &ProviderSpec, strip_ambient: bool) -> 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({
|
||||
let root = root.clone();
|
||||
move || launch.resolve_for_launch_with_copilot_env(&root, strip_ambient)
|
||||
move || launch.resolve_for_launch(&root)
|
||||
})
|
||||
.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())?;
|
||||
@@ -71,41 +51,34 @@ async fn run_once(shared: &Arc<Shared>, spec: &ProviderSpec, strip_ambient: bool
|
||||
(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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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]
|
||||
|
||||
+184
-394
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,11 +373,6 @@ struct TaskFile {
|
||||
/// The folder (in the task store) with copies of the `before` files, for rolling back.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
saved: Option<String>,
|
||||
/// In the project's own checkout, where the user and other tasks change files too: the
|
||||
/// files that changed while one of this task's turns ran, the only ones its changes list.
|
||||
/// `None` for a worktree task, and a task saved before these were recorded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
touched: Option<BTreeSet<String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
subagents: Vec<SavedSubagent>,
|
||||
/// Turns to drop from the session once it is loaded (a fork of an earlier point).
|
||||
@@ -298,9 +383,6 @@ struct TaskFile {
|
||||
title_renamed: bool,
|
||||
}
|
||||
|
||||
/// The changed files of a checkout, stamped.
|
||||
type Snapshot = BTreeMap<String, worktree::Stamp>;
|
||||
|
||||
struct TaskState {
|
||||
task: AgentTask,
|
||||
session_id: Option<String>,
|
||||
@@ -308,18 +390,11 @@ struct TaskState {
|
||||
wanted: Vec<AgentConfigChoice>,
|
||||
before: Arc<BTreeMap<String, worktree::Stamp>>,
|
||||
saved: Option<String>,
|
||||
/// See `TaskFile::touched`.
|
||||
touched: Option<Arc<BTreeSet<String>>>,
|
||||
pending_rollback: u32,
|
||||
/// The user named the task; see `TaskFile::title_renamed`.
|
||||
title_renamed: bool,
|
||||
/// A turn is running or about to (prompts arriving now are queued).
|
||||
busy: bool,
|
||||
/// A temporary side conversation: lives in memory only, never persisted.
|
||||
ephemeral: bool,
|
||||
/// The conversation a temporary side conversation starts with, sent to the agent with
|
||||
/// its first real message; the task's own entries hold only the questions.
|
||||
ephemeral_context: Option<String>,
|
||||
/// Background tasks the agent reported as running (`run_in_background` commands and
|
||||
/// the like), as it sent them: unlike a turn they outlive it, so an idle release
|
||||
/// waits for the last one. A (re)started process runs nothing yet.
|
||||
@@ -394,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,
|
||||
@@ -403,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();
|
||||
@@ -439,14 +515,8 @@ impl AgentManager {
|
||||
}
|
||||
|
||||
/// Routes events to `sink` (replacing the previous subscriber); returns every task.
|
||||
/// Ephemeral tasks belonged to the previous page session: a fresh one starts without them.
|
||||
pub fn subscribe(&self, sink: AgentSink) -> Vec<AgentTask> {
|
||||
*self.shared.sink.write() = Some(sink);
|
||||
let stale: Vec<Arc<Task>> = self.tasks.read().values().filter(|t| t.state.lock().ephemeral).cloned().collect();
|
||||
for task in &stale {
|
||||
self.drop_in_memory(task);
|
||||
self.shared.emit(AgentEvent::Removed { id: task.id.clone() });
|
||||
}
|
||||
let mut tasks: Vec<AgentTask> = self.tasks.read().values().map(|t| t.state.lock().task.clone()).collect();
|
||||
tasks.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
|
||||
tasks
|
||||
@@ -472,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
|
||||
})
|
||||
@@ -562,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) {
|
||||
@@ -676,18 +743,19 @@ impl AgentManager {
|
||||
retry: None,
|
||||
background: Box::default(),
|
||||
};
|
||||
let handle = self.register(TaskFile {
|
||||
let file = TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: Some(session_id.to_owned()),
|
||||
entries: Vec::new(),
|
||||
wanted: Vec::new(),
|
||||
before: BTreeMap::new(),
|
||||
saved: None,
|
||||
touched: Some(BTreeSet::new()),
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: true,
|
||||
});
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(task.id.clone(), handle.clone());
|
||||
handle.emit_task();
|
||||
handle.persist();
|
||||
Ok(task)
|
||||
@@ -725,8 +793,25 @@ impl AgentManager {
|
||||
(root, None, base, commit, before)
|
||||
}
|
||||
};
|
||||
// The session's settings: what the agent offered last time, with the choices applied.
|
||||
let config = self.merged_config(&spec.id, &req.config);
|
||||
// Until the session reports its own, show what the agent offered last time with the choices applied.
|
||||
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) = self.shared.known.lock().get(&spec.id).and_then(|k| k.thought_levels.get(&model))
|
||||
{
|
||||
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))
|
||||
{
|
||||
o.current = Some(choice.value.clone());
|
||||
}
|
||||
o
|
||||
}));
|
||||
}
|
||||
let now = now_ms();
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
// Copies of the files the user had changed already, for rolling back the task's changes to them.
|
||||
@@ -767,128 +852,26 @@ impl AgentManager {
|
||||
retry: None,
|
||||
background: Box::default(),
|
||||
};
|
||||
let handle = self.register(TaskFile {
|
||||
let file = TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: None,
|
||||
entries: Vec::new(),
|
||||
wanted: req.config,
|
||||
before,
|
||||
saved,
|
||||
touched: (req.isolation == Isolation::Local).then(BTreeSet::new),
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: false,
|
||||
});
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
handle.state.lock().busy = true;
|
||||
self.tasks.write().insert(task.id.clone(), handle.clone());
|
||||
handle.emit_task();
|
||||
handle.persist();
|
||||
self.shared.runtime.spawn(async move { handle.run(prompt).await });
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// Builds the in-memory handle of a fresh task, registers it and announces it. The
|
||||
/// caller decides what else its life starts with (a persisted file, a session, a run).
|
||||
fn register(&self, file: TaskFile) -> Arc<Task> {
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(handle.id.clone(), handle.clone());
|
||||
handle.emit_task();
|
||||
handle
|
||||
}
|
||||
|
||||
/// The session's settings: what the agent offered last time, with the request's
|
||||
/// choices — and the chosen model's thought levels — applied.
|
||||
fn merged_config(&self, provider: &str, req: &[AgentConfigChoice]) -> Vec<AgentConfigOption> {
|
||||
let catalog = self.shared.paths.catalogs.cached(&self.shared, provider);
|
||||
let mut levels = catalog.thought_levels;
|
||||
let mut config = self.shared.config_for_new_task(catalog.config, provider);
|
||||
for choice in req {
|
||||
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)
|
||||
{
|
||||
config.extend(levels.into_iter().map(|mut o| {
|
||||
if let Some(choice) = req.iter().find(|c| c.id == o.id && o.values.iter().any(|v| v.value == c.value)) {
|
||||
o.current = Some(choice.value.clone());
|
||||
}
|
||||
o
|
||||
}));
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// Creates a temporary side conversation of `source`: an in-memory task that is never
|
||||
/// persisted, whose session starts with its first real message (`prompt` only names
|
||||
/// the title). `source`'s conversation rides along as the context of that message.
|
||||
/// The task is read-only: its settings are pushed to a mode that asks before anything,
|
||||
/// and requests that would change the project are denied regardless. Fails for an
|
||||
/// agent that offers no way to ask.
|
||||
pub async fn create_ephemeral(&self, req: NewAgentTask, source: &str) -> IdeResult<AgentTask> {
|
||||
let spec = self
|
||||
.shared
|
||||
.spec(&req.provider)
|
||||
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
|
||||
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the topic first"))?;
|
||||
let title = title_of(&prompt);
|
||||
let mut config = self.merged_config(&spec.id, &req.config);
|
||||
config::ask_for_approvals(&mut config);
|
||||
if config::bypass_approvals(&config) {
|
||||
return Err(IdeError::invalid(
|
||||
"this agent has no mode that asks before changing things, so it can't run a side conversation",
|
||||
));
|
||||
}
|
||||
let read_only = !config::bypass_approvals(&config);
|
||||
let context = context_transcript(&self.task(source)?.state.lock().conversation.main.entries);
|
||||
let root = self.shared.paths.project_root.clone();
|
||||
let before = blocking(move || Ok(worktree::snapshot(&root).unwrap_or_default())).await?;
|
||||
let now = now_ms();
|
||||
let mut capabilities = self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default();
|
||||
capabilities.read_only = read_only;
|
||||
let task = AgentTask {
|
||||
project_root: Some(IpcPath(self.shared.paths.project_root.clone())),
|
||||
id: uuid::Uuid::new_v4().simple().to_string(),
|
||||
title,
|
||||
provider: spec.id.clone(),
|
||||
model: model_of(&config),
|
||||
status: AgentStatus::Ready,
|
||||
isolation: Isolation::Local,
|
||||
cwd: IpcPath(self.shared.paths.project_root.clone()),
|
||||
branch: None,
|
||||
base: None,
|
||||
base_commit: None,
|
||||
error: None,
|
||||
created_ms: now,
|
||||
updated_ms: now,
|
||||
commands: self.shared.known_commands(&spec.id),
|
||||
usage: None,
|
||||
queue: Vec::new(),
|
||||
capabilities,
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
background: Box::default(),
|
||||
config,
|
||||
};
|
||||
let handle = self.register(TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: None,
|
||||
entries: Vec::new(),
|
||||
wanted: req.config,
|
||||
before,
|
||||
saved: None,
|
||||
touched: None,
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: false,
|
||||
});
|
||||
{
|
||||
let mut s = handle.state.lock();
|
||||
s.ephemeral = true;
|
||||
s.ephemeral_context = Some(context);
|
||||
}
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// Sends a message. While the agent works it is queued, or with `now` the current turn
|
||||
/// is cancelled and the message goes next.
|
||||
pub async fn prompt(&self, id: &str, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
|
||||
@@ -1109,14 +1092,11 @@ impl AgentManager {
|
||||
wanted: s.wanted.clone(),
|
||||
before: (*s.before).clone(),
|
||||
saved: s.saved.clone(),
|
||||
touched: s.touched.as_deref().cloned(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: total - keep,
|
||||
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();
|
||||
@@ -1186,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)
|
||||
}
|
||||
|
||||
@@ -1309,15 +1287,6 @@ impl AgentManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a task from the manager's table and stops its agent. The disk (its file,
|
||||
/// its worktree) is the caller's business.
|
||||
fn drop_in_memory(&self, task: &Arc<Task>) {
|
||||
task.kill();
|
||||
self.tasks.write().remove(&task.id);
|
||||
*task.deleted.lock() = true;
|
||||
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{}.json", task.id)));
|
||||
}
|
||||
|
||||
/// Returns an archived task to the active list without starting the agent.
|
||||
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
@@ -1401,8 +1370,8 @@ impl AgentManager {
|
||||
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let task = self.task(id)?;
|
||||
let at = task.base();
|
||||
let (before, touched) = task.attribution();
|
||||
blocking(move || Ok(worktree::made_by_task(at.changes(&before)?, touched.as_deref()))).await
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || at.changes(&before)).await
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and, for a worktree task, the commits made since it
|
||||
@@ -1410,13 +1379,8 @@ impl AgentManager {
|
||||
pub async fn grouped_changes(&self, id: &str) -> IdeResult<AgentChanges> {
|
||||
let task = self.task(id)?;
|
||||
let at = task.base();
|
||||
let (before, touched) = task.attribution();
|
||||
blocking(move || {
|
||||
let mut grouped = at.grouped_changes(&before)?;
|
||||
grouped.uncommitted = worktree::made_by_task(grouped.uncommitted, touched.as_deref());
|
||||
Ok(grouped)
|
||||
})
|
||||
.await
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || at.grouped_changes(&before)).await
|
||||
}
|
||||
|
||||
/// Content of `path` (in the task's directory) when the task started.
|
||||
@@ -1442,16 +1406,15 @@ impl AgentManager {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
|
||||
}
|
||||
let at = task.base();
|
||||
let (before, saved, touched) = {
|
||||
let (before, saved) = {
|
||||
let s = task.state.lock();
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)), s.touched.clone())
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
|
||||
};
|
||||
let restamped = blocking(move || {
|
||||
let (changes, rev) = match view {
|
||||
AgentChangesView::Group => (at.grouped_changes(&before)?.uncommitted, worktree::head_commit(at.cwd())?),
|
||||
AgentChangesView::SinceStart => (at.changes(&before)?, at.rev()?),
|
||||
};
|
||||
let changes = worktree::made_by_task(changes, touched.as_deref());
|
||||
let sources = match rev.as_deref() {
|
||||
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
|
||||
None => Default::default(),
|
||||
@@ -1542,12 +1505,9 @@ impl Task {
|
||||
wanted: file.wanted,
|
||||
before: Arc::new(file.before),
|
||||
saved: file.saved,
|
||||
touched: file.touched.map(Arc::new),
|
||||
pending_rollback: file.pending_rollback,
|
||||
title_renamed: file.title_renamed,
|
||||
busy: false,
|
||||
ephemeral: false,
|
||||
ephemeral_context: None,
|
||||
background_tasks: Vec::new(),
|
||||
}),
|
||||
live: Mutex::new(None),
|
||||
@@ -1569,41 +1529,6 @@ impl Task {
|
||||
})
|
||||
}
|
||||
|
||||
/// The files already changed when the task started, and the files its turns changed.
|
||||
fn attribution(&self) -> (Arc<Snapshot>, Option<Arc<BTreeSet<String>>>) {
|
||||
let s = self.state.lock();
|
||||
(s.before.clone(), s.touched.clone())
|
||||
}
|
||||
|
||||
/// The changed files of the checkout as a turn starts, when the task records what its turns change.
|
||||
async fn checkout_before_turn(&self) -> Option<Snapshot> {
|
||||
let cwd = {
|
||||
let s = self.state.lock();
|
||||
s.touched.as_ref()?;
|
||||
s.task.cwd.0.clone()
|
||||
};
|
||||
blocking(move || worktree::snapshot(&cwd)).await.ok()
|
||||
}
|
||||
|
||||
/// Records the files that changed in the checkout since `start`, as the turn ends, as the task's.
|
||||
async fn record_turn_changes(&self, start: Option<Snapshot>) {
|
||||
let Some(start) = start else { return };
|
||||
let cwd = self.state.lock().task.cwd.0.clone();
|
||||
let Ok(end) = blocking(move || worktree::snapshot(&cwd)).await else { return };
|
||||
let changed = worktree::changed_between(&start, &end);
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
let Some(touched) = &s.touched else { return };
|
||||
let mut touched = (**touched).clone();
|
||||
touched.extend(changed);
|
||||
s.touched = Some(Arc::new(touched));
|
||||
}
|
||||
self.persist();
|
||||
}
|
||||
|
||||
fn base(&self) -> worktree::TaskBase {
|
||||
let s = self.state.lock();
|
||||
let cwd = s.task.cwd.0.clone();
|
||||
@@ -1684,9 +1609,6 @@ impl Task {
|
||||
if *deleted {
|
||||
return;
|
||||
}
|
||||
if self.state.lock().ephemeral {
|
||||
return;
|
||||
}
|
||||
let bytes = {
|
||||
let s = self.state.lock();
|
||||
serde_json::to_vec(&TaskFile {
|
||||
@@ -1696,7 +1618,6 @@ impl Task {
|
||||
wanted: s.wanted.clone(),
|
||||
before: (*s.before).clone(),
|
||||
saved: s.saved.clone(),
|
||||
touched: s.touched.as_deref().cloned(),
|
||||
subagents: s.conversation.saved_subagents(),
|
||||
pending_rollback: s.pending_rollback,
|
||||
title_renamed: s.title_renamed,
|
||||
@@ -1748,11 +1669,9 @@ impl Task {
|
||||
/// the user stops one. The caller set `busy`.
|
||||
async fn run(self: Arc<Self>, mut prompt: AgentPrompt) {
|
||||
loop {
|
||||
let start = self.checkout_before_turn().await;
|
||||
let result = self.turn(&prompt).await;
|
||||
self.cancel_pending.store(false, Ordering::Release);
|
||||
tokio::time::sleep(TURN_GRACE).await;
|
||||
self.record_turn_changes(start).await;
|
||||
let changed = self.state.lock().conversation.end_turn(Some(now_ms()));
|
||||
self.mark_dirty(changed);
|
||||
let send_now = self.send_now.swap(false, Ordering::AcqRel);
|
||||
@@ -1861,24 +1780,12 @@ impl Task {
|
||||
let s = self.state.lock();
|
||||
s.busy && s.task.status == AgentStatus::Running
|
||||
};
|
||||
let Some((live, method)) = live
|
||||
.filter(|l| running && (prompt.images.is_empty() || l.capabilities.images))
|
||||
.and_then(|l| steer_method(&l.init).map(|m| (l, m)))
|
||||
else {
|
||||
let Some(live) = live.filter(|l| running && (prompt.images.is_empty() || l.capabilities.images)) else {
|
||||
return Err(prompt);
|
||||
};
|
||||
let content = self.content(&live, &prompt).await;
|
||||
let sent: Result<Value, RpcError> =
|
||||
live.peer.request(method, json!({ "sessionId": live.session, "prompt": content }), None).await;
|
||||
// `_session/steering` answers with an outcome: only `injected` joined the turn
|
||||
// (`promptRequired`: no turn is running any more).
|
||||
let sent = match sent {
|
||||
Ok(reply) => match reply.get("outcome").and_then(Value::as_str) {
|
||||
None | Some("injected") => Ok(()),
|
||||
Some(outcome) => Err(reply.get("reason").and_then(Value::as_str).unwrap_or(outcome).to_owned()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
live.peer.request("_openidea/steer", json!({ "sessionId": live.session, "prompt": content }), None).await;
|
||||
if let Err(e) = sent {
|
||||
// The turn ended meanwhile: the message waits for the next one instead.
|
||||
tracing::debug!("agent task {}: steering failed: {e}", self.id);
|
||||
@@ -1932,20 +1839,7 @@ impl Task {
|
||||
if !prompt.images.is_empty() && !live.capabilities.images {
|
||||
return Err(Failure::from("This agent can't read images.".to_owned()));
|
||||
}
|
||||
// A temporary side conversation's context rides with its first real message; the
|
||||
// conversation itself records only the question (and whatever comes after).
|
||||
let prompt = {
|
||||
let context = self.state.lock().ephemeral_context.take();
|
||||
match context {
|
||||
Some(context) => {
|
||||
let mut owned = prompt.clone();
|
||||
owned.text = format!("{context}\n\n---\n\n{}", owned.text);
|
||||
owned
|
||||
}
|
||||
None => prompt.clone(),
|
||||
}
|
||||
};
|
||||
let content = self.content(&live, &prompt).await;
|
||||
let content = self.content(&live, prompt).await;
|
||||
let result: Result<Value, RpcError> =
|
||||
live.peer.request("session/prompt", json!({ "sessionId": live.session, "prompt": content }), None).await;
|
||||
match result {
|
||||
@@ -2399,29 +2293,6 @@ impl Task {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// A side conversation is read-only: deny anything that would change the project
|
||||
// (its task is deleted when its pane closes, leaving nothing to review or revert).
|
||||
if self.state.lock().ephemeral
|
||||
&& params
|
||||
.pointer("/toolCall/kind")
|
||||
.or_else(|| params.pointer("/subject/toolCall/kind"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "edit" | "delete" | "move" | "execute"))
|
||||
{
|
||||
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
|
||||
let _ = self.push_entry(AgentEntry::Permission {
|
||||
id,
|
||||
title,
|
||||
command: None,
|
||||
options,
|
||||
questions: Vec::new(),
|
||||
chosen: Some("Denied: side conversations are read-only".to_owned()),
|
||||
});
|
||||
return match reject {
|
||||
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
|
||||
None => json!({ "outcome": { "outcome": "cancelled" } }),
|
||||
};
|
||||
}
|
||||
let asked: Vec<String> = questions
|
||||
.iter()
|
||||
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
|
||||
@@ -2619,72 +2490,6 @@ fn clean(mut prompt: AgentPrompt) -> Option<AgentPrompt> {
|
||||
(!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt)
|
||||
}
|
||||
|
||||
/// The character budget of a side conversation's context: the newest entries win, the
|
||||
/// oldest are dropped first. Leaves room for the question and the agent's answer.
|
||||
const CONTEXT_BUDGET: usize = 100_000;
|
||||
|
||||
/// A long tool output trimmed to its first and last few lines, with the elided middle noted.
|
||||
fn trimmed_output(output: &str) -> String {
|
||||
const KEPT: usize = 4;
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
if lines.len() <= KEPT * 2 + 1 {
|
||||
return output.to_owned();
|
||||
}
|
||||
let omitted = lines.len() - KEPT * 2;
|
||||
let mut out = lines[..KEPT].join("\n");
|
||||
out.push_str(&format!("\n… {omitted} lines omitted …\n"));
|
||||
out.push_str(&lines[lines.len() - KEPT..].join("\n"));
|
||||
out
|
||||
}
|
||||
|
||||
fn context_entry(e: &AgentEntry) -> Option<String> {
|
||||
match e {
|
||||
AgentEntry::User { text, .. } => Some(format!("User: {text}")),
|
||||
AgentEntry::Agent { text, .. } if !text.trim().is_empty() => Some(format!("Agent: {text}")),
|
||||
AgentEntry::Thought { text, .. } if !text.trim().is_empty() => Some(format!("Thought: {text}")),
|
||||
AgentEntry::Tool { title, output, .. } => match output.as_deref() {
|
||||
Some(output) if !output.trim().is_empty() => Some(format!("Tool: {title}\n{}", trimmed_output(output))),
|
||||
_ => Some(format!("Tool: {title}")),
|
||||
},
|
||||
AgentEntry::Error { message, .. } => Some(format!("Error: {message}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The conversation as the context a temporary side conversation starts with: what was
|
||||
/// said, what was done (tool calls with trimmed output), errors and the agent's reasoning,
|
||||
/// oldest first, within [`CONTEXT_BUDGET`].
|
||||
fn context_transcript(entries: &[AgentEntry]) -> String {
|
||||
// Newest wins: walk back until the budget is spent, dropping the oldest entries first.
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut used = 0usize;
|
||||
for e in entries.iter().rev() {
|
||||
let Some(line) = context_entry(e) else { continue };
|
||||
if used + line.len() > CONTEXT_BUDGET {
|
||||
if lines.is_empty() {
|
||||
// Even the newest entry alone exceeds the budget: keep its head.
|
||||
lines.push(line.chars().take(CONTEXT_BUDGET).collect());
|
||||
}
|
||||
break;
|
||||
}
|
||||
used += line.len();
|
||||
lines.push(line);
|
||||
}
|
||||
lines.reverse();
|
||||
let dropped = entries.iter().rev().filter_map(context_entry).count() - lines.len();
|
||||
let mut context = String::from(
|
||||
"You are answering a side question in a temporary conversation. The conversation so far is below, \
|
||||
for context only; answer the new question after the marker, and do not continue the main task \
|
||||
unless asked.\n\n<conversation>\n",
|
||||
);
|
||||
if dropped > 0 {
|
||||
context.push_str(&format!("(The {dropped} oldest entries are omitted.)\n\n"));
|
||||
}
|
||||
context.push_str(&lines.join("\n\n"));
|
||||
context.push_str("\n</conversation>");
|
||||
context
|
||||
}
|
||||
|
||||
fn permission_title(params: &Value) -> String {
|
||||
let call = ¶ms["toolCall"];
|
||||
let title = call.get("title").and_then(Value::as_str).filter(|t| !t.is_empty());
|
||||
@@ -2877,29 +2682,14 @@ fn shown_background(tasks: &[Value], known: &[AgentBackgroundTask]) -> Vec<Agent
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The method that adds a message to the running turn: OpenIDEA's own, or the
|
||||
/// `_session/steering` extension an agent advertises in the response's top-level `_meta`
|
||||
/// (OMP).
|
||||
fn steer_method(init: &acp::Negotiated) -> Option<&'static str> {
|
||||
let supported = |v: Option<&Value>| v.and_then(Value::as_bool) == Some(true);
|
||||
if supported(init.meta("/openidea/steer")) {
|
||||
Some("_openidea/steer")
|
||||
} else if supported(init.meta.as_ref().and_then(|m| m.pointer("/steering/supported"))) {
|
||||
Some("_session/steering")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) {
|
||||
let meta = |p: &str| init.meta(p).and_then(Value::as_bool) == Some(true);
|
||||
let capabilities = AgentCapabilities {
|
||||
images: init.prompt("image"),
|
||||
fork: init.session("fork"),
|
||||
rollback: meta("/openidea/rollback"),
|
||||
steer: steer_method(init).is_some(),
|
||||
steer: meta("/openidea/steer"),
|
||||
background_tasks: meta("/openidea/backgroundTasks"),
|
||||
read_only: false,
|
||||
};
|
||||
(capabilities, init.prompt("embeddedContext"))
|
||||
}
|
||||
|
||||
@@ -36,9 +36,6 @@ pub struct AgentCapabilities {
|
||||
pub steer: bool,
|
||||
/// It reports its background tasks and stops them one by one.
|
||||
pub background_tasks: bool,
|
||||
/// The task runs read-only: it asks before anything, and requests that would change
|
||||
/// the project are denied (a temporary side conversation).
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -541,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")]
|
||||
@@ -591,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,
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -120,18 +120,10 @@ impl ProviderSpec {
|
||||
}
|
||||
|
||||
/// Resolve once before connecting, so authentication guidance names that same install.
|
||||
pub(crate) fn resolve_for_launch(self, cwd: &Path) -> Self {
|
||||
let strip = prefer_stored_copilot_auth();
|
||||
self.resolve_for_launch_with_copilot_env(cwd, strip)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_for_launch_with_copilot_env(mut self, cwd: &Path, strip_ambient_tokens: bool) -> Self {
|
||||
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
|
||||
if self.driver != Driver::Acp {
|
||||
self.command = crate::native::newest_install(&self.command);
|
||||
}
|
||||
if is_copilot_provider(&self.id) {
|
||||
self.env = sanitized_copilot_env(&self.env, strip_ambient_tokens);
|
||||
}
|
||||
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
|
||||
self.command =
|
||||
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
|
||||
@@ -141,69 +133,6 @@ impl ProviderSpec {
|
||||
}
|
||||
}
|
||||
|
||||
const AMBIENT_COPILOT_AUTH_VARS: [&str; 3] = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
|
||||
static PREFER_STORED_COPILOT_AUTH: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn is_copilot_provider(id: &str) -> bool {
|
||||
id == "copilot" || id.starts_with("copilot-")
|
||||
}
|
||||
|
||||
pub(crate) fn prefer_stored_copilot_auth() -> bool {
|
||||
PREFER_STORED_COPILOT_AUTH.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_prefer_stored_copilot_auth() {
|
||||
PREFER_STORED_COPILOT_AUTH.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
pub(crate) fn has_unconfigured_copilot_ambient_token(spec_env: &[(String, String)]) -> bool {
|
||||
AMBIENT_COPILOT_AUTH_VARS.iter().any(|&key| {
|
||||
!spec_env.iter().any(|(k, v)| k == key && !v.is_empty())
|
||||
&& ambient_copilot_env_value(key).is_some_and(|v| !v.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn ambient_copilot_env_value(key: &str) -> Option<String> {
|
||||
ide_core::process::shell_env()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| v.to_string_lossy().into_owned())
|
||||
.or_else(|| ide_core::process::host_var_os(key).map(|v| v.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Builds child environment overrides for `copilot`:
|
||||
/// - Always clears ambient classic PATs (`ghp_…`), which Copilot CLI unconditionally rejects
|
||||
/// (`"Classic PATs are not supported"`) instead of falling back to Keychain / `gh` login.
|
||||
/// - When `strip_ambient_tokens` is true, clears unconfigured ambient `GITHUB_TOKEN` / `GH_TOKEN` /
|
||||
/// `COPILOT_GITHUB_TOKEN` so Copilot uses its stored login (`~/.copilot` or `gh auth` keyring),
|
||||
/// while preserving any token explicitly configured in OpenIDEA's agent `spec_env`.
|
||||
pub(crate) fn sanitized_copilot_env(
|
||||
spec_env: &[(String, String)],
|
||||
strip_ambient_tokens: bool,
|
||||
) -> Vec<(String, String)> {
|
||||
sanitize_copilot_env_with(spec_env, strip_ambient_tokens, ambient_copilot_env_value)
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_copilot_env_with(
|
||||
spec_env: &[(String, String)],
|
||||
strip_ambient_tokens: bool,
|
||||
lookup: impl Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut out = Vec::with_capacity(spec_env.len() + AMBIENT_COPILOT_AUTH_VARS.len());
|
||||
for key in AMBIENT_COPILOT_AUTH_VARS {
|
||||
let explicit = spec_env.iter().any(|(k, v)| k == key && !v.is_empty());
|
||||
if explicit {
|
||||
continue;
|
||||
}
|
||||
if let Some(val) = lookup(key).filter(|v| !v.is_empty())
|
||||
&& (strip_ambient_tokens || val.trim().starts_with("ghp_"))
|
||||
{
|
||||
out.push((key.to_owned(), String::new()));
|
||||
}
|
||||
}
|
||||
out.extend(spec_env.iter().cloned());
|
||||
out
|
||||
}
|
||||
|
||||
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
|
||||
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
|
||||
let prefix = format!("Run `{}", builtin.command);
|
||||
@@ -1172,23 +1101,4 @@ mod tests {
|
||||
import_acp(dir.path(), None, "not json").unwrap_err();
|
||||
assert!(import_acp(dir.path(), None, "{}").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_classic_pats_and_ambient_tokens_while_preserving_explicit_agent_env() {
|
||||
let lookup = |k: &str| match k {
|
||||
"GITHUB_TOKEN" => Some("ghp_classic_pat_123".into()),
|
||||
"GH_TOKEN" => Some("gho_oauth_token_456".into()),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(sanitize_copilot_env_with(&[], false, lookup), vec![("GITHUB_TOKEN".into(), String::new())]);
|
||||
assert_eq!(
|
||||
sanitize_copilot_env_with(&[], true, lookup),
|
||||
vec![("GH_TOKEN".into(), String::new()), ("GITHUB_TOKEN".into(), String::new())]
|
||||
);
|
||||
let explicit = vec![("GH_TOKEN".into(), "github_pat_custom".into())];
|
||||
assert_eq!(
|
||||
sanitize_copilot_env_with(&explicit, true, lookup),
|
||||
vec![("GITHUB_TOKEN".into(), String::new()), ("GH_TOKEN".into(), "github_pat_custom".into())]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ fn paths(dir: &Path) -> AgentPaths {
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
env_secrets: None,
|
||||
catalogs: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,9 +51,6 @@ struct FakeAgent {
|
||||
/// Config options `session/new` reports after the model one (per-model efforts, say).
|
||||
extra_options: Mutex<Vec<Value>>,
|
||||
model_options: Mutex<HashMap<String, Vec<Value>>>,
|
||||
/// Advertises steering as OMP does (`_meta.steering` at the top of `initialize`)
|
||||
/// instead of `_meta.openidea.steer`.
|
||||
session_steering: bool,
|
||||
}
|
||||
|
||||
impl FakeAgent {
|
||||
@@ -159,10 +156,6 @@ impl Handler for FakeAgent {
|
||||
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
|
||||
self.calls.lock().push("subagents".into());
|
||||
}
|
||||
if self.session_steering {
|
||||
return Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true},
|
||||
"_meta": {"steering": {"supported": true}}}));
|
||||
}
|
||||
Ok(
|
||||
json!({"protocolVersion": 1, "authMethods": self.auth_methods, "agentCapabilities": {"loadSession": !self.no_load,
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}),
|
||||
@@ -190,13 +183,6 @@ impl Handler for FakeAgent {
|
||||
self.calls.lock().push(format!("{method} {detail}"));
|
||||
Ok(json!({}))
|
||||
}
|
||||
"_session/steering" => {
|
||||
let text = params["prompt"][0]["text"].clone();
|
||||
self.calls.lock().push(format!("{method} {text}"));
|
||||
// OMP answers whether the message joined the turn rather than failing.
|
||||
let outcome = if text == "too late" { "promptRequired" } else { "injected" };
|
||||
Ok(json!({"outcome": outcome, "queue": "steering"}))
|
||||
}
|
||||
"session/load" => {
|
||||
self.say(&session, "replayed history");
|
||||
Ok(json!({}))
|
||||
@@ -419,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,
|
||||
@@ -457,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"] {
|
||||
@@ -517,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 {
|
||||
@@ -572,6 +553,7 @@ for line in sys.stdin:
|
||||
effort.unwrap().current.as_deref(),
|
||||
Some(match outcome {
|
||||
"settings_timeout" => "default",
|
||||
"session_report" => "low",
|
||||
_ => "high",
|
||||
})
|
||||
);
|
||||
@@ -589,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()
|
||||
@@ -1074,42 +1054,6 @@ fn text(t: &str) -> AgentPrompt {
|
||||
AgentPrompt { text: t.into(), ..AgentPrompt::default() }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn steers_through_session_steering() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { session_steering: true, ..FakeAgent::default() });
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("wait"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Running).await;
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.capabilities.steer);
|
||||
|
||||
mgr.prompt(&task.id, text("also this"), true).await.unwrap();
|
||||
assert!(agent.calls.lock().iter().any(|c| c == "_session/steering \"also this\""));
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(matches!(entries.last(), Some(AgentEntry::User { steered: true, .. })));
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty());
|
||||
|
||||
// The agent didn't take it into the turn: the turn stops and it goes next, as without
|
||||
// steering (the fake agent's next turn asks for permission).
|
||||
mgr.prompt(&task.id, text("too late"), true).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(entries.iter().any(|e| matches!(e, AgentEntry::User { text, steered: false, .. } if text == "too late")));
|
||||
assert!(!entries.iter().any(|e| matches!(e, AgentEntry::User { text, steered: true, .. } if text == "too late")));
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1487,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(),
|
||||
);
|
||||
@@ -2261,7 +2204,6 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
|
||||
.unwrap();
|
||||
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
count_every_change(&mgr, &task.id);
|
||||
assert_eq!(task.project_root, Some(IpcPath(member.clone())));
|
||||
assert_eq!(task.base.as_deref(), Some("member-main"));
|
||||
if isolation == Isolation::Local {
|
||||
@@ -2323,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,
|
||||
@@ -2450,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() });
|
||||
@@ -2656,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,
|
||||
@@ -2719,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());
|
||||
@@ -2805,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();
|
||||
@@ -2893,14 +2865,12 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
// What the agent did, while its turn ran.
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// What the agent did.
|
||||
write("mine.txt", "my edit\nand the agent's\n");
|
||||
write("other.txt", "agent\n");
|
||||
write("new.txt", "agent\n");
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// The user's (or another task's) work between turns is not the task's.
|
||||
write("later.txt", "mine\n");
|
||||
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
|
||||
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
|
||||
}
|
||||
@@ -2916,7 +2886,6 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
|
||||
mgr.rollback_changes(&task.id, None, AgentChangesView::SinceStart).await.unwrap();
|
||||
assert_eq!(read("mine.txt"), "my edit\n", "the user's edit stays");
|
||||
assert!(!dir.join("new.txt").exists());
|
||||
assert_eq!(read("later.txt"), "mine\n", "not the task's to roll back");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
|
||||
mgr.delete(&task.id, false).await.unwrap();
|
||||
@@ -3001,17 +2970,10 @@ impl Checkout {
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
count_every_change(&mgr, &task.id);
|
||||
(mgr, task)
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts every change in the checkout as the task's, as for a task saved before tasks
|
||||
/// recorded what their turns changed: for tests that change files outside a turn.
|
||||
fn count_every_change(mgr: &AgentManager, id: &str) {
|
||||
mgr.task(id).unwrap().state.lock().touched = None;
|
||||
}
|
||||
|
||||
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
|
||||
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
|
||||
}
|
||||
@@ -3288,192 +3250,3 @@ async fn security_policy_never_auto_approves_a_request_that_only_reads_like_a_co
|
||||
"{entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A temporary side conversation is created ready, without starting its session and
|
||||
/// without writing anything to the store; a fresh subscriber (a reloaded page) sweeps
|
||||
/// it, and deleting it just drops it from memory.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ephemeral_tasks_stay_in_memory_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let source = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("hello"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
|
||||
let calls_before = agent.calls.lock().len();
|
||||
let task = mgr
|
||||
.create_ephemeral(
|
||||
NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("What changed in this file?"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
},
|
||||
&source.id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Registering it starts nothing: the session waits for its first real message.
|
||||
assert_eq!(agent.calls.lock().len(), calls_before);
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
|
||||
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
|
||||
|
||||
// Its first message starts the session like any task's, and still saves nothing: the
|
||||
// conversation records the question, not the context that rode along to the agent.
|
||||
// The fake agent's turn asks to change something, which a side conversation denies by
|
||||
// itself, so the turn ends without waiting for the user.
|
||||
mgr.prompt(&task.id, text("Why?"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let question = entries.iter().find_map(|e| match e {
|
||||
AgentEntry::User { text, .. } => Some(text.clone()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(question.as_deref(), Some("Why?"));
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c.contains("read-only"))),
|
||||
"its `cargo test` request was denied: {entries:?}"
|
||||
);
|
||||
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
|
||||
|
||||
// A fresh subscriber (a reloaded page) sweeps it: it never reaches the task list.
|
||||
let events: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_events = events.clone();
|
||||
let tasks = mgr.subscribe(Arc::new(move |e| sink_events.lock().push(e)));
|
||||
assert!(!tasks.iter().any(|t| t.id == task.id));
|
||||
assert!(mgr.task(&task.id).is_err());
|
||||
assert!(events.lock().iter().any(|e| matches!(e, AgentEvent::Removed { id } if *id == task.id)));
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
/// A side conversation's context stays within its budget: long tool outputs are cut to
|
||||
/// their head and tail, and entries that no longer fit are dropped oldest first.
|
||||
#[test]
|
||||
fn side_conversation_context_is_bounded() {
|
||||
let long_output = (0..400).map(|i| format!("line {i}")).collect::<Vec<_>>().join(
|
||||
"
|
||||
",
|
||||
);
|
||||
// Each reply alone is well inside the budget; together they are past it.
|
||||
let reply = |n: usize| AgentEntry::Agent { text: format!("reply {n} {}", "x".repeat(30_000)) };
|
||||
let entries = vec![
|
||||
AgentEntry::User {
|
||||
text: "oldest question".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: None,
|
||||
done_ms: None,
|
||||
steered: false,
|
||||
},
|
||||
reply(1),
|
||||
reply(2),
|
||||
reply(3),
|
||||
reply(4),
|
||||
AgentEntry::Tool {
|
||||
id: "t1".into(),
|
||||
title: "grep".into(),
|
||||
kind: "search".into(),
|
||||
status: AgentToolStatus::Completed,
|
||||
locations: vec![],
|
||||
output: Some(long_output),
|
||||
diffs: vec![],
|
||||
},
|
||||
AgentEntry::User {
|
||||
text: "newest question".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: None,
|
||||
done_ms: None,
|
||||
steered: false,
|
||||
},
|
||||
];
|
||||
let transcript = context_transcript(&entries);
|
||||
assert!(transcript.contains("newest question"), "the newest entry is always kept");
|
||||
assert!(transcript.contains("lines omitted"), "a long tool output is trimmed");
|
||||
assert!(transcript.contains("line 0") && transcript.contains("line 399"), "its head and tail stay");
|
||||
assert!(!transcript.contains("oldest question"), "entries past the budget are dropped oldest first");
|
||||
assert!(transcript.contains("oldest entries are omitted"));
|
||||
}
|
||||
|
||||
/// An ephemeral task denies a permission request that would change the project.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ephemeral_tasks_deny_write_permissions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let source = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Something with a conversation to reuse"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &source.id, AgentStatus::NeedsInput).await;
|
||||
let pending = mgr
|
||||
.entries(&source.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, chosen: None, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no pending permission request"));
|
||||
mgr.respond(&source.id, pending, Some("no".into())).unwrap();
|
||||
wait_for(&mgr, &source.id, AgentStatus::Ready).await;
|
||||
|
||||
let task = mgr
|
||||
.create_ephemeral(
|
||||
NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("What changed in this file?"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
},
|
||||
&source.id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(mgr.task(&task.id).unwrap().state.lock().task.capabilities.read_only);
|
||||
|
||||
let answer = mgr
|
||||
.task(&task.id)
|
||||
.unwrap()
|
||||
.ask_permission(&json!({
|
||||
"sessionId": "c1",
|
||||
"toolCall": {"toolCallId": "c1-t2", "title": "Edit main.rs", "kind": "edit"},
|
||||
"options": [
|
||||
{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Reject", "kind": "reject_once"}
|
||||
]
|
||||
}))
|
||||
.pointer("/outcome/optionId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("none")
|
||||
.to_owned();
|
||||
assert_eq!(answer, "no", "the write request is denied");
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
assert!(
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(chosen), .. } if chosen.contains("read-only"))),
|
||||
"{entries:?}"
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::model::{AgentChange, AgentChanges, AgentCommit};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, LogQuery, Repo};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
@@ -548,25 +548,6 @@ pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
|
||||
Ok(repo.status()?.into_iter().map(|s| (s.rel_path, stamp(&s.path))).collect())
|
||||
}
|
||||
|
||||
/// The files whose state changed between two snapshots of a checkout: edited, new, deleted,
|
||||
/// or back to as committed.
|
||||
pub(crate) fn changed_between(start: &BTreeMap<String, Stamp>, end: &BTreeMap<String, Stamp>) -> Vec<String> {
|
||||
let mut changed: Vec<String> =
|
||||
end.iter().filter(|(path, stamp)| start.get(*path) != Some(*stamp)).map(|(path, _)| path.clone()).collect();
|
||||
changed.extend(start.keys().filter(|path| !end.contains_key(*path)).cloned());
|
||||
changed
|
||||
}
|
||||
|
||||
/// The changes among `changes` that the task made, when it records the files its turns
|
||||
/// changed (`None`: every change counts).
|
||||
pub(crate) fn made_by_task(changes: Vec<AgentChange>, touched: Option<&BTreeSet<String>>) -> Vec<AgentChange> {
|
||||
let Some(touched) = touched else { return changes };
|
||||
changes
|
||||
.into_iter()
|
||||
.filter(|c| touched.contains(&c.rel_path) || c.old_path.as_ref().is_some_and(|old| touched.contains(old)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drops the changes that were there before the task and haven't been touched since.
|
||||
pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<String, Stamp>) -> Vec<AgentChange> {
|
||||
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
|
||||
@@ -1203,20 +1184,4 @@ mod tests {
|
||||
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
|
||||
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_changed_the_files_whose_state_moved() {
|
||||
let start: BTreeMap<String, Stamp> =
|
||||
[("edited", Some((1, 1))), ("same", Some((2, 2))), ("reverted", Some((3, 3)))]
|
||||
.into_iter()
|
||||
.map(|(p, s)| (p.to_owned(), s))
|
||||
.collect();
|
||||
let end: BTreeMap<String, Stamp> = [("edited", Some((1, 9))), ("same", Some((2, 2))), ("new", Some((4, 4)))]
|
||||
.into_iter()
|
||||
.map(|(p, s)| (p.to_owned(), s))
|
||||
.collect();
|
||||
let mut changed = changed_between(&start, &end);
|
||||
changed.sort();
|
||||
assert_eq!(changed, ["edited", "new", "reverted"]);
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,6 @@ pub enum BuildSystem {
|
||||
TypeScript,
|
||||
#[serde(rename = "cmake")]
|
||||
CMake,
|
||||
#[serde(rename = "dotnet")]
|
||||
Dotnet,
|
||||
}
|
||||
|
||||
/// One command of a build.
|
||||
@@ -167,8 +165,6 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
|
||||
{
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if dotnet_target(dir).is_some() {
|
||||
Some(BuildSystem::Dotnet)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
} else if has(dir, &["go.mod", "go.work"]) {
|
||||
@@ -229,7 +225,7 @@ pub fn plan(
|
||||
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
|
||||
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, .NET, Cargo, Go, TypeScript, CMake) found")
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
|
||||
})?;
|
||||
let module = module_dir.as_deref().filter(|d| *d != root);
|
||||
let title = match module {
|
||||
@@ -248,7 +244,6 @@ pub fn plan(
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
|
||||
BuildSystem::TypeScript => ts_steps(&dir, kind),
|
||||
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
|
||||
BuildSystem::Dotnet => dotnet_steps(&dir, kind)?,
|
||||
};
|
||||
Ok(BuildPlan { system, title, steps })
|
||||
}
|
||||
@@ -456,33 +451,6 @@ fn has_build_script(dir: &Path) -> bool {
|
||||
.is_some_and(|v| v.pointer("/scripts/build").is_some_and(serde_json::Value::is_string))
|
||||
}
|
||||
|
||||
/// The solution (`.sln` / `.slnx`) or project (`.csproj` / `.fsproj` / `.vbproj`) file `dir`
|
||||
/// builds: a solution first, then a project; the first by name when there are several.
|
||||
fn dotnet_target(dir: &Path) -> Option<PathBuf> {
|
||||
let mut files: Vec<PathBuf> =
|
||||
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
|
||||
files.sort();
|
||||
let ext = |p: &Path| p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
let solution = files.iter().find(|p| matches!(ext(p).as_str(), "sln" | "slnx"));
|
||||
solution.or_else(|| files.iter().find(|p| matches!(ext(p).as_str(), "csproj" | "fsproj" | "vbproj"))).cloned()
|
||||
}
|
||||
|
||||
/// `dotnet build` of the directory's solution or project; Rebuild skips incremental builds,
|
||||
/// Build Artifacts publishes. The summary that repeats every message is left out.
|
||||
fn dotnet_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
|
||||
let target = dotnet_target(dir).ok_or_else(|| IdeError::not_found("no .NET solution or project found"))?;
|
||||
let target = target.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
|
||||
let verb = if kind == BuildKind::Artifacts { "publish" } else { "build" };
|
||||
let mut step = BuildStep::new("dotnet", &[verb, &target, "-nologo", "-clp:NoSummary"], dir, Format::Msbuild);
|
||||
if kind == BuildKind::Rebuild {
|
||||
step.args.push("--no-incremental".into());
|
||||
}
|
||||
// The classic console logger prints the canonical `file(line,col): error` lines; the
|
||||
// terminal logger (.NET 8+) only appears on a terminal, but rules it out for older SDKs too.
|
||||
step.env.insert("MSBUILDTERMINALLOGGER".into(), "off".into());
|
||||
Ok(vec![step])
|
||||
}
|
||||
|
||||
fn ts_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
let script = has_build_script(dir);
|
||||
if script && (kind == BuildKind::Artifacts || !dir.join("tsconfig.json").is_file()) {
|
||||
@@ -1173,24 +1141,6 @@ mod tests {
|
||||
("pnpm", &["run".to_owned(), "build".to_owned()][..])
|
||||
);
|
||||
|
||||
// .NET: the solution, else the project; a module build is the file's project. An ASP.NET
|
||||
// project's package.json (its front end) doesn't make it a TypeScript build.
|
||||
let net = tmp.path().join("net");
|
||||
write(&net, "App.sln", "");
|
||||
write(&net, "src/Api/Api.csproj", "<Project/>");
|
||||
write(&net, "src/Api/package.json", "{}");
|
||||
write(&net, "src/Api/Program.cs", "");
|
||||
let p = plan(&net, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Dotnet);
|
||||
assert_eq!(p.steps[0].args, ["build", "App.sln", "-nologo", "-clp:NoSummary"]);
|
||||
assert_eq!(p.steps[0].env["MSBUILDTERMINALLOGGER"], "off");
|
||||
let p = plan(&net, &BuildModel::default(), BuildKind::Rebuild, Some(&net.join("src/Api/Program.cs")), None)
|
||||
.unwrap();
|
||||
assert_eq!(p.steps[0].args, ["build", "Api.csproj", "-nologo", "-clp:NoSummary", "--no-incremental"]);
|
||||
assert_eq!(p.steps[0].cwd, net.join("src/Api"));
|
||||
let p = plan(&net, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
|
||||
assert_eq!(p.steps[0].args[0], "publish");
|
||||
|
||||
let cm = tmp.path().join("cm");
|
||||
write(&cm, "CMakeLists.txt", "project(app)");
|
||||
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
|
||||
|
||||
@@ -25,8 +25,6 @@ pub(crate) enum Format {
|
||||
Go,
|
||||
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
|
||||
Gcc,
|
||||
/// MSBuild (`dotnet build`): `C:\p\A.cs(12,5): error CS1002: msg [C:\p\App.csproj]`.
|
||||
Msbuild,
|
||||
}
|
||||
|
||||
/// What a printed column counts.
|
||||
@@ -146,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() };
|
||||
@@ -171,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.
|
||||
@@ -229,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);
|
||||
@@ -270,43 +263,6 @@ fn tsc_line(line: &str) -> Option<RawMessage> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// MSBuild's canonical form: `path(12,5): error CS1002: msg [project]`, `path(12,5,12,9): …`,
|
||||
/// or `path : error NU1101: msg [project]` for a project-level message (no position).
|
||||
/// The path may contain spaces (`C:\Users\Jane Doe\…`); "error" / "warning" are never localized.
|
||||
fn msbuild_line(line: &str) -> Option<RawMessage> {
|
||||
let line = line.trim();
|
||||
// The project the message came from, appended by the console logger.
|
||||
let line = match line.rfind(" [") {
|
||||
Some(i) if line.ends_with(']') => &line[..i],
|
||||
_ => line,
|
||||
};
|
||||
let (head, severity, rest) =
|
||||
[(" error ", Severity::Error), (" warning ", Severity::Warning)].into_iter().find_map(|(word, severity)| {
|
||||
let i = line.find(&format!(":{word}"))?;
|
||||
Some((&line[..i], severity, &line[i + word.len() + 1..]))
|
||||
})?;
|
||||
let (code, msg) = rest.split_once(':')?;
|
||||
let code = code.trim();
|
||||
if code.is_empty() || code.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
let head = head.trim_end();
|
||||
let (path, pos) = match head.strip_suffix(')').and_then(|h| h.rsplit_once('(')) {
|
||||
Some((path, pos)) => (path, pos.split(',').map(num).collect::<Vec<_>>()),
|
||||
None => (head, Vec::new()),
|
||||
};
|
||||
let at = |i: usize| pos.get(i).copied().flatten();
|
||||
// `MSBUILD : error MSB1009` and the like name no file.
|
||||
let file = path.rsplit(['/', '\\']).next().is_some_and(|n| n.contains('.'));
|
||||
let mut m = RawMessage::new(severity, msg.trim(), ColUnit::Utf16);
|
||||
if file {
|
||||
m.path = Some(path.trim().to_owned());
|
||||
(m.line, m.col, m.end_line, m.end_col) = (at(0), at(1), at(2), at(3));
|
||||
}
|
||||
m.code = Some(code.to_owned());
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// go: `./a.go:12:5: msg` (errors only; `go vet` style `a.go:12: msg` too).
|
||||
fn go_line(line: &str) -> Option<RawMessage> {
|
||||
let (path, ln, col, rest) = path_line_col(line)?;
|
||||
@@ -443,7 +399,6 @@ impl OutputParser {
|
||||
Format::Tsc => tsc_line(&line),
|
||||
Format::Go => go_line(&line),
|
||||
Format::Gcc => gcc_line(&line),
|
||||
Format::Msbuild => msbuild_line(&line),
|
||||
Format::Cargo => None,
|
||||
};
|
||||
if let Some(msg) = parsed {
|
||||
@@ -501,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(
|
||||
@@ -591,27 +532,6 @@ mod tests {
|
||||
assert!(plain.messages.is_empty() && plain.console.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msbuild_canonical_messages() {
|
||||
let out = parse(
|
||||
Format::Msbuild,
|
||||
" Determining projects to restore...\n\
|
||||
C:\\Users\\Jane Doe\\App\\Program.cs(12,5): error CS1002: ; expected [C:\\Users\\Jane Doe\\App\\App.csproj]\n\
|
||||
/p/App/Models/User.cs(3,8,3,12): warning CS0168: The variable 'e' is declared but never used [/p/App/App.csproj]\n\
|
||||
/p/App/App.csproj : error NU1101: Unable to find package Foo. [/p/App/App.csproj]\n\
|
||||
MSBUILD : error MSB1009: Project file does not exist.\n\
|
||||
Build FAILED.\n",
|
||||
);
|
||||
assert_eq!(out.len(), 4, "{out:?}");
|
||||
assert_eq!(pos(&out[0]), (Severity::Error, "C:\\Users\\Jane Doe\\App\\Program.cs", 12, Some(5)));
|
||||
assert_eq!((out[0].code.as_deref(), out[0].text.as_str()), (Some("CS1002"), "; expected"));
|
||||
assert_eq!(pos(&out[1]), (Severity::Warning, "/p/App/Models/User.cs", 3, Some(8)));
|
||||
assert_eq!((out[1].end_line, out[1].end_col), (Some(3), Some(12)));
|
||||
assert_eq!((out[2].path.as_deref(), out[2].line), (Some("/p/App/App.csproj"), None));
|
||||
assert_eq!(out[2].text, "Unable to find package Foo.");
|
||||
assert_eq!((out[3].path.as_deref(), out[3].code.as_deref()), (None, Some("MSB1009")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsc_plain_and_pretty() {
|
||||
let out = parse(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ide-clipboard"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view) and pasted images: Windows CF_HDROP / PNG / CF_DIB or macOS NSPasteboard, no window or message loop"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP or macOS NSPasteboard file URLs, no window or message loop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -9,14 +9,8 @@ license.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSBitmapImageRep", "NSImageRep", "NSPasteboard"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
png = "0.18"
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
|
||||
[dev-dependencies]
|
||||
png = "0.18"
|
||||
@@ -18,10 +18,6 @@
|
||||
//!
|
||||
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
|
||||
//! empty and [`set_files`] fails there.
|
||||
//!
|
||||
//! [`png`] reads an image (a screenshot) for the project view's Paste, which
|
||||
//! saves it as a file: `public.png` or `public.tiff` on macOS, the registered
|
||||
//! `PNG` format or `CF_DIB` on Windows. Linux reads it through GTK in the app.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -38,19 +34,6 @@ pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// The clipboard's image as PNG bytes, None when it holds none. Call on the main
|
||||
/// thread on macOS.
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
pub fn png() -> Option<Vec<u8>> {
|
||||
imp::png()
|
||||
}
|
||||
|
||||
/// Whether the clipboard holds an image, without reading it. Call on the main
|
||||
/// thread on macOS and Linux.
|
||||
pub fn has_image() -> bool {
|
||||
imp::has_image()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -151,62 +134,9 @@ pub fn decode(bytes: &[u8]) -> Vec<PathBuf> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `BI_RGB` and `BI_BITFIELDS`: uncompressed pixels, the forms screenshots take.
|
||||
#[cfg(any(windows, test))]
|
||||
const BI_RGB: u32 = 0;
|
||||
#[cfg(any(windows, test))]
|
||||
const BI_BITFIELDS: u32 = 3;
|
||||
|
||||
/// A packed DIB (`BITMAPINFOHEADER` or a later header, then the pixels) as PNG.
|
||||
/// Only 24- and 32-bit uncompressed bitmaps, which is what screenshots and
|
||||
/// copied images are; None for anything else. A 32-bit bitmap whose alpha is
|
||||
/// all zero (most writers leave it unset) is opaque.
|
||||
#[cfg(any(windows, test))]
|
||||
fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
|
||||
let u32_at = |at: usize| dib.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
let header = u32_at(0)? as usize;
|
||||
let width = u32_at(4)? as i32;
|
||||
let height = u32_at(8)? as i32;
|
||||
let bits = u16::from_le_bytes([*dib.get(14)?, *dib.get(15)?]);
|
||||
let compression = u32_at(16)?;
|
||||
if header < 40 || width <= 0 || height == 0 || !matches!(bits, 24 | 32) {
|
||||
return None;
|
||||
}
|
||||
if compression != BI_RGB && !(compression == BI_BITFIELDS && bits == 32) {
|
||||
return None;
|
||||
}
|
||||
// A `BITMAPINFOHEADER` keeps the three bitfield masks after it; later
|
||||
// headers hold them inside. Masks other than BGRA's are not supported.
|
||||
let masks = if compression == BI_BITFIELDS && header == 40 { 12 } else { 0 };
|
||||
let colors = u32_at(32)? as usize;
|
||||
let (width, rows) = (width as usize, height.unsigned_abs() as usize);
|
||||
let stride = (width * bits as usize).div_ceil(32) * 4;
|
||||
let start = header + masks + colors * 4;
|
||||
let pixels = dib.get(start..start.checked_add(stride.checked_mul(rows)?)?)?;
|
||||
let step = bits as usize / 8;
|
||||
let mut rgba = Vec::with_capacity(width * rows * 4);
|
||||
for row in 0..rows {
|
||||
// A positive height stores the bottom row first.
|
||||
let source = if height > 0 { rows - 1 - row } else { row };
|
||||
let line = &pixels[source * stride..source * stride + width * step];
|
||||
for px in line.chunks_exact(step) {
|
||||
rgba.extend_from_slice(&[px[2], px[1], px[0], if step == 4 { px[3] } else { 255 }]);
|
||||
}
|
||||
}
|
||||
if step == 4 && rgba.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
|
||||
rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|px| px[3] = 255);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, width as u32, rows as u32);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header().ok()?.write_image_data(&rgba).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::{HEADER, decode, dib_to_png, encode, names_text};
|
||||
use super::{HEADER, decode, encode, names_text};
|
||||
use std::ffi::c_void;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
@@ -234,59 +164,6 @@ mod imp {
|
||||
fn GetClipboardData(format: u32) -> Handle;
|
||||
fn SetClipboardData(format: u32, memory: Handle) -> Handle;
|
||||
fn IsClipboardFormatAvailable(format: u32) -> i32;
|
||||
fn RegisterClipboardFormatW(name: *const u16) -> u32;
|
||||
}
|
||||
|
||||
/// `CF_DIB`: Windows synthesizes it from `CF_BITMAP` and `CF_DIBV5`, so every
|
||||
/// bitmap on the clipboard reads as one.
|
||||
const CF_DIB: u32 = 8;
|
||||
|
||||
/// The `PNG` format browsers and the Snipping Tool add beside the bitmap; it
|
||||
/// keeps transparency, so it is read first.
|
||||
fn png_format() -> u32 {
|
||||
let name: Vec<u16> = "PNG\0".encode_utf16().collect();
|
||||
unsafe { RegisterClipboardFormatW(name.as_ptr()) }
|
||||
}
|
||||
|
||||
pub fn has_image() -> bool {
|
||||
let png = png_format();
|
||||
(png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0)
|
||||
|| unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0
|
||||
}
|
||||
|
||||
/// A copy of the clipboard's `format` block. Call with the clipboard open.
|
||||
fn read_block(format: u32) -> Option<Vec<u8>> {
|
||||
// SAFETY: the clipboard keeps the block alive while it is open, and
|
||||
// `GlobalSize` bounds the bytes read from it.
|
||||
unsafe {
|
||||
let memory = GetClipboardData(format);
|
||||
if memory.is_null() {
|
||||
return None;
|
||||
}
|
||||
let data = GlobalLock(memory);
|
||||
if data.is_null() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory)).to_vec();
|
||||
GlobalUnlock(memory);
|
||||
Some(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn png() -> Option<Vec<u8>> {
|
||||
if !open() {
|
||||
return None;
|
||||
}
|
||||
let png = png_format();
|
||||
let image = if png != 0 && unsafe { IsClipboardFormatAvailable(png) } != 0 {
|
||||
read_block(png)
|
||||
} else if unsafe { IsClipboardFormatAvailable(CF_DIB) } != 0 {
|
||||
read_block(CF_DIB).and_then(|dib| dib_to_png(&dib))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
unsafe { CloseClipboard() };
|
||||
image
|
||||
}
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
@@ -450,8 +327,8 @@ mod imp {
|
||||
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
|
||||
use objc2::{ClassType, MainThreadMarker};
|
||||
use objc2_app_kit::{
|
||||
NSBitmapImageFileType, NSBitmapImageRep, NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypePNG,
|
||||
NSPasteboardTypeString, NSPasteboardTypeTIFF, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey,
|
||||
NSPasteboardWriting,
|
||||
};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL};
|
||||
use std::path::PathBuf;
|
||||
@@ -503,33 +380,6 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_image() -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
|
||||
let types = NSArray::from_slice(&[unsafe { NSPasteboardTypePNG }, unsafe { NSPasteboardTypeTIFF }]);
|
||||
NSPasteboard::generalPasteboard().availableTypeFromArray(&types).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// PNG as copied, else TIFF (what macOS screenshots and most apps write) converted.
|
||||
pub fn png() -> Option<Vec<u8>> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
// SAFETY: the types are AppKit constants, valid for the process' lifetime.
|
||||
if let Some(png) = board.dataForType(unsafe { NSPasteboardTypePNG }) {
|
||||
return Some(png.to_vec());
|
||||
}
|
||||
let tiff = board.dataForType(unsafe { NSPasteboardTypeTIFF })?;
|
||||
let image = NSBitmapImageRep::imageRepWithData(&tiff)?;
|
||||
// SAFETY: an empty property dictionary is valid for every file type.
|
||||
let png =
|
||||
unsafe { image.representationUsingType_properties(NSBitmapImageFileType::PNG, &NSDictionary::new()) }?;
|
||||
Some(png.to_vec())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
@@ -598,17 +448,6 @@ mod imp {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn has_image() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).wait_is_image_available()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn files() -> std::io::Result<Vec<PathBuf>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -682,62 +521,4 @@ mod tests {
|
||||
bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0");
|
||||
assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"]));
|
||||
}
|
||||
|
||||
/// A `BITMAPINFOHEADER` DIB of `width` × `height` (positive: bottom-up).
|
||||
fn dib(width: i32, height: i32, bits: u16, compression: u32, rows: &[&[u8]]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&40u32.to_le_bytes());
|
||||
out.extend_from_slice(&width.to_le_bytes());
|
||||
out.extend_from_slice(&height.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&bits.to_le_bytes());
|
||||
out.extend_from_slice(&compression.to_le_bytes());
|
||||
out.extend_from_slice(&[0u8; 20]);
|
||||
if compression == super::BI_BITFIELDS {
|
||||
for mask in [0x00ff_0000u32, 0x0000_ff00, 0x0000_00ff] {
|
||||
out.extend_from_slice(&mask.to_le_bytes());
|
||||
}
|
||||
}
|
||||
for row in rows {
|
||||
out.extend_from_slice(row);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_png(png: &[u8]) -> (u32, u32, Vec<u8>) {
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
|
||||
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut buf).unwrap();
|
||||
buf.truncate(info.buffer_size());
|
||||
(info.width, info.height, buf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_up_24_bit_bitmap_becomes_an_upright_opaque_png() {
|
||||
// Rows are padded to 4 bytes; the first stored row is the bottom one.
|
||||
let bottom: &[u8] = &[255, 0, 0, 0, 255, 0, 0, 0];
|
||||
let top: &[u8] = &[0, 0, 255, 255, 255, 255, 0, 0];
|
||||
let png = super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[bottom, top])).unwrap();
|
||||
let (w, h, rgba) = decode_png(&png);
|
||||
assert_eq!((w, h), (2, 2));
|
||||
assert_eq!(rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_32_bit_bitmap_keeps_its_alpha_unless_none_was_written() {
|
||||
let row: &[u8] = &[10, 20, 30, 0, 40, 50, 60, 0];
|
||||
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_BITFIELDS, &[row])).unwrap());
|
||||
assert_eq!(rgba, [30, 20, 10, 255, 60, 50, 40, 255]);
|
||||
let row: &[u8] = &[10, 20, 30, 128, 40, 50, 60, 0];
|
||||
let (_, _, rgba) = decode_png(&super::dib_to_png(&dib(2, -1, 32, super::BI_RGB, &[row])).unwrap());
|
||||
assert_eq!(rgba, [30, 20, 10, 128, 60, 50, 40, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_truncated_bitmaps_are_not_images() {
|
||||
let row: &[u8] = &[1, 2, 3, 4];
|
||||
assert!(super::dib_to_png(&dib(4, 1, 8, super::BI_RGB, &[row])).is_none());
|
||||
assert!(super::dib_to_png(&dib(2, 2, 24, super::BI_RGB, &[row])).is_none());
|
||||
assert!(super::dib_to_png(&[0; 10]).is_none());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -118,10 +118,6 @@ pub enum IdeEvent {
|
||||
LanguageServer {
|
||||
server: ServerId,
|
||||
name: String,
|
||||
/// What the user sees as the server when another program serves in its place.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
label: Option<String>,
|
||||
status: ServerStatus,
|
||||
message: Option<String>,
|
||||
},
|
||||
@@ -295,56 +291,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,10 +1,8 @@
|
||||
//! JSON with comments (`devcontainer.json`, `deno.jsonc`, `tsconfig.json`): turned
|
||||
//! into plain JSON for `serde_json`.
|
||||
|
||||
/// Strips `//` and `/* */` comments, trailing commas and a leading byte order mark (Visual
|
||||
/// Studio writes one into `launchSettings.json`) so JSONC parses as JSON.
|
||||
/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON.
|
||||
pub fn strip(src: &str) -> String {
|
||||
let src = src.strip_prefix('\u{feff}').unwrap_or(src);
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut chars = src.chars().peekable();
|
||||
let mut in_str = false;
|
||||
@@ -89,10 +87,4 @@ mod tests {
|
||||
assert_eq!(v["url"], "https://x.dev//y /* no */");
|
||||
assert_eq!(v["q"], "say \"hi\",");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_a_byte_order_mark() {
|
||||
let v: serde_json::Value = serde_json::from_str(&strip("\u{feff}{ \"a\": 1 }")).unwrap();
|
||||
assert_eq!(v["a"], 1);
|
||||
}
|
||||
}
|
||||
@@ -141,30 +141,6 @@ pub fn process_tree(pid: i32) -> Vec<i32> {
|
||||
tree
|
||||
}
|
||||
|
||||
/// Every process `pid` started, directly or not, parents before their children.
|
||||
pub fn descendants(pid: u32) -> Vec<u32> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let Ok(root) = i32::try_from(pid) else { return Vec::new() };
|
||||
process_tree(root).into_iter().skip(1).filter_map(|p| u32::try_from(p).ok()).collect()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
windows_tree::descendants(pid)
|
||||
.iter()
|
||||
// SAFETY: a live process handle opened with query access.
|
||||
.map(|h| unsafe { windows_sys::Win32::System::Threading::GetProcessId(h.as_raw_handle()) })
|
||||
.filter(|&p| p != 0)
|
||||
.collect()
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
|
||||
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
|
||||
/// git command fails. Windows has no such signal to send from outside the child's console,
|
||||
|
||||
@@ -42,8 +42,6 @@ pub struct Settings {
|
||||
pub terminal: TerminalSettings,
|
||||
pub actions_on_save: ActionsOnSaveSettings,
|
||||
pub external_tools: Vec<ExternalTool>,
|
||||
/// Tools › Diff & Merge › External Diff Tools.
|
||||
pub external_diff: ExternalDiffSettings,
|
||||
/// Language servers / debug adapters at a user-chosen location, by the
|
||||
/// command the language definition launches (`elixir-ls`, `codelldb`).
|
||||
pub tools: BTreeMap<String, ToolSettings>,
|
||||
@@ -88,18 +86,6 @@ pub struct AgentSettings {
|
||||
pub fold_turns: bool,
|
||||
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
|
||||
pub smooth_text: bool,
|
||||
/// Conversations and the composer expand to the full width of the pane instead of staying in a centred column.
|
||||
pub fit_window_width: bool,
|
||||
/// While the agent works, playful words fill the pauses in what it reports.
|
||||
pub playful_status: bool,
|
||||
/// Seconds the agent reports nothing new before the playful words take over; 0 shows them
|
||||
/// from the start.
|
||||
pub playful_after_seconds: u32,
|
||||
/// The user's playful words by activity (`working`, `thinking`, `reading`, …), replacing
|
||||
/// the built-in ones of that activity; an empty list keeps its status factual.
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = BTreeMap<String, Vec<String>>)]
|
||||
pub playful_words: BTreeMap<String, Vec<String>>,
|
||||
#[serde(deserialize_with = "or_default")]
|
||||
#[specta(type = ThinkingDisplay)]
|
||||
pub thinking: ThinkingDisplay,
|
||||
@@ -141,10 +127,6 @@ impl Default for AgentSettings {
|
||||
show_cost: false,
|
||||
fold_turns: true,
|
||||
smooth_text: true,
|
||||
fit_window_width: false,
|
||||
playful_status: false,
|
||||
playful_after_seconds: 0,
|
||||
playful_words: BTreeMap::new(),
|
||||
thinking: ThinkingDisplay::Auto,
|
||||
tool_calls: ToolCallDisplay::Grouped,
|
||||
expand_tool_output: false,
|
||||
@@ -309,7 +291,7 @@ pub enum NewTaskApprovals {
|
||||
#[derive(Clone, Debug, 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.
|
||||
@@ -318,9 +300,6 @@ pub struct McpServerSettings {
|
||||
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,
|
||||
@@ -333,8 +312,6 @@ pub struct McpServerSettings {
|
||||
|
||||
/// 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 {
|
||||
@@ -343,7 +320,6 @@ impl Default for McpServerSettings {
|
||||
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,
|
||||
@@ -484,7 +460,6 @@ impl Default for Settings {
|
||||
debugger: DebuggerSettings::default(),
|
||||
run: RunSettings::default(),
|
||||
terminal: TerminalSettings::default(),
|
||||
external_diff: ExternalDiffSettings::default(),
|
||||
actions_on_save: ActionsOnSaveSettings::default(),
|
||||
external_tools: Vec::new(),
|
||||
tools: BTreeMap::new(),
|
||||
@@ -698,28 +673,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`,
|
||||
@@ -790,7 +743,6 @@ impl Default for AppearanceSettings {
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
background_image: BackgroundImageSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -989,7 +941,6 @@ pub struct LanguagesSettings {
|
||||
pub ruby: RubySettings,
|
||||
pub markdown: MarkdownSettings,
|
||||
pub arduino: ArduinoSettings,
|
||||
pub zig: ZigSettings,
|
||||
}
|
||||
|
||||
impl Default for LanguagesSettings {
|
||||
@@ -1012,7 +963,6 @@ impl Default for LanguagesSettings {
|
||||
ruby: RubySettings::default(),
|
||||
markdown: MarkdownSettings::default(),
|
||||
arduino: ArduinoSettings::default(),
|
||||
zig: ZigSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1113,8 +1063,7 @@ impl Default for ExternalFormatterSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PythonSettings {
|
||||
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`);
|
||||
/// `target:<run-target-id>` = the Python of a Docker or Compose run target.
|
||||
/// Project interpreter executable; empty = auto-detect (project venv, else `python3`).
|
||||
pub interpreter: String,
|
||||
/// Activate the interpreter's virtualenv in new terminal sessions.
|
||||
pub activate_virtualenv: bool,
|
||||
@@ -1143,17 +1092,6 @@ impl Default for PythonSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl PythonSettings {
|
||||
/// What `interpreter` starts with when the project interpreter is a run target's.
|
||||
pub const TARGET_PREFIX: &str = "target:";
|
||||
|
||||
/// The run target whose Python is the project interpreter (`target:<id>`), if one is.
|
||||
pub fn run_target(&self) -> Option<&str> {
|
||||
let id = self.interpreter.trim().strip_prefix(Self::TARGET_PREFIX)?.trim();
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PhpSettings {
|
||||
@@ -1192,9 +1130,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)]
|
||||
@@ -1224,13 +1159,6 @@ impl Default for RustSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ZigSettings {
|
||||
/// Directory holding the `zig` executable; empty = the one on `PATH`.
|
||||
pub zig: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GoSettings {
|
||||
@@ -1398,15 +1326,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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1582,16 +1506,6 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn python_interpreter_may_name_a_run_target() {
|
||||
let python = |interpreter: &str| PythonSettings { interpreter: interpreter.into(), ..Default::default() };
|
||||
assert_eq!(python("target:web").run_target(), Some("web"));
|
||||
assert_eq!(python(" target: web ").run_target(), Some("web"));
|
||||
for local in ["", "target:", "/usr/bin/python3", ".venv/bin/python", "targets/python"] {
|
||||
assert_eq!(python(local).run_target(), None, "{local}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_one_themes_read_as_islands_themes() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
@@ -1743,15 +1657,11 @@ mod tests {
|
||||
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
|
||||
assert!(old.agents.show_cost);
|
||||
assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits);
|
||||
assert!(!old.agents.fit_window_width);
|
||||
assert!(!Settings::default().agents.show_cost);
|
||||
assert!(!Settings::default().agents.fit_window_width);
|
||||
let mut settings = Settings::default();
|
||||
settings.agents.show_usage_limits = false;
|
||||
settings.agents.fit_window_width = true;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["agents"]["showUsageLimits"], false);
|
||||
assert_eq!(json["agents"]["fitWindowWidth"], true);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.agents, settings.agents);
|
||||
}
|
||||
|
||||
@@ -175,24 +175,6 @@ impl Default for VcsConfirmationSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools › Diff & Merge › External Diff Tools: a program that shows two files' differences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExternalDiffSettings {
|
||||
/// Offer Show Diff in External Tool in diff viewers.
|
||||
pub enabled: bool,
|
||||
/// The program to start.
|
||||
pub path: String,
|
||||
/// Its arguments: `%1` the left file, `%2` the right file, `%3` the diff's title.
|
||||
pub parameters: String,
|
||||
}
|
||||
|
||||
impl Default for ExternalDiffSettings {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, path: String::new(), parameters: "%1 %2 %3".into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Version Control › Shelf.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -278,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,
|
||||
}
|
||||
|
||||
@@ -305,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(),
|
||||
}
|
||||
}
|
||||
@@ -345,8 +318,6 @@ pub struct RunSettings {
|
||||
/// Ask before closing a Run tab whose process is still running.
|
||||
pub confirm_terminate: bool,
|
||||
pub show_on_start: bool,
|
||||
/// New Run/Debug tabs start pinned: a later run doesn't take their place once finished.
|
||||
pub pin_tabs: bool,
|
||||
/// Grace period between interrupt and kill when stopping a process.
|
||||
pub termination_timeout_ms: u32,
|
||||
}
|
||||
@@ -358,7 +329,6 @@ impl Default for RunSettings {
|
||||
confirm_rerun: true,
|
||||
confirm_terminate: true,
|
||||
show_on_start: true,
|
||||
pin_tabs: false,
|
||||
termination_timeout_ms: 3000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
futures.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
base64.workspace = true
|
||||
tempfile.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
@@ -30,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"] }
|
||||
@@ -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(
|
||||
|
||||
@@ -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,29 +244,11 @@ 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)
|
||||
}
|
||||
|
||||
/// The Attach to Process candidates among the processes `root` started, parents first.
|
||||
/// Blocking, like [`attachable_processes`].
|
||||
pub fn attachable_descendants(root: u32) -> IdeResult<Vec<AttachableProcess>> {
|
||||
let order = ide_core::process::descendants(root);
|
||||
if order.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
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(|p| order.contains(&p.pid))
|
||||
.filter_map(|p| classify(p, is_dotnet, is_go_binary, native))
|
||||
.collect();
|
||||
out.sort_by_key(|p| order.iter().position(|&pid| pid == p.pid));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -432,30 +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))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_runs_candidates_are_the_processes_it_started() {
|
||||
let mut child = std::process::Command::new("sh").args(["-c", "sleep 30; true"]).spawn().unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let found = loop {
|
||||
let found = attachable_descendants(child.id()).unwrap();
|
||||
if !found.is_empty() || std::time::Instant::now() > deadline {
|
||||
break found;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
};
|
||||
for p in &found {
|
||||
let _ = std::process::Command::new("kill").arg(p.pid.to_string()).status();
|
||||
}
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
assert!(found.iter().all(|p| p.pid != child.id()), "{found:?}");
|
||||
assert!(found.iter().any(|p| p.name == "sleep" && p.kind == AttachKind::Native), "{found:?}");
|
||||
assert!(attachable_descendants(u32::MAX - 1).unwrap().is_empty());
|
||||
classify(&raw(exe, args), |e| e.ends_with("/gosrv"), "codelldb").map(|p| (p.kind, p.port))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -489,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()));
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
//! Replacing the responses a debugged page receives. js-debug hands out a WebSocket that
|
||||
//! speaks the browser's own protocol (`requestCDPProxy`); over it the browser is told to hold
|
||||
//! the requests whose URL matches a rule (`Fetch.enable`) and each held request is answered
|
||||
//! here, with the rule's response or by letting it through.
|
||||
//!
|
||||
//! A held request stays held until it is answered, so interception is always switched off
|
||||
//! (`Fetch.disable`) before the connection is given up: a page whose requests nobody answers hangs.
|
||||
|
||||
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT, js_debug};
|
||||
use base64::Engine;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::{oneshot, watch};
|
||||
use tokio_tungstenite::tungstenite::{Message, http::StatusCode};
|
||||
|
||||
/// A response given in place of the server's to every request whose URL contains `url_contains`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsResponseRule {
|
||||
pub url_contains: String,
|
||||
pub status: u16,
|
||||
pub content_type: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// The browser's URL pattern (`*` any text, `?` one character, `\` escapes) for "contains `part`".
|
||||
pub(crate) fn url_pattern(part: &str) -> String {
|
||||
let mut pattern = String::with_capacity(part.len() + 2);
|
||||
pattern.push('*');
|
||||
for c in part.chars() {
|
||||
if matches!(c, '*' | '?' | '\\') {
|
||||
pattern.push('\\');
|
||||
}
|
||||
pattern.push(c);
|
||||
}
|
||||
pattern.push('*');
|
||||
pattern
|
||||
}
|
||||
|
||||
pub(crate) fn rule_for<'a>(rules: &'a [JsResponseRule], url: &str) -> Option<&'a JsResponseRule> {
|
||||
rules.iter().find(|rule| url.contains(&rule.url_contains))
|
||||
}
|
||||
|
||||
/// What to tell the browser about a request it holds: the first matching rule's response, or
|
||||
/// to send the request on. Every held request gets one of the two.
|
||||
pub(crate) fn answer(held: &Value, rules: &[JsResponseRule]) -> (&'static str, Value) {
|
||||
let request_id = held.get("requestId").and_then(Value::as_str).unwrap_or_default();
|
||||
let url = held.pointer("/request/url").and_then(Value::as_str).unwrap_or_default();
|
||||
let Some(rule) = rule_for(rules, url) else {
|
||||
return ("Fetch.continueRequest", json!({ "requestId": request_id }));
|
||||
};
|
||||
let header = |name: &str, value: &str| json!({ "name": name, "value": value });
|
||||
// A page on another origin only reads a response that names its origin. The server would
|
||||
// say so; the response given in its place has to.
|
||||
let mut cross_origin = Vec::new();
|
||||
if let Some(origin) = request_header(held, "Origin") {
|
||||
cross_origin.push(header("Access-Control-Allow-Origin", origin));
|
||||
cross_origin.push(header("Access-Control-Allow-Credentials", "true"));
|
||||
}
|
||||
let method = held.pointer("/request/method").and_then(Value::as_str).unwrap_or_default();
|
||||
// The browser's question before such a request: allowed here, the server may not exist.
|
||||
if method == "OPTIONS"
|
||||
&& let Some(asked) = request_header(held, "Access-Control-Request-Method")
|
||||
{
|
||||
cross_origin.push(header("Access-Control-Allow-Methods", asked));
|
||||
if let Some(asked) = request_header(held, "Access-Control-Request-Headers") {
|
||||
cross_origin.push(header("Access-Control-Allow-Headers", asked));
|
||||
}
|
||||
let allowed = json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": 204,
|
||||
"responsePhrase": phrase(204),
|
||||
"responseHeaders": cross_origin,
|
||||
});
|
||||
return ("Fetch.fulfillRequest", allowed);
|
||||
}
|
||||
let mut headers = vec![header("Content-Type", &rule.content_type)];
|
||||
headers.extend(cross_origin);
|
||||
let replaced = json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": rule.status,
|
||||
"responsePhrase": phrase(rule.status),
|
||||
"responseHeaders": headers,
|
||||
"body": base64::engine::general_purpose::STANDARD.encode(rule.body.as_bytes()),
|
||||
});
|
||||
("Fetch.fulfillRequest", replaced)
|
||||
}
|
||||
|
||||
/// A header of the held request; the browser reports names as the page wrote them.
|
||||
fn request_header<'a>(held: &'a Value, name: &str) -> Option<&'a str> {
|
||||
held.pointer("/request/headers")?.as_object()?.iter().find(|(key, _)| key.eq_ignore_ascii_case(name))?.1.as_str()
|
||||
}
|
||||
|
||||
/// The browser refuses a response without a phrase when it has none of its own for the status.
|
||||
fn phrase(status: u16) -> &'static str {
|
||||
StatusCode::from_u16(status).ok().and_then(|status| status.canonical_reason()).unwrap_or("Unknown")
|
||||
}
|
||||
|
||||
fn interception(rules: &[JsResponseRule]) -> Value {
|
||||
let patterns: Vec<Value> = rules
|
||||
.iter()
|
||||
.map(|rule| json!({ "urlPattern": url_pattern(&rule.url_contains), "requestStage": "Request" }))
|
||||
.collect();
|
||||
json!({ "patterns": patterns })
|
||||
}
|
||||
|
||||
/// Applies `rules` to the page behind js-debug's CDP proxy at `ws_url` until their sender is
|
||||
/// dropped (the session is gone) or the proxy closes the connection. The browser holds
|
||||
/// requests only while there are rules. `ready` hears whether the proxy reports held
|
||||
/// requests at all; nothing is held before it has agreed to.
|
||||
pub(crate) async fn serve(
|
||||
ws_url: String,
|
||||
mut rules: watch::Receiver<Vec<JsResponseRule>>,
|
||||
ready: oneshot::Sender<Result<(), String>>,
|
||||
) {
|
||||
let socket = match tokio_tungstenite::connect_async(ws_url.as_str()).await {
|
||||
Ok((socket, _)) => socket,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (mut write, mut read) = socket.split();
|
||||
let mut last_id = 0u64;
|
||||
let mut call = |method: &str, params: Value| {
|
||||
last_id += 1;
|
||||
(last_id, Message::text(json!({ "id": last_id, "method": method, "params": params }).to_string()))
|
||||
};
|
||||
|
||||
let (subscription, subscribe) = call("JsDebug.subscribe", json!({ "events": ["Fetch.requestPaused"] }));
|
||||
let subscribed = tokio::time::timeout(REQUEST_TIMEOUT, async {
|
||||
write.send(subscribe).await.map_err(|e| e.to_string())?;
|
||||
loop {
|
||||
let reply = read.next().await.ok_or("the connection closed")?.map_err(|e| e.to_string())?;
|
||||
let Some(reply) = reply.to_text().ok().and_then(|text| serde_json::from_str::<Value>(text).ok()) else {
|
||||
continue;
|
||||
};
|
||||
if reply.get("id").and_then(Value::as_u64) != Some(subscription) {
|
||||
continue;
|
||||
}
|
||||
break match reply.get("error") {
|
||||
Some(error) => Err(error.get("message").and_then(Value::as_str).unwrap_or("refused").to_owned()),
|
||||
None => Ok(()),
|
||||
};
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("no answer".to_owned()));
|
||||
if let Err(why) = subscribed {
|
||||
let _ = ready.send(Err(why));
|
||||
let _ = write.close().await;
|
||||
return;
|
||||
}
|
||||
let _ = ready.send(Ok(()));
|
||||
|
||||
let mut current = rules.borrow_and_update().clone();
|
||||
let mut holding = !current.is_empty();
|
||||
if holding && write.send(call("Fetch.enable", interception(¤t)).1).await.is_err() {
|
||||
return;
|
||||
}
|
||||
// The request each sent replacement is for, by command id, until the browser has taken it.
|
||||
let mut replacing: HashMap<u64, String> = HashMap::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = rules.changed() => {
|
||||
if changed.is_err() {
|
||||
break;
|
||||
}
|
||||
current = rules.borrow_and_update().clone();
|
||||
let (_, command) = match (current.is_empty(), holding) {
|
||||
(true, false) => continue,
|
||||
(true, true) => call("Fetch.disable", json!({})),
|
||||
(false, _) => call("Fetch.enable", interception(¤t)),
|
||||
};
|
||||
holding = !current.is_empty();
|
||||
if write.send(command).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
message = read.next() => {
|
||||
// The proxy closed (the page or the session ended): nothing is held any more.
|
||||
let Some(Ok(message)) = message else { return };
|
||||
let Ok(text) = message.to_text() else { continue };
|
||||
let Ok(message) = serde_json::from_str::<Value>(text) else { continue };
|
||||
if let Some(id) = message.get("id").and_then(Value::as_u64) {
|
||||
// The browser did not take the replacement: let the request through, or
|
||||
// it stays held and the page waits for it.
|
||||
if let Some(request_id) = replacing.remove(&id)
|
||||
&& message.get("error").is_some()
|
||||
{
|
||||
let (_, go_on) = call("Fetch.continueRequest", json!({ "requestId": request_id }));
|
||||
if write.send(go_on).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if message.get("method").and_then(Value::as_str) != Some("Fetch.requestPaused") {
|
||||
continue;
|
||||
}
|
||||
let Some(held) = message.get("params") else { continue };
|
||||
let Some(request_id) = held.get("requestId").and_then(Value::as_str) else { continue };
|
||||
let (method, params) = answer(held, ¤t);
|
||||
let (id, command) = call(method, params);
|
||||
if method == "Fetch.fulfillRequest" {
|
||||
replacing.insert(id, request_id.to_owned());
|
||||
}
|
||||
if write.send(command).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if holding {
|
||||
let _ = write.send(call("Fetch.disable", json!({})).1).await;
|
||||
}
|
||||
let _ = write.close().await;
|
||||
}
|
||||
|
||||
impl DebugManager {
|
||||
/// Makes `rules` the page's response rules; none switches the replacing off. The first
|
||||
/// rules of a session open the connection to js-debug's CDP proxy, later ones go over it.
|
||||
/// An error means the rules are not applied.
|
||||
pub async fn js_set_response_overrides(&self, s: DebugSessionId, rules: Vec<JsResponseRule>) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
if !js_debug::is_page(&session) {
|
||||
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
|
||||
}
|
||||
// Held to the end: two callers at once would each open a connection.
|
||||
let mut applied = session.js_overrides.lock().await;
|
||||
if applied.as_ref().is_some_and(|sender| sender.send(rules.clone()).is_ok()) {
|
||||
return Ok(());
|
||||
}
|
||||
// No connection yet, or the proxy closed it.
|
||||
applied.take();
|
||||
if rules.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let refused =
|
||||
|why: String| IdeError::unavailable(format!("the JavaScript debugger has no browser connection: {why}"));
|
||||
if !js_debug::launched(&session).await {
|
||||
return Err(refused("the page's debug session did not finish starting".into()));
|
||||
}
|
||||
let proxy = session
|
||||
.conn
|
||||
.request_timeout("requestCDPProxy", json!({}), REQUEST_TIMEOUT)
|
||||
.await
|
||||
.map_err(|e| refused(e.to_string()))?;
|
||||
let (Some(host), Some(port)) =
|
||||
(proxy.get("host").and_then(Value::as_str), proxy.get("port").and_then(Value::as_u64))
|
||||
else {
|
||||
return Err(refused("no address".into()));
|
||||
};
|
||||
let path = proxy.get("path").and_then(Value::as_str).unwrap_or_default();
|
||||
let (sender, receiver) = watch::channel(rules);
|
||||
let (ready, is_ready) = oneshot::channel();
|
||||
tokio::spawn(serve(format!("ws://{host}:{port}{path}"), receiver, ready));
|
||||
match is_ready.await {
|
||||
Ok(Ok(())) => {
|
||||
*applied = Some(sender);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(why)) => Err(refused(why)),
|
||||
Err(_) => Err(refused("no answer".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
pub(crate) fn rule(url_contains: &str, status: u16, body: &str) -> JsResponseRule {
|
||||
JsResponseRule {
|
||||
url_contains: url_contains.into(),
|
||||
status,
|
||||
content_type: "application/json".into(),
|
||||
body: body.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_of_a_url_becomes_a_pattern_that_matches_it_anywhere() {
|
||||
assert_eq!(url_pattern("api/items"), "*api/items*");
|
||||
// The pattern's own wildcards and escape character are taken literally.
|
||||
assert_eq!(url_pattern(r"a*b?c\d"), r"*a\*b\?c\\d*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_rule_whose_text_is_in_the_url_answers() {
|
||||
let rules = [rule("api/items", 201, "{\"a\":\"é\"}"), rule("api", 500, "later")];
|
||||
assert_eq!(rule_for(&rules, "http://h/api/items?x=1").map(|r| r.status), Some(201));
|
||||
assert_eq!(rule_for(&rules, "http://h/api/users").map(|r| r.status), Some(500));
|
||||
assert!(rule_for(&rules, "http://h/logo.png").is_none());
|
||||
|
||||
let (command, params) = answer(&held("7", "http://h/api/items")["params"], &rules);
|
||||
assert_eq!(command, "Fetch.fulfillRequest");
|
||||
assert_eq!(
|
||||
params,
|
||||
json!({
|
||||
"requestId": "7",
|
||||
"responseCode": 201,
|
||||
"responsePhrase": "Created",
|
||||
"responseHeaders": [{ "name": "Content-Type", "value": "application/json" }],
|
||||
// base64 of the UTF-8 text `{"a":"é"}`
|
||||
"body": "eyJhIjoiw6kifQ==",
|
||||
})
|
||||
);
|
||||
// A request the browser held that no rule is for goes on to the network.
|
||||
assert_eq!(
|
||||
answer(&held("8", "http://h/logo.png")["params"], &rules),
|
||||
("Fetch.continueRequest", json!({ "requestId": "8" }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_the_browser_has_no_phrase_for_is_given_one() {
|
||||
// The browser refuses a response whose status it cannot name.
|
||||
let (_, params) = answer(&held("1", "http://h/api")["params"], &[rule("api", 419, "")]);
|
||||
assert_eq!((¶ms["responseCode"], ¶ms["responsePhrase"]), (&json!(419), &json!("Unknown")));
|
||||
}
|
||||
|
||||
fn header<'a>(params: &'a Value, name: &str) -> Option<&'a str> {
|
||||
params["responseHeaders"].as_array()?.iter().find(|h| h["name"] == name)?["value"].as_str()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_response_passes_the_pages_cross_origin_check() {
|
||||
let rules = [rule("api/items", 200, "[]")];
|
||||
let from_page = |method: &str, headers: Value| json!({ "requestId": "3", "request": { "url": "http://localhost:8080/api/items", "method": method, "headers": headers } });
|
||||
// A page on another origin: the response names that origin, as the real server would.
|
||||
let (_, params) = answer(&from_page("GET", json!({ "origin": "http://localhost:5173" })), &rules);
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Credentials"), Some("true"));
|
||||
assert_eq!(params["body"], "W10=");
|
||||
|
||||
// The browser's question before such a request is answered too: the server may not exist.
|
||||
let preflight = from_page(
|
||||
"OPTIONS",
|
||||
json!({
|
||||
"Origin": "http://localhost:5173",
|
||||
"Access-Control-Request-Method": "PUT",
|
||||
"Access-Control-Request-Headers": "content-type,x-token",
|
||||
}),
|
||||
);
|
||||
let (command, params) = answer(&preflight, &rules);
|
||||
assert_eq!((command, ¶ms["responseCode"]), ("Fetch.fulfillRequest", &json!(204)));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Origin"), Some("http://localhost:5173"));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Methods"), Some("PUT"));
|
||||
assert_eq!(header(¶ms, "Access-Control-Allow-Headers"), Some("content-type,x-token"));
|
||||
assert_eq!(params.get("body"), None);
|
||||
|
||||
// Same origin: nothing but the rule's own header.
|
||||
let (_, params) = answer(&from_page("GET", json!({})), &rules);
|
||||
assert_eq!(params["responseHeaders"], json!([{ "name": "Content-Type", "value": "application/json" }]));
|
||||
}
|
||||
|
||||
/// A stand-in for js-debug's CDP proxy: what it receives, and a way to send it events.
|
||||
pub(crate) async fn proxy() -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
|
||||
proxy_refusing(|_| false).await
|
||||
}
|
||||
|
||||
/// A proxy that answers the commands `refused` picks with an error, like the real one does
|
||||
/// for what it or the browser does not accept.
|
||||
pub(crate) async fn proxy_refusing(
|
||||
refused: fn(&Value) -> bool,
|
||||
) -> (String, mpsc::UnboundedReceiver<Value>, mpsc::UnboundedSender<Value>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
|
||||
let (received_tx, received) = mpsc::unbounded_channel();
|
||||
let (events, mut events_rx) = mpsc::unbounded_channel::<Value>();
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let (mut write, mut read) = tokio_tungstenite::accept_async(stream).await.unwrap().split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = read.next() => {
|
||||
let Some(Ok(message)) = message else { break };
|
||||
let Ok(text) = message.to_text() else { continue };
|
||||
let Ok(request) = serde_json::from_str::<Value>(text) else { continue };
|
||||
let reply = if refused(&request) {
|
||||
json!({ "id": request["id"], "error": { "code": -32602, "message": "refused" } })
|
||||
} else {
|
||||
json!({ "id": request["id"], "result": {} })
|
||||
};
|
||||
let _ = write.send(Message::text(reply.to_string())).await;
|
||||
let _ = received_tx.send(request);
|
||||
}
|
||||
Some(event) = events_rx.recv() => {
|
||||
let _ = write.send(Message::text(event.to_string())).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(url, received, events)
|
||||
}
|
||||
|
||||
pub(crate) async fn next(received: &mut mpsc::UnboundedReceiver<Value>) -> (String, Value) {
|
||||
let request = tokio::time::timeout(std::time::Duration::from_secs(5), received.recv())
|
||||
.await
|
||||
.expect("the proxy got a request in time")
|
||||
.expect("the connection is open");
|
||||
(request["method"].as_str().unwrap().to_owned(), request["params"].clone())
|
||||
}
|
||||
|
||||
/// The browser's event for a request it holds.
|
||||
fn held(request_id: &str, url: &str) -> Value {
|
||||
json!({ "method": "Fetch.requestPaused", "params": { "requestId": request_id, "request": { "url": url } } })
|
||||
}
|
||||
|
||||
type Serving = (tokio::task::JoinHandle<()>, oneshot::Receiver<Result<(), String>>);
|
||||
|
||||
fn serving(url: String, rules: watch::Receiver<Vec<JsResponseRule>>) -> Serving {
|
||||
let (ready, is_ready) = oneshot::channel();
|
||||
(tokio::spawn(serve(url, rules, ready)), is_ready)
|
||||
}
|
||||
|
||||
async fn ended(task: tokio::task::JoinHandle<()>) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), task).await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn held_requests_are_answered_and_interception_ends_before_the_connection() {
|
||||
let (url, mut received, events) = proxy().await;
|
||||
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api/items", 200, "[]")]);
|
||||
let (serving, ready) = serving(url, watched);
|
||||
assert_eq!(ready.await.unwrap(), Ok(()));
|
||||
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
("JsDebug.subscribe".into(), json!({ "events": ["Fetch.requestPaused"] }))
|
||||
);
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
(
|
||||
"Fetch.enable".into(),
|
||||
json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] })
|
||||
)
|
||||
);
|
||||
|
||||
events.send(held("1", "http://h/api/items")).unwrap();
|
||||
let (command, params) = next(&mut received).await;
|
||||
assert_eq!(
|
||||
(command.as_str(), ¶ms["requestId"], ¶ms["responseCode"]),
|
||||
("Fetch.fulfillRequest", &json!("1"), &json!(200))
|
||||
);
|
||||
events.send(held("2", "http://h/other")).unwrap();
|
||||
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "2" })));
|
||||
|
||||
// New rules replace the browser's patterns.
|
||||
rules.send(vec![rule("api/users", 404, "{}")]).unwrap();
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
(
|
||||
"Fetch.enable".into(),
|
||||
json!({ "patterns": [{ "urlPattern": "*api/users*", "requestStage": "Request" }] })
|
||||
)
|
||||
);
|
||||
|
||||
// No rules left: the browser stops holding requests. The connection stays for the
|
||||
// rules that come next, and goes with the session.
|
||||
rules.send(Vec::new()).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
rules.send(vec![rule("api/items", 200, "[]")]).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
rules.send(Vec::new()).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
drop(rules);
|
||||
ended(serving).await;
|
||||
// Already off: not switched off a second time.
|
||||
assert!(received.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_that_goes_away_switches_interception_off() {
|
||||
let (url, mut received, _events) = proxy().await;
|
||||
let (rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (serving, _ready) = serving(url, watched);
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
drop(rules);
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
ended(serving).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_response_the_browser_refuses_lets_the_request_through() {
|
||||
let (url, mut received, events) = proxy_refusing(|request| request["method"] == "Fetch.fulfillRequest").await;
|
||||
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (_serving, _ready) = serving(url, watched);
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
events.send(held("9", "http://h/api")).unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.fulfillRequest");
|
||||
// Unanswered, the request would hang the page.
|
||||
assert_eq!(next(&mut received).await, ("Fetch.continueRequest".into(), json!({ "requestId": "9" })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_proxy_that_does_not_report_held_requests_is_not_asked_to_hold_any() {
|
||||
let (url, mut received, _events) = proxy_refusing(|request| request["method"] == "JsDebug.subscribe").await;
|
||||
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (serving, ready) = serving(url, watched);
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(ready.await.unwrap(), Err("refused".to_owned()));
|
||||
ended(serving).await;
|
||||
assert!(received.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_connection_to_the_proxy_is_reported() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("ws://127.0.0.1:{}/session", listener.local_addr().unwrap().port());
|
||||
drop(listener);
|
||||
let (_rules, watched) = tokio::sync::watch::channel(vec![rule("api", 200, "[]")]);
|
||||
let (serving, ready) = serving(url, watched);
|
||||
assert!(ready.await.unwrap().is_err());
|
||||
ended(serving).await;
|
||||
}
|
||||
}
|
||||
@@ -1,323 +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;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
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.
|
||||
pub(crate) 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(())
|
||||
}
|
||||
|
||||
/// Starts recording the page's CPU profile; [`Self::js_stop_profile`] ends it.
|
||||
pub async fn js_start_profile(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
self.js_page_request(s, "startProfile", json!({ "type": "cpu" })).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ends the recording and returns a new directory holding it as `page.cpuprofile`
|
||||
/// (what `ide-profiler` reads a directory of).
|
||||
pub async fn js_stop_profile(&self, s: DebugSessionId) -> IdeResult<PathBuf> {
|
||||
let session = self.session(s)?;
|
||||
// js-debug names the file it records into when the recording starts.
|
||||
let Some(recorded) = session.js_profile.lock().take() else {
|
||||
return Err(IdeError::invalid("no CPU profile is being recorded"));
|
||||
};
|
||||
self.js_page_request(s, "stopProfile", json!({})).await?;
|
||||
static HANDED_OVER: AtomicU32 = AtomicU32::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openidea-js-profile-{}-{}-{}",
|
||||
std::process::id(),
|
||||
s.0,
|
||||
HANDED_OVER.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let copied = tokio::task::spawn_blocking({
|
||||
let dir = dir.clone();
|
||||
move || {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::copy(&recorded, dir.join("page.cpuprofile"))?;
|
||||
// The adapter's own copy is in the system's temporary directory: no use keeping two.
|
||||
let _ = std::fs::remove_file(&recorded);
|
||||
Ok::<_, std::io::Error>(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
copied.map_err(|e| IdeError::unavailable(format!("the recorded CPU profile cannot be read: {e}")))?;
|
||||
Ok(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, "");
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,6 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod js_cdp;
|
||||
mod js_debug;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
pub mod kotlin_lsp;
|
||||
@@ -25,8 +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_cdp::JsResponseRule;
|
||||
pub use js_debug::JsResponseBody;
|
||||
pub use jvm_memory::*;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -140,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`].
|
||||
@@ -402,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
|
||||
@@ -599,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,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
|
||||
}
|
||||
|
||||
@@ -767,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
|
||||
@@ -898,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());
|
||||
@@ -946,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);
|
||||
@@ -1183,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();
|
||||
}
|
||||
|
||||
@@ -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,14 +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>,
|
||||
/// The response rules of a page once a connection applies them (`js_cdp::serve`).
|
||||
/// Dropped with the session, which switches the browser's interception off.
|
||||
pub js_overrides: tokio::sync::Mutex<Option<tokio::sync::watch::Sender<Vec<crate::JsResponseRule>>>>,
|
||||
/// The file js-debug records the page's CPU profile into, while one is being recorded.
|
||||
pub js_profile: parking_lot::Mutex<Option<std::path::PathBuf>>,
|
||||
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<()>,
|
||||
@@ -240,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,
|
||||
@@ -259,9 +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(),
|
||||
js_overrides: tokio::sync::Mutex::new(None),
|
||||
js_profile: parking_lot::Mutex::new(None),
|
||||
jvm_inspector: parking_lot::Mutex::new(None),
|
||||
inspect_lock: tokio::sync::Mutex::default(),
|
||||
ended: ide_core::CancellationToken::new(),
|
||||
@@ -481,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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,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
|
||||
}
|
||||
@@ -843,16 +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 });
|
||||
}
|
||||
}
|
||||
// js-debug, after `startProfile`: where it records the profile.
|
||||
"profileStarted" => {
|
||||
*s.js_profile.lock() = str_("file").map(std::path::PathBuf::from);
|
||||
}
|
||||
_ => tracing::trace!("dap: ignored event {event}"),
|
||||
}
|
||||
}
|
||||
@@ -882,11 +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>,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -894,29 +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.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
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
|
||||
@@ -1003,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 }))
|
||||
@@ -1377,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);
|
||||
}
|
||||
}
|
||||
@@ -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,298 +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;
|
||||
}
|
||||
|
||||
/// Where the fake adapter says its CDP proxy listens (`requestCDPProxy`).
|
||||
static CDP_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
|
||||
|
||||
fn proxy_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
let port = CDP_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
(msg["command"] == "requestCDPProxy")
|
||||
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_overrides_reach_the_page_through_the_adapters_proxy() {
|
||||
use crate::js_cdp::tests::{next, proxy, rule};
|
||||
let (url, mut received, _events) = proxy().await;
|
||||
let port: u16 = url.trim_start_matches("ws://127.0.0.1:").trim_end_matches("/session").parse().unwrap();
|
||||
CDP_PROXY_PORT.store(port, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let h = harness(Script {
|
||||
start_debugging: true,
|
||||
custom: Some(proxy_adapter),
|
||||
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;
|
||||
|
||||
// Rules that are there as the page starts wait until its session takes requests.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
|
||||
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
|
||||
assert_eq!(dropped, 0, "the proxy was asked for before the page took requests");
|
||||
assert_eq!(next(&mut received).await.0, "JsDebug.subscribe");
|
||||
assert_eq!(
|
||||
next(&mut received).await,
|
||||
("Fetch.enable".into(), json!({ "patterns": [{ "urlPattern": "*api/items*", "requestStage": "Request" }] }))
|
||||
);
|
||||
// Changed rules go over the connection that is open: the proxy is asked for once.
|
||||
h.manager.js_set_response_overrides(page, vec![rule("api/users", 200, "[]")]).await.unwrap();
|
||||
assert_eq!(next(&mut received).await.1["patterns"][0]["urlPattern"], "*api/users*");
|
||||
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
|
||||
|
||||
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.disable");
|
||||
// A rule switched back on.
|
||||
h.manager.js_set_response_overrides(page, vec![rule("api/items", 200, "[]")]).await.unwrap();
|
||||
assert_eq!(next(&mut received).await.0, "Fetch.enable");
|
||||
assert_eq!(requests(&h, "requestCDPProxy").len(), 1);
|
||||
|
||||
let not_a_page = h.manager.js_set_response_overrides(browser, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_overrides_are_unavailable_without_the_adapters_proxy() {
|
||||
use crate::js_cdp::tests::rule;
|
||||
let h = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
let refused = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
// Nothing to switch off is not an error.
|
||||
h.manager.js_set_response_overrides(page, Vec::new()).await.unwrap();
|
||||
}
|
||||
|
||||
/// A port nothing listens on, which the fake adapter gives as its CDP proxy.
|
||||
static DEAD_PROXY_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
|
||||
|
||||
fn dead_proxy_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
let port = DEAD_PROXY_PORT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
(msg["command"] == "requestCDPProxy")
|
||||
.then(|| (json!({ "host": "127.0.0.1", "port": port, "path": "/session" }), Vec::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_overrides_that_cannot_reach_the_browser_are_an_error() {
|
||||
use crate::js_cdp::tests::rule;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
DEAD_PROXY_PORT.store(listener.local_addr().unwrap().port(), std::sync::atomic::Ordering::Relaxed);
|
||||
drop(listener);
|
||||
|
||||
let h = harness(Script { start_debugging: true, custom: Some(dead_proxy_adapter), ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
let unreachable = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(unreachable.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
// The next rules try again instead of going to a connection that never was.
|
||||
let again = h.manager.js_set_response_overrides(page, vec![rule("api", 200, "")]).await.unwrap_err();
|
||||
assert_eq!(again.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert_eq!(requests(&h, "requestCDPProxy").len(), 2);
|
||||
}
|
||||
|
||||
/// The file the fake adapter says it records a CPU profile into (`profileStarted`).
|
||||
static PROFILE_FILE: parking_lot::Mutex<String> = parking_lot::Mutex::new(String::new());
|
||||
|
||||
fn profiling_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
match msg["command"].as_str()? {
|
||||
"startProfile" => {
|
||||
Some((json!({}), vec![("profileStarted", json!({ "file": *PROFILE_FILE.lock(), "type": "cpu" }))]))
|
||||
}
|
||||
"stopProfile" => Some((json!({}), vec![("profilerStateUpdate", json!({ "label": "", "running": false }))])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_pages_cpu_profile_is_handed_over_as_a_cpuprofile_file() {
|
||||
let recorded = std::env::temp_dir().join(format!("openidea-dap-profile-{}", std::process::id()));
|
||||
std::fs::write(&recorded, r#"{"nodes":[],"samples":[]}"#).unwrap();
|
||||
*PROFILE_FILE.lock() = recorded.to_string_lossy().into_owned();
|
||||
|
||||
let h = harness(Script { start_debugging: true, custom: Some(profiling_adapter), ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
|
||||
// Nothing is being recorded yet.
|
||||
let idle = h.manager.js_stop_profile(page).await.unwrap_err();
|
||||
assert_eq!(idle.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
|
||||
h.manager.js_start_profile(page).await.unwrap();
|
||||
assert_eq!(requests(&h, "startProfile"), [json!({ "type": "cpu" })]);
|
||||
let dir = h.manager.js_stop_profile(page).await.unwrap();
|
||||
assert_eq!(requests(&h, "stopProfile").len(), 1);
|
||||
let files: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(files, ["page.cpuprofile"]);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("page.cpuprofile")).unwrap(), r#"{"nodes":[],"samples":[]}"#);
|
||||
// One recording is handed over once.
|
||||
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
|
||||
// The adapter's file isn't there when it is asked for (the hand-over above took the first one).
|
||||
assert!(!recorded.exists());
|
||||
h.manager.js_start_profile(page).await.unwrap();
|
||||
assert_eq!(h.manager.js_stop_profile(page).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
|
||||
assert_eq!(h.manager.js_start_profile(browser).await.unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -542,24 +542,6 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
/// Runs the docker CLI with `args` in `dir` and returns its standard output; a non-zero
|
||||
/// exit is an error with docker's message.
|
||||
pub fn cli(
|
||||
ep: &DockerEndpoint,
|
||||
dir: Option<&Path>,
|
||||
args: &[String],
|
||||
timeout: Duration,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<String> {
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
let mut cmd = command(ep, &args)?;
|
||||
if let Some(dir) = dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let out = process::run(cmd, timeout, cancel)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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.9.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)?;
|
||||
|
||||
@@ -325,20 +325,6 @@ pub fn migrate(data: &Path, local: &Path) {
|
||||
if cfg!(target_os = "windows") && data != local {
|
||||
migrate_entries(data, local);
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
relative_tool_shims(&local.join("tools"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrappers written before they named their tool relative to themselves hold its absolute
|
||||
/// path, which cmd.exe can't find through a non-ASCII user directory (#3338).
|
||||
fn relative_tool_shims(tools: &Path) {
|
||||
let Ok(entries) = fs::read_dir(tools) else { return };
|
||||
for dir in entries.flatten().map(|e| e.path()).filter(|p| p.is_dir()) {
|
||||
if let Err(error) = relocate_shims(&dir, &dir) {
|
||||
tracing::warn!(%error, ?dir, "cannot make the tool's wrappers relative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_entries(data: &Path, local: &Path) {
|
||||
@@ -665,22 +651,6 @@ mod tests {
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_tool_wrappers_become_relative() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let tools = tmp.path().join("tools");
|
||||
let tool = tools.join("taplo");
|
||||
fs::create_dir_all(tool.join("bin")).unwrap();
|
||||
let shim = tool.join("bin/taplo.cmd");
|
||||
fs::write(&shim, format!("@echo off\r\n\"{}\\taplo.exe\" %*\r\n", tool.display())).unwrap();
|
||||
// A wrapper the user re-saved in another encoding is left alone.
|
||||
fs::create_dir_all(tools.join("other/bin")).unwrap();
|
||||
fs::write(tools.join("other/bin/x.cmd"), b"@echo off\r\n\"C:\\\xd5\xc5\" %*\r\n").unwrap();
|
||||
relative_tool_shims(&tools);
|
||||
assert_eq!(fs::read_to_string(&shim).unwrap(), "@echo off\r\n\"%~dp0..\\taplo.exe\" %*\r\n");
|
||||
relative_tool_shims(&tmp.path().join("missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
|
||||
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -251,12 +251,18 @@ impl KotlinBridge {
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let (text, notify) = match other {
|
||||
Ok(Ok(outcome)) => failed_build(&outcome),
|
||||
Ok(Err(error)) => (error.message, true),
|
||||
Err(error) => (error.to_string(), true),
|
||||
let text = match other {
|
||||
Ok(Ok(outcome)) => outcome
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.severity == Severity::Error)
|
||||
.map(|m| m.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
Ok(Err(error)) => error.message,
|
||||
Err(error) => error.to_string(),
|
||||
};
|
||||
self.report(text, notify, &mut diagnostics, &mut last_failure);
|
||||
self.failure(text, &mut diagnostics, &mut last_failure);
|
||||
remove_directory(directory).await;
|
||||
}
|
||||
}
|
||||
@@ -270,12 +276,6 @@ impl KotlinBridge {
|
||||
}
|
||||
|
||||
fn failure(&self, text: String, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
|
||||
self.report(text, true, paths, previous);
|
||||
}
|
||||
|
||||
/// Publishes a failed build in Problems (on `pom.xml`) and, when `notify`, as a
|
||||
/// notification once per distinct message.
|
||||
fn report(&self, text: String, notify: bool, paths: &mut BTreeSet<PathBuf>, previous: &mut String) {
|
||||
let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text };
|
||||
let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API.");
|
||||
self.clear_diagnostics(paths);
|
||||
@@ -295,7 +295,7 @@ impl KotlinBridge {
|
||||
deprecated: false,
|
||||
}],
|
||||
});
|
||||
if notify && previous != &text {
|
||||
if previous != &text {
|
||||
self.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
title: "Kotlin editor compilation failed".into(),
|
||||
@@ -306,15 +306,6 @@ impl KotlinBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// The error text of a failed bridge build, and whether to notify about it. Compiler errors
|
||||
/// in the user's own Kotlin already show in its editor and stay in Problems; only a build
|
||||
/// that failed without one (Maven itself, a missing plugin) is worth a notification.
|
||||
fn failed_build(outcome: &Outcome) -> (String, bool) {
|
||||
let errors: Vec<_> = outcome.messages.iter().filter(|m| m.severity == Severity::Error).collect();
|
||||
let text = errors.iter().map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
|
||||
(text, !errors.iter().any(|m| m.path.is_some()))
|
||||
}
|
||||
|
||||
fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool {
|
||||
let input = path.extension().is_some_and(|e| e == "kt")
|
||||
|| path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd")
|
||||
@@ -392,23 +383,6 @@ mod tests {
|
||||
assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_errors_stay_out_of_notifications() {
|
||||
let message = |text: &str, path: Option<&str>| ide_build::compile::BuildMessage {
|
||||
severity: Severity::Error,
|
||||
text: text.into(),
|
||||
path: path.map(|p| PathBuf::from(p).into()),
|
||||
range: None,
|
||||
code: None,
|
||||
};
|
||||
let outcome = |messages| Outcome { projects: Vec::new(), messages, success: false };
|
||||
let (text, notify) = failed_build(&outcome(vec![message("No value passed", Some("/p/src/A.kt"))]));
|
||||
assert_eq!((text.as_str(), notify), ("No value passed", false));
|
||||
let (text, notify) = failed_build(&outcome(vec![message("Plugin not found", None)]));
|
||||
assert_eq!((text.as_str(), notify), ("Plugin not found", true));
|
||||
assert!(failed_build(&outcome(Vec::new())).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extensionless_root_file_does_not_trigger() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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,10 @@ 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 +135,15 @@ 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 +180,6 @@ impl Host {
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
ops::agent::move_user_mcp_values(&host);
|
||||
host
|
||||
}
|
||||
|
||||
@@ -298,9 +287,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);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use super::page::{OFFSET, Page};
|
||||
use super::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{Diagnostic, IdeResult, Severity};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// Problems `get_project_problems` lists by default.
|
||||
const PROBLEMS_LIMIT: usize = 100;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![
|
||||
@@ -41,10 +37,7 @@ Lines and UTF-16 columns are 1-based.",
|
||||
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
|
||||
Lines and UTF-16 columns are 1-based.",
|
||||
project_problems,
|
||||
)
|
||||
.param(Param::optional("errorsOnly", ParamType::Boolean, "Only errors (default) or warnings too"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Problems to return at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET)),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -97,25 +90,9 @@ async fn file_problems(call: Call) -> IdeResult<Output> {
|
||||
}
|
||||
|
||||
async fn project_problems(call: Call) -> IdeResult<Output> {
|
||||
let page = Page::of(&call, PROBLEMS_LIMIT)?;
|
||||
let errors_only = call.flag("errorsOnly", true)?;
|
||||
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
|
||||
let problems: Vec<(String, Value)> = files
|
||||
.into_iter()
|
||||
.flat_map(|f| {
|
||||
let path = call.display(&f.path.0);
|
||||
let wanted = f.items.into_iter().filter(|d| !errors_only || d.severity == Severity::Error);
|
||||
wanted.map(move |d| (path.clone(), problem(d, "")))
|
||||
})
|
||||
.collect();
|
||||
let mut out = Map::new();
|
||||
let mut files: Vec<Value> = Vec::new();
|
||||
for (path, problem) in page.take(problems, &mut out) {
|
||||
match files.last_mut() {
|
||||
Some(file) if file["filePath"] == path.as_str() => file["problems"].as_array_mut().unwrap().push(problem),
|
||||
_ => files.push(json!({ "filePath": path, "problems": [problem] })),
|
||||
}
|
||||
}
|
||||
out.insert("files".into(), files.into());
|
||||
Output::json(Value::Object(out))
|
||||
let files: Vec<_> = files.into_iter().map(|f| json!({
|
||||
"filePath":call.display(&f.path.0), "problems": f.items.into_iter().map(|d| problem(d, "")).collect::<Vec<_>>()
|
||||
})).collect();
|
||||
Output::json(json!({"files":files}))
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
// the list_directory_tree part of FileToolset.kt, src/com/intellij/mcpserver/util/fs.util.kt), Apache-2.0.
|
||||
//! Reading files and listing directories.
|
||||
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use crate::mcp::util::{
|
||||
self, RELATIVE_PATH_IN_PROJECT_DESCRIPTION, TIMED_OUT_DESCRIPTION, TIMEOUT_MILLISECONDS_DESCRIPTION,
|
||||
@@ -18,8 +17,6 @@ const MAX_READ_LIMIT: i64 = 5000;
|
||||
/// Longer lines are cut (minified files would flood the context), in UTF-16 units.
|
||||
const MAX_LINE_LENGTH: usize = 2000;
|
||||
const DEFAULT_TREE_DEPTH: i64 = 5;
|
||||
/// Entries `list_directory_tree` lists by default.
|
||||
const TREE_LIMIT: usize = 100;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![read_file(), list_directory_tree()]
|
||||
@@ -152,12 +149,6 @@ struct DirectoryTreeInfo {
|
||||
errors: Vec<String>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
listing_timed_out: bool,
|
||||
/// Entries under the directory within `maxDepth`.
|
||||
total: usize,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
more: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next_offset: Option<usize>,
|
||||
}
|
||||
|
||||
fn list_directory_tree() -> Tool {
|
||||
@@ -165,12 +156,10 @@ fn list_directory_tree() -> Tool {
|
||||
"list_directory_tree",
|
||||
"Provides a tree representation of the specified directory in the pseudo graphic format like `tree` utility does.
|
||||
Use this tool to explore the contents of a directory or the whole project.
|
||||
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.
|
||||
A page that starts inside a directory repeats the lines of the directories above it.",
|
||||
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.",
|
||||
|call| async move {
|
||||
let directory = call.str("directoryPath")?;
|
||||
let max_depth = call.opt_i64("maxDepth")?.unwrap_or(DEFAULT_TREE_DEPTH);
|
||||
let page = Page::of(&call, TREE_LIMIT)?;
|
||||
let timeout = call.opt_i64("timeout")?.unwrap_or(util::MEDIUM_TIMEOUT.as_millis() as i64);
|
||||
let dir = call.resolve_readable(&directory).await?;
|
||||
if !dir.exists() {
|
||||
@@ -181,16 +170,8 @@ A page that starts inside a directory repeats the lines of the directories above
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout.max(0) as u64);
|
||||
let cancel = call.cancel.clone();
|
||||
let mut tree = TreeRender {
|
||||
out: String::new(),
|
||||
errors: Vec::new(),
|
||||
timed_out: false,
|
||||
deadline,
|
||||
cancel: cancel.clone(),
|
||||
page,
|
||||
seen: 0,
|
||||
above: Vec::new(),
|
||||
};
|
||||
let mut tree =
|
||||
TreeRender { out: String::new(), errors: Vec::new(), timed_out: false, deadline, cancel: cancel.clone() };
|
||||
let tree = call
|
||||
.host()
|
||||
.blocking(move || {
|
||||
@@ -206,16 +187,11 @@ A page that starts inside a directory repeats the lines of the directories above
|
||||
tree: tree.out,
|
||||
errors: tree.errors,
|
||||
listing_timed_out: tree.timed_out,
|
||||
total: tree.seen,
|
||||
more: tree.seen > page.end(),
|
||||
next_offset: (tree.seen > page.end()).then_some(page.end()),
|
||||
})
|
||||
},
|
||||
)
|
||||
.param(Param::required("directoryPath", ParamType::String, RELATIVE_PATH_IN_PROJECT_DESCRIPTION))
|
||||
.param(Param::optional("maxDepth", ParamType::Integer, "Maximum recursion depth"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Entries to return at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
.param(Param::optional("timeout", ParamType::Integer, TIMEOUT_MILLISECONDS_DESCRIPTION))
|
||||
.output(json!({
|
||||
"type": "object",
|
||||
@@ -224,28 +200,19 @@ A page that starts inside a directory repeats the lines of the directories above
|
||||
"tree": { "type": "string" },
|
||||
"errors": { "type": "array", "items": { "type": "string" } },
|
||||
"listingTimedOut": { "type": "boolean", "description": TIMED_OUT_DESCRIPTION },
|
||||
"total": { "type": "integer" },
|
||||
"more": { "type": "boolean" },
|
||||
"nextOffset": { "type": "integer" },
|
||||
},
|
||||
"required": ["traversedDirectory", "tree", "errors", "total"],
|
||||
"required": ["traversedDirectory", "tree", "errors"],
|
||||
}))
|
||||
}
|
||||
|
||||
/// The `tree`-like listing: the directory's absolute path, then every entry (hidden and
|
||||
/// ignored ones too) by lower-cased name, directories marked with `/`; entries outside the
|
||||
/// page are counted, not listed.
|
||||
/// ignored ones too) by lower-cased name, directories marked with `/`.
|
||||
struct TreeRender {
|
||||
out: String,
|
||||
errors: Vec<String>,
|
||||
timed_out: bool,
|
||||
deadline: Instant,
|
||||
cancel: CancellationToken,
|
||||
page: Page,
|
||||
/// Entries met so far.
|
||||
seen: usize,
|
||||
/// The lines of the directories above the entry, and whether this page shows them yet.
|
||||
above: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
impl TreeRender {
|
||||
@@ -268,13 +235,7 @@ impl TreeRender {
|
||||
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
|
||||
};
|
||||
let is_dir = path.is_dir() && !path.is_symlink();
|
||||
let line = format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" });
|
||||
let shown = if is_root {
|
||||
self.out.push_str(&line);
|
||||
true
|
||||
} else {
|
||||
self.entry(&line)
|
||||
};
|
||||
self.out.push_str(&format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" }));
|
||||
if !is_dir {
|
||||
return;
|
||||
}
|
||||
@@ -289,32 +250,9 @@ impl TreeRender {
|
||||
children.sort_by_cached_key(|p| (key(p).to_lowercase(), key(p)));
|
||||
let child_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
|
||||
let n = children.len();
|
||||
if !is_root {
|
||||
self.above.push((line, shown));
|
||||
}
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
self.render(child, &child_indent, i + 1 == n, max_depth - 1, false);
|
||||
}
|
||||
if !is_root {
|
||||
self.above.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts an entry and lists it when on the page, after the directories above it.
|
||||
fn entry(&mut self, line: &str) -> bool {
|
||||
let n = self.seen;
|
||||
self.seen += 1;
|
||||
if n < self.page.offset || n >= self.page.end() {
|
||||
return false;
|
||||
}
|
||||
for (above, shown) in &mut self.above {
|
||||
if !*shown {
|
||||
self.out.push_str(above);
|
||||
*shown = true;
|
||||
}
|
||||
}
|
||||
self.out.push_str(line);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,36 +302,4 @@ mod tests {
|
||||
let short = format!("{}😀", "x".repeat(1998));
|
||||
assert_eq!(format_line(&short), short);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_page_repeats_the_directories_above_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for file in ["a/x.txt", "a/y.txt", "b.txt"] {
|
||||
let path = tmp.path().join(file);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, "").unwrap();
|
||||
}
|
||||
let render = |offset, limit| {
|
||||
let mut tree = TreeRender {
|
||||
out: String::new(),
|
||||
errors: Vec::new(),
|
||||
timed_out: false,
|
||||
deadline: Instant::now() + Duration::from_secs(10),
|
||||
cancel: CancellationToken::new(),
|
||||
page: Page { offset, limit },
|
||||
seen: 0,
|
||||
above: Vec::new(),
|
||||
};
|
||||
tree.render(tmp.path(), "", true, 5, true);
|
||||
let root = format!("{}/\n", tmp.path().display());
|
||||
(tree.out.strip_prefix(&root).unwrap().to_owned(), tree.seen)
|
||||
};
|
||||
assert_eq!(render(0, 2), (" ├── a/\n │ ├── x.txt\n".to_owned(), 4));
|
||||
assert_eq!(
|
||||
render(2, 2).0,
|
||||
" ├── a/\n │ └── y.txt\n └── b.txt\n",
|
||||
"the page in `a/` repeats its line"
|
||||
);
|
||||
assert_eq!(render(0, 100).0, " ├── a/\n │ ├── x.txt\n │ └── y.txt\n └── b.txt\n");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ pub(crate) mod run;
|
||||
mod vcs;
|
||||
pub use requests::Requests;
|
||||
mod files;
|
||||
mod page;
|
||||
mod reports;
|
||||
mod search;
|
||||
mod symbols;
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
//! `limit` and `offset` of the tools that list things: a few items by default, the rest page by page.
|
||||
|
||||
use super::tool::Call;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// The most items one call returns.
|
||||
pub const MAX_LIMIT: usize = 1000;
|
||||
pub const OFFSET: &str = "Items to skip: `nextOffset` of the previous page";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Page {
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// The `limit` (`default` when absent, at most [`MAX_LIMIT`]) and `offset` (0) arguments.
|
||||
pub fn of(call: &Call, default: usize) -> IdeResult<Self> {
|
||||
let limit = match call.opt_i64("limit")? {
|
||||
None => default,
|
||||
Some(n) if n <= 0 => return Err(IdeError::invalid("limit must be > 0")),
|
||||
Some(n) => (n as usize).min(MAX_LIMIT),
|
||||
};
|
||||
let offset = match call.opt_i64("offset")? {
|
||||
None => 0,
|
||||
Some(n) if n < 0 => return Err(IdeError::invalid("offset must be >= 0")),
|
||||
Some(n) => n as usize,
|
||||
};
|
||||
Ok(Self { offset, limit })
|
||||
}
|
||||
|
||||
/// Where the page ends: the items to find for it.
|
||||
pub fn end(&self) -> usize {
|
||||
self.offset.saturating_add(self.limit)
|
||||
}
|
||||
|
||||
/// This page of all the `items`; `out` gets their `total`, and `more` and `nextOffset` when some follow.
|
||||
pub fn take<T>(&self, items: Vec<T>, out: &mut Map<String, Value>) -> Vec<T> {
|
||||
let total = items.len();
|
||||
out.insert("total".into(), total.into());
|
||||
self.mark(total > self.end(), out);
|
||||
items.into_iter().skip(self.offset).take(self.limit).collect()
|
||||
}
|
||||
|
||||
/// `more` and `nextOffset` when items follow the page.
|
||||
pub fn mark(&self, more: bool, out: &mut Map<String, Value>) {
|
||||
if more {
|
||||
out.insert("more".into(), true.into());
|
||||
out.insert("nextOffset".into(), self.end().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pages_say_what_follows() {
|
||||
let page = |offset, limit| Page { offset, limit };
|
||||
let mut out = Map::new();
|
||||
assert_eq!(page(0, 2).take(vec![1, 2, 3, 4, 5], &mut out), [1, 2]);
|
||||
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5, "more": true, "nextOffset": 2 }));
|
||||
let mut out = Map::new();
|
||||
assert_eq!(page(4, 2).take(vec![1, 2, 3, 4, 5], &mut out), [5]);
|
||||
assert_eq!(Value::Object(out), serde_json::json!({ "total": 5 }), "the last page");
|
||||
let mut out = Map::new();
|
||||
assert!(page(9, 2).take(vec![1], &mut out).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
use super::agent::{AgentRun, INTERVENTION_ADVICE, TruncateMode, clean_output, tab_name, truncate_lines};
|
||||
use super::output::RUN_PREVIEW_MAX_LENGTH;
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
@@ -36,14 +35,6 @@ and `nextOffset` are positions in the tab's raw output log; pass `nextOffset` as
|
||||
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";
|
||||
/// `list_runs`: tabs returned by default, and characters of each command, enough to tell them apart.
|
||||
const LIST_LIMIT: usize = 10;
|
||||
const LIST_COMMAND_CHARS: usize = 80;
|
||||
const LIST_STATES: [(&str, &[&str]); 3] = [
|
||||
("running", &["starting", "running", "stopping"]),
|
||||
("idle", &["succeeded", "failed", "crashed", "stoppedByAgent", "stoppedByUser", "startFailed"]),
|
||||
("error", &["failed", "crashed", "startFailed"]),
|
||||
];
|
||||
|
||||
fn find(call: &Call) -> IdeResult<Arc<AgentRun>> {
|
||||
let name = tab_name(&call.str("name")?)?;
|
||||
@@ -292,64 +283,13 @@ for input) and `tabClosedAt` when the user closed the tab.{INTERVENTIONS}"
|
||||
pub fn list_runs() -> Tool {
|
||||
Tool::new(
|
||||
"list_runs",
|
||||
"Lists the agent tabs of the project (those started by `run_command`), newest first: name, state, the \
|
||||
start of the command, exit code and times. `get_run_status` gives a tab's whole status.",
|
||||
"Lists the agent tabs of the project (those started by `run_command`) with their status.",
|
||||
|call| async move {
|
||||
let page = Page::of(&call, LIST_LIMIT)?;
|
||||
let wanted = call.opt_str("state")?.filter(|s| s != "all");
|
||||
let states = match wanted.as_deref() {
|
||||
None => None,
|
||||
Some(s) => Some(match LIST_STATES.iter().find(|(group, _)| *group == s) {
|
||||
Some((_, states)) => states.to_vec(),
|
||||
None if LIST_STATES[1].1.contains(&s) || LIST_STATES[0].1.contains(&s) => vec![s],
|
||||
None => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"unknown state `{s}`: all, running, idle, error, or one of the states of get_run_status"
|
||||
)));
|
||||
}
|
||||
}),
|
||||
};
|
||||
let runs: Vec<Value> = call
|
||||
.host()
|
||||
.mcp_agent_runs
|
||||
.list(call.root())
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|r| r.status())
|
||||
.filter(|s| {
|
||||
states.as_ref().is_none_or(|states| s["state"].as_str().is_some_and(|v| states.contains(&v)))
|
||||
})
|
||||
.map(|s| Value::Object(brief(s)))
|
||||
.collect();
|
||||
let mut out = Map::new();
|
||||
let runs = page.take(runs, &mut out);
|
||||
out.insert("runs".into(), runs.into());
|
||||
Output::json(Value::Object(out))
|
||||
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 }))
|
||||
},
|
||||
)
|
||||
.param(Param::optional(
|
||||
"state",
|
||||
ParamType::String,
|
||||
"all (default), running, idle (not running), error (failed, crashed, startFailed), or one state",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Tabs to return at most (default 10)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
}
|
||||
|
||||
/// A tab in `list_runs`: what tells it apart and how it went, the command cut short.
|
||||
fn brief(mut status: Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = Map::new();
|
||||
for key in ["name", "state", "command", "exitCode", "crash", "error", "startedAt", "endedAt", "tabClosedAt"] {
|
||||
if let Some(v) = status.remove(key) {
|
||||
out.insert(key.into(), v);
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(command)) = out.get_mut("command")
|
||||
&& command.chars().count() > LIST_COMMAND_CHARS
|
||||
{
|
||||
*command = format!("{}…", command.chars().take(LIST_COMMAND_CHARS).collect::<String>());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What a key does to a command on pipes.
|
||||
@@ -467,22 +407,6 @@ pub fn tools() -> Vec<Tool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn listed_tabs_are_brief_and_their_commands_cut_short() {
|
||||
let status = serde_json::json!({
|
||||
"name": "_agent_build", "state": "failed", "command": "x".repeat(100), "exitCode": 1,
|
||||
"workingDirectory": "", "outputLength": 9, "startedBy": "agent",
|
||||
});
|
||||
let Value::Object(status) = status else { unreachable!() };
|
||||
let brief = Value::Object(brief(status));
|
||||
assert_eq!(brief["command"].as_str().unwrap(), format!("{}…", "x".repeat(LIST_COMMAND_CHARS)));
|
||||
assert_eq!(
|
||||
(&brief["name"], &brief["state"], &brief["exitCode"]),
|
||||
(&"_agent_build".into(), &"failed".into(), &1.into())
|
||||
);
|
||||
assert!(brief.get("outputLength").is_none() && brief.get("workingDirectory").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_what_pipes_can_take() {
|
||||
assert_eq!(key(" enter ").unwrap(), Key::Text("\n"));
|
||||
|
||||
@@ -7,7 +7,6 @@ use super::output::OutputCollector;
|
||||
use super::process::{quote, split_words};
|
||||
use super::targets;
|
||||
use crate::Project;
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_build::compile::BuildKind;
|
||||
@@ -20,8 +19,6 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 10_000;
|
||||
/// Configurations `get_run_configurations` lists by default.
|
||||
const CONFIGURATIONS_LIMIT: usize = 100;
|
||||
/// 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.
|
||||
@@ -53,8 +50,7 @@ fn describe_config(call: &Call, c: &RunConfig) -> Value {
|
||||
if let Some(cwd) = c.cwd.as_deref() {
|
||||
v["workingDirectory"] = call.display(&call.root().join(cwd)).into();
|
||||
}
|
||||
// A detected configuration's environment is the IDE's own (its tools on `PATH`).
|
||||
if !c.env.is_empty() && !c.detected {
|
||||
if !c.env.is_empty() {
|
||||
v["environment"] = json!(c.env);
|
||||
}
|
||||
}
|
||||
@@ -137,23 +133,8 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
|
||||
.await?;
|
||||
return Output::json(json!({ "filePath": file, "runPoints": points }));
|
||||
}
|
||||
let page = Page::of(&call, CONFIGURATIONS_LIMIT)?;
|
||||
let query = call.opt_str("query")?.map(|q| q.to_lowercase());
|
||||
let list: Vec<Value> = configs
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
query.as_ref().is_none_or(|q| {
|
||||
[c.name.as_str(), c.kind.as_str(), command_line(c).as_str()]
|
||||
.iter()
|
||||
.any(|s| s.to_lowercase().contains(q.as_str()))
|
||||
})
|
||||
})
|
||||
.map(|c| describe_config(&call, c))
|
||||
.collect();
|
||||
let mut out = Map::new();
|
||||
let list = page.take(list, &mut out);
|
||||
out.insert("configurations".into(), list.into());
|
||||
Output::json(Value::Object(out))
|
||||
let list: Vec<Value> = configs.iter().map(|c| describe_config(&call, c)).collect();
|
||||
Output::json(json!({ "configurations": list }))
|
||||
},
|
||||
)
|
||||
.write()
|
||||
@@ -163,9 +144,6 @@ the whole command. Run points are found in Rust, JavaScript/TypeScript, Python a
|
||||
"Optional file path relative to the project root. When provided, returns run points (executable entry points) \
|
||||
in the file instead of project-wide run configurations.",
|
||||
))
|
||||
.param(Param::optional("query", ParamType::String, "Only configurations whose name, type or command contains it"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Configurations to return at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
}
|
||||
|
||||
/// What `execute_run_configuration` runs.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! Coordinates are 1-based, end exclusive; columns count UTF-16 code units like
|
||||
//! the editor (one per character except emoji and other astral characters).
|
||||
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use crate::mcp::util::{INDEXING_PARTIAL_RESULT_REASON, MEDIUM_TIMEOUT};
|
||||
use crate::ops::search::SearchEvent;
|
||||
@@ -22,8 +21,8 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const MAX_RESULTS_UPPER_BOUND: usize = 5000;
|
||||
const DEFAULT_LIMIT: usize = 100;
|
||||
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: usize = 50;
|
||||
const DEFAULT_LIMIT: i64 = 1000;
|
||||
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: i64 = 50;
|
||||
pub const SEARCH_TIMEOUT_PARTIAL_RESULT_REASON: &str =
|
||||
"The search timed out after 10 s. Narrow the query or pass paths.";
|
||||
/// Symbols requested from the index per result when `paths` filters them afterwards.
|
||||
@@ -38,8 +37,7 @@ 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 = "Results to return at most (default 100)";
|
||||
const SYMBOL_LIMIT_DESCRIPTION: &str = "Results to return at most (default 50)";
|
||||
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.";
|
||||
@@ -69,7 +67,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("offset", ParamType::Integer, OFFSET))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
@@ -91,7 +88,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("offset", ParamType::Integer, OFFSET))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
@@ -117,8 +113,7 @@ If you don't find a suitable result, try again with include_external=true to sea
|
||||
"Whether to include SDK and library symbols. Disabled by default; if nothing suitable is found, try again \
|
||||
with include_external=true.",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, SYMBOL_LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("context", ParamType::String, CONTEXT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
@@ -143,7 +138,6 @@ Paths are optional additional glob filters relative to the project root.",
|
||||
"Whether to include excluded/ignored files in results",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
@@ -197,9 +191,6 @@ struct SearchResult {
|
||||
items: Vec<SearchItem>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
more: bool,
|
||||
/// The `offset` of the next page, when `more`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next_offset: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
partial_result_reason: Option<&'static str>,
|
||||
}
|
||||
@@ -221,23 +212,16 @@ fn result_schema() -> Value {
|
||||
"required": ["filePath"],
|
||||
} },
|
||||
"more": { "type": "boolean" },
|
||||
"nextOffset": int,
|
||||
"partialResultReason": string,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The page of `call` and the results to find for it: up to its end.
|
||||
fn page(call: &Call, default: usize) -> IdeResult<(Page, usize)> {
|
||||
let page = Page::of(call, default)?;
|
||||
Ok((page, page.end().min(MAX_RESULTS_UPPER_BOUND)))
|
||||
}
|
||||
|
||||
/// Drops the results before the page, and the `nextOffset` when more follow. A search finds
|
||||
/// [`MAX_RESULTS_UPPER_BOUND`] results at most, so no page starts past them.
|
||||
fn skip<T>(page: Page, items: &mut Vec<T>, more: bool) -> Option<usize> {
|
||||
items.drain(..page.offset.min(items.len()));
|
||||
(more && page.end() < MAX_RESULTS_UPPER_BOUND).then_some(page.end())
|
||||
fn limit(call: &Call, default: i64) -> IdeResult<usize> {
|
||||
match call.opt_i64("limit")?.unwrap_or(default) {
|
||||
n if n <= 0 => Err(IdeError::invalid("limit must be > 0")),
|
||||
n => Ok((n as usize).min(MAX_RESULTS_UPPER_BOUND)),
|
||||
}
|
||||
}
|
||||
|
||||
fn query(call: &Call, empty: &str) -> IdeResult<String> {
|
||||
@@ -537,7 +521,7 @@ 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 (page, limit) = page(&call, DEFAULT_LIMIT)?;
|
||||
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
|
||||
@@ -628,12 +612,11 @@ async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let next_offset = skip(page, &mut items, more);
|
||||
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, next_offset, partial_result_reason })
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
}
|
||||
|
||||
/// The matches of `ranges` (byte ranges in `text`) that start on 0-based `line`, with their
|
||||
@@ -738,7 +721,7 @@ async fn run_search(call: &Call, query: SearchQuery) -> IdeResult<(Vec<FileMatch
|
||||
|
||||
async fn file_search(call: Call) -> IdeResult<Output> {
|
||||
let q = query(&call, "Glob pattern is empty")?;
|
||||
let (page, limit) = page(&call, DEFAULT_LIMIT)?;
|
||||
let limit = limit(&call, DEFAULT_LIMIT)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
let include_excluded = call.flag("includeExcluded", false)?;
|
||||
let glob = GlobSetBuilder::new()
|
||||
@@ -776,11 +759,9 @@ async fn file_search(call: Call) -> IdeResult<Output> {
|
||||
Ok((found, more, walk.timed_out.get()))
|
||||
})
|
||||
.await?;
|
||||
let mut items: Vec<SearchItem> =
|
||||
paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
|
||||
let next_offset = skip(page, &mut items, more);
|
||||
let items = paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
|
||||
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
|
||||
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
}
|
||||
|
||||
struct Walk {
|
||||
@@ -854,7 +835,7 @@ impl Walk {
|
||||
async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
let started = Instant::now();
|
||||
let q = query(&call, "Search query is empty")?;
|
||||
let (page, limit) = page(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
|
||||
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)?;
|
||||
@@ -905,7 +886,6 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
}
|
||||
let more = items.len() > limit || timed_out;
|
||||
items.truncate(limit);
|
||||
let next_offset = skip(page, &mut items, more);
|
||||
// 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| {
|
||||
@@ -919,7 +899,7 @@ async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
} else {
|
||||
timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON)
|
||||
};
|
||||
Output::json(SearchResult { items, more, next_offset, partial_result_reason })
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
}
|
||||
|
||||
/// The language servers' workspace symbols, and whether `budget` ran out first; empty
|
||||
|
||||
@@ -210,7 +210,6 @@ fn leads_declaration(line: &str, line_comment: Option<&str>) -> bool {
|
||||
pub fn find_references() -> Tool {
|
||||
Tool::new("find_references", "Find references using the language service and the IDE's project index. Positions are 1-based UTF-16. Reports incomplete results when analysis is still running.", |call| async move {
|
||||
let path = call.resolve_readable(&call.str("filePath")?).await?;
|
||||
let page = super::page::Page::of(&call, REFERENCES_LIMIT)?;
|
||||
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
|
||||
let column = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
|
||||
let project = call.project.clone();
|
||||
@@ -245,28 +244,14 @@ pub fn find_references() -> Tool {
|
||||
}
|
||||
}
|
||||
positions(&mut value);
|
||||
// `classified`, when there, goes with `locations` one to one.
|
||||
if let serde_json::Value::Object(map) = &mut value {
|
||||
let mut list = |key: &str| match map.remove(key) { Some(serde_json::Value::Array(items)) => items, _ => Vec::new() };
|
||||
let (locations, classified) = (list("locations"), list("classified"));
|
||||
let classified = page.take(classified, &mut serde_json::Map::new());
|
||||
let locations = page.take(locations, map);
|
||||
map.insert("locations".into(), locations.into());
|
||||
map.insert("classified".into(), classified.into());
|
||||
}
|
||||
Output::json(value)
|
||||
})
|
||||
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
|
||||
.param(Param::required("line", ParamType::Integer, "1-based line"))
|
||||
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column"))
|
||||
.param(Param::optional("includeDeclaration", ParamType::Boolean, "Include declarations (default true)"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "References to return at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, super::page::OFFSET))
|
||||
}
|
||||
|
||||
/// References `find_references` lists by default.
|
||||
const REFERENCES_LIMIT: usize = 100;
|
||||
|
||||
fn mcp_position(text: &str, line: i64, column: i64) -> IdeResult<Pos> {
|
||||
if line < 1 || line > u32::MAX as i64 || column < 1 || column > u32::MAX as i64 {
|
||||
return Err(IdeError::invalid("line and column must be positive 1-based positions"));
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
// (mcpserver.vcs/src/com/intellij/mcpserver/toolsets/vcs/VcsToolset.kt), Apache-2.0.
|
||||
//! Version control: repositories and git status.
|
||||
|
||||
use crate::mcp::page::{OFFSET, Page};
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const STATUS_DEFAULT_LIMIT: usize = 100;
|
||||
const STATUS_DEFAULT_LIMIT: i64 = 1000;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![get_repositories(), git_status(), git_diff()]
|
||||
@@ -255,7 +254,11 @@ By default all Git repositories are returned.",
|
||||
|call| async move {
|
||||
let include_untracked = call.flag("includeUntracked", true)?;
|
||||
let include_ignored = call.flag("includeIgnored", false)?;
|
||||
let page = Page::of(&call, STATUS_DEFAULT_LIMIT)?;
|
||||
let limit = call.opt_i64("limit")?.unwrap_or(STATUS_DEFAULT_LIMIT);
|
||||
if limit <= 0 {
|
||||
return Err(IdeError::invalid("limit must be > 0"));
|
||||
}
|
||||
let limit = limit as usize;
|
||||
let host = call.host();
|
||||
let h = host.clone();
|
||||
let w = call.window.clone();
|
||||
@@ -302,8 +305,7 @@ By default all Git repositories are returned.",
|
||||
let count = |f: fn(&StatusEntry) -> bool| entries.iter().filter(|e| f(e)).count();
|
||||
let listed: Vec<Value> = entries
|
||||
.iter()
|
||||
.skip(page.offset)
|
||||
.take(page.limit)
|
||||
.take(limit)
|
||||
.map(|e| {
|
||||
let mut v = json!({
|
||||
"pathRelativeToRepository": e.path.trim_end_matches('/'),
|
||||
@@ -316,24 +318,19 @@ By default all Git repositories are returned.",
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let more = entries.len() > page.end();
|
||||
let mut repository = json!({
|
||||
repositories.push(json!({
|
||||
"repositoryPathRelativeToProject": rel,
|
||||
"currentBranch": branch,
|
||||
"isClean": entries.is_empty(),
|
||||
"totalEntries": entries.len(),
|
||||
"hasMoreEntries": more,
|
||||
"hasMoreEntries": entries.len() > listed.len(),
|
||||
"stagedCount": count(StatusEntry::staged),
|
||||
"unstagedCount": count(StatusEntry::unstaged),
|
||||
"untrackedCount": count(StatusEntry::untracked),
|
||||
"ignoredCount": count(StatusEntry::ignored),
|
||||
"conflictedCount": count(StatusEntry::conflicted),
|
||||
"entries": listed,
|
||||
});
|
||||
if more {
|
||||
repository["nextOffset"] = page.end().into();
|
||||
}
|
||||
repositories.push(repository);
|
||||
}));
|
||||
}
|
||||
Output::json(json!({ "repositories": repositories }))
|
||||
},
|
||||
@@ -345,8 +342,7 @@ By default all Git repositories are returned.",
|
||||
))
|
||||
.param(Param::optional("includeUntracked", ParamType::Boolean, "Whether to include untracked files"))
|
||||
.param(Param::optional("includeIgnored", ParamType::Boolean, "Whether to include ignored files"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Entries to return per repository at most (default 100)"))
|
||||
.param(Param::optional("offset", ParamType::Integer, OFFSET))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Maximum number of entries returned per repository"))
|
||||
}
|
||||
|
||||
fn git_diff() -> Tool {
|
||||
|
||||
@@ -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<()> {
|
||||
@@ -261,18 +254,6 @@ pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -
|
||||
host.project(window)?.agents.fork(&task, turns).await
|
||||
}
|
||||
|
||||
/// Creates a temporary side conversation of `source`: lives in memory only, never
|
||||
/// persisted, not listed.
|
||||
pub async fn create_ephemeral(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: NewAgentTask,
|
||||
source: String,
|
||||
) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.create_ephemeral(request, &source).await
|
||||
}
|
||||
|
||||
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
|
||||
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
|
||||
host.require_trust(window)?;
|
||||
@@ -361,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()
|
||||
@@ -446,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 {
|
||||
@@ -463,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 => {
|
||||
@@ -514,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)
|
||||
}
|
||||
@@ -575,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 });
|
||||
@@ -694,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();
|
||||
@@ -707,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()
|
||||
@@ -716,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()
|
||||
|
||||
@@ -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())
|
||||
@@ -331,61 +330,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(
|
||||
@@ -1092,6 +1051,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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -372,46 +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_overrides(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
rules: Vec<ide_dap::JsResponseRule>,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.js_set_response_overrides(session, rules).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 async fn debug_js_start_profile(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.js_start_profile(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_stop_profile(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
) -> IdeResult<ide_core::IpcPath> {
|
||||
host.debugger.js_stop_profile(session).await.map(Into::into)
|
||||
}
|
||||
|
||||
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
host.debugger.capabilities(session)
|
||||
}
|
||||
@@ -628,23 +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_overrides", |c| async move {
|
||||
debug_js_response_overrides(&c.host, &c.window, c.arg("session")?, c.arg("rules")?).await
|
||||
});
|
||||
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_js_start_profile",
|
||||
|c| async move { debug_js_start_profile(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
d.add(
|
||||
"debug_js_stop_profile",
|
||||
|c| async move { debug_js_stop_profile(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
d.add("debug_set_function_breakpoints", |c| async move {
|
||||
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -21,28 +21,6 @@ pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens an editable untitled buffer (a new-file tab); it reaches the disk
|
||||
/// through [`save_as`], which turns it into a regular file document. `name`
|
||||
/// re-references an already minted buffer instead of creating one.
|
||||
pub async fn open_untitled(host: &Host, window: &str, name: Option<String>) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.open_untitled(name.as_deref())).await
|
||||
}
|
||||
|
||||
/// Save As for an untitled buffer ([`Documents::save_as`]); LightEdit windows
|
||||
/// start watching the saved file's folder.
|
||||
pub async fn save_as(host: &Host, window: &str, doc: DocId, path: PathBuf) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let info = project.documents.save_as(doc, &path)?;
|
||||
if let Some(dir) = path.parent().filter(|_| project.light) {
|
||||
project.watch_light_dir(dir);
|
||||
}
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
|
||||
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
||||
host.project(window)?.documents.edit(doc, base_version, changes)
|
||||
|
||||
@@ -50,10 +50,7 @@ pub async fn doc_set_encoding(
|
||||
reload: bool,
|
||||
) -> IdeResult<DocumentEncoding> {
|
||||
let project = host.project(window)?;
|
||||
if reload
|
||||
&& !project.documents.is_untitled(doc)?
|
||||
&& let Some(uri) = project.documents.virtual_uri(doc)?
|
||||
{
|
||||
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
|
||||
let vd = if uri.starts_with("git-revision:") {
|
||||
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
|
||||
} else {
|
||||
@@ -162,15 +159,6 @@ pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<
|
||||
.await
|
||||
}
|
||||
|
||||
/// Python class facts the inheritance inspections read: base names as written, a method's
|
||||
/// parameters, decorator semantics and its body shape. Tree-sitter answers; no LSP.
|
||||
pub async fn doc_python_facts(host: &Host, window: &str, doc: DocId) -> IdeResult<ide_language::PythonFacts> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
blocking(move || Ok(syntax.python_facts(&snap.language, &snap.text))).await
|
||||
}
|
||||
|
||||
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
|
||||
/// Large documents fall back to word / line / document ranges.
|
||||
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
|
||||
@@ -220,7 +208,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_python_facts", |c| async move { doc_python_facts(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_selection_ranges", |c| async move {
|
||||
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
|
||||
});
|
||||
|
||||
@@ -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 || {
|
||||
@@ -1472,15 +1438,6 @@ pub async fn lsp_type_hierarchy(
|
||||
host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await
|
||||
}
|
||||
|
||||
pub async fn lsp_prepare_type_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<HierarchyItem>> {
|
||||
host.project(window)?.lsp.prepare_type_hierarchy(doc, pos).await
|
||||
}
|
||||
|
||||
pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
|
||||
Ok(host.project(window)?.lsp.servers())
|
||||
}
|
||||
@@ -1865,9 +1822,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_type_hierarchy", |c| async move {
|
||||
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
});
|
||||
d.add("lsp_prepare_type_hierarchy", |c| async move {
|
||||
lsp_prepare_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
|
||||
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
|
||||
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
|
||||
@@ -2182,24 +2136,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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -239,12 +239,6 @@ pub fn set_settings(
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
// A run target chosen as the Python interpreter (or changed) needs a snapshot of its libraries.
|
||||
if ["languages", "languages.python", "languages.python.interpreter", "runTargets"].contains(&key) {
|
||||
for project in affected() {
|
||||
crate::ops::sdk::container_python::ensure(host, &project);
|
||||
}
|
||||
}
|
||||
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
|
||||
if code_style || toolchain {
|
||||
for project in affected() {
|
||||
|
||||
@@ -508,7 +508,7 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub(crate) mod container;
|
||||
mod container;
|
||||
pub use commands::*;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -33,11 +33,23 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
|
||||
let root = root.to_path_buf();
|
||||
let data_dir = host.data_dir.clone();
|
||||
host.blocking(move || {
|
||||
require_local(&endpoint, &target)?;
|
||||
if !ide_docker::endpoint_is_local(&endpoint)? {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let docker = ide_docker::docker_executable().ok_or_else(|| {
|
||||
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
|
||||
})?;
|
||||
let container_root = container_root(&endpoint, &root, &target)?;
|
||||
let container_root = match target.container_root.trim() {
|
||||
"" if target.kind == RunTargetKind::Compose => {
|
||||
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
|
||||
compose_container_root(&json, target.service.trim(), &root)?
|
||||
}
|
||||
"" => default_container_root(&root),
|
||||
folder => folder.to_owned(),
|
||||
};
|
||||
let file_env = env_files(&root, &config)?;
|
||||
let name = container_name(&config.id, &unique());
|
||||
let debugging = if debug { Some(Debugging::prepare(&data_dir)?) } else { None };
|
||||
@@ -63,29 +75,6 @@ pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config:
|
||||
.await
|
||||
}
|
||||
|
||||
/// The project can only be bind-mounted from a daemon on this machine.
|
||||
pub(crate) fn require_local(endpoint: &DockerEndpoint, target: &RunTarget) -> IdeResult<()> {
|
||||
if !ide_docker::endpoint_is_local(endpoint)? {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the project is in the target's containers.
|
||||
pub(crate) fn container_root(endpoint: &DockerEndpoint, root: &Path, target: &RunTarget) -> IdeResult<String> {
|
||||
Ok(match target.container_root.trim() {
|
||||
"" if target.kind == RunTargetKind::Compose => {
|
||||
let json = ide_docker::compose_config(endpoint, root, &target.compose_files)?;
|
||||
compose_container_root(&json, target.service.trim(), root)?
|
||||
}
|
||||
"" => default_container_root(root),
|
||||
folder => folder.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// What a debug run mounts and publishes.
|
||||
struct Debugging {
|
||||
port: u16,
|
||||
@@ -115,7 +104,7 @@ fn free_port() -> IdeResult<u16> {
|
||||
}
|
||||
|
||||
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
|
||||
pub(crate) fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
|
||||
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
|
||||
let id = target.docker_connection.trim();
|
||||
if id.is_empty() {
|
||||
return Ok(DockerEndpoint::Default);
|
||||
|
||||
@@ -10,9 +10,6 @@ use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
pub(crate) mod container_python;
|
||||
pub use container_python::{ContainerPython, sdk_container_python, sdk_container_python_refresh};
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
}
|
||||
@@ -235,17 +232,12 @@ pub async fn sdk_project_libraries(
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let container = container_python::library_group(host, &root, &settings);
|
||||
let libraries = {
|
||||
let (root, settings, cancel, on_groups) = (root.clone(), settings.clone(), cancel.clone(), on_groups.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Some(group) = &container {
|
||||
let _ = on_groups.send(vec![group.clone()]);
|
||||
}
|
||||
let groups = ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
|
||||
ide_sdk::libraries::project_libraries_stream(&root, &settings, &[], &cancel, &|groups| {
|
||||
let _ = on_groups.send(groups);
|
||||
});
|
||||
container.into_iter().chain(groups).collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
};
|
||||
let dependencies = {
|
||||
@@ -385,8 +377,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
|
||||
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
|
||||
d.add("sdk_container_python", |c| async move { sdk_container_python(&c.host, &c.window) });
|
||||
d.add("sdk_container_python_refresh", |c| async move { sdk_container_python_refresh(&c.host, &c.window).await });
|
||||
d.add("sdk_php_xdebug", |c| async move { sdk_php_xdebug(&c.host, &c.window, c.arg("php")?, c.arg("cwd")?).await });
|
||||
d.add(
|
||||
"sdk_php_language_level",
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
//! A Docker or Compose run target's Python as the project interpreter: snapshots of the
|
||||
//! container's library folders for the language server (see `ide_sdk::container_python`).
|
||||
//!
|
||||
//! A snapshot runs the target's Python once in a new container (the image's entrypoint
|
||||
//! and the service's dependencies skipped), then copies its `sys.path` folders out of
|
||||
//! the stopped container with `docker cp`. Nothing is installed in the container.
|
||||
|
||||
use crate::Host;
|
||||
use crate::project::Project;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::remote_settings::{RunTarget, RunTargetKind};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, TaskId};
|
||||
use ide_sdk::container_python::{self as snapshots, Snapshot};
|
||||
use ide_sdk::python::PythonPackage;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Building or pulling the image may come first.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||
const COPY_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const QUICK: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The project interpreter when it is a run target's.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContainerPython {
|
||||
/// The run target's id.
|
||||
pub target: String,
|
||||
/// Its name; empty when no run target has the id.
|
||||
pub name: String,
|
||||
/// The Python version of the snapshot in use; `None`: no snapshot yet.
|
||||
pub version: Option<String>,
|
||||
/// The image the snapshot was copied from.
|
||||
pub image: Option<String>,
|
||||
/// The copied library folders on this machine.
|
||||
pub folders: Vec<IpcPath>,
|
||||
/// Packages of the snapshot (read-only).
|
||||
pub packages: Vec<PythonPackage>,
|
||||
/// A snapshot is being taken.
|
||||
pub updating: bool,
|
||||
}
|
||||
|
||||
/// Where snapshots are kept, and the Docker connections the targets refer to.
|
||||
#[derive(Clone)]
|
||||
struct Dirs {
|
||||
data: PathBuf,
|
||||
/// `<local data>/container-python/`: `<project>/<target>/<image>/` and `neutral/`.
|
||||
cache: PathBuf,
|
||||
}
|
||||
|
||||
impl Dirs {
|
||||
fn of(host: &Host) -> Self {
|
||||
Self::new(&host.data_dir, &host.local_data_dir)
|
||||
}
|
||||
|
||||
fn new(data_dir: &Path, local_data_dir: &Path) -> Self {
|
||||
Self { data: data_dir.to_path_buf(), cache: local_data_dir.join("container-python") }
|
||||
}
|
||||
|
||||
fn target(&self, root: &Path, target: &str) -> PathBuf {
|
||||
snapshots::target_dir(&self.cache.join(crate::project::project_key(root)), target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Target folders with a snapshot being taken.
|
||||
static UPDATING: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(Default::default);
|
||||
|
||||
fn updating(dir: &Path) -> bool {
|
||||
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).contains(dir)
|
||||
}
|
||||
|
||||
/// The snapshot in use for the project's interpreter, if it is a run target's.
|
||||
fn current(dirs: &Dirs, root: &Path, settings: &Settings) -> Option<(PathBuf, Snapshot)> {
|
||||
let target = settings.languages.python.run_target()?;
|
||||
snapshots::current(&dirs.target(root, target))
|
||||
}
|
||||
|
||||
/// Language server configuration: basedpyright / pyright see the container's libraries.
|
||||
pub(crate) fn configure_server(
|
||||
cfg: &mut ide_language::LanguageServerConfig,
|
||||
local_data_dir: &Path,
|
||||
root: &Path,
|
||||
settings: &Settings,
|
||||
) {
|
||||
if settings.languages.python.run_target().is_none() {
|
||||
return;
|
||||
}
|
||||
let current = current(&Dirs::new(local_data_dir, local_data_dir), root, settings);
|
||||
snapshots::configure_server(cfg, current.as_ref().map(|(d, s)| (d.as_path(), s)));
|
||||
}
|
||||
|
||||
/// External Libraries: the snapshot's folders, named after the run target.
|
||||
pub(crate) fn library_group(host: &Host, root: &Path, settings: &Settings) -> Option<ide_sdk::libraries::LibraryGroup> {
|
||||
let target = settings.languages.python.run_target()?;
|
||||
let (dir, snapshot) = current(&Dirs::of(host), root, settings)?;
|
||||
let name = settings.run_targets.iter().find(|t| t.id == target).map_or(target, |t| t.name.as_str());
|
||||
snapshots::library_group(name, &dir, &snapshot)
|
||||
}
|
||||
|
||||
/// The project interpreter when it is a run target's, with its snapshot.
|
||||
pub fn sdk_container_python(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
Ok(describe(host, &project.root, &project.settings.effective()))
|
||||
}
|
||||
|
||||
fn describe(host: &Host, root: &Path, settings: &Settings) -> Option<ContainerPython> {
|
||||
let target = settings.languages.python.run_target()?;
|
||||
let name = settings.run_targets.iter().find(|t| t.id == target).map(|t| t.name.clone()).unwrap_or_default();
|
||||
let dir = Dirs::of(host).target(root, target);
|
||||
let mut out = ContainerPython {
|
||||
target: target.to_owned(),
|
||||
name,
|
||||
version: None,
|
||||
image: None,
|
||||
folders: Vec::new(),
|
||||
packages: Vec::new(),
|
||||
updating: updating(&dir),
|
||||
};
|
||||
if let Some((dir, snapshot)) = snapshots::current(&dir) {
|
||||
out.folders = snapshots::local_folders(&dir, &snapshot).into_iter().map(Into::into).collect();
|
||||
out.packages = snapshots::packages(&dir, &snapshot);
|
||||
out.version = Some(snapshot.probe.version);
|
||||
out.image = Some(snapshot.image);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Takes a new snapshot of the project interpreter's container (also of an image that
|
||||
/// has one already), then the language servers pick it up.
|
||||
pub async fn sdk_container_python_refresh(host: &Host, window: &str) -> IdeResult<Option<ContainerPython>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
update(Dirs::of(host), &project, true).await?;
|
||||
Ok(describe(host, &project.root, &project.settings.effective()))
|
||||
}
|
||||
|
||||
/// Takes a snapshot when the project interpreter is a run target's and the current image
|
||||
/// has none yet (a new choice, a rebuilt image), in the background. Errors are notified.
|
||||
pub(crate) fn ensure(host: &Host, project: &Arc<Project>) {
|
||||
if !project.is_trusted() || project.settings.effective().languages.python.run_target().is_none() {
|
||||
return;
|
||||
}
|
||||
let (dirs, project) = (Dirs::of(host), project.clone());
|
||||
host.runtime.spawn(async move {
|
||||
if let Err(e) = update(dirs, &project, false).await
|
||||
&& e.kind != ide_core::error::ErrorKind::Cancelled
|
||||
{
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Warning,
|
||||
title: "Cannot copy the container's Python libraries".into(),
|
||||
body: Some(e.message),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn update(dirs: Dirs, project: &Arc<Project>, force: bool) -> IdeResult<()> {
|
||||
let settings = project.settings.effective();
|
||||
let Some(id) = settings.languages.python.run_target() else { return Ok(()) };
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind == RunTargetKind::Ssh {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' runs over SSH: only a Docker or Compose run target can be the project interpreter",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let dir = dirs.target(&project.root, id);
|
||||
if !UPDATING.lock().unwrap_or_else(|e| e.into_inner()).insert(dir.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
let _done = Updating(dir.clone());
|
||||
let endpoint = crate::ops::run::container::endpoint(&dirs.data, &target)?;
|
||||
let task = TaskId::next();
|
||||
let title = format!("Python libraries of {}", target.name);
|
||||
let (root, sink) = (project.root.clone(), project.sink.clone());
|
||||
let progress = move |message: &str, done: bool| {
|
||||
sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: title.clone(),
|
||||
message: Some(message.to_owned()),
|
||||
fraction: None,
|
||||
done,
|
||||
cancellable: false,
|
||||
})
|
||||
};
|
||||
let report = progress.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
take(&endpoint, &root, &target, &dir, &dirs.cache, force, &|message| report(message, false))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
.and_then(|r| r);
|
||||
progress(if result.is_ok() { "Done" } else { "Failed" }, true);
|
||||
if result? {
|
||||
project.lsp.reconfigure().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Updating(PathBuf);
|
||||
|
||||
impl Drop for Updating {
|
||||
fn drop(&mut self) {
|
||||
UPDATING.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the probe container, also when copying failed.
|
||||
struct Container<'a> {
|
||||
endpoint: &'a ide_docker::DockerEndpoint,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl Drop for Container<'_> {
|
||||
fn drop(&mut self) {
|
||||
let args = ["rm", "--force", "--volumes", &self.name].map(String::from);
|
||||
if let Err(e) = ide_docker::cli(self.endpoint, None, &args, QUICK, None) {
|
||||
tracing::warn!("cannot remove container {}: {}", self.name, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the snapshot in `target_dir` and makes it the one in use; `false` when the image
|
||||
/// had one already and it is in use (unless `force`).
|
||||
fn take(
|
||||
endpoint: &ide_docker::DockerEndpoint,
|
||||
root: &Path,
|
||||
target: &RunTarget,
|
||||
target_dir: &Path,
|
||||
cache: &Path,
|
||||
force: bool,
|
||||
progress: &dyn Fn(&str),
|
||||
) -> IdeResult<bool> {
|
||||
crate::ops::run::container::require_local(endpoint, target)?;
|
||||
let container_root = crate::ops::run::container::container_root(endpoint, root, target)?;
|
||||
let name = format!("openidea-python-{}", unique());
|
||||
let args = ide_remote::docker::python_probe_args(target, root, &name, snapshots::PROBE)?;
|
||||
progress("Starting the container's Python");
|
||||
let container = Container { endpoint, name };
|
||||
let out = ide_docker::cli(endpoint, Some(root), &args, PROBE_TIMEOUT, None);
|
||||
let probe = snapshots::parse_probe(&out?)?;
|
||||
let inspect = ["inspect".into(), "--format".into(), "{{.Image}}".into(), container.name.clone()];
|
||||
let image = ide_docker::cli(endpoint, None, &inspect, QUICK, None)?.trim().to_owned();
|
||||
if image.is_empty() {
|
||||
return Err(IdeError::internal(format!("docker inspect printed no image for {}", container.name)));
|
||||
}
|
||||
let dir = target_dir.join(snapshots::dir_name(&image));
|
||||
let before = snapshots::current(target_dir).map(|(_, s)| s.image);
|
||||
if !force && snapshots::read(&dir).is_some() {
|
||||
snapshots::make_current(target_dir, &image)?;
|
||||
return Ok(before.as_deref() != Some(image.as_str()));
|
||||
}
|
||||
let partial = target_dir.join(format!("{}.partial", snapshots::dir_name(&image)));
|
||||
if partial.exists() {
|
||||
std::fs::remove_dir_all(&partial)?;
|
||||
}
|
||||
std::fs::create_dir_all(&partial)?;
|
||||
let mut folders = Vec::new();
|
||||
for folder in snapshots::folders_to_copy(&probe, &container_root) {
|
||||
progress(&format!("Copying {folder}"));
|
||||
let local = snapshots::local_folder(&partial, &folder);
|
||||
let Some(parent) = local.parent() else { continue };
|
||||
std::fs::create_dir_all(parent)?;
|
||||
// Into an existing folder: `docker cp` puts the source folder in it under its own name.
|
||||
let args = ["cp".into(), format!("{}:{folder}", container.name), format!("{}/", parent.to_string_lossy())];
|
||||
match ide_docker::cli(endpoint, None, &args, COPY_TIMEOUT, None) {
|
||||
Ok(_) => folders.push(folder),
|
||||
Err(e) => tracing::warn!("cannot copy {folder} from {}: {}", container.name, e.message),
|
||||
}
|
||||
}
|
||||
drop(container);
|
||||
progress("Preparing the language server's interpreter");
|
||||
let snapshot = Snapshot {
|
||||
neutral_python: snapshots::neutral_python(cache, &probe.version),
|
||||
image: image.clone(),
|
||||
probe,
|
||||
folders,
|
||||
};
|
||||
snapshots::write(&partial, &snapshot)?;
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
std::fs::rename(&partial, &dir)?;
|
||||
snapshots::make_current(target_dir, &image)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Distinguishes probe containers.
|
||||
fn unique() -> String {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
format!("{:x}-{}-{}", secs & 0xff_ffff, std::process::id(), COUNT.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
@@ -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, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)?;
|
||||
Ok((path, typos))
|
||||
speller(&project).check(&snap.text, ®ions, &|| 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…).
|
||||
|
||||
@@ -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(())
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -3047,16 +3044,7 @@ impl PluginWorkspace for HostWorkspace {
|
||||
&project.root,
|
||||
&project.settings.effective(),
|
||||
);
|
||||
if config.debug_adapter.is_none()
|
||||
&& let Some(language) = config.extras.params.get(super::run_detectors::DEBUG_LANGUAGE)
|
||||
{
|
||||
config.debug_adapter = project
|
||||
.languages
|
||||
.get(&ide_core::LanguageId::new(language.as_str()))
|
||||
.and_then(|l| l.debugger)
|
||||
.map(|d| d.adapter);
|
||||
}
|
||||
if config.debug_adapter.is_none() && !config.extras.params.contains_key(super::run_detectors::DEBUG_ATTACH) {
|
||||
if config.debug_adapter.is_none() {
|
||||
let cwd = config.cwd.as_ref().map(|p| p.as_ref()).unwrap_or(&project.root);
|
||||
config.debug_adapter =
|
||||
config.args.iter().map(|arg| cwd.join(arg)).filter(|path| path.is_file()).find_map(|path| {
|
||||
@@ -3188,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)
|
||||
}
|
||||
@@ -3324,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()
|
||||
@@ -3337,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}"),
|
||||
@@ -4136,25 +4130,25 @@ impl PluginApi {
|
||||
{
|
||||
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| f.note.is_some() || decoration_style(&f.style)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -4182,60 +4176,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!(
|
||||
@@ -4248,8 +4188,6 @@ fn decoration_style(style: &str) -> bool {
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "typo"
|
||||
| "weak"
|
||||
| "underline"
|
||||
| "strikethrough"
|
||||
| "muted"
|
||||
@@ -4370,11 +4308,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();
|
||||
}
|
||||
@@ -4389,8 +4324,7 @@ mod workbench_tests {
|
||||
badge: Some("abc".into()),
|
||||
style: "info".into(),
|
||||
tooltip: None,
|
||||
note: None,
|
||||
icon: None,
|
||||
note: None
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
@@ -4400,7 +4334,6 @@ mod workbench_tests {
|
||||
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| {
|
||||
@@ -4414,44 +4347,9 @@ mod workbench_tests {
|
||||
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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -77,22 +77,9 @@ pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Largest side of a plugin diff, in UTF-16 code units (earlier hosts allowed 1 MiB).
|
||||
pub(super) const MAX_DIFF_SIDE: usize = 8 << 20;
|
||||
|
||||
fn diff_side(value: &str, side: &str) -> Result<(), HostError> {
|
||||
if value.contains('\0') {
|
||||
return Err(HostError::Invalid(format!("the {side} side of the diff contains NUL")));
|
||||
}
|
||||
if value.encode_utf16().count() > MAX_DIFF_SIDE {
|
||||
return Err(HostError::Invalid(format!("diff too large: the {side} side is over {} MiB", MAX_DIFF_SIDE >> 20)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_diff(diff: &ide_plugin::ContentDiff) -> Result<(), HostError> {
|
||||
diff_side(&diff.left, "left")?;
|
||||
diff_side(&diff.right, "right")?;
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
@@ -415,67 +402,6 @@ fn validate_configurations(
|
||||
if let Some(cwd) = &c.cwd {
|
||||
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
|
||||
}
|
||||
validate_env(&c.env)?;
|
||||
validate_debugger(c.debugger.as_ref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Like `process` commands, a configuration only adds variables: one the IDE's own
|
||||
/// environment has (`PATH`, …) would change what the approved program runs.
|
||||
fn validate_env(env: &[(String, String)]) -> Result<(), HostError> {
|
||||
if env.len() > 64 {
|
||||
return Err(HostError::Invalid("at most 64 environment variables".into()));
|
||||
}
|
||||
let mut names = std::collections::BTreeSet::new();
|
||||
for (name, value) in env {
|
||||
if name.is_empty() || name.len() > 256 || name.contains(['=', '\0']) {
|
||||
return Err(HostError::Invalid(format!("{name:?} is not an environment variable name")));
|
||||
}
|
||||
if value.len() > 4096 || value.contains('\0') {
|
||||
return Err(HostError::Invalid(format!("the value of {name:?} is too long or holds a NUL")));
|
||||
}
|
||||
let key = if cfg!(windows) { name.to_ascii_uppercase() } else { name.clone() };
|
||||
if !names.insert(key) || inherited_env(name) {
|
||||
return Err(HostError::Invalid(format!(
|
||||
"environment variable {name:?} is repeated or already set; only new variables may be added"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inherited_env(name: &str) -> bool {
|
||||
std::env::vars_os().any(|(inherited, _)| {
|
||||
if cfg!(windows) { inherited.to_string_lossy().eq_ignore_ascii_case(name) } else { inherited == name }
|
||||
})
|
||||
}
|
||||
|
||||
/// Attach to Process kinds a configuration may attach to.
|
||||
pub(super) const ATTACH_KINDS: [&str; 5] = ["dotnet", "node", "python", "go", "native"];
|
||||
|
||||
fn validate_debugger(debugger: Option<&ide_plugin::PluginDebugger>) -> Result<(), HostError> {
|
||||
match debugger {
|
||||
Some(ide_plugin::PluginDebugger::Launch { language }) => {
|
||||
if language.is_empty()
|
||||
|| language.len() > 64
|
||||
|| !language.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
{
|
||||
return Err(HostError::Invalid(format!("{language:?} is not a language id")));
|
||||
}
|
||||
}
|
||||
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
|
||||
if !ATTACH_KINDS.contains(&kind.as_str()) {
|
||||
return Err(HostError::Invalid(format!(
|
||||
"attach-child kind {kind:?} is not one of {}",
|
||||
ATTACH_KINDS.join(", ")
|
||||
)));
|
||||
}
|
||||
if command_contains.as_ref().is_some_and(|t| t.is_empty() || t.len() > 256 || t.contains('\0')) {
|
||||
return Err(HostError::Invalid("attach-child command-contains must be 1-256 bytes".into()));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -592,52 +518,6 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// The process an `attach-child` configuration's debugger attaches to: the first Attach to
|
||||
/// Process candidate of its kind that `run` (a run of this configuration) started, waited
|
||||
/// for while the run lasts, up to a minute. Only the run's own descendants are candidates.
|
||||
pub fn debug_attach_target(
|
||||
&self,
|
||||
host: &crate::Host,
|
||||
client: &str,
|
||||
config: &str,
|
||||
run: ide_core::RunId,
|
||||
) -> IdeResult<ide_dap::attach::AttachableProcess> {
|
||||
use super::run_detectors::{DEBUG_ATTACH, DEBUG_ATTACH_CONTAINS};
|
||||
let Some((plugin, id)) = config.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':')) else {
|
||||
return Err(IdeError::invalid("not a plugin configuration"));
|
||||
};
|
||||
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
|
||||
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let kind = owned
|
||||
.extras
|
||||
.params
|
||||
.get(DEBUG_ATTACH)
|
||||
.filter(|k| ATTACH_KINDS.contains(&k.as_str()))
|
||||
.ok_or_else(|| IdeError::invalid("this configuration does not attach to a process"))?
|
||||
.clone();
|
||||
let contains = owned.extras.params.get(DEBUG_ATTACH_CONTAINS).cloned();
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let info = crate::ops::run::list(host).into_iter().find(|r| r.id == run && r.config_id == owned.id);
|
||||
let Some(pid) = info.as_ref().filter(|r| r.running).and_then(|r| r.pid) else {
|
||||
return Err(IdeError::not_found("the run ended before a process to debug started"));
|
||||
};
|
||||
let found = ide_dap::attach::attachable_descendants(pid)?.into_iter().find(|p| {
|
||||
serde_json::to_value(p.kind).ok().and_then(|v| v.as_str().map(|k| k == kind)) == Some(true)
|
||||
&& contains.as_ref().is_none_or(|t| p.command_line.contains(t.as_str()))
|
||||
});
|
||||
if let Some(process) = found {
|
||||
return Ok(process);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(IdeError::not_found(format!("no {kind} process started by the run within a minute")));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal launch metadata; it is ignored after the original operation finishes.
|
||||
pub(super) const RUN_OPERATION: &str = "plugin.operation";
|
||||
impl PluginHost {
|
||||
@@ -691,73 +571,3 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn diff(left: String, right: String) -> ide_plugin::ContentDiff {
|
||||
ide_plugin::ContentDiff { left, right, left_label: None, right_label: None, title: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_sides_up_to_the_limit_open_and_larger_ones_say_why() {
|
||||
assert!(validate_diff(&diff("x".repeat(5 << 20), "y".repeat(MAX_DIFF_SIDE))).is_ok());
|
||||
let err = validate_diff(&diff(String::new(), "y".repeat(MAX_DIFF_SIDE + 1))).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, HostError::Invalid(m) if m == "diff too large: the right side is over 8 MiB"),
|
||||
"{err:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(validate_diff(&diff("a\0".into(), String::new())), Err(HostError::Invalid(m)) if m.contains("NUL"))
|
||||
);
|
||||
}
|
||||
|
||||
fn launch(
|
||||
env: Vec<(&str, &str)>,
|
||||
debugger: Option<ide_plugin::PluginDebugger>,
|
||||
) -> ide_plugin::PluginRunConfiguration {
|
||||
ide_plugin::PluginRunConfiguration {
|
||||
id: "app".into(),
|
||||
name: "App".into(),
|
||||
program: "tool".into(),
|
||||
env: env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
|
||||
debugger,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configurations_only_add_environment_variables() {
|
||||
let root = std::env::temp_dir();
|
||||
let ok = |c| validate_configurations(&root, &[c]).is_ok();
|
||||
assert!(ok(launch(vec![("OPENIDEA_TEST_NEW_VARIABLE", "1")], None)));
|
||||
let (inherited, _) = std::env::vars().next().expect("the test runs with an environment");
|
||||
assert!(!ok(launch(vec![(inherited.as_str(), "x")], None)));
|
||||
assert!(!ok(launch(vec![("A_B", "1"), ("A_B", "2")], None)));
|
||||
for name in ["", "A=B", "A\0"] {
|
||||
assert!(!ok(launch(vec![(name, "1")], None)), "{name:?}");
|
||||
}
|
||||
assert!(!ok(launch(vec![("OPENIDEA_TEST_NUL", "a\0")], None)));
|
||||
let many: Vec<_> = (0..65).map(|i| (format!("OPENIDEA_TEST_{i}"), String::new())).collect();
|
||||
let mut config = launch(vec![], None);
|
||||
config.env = many;
|
||||
assert!(!ok(config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configurations_name_a_language_or_an_attach_to_process_kind() {
|
||||
use ide_plugin::PluginDebugger::{AttachChild, Launch};
|
||||
let root = std::env::temp_dir();
|
||||
let ok = |d| validate_configurations(&root, &[launch(vec![], Some(d))]).is_ok();
|
||||
assert!(ok(Launch { language: "csharp".into() }));
|
||||
assert!(!ok(Launch { language: String::new() }));
|
||||
assert!(!ok(Launch { language: "c sharp".into() }));
|
||||
for kind in ATTACH_KINDS {
|
||||
assert!(ok(AttachChild { kind: kind.into(), command_contains: None }), "{kind}");
|
||||
}
|
||||
assert!(!ok(AttachChild { kind: "java".into(), command_contains: None }));
|
||||
assert!(ok(AttachChild { kind: "dotnet".into(), command_contains: Some("Worker.dll".into()) }));
|
||||
assert!(!ok(AttachChild { kind: "dotnet".into(), command_contains: Some(String::new()) }));
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -57,7 +57,6 @@ pub const COMMANDS: &[&str] = &[
|
||||
"plugin_terminal_session_close",
|
||||
"plugin_terminal_session_spawn",
|
||||
"plugin_prepare_debug_run",
|
||||
"plugin_debug_attach_target",
|
||||
"plugin_finish_operation",
|
||||
"plugin_content_tab_action",
|
||||
"plugin_post_events",
|
||||
@@ -573,15 +572,6 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_debug_attach_target", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
let plugins = alive(&h)?;
|
||||
let (config, run) = (c.arg::<String>("config")?, c.arg("run")?);
|
||||
blocking(move || plugins.debug_attach_target(&c.host, &c.window, &config, run)).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_finish_operation", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
|
||||
@@ -130,11 +130,6 @@ pub struct PluginRunDetection {
|
||||
pub(crate) project_pins: super::api::ProjectPins,
|
||||
}
|
||||
|
||||
/// `RunConfig.extras.params` keys carrying an API 1.9 configuration's debugger.
|
||||
pub const DEBUG_LANGUAGE: &str = "debugLanguage";
|
||||
pub const DEBUG_ATTACH: &str = "debugAttach";
|
||||
pub const DEBUG_ATTACH_CONTAINS: &str = "debugAttachContains";
|
||||
|
||||
pub(super) fn config_id(plugin: &str, detector: &str) -> String {
|
||||
format!("{EXTERNAL_PREFIX}{plugin}:{detector}")
|
||||
}
|
||||
@@ -200,19 +195,6 @@ impl ExternalDetection for PluginRunDetection {
|
||||
if let Some(framework) = &c.framework {
|
||||
config.extras.params.insert("framework".into(), framework.clone());
|
||||
}
|
||||
config.env = c.env.iter().cloned().collect();
|
||||
match &c.debugger {
|
||||
Some(ide_plugin::PluginDebugger::Launch { language }) => {
|
||||
config.extras.params.insert(DEBUG_LANGUAGE.into(), language.clone());
|
||||
}
|
||||
Some(ide_plugin::PluginDebugger::AttachChild { kind, command_contains }) => {
|
||||
config.extras.params.insert(DEBUG_ATTACH.into(), kind.clone());
|
||||
if let Some(text) = command_contains {
|
||||
config.extras.params.insert(DEBUG_ATTACH_CONTAINS.into(), text.clone());
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -88,9 +88,6 @@ impl Host {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Untitled buffers belong to the window, not a project: they follow it
|
||||
// into the project's fresh document registry.
|
||||
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
let is_workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
@@ -177,7 +174,6 @@ impl Host {
|
||||
let root = member.as_ref().unwrap_or(window_root);
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
crate::ops::sdk::container_python::configure_server(cfg, &data_dir, root, &effective);
|
||||
if cfg.name == "jdtls" {
|
||||
// A workspace member keeps its build model under its own key (Build Project).
|
||||
let cache = match &member {
|
||||
@@ -451,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);
|
||||
}
|
||||
@@ -624,9 +615,6 @@ impl Host {
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
for (key, text, dirty) in &carried {
|
||||
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
|
||||
}
|
||||
project.lsp.prewarm_java_configuration();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
@@ -637,7 +625,6 @@ impl Host {
|
||||
if let Some(cmd) = post_start {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
crate::ops::sdk::container_python::ensure(self, &project);
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
@@ -672,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 {
|
||||
@@ -689,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)
|
||||
}
|
||||
@@ -709,29 +694,12 @@ impl Host {
|
||||
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
|
||||
}
|
||||
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
|
||||
let watch = dir.clone();
|
||||
self.open_light_in(window, dir, Some(&watch))
|
||||
}
|
||||
|
||||
/// LightEdit without a file (a welcome window's File › New File): rooted
|
||||
/// at the user's home folder, watching nothing until a buffer is saved
|
||||
/// into a real folder ([`Project::watch_light_dir`]).
|
||||
pub fn open_light_empty(&self, window: &str) -> IdeResult<Arc<Project>> {
|
||||
let dir = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
|
||||
self.open_light_in(window, dir, None)
|
||||
}
|
||||
|
||||
fn open_light_in(&self, window: &str, dir: PathBuf, watch_dir: Option<&Path>) -> IdeResult<Arc<Project>> {
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Untitled buffers belong to the window: they follow it out of a project.
|
||||
let carried = self.project(window).ok().map(|prev| prev.documents.untitled_buffers()).unwrap_or_default();
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.light
|
||||
{
|
||||
if let Some(dir) = watch_dir {
|
||||
current.watch_light_dir(dir);
|
||||
}
|
||||
current.watch_light_dir(&dir);
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
@@ -750,16 +718,7 @@ impl Host {
|
||||
let syntax = Arc::new(SyntaxCache::new(self.syntax()));
|
||||
documents.add_observer(Arc::new(SyntaxObserver(syntax.clone())));
|
||||
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
|
||||
let watcher = match watch_dir {
|
||||
Some(dir) => light_watcher(dir, &sink, &documents, &history)?,
|
||||
// A quiet private folder stands in until the first save brings a
|
||||
// real folder in.
|
||||
None => {
|
||||
let idle = self.local_data_dir.join("light-edit");
|
||||
std::fs::create_dir_all(&idle)?;
|
||||
light_watcher(&idle, &sink, &documents, &history)?
|
||||
}
|
||||
};
|
||||
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
|
||||
let project_key = project_key(&dir);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key, &settings, &sink);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
@@ -805,11 +764,8 @@ impl Host {
|
||||
light: true,
|
||||
root: dir,
|
||||
});
|
||||
tracing::info!("window {window} edits {} without a project", project.root.display());
|
||||
tracing::info!("window {window} edits {} without a project", file.display());
|
||||
self.insert_project(window, project.clone());
|
||||
for (key, text, dirty) in &carried {
|
||||
let _ = project.documents.restore_untitled_buffer(key, text, *dirty);
|
||||
}
|
||||
Ok(project)
|
||||
}
|
||||
}
|
||||
@@ -907,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| {
|
||||
@@ -932,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 {
|
||||
@@ -1275,9 +1225,7 @@ impl Project {
|
||||
}
|
||||
let defaults = self.documents.file_defaults();
|
||||
let fallback = defaults.fallback_for(path);
|
||||
// The working file only when it is a regular file: a path that is a directory there
|
||||
// (a committed symlink to a folder, a file replaced by a folder) has only its blob.
|
||||
if path.is_file() {
|
||||
if path.exists() {
|
||||
let loaded = ide_vfs::load_text_with_fallback(path, fallback)?;
|
||||
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
|
||||
@@ -1495,7 +1443,7 @@ fn content_root_watcher(
|
||||
) -> Option<Watcher> {
|
||||
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
|
||||
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
|
||||
let (root, events) = (dir.to_path_buf(), sink.clone());
|
||||
let root = dir.to_path_buf();
|
||||
Watcher::start(
|
||||
dir,
|
||||
excludes.to_vec(),
|
||||
@@ -1517,11 +1465,6 @@ fn content_root_watcher(
|
||||
}
|
||||
documents.on_disk_changed(&[&changed[..], &ignored].concat());
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
// A member outside the project folder: its repository changes (commit, checkout,
|
||||
// fetch, pull) reach the UI only through this watcher.
|
||||
if batch.vcs.iter().any(|p| vcs_path_changed(p)) {
|
||||
events.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}
|
||||
}),
|
||||
)
|
||||
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
|
||||
|
||||
@@ -218,13 +218,6 @@ impl Dispatcher {
|
||||
})
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open_untitled", |c| async move {
|
||||
ops::doc::open_untitled(&c.host, &c.window, c.arg::<Option<String>>("name")?).await
|
||||
})
|
||||
.add(
|
||||
"doc_save_as",
|
||||
|c| async move { ops::doc::save_as(&c.host, &c.window, c.arg("doc")?, c.arg("path")?).await },
|
||||
)
|
||||
.add("doc_close", |c| async move {
|
||||
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
|
||||
})
|
||||
@@ -277,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
|
||||
}
|
||||
|
||||
@@ -289,151 +289,45 @@ impl crate::Host {
|
||||
}
|
||||
}
|
||||
project.lsp.reconfigure().await;
|
||||
crate::ops::sdk::container_python::ensure(self, &project);
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands allowed in Restricted Mode, by exact name: those which only inspect/edit source or
|
||||
/// operate user-owned application state. A new command is denied until deliberately listed here.
|
||||
/// Commands which only inspect/edit source or operate user-owned application state.
|
||||
/// New execution commands are denied until deliberately classified here.
|
||||
pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
if [
|
||||
"fs_",
|
||||
"doc_",
|
||||
"syntax_",
|
||||
"search_",
|
||||
"nav_",
|
||||
"history_",
|
||||
"spell_",
|
||||
"analysis_",
|
||||
"structural_",
|
||||
"refactor_",
|
||||
"bytes_",
|
||||
"window_",
|
||||
"window_group_",
|
||||
"term_",
|
||||
"settings_",
|
||||
"project_trust_",
|
||||
// New Project writes only where the user chose and runs no code of an open project.
|
||||
"scaffold_",
|
||||
"ssh_",
|
||||
"git_credential",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| cmd.starts_with(p))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
cmd,
|
||||
"fs_compare_directories"
|
||||
| "fs_copy"
|
||||
| "fs_copy_merge"
|
||||
| "fs_create_dir"
|
||||
| "fs_create_file"
|
||||
| "fs_create_file_bytes"
|
||||
| "fs_delete"
|
||||
| "fs_entry_kind"
|
||||
| "fs_open_with_system"
|
||||
// The user's own diff program (global settings) on the diff's two sides.
|
||||
| "diff_external_tool"
|
||||
| "fs_probe"
|
||||
| "fs_read_dir"
|
||||
| "fs_read_image"
|
||||
| "fs_read_range"
|
||||
| "fs_read_text"
|
||||
| "fs_rename"
|
||||
| "fs_resolve_reference"
|
||||
| "fs_set_writable"
|
||||
| "doc_close"
|
||||
| "doc_edit"
|
||||
| "doc_folding"
|
||||
| "doc_highlights"
|
||||
| "doc_open"
|
||||
| "doc_open_untitled"
|
||||
| "doc_open_virtual"
|
||||
| "doc_outline"
|
||||
| "doc_python_facts"
|
||||
| "doc_save"
|
||||
| "doc_save_as"
|
||||
| "doc_save_all"
|
||||
| "doc_selection_ranges"
|
||||
| "doc_set_bom"
|
||||
| "doc_set_encoding"
|
||||
| "doc_set_line_ending"
|
||||
| "doc_snapshot"
|
||||
| "doc_sync_from_disk"
|
||||
| "doc_ui_mode"
|
||||
| "syntax_errors"
|
||||
| "syntax_expression_at"
|
||||
| "syntax_identifiers"
|
||||
| "syntax_identifiers_in_files"
|
||||
| "syntax_locals"
|
||||
| "syntax_node_at"
|
||||
| "syntax_structural_ranges"
|
||||
| "syntax_support"
|
||||
| "search_start"
|
||||
| "search_validate"
|
||||
| "nav_index_definitions"
|
||||
| "nav_inheritance"
|
||||
| "nav_resolve_module"
|
||||
| "history_content"
|
||||
| "history_list"
|
||||
| "history_list_dir"
|
||||
| "history_put_label"
|
||||
| "history_restore"
|
||||
| "spell_check"
|
||||
| "spell_suggest"
|
||||
| "analysis_locate_duplicates"
|
||||
| "structural_languages"
|
||||
| "structural_search"
|
||||
| "structural_validate"
|
||||
| "refactor_can_undo_file_move"
|
||||
| "refactor_file_move_edits"
|
||||
| "refactor_file_move_text_edits"
|
||||
| "refactor_move_files"
|
||||
| "refactor_undo_file_move"
|
||||
| "window_close_context"
|
||||
| "window_complete_launch"
|
||||
| "window_cycle"
|
||||
| "window_flash_taskbar"
|
||||
| "window_group_cancel_close"
|
||||
| "window_group_close"
|
||||
| "window_group_close_tabs"
|
||||
| "window_group_current"
|
||||
| "window_group_detach"
|
||||
| "window_group_merge"
|
||||
| "window_group_reorder"
|
||||
| "window_group_restore"
|
||||
| "window_group_select"
|
||||
| "window_is_fullscreen"
|
||||
| "window_new"
|
||||
| "window_open_comparison"
|
||||
| "window_open_deployment"
|
||||
| "window_open_diff"
|
||||
| "window_open_file_in_new_window"
|
||||
| "window_open_files"
|
||||
| "window_open_project"
|
||||
| "window_open_projects"
|
||||
| "window_open_remote"
|
||||
| "window_open_settings"
|
||||
| "window_set_titlebar_height"
|
||||
| "window_start_drag"
|
||||
| "window_take_launch"
|
||||
| "window_toggle_fullscreen"
|
||||
| "window_toggle_zoom"
|
||||
| "term_available_shells"
|
||||
| "term_default_shell"
|
||||
| "term_is_busy"
|
||||
| "term_kill"
|
||||
| "term_resize"
|
||||
| "term_spawn"
|
||||
| "term_store_image"
|
||||
| "term_write"
|
||||
| "settings_export"
|
||||
| "settings_get"
|
||||
| "settings_get_global"
|
||||
| "settings_import"
|
||||
| "settings_list_jetbrains_ides"
|
||||
| "settings_read_jetbrains"
|
||||
| "settings_set"
|
||||
| "project_trust_add_location"
|
||||
| "project_trust_list"
|
||||
| "project_trust_remove_location"
|
||||
| "project_trust_set"
|
||||
| "project_trust_status"
|
||||
// New Project writes only where the user chose and runs no code of an open project.
|
||||
| "scaffold_catalog"
|
||||
| "scaffold_create"
|
||||
| "scaffold_options"
|
||||
| "scaffold_requirements"
|
||||
| "ssh_askpass_answer"
|
||||
| "ssh_connected"
|
||||
| "ssh_disconnect"
|
||||
| "ssh_needs_secret"
|
||||
| "ssh_open_terminal"
|
||||
| "ssh_password_saved"
|
||||
| "ssh_set_secret"
|
||||
| "ssh_test"
|
||||
| "ssh_trust_host"
|
||||
| "git_credential_forget"
|
||||
| "git_credentials"
|
||||
| "app_info"
|
||||
"app_info"
|
||||
| "app_build_info"
|
||||
| "app_cli_install"
|
||||
| "app_cli_uninstall"
|
||||
@@ -447,7 +341,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "clear_caches"
|
||||
| "clipboard_file_list"
|
||||
| "clipboard_has_files"
|
||||
| "clipboard_has_image"
|
||||
| "clipboard_image"
|
||||
| "clipboard_set_file_list"
|
||||
| "clipboard_write_text"
|
||||
@@ -464,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"
|
||||
@@ -507,7 +399,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "web_unresolved_references"
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "light_edit_open_untitled"
|
||||
| "workspace_create"
|
||||
| "workspace_discover_projects"
|
||||
| "workspace_update"
|
||||
@@ -584,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"
|
||||
@@ -678,14 +568,6 @@ mod tests {
|
||||
assert!(super::restricted_command_allowed("clipboard_write_text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_mode_denies_new_commands_whatever_their_name() {
|
||||
for cmd in ["fs_new_command", "term_new_command", "ssh_new_command", "settings_new_command", "git_credential_x"]
|
||||
{
|
||||
assert!(!super::restricted_command_allowed(cmd), "{cmd} is allowed without being listed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_project_decisions_are_kept_on_this_machine() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! A Compose run target as the Python interpreter, against a real daemon and the real
|
||||
//! basedpyright. Ignored by default: `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the
|
||||
//! daemon can bind-mount (on this machine, or at the same path in a VM such as OrbStack's),
|
||||
//! `docker` with the Compose plugin and `basedpyright-langserver` must be on `PATH`, and
|
||||
//! this machine's Python must not have Django installed.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::IdeEvent;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
type Events = Arc<Mutex<Vec<IdeEvent>>>;
|
||||
|
||||
fn host(base: &Path, events: &Events) -> Arc<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({
|
||||
let events = events.clone();
|
||||
move |_| {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
|
||||
}
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// basedpyright's latest diagnostics of `path` once `done` accepts them.
|
||||
async fn diagnostics(events: &Events, path: &Path, timeout: Duration, done: impl Fn(&[String]) -> bool) -> Vec<String> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let latest = events.lock().unwrap().iter().rev().find_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path: p, items, source } if **p == *path && source.contains("basedpyright") => {
|
||||
Some(items.iter().map(|d| d.message.clone()).collect::<Vec<_>>())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(messages) = &latest
|
||||
&& done(messages)
|
||||
{
|
||||
return messages.clone();
|
||||
}
|
||||
assert!(Instant::now() < deadline, "timed out; last diagnostics: {latest:?}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn unresolved(messages: &[String]) -> bool {
|
||||
messages.iter().any(|m| m.contains("\"django") && m.contains("could not be resolved"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "requires a Docker daemon, basedpyright and OPENIDEA_DOCKER_TEST_DIR"]
|
||||
async fn compose_target_is_the_python_interpreter() {
|
||||
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let files = [
|
||||
("compose.yaml", "services:\n web:\n build: .\n volumes:\n - .:/app\n working_dir: /app\n"),
|
||||
(
|
||||
"Dockerfile",
|
||||
"FROM python:3.12-slim\nCOPY requirements.txt /tmp/requirements.txt\n\
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt\n",
|
||||
),
|
||||
("requirements.txt", "Django==5.1.3\n"),
|
||||
(
|
||||
"app/main.py",
|
||||
"import django\nfrom django.http import HttpResponse\n\nprint(django.get_version(), HttpResponse)\n",
|
||||
),
|
||||
];
|
||||
for (path, text) in files {
|
||||
std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(root.join(path), text).unwrap();
|
||||
}
|
||||
let main = root.join("app/main.py");
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let events: Events = Arc::default();
|
||||
let host = host(state.path(), &events);
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project(WINDOW, &root).unwrap();
|
||||
let set = |key: &str, value: serde_json::Value| {
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
WINDOW,
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
key,
|
||||
&value.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
set("runTargets", serde_json::json!([{ "kind": "compose", "id": "web", "name": "Compose web", "service": "web" }]));
|
||||
|
||||
// A local interpreter: Django is only in the image.
|
||||
ops::doc::open(&host, WINDOW, main.clone()).await.unwrap();
|
||||
let before = diagnostics(&events, &main, Duration::from_secs(120), unresolved).await;
|
||||
eprintln!("local interpreter: {before:?}");
|
||||
|
||||
// The run target as the interpreter: a snapshot is taken in the background, then the
|
||||
// server restarts with the copied site-packages as `extraPaths`.
|
||||
set("languages.python.interpreter", serde_json::json!("target:web"));
|
||||
let after = diagnostics(&events, &main, Duration::from_secs(900), |m| !unresolved(m)).await;
|
||||
eprintln!("run target interpreter: {after:?}");
|
||||
assert!(after.iter().all(|m| !m.contains("could not be resolved")), "{after:?}");
|
||||
|
||||
let info = ops::sdk::sdk_container_python(&host, WINDOW).unwrap().expect("a run target interpreter");
|
||||
eprintln!("snapshot: version {:?}, image {:?}, folders {:?}", info.version, info.image, info.folders);
|
||||
assert_eq!(info.name, "Compose web");
|
||||
assert!(info.version.as_deref().is_some_and(|v| v.starts_with("3.12.")), "{info:?}");
|
||||
assert!(info.packages.iter().any(|p| p.name == "Django" && p.version == "5.1.3"), "{:?}", info.packages);
|
||||
let site = info.folders.iter().find(|f| f.0.ends_with("site-packages")).expect("site-packages copied");
|
||||
assert!(site.0.join("django/__init__.py").is_file());
|
||||
assert!(site.0.starts_with(state.path().join("data/container-python")), "{site:?}");
|
||||
let servers = ops::lsp::lsp_servers(&host, WINDOW).unwrap();
|
||||
eprintln!("servers: {:?}", servers.iter().map(|s| (&s.name, s.status)).collect::<Vec<_>>());
|
||||
|
||||
// Run: a Python configuration without its own target runs in the container.
|
||||
let out = Arc::new(Mutex::new(String::new()));
|
||||
let sink = out.clone();
|
||||
let config = RunConfig {
|
||||
id: "main".into(),
|
||||
name: "main".into(),
|
||||
kind: "python".into(),
|
||||
command: "python3".into(),
|
||||
args: vec!["app/main.py".into()],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
ops::run::start(
|
||||
&host,
|
||||
WINDOW,
|
||||
config,
|
||||
Arc::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
sink.lock().unwrap().push_str(&text)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..1200 {
|
||||
if out.lock().unwrap().contains("Process finished") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let out = out.lock().unwrap().clone();
|
||||
eprintln!("run output: {out}");
|
||||
assert!(out.contains("5.1.3") && out.contains("exit code 0"), "{out}");
|
||||
|
||||
// Refresh takes a new snapshot of the same image; nothing is left running.
|
||||
let refreshed = ops::sdk::sdk_container_python_refresh(&host, WINDOW).await.unwrap().unwrap();
|
||||
assert_eq!(refreshed.image, info.image);
|
||||
let probes = Command::new("docker")
|
||||
.args(["ps", "--all", "--quiet", "--filter", "label=openidea.python-probe=true"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&probes.stdout).trim(), "", "probe containers are removed");
|
||||
Command::new("docker").args(["compose", "down", "--rmi", "local"]).current_dir(&root).status().unwrap();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Loaded 100 of 1339 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача