mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9944d13314 | ||
|
|
7aad01b035 | ||
|
|
2613e61ebf | ||
|
|
971760f858 | ||
|
|
ad5f827673 | ||
|
|
dd8c8b8993 | ||
|
|
35ba957893 | ||
|
|
e272131823 | ||
|
|
d5fe771eed | ||
|
|
14c7821cf4 | ||
|
|
73dd9fdf65 | ||
|
|
091c25351b | ||
|
|
81fe384f4f | ||
|
|
01defdefe4 | ||
|
|
5bab140968 | ||
|
|
33d8d87ca2 | ||
|
|
669595e045 | ||
|
|
9c31a27e30 | ||
|
|
f265e636e9 | ||
|
|
47bd21e315 | ||
|
|
a5b7c154e1 | ||
|
|
b2c183dbd4 | ||
|
|
bc70ec7028 | ||
|
|
195356c4b1 | ||
|
|
67e3c60db8 | ||
|
|
f87d153caf | ||
|
|
552a43a0a1 | ||
|
|
2b1add64db | ||
|
|
d277fc1522 | ||
|
|
b983e3d0b2 | ||
|
|
ce981e4fd6 | ||
|
|
19b96b60a4 | ||
|
|
9295a8f78a | ||
|
|
f507f55d16 | ||
|
|
86cf348310 | ||
|
|
a1294598a5 | ||
|
|
6669df8313 | ||
|
|
83eb1ce2ba | ||
|
|
6356e85671 | ||
|
|
ed6092a4e9 | ||
|
|
12f676e0c0 | ||
|
|
1ee22dbf35 | ||
|
|
7ba75c814c | ||
|
|
ae110f73be | ||
|
|
c3252d43c3 | ||
|
|
924446279b | ||
|
|
f88b5d6f8c | ||
|
|
ea53e4f6fa | ||
|
|
379a52d6e7 | ||
|
|
92b3ae508c | ||
|
|
d837cb1acb | ||
|
|
932ac379bc | ||
|
|
fd5908e224 | ||
|
|
e017154c75 | ||
|
|
3655961642 | ||
|
|
545fb93744 | ||
|
|
d9d1caa476 | ||
|
|
9d1fe17894 | ||
|
|
9575500dd6 | ||
|
|
0329678124 | ||
|
|
2924c0f8e5 | ||
|
|
082420b5bb | ||
|
|
9a5b44bebb | ||
|
|
2203938491 | ||
|
|
503bbb2f02 | ||
|
|
ab6fca01c4 | ||
|
|
0a27d02f4d | ||
|
|
97b8928e4f | ||
|
|
53fd3683e2 | ||
|
|
27bef6956e | ||
|
|
3563f70810 | ||
|
|
7a46774fda | ||
|
|
2ad979af66 | ||
|
|
f5a95dcc21 | ||
|
|
1ed6f7241b | ||
|
|
0d880b9d35 | ||
|
|
95068f5885 | ||
|
|
15d926a5d8 | ||
|
|
7da58637c2 | ||
|
|
b1be3c6a09 | ||
|
|
5792e7d9b6 | ||
|
|
ab7cc02561 | ||
|
|
a7b5758f5a | ||
|
|
e85ffa2ae8 | ||
|
|
2c10b63f76 | ||
|
|
44d0f40b13 | ||
|
|
f28d8da3a1 | ||
|
|
822a93abac | ||
|
|
08956b615e | ||
|
|
6c1eba9d82 | ||
|
|
cdb6cc7c0d | ||
|
|
181a2ea03e | ||
|
|
d44e2414ca | ||
|
|
3bb994062d | ||
|
|
2ea47873cf | ||
|
|
c55fd9b86e | ||
|
|
922bbf493a | ||
|
|
c0626b2728 | ||
|
|
0d368ba947 | ||
|
|
3a1e47d872 | ||
|
|
d1f84ef364 | ||
|
|
00693334f7 | ||
|
|
285c7187fb | ||
|
|
1602e4b530 | ||
|
|
5217f8570b | ||
|
|
c3b3534f08 | ||
|
|
aeec0e6759 | ||
|
|
d1a597898d | ||
|
|
94d209c99c | ||
|
|
44f01b5ce6 | ||
|
|
14d7fd0061 | ||
|
|
b9cfd54742 | ||
|
|
f6a0152488 | ||
|
|
234703c7e9 | ||
|
|
aac924517c | ||
|
|
b1347dd331 | ||
|
|
fc2496c3f0 | ||
|
|
0f26b72d07 | ||
|
|
ad09a76212 | ||
|
|
8568a70a05 | ||
|
|
a20207110e | ||
|
|
74b2d5f5d7 | ||
|
|
297b15a7b1 | ||
|
|
97144421d2 | ||
|
|
200ed6a528 | ||
|
|
4666c4b9a5 | ||
|
|
ce43a7aa70 | ||
|
|
f911196f7d | ||
|
|
8dea5904f2 | ||
|
|
c7d32f9e23 | ||
|
|
3f9512056d | ||
|
|
1283af8a41 | ||
|
|
d37b96002b | ||
|
|
364bb5ef50 | ||
|
|
4d503ef976 | ||
|
|
75b1704f78 | ||
|
|
06d78cf524 | ||
|
|
fff2c7110c | ||
|
|
9fbd57ed15 | ||
|
|
aea0b33585 | ||
|
|
e537dd43a0 | ||
|
|
c821ab144c | ||
|
|
6767a5ff8c | ||
|
|
a233d85fcc | ||
|
|
4e675c1171 | ||
|
|
8ebeaa2663 | ||
|
|
ed7b0f88ef | ||
|
|
9d4e778c7b | ||
|
|
c8e3fe9d2e | ||
|
|
4410da60a2 |
No files matched your search
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -69,6 +71,15 @@ jobs:
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
|
||||
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
|
||||
|
||||
deny:
|
||||
name: Cargo deny
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: taiki-e/install-action@cargo-deny
|
||||
- run: cargo deny check
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -82,6 +93,7 @@ jobs:
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm format:check
|
||||
- run: pnpm knip
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@ jobs:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
@@ -172,8 +174,65 @@ jobs:
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
needs: [create-release, build, server]
|
||||
needs: [create-release, build, server, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dist",
|
||||
"target",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"src-tauri",
|
||||
"crates",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"fixtures",
|
||||
"plugins",
|
||||
"test-results",
|
||||
|
||||
Generated
+434
-143
File diff suppressed because it is too large.
Load diff
+10
-2
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.23"
|
||||
version = "0.1.28"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -36,9 +36,11 @@ ide-web = { path = "crates/ide-web" }
|
||||
ide-spell = { path = "crates/ide-spell" }
|
||||
ide-agent = { path = "crates/ide-agent" }
|
||||
ide-clipboard = { path = "crates/ide-clipboard" }
|
||||
ide-fonts = { path = "crates/ide-fonts" }
|
||||
ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -74,6 +76,7 @@ trash = "5"
|
||||
blake3 = "1"
|
||||
dunce = "1"
|
||||
dirs = "6"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
|
||||
# search, index, language
|
||||
grep-searcher = "0.1"
|
||||
@@ -84,13 +87,18 @@ redb = "4"
|
||||
tree-sitter = "0.25"
|
||||
|
||||
# protocols & integrations
|
||||
jupyter-zmq-client = { version = "=2.0.0", default-features = false, features = ["tokio-runtime", "ring"] }
|
||||
jupyter-protocol = "=2.0.3"
|
||||
zeromq = { version = "0.6", default-features = false, features = ["tokio-runtime", "tcp-transport"] }
|
||||
lsp-types = "0.97"
|
||||
gix = { version = "0.89", default-features = false }
|
||||
portable-pty = "0.9"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "gzip", "deflate", "brotli"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# testing
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#build">Build</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -54,6 +56,15 @@ pnpm tauri dev
|
||||
|
||||
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
Native notebook execution tests are opt-in and need two distinct Python environments
|
||||
with `ipykernel` installed:
|
||||
|
||||
```sh
|
||||
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
|
||||
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
|
||||
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -28,6 +28,19 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
|
||||
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
|
||||
|
||||
## JetBrains IntelliJ Platform diff algorithm
|
||||
|
||||
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
|
||||
[intellij-community](https://github.com/JetBrains/intellij-community) (Apache-2.0):
|
||||
`platform/util/diff` (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS,
|
||||
PatienceIntLCS, UniqueLCS; `com.intellij.diff.comparison`: ByLineRt, ByWordRt,
|
||||
ByCharRt, ChunkOptimizer, ChangeCorrector, LineFragmentSplitter, TrimUtil,
|
||||
ComparisonUtil, iterables) and `platform/diff-impl` (ComparisonManagerImpl's
|
||||
`compareLinesInner` and `processBlocks`). `__tests__/ideaCases.json` is converted
|
||||
from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
|
||||
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
|
||||
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
|
||||
languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig,
|
||||
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
|
||||
@@ -98,6 +111,18 @@ shell and css dictionaries of [cspell-dicts](https://github.com/streetsidesoftwa
|
||||
(October 2026): lowercased, filtered and merged by OpenIDEA. Copyright Street Side Software;
|
||||
MIT licence, full text in `crates/ide-spell/src/dict/LICENSE-cspell-dicts`.
|
||||
|
||||
## CodeMirror Angular template language
|
||||
|
||||
`src/editor/core/cm/angular/` (`angular.grammar`, `tokens.ts`, `index.ts` and the
|
||||
`parser.ts` generated from them) is derived from
|
||||
[@codemirror/lang-angular](https://github.com/codemirror/lang-angular) 0.1.4
|
||||
(commit `ecc5038f`). Copyright (C) 2018-2021 by Marijn Haverbeke and others; MIT
|
||||
licence, full text in `src/editor/core/cm/angular/LICENSE`. Modifications by
|
||||
OpenIDEA: control-flow blocks (`@if`, `@for`, `@switch`, `@defer`, ...) and `@let`
|
||||
declarations, key and animation binding names (`(keydown.enter)`, `[@fade]`,
|
||||
`(@fade.done)`), runs of text that entities split (`a && b`) parsed as one, and one
|
||||
token for `{` and `}`.
|
||||
|
||||
## nvim-treesitter queries
|
||||
|
||||
`crates/ide-syntax/queries/<grammar>/locals.scm` are derived from the `locals.scm`
|
||||
|
||||
@@ -6,6 +6,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
|
||||
@@ -147,6 +147,7 @@ async fn a_task_runs_on_a_v2_agent_and_resumes_its_session() {
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Use the TodoWrite tool"),
|
||||
provider: "claude-acp".into(),
|
||||
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
|
||||
|
||||
@@ -4,6 +4,49 @@
|
||||
use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Cursor exposes execution modes over ACP, but leaves approval policy to its client.
|
||||
pub(crate) const CURSOR_APPROVAL: &str = "openidea.cursor.approval";
|
||||
|
||||
pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOption>) {
|
||||
if provider != "cursor" || config.iter().any(|o| o.id == CURSOR_APPROVAL) {
|
||||
return;
|
||||
}
|
||||
config.push(AgentConfigOption {
|
||||
id: CURSOR_APPROVAL.into(),
|
||||
name: "Approvals".into(),
|
||||
description: Some("Tool approvals for this Cursor conversation".into()),
|
||||
category: Some("approval".into()),
|
||||
current: Some("ask".into()),
|
||||
via: AgentConfigVia::Option,
|
||||
values: vec![
|
||||
AgentConfigValue {
|
||||
value: "ask".into(),
|
||||
name: "Ask for approval".into(),
|
||||
description: Some("Show permission requests from Cursor".into()),
|
||||
},
|
||||
AgentConfigValue {
|
||||
value: "bypass".into(),
|
||||
name: "Bypass approvals".into(),
|
||||
description: Some("Automatically approve tool requests in this conversation".into()),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
|
||||
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
|
||||
}
|
||||
|
||||
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
|
||||
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
|
||||
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
|
||||
let value = option.values.iter().find(|v| {
|
||||
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
})?;
|
||||
Some((option, value))
|
||||
}
|
||||
|
||||
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
|
||||
/// plus the older `modes` / `models` fields for what those don't cover.
|
||||
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
@@ -64,7 +107,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
|
||||
pub(crate) fn merge_update(config: &mut Vec<AgentConfigOption>, options: &Value) {
|
||||
let fresh = parse_options(options);
|
||||
let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category);
|
||||
config.retain(|o| o.via != AgentConfigVia::Option && !covered(o));
|
||||
config.retain(|o| o.id == CURSOR_APPROVAL || (o.via != AgentConfigVia::Option && !covered(o)));
|
||||
let legacy = std::mem::take(config);
|
||||
config.extend(fresh);
|
||||
config.extend(legacy);
|
||||
|
||||
+140
-11
@@ -23,6 +23,7 @@ pub use model::*;
|
||||
use conversation::{Conversation, SavedSubagent, Session};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
|
||||
use ide_vcs::Repo;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -46,6 +47,8 @@ const STDERR_LINES: usize = 20;
|
||||
/// Updates of a session's state rather than its conversation.
|
||||
const SESSION_STATE: [&str; 5] =
|
||||
["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"];
|
||||
/// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]).
|
||||
pub(crate) const RETRY_UPDATE: &str = "openidea_retry";
|
||||
/// Referenced files up to this size are embedded in the prompt (when the agent takes it).
|
||||
const MAX_EMBED: u64 = 256 * 1024;
|
||||
/// How long a probe waits for the agent to answer `initialize`.
|
||||
@@ -112,7 +115,17 @@ impl Shared {
|
||||
}
|
||||
|
||||
fn known_config(&self, provider: &str) -> Vec<AgentConfigOption> {
|
||||
self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default()
|
||||
let mut config = self.known.lock().get(provider).map(|k| k.config.clone()).unwrap_or_default();
|
||||
config::add_client_options(provider, &mut config);
|
||||
if let Some((o, v)) = config::bypass_mode(&config).map(|(o, v)| (o.id.clone(), v.value.clone()))
|
||||
&& let Some(o) = config.iter_mut().find(|c| c.id == o)
|
||||
{
|
||||
o.current = Some(v);
|
||||
}
|
||||
if let Some(o) = config.iter_mut().find(|o| o.id == config::CURSOR_APPROVAL) {
|
||||
o.current = Some("bypass".into());
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// The commands the agent offered last time in this project.
|
||||
@@ -264,6 +277,7 @@ impl AgentManager {
|
||||
if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) {
|
||||
f.task.status = AgentStatus::Ready;
|
||||
}
|
||||
f.task.retry = None;
|
||||
let task = Task::restore(f, shared.clone());
|
||||
task.state.lock().conversation.settle(now_ms());
|
||||
tasks.insert(task.id.clone(), task);
|
||||
@@ -305,8 +319,8 @@ impl AgentManager {
|
||||
}
|
||||
|
||||
/// The slash commands to complete for `provider`, in `task`'s directory or (without one)
|
||||
/// in the project: what the agent offered last time there, plus, for Claude Code, the
|
||||
/// skills and custom commands on disk it has not listed.
|
||||
/// in the project: what the agent offered last time there, unadvertised builtins,
|
||||
/// and, for Claude Code, skills and custom commands on disk it has not listed.
|
||||
pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult<Vec<AgentCommand>> {
|
||||
let (provider, offered, cwd) = match task {
|
||||
Some(id) => {
|
||||
@@ -316,6 +330,14 @@ impl AgentManager {
|
||||
}
|
||||
None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()),
|
||||
};
|
||||
// Commands the agent implements itself and never lists (its CLI builtins):
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
|
||||
}
|
||||
}
|
||||
if provider != "claude" && provider != "claude-acp" {
|
||||
return Ok(offered);
|
||||
}
|
||||
@@ -344,9 +366,16 @@ impl AgentManager {
|
||||
.ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?;
|
||||
let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?;
|
||||
let title = title_of(&prompt);
|
||||
let root = self.shared.paths.project_root.clone();
|
||||
let root = req.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone());
|
||||
let project_root = Some(IpcPath(root.clone()));
|
||||
let (cwd, branch, base, base_commit, before) = match req.isolation {
|
||||
Isolation::Worktree => {
|
||||
// The UI offers worktrees for git projects only; state it when it isn't one.
|
||||
if Repo::discover(&root)?.is_none() {
|
||||
return Err(IdeError::invalid(
|
||||
"worktrees need a git repository: run the task in the local checkout",
|
||||
));
|
||||
}
|
||||
let dir = self.shared.paths.worktrees_dir.clone();
|
||||
let (t, b) = (title.clone(), req.base.clone());
|
||||
let c = blocking(move || worktree::create(&root, &dir, &t, b.as_deref())).await?;
|
||||
@@ -370,6 +399,7 @@ impl AgentManager {
|
||||
}
|
||||
let now = now_ms();
|
||||
let task = AgentTask {
|
||||
project_root,
|
||||
id: uuid::Uuid::new_v4().simple().to_string(),
|
||||
title,
|
||||
provider: spec.id.clone(),
|
||||
@@ -389,6 +419,7 @@ impl AgentManager {
|
||||
queue: Vec::new(),
|
||||
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
};
|
||||
let file = TaskFile {
|
||||
task: task.clone(),
|
||||
@@ -512,7 +543,7 @@ impl AgentManager {
|
||||
o.clone()
|
||||
};
|
||||
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
|
||||
if let Some(live) = live {
|
||||
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
|
||||
let (method, mut params) = config::set_request(&target, &value);
|
||||
params["sessionId"] = json!(live.session);
|
||||
let reply: Value =
|
||||
@@ -529,7 +560,12 @@ impl AgentManager {
|
||||
o.current = Some(value);
|
||||
}
|
||||
s.task.model = model_of(&s.task.config).or(s.task.model.take());
|
||||
let provider = s.task.provider.clone();
|
||||
let config = s.task.config.clone();
|
||||
drop(s);
|
||||
task.shared.remember(&provider, Some(&config), None);
|
||||
}
|
||||
task.approve_pending();
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
Ok(())
|
||||
@@ -577,6 +613,7 @@ impl AgentManager {
|
||||
usage: None,
|
||||
queue: Vec::new(),
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
..s.task.clone()
|
||||
};
|
||||
TaskFile {
|
||||
@@ -736,18 +773,41 @@ impl AgentManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns an archived task to the active list without starting the agent.
|
||||
pub fn unarchive(&self, id: &str) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
{
|
||||
let mut s = task.state.lock();
|
||||
if s.task.status != AgentStatus::Archived {
|
||||
return Ok(());
|
||||
}
|
||||
if s.busy {
|
||||
return Err(IdeError::invalid("the task is still stopping"));
|
||||
}
|
||||
s.task.status = AgentStatus::Ready;
|
||||
s.task.error = None;
|
||||
s.task.updated_ms = now_ms();
|
||||
}
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
|
||||
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
task.kill();
|
||||
let (isolation, cwd) = {
|
||||
let (isolation, cwd, root) = {
|
||||
let s = task.state.lock();
|
||||
(s.task.isolation, s.task.cwd.0.clone())
|
||||
(
|
||||
s.task.isolation,
|
||||
s.task.cwd.0.clone(),
|
||||
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
|
||||
)
|
||||
};
|
||||
// A copy of the task (a fork) may work in the same worktree.
|
||||
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
|
||||
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
|
||||
let root = self.shared.paths.project_root.clone();
|
||||
blocking(move || worktree::remove(&root, &cwd)).await?;
|
||||
}
|
||||
self.tasks.write().remove(id);
|
||||
@@ -784,7 +844,8 @@ impl AgentManager {
|
||||
|
||||
impl Task {
|
||||
fn restore(file: TaskFile, shared: Arc<Shared>) -> Arc<Self> {
|
||||
let task = file.task;
|
||||
let mut task = file.task;
|
||||
config::add_client_options(&task.provider, &mut task.config);
|
||||
let conversation = Conversation::restore(file.entries, file.subagents);
|
||||
Arc::new(Self {
|
||||
id: task.id.clone(),
|
||||
@@ -830,9 +891,22 @@ impl Task {
|
||||
self.shared.emit(AgentEvent::Task { task });
|
||||
}
|
||||
|
||||
/// Shows (or clears) the agent's pending retry; emitted only when it changes.
|
||||
fn set_retry(&self, retry: Option<AgentRetry>) {
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
if s.task.retry == retry {
|
||||
return;
|
||||
}
|
||||
s.task.retry = retry;
|
||||
}
|
||||
self.emit_task();
|
||||
}
|
||||
|
||||
fn set_status(&self, status: AgentStatus, error: Option<String>) {
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
s.task.retry = None;
|
||||
s.task.status = status;
|
||||
s.task.error = error;
|
||||
s.task.updated_ms = now_ms();
|
||||
@@ -991,6 +1065,7 @@ impl Task {
|
||||
self.push_entry(AgentEntry::User {
|
||||
text: prompt.text,
|
||||
attachments,
|
||||
images: prompt.images,
|
||||
sent_ms: Some(now_ms()),
|
||||
done_ms: None,
|
||||
steered: true,
|
||||
@@ -1011,6 +1086,7 @@ impl Task {
|
||||
self.push_entry(AgentEntry::User {
|
||||
text: prompt.text.clone(),
|
||||
attachments,
|
||||
images: prompt.images.clone(),
|
||||
sent_ms: Some(now_ms()),
|
||||
done_ms: None,
|
||||
steered: false,
|
||||
@@ -1156,10 +1232,16 @@ impl Task {
|
||||
let s = self.state.lock();
|
||||
(if known { reported } else { s.task.config.clone() }, s.wanted.clone())
|
||||
};
|
||||
self.shared.remember(&provider_id, Some(&options), None);
|
||||
config::add_client_options(&provider_id, &mut options);
|
||||
for choice in &wanted {
|
||||
// An option the session doesn't offer yet is set once it does (`apply_late_choices`).
|
||||
let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue };
|
||||
if o.id == config::CURSOR_APPROVAL {
|
||||
if o.values.iter().any(|v| v.value == choice.value) {
|
||||
options.iter_mut().find(|o| o.id == choice.id).unwrap().current = Some(choice.value.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let already = known && o.current.as_deref() == Some(choice.value.as_str());
|
||||
if already || !o.values.iter().any(|v| v.value == choice.value) {
|
||||
continue;
|
||||
@@ -1179,6 +1261,7 @@ impl Task {
|
||||
Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value),
|
||||
}
|
||||
}
|
||||
self.shared.remember(&provider_id, Some(&options), None);
|
||||
{
|
||||
let mut s = self.state.lock();
|
||||
s.task.model = model_of(&options).or(s.task.model.take());
|
||||
@@ -1203,7 +1286,7 @@ impl Task {
|
||||
s.wanted
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let o = s.task.config.iter().find(|o| o.id == c.id)?;
|
||||
let o = s.task.config.iter().find(|o| o.id == c.id && o.id != config::CURSOR_APPROVAL)?;
|
||||
let due = o.current.as_deref() != Some(c.value.as_str())
|
||||
&& o.values.iter().any(|v| v.value == c.value)
|
||||
&& sent.insert((c.id.clone(), c.value.clone()));
|
||||
@@ -1264,6 +1347,12 @@ impl Task {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == RETRY_UPDATE {
|
||||
if !self.replaying.load(Ordering::Acquire) {
|
||||
self.set_retry(retry_of(update, now_ms()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == "session_info_update" {
|
||||
// The agent's own title for the session replaces the first line of the prompt.
|
||||
let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty());
|
||||
@@ -1332,6 +1421,8 @@ impl Task {
|
||||
}
|
||||
|
||||
fn apply(self: &Arc<Self>, session: Option<&str>, kind: &str, update: &Value) {
|
||||
// Output again: the retried request went through.
|
||||
self.set_retry(None);
|
||||
let applied = self.state.lock().conversation.apply(session, update, now_ms());
|
||||
self.mark_dirty(applied);
|
||||
// A subagent can start or finish after the turn that started it: saved right away.
|
||||
@@ -1374,6 +1465,7 @@ impl Task {
|
||||
let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None });
|
||||
self.permissions.lock().insert(id, tx);
|
||||
self.set_status(AgentStatus::NeedsInput, None);
|
||||
self.approve_pending();
|
||||
|
||||
let answer = rx.recv().ok().flatten();
|
||||
let name = answer
|
||||
@@ -1397,6 +1489,22 @@ impl Task {
|
||||
}
|
||||
}
|
||||
|
||||
fn approve_pending(&self) {
|
||||
let s = self.state.lock();
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
|
||||
return;
|
||||
}
|
||||
let mut pending = self.permissions.lock();
|
||||
for entry in s.conversation.main.entries.iter().rev() {
|
||||
if let AgentEntry::Permission { id, options, chosen: None, .. } = entry
|
||||
&& let Some(option) = options.iter().find(|o| o.kind == "allow_once")
|
||||
&& let Some(reply) = pending.remove(id)
|
||||
{
|
||||
let _ = reply.send(Some(option.id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_permissions(&self) {
|
||||
for (_, reply) in self.permissions.lock().drain() {
|
||||
let _ = reply.send(None);
|
||||
@@ -1556,10 +1664,31 @@ fn is_zero(n: &u32) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
/// The retry a [`RETRY_UPDATE`] announces, timed from `now`.
|
||||
fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
|
||||
let n = |k: &str| update.get(k).and_then(Value::as_u64);
|
||||
Some(AgentRetry {
|
||||
attempt: u32::try_from(n("attempt")?).unwrap_or(u32::MAX),
|
||||
max_retries: n("maxRetries").and_then(|m| u32::try_from(m).ok()),
|
||||
retry_at_ms: now + n("retryDelayMs").unwrap_or(0) as f64,
|
||||
error: update.get("error").and_then(Value::as_str).filter(|e| !e.is_empty()).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
|
||||
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
|
||||
}
|
||||
|
||||
/// Commands the agent implements on its own and never lists in `availableCommands` —
|
||||
/// its CLI builtins, passed through as prompt text (e.g. `/compact`). `(name, description)`.
|
||||
fn builtin_commands(provider: &str) -> &'static [(&'static str, &'static str)] {
|
||||
match provider {
|
||||
// OpenCode ACP handles `/compact` via session.summarize but omits it from its command list.
|
||||
"opencode" => &[("compact", "compact the conversation")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn file_name(path: &Path) -> String {
|
||||
path.file_name().map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ pub enum Isolation {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentTask {
|
||||
/// Original project folder; absent in tasks saved before per-task roots.
|
||||
#[serde(default)]
|
||||
pub project_root: Option<IpcPath>,
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
/// [`AgentProvider::id`].
|
||||
@@ -81,6 +84,21 @@ pub struct AgentTask {
|
||||
/// When a [`AgentStatus::Limited`] task's limit resets (ms since the epoch), if the agent said.
|
||||
#[serde(default)]
|
||||
pub resets_ms: Option<f64>,
|
||||
/// The agent is retrying a failed API request (connection lost, overloaded…); cleared
|
||||
/// by its next output or any status change.
|
||||
#[serde(default)]
|
||||
pub retry: Option<AgentRetry>,
|
||||
}
|
||||
|
||||
/// An API request the agent is about to retry.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentRetry {
|
||||
pub attempt: u32,
|
||||
pub max_retries: Option<u32>,
|
||||
/// When the next attempt starts (ms since the epoch).
|
||||
pub retry_at_ms: f64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// One value of a select-type session setting.
|
||||
@@ -225,6 +243,9 @@ pub enum AgentEntry {
|
||||
text: String,
|
||||
/// Names of the files and images sent with it.
|
||||
attachments: Vec<String>,
|
||||
/// The images sent with it, shown in the conversation.
|
||||
#[serde(default)]
|
||||
images: Vec<AgentImage>,
|
||||
/// When it was sent (ms since the epoch); `None` in tasks saved before this was kept.
|
||||
#[serde(default)]
|
||||
sent_ms: Option<f64>,
|
||||
@@ -346,6 +367,9 @@ pub struct AgentProbe {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewAgentTask {
|
||||
/// Validated by the host; defaults to the window project root.
|
||||
#[serde(default)]
|
||||
pub project_root: Option<IpcPath>,
|
||||
pub prompt: AgentPrompt,
|
||||
pub provider: String,
|
||||
/// Settings for the new session (model, mode…).
|
||||
|
||||
@@ -296,7 +296,7 @@ impl Shim {
|
||||
let mut cmd = std::process::Command::new(&self.config.program);
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let text = ide_core::output_encoding::decode_tool_output(&out.stdout);
|
||||
text.split_whitespace().next().filter(|v| out.status.success() && !v.is_empty()).map(str::to_owned)
|
||||
}
|
||||
|
||||
|
||||
@@ -917,6 +917,20 @@ fn pending_starts_round_trip() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_retries_are_announced() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), true);
|
||||
let out = t.on_message(&json!({"type": "system", "subtype": "api_retry", "attempt": 3, "max_retries": 10,
|
||||
"retry_delay_ms": 4000, "error_status": null, "error": "Connection error."}));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].session, "s");
|
||||
assert_eq!(
|
||||
out[0].update,
|
||||
json!({"sessionUpdate": crate::RETRY_UPDATE, "attempt": 3, "maxRetries": 10, "retryDelayMs": 4000,
|
||||
"error": "Connection error."})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_text_is_not_sent_twice() {
|
||||
let mut t = Translator::new("s", PathBuf::from("/w"), true);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`.
|
||||
|
||||
use super::tools;
|
||||
use crate::RETRY_UPDATE;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
@@ -427,6 +428,23 @@ impl Translator {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
// Claude Code retries a failed API request on its own; without this the turn looks stuck.
|
||||
Some("api_retry") => {
|
||||
let n = |k: &str| msg.get(k).and_then(Value::as_u64);
|
||||
let error = msg.get("error").and_then(|e| {
|
||||
e.as_str().map(str::to_owned).or_else(|| e.get("message")?.as_str().map(str::to_owned))
|
||||
});
|
||||
vec![Update {
|
||||
session: self.root.clone(),
|
||||
update: json!({
|
||||
"sessionUpdate": RETRY_UPDATE,
|
||||
"attempt": n("attempt").unwrap_or(1),
|
||||
"maxRetries": n("max_retries"),
|
||||
"retryDelayMs": n("retry_delay_ms").unwrap_or(0),
|
||||
"error": error,
|
||||
}),
|
||||
}]
|
||||
}
|
||||
Some("task_notification") if self.children.get(&tool_use).is_some_and(|c| c.background != Some(false)) => {
|
||||
let stop = match msg.get("status").and_then(Value::as_str) {
|
||||
Some("failed") => "error",
|
||||
|
||||
@@ -199,7 +199,12 @@ fn invalid(message: impl Into<String>) -> ResponseError {
|
||||
fn from_codex(e: RpcError) -> ResponseError {
|
||||
match e {
|
||||
RpcError::Server { code, message, data } => {
|
||||
let code = if message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
// Protocol errors can list method names containing "auth"; they are not sign-in failures.
|
||||
let protocol = matches!(
|
||||
code,
|
||||
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
|
||||
);
|
||||
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
ResponseError { code, message, data }
|
||||
}
|
||||
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
|
||||
@@ -426,10 +431,48 @@ impl CodexShim {
|
||||
async fn rollback(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let session = str_param(params, "sessionId")?;
|
||||
let turns = params.get("turns").and_then(Value::as_u64).filter(|n| *n > 0).ok_or_else(|| invalid("turns"))?;
|
||||
self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await?;
|
||||
match self.call("thread/rollback", json!({ "threadId": session, "numTurns": turns })).await {
|
||||
Ok(_) => {}
|
||||
Err(e)
|
||||
if e.code == codes::METHOD_NOT_FOUND
|
||||
|| (e.code == codes::INVALID_REQUEST
|
||||
&& e.message.contains("unknown variant `thread/rollback`")) =>
|
||||
{
|
||||
self.revert(session, turns).await?;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
/// New Codex versions replace rollback counts with an exclusive turn id.
|
||||
async fn revert(&self, session: &str, mut remaining: u64) -> Result<(), ResponseError> {
|
||||
let mut cursor = Value::Null;
|
||||
let mut seen = HashSet::new();
|
||||
loop {
|
||||
let page = self
|
||||
.call(
|
||||
"thread/turns/list",
|
||||
json!({ "threadId": session, "sortDirection": "desc", "limit": remaining.min(100), "cursor": cursor }),
|
||||
)
|
||||
.await?;
|
||||
let turns = page.get("data").and_then(Value::as_array).ok_or_else(|| invalid("Codex returned no turns"))?;
|
||||
if remaining <= turns.len() as u64 {
|
||||
let before = str_param(&turns[remaining as usize - 1], "id")?;
|
||||
self.call("thread/revert", json!({ "threadId": session, "beforeTurnId": before })).await?;
|
||||
return Ok(());
|
||||
}
|
||||
remaining -= turns.len() as u64;
|
||||
let next = page.get("nextCursor").and_then(Value::as_str);
|
||||
let Some(next) = next.filter(|next| seen.insert((*next).to_owned())) else {
|
||||
return Err(invalid(
|
||||
"Codex history has fewer turns than the conversation; reload the task before editing",
|
||||
));
|
||||
};
|
||||
cursor = json!(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops following the session's thread (codex unloads it once nothing follows it).
|
||||
async fn close(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let session = str_param(params, "sessionId")?;
|
||||
|
||||
@@ -593,6 +593,104 @@ async fn resume_replays_history_then_forks_and_rolls_back() {
|
||||
assert_eq!(sent(&frames, "thread/unsubscribe")[0]["params"], json!({"threadId": thread}));
|
||||
}
|
||||
|
||||
async fn rollback_replies(turns: u32, replies: Vec<(&str, Value)>) -> (Result<Value, RpcError>, Vec<Value>) {
|
||||
let frames = replies
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, (method, mut reply))| {
|
||||
let id = i + 1;
|
||||
reply["id"] = json!(id);
|
||||
[(true, json!({ "id": id, "method": method })), (false, reply)]
|
||||
})
|
||||
.collect();
|
||||
let transcript = Transcript { meta: Value::Null, frames };
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let acp = attach(Arc::new(Client::default()), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let player = tokio::spawn(async move { play(&transcript, read, write).await });
|
||||
let result = request(&acp, "_openidea/rollback", json!({ "sessionId": "s", "turns": turns })).await;
|
||||
let (sent, _read, _write) = player.await.unwrap();
|
||||
acp.kill();
|
||||
(result, sent)
|
||||
}
|
||||
|
||||
fn removed_rollback() -> Value {
|
||||
json!({ "error": { "code": -32600, "message": "Invalid request: unknown variant `thread/rollback`, expected one of `thread/revert`, `account/gatewayOAuth/read`, `getAuthStatus`" } })
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn removed_rollback_reverts_before_the_target_turn_across_pages() {
|
||||
let (result, frames) = rollback_replies(
|
||||
3,
|
||||
vec![
|
||||
("thread/rollback", removed_rollback()),
|
||||
(
|
||||
"thread/turns/list",
|
||||
json!({ "result": { "data": [{ "id": "last" }, { "id": "middle" }], "nextCursor": "older" } }),
|
||||
),
|
||||
(
|
||||
"thread/turns/list",
|
||||
json!({ "result": { "data": [{ "id": "edited" }, { "id": "retained" }], "nextCursor": null } }),
|
||||
),
|
||||
("thread/revert", json!({ "result": { "thread": { "id": "s", "turns": [] } } })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), json!({}));
|
||||
let pages = sent(&frames, "thread/turns/list");
|
||||
assert_eq!(pages[0]["params"], json!({ "threadId": "s", "sortDirection": "desc", "limit": 3, "cursor": null }));
|
||||
assert_eq!(pages[1]["params"]["cursor"], "older");
|
||||
assert_eq!(pages[1]["params"]["limit"], 1);
|
||||
assert_eq!(sent(&frames, "thread/revert")[0]["params"], json!({ "threadId": "s", "beforeTurnId": "edited" }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn method_not_found_reverts_the_latest_turn() {
|
||||
let (result, frames) = rollback_replies(
|
||||
1,
|
||||
vec![
|
||||
("thread/rollback", json!({ "error": { "code": -32601, "message": "Method not found" } })),
|
||||
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": null } })),
|
||||
("thread/revert", json!({ "result": {} })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), json!({}));
|
||||
assert_eq!(sent(&frames, "thread/revert")[0]["params"]["beforeTurnId"], "last");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rollback_refusals_are_not_retried_as_revert() {
|
||||
let (result, frames) = rollback_replies(
|
||||
1,
|
||||
vec![("thread/rollback", json!({ "error": { "code": -32600, "message": "a turn is still running" } }))],
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().to_string().contains("a turn is still running"));
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn incomplete_or_repeated_history_pages_do_not_revert_the_wrong_turn() {
|
||||
for next in [Value::Null, json!("older")] {
|
||||
let (result, frames) = rollback_replies(
|
||||
3,
|
||||
vec![
|
||||
("thread/rollback", removed_rollback()),
|
||||
("thread/turns/list", json!({ "result": { "data": [{ "id": "last" }], "nextCursor": "older" } })),
|
||||
("thread/turns/list", json!({ "result": { "data": [], "nextCursor": next } })),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().to_string().contains("reload the task"));
|
||||
assert!(sent(&frames, "thread/revert").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn steering_adds_to_the_running_turn() {
|
||||
let (results, _client, frames, _) = replay(Scenario::Steer).await;
|
||||
|
||||
@@ -21,18 +21,26 @@ use std::sync::Arc;
|
||||
/// The newest install of `command` on the `PATH` a child sees, by its `--version`.
|
||||
/// Several installs are common (npm, Homebrew, the vendor's own installer), and the first
|
||||
/// on `PATH` is often an old one that the service no longer serves the current models to.
|
||||
/// A path, or a name found once (or not at all), is used as it is.
|
||||
/// A path, or a name not found on `PATH`, is used as it is; a single match returns its
|
||||
/// resolved path so Windows `PATHEXT` shims (`claude.cmd`, `codex.cmd`) start.
|
||||
pub(crate) fn newest_install(command: &str) -> String {
|
||||
if Path::new(command).components().count() > 1 {
|
||||
let path = ide_core::process::host_var_os("PATH")
|
||||
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
|
||||
.unwrap_or_default();
|
||||
newest_install_in(command, &path)
|
||||
}
|
||||
|
||||
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
|
||||
if Path::new(command).components().count() > 1 || Path::new(command).is_absolute() {
|
||||
return command.to_owned();
|
||||
}
|
||||
let path = ide_core::process::host_var_os("PATH")
|
||||
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()));
|
||||
let pathext = ide_core::process::host_var_os("PATHEXT");
|
||||
let names =
|
||||
ide_core::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()));
|
||||
let mut seen = Vec::new();
|
||||
let candidates: Vec<PathBuf> = path
|
||||
.iter()
|
||||
.flat_map(std::env::split_paths)
|
||||
.map(|dir| dir.join(command))
|
||||
let candidates: Vec<PathBuf> = std::env::split_paths(search)
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|p| {
|
||||
let real = dunce::canonicalize(p).unwrap_or_else(|_| p.clone());
|
||||
@@ -42,7 +50,7 @@ pub(crate) fn newest_install(command: &str) -> String {
|
||||
})
|
||||
.collect();
|
||||
if candidates.len() < 2 {
|
||||
return command.to_owned();
|
||||
return candidates.into_iter().next().map_or_else(|| command.to_owned(), |p| p.to_string_lossy().into_owned());
|
||||
}
|
||||
candidates
|
||||
.into_iter()
|
||||
@@ -56,7 +64,7 @@ fn version_of(program: &Path) -> Option<Vec<u64>> {
|
||||
let mut cmd = std::process::Command::new(program);
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
|
||||
parse_version(&String::from_utf8_lossy(&out.stdout))
|
||||
parse_version(&ide_core::output_encoding::decode_tool_output(&out.stdout))
|
||||
}
|
||||
|
||||
fn parse_version(text: &str) -> Option<Vec<u64>> {
|
||||
@@ -66,16 +74,6 @@ fn parse_version(text: &str) -> Option<Vec<u64>> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn versions_are_read_from_either_side_of_the_name() {
|
||||
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
|
||||
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
|
||||
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
|
||||
assert_eq!(parse_version("no version here"), None);
|
||||
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
|
||||
}
|
||||
|
||||
/// The agent side of an in-process ACP connection.
|
||||
pub(crate) trait Shim: Handler {
|
||||
/// Called once, before any message arrives: the connection to the client, for
|
||||
@@ -94,3 +92,56 @@ pub(crate) fn serve<S: Shim>(shim: Arc<S>, client: Arc<dyn Handler>) -> Peer {
|
||||
shim.attach(to_client);
|
||||
Peer::connect_framed(or, ow, client, Framing::Lines)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn versions_are_read_from_either_side_of_the_name() {
|
||||
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
|
||||
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
|
||||
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
|
||||
assert_eq!(parse_version("no version here"), None);
|
||||
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_install_resolves_single_candidate_and_keeps_missing_or_paths() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a");
|
||||
let b = dir.path().join("b");
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
std::fs::write(a.join("claude"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(b.join("claude.cmd"), "@echo off\n").unwrap();
|
||||
let search = std::env::join_paths([&a, &b]).unwrap();
|
||||
if cfg!(windows) {
|
||||
assert_eq!(newest_install_in("claude", &search), b.join("claude.cmd").to_string_lossy());
|
||||
} else {
|
||||
assert_eq!(newest_install_in("claude", &search), a.join("claude").to_string_lossy());
|
||||
}
|
||||
assert_eq!(newest_install_in("nope", &search), "nope");
|
||||
assert_eq!(newest_install_in("bin/claude", &search), "bin/claude");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn newest_install_compares_versions_across_path_entries() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old = dir.path().join("old");
|
||||
let new = dir.path().join("new");
|
||||
for (path, version) in [(&old, "2.9.0"), (&new, "2.10.0")] {
|
||||
std::fs::create_dir_all(path).unwrap();
|
||||
let executable = path.join("agent");
|
||||
std::fs::write(&executable, format!("#!/bin/sh\necho {version}\n")).unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
for paths in [[&old, &new], [&new, &old]] {
|
||||
let search = std::env::join_paths(paths).unwrap();
|
||||
assert_eq!(newest_install_in("agent", &search), new.join("agent").to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,7 +335,7 @@ pub fn mark_updates(paths: &RegistryPaths, providers: &mut [AgentProvider]) {
|
||||
|
||||
/// HTTPS only, redirects included. Tests serve downloads over `file://`.
|
||||
fn http() -> IdeResult<Http> {
|
||||
let http = Http::new(&ide_tools::default_search_path())?;
|
||||
let http = Http::new(&ide_core::tool_path::search_path())?;
|
||||
Ok(if cfg!(test) { http } else { http.https_only() })
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,23 @@ pub(crate) fn recorder(out: PathBuf) -> Connector {
|
||||
let file = Arc::new(Mutex::new(String::new()));
|
||||
let connections = Arc::new(Mutex::new(0u32));
|
||||
Arc::new(move |spec, cwd, client, on_stderr| {
|
||||
let mut cmd = tokio::process::Command::new(&spec.command);
|
||||
let program = {
|
||||
let path = Path::new(&spec.command);
|
||||
if path.components().count() > 1 || path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
let search = spec
|
||||
.env
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
|
||||
.map(|(_, v)| std::ffi::OsString::from(v))
|
||||
.or_else(|| ide_core::process::shell_env().find(|(k, _)| k == "PATH").map(|(_, v)| v))
|
||||
.or_else(|| ide_core::process::host_var_os("PATH"))
|
||||
.unwrap_or_default();
|
||||
ide_core::process::find_executable(&spec.command, &search).unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
};
|
||||
let mut cmd = tokio::process::Command::new(&program);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
let mut child = cmd
|
||||
.args(&spec.args)
|
||||
|
||||
@@ -56,6 +56,7 @@ fn config(provider: &str) -> Vec<AgentConfigChoice> {
|
||||
|
||||
async fn create(mgr: &AgentManager, provider: &str, prompt: &str) -> String {
|
||||
let req = NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text(prompt),
|
||||
provider: provider.into(),
|
||||
config: config(provider),
|
||||
|
||||
@@ -210,10 +210,11 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
|
||||
let names = |c: Vec<AgentCommand>| c.into_iter().map(|c| c.name).collect::<Vec<_>>();
|
||||
|
||||
let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default()));
|
||||
// Before any session: the skills on disk.
|
||||
// Before any session: only the skills on disk.
|
||||
assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]);
|
||||
let task = mgr_a
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix the lexer"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
@@ -227,6 +228,8 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
|
||||
assert_eq!(names(mgr_a.commands("claude", Some(&task.id)).await.unwrap()), ["review", "triage"]);
|
||||
// Other agents get only what they offered.
|
||||
assert!(mgr_a.commands("codex", None).await.unwrap().is_empty());
|
||||
mgr_a.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr_a, &task.id, AgentStatus::Ready).await;
|
||||
mgr_a.shutdown();
|
||||
|
||||
// Another project shares the settings but not the commands.
|
||||
@@ -239,6 +242,46 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
|
||||
assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metadata() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager_at(tmp.path(), &tmp.path().join("models.json"), agent);
|
||||
for provider in ["claude", "claude-acp", "opencode2", "codex", "custom"] {
|
||||
assert!(mgr.commands(provider, None).await.unwrap().is_empty(), "{provider}");
|
||||
}
|
||||
let commands = mgr.commands("opencode", None).await.unwrap();
|
||||
assert_eq!(commands.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["compact"]);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Hello"),
|
||||
provider: "opencode".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let advertised = AgentCommand {
|
||||
name: "compact".into(),
|
||||
description: "Agent supplied description".into(),
|
||||
hint: Some("instructions".into()),
|
||||
};
|
||||
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
|
||||
// The task's provider wins over the caller's provider; metadata is not replaced.
|
||||
let commands = mgr.commands("claude", Some(&task.id)).await.unwrap();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].name, advertised.name);
|
||||
assert_eq!(commands[0].description, advertised.description);
|
||||
assert_eq!(commands[0].hint, advertised.hint);
|
||||
// Finish the in-process agent before Tokio stops driving its permission response.
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) {
|
||||
for _ in 0..500 {
|
||||
let task = mgr.task(id).unwrap();
|
||||
@@ -268,6 +311,7 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() {
|
||||
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix the lexer\nmore detail"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }],
|
||||
@@ -374,6 +418,7 @@ async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() {
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("wait"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
@@ -428,6 +473,7 @@ async fn subagents_get_their_own_conversation() {
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("delegate"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
@@ -521,6 +567,7 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("do it"),
|
||||
provider: "codex".into(),
|
||||
config: vec![],
|
||||
@@ -542,6 +589,7 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() {
|
||||
assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists());
|
||||
assert!(
|
||||
mgr.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text(" "),
|
||||
provider: "codex".into(),
|
||||
config: vec![],
|
||||
@@ -587,6 +635,7 @@ async fn real_claude_task_in_a_worktree() {
|
||||
);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }],
|
||||
@@ -635,6 +684,7 @@ async fn forks_rollbacks_and_steering() {
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let new = |prompt: &str| NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text(prompt),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
@@ -685,6 +735,7 @@ async fn usage_limits_and_late_settings() {
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("limit"),
|
||||
provider: "claude".into(),
|
||||
// `effort` isn't offered until the session runs.
|
||||
@@ -709,3 +760,185 @@ async fn usage_limits_and_late_settings() {
|
||||
let config = mgr.task(&task.id).unwrap().state.lock().task.config.clone();
|
||||
assert_eq!(config.iter().find(|o| o.id == "effort").unwrap().current.as_deref(), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_updates_become_a_countdown() {
|
||||
let update =
|
||||
json!({"sessionUpdate": RETRY_UPDATE, "attempt": 2, "maxRetries": 10, "retryDelayMs": 1500, "error": ""});
|
||||
assert_eq!(
|
||||
retry_of(&update, 1000.0),
|
||||
Some(AgentRetry { attempt: 2, max_retries: Some(10), retry_at_ms: 2500.0, error: None })
|
||||
);
|
||||
assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let new = || NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "cursor".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
};
|
||||
let task = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "bypass".into()).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| matches!(e, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
// Agent config updates must not erase the client's approval setting.
|
||||
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let t = mgr.task(&task.id).unwrap();
|
||||
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
|
||||
t.kill();
|
||||
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
|
||||
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
|
||||
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&task.id, None).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// A task created without choosing approvals still asks; the New Task page sends the default.
|
||||
let other = mgr.create(new()).await.unwrap();
|
||||
wait_for(&mgr, &other.id, AgentStatus::NeedsInput).await;
|
||||
mgr.cancel(&other.id, None).unwrap();
|
||||
wait_for(&mgr, &other.id, AgentStatus::Ready).await;
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "cursor".into(),
|
||||
config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn archived_tasks_can_be_restored_and_continue_their_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
mgr.archive(&task.id).unwrap();
|
||||
assert!(mgr.prompt(&task.id, text("again"), false).await.is_err());
|
||||
mgr.unarchive(&task.id).unwrap();
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
|
||||
assert!(mgr.task(&task.id).unwrap().live.lock().is_none());
|
||||
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len());
|
||||
drop(mgr);
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready);
|
||||
mgr.prompt(&task.id, text("again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|call| call == "session/load"));
|
||||
assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len() + 2);
|
||||
mgr.unarchive(&task.id).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn task_project_root_controls_local_and_worktree_repositories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let member = root.join("project-b");
|
||||
std::fs::create_dir(&member).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&member)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
};
|
||||
git(&["init", "-q", "-b", "member-main"]);
|
||||
std::fs::write(member.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
let mgr = manager(&root, Arc::new(FakeAgent::default()));
|
||||
for isolation in [Isolation::Local, Isolation::Worktree] {
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: Some(IpcPath(member.clone())),
|
||||
prompt: text("member task"),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
let permission = mgr
|
||||
.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Permission { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
mgr.respond(&task.id, permission, Some("yes".into())).unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert_eq!(task.project_root, Some(IpcPath(member.clone())));
|
||||
assert_eq!(task.base.as_deref(), Some("member-main"));
|
||||
if isolation == Isolation::Local {
|
||||
assert_eq!(task.cwd.0, member);
|
||||
} else {
|
||||
assert_ne!(task.cwd.0, member);
|
||||
assert_eq!(std::fs::read_to_string(task.cwd.0.join("file.txt")).unwrap(), "original\n");
|
||||
}
|
||||
std::fs::write(task.cwd.0.join("file.txt"), "changed\n").unwrap();
|
||||
let changes = mgr.changes(&task.id).await.unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[0].path.0, task.cwd.0.join("file.txt"));
|
||||
assert_eq!(mgr.base_text(&task.id, changes[0].path.0.clone()).await.unwrap().as_deref(), Some("original\n"));
|
||||
let saved = std::fs::read(root.join("store").join(format!("{}.json", task.id))).unwrap();
|
||||
assert!(String::from_utf8(saved).unwrap().contains("projectRoot"));
|
||||
let mut old = serde_json::to_value(&task).unwrap();
|
||||
old.as_object_mut().unwrap().remove("projectRoot");
|
||||
assert!(serde_json::from_value::<AgentTask>(old).unwrap().project_root.is_none());
|
||||
mgr.delete(&task.id, true).await.unwrap();
|
||||
if isolation == Isolation::Worktree {
|
||||
assert!(!task.cwd.0.exists());
|
||||
}
|
||||
std::fs::write(member.join("file.txt"), "original\n").unwrap();
|
||||
}
|
||||
assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1);
|
||||
}
|
||||
@@ -446,6 +446,7 @@ mod tests {
|
||||
let user = |sent: f64| AgentEntry::User {
|
||||
text: "q".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: Some(sent),
|
||||
done_ms: None,
|
||||
steered: false,
|
||||
|
||||
@@ -53,8 +53,9 @@ pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
|
||||
/// Outside a repository there is nothing to compare against: no changes.
|
||||
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let repo = repo_at(cwd)?;
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
|
||||
let root = repo.root().to_path_buf();
|
||||
let stats = repo.numstat_with_worktree(base)?;
|
||||
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
|
||||
@@ -96,7 +97,12 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
|
||||
}
|
||||
|
||||
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
|
||||
repo_at(cwd)?.file_at(path, Some(rev))
|
||||
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
|
||||
let encoding = match ide_vfs::load_text(path) {
|
||||
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
|
||||
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
|
||||
};
|
||||
repo.file_at(path, Some(rev), encoding)
|
||||
}
|
||||
|
||||
/// A short ASCII branch-safe name from the task title.
|
||||
@@ -136,6 +142,20 @@ mod tests {
|
||||
assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_a_repository_there_is_nothing_to_compare() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("plain");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
|
||||
|
||||
assert!(changes(&dir, "HEAD").unwrap().is_empty());
|
||||
assert!(snapshot(&dir).unwrap().is_empty());
|
||||
assert_eq!(file_at(&dir, &dir.join("a.txt"), "HEAD").unwrap(), None);
|
||||
assert!(create(&dir, &tmp.path().join("wt"), "Edit A", None).is_err());
|
||||
assert!(remove(&dir, &dir).is_err());
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let ok = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
|
||||
@@ -234,6 +234,9 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
|
||||
let Some(module) = module else { return Ok(tops.iter().map(|p| step(p, &[])).collect()) };
|
||||
// The reactor root containing the module builds it with what it depends on (`-pl … -am`).
|
||||
let rel = rel_str(root, module);
|
||||
if !m.projects.iter().any(|p| p.dir == rel) {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
}
|
||||
let top = tops
|
||||
.iter()
|
||||
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
|
||||
|
||||
@@ -399,6 +399,12 @@ pub fn parse_tasks(output: &str) -> Vec<(String, GradleTask)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Static fallback until Gradle has reported its tasks.
|
||||
pub(crate) fn has_war_plugin(script: &str) -> bool {
|
||||
let script = strip_comments(script);
|
||||
script.split(|c: char| !c.is_alphanumeric() && c != '_').any(|word| word == "war")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod maven;
|
||||
pub mod pom_edit;
|
||||
mod process;
|
||||
mod sync;
|
||||
pub mod web;
|
||||
mod xml;
|
||||
|
||||
pub use gradle::{GradleModel, GradleProject, GradleTask};
|
||||
@@ -39,8 +40,19 @@ pub struct BuildModel {
|
||||
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
|
||||
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
|
||||
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
|
||||
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
|
||||
}
|
||||
|
||||
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
|
||||
pub fn load_linked(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
maven_repo: Option<PathBuf>,
|
||||
settings: &ide_core::settings::MavenSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::is_maven_project(root).then(|| {
|
||||
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
sync::apply_maven_cache(root, cache_dir, &mut m);
|
||||
m
|
||||
});
|
||||
|
||||
@@ -401,6 +401,9 @@ struct Inherited {
|
||||
managed: Vec<RawDep>,
|
||||
plugins: Vec<RawPlugin>,
|
||||
managed_plugins: Vec<RawPlugin>,
|
||||
/// `<id>`, `activeByDefault`: profiles of the POM and its ancestors —
|
||||
/// Maven lets a child enable a parent's profiles with `-P`.
|
||||
profiles: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
fn merge_by<T: Clone>(base: &mut Vec<T>, own: &[T], key: impl Fn(&T) -> String) {
|
||||
@@ -523,6 +526,11 @@ impl Resolver {
|
||||
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
|
||||
merge_plugins(&mut ctx.plugins, &p.plugins);
|
||||
}
|
||||
merge_by(
|
||||
&mut ctx.profiles,
|
||||
&pom.profiles.iter().map(|p| (p.id.clone(), p.active_by_default)).collect::<Vec<_>>(),
|
||||
|(id, _)| id.clone(),
|
||||
);
|
||||
// Managed dependencies: own entries win; imported BOMs fill the gaps.
|
||||
let props = self.properties_for(&pom, &ctx, &dir);
|
||||
let mut imported: Vec<RawDep> = Vec::new();
|
||||
@@ -935,9 +943,10 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
.or_else(|| pom.compiler_release.as_deref().map(i))
|
||||
.and_then(|v| java_version(&v));
|
||||
|
||||
for p in &pom.profiles {
|
||||
if !model.profiles.iter().any(|x| x.id == p.id) {
|
||||
model.profiles.push(MavenProfile { id: p.id.clone(), active_by_default: p.active_by_default });
|
||||
// The project can activate the profiles of its own POM and its ancestors'.
|
||||
for (id, active) in &ctx.profiles {
|
||||
if !model.profiles.iter().any(|x| x.id == *id) {
|
||||
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
|
||||
}
|
||||
}
|
||||
let artifact_id = i(&pom.artifact_id);
|
||||
@@ -954,7 +963,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
aggregated,
|
||||
plugins,
|
||||
dependencies,
|
||||
profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(),
|
||||
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
|
||||
java_version: jv,
|
||||
command: wrapper(root, &dir),
|
||||
options: Vec::new(),
|
||||
@@ -967,6 +976,67 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
model
|
||||
}
|
||||
|
||||
/// Shared linkage decision for every Maven consumer, including modules outside a reactor directory.
|
||||
#[derive(Default)]
|
||||
pub struct UnlinkedProjects {
|
||||
poms: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl UnlinkedProjects {
|
||||
pub fn load(root: &Path, settings: &ide_core::settings::MavenSettings) -> Self {
|
||||
if settings.unlinked_projects.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
Self::from_model(&load(root), settings)
|
||||
}
|
||||
|
||||
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
|
||||
let mut result = Self::default();
|
||||
let mut pending = settings.unlinked_projects.clone();
|
||||
while let Some(pom) = pending.pop() {
|
||||
let pom = normalized_pom(&pom);
|
||||
if !result.poms.insert(pom.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(project) = model.projects.iter().find(|p| normalized_pom(&p.pom) == pom) {
|
||||
pending.extend(project.modules.iter().cloned());
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn contains(&self, pom: &str) -> bool {
|
||||
self.poms.contains(&normalized_pom(pom))
|
||||
}
|
||||
|
||||
pub fn poms(&self) -> impl Iterator<Item = &str> {
|
||||
self.poms.iter().map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn filter(&self, model: &mut MavenModel) {
|
||||
model.projects.retain(|p| !self.contains(&p.pom));
|
||||
for project in &mut model.projects {
|
||||
project.modules.retain(|p| !self.contains(p));
|
||||
}
|
||||
model.profiles.retain(|p| model.projects.iter().any(|project| project.profiles.contains(&p.id)));
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_pom(pom: &str) -> String {
|
||||
let pom = pom.replace('\\', "/");
|
||||
let mut parts = Vec::new();
|
||||
for part in pom.split('/') {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
parts.pop();
|
||||
}
|
||||
part => parts.push(part),
|
||||
}
|
||||
}
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
/// Every POM of the model (for change detection).
|
||||
pub fn build_files(root: &Path, model: &MavenModel) -> Vec<PathBuf> {
|
||||
model.projects.iter().map(|p| root.join(&p.pom)).collect()
|
||||
@@ -1219,9 +1289,41 @@ mod tests {
|
||||
let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect();
|
||||
assert_eq!(profiles, [("dev", true), ("extra", false)]);
|
||||
assert_eq!(parent.profiles, ["dev", "extra"]);
|
||||
assert_eq!(core.profiles, ["dev", "extra"], "a module inherits its parent's profiles");
|
||||
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_of_a_parent_pom_are_inherited() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version>
|
||||
<packaging>pom</packaging><modules><module>child</module></modules>
|
||||
<profiles>
|
||||
<profile><id>jdk8</id></profile>
|
||||
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
|
||||
</profiles>
|
||||
</project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"child/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent>
|
||||
<artifactId>child</artifactId>
|
||||
<profiles><profile><id>own</id></profile></profiles>
|
||||
</project>"#,
|
||||
);
|
||||
let model = load_with_repo(root, None);
|
||||
let child = &model.projects[1];
|
||||
// `-Pjdk8` on the module activates the parent's profile, so it is listed.
|
||||
assert_eq!(child.profiles, ["jdk8", "jdk21", "own"]);
|
||||
let profiles: Vec<(&str, bool)> = model.profiles.iter().map(|p| (p.id.as_str(), p.active_by_default)).collect();
|
||||
assert_eq!(profiles, [("jdk8", false), ("jdk21", true), ("own", false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_and_bom_from_the_local_repository_and_wrapper() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1459,3 +1561,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod linkage_tests {
|
||||
use super::*;
|
||||
use ide_core::settings::MavenSettings;
|
||||
|
||||
#[test]
|
||||
fn excludes_reactor_modules_and_profiles_but_keeps_other_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for (name, body) in [
|
||||
(
|
||||
"a",
|
||||
"<modules><module>child</module><module>../shared</module></modules><profiles><profile><id>only-a</id></profile></profiles>",
|
||||
),
|
||||
("a/child", ""),
|
||||
("shared", ""),
|
||||
("b", ""),
|
||||
] {
|
||||
std::fs::create_dir_all(root.join(name)).unwrap();
|
||||
std::fs::write(
|
||||
root.join(name).join("pom.xml"),
|
||||
format!("<project><artifactId>{name}</artifactId>{body}</project>"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let original = load_with_repo(root, None);
|
||||
let settings = MavenSettings { unlinked_projects: vec!["./a/pom.xml".into()], ..Default::default() };
|
||||
let unlinked = UnlinkedProjects::from_model(&original, &settings);
|
||||
assert!(unlinked.contains("a/child/pom.xml"));
|
||||
assert!(unlinked.contains("shared/pom.xml"));
|
||||
assert!(unlinked.contains("a/../shared/pom.xml"));
|
||||
assert!(!unlinked.contains("b/pom.xml"));
|
||||
let mut model = original.clone();
|
||||
unlinked.filter(&mut model);
|
||||
assert_eq!(model.projects.iter().map(|p| p.pom.as_str()).collect::<Vec<_>>(), ["b/pom.xml"]);
|
||||
assert!(model.profiles.is_empty());
|
||||
let mut restored = original.clone();
|
||||
UnlinkedProjects::from_model(&original, &MavenSettings::default()).filter(&mut restored);
|
||||
assert_eq!(restored, original);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,22 @@ fn kill_tree(child: &mut Child) {
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
|
||||
/// search path — the login shell's `PATH` plus version-manager shims — since the
|
||||
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
|
||||
fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
let path = std::path::Path::new(program);
|
||||
if !path.is_relative() || path.components().count() > 1 {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
let search = env
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
|
||||
.map(|(_, v)| std::ffi::OsString::from(v))
|
||||
.unwrap_or_else(ide_core::tool_path::search_path);
|
||||
ide_core::process::find_executable(program, &search).unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Runs `program args` in `cwd`, feeding each output line to `on_line`; kills
|
||||
/// the process group and returns `cancelled` when `cancel` fires.
|
||||
pub(crate) fn run(
|
||||
@@ -45,15 +61,21 @@ pub(crate) fn run(
|
||||
mut on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
|
||||
let mut command = Command::new(program);
|
||||
command.args(args).current_dir(cwd).envs(env).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
// Output is read as UTF-8: JVMs on Windows are told to write it (see `ide_core::output_encoding`).
|
||||
let encoding =
|
||||
ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows));
|
||||
let program = program_path(program, env);
|
||||
let mut command = Command::new(&program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(&mut command);
|
||||
let mut child = command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {program}: {e}")))?;
|
||||
let mut child =
|
||||
command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?;
|
||||
let (tx, rx) = mpsc::channel::<(Stream, String)>();
|
||||
let pump = |stream: Box<dyn Read + Send>, kind: Stream, tx: mpsc::Sender<(Stream, String)>| {
|
||||
std::thread::spawn(move || {
|
||||
@@ -64,7 +86,7 @@ pub(crate) fn run(
|
||||
match reader.read_until(b'\n', &mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {
|
||||
let line = String::from_utf8_lossy(&buf);
|
||||
let line = ide_core::output_encoding::decode_tool_output(&buf);
|
||||
let line = line.trim_end_matches(['\n', '\r']).to_owned();
|
||||
if tx.send((kind, line)).is_err() {
|
||||
break;
|
||||
@@ -86,6 +108,7 @@ pub(crate) fn run(
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok((_, line)) if encoding.notice.as_ref() == Some(&line) => {}
|
||||
Ok((stream, line)) => {
|
||||
on_line(stream, &line);
|
||||
text.push_str(&line);
|
||||
|
||||
@@ -23,6 +23,7 @@ pub struct SyncProgress {
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SyncOptions {
|
||||
pub maven_settings: ide_core::settings::MavenSettings,
|
||||
/// `-o` / `--offline`.
|
||||
pub offline: bool,
|
||||
/// Maven profiles (`-P`), `!id` deactivates.
|
||||
@@ -31,6 +32,8 @@ pub struct SyncOptions {
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Maven launcher replacing the project's `mvnw` / `mvn` (Settings › Maven home).
|
||||
pub maven_command: Option<String>,
|
||||
/// Resolved Gradle launcher from Settings.
|
||||
pub gradle_command: Option<String>,
|
||||
/// Maven options before the goals (`-s`, `-Dmaven.repo.local=`).
|
||||
pub maven_options: Vec<String>,
|
||||
}
|
||||
@@ -118,6 +121,16 @@ fn now_ms() -> f64 {
|
||||
/// Merges the cached effective build into `model`.
|
||||
pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel) {
|
||||
let Some(cache) = read_cache::<MavenCache>(cache_dir, MAVEN_CACHE) else { return };
|
||||
model.synced_at = Some(cache.at_ms);
|
||||
model.sync_error = cache.error;
|
||||
let changed = cache.hash != hash_files(&maven::build_files(root, model));
|
||||
model.stale = !cache.verbose || changed;
|
||||
// A cache older than the current build files: its effective-POM plugins and
|
||||
// resolved dependencies would hide what the POMs declare now (a just added
|
||||
// dependency never appears). The static parse stays until Reload.
|
||||
if changed {
|
||||
return;
|
||||
}
|
||||
for p in &mut model.projects {
|
||||
if let Some(c) = cache.projects.iter().find(|c| c.key == p.key()) {
|
||||
if !c.plugins.is_empty() {
|
||||
@@ -126,9 +139,6 @@ pub fn apply_maven_cache(root: &Path, cache_dir: &Path, model: &mut MavenModel)
|
||||
p.dependencies = c.dependencies.clone();
|
||||
}
|
||||
}
|
||||
model.synced_at = Some(cache.at_ms);
|
||||
model.sync_error = cache.error;
|
||||
model.stale = !cache.verbose || cache.hash != hash_files(&maven::build_files(root, model));
|
||||
}
|
||||
|
||||
/// Merges the cached task list into `model`.
|
||||
@@ -198,7 +208,8 @@ pub fn sync_maven(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let model = maven::load(root);
|
||||
let mut model = maven::load(root);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let mut cache =
|
||||
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
|
||||
@@ -266,7 +277,14 @@ pub fn sync_gradle(
|
||||
args.push("--offline".into());
|
||||
}
|
||||
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
|
||||
let out = run(&model.command, &args, &root.join(&model.dir), &options.env, cancel, |_| {})?;
|
||||
let out = run(
|
||||
options.gradle_command.as_deref().unwrap_or(&model.command),
|
||||
&args,
|
||||
&root.join(&model.dir),
|
||||
&options.env,
|
||||
cancel,
|
||||
|_| {},
|
||||
)?;
|
||||
let cache = GradleCache {
|
||||
hash: hash_files(&gradle::build_files(root, &model)),
|
||||
at_ms: now_ms(),
|
||||
@@ -280,6 +298,29 @@ pub fn sync_gradle(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn gradle_sync_uses_resolved_command_and_environment() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let launcher = root.join("configured-gradle");
|
||||
std::fs::write(&launcher, "#!/bin/sh\nprintf '%s\\n' \"$JAVA_HOME\" \"$GRADLE_USER_HOME\" > actual-env\n")
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let options = SyncOptions {
|
||||
gradle_command: Some(launcher.to_string_lossy().into_owned()),
|
||||
env: BTreeMap::from([
|
||||
("JAVA_HOME".into(), "/jdk21".into()),
|
||||
("GRADLE_USER_HOME".into(), "/gradle-cache".into()),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
sync_gradle(root, &root.join("cache"), &options, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(root.join("actual-env")).unwrap(), "/jdk21\n/gradle-cache\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caches_merge_and_go_stale() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -315,12 +356,16 @@ mod tests {
|
||||
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version></project>",
|
||||
"<project><groupId>g</groupId><artifactId>a</artifactId><version>2</version>\
|
||||
<dependencies><dependency><groupId>g</groupId><artifactId>fresh</artifactId><version>1</version></dependency></dependencies>\
|
||||
</project>",
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = maven::load_with_repo(&root, None);
|
||||
apply_maven_cache(&root, &cache_dir, &mut model);
|
||||
assert!(model.stale);
|
||||
// The POM changed: the stale effective dependencies must not hide the new one (#249).
|
||||
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! WAR modules and build-tool-owned archive resolution (including custom output names).
|
||||
use crate::BuildModel;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebArtifact {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub module: String,
|
||||
pub tool: String,
|
||||
}
|
||||
|
||||
pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(maven) = &model.maven {
|
||||
for p in maven.projects.iter().filter(|p| p.packaging == "war") {
|
||||
out.push(WebArtifact {
|
||||
id: format!("maven:{}", p.pom),
|
||||
name: p.artifact_id.clone(),
|
||||
module: p.dir.clone(),
|
||||
tool: "maven".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(gradle) = &model.gradle {
|
||||
for p in &gradle.projects {
|
||||
let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default();
|
||||
if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) {
|
||||
out.push(WebArtifact {
|
||||
id: format!("gradle:{}", p.path),
|
||||
name: p.name.clone(),
|
||||
module: p.dir.clone(),
|
||||
tool: "gradle".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct WebBuild {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult<WebBuild> {
|
||||
fs::create_dir_all(cache)?;
|
||||
let manifest = cache.join("artifact-path.txt");
|
||||
// A failed build must never silently reuse an older archive manifest.
|
||||
if manifest.exists() {
|
||||
fs::remove_file(&manifest)?;
|
||||
}
|
||||
fs::write(cache.join("artifact-id.txt"), id)?;
|
||||
if let Some(pom) = id.strip_prefix("maven:") {
|
||||
let p = model
|
||||
.maven
|
||||
.as_ref()
|
||||
.and_then(|m| m.projects.iter().find(|p| p.pom == pom && p.packaging == "war"))
|
||||
.ok_or_else(|| IdeError::invalid("Choose a Maven WAR module"))?;
|
||||
let projects = &model.maven.as_ref().unwrap().projects;
|
||||
let top = projects
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
!t.aggregated && (t.dir.is_empty() || p.dir == t.dir || p.dir.starts_with(&format!("{}/", t.dir)))
|
||||
})
|
||||
.max_by_key(|t| t.dir.len())
|
||||
.unwrap_or(p);
|
||||
let mut args = top.options.clone();
|
||||
if top.pom != p.pom {
|
||||
let module = root
|
||||
.join(&p.dir)
|
||||
.strip_prefix(root.join(&top.dir))
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
args.extend(["-pl".into(), module, "-am".into()]);
|
||||
}
|
||||
args.extend([
|
||||
"-DskipTests".into(),
|
||||
"package".into(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.artifact.file".into(),
|
||||
format!("-Doutput={}", manifest.display()),
|
||||
]);
|
||||
return Ok(WebBuild { command: top.command.clone(), args, cwd: top.dir.clone(), env: p.env.clone() });
|
||||
}
|
||||
if let Some(path) = id.strip_prefix("gradle:") {
|
||||
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == id) {
|
||||
return Err(IdeError::invalid("Choose a Gradle WAR module"));
|
||||
}
|
||||
let literal = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"));
|
||||
let script = cache.join("war.init.gradle");
|
||||
fs::write(
|
||||
&script,
|
||||
format!(
|
||||
r#"gradle.projectsEvaluated {{
|
||||
def p = gradle.rootProject.project({})
|
||||
def warTask = p.tasks.named('war')
|
||||
p.tasks.register('openideaTomcatArtifact') {{
|
||||
dependsOn warTask
|
||||
doLast {{ new File({}).text = warTask.get().archiveFile.get().asFile.absolutePath }}
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
literal(path),
|
||||
literal(&manifest.to_string_lossy())
|
||||
),
|
||||
)?;
|
||||
let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':'));
|
||||
return Ok(WebBuild {
|
||||
command: g.command.clone(),
|
||||
args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task],
|
||||
cwd: g.dir.clone(),
|
||||
env: g.env.clone(),
|
||||
});
|
||||
}
|
||||
Err(IdeError::invalid("Choose a WAR artifact"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maven_war_modules_use_resolved_artifact_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("pom.xml"), "<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>web</artifactId><version>1</version><packaging>war</packaging><build><directory>custom</directory><finalName>renamed</finalName></build></project>").unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let a = artifacts(tmp.path(), &model);
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(a[0].name, "web");
|
||||
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base with spaces")).unwrap();
|
||||
assert!(plan.args.contains(&"-Dexpression=project.artifact.file".into()));
|
||||
assert!(
|
||||
plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt")
|
||||
|| a.ends_with("base with spaces\\artifact-path.txt"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn maven_module_build_includes_reactor_dependencies() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::create_dir_all(tmp.path().join("web")).unwrap();
|
||||
fs::write(tmp.path().join("pom.xml"), "<project><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>web</module></modules></project>").unwrap();
|
||||
fs::write(tmp.path().join("web/pom.xml"), "<project><parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>web</artifactId><packaging>war</packaging></project>").unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap();
|
||||
assert_eq!(plan.cwd, "");
|
||||
assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]);
|
||||
}
|
||||
#[test]
|
||||
fn gradle_war_uses_archive_provider_and_ignores_comments() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
tmp.path().join("build.gradle.kts"),
|
||||
"plugins { war }\ntasks.war { archiveFileName.set(\"custom.war\") }",
|
||||
)
|
||||
.unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let a = artifacts(tmp.path(), &model);
|
||||
assert_eq!(a.len(), 1);
|
||||
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base")).unwrap();
|
||||
assert!(plan.args.contains(&":openideaTomcatArtifact".into()));
|
||||
assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()"));
|
||||
assert!(!crate::gradle::has_war_plugin("// plugins { war }"));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
[package]
|
||||
name = "ide-clipboard"
|
||||
description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, 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
|
||||
|
||||
[dependencies]
|
||||
|
||||
[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", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
@@ -7,9 +7,12 @@
|
||||
//! in full rather than rendered on demand, so no window or message loop is
|
||||
//! needed and the list survives after `CloseClipboard`.
|
||||
//!
|
||||
//! Other platforms keep file lists elsewhere (`public.file-url` on macOS,
|
||||
//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and
|
||||
//! [`set_files`] fails there.
|
||||
//! macOS keeps one `public.file-url` item per file on the general
|
||||
//! `NSPasteboard`; `NSURL` reads and writes them, so Finder's copies paste here
|
||||
//! and ours paste in Finder.
|
||||
//!
|
||||
//! Other platforms (`text/uri-list` on X11) are not wired up: [`files`] is
|
||||
//! empty and [`set_files`] fails there.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -218,7 +221,78 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2::ClassType;
|
||||
use objc2::rc::{Retained, autoreleasepool};
|
||||
use objc2::runtime::{AnyClass, AnyObject, ProtocolObject};
|
||||
use objc2_app_kit::{
|
||||
NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting,
|
||||
};
|
||||
use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// `NSURL` as the one class to read, limited to file URLs: a web link the
|
||||
/// user copied is not a file to paste.
|
||||
fn read_file_urls<R>(
|
||||
f: impl FnOnce(&NSPasteboard, &NSArray<AnyClass>, &NSDictionary<NSPasteboardReadingOptionKey, AnyObject>) -> R,
|
||||
) -> R {
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
let classes = NSArray::from_slice(&[NSURL::class()]);
|
||||
let yes = NSNumber::new_bool(true);
|
||||
// SAFETY: the key is an AppKit constant, valid for the process' lifetime.
|
||||
let key = unsafe { NSPasteboardURLReadingFileURLsOnlyKey };
|
||||
let options = NSDictionary::from_slices(&[key], &[yes.as_ref() as &AnyObject]);
|
||||
f(&board, &classes, &options)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
// SAFETY: the class list holds `NSURL`, which reads from pasteboards,
|
||||
// and the option takes an `NSNumber`.
|
||||
read_file_urls(|board, classes, options| unsafe {
|
||||
board.canReadObjectForClasses_options(classes, Some(options))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn files() -> Vec<PathBuf> {
|
||||
read_file_urls(|board, classes, options| {
|
||||
// SAFETY: as in `has_files`; every object read for `NSURL` is one.
|
||||
let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
objects
|
||||
.iter()
|
||||
.filter_map(|object| object.downcast::<NSURL>().ok())
|
||||
.filter_map(|url| url.to_file_path())
|
||||
// Finder ends a folder's URL in `/`; the project view names it without.
|
||||
.map(|path| path.components().collect())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
autoreleasepool(|_| {
|
||||
// Folders get a directory URL (ending in `/`), as Finder writes them.
|
||||
let urls: Vec<Retained<NSURL>> = paths
|
||||
.iter()
|
||||
.filter_map(|p| if p.is_dir() { NSURL::from_directory_path(p) } else { NSURL::from_file_path(p) })
|
||||
.collect();
|
||||
if urls.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let writers: Vec<&ProtocolObject<dyn NSPasteboardWriting>> =
|
||||
urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect();
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
// Replacing the list also drops the text the user copied before.
|
||||
board.clearContents();
|
||||
board.writeObjects(&NSArray::from_slice(&writers))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -7,9 +7,16 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio-util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
|
||||
@@ -13,6 +13,8 @@ use std::sync::Arc;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum IdeEvent {
|
||||
/// Project window membership or selection changed.
|
||||
WindowGroupsChanged,
|
||||
/// Files or directories changed on disk (after debouncing, ignore rules applied).
|
||||
FsChanged {
|
||||
created: Vec<crate::IpcPath>,
|
||||
@@ -32,10 +34,13 @@ pub enum IdeEvent {
|
||||
doc: DocId,
|
||||
dirty: bool,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File Type).
|
||||
/// An open document's language changed (Settings › File Types, Override File
|
||||
/// Type), or its UI mode (a framework's root marker appeared or went).
|
||||
DocLanguage {
|
||||
doc: DocId,
|
||||
language: crate::LanguageId,
|
||||
/// See `DocumentInfo::ui_mode`.
|
||||
ui_mode: Option<String>,
|
||||
},
|
||||
/// Full replacement of diagnostics for one file from one producer.
|
||||
Diagnostics {
|
||||
@@ -112,11 +117,22 @@ pub enum IdeEvent {
|
||||
id: u64,
|
||||
prompt: String,
|
||||
kind: AskpassKind,
|
||||
/// The dialog offers "Remember" for the answer (git HTTPS passwords).
|
||||
remember: bool,
|
||||
},
|
||||
/// Prompt `id` was answered (in any window) or gave up.
|
||||
AskpassDone {
|
||||
id: u64,
|
||||
},
|
||||
/// The kernel of an open notebook changed state (`starting`, `idle`, `busy`,
|
||||
/// `dead`); `message` says why it failed to start or died.
|
||||
NotebookKernel {
|
||||
/// Backend document incarnation; stale queued events cannot target a reopen.
|
||||
id: String,
|
||||
path: crate::IpcPath,
|
||||
state: String,
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an askpass prompt asks for.
|
||||
|
||||
@@ -12,12 +12,16 @@ pub mod ignored;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod proxy_settings;
|
||||
pub mod remote_settings;
|
||||
pub mod settings;
|
||||
pub mod task;
|
||||
pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
|
||||
pub use error::{IdeError, IdeResult};
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
//! Makes the output of JVM and Python processes match the encoding the IDE decodes.
|
||||
//!
|
||||
//! On Windows both write piped output in the ANSI code page (GBK on a Chinese
|
||||
//! system: `System.out.println`, `javac` errors, Maven / Gradle logs, `print`), while
|
||||
//! the console decodes the configured encoding (UTF-8 by default). They are told
|
||||
//! which one to use:
|
||||
//! - `java`: `stdout.encoding` / `stderr.encoding` (JDK 19+) and their `sun.*`
|
||||
//! forerunners (JDK 8–18) on the command line, plus `file.encoding=UTF-8` for a
|
||||
//! UTF-8 console, the default charset of JDK 18+, which logging frameworks' console
|
||||
//! appenders write in on older JDKs too.
|
||||
//! - Build tools and launcher scripts (Maven, Gradle, sbt, …): the same `std*.encoding`
|
||||
//! properties in `JAVA_TOOL_OPTIONS`, the one variable every JVM reads, so the JVMs
|
||||
//! they fork get them too (`spring-boot:run`, Surefire, `bootRun`, test workers).
|
||||
//! Each JVM then reports "Picked up JAVA_TOOL_OPTIONS: …" on stderr (which a build tool
|
||||
//! may copy to its stdout); that line is hidden again ([`NoticeFilter`]). `file.encoding` is left alone there: it would change
|
||||
//! how a build reads sources that declare no encoding.
|
||||
//! - Python: `PYTHONIOENCODING`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Decodes complete lines or collected output from IDE-managed tools, preferring UTF-8.
|
||||
/// Windows launcher/native diagnostics fall back to the system ANSI code page.
|
||||
/// Callers must buffer incomplete lines so multibyte characters are never split.
|
||||
/// Run consoles and protocol streams have their own encoding contracts.
|
||||
pub fn decode_tool_output(bytes: &[u8]) -> String {
|
||||
#[cfg(windows)]
|
||||
// SAFETY: GetACP is an argument-less query with no preconditions.
|
||||
let code_page = unsafe { windows_sys::Win32::Globalization::GetACP() };
|
||||
#[cfg(not(windows))]
|
||||
let code_page = 65001;
|
||||
decode_tool_output_with_code_page(bytes, code_page)
|
||||
}
|
||||
|
||||
fn decode_tool_output_with_code_page(bytes: &[u8], code_page: u32) -> String {
|
||||
if let Ok(text) = std::str::from_utf8(bytes) {
|
||||
return text.to_owned();
|
||||
}
|
||||
let encoding = encoding_for_code_page(code_page).unwrap_or(encoding_rs::UTF_8);
|
||||
// Collected output can mix UTF-8 JVM lines with native launcher diagnostics.
|
||||
let mut text = String::new();
|
||||
for line in bytes.split_inclusive(|b| *b == b'\n') {
|
||||
match std::str::from_utf8(line) {
|
||||
Ok(line) => text.push_str(line),
|
||||
Err(_) => text.push_str(&encoding.decode_without_bom_handling(line).0),
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn encoding_for_code_page(cp: u32) -> Option<&'static encoding_rs::Encoding> {
|
||||
Some(match cp {
|
||||
65001 => encoding_rs::UTF_8,
|
||||
936 => encoding_rs::GBK,
|
||||
54936 => encoding_rs::GB18030,
|
||||
950 => encoding_rs::BIG5,
|
||||
932 => encoding_rs::SHIFT_JIS,
|
||||
949 => encoding_rs::EUC_KR,
|
||||
866 => encoding_rs::IBM866,
|
||||
874 => encoding_rs::WINDOWS_874,
|
||||
1250 => encoding_rs::WINDOWS_1250,
|
||||
1251 => encoding_rs::WINDOWS_1251,
|
||||
1252 => encoding_rs::WINDOWS_1252,
|
||||
1253 => encoding_rs::WINDOWS_1253,
|
||||
1254 => encoding_rs::WINDOWS_1254,
|
||||
1255 => encoding_rs::WINDOWS_1255,
|
||||
1256 => encoding_rs::WINDOWS_1256,
|
||||
1257 => encoding_rs::WINDOWS_1257,
|
||||
1258 => encoding_rs::WINDOWS_1258,
|
||||
20866 => encoding_rs::KOI8_R,
|
||||
21866 => encoding_rs::KOI8_U,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
const STD_PROPERTIES: &[&str] = &["stdout.encoding", "stderr.encoding", "sun.stdout.encoding", "sun.stderr.encoding"];
|
||||
const FILE_ENCODING: &str = "file.encoding";
|
||||
const TOOL_OPTIONS: &str = "JAVA_TOOL_OPTIONS";
|
||||
|
||||
/// Build tools and launcher scripts that run on (and may fork) JVMs.
|
||||
const JVM_TOOLS: &[&str] =
|
||||
&["mvn", "mvnw", "mvnd", "gradle", "gradlew", "sbt", "ant", "kotlin", "kotlinc", "groovy", "scala", "jbang"];
|
||||
|
||||
/// What a launch needs: `-D` options to put first on a `java` command line,
|
||||
/// environment variables to set, and the notice line its JVMs print because of them.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncodingFix {
|
||||
pub java_args: Vec<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
/// "Picked up JAVA_TOOL_OPTIONS: …" for a variable set here alone, to drop from the output.
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
pub fn is_utf8(encoding: &str) -> bool {
|
||||
matches!(encoding.trim().to_ascii_lowercase().replace('_', "-").as_str(), "utf-8" | "utf8")
|
||||
}
|
||||
|
||||
/// File name of `command` without a Windows executable extension: `C:\jdk\bin\java.exe` → `java`.
|
||||
fn program_name(command: &str) -> String {
|
||||
let name = command.rsplit(['/', '\\']).next().unwrap_or(command).to_ascii_lowercase();
|
||||
for ext in [".exe", ".cmd", ".bat"] {
|
||||
if let Some(stem) = name.strip_suffix(ext) {
|
||||
return stem.to_owned();
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn sets(text: &str, properties: &[&str]) -> bool {
|
||||
properties.iter().any(|p| text.contains(&format!("-D{p}=")))
|
||||
}
|
||||
|
||||
fn options(properties: &[&str], encoding: &str) -> Vec<String> {
|
||||
properties.iter().map(|p| format!("-D{p}={encoding}")).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Java,
|
||||
JvmTool,
|
||||
Python,
|
||||
Other,
|
||||
}
|
||||
|
||||
fn kind(program: &str) -> Kind {
|
||||
let name = program_name(program);
|
||||
if name == "java" || name == "javaw" {
|
||||
Kind::Java
|
||||
} else if JVM_TOOLS.contains(&name.as_str()) {
|
||||
Kind::JvmTool
|
||||
} else if is_python(&name) {
|
||||
Kind::Python
|
||||
} else {
|
||||
Kind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// The options for a launch of `program` (`args`, environment `vars` over the
|
||||
/// `inherited` one) whose output is decoded as `encoding`; nothing when the
|
||||
/// process already writes it (UTF-8 off Windows) or the user chose the options.
|
||||
pub fn fix(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
fix_kind(kind(program), Some(args), vars, inherited, encoding, windows)
|
||||
}
|
||||
|
||||
/// [`fix`] for a command line run by the shell (`cmd /C "mvn spring-boot:run"`), whose
|
||||
/// arguments cannot be changed: its first word decides, and only variables are set.
|
||||
pub fn fix_command_line(
|
||||
line: &str,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
let line = line.trim_start();
|
||||
let first = match line.chars().next() {
|
||||
Some(q @ ('"' | '\'')) => line[1..].split(q).next(),
|
||||
_ => line.split(char::is_whitespace).next(),
|
||||
}
|
||||
.unwrap_or_default();
|
||||
fix_kind(kind(first), None, vars, inherited, encoding, windows)
|
||||
}
|
||||
|
||||
fn fix_kind(
|
||||
kind: Kind,
|
||||
args: Option<&[String]>,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: &str,
|
||||
windows: bool,
|
||||
) -> EncodingFix {
|
||||
let encoding = encoding.trim();
|
||||
let mut out = EncodingFix::default();
|
||||
if encoding.is_empty() || (!windows && is_utf8(encoding)) {
|
||||
return out;
|
||||
}
|
||||
let current = |key: &str| vars.get(key).cloned().or_else(|| inherited(key)).unwrap_or_default();
|
||||
let tool_options = current(TOOL_OPTIONS);
|
||||
match (kind, args) {
|
||||
(Kind::Java, Some(args)) => {
|
||||
let java_options = format!("{} {tool_options}", current("JDK_JAVA_OPTIONS"));
|
||||
let chosen =
|
||||
|properties: &[&str]| args.iter().any(|a| sets(a, properties)) || sets(&java_options, properties);
|
||||
if !chosen(STD_PROPERTIES) {
|
||||
out.java_args = options(STD_PROPERTIES, encoding);
|
||||
}
|
||||
if is_utf8(encoding) && !chosen(&[FILE_ENCODING]) {
|
||||
out.java_args.push(format!("-D{FILE_ENCODING}=UTF-8"));
|
||||
}
|
||||
}
|
||||
(Kind::Java | Kind::JvmTool, _) => {
|
||||
if !sets(&tool_options, STD_PROPERTIES) {
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
let value = match tool_options.trim() {
|
||||
"" => {
|
||||
out.notice = Some(format!("Picked up {TOOL_OPTIONS}: {added}"));
|
||||
added
|
||||
}
|
||||
own => format!("{own} {added}"),
|
||||
};
|
||||
out.env.push((TOOL_OPTIONS.to_owned(), value));
|
||||
}
|
||||
}
|
||||
(Kind::Python, _) if current("PYTHONIOENCODING").trim().is_empty() => {
|
||||
out.env.push(("PYTHONIOENCODING".to_owned(), encoding.to_owned()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
|
||||
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
|
||||
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
|
||||
return None;
|
||||
}
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
Some(match vm_args.trim() {
|
||||
"" => added,
|
||||
own => format!("{own} {added}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `python`, `python3`, `python3.12`, `py`, and the runners that start one.
|
||||
fn is_python(name: &str) -> bool {
|
||||
name == "python"
|
||||
|| name == "py"
|
||||
|| name.strip_prefix("python").is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.'))
|
||||
|| matches!(name, "uv" | "poetry" | "pdm" | "pytest" | "pipenv")
|
||||
}
|
||||
|
||||
/// Drops whole lines equal to `notice` from a stream of decoded text (the
|
||||
/// [`EncodingFix::notice`] each JVM prints), passing everything else through.
|
||||
/// The start of a line is held back only while it can still become the notice.
|
||||
#[derive(Debug)]
|
||||
pub struct NoticeFilter {
|
||||
notice: String,
|
||||
held: String,
|
||||
at_line_start: bool,
|
||||
}
|
||||
|
||||
impl NoticeFilter {
|
||||
pub fn new(notice: String) -> Self {
|
||||
Self { notice, held: String::new(), at_line_start: true }
|
||||
}
|
||||
|
||||
pub fn filter(&mut self, text: &str, out: &mut String) {
|
||||
for piece in text.split_inclusive('\n') {
|
||||
if !self.at_line_start && self.held.is_empty() {
|
||||
out.push_str(piece);
|
||||
self.at_line_start = piece.ends_with('\n');
|
||||
continue;
|
||||
}
|
||||
self.held.push_str(piece);
|
||||
if self.held.ends_with('\n') {
|
||||
if self.held.trim_end_matches(['\n', '\r']) != self.notice {
|
||||
out.push_str(&self.held);
|
||||
}
|
||||
self.held.clear();
|
||||
self.at_line_start = true;
|
||||
} else if !self.could_be_notice() {
|
||||
out.push_str(&self.held);
|
||||
self.held.clear();
|
||||
self.at_line_start = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The held text is the start of the notice, or all of it before a `\r\n`.
|
||||
fn could_be_notice(&self) -> bool {
|
||||
match self.held.strip_suffix('\r') {
|
||||
Some(line) => line == self.notice,
|
||||
None => self.notice.starts_with(self.held.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The held-back start of a line, at the end of the stream.
|
||||
pub fn finish(&mut self) -> String {
|
||||
std::mem::take(&mut self.held)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_output_prefers_utf8() {
|
||||
for cp in [936, 1252, 65001, 0] {
|
||||
for text in ["", "plain ASCII\r\n", "中文 café 🦀\n"] {
|
||||
assert_eq!(decode_tool_output_with_code_page(text.as_bytes(), cp), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_output_decodes_native_diagnostics_and_mixed_lines() {
|
||||
const GBK: &[u8] = b"\xcf\xb5\xcd\xb3\xd5\xd2\xb2\xbb\xb5\xbd\xd6\xb8\xb6\xa8\xb5\xc4\xc2\xb7\xbe\xb6\xa1\xa3";
|
||||
assert_eq!(decode_tool_output_with_code_page(GBK, 936), "系统找不到指定的路径。");
|
||||
let mut mixed = "JVM: 中文\r\n".as_bytes().to_vec();
|
||||
mixed.extend_from_slice(GBK);
|
||||
mixed.extend_from_slice(b"\r\nlast");
|
||||
assert_eq!(decode_tool_output_with_code_page(&mixed, 936), "JVM: 中文\r\n系统找不到指定的路径。\r\nlast");
|
||||
assert_eq!(decode_tool_output_with_code_page(b"caf\xe9", 1252), "café");
|
||||
for cp in [65001, 0] {
|
||||
assert_eq!(decode_tool_output_with_code_page(b"a\xffb", cp), "a�b");
|
||||
}
|
||||
}
|
||||
|
||||
const STD_UTF8: [&str; 4] = [
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
];
|
||||
const TOOL_UTF8: &str =
|
||||
"-Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8";
|
||||
|
||||
fn vars(vars: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()
|
||||
}
|
||||
|
||||
fn run(program: &str, args: &[&str], env: &[(&str, &str)], encoding: &str, windows: bool) -> EncodingFix {
|
||||
let args: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
|
||||
fix(program, &args, &vars(env), |_| None, encoding, windows)
|
||||
}
|
||||
|
||||
fn tool_env(f: &EncodingFix) -> Option<&str> {
|
||||
f.env.iter().find(|(k, _)| k == TOOL_OPTIONS).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_launches_write_the_console_encoding_on_windows() {
|
||||
let mut expected: Vec<&str> = STD_UTF8.to_vec();
|
||||
expected.push("-Dfile.encoding=UTF-8");
|
||||
// A source-file launch through the Oracle `javapath` shim, and an Application / Spring Boot launch.
|
||||
let java = "C:/Program Files/Common Files/Oracle/Java/javapath/java.exe";
|
||||
for program in [java, "java", "javaw.exe", r"C:\jdk\bin\java.exe", "JAVA.EXE"] {
|
||||
for args in [&["Main.java"][..], &["-cp", "a;b", "org.dromara.DromaraApplication"]] {
|
||||
let f = run(program, args, &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, expected, "{program}");
|
||||
assert!(f.env.is_empty() && f.notice.is_none());
|
||||
}
|
||||
}
|
||||
// Another console encoding: the streams follow it, the default charset stays the platform's.
|
||||
assert_eq!(
|
||||
run("java", &["-cp", "x", "a.Main"], &[], "GBK", true).java_args,
|
||||
[
|
||||
"-Dstdout.encoding=GBK",
|
||||
"-Dstderr.encoding=GBK",
|
||||
"-Dsun.stdout.encoding=GBK",
|
||||
"-Dsun.stderr.encoding=GBK"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_changes_where_output_already_matches() {
|
||||
for (program, args) in [("java", vec!["Main.java"]), ("mvn", vec!["package"]), ("python3", vec!["a.py"])] {
|
||||
assert_eq!(run(program, &args, &[], "UTF-8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "utf8", false), EncodingFix::default(), "{program}");
|
||||
assert_eq!(run(program, &args, &[], "", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
for program in ["node", "cargo", "go", "npm.cmd", "cmd.exe", "javac-wrapper"] {
|
||||
assert_eq!(run(program, &[], &[], "UTF-8", true), EncodingFix::default(), "{program}");
|
||||
}
|
||||
// A console set to another encoding elsewhere still gets it.
|
||||
assert_eq!(run("java", &["Main"], &[], "GBK", false).java_args.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_chosen_by_the_user_win() {
|
||||
let f = run("java", &["-Dstdout.encoding=GBK", "Main"], &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, ["-Dfile.encoding=UTF-8"]);
|
||||
let f = run("java", &["-Dfile.encoding=GBK", "Main"], &[], "UTF-8", true);
|
||||
assert_eq!(f.java_args, STD_UTF8);
|
||||
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
|
||||
let f = run("java", &["Main"], &[(key, "-Dsun.stdout.encoding=GBK -Dfile.encoding=GBK")], "UTF-8", true);
|
||||
assert!(f.java_args.is_empty(), "{key}");
|
||||
}
|
||||
assert!(run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-Dstdout.encoding=GBK")], "UTF-8", true).env.is_empty());
|
||||
assert!(run("python.exe", &[], &[("PYTHONIOENCODING", "gbk")], "UTF-8", true).env.is_empty());
|
||||
}
|
||||
|
||||
/// Maven's `spring-boot:run` and Surefire, Gradle's `bootRun` and test workers fork JVMs
|
||||
/// that never see `MAVEN_OPTS` / `GRADLE_OPTS`: the options go where every JVM reads them.
|
||||
#[test]
|
||||
fn build_tools_reach_the_jvms_they_fork() {
|
||||
for program in [
|
||||
"mvn.cmd",
|
||||
"mvnw.cmd",
|
||||
r"C:\maven\bin\mvn.cmd",
|
||||
"mvnd.cmd",
|
||||
"gradlew.bat",
|
||||
"gradle.bat",
|
||||
"sbt.bat",
|
||||
"kotlinc.bat",
|
||||
] {
|
||||
let f = run(program, &["spring-boot:run"], &[], "UTF-8", true);
|
||||
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{program}");
|
||||
assert_eq!(f.notice.as_deref(), Some(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}").as_str()));
|
||||
assert!(f.java_args.is_empty());
|
||||
assert!(!tool_env(&f).unwrap().contains("file.encoding"), "a build's source encoding is left alone");
|
||||
}
|
||||
// Existing options are kept, from the configuration or the inherited environment; the
|
||||
// JVMs then print the user's own variable, which stays visible.
|
||||
let f = run("mvn.cmd", &[], &[(TOOL_OPTIONS, "-javaagent:a.jar")], "UTF-8", true);
|
||||
assert_eq!(tool_env(&f), Some(format!("-javaagent:a.jar {TOOL_UTF8}").as_str()));
|
||||
assert!(f.notice.is_none());
|
||||
let inherited =
|
||||
fix("gradlew.bat", &[], &BTreeMap::new(), |k| (k == TOOL_OPTIONS).then(|| "-Xss4m".into()), "UTF-8", true);
|
||||
assert_eq!(tool_env(&inherited), Some(format!("-Xss4m {TOOL_UTF8}").as_str()));
|
||||
for program in ["python.exe", "python3", "python3.12", "py.exe", "uv.exe", "poetry"] {
|
||||
assert_eq!(
|
||||
run(program, &[], &[], "UTF-8", true).env,
|
||||
[("PYTHONIOENCODING".to_owned(), "UTF-8".to_owned())],
|
||||
"{program}"
|
||||
);
|
||||
}
|
||||
assert_eq!(run("python3", &[], &[], "GBK", false).env, [("PYTHONIOENCODING".to_owned(), "GBK".to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_lines_get_variables_from_their_first_word() {
|
||||
let line = |l: &str| fix_command_line(l, &BTreeMap::new(), |_| None, "UTF-8", true);
|
||||
for l in [
|
||||
"mvn spring-boot:run -pl ruoyi-admin",
|
||||
"\"C:\\Program Files\\Maven\\bin\\mvn.cmd\" test",
|
||||
"java -jar app.jar",
|
||||
] {
|
||||
let f = line(l);
|
||||
assert_eq!(tool_env(&f), Some(TOOL_UTF8), "{l}");
|
||||
assert!(f.java_args.is_empty());
|
||||
}
|
||||
assert_eq!(line("python manage.py runserver").env[0].0, "PYTHONIOENCODING");
|
||||
assert_eq!(line("npm run dev && echo done"), EncodingFix::default());
|
||||
assert_eq!(fix_command_line("mvn test", &BTreeMap::new(), |_| None, "UTF-8", false), EncodingFix::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_write_what_java_debug_decodes() {
|
||||
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
|
||||
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
|
||||
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
|
||||
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
|
||||
}
|
||||
|
||||
fn filtered(chunks: &[&str]) -> String {
|
||||
let mut f = NoticeFilter::new(format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}"));
|
||||
let mut out = String::new();
|
||||
for c in chunks {
|
||||
f.filter(c, &mut out);
|
||||
}
|
||||
out.push_str(&f.finish());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_lines_are_dropped_wherever_the_chunks_split() {
|
||||
let notice = format!("Picked up JAVA_TOOL_OPTIONS: {TOOL_UTF8}");
|
||||
let text =
|
||||
format!("{notice}\r\nerror: one\n{notice}\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast");
|
||||
let expected = "error: one\nPicked up nothing\nPicked up JAVA_TOOL_OPTIONS: -Dx\nlast";
|
||||
assert_eq!(filtered(&[&text]), expected);
|
||||
// Every split point, also inside the notice and between `\r` and `\n`.
|
||||
for i in 1..text.len() {
|
||||
let (a, b) = text.split_at(i);
|
||||
assert_eq!(filtered(&[a, b]), expected, "split at {i}");
|
||||
}
|
||||
// A line that only starts like it is not held: text mid-line passes through at once.
|
||||
let mut f = NoticeFilter::new(notice.clone());
|
||||
let mut out = String::new();
|
||||
f.filter("Pick a number: ", &mut out);
|
||||
assert_eq!(out, "Pick a number: ");
|
||||
f.filter("Picked", &mut out);
|
||||
assert_eq!(out, "Pick a number: Picked");
|
||||
// A prefix at the end of the stream comes out on finish.
|
||||
assert_eq!(filtered(&["Picked up"]), "Picked up");
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
|
||||
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
|
||||
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables.
|
||||
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables and
|
||||
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]).
|
||||
/// Variables the call site has already set are kept. A no-op outside an AppImage
|
||||
/// without an overlay.
|
||||
pub fn host_env(cmd: &mut Command) -> &mut Command {
|
||||
@@ -89,12 +90,16 @@ fn apply_changes(
|
||||
vars
|
||||
}
|
||||
|
||||
/// The AppImage fixes, then the overlay (which wins for a name both change); one entry per name.
|
||||
/// The AppImage fixes, the overlay, then the manual proxy's (a later one wins); one entry per name.
|
||||
fn child_env_changes() -> Vec<(OsString, Option<OsString>)> {
|
||||
let mut changes = appimage_fixes();
|
||||
let overlay = env_overlay();
|
||||
changes.retain(|(k, _)| !overlay.iter().any(|(o, _)| o == k));
|
||||
changes.extend(overlay.iter().cloned());
|
||||
// A manual HTTP proxy reaches tools that read `HTTP_PROXY` (language servers, git, curl).
|
||||
let proxy = crate::proxy_settings::proxy().child_env();
|
||||
changes.retain(|(k, _)| !proxy.iter().any(|(p, _)| p == k));
|
||||
changes.extend(proxy);
|
||||
changes
|
||||
}
|
||||
|
||||
@@ -213,6 +218,25 @@ pub fn executable_names(name: &str, windows: bool, pathext: Option<&str>) -> Vec
|
||||
exts.iter().map(|e| format!("{name}{}", e.to_ascii_lowercase())).collect()
|
||||
}
|
||||
|
||||
/// First `search` directory holding `name` (see [`executable_names`]).
|
||||
pub fn find_executable_in(
|
||||
name: &str,
|
||||
search: &OsStr,
|
||||
windows: bool,
|
||||
pathext: Option<&str>,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
let names = executable_names(name, windows, pathext);
|
||||
std::env::split_paths(search)
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
.find_map(|dir| names.iter().map(|n| dir.join(n)).find(|p| p.is_file()))
|
||||
}
|
||||
|
||||
/// [`find_executable_in`] for this platform (`PATHEXT` from the environment).
|
||||
pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf> {
|
||||
let pathext = host_var_os("PATHEXT");
|
||||
find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -358,4 +382,23 @@ mod tests {
|
||||
prepare(&mut cmd);
|
||||
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "PATH").and_then(|(_, v)| v), Some(OsStr::new("/custom")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_executable_finds_bare_names_on_the_given_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a");
|
||||
let b = dir.path().join("b");
|
||||
std::fs::create_dir_all(&a).unwrap();
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
std::fs::write(a.join("agent"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(b.join("agent.cmd"), "@echo off\n").unwrap();
|
||||
let search = std::env::join_paths([&a, &b]).unwrap();
|
||||
if cfg!(windows) {
|
||||
// The `.cmd` shim wins over a POSIX script `Command` can't start.
|
||||
assert_eq!(find_executable("agent", &search), Some(b.join("agent.cmd")));
|
||||
} else {
|
||||
assert_eq!(find_executable("agent", &search), Some(a.join("agent")));
|
||||
}
|
||||
assert_eq!(find_executable("nope", &search), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Appearance & Behavior › System Settings › HTTP Proxy, and the proxy the IDE's own
|
||||
//! connections use: [`set_proxy`] makes a configuration current, every outgoing
|
||||
//! request asks [`proxy`] (the HTTP client, `curl` downloads, the updater), and with a
|
||||
//! manual proxy child processes get `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ProxyMode {
|
||||
/// Every connection goes direct.
|
||||
None,
|
||||
/// The system's proxy: `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` of the environment.
|
||||
#[default]
|
||||
Auto,
|
||||
/// The proxy configured here.
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ProxyKind {
|
||||
#[default]
|
||||
Http,
|
||||
Socks,
|
||||
}
|
||||
|
||||
impl ProxyKind {
|
||||
pub fn default_port(self) -> u16 {
|
||||
match self {
|
||||
ProxyKind::Http => 80,
|
||||
ProxyKind::Socks => 1080,
|
||||
}
|
||||
}
|
||||
|
||||
fn scheme(self) -> &'static str {
|
||||
match self {
|
||||
ProxyKind::Http => "http",
|
||||
// Host names resolve on the proxy.
|
||||
ProxyKind::Socks => "socks5h",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProxySettings {
|
||||
pub mode: ProxyMode,
|
||||
pub kind: ProxyKind,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// Hosts reached directly, comma-separated; `*` matches any characters (`*.example.com`, `192.168.*`).
|
||||
pub no_proxy: String,
|
||||
/// Send `login` / `password` to the proxy.
|
||||
pub authenticate: bool,
|
||||
pub login: String,
|
||||
/// Kept in the saved passwords rather than `settings.toml`; filled in when settings are read.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl Default for ProxySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: ProxyMode::Auto,
|
||||
kind: ProxyKind::Http,
|
||||
host: String::new(),
|
||||
port: ProxyKind::Http.default_port(),
|
||||
no_proxy: String::new(),
|
||||
authenticate: false,
|
||||
login: String::new(),
|
||||
password: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProxySettings {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProxySettings")
|
||||
.field("mode", &self.mode)
|
||||
.field("kind", &self.kind)
|
||||
.field("host", &self.host)
|
||||
.field("port", &self.port)
|
||||
.field("no_proxy", &self.no_proxy)
|
||||
.field("authenticate", &self.authenticate)
|
||||
.field("login", &self.login)
|
||||
.field("password", &if self.password.is_empty() { "" } else { "***" })
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// How a connection to some host is made.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProxyRoute {
|
||||
Direct,
|
||||
/// Whatever the environment says (the HTTP library's / `curl`'s own proxy lookup).
|
||||
System,
|
||||
/// Through this proxy URL, credentials included (`http://user:pass@proxy:3128`).
|
||||
Via(String),
|
||||
}
|
||||
|
||||
/// Loopback hosts never go through the manual proxy.
|
||||
const LOOPBACK: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
|
||||
|
||||
impl ProxySettings {
|
||||
/// A manual proxy with a host: what [`Self::route`] sends connections through.
|
||||
pub fn manual(&self) -> bool {
|
||||
self.mode == ProxyMode::Manual && !self.host.trim().is_empty()
|
||||
}
|
||||
|
||||
/// What is wrong with the manual configuration, if anything.
|
||||
pub fn problem(&self) -> Option<String> {
|
||||
if self.mode != ProxyMode::Manual {
|
||||
return None;
|
||||
}
|
||||
let host = self.host.trim();
|
||||
if host.is_empty() {
|
||||
return Some("Proxy host is empty".into());
|
||||
}
|
||||
if host.contains(['/', '@', ' ']) {
|
||||
return Some(format!("Invalid proxy host: {host}"));
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Some("Proxy port must be between 1 and 65535".into());
|
||||
}
|
||||
if self.authenticate && self.login.trim().is_empty() {
|
||||
return Some("Proxy login is empty".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `host` is reached directly: loopback, or a "No proxy for" entry.
|
||||
pub fn bypasses(&self, host: &str) -> bool {
|
||||
let host = host.trim().trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase();
|
||||
if LOOPBACK.contains(&host.as_str()) {
|
||||
return true;
|
||||
}
|
||||
self.no_proxy_entries().any(|pattern| glob_match(&pattern, &host))
|
||||
}
|
||||
|
||||
fn no_proxy_entries(&self) -> impl Iterator<Item = String> + '_ {
|
||||
self.no_proxy.split([',', ';', '\n']).map(|e| e.trim().to_ascii_lowercase()).filter(|e| !e.is_empty())
|
||||
}
|
||||
|
||||
/// The manual proxy as a URL, with the credentials when it authenticates.
|
||||
pub fn proxy_url(&self) -> Option<String> {
|
||||
if !self.manual() {
|
||||
return None;
|
||||
}
|
||||
let host = self.host.trim();
|
||||
let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_owned() };
|
||||
let auth = if self.authenticate && !self.login.is_empty() {
|
||||
format!("{}:{}@", encode_userinfo(&self.login), encode_userinfo(&self.password))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Some(format!("{}://{auth}{host}:{}", self.kind.scheme(), self.port))
|
||||
}
|
||||
|
||||
/// How to connect to `host` (`None`: not known yet, e.g. the request has no URL).
|
||||
pub fn route(&self, host: Option<&str>) -> ProxyRoute {
|
||||
match self.mode {
|
||||
ProxyMode::None => ProxyRoute::Direct,
|
||||
ProxyMode::Auto => ProxyRoute::System,
|
||||
ProxyMode::Manual => match self.proxy_url() {
|
||||
Some(_) if host.is_some_and(|h| self.bypasses(h)) => ProxyRoute::Direct,
|
||||
Some(url) => ProxyRoute::Via(url),
|
||||
None => ProxyRoute::Direct,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::route`] for the host of `url` (`https://user@host:443/path`).
|
||||
pub fn route_for_url(&self, url: &str) -> ProxyRoute {
|
||||
self.route(url_host(url).as_deref())
|
||||
}
|
||||
|
||||
/// Arguments that make a `curl` download of `url` follow this configuration. The
|
||||
/// manual proxy itself comes from the environment (see [`Self::child_env`]), which
|
||||
/// keeps the credentials out of the command line.
|
||||
pub fn curl_args(&self, url: &str) -> Vec<&'static str> {
|
||||
match self.route_for_url(url) {
|
||||
ProxyRoute::Direct => vec!["--noproxy", "*"],
|
||||
ProxyRoute::System => Vec::new(),
|
||||
// Only this configuration decides what bypasses the proxy.
|
||||
ProxyRoute::Via(_) => vec!["--noproxy", ""],
|
||||
}
|
||||
}
|
||||
|
||||
/// Variables child processes get with a manual proxy (`HTTP_PROXY`, `HTTPS_PROXY`,
|
||||
/// `ALL_PROXY`, `NO_PROXY` and their lowercase forms); none otherwise.
|
||||
pub fn child_env(&self) -> Vec<(OsString, Option<OsString>)> {
|
||||
let Some(url) = self.proxy_url() else { return Vec::new() };
|
||||
let mut no_proxy: Vec<String> = LOOPBACK.iter().map(|h| (*h).to_owned()).collect();
|
||||
// `*.example.com` is `.example.com` to curl, Go, Python and Node.
|
||||
no_proxy.extend(self.no_proxy_entries().map(|e| e.strip_prefix('*').map(str::to_owned).unwrap_or(e)));
|
||||
no_proxy.dedup();
|
||||
let no_proxy = no_proxy.join(",");
|
||||
let mut env = Vec::new();
|
||||
for name in ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] {
|
||||
env.push((OsString::from(name), Some(OsString::from(&url))));
|
||||
env.push((OsString::from(name.to_ascii_lowercase()), Some(OsString::from(&url))));
|
||||
}
|
||||
env.push((OsString::from("NO_PROXY"), Some(OsString::from(&no_proxy))));
|
||||
env.push((OsString::from("no_proxy"), Some(OsString::from(no_proxy))));
|
||||
env
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive host glob (`pattern` is lowercase): `*` matches any run of characters.
|
||||
fn glob_match(pattern: &str, host: &str) -> bool {
|
||||
let (p, h) = (pattern.as_bytes(), host.as_bytes());
|
||||
let (mut pi, mut hi) = (0, 0);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
while hi < h.len() {
|
||||
if pi < p.len() && p[pi] == b'*' {
|
||||
star = Some((pi, hi));
|
||||
pi += 1;
|
||||
} else if pi < p.len() && p[pi] == h[hi] {
|
||||
pi += 1;
|
||||
hi += 1;
|
||||
} else if let Some((sp, sh)) = star {
|
||||
pi = sp + 1;
|
||||
hi = sh + 1;
|
||||
star = Some((sp, sh + 1));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
p[pi..].iter().all(|&c| c == b'*')
|
||||
}
|
||||
|
||||
fn encode_userinfo(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The host of `url`, without brackets, userinfo or port.
|
||||
fn url_host(url: &str) -> Option<String> {
|
||||
let rest = url.split_once("://").map_or(url, |(_, r)| r);
|
||||
let authority = rest.split(['/', '?', '#']).next()?;
|
||||
let authority = authority.rsplit_once('@').map_or(authority, |(_, a)| a);
|
||||
let host =
|
||||
if let Some(v6) = authority.strip_prefix('[') { v6.split(']').next()? } else { authority.split(':').next()? };
|
||||
(!host.is_empty()).then(|| host.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<ProxySettings>>> = RwLock::new(None);
|
||||
|
||||
/// Makes `settings` (password filled in) the proxy of every later connection and child process.
|
||||
pub fn set_proxy(settings: ProxySettings) {
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(settings));
|
||||
}
|
||||
|
||||
/// The current proxy configuration (the default, auto-detect, until [`set_proxy`]).
|
||||
pub fn proxy() -> Arc<ProxySettings> {
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn manual(host: &str, port: u16) -> ProxySettings {
|
||||
ProxySettings { mode: ProxyMode::Manual, host: host.into(), port, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_is_the_default_and_defers_to_the_system() {
|
||||
let p = ProxySettings::default();
|
||||
assert_eq!(p.mode, ProxyMode::Auto);
|
||||
assert_eq!(p.route_for_url("https://example.com/"), ProxyRoute::System);
|
||||
assert!(p.curl_args("https://example.com/").is_empty());
|
||||
assert!(p.child_env().is_empty());
|
||||
assert_eq!(p.problem(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_goes_direct() {
|
||||
let p = ProxySettings { mode: ProxyMode::None, ..manual("proxy", 3128) };
|
||||
assert_eq!(p.route_for_url("https://example.com/"), ProxyRoute::Direct);
|
||||
assert_eq!(p.curl_args("https://example.com/"), ["--noproxy", "*"]);
|
||||
assert!(p.child_env().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_http_and_socks_urls() {
|
||||
let p = manual("proxy.corp", 3128);
|
||||
assert_eq!(p.route_for_url("https://example.com/x"), ProxyRoute::Via("http://proxy.corp:3128".into()));
|
||||
assert_eq!(p.curl_args("https://example.com/x"), ["--noproxy", ""]);
|
||||
let socks = ProxySettings { kind: ProxyKind::Socks, ..manual("10.0.0.1", 1080) };
|
||||
assert_eq!(socks.proxy_url().as_deref(), Some("socks5h://10.0.0.1:1080"));
|
||||
assert_eq!(manual("::1", 8080).proxy_url().as_deref(), Some("http://[::1]:8080"));
|
||||
// Manual without a host connects directly.
|
||||
assert_eq!(manual(" ", 80).route(Some("example.com")), ProxyRoute::Direct);
|
||||
assert!(manual(" ", 80).problem().is_some());
|
||||
assert!(manual("proxy", 0).problem().is_some());
|
||||
assert!(manual("http://proxy", 80).problem().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_are_percent_encoded() {
|
||||
let p = ProxySettings {
|
||||
authenticate: true,
|
||||
login: "dom\\me".into(),
|
||||
password: "p@ss:w/rd".into(),
|
||||
..manual("proxy", 3128)
|
||||
};
|
||||
assert_eq!(p.proxy_url().as_deref(), Some("http://dom%5Cme:p%40ss%3Aw%2Frd@proxy:3128"));
|
||||
// Not sent without "Proxy authentication".
|
||||
let off = ProxySettings { authenticate: false, ..p.clone() };
|
||||
assert_eq!(off.proxy_url().as_deref(), Some("http://proxy:3128"));
|
||||
assert!(ProxySettings { login: " ".into(), ..p.clone() }.problem().is_some());
|
||||
assert!(!format!("{p:?}").contains("p@ss"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_proxy_for_list_matches_globs_and_loopback() {
|
||||
let p = ProxySettings { no_proxy: "*.example.com, 192.168.*,Intranet".into(), ..manual("proxy", 3128) };
|
||||
assert!(p.bypasses("api.example.com"));
|
||||
assert!(p.bypasses("a.b.EXAMPLE.com"));
|
||||
assert!(!p.bypasses("example.com"));
|
||||
assert!(!p.bypasses("example.com.evil.org"));
|
||||
assert!(p.bypasses("192.168.1.20"));
|
||||
assert!(p.bypasses("intranet"));
|
||||
assert!(!p.bypasses("intranet2"));
|
||||
assert!(p.bypasses("localhost") && p.bypasses("127.0.0.1") && p.bypasses("[::1]"));
|
||||
assert_eq!(p.route_for_url("https://[email protected]:8443/v1"), ProxyRoute::Direct);
|
||||
assert_eq!(p.curl_args("http://192.168.0.5/"), ["--noproxy", "*"]);
|
||||
assert!(matches!(p.route_for_url("https://github.com"), ProxyRoute::Via(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_processes_get_the_manual_proxy() {
|
||||
let p = ProxySettings {
|
||||
no_proxy: "*.corp, 10.*".into(),
|
||||
authenticate: true,
|
||||
login: "me".into(),
|
||||
password: "pw".into(),
|
||||
..manual("proxy", 3128)
|
||||
};
|
||||
let env = p.child_env();
|
||||
let get = |k: &str| env.iter().find(|(n, _)| n == k).and_then(|(_, v)| v.clone()).unwrap();
|
||||
assert_eq!(get("HTTPS_PROXY"), "http://me:pw@proxy:3128");
|
||||
assert_eq!(get("http_proxy"), "http://me:pw@proxy:3128");
|
||||
assert_eq!(get("NO_PROXY"), "localhost,127.0.0.1,::1,.corp,10.*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_hosts() {
|
||||
assert_eq!(url_host("https://Example.com:443/a?b").as_deref(), Some("example.com"));
|
||||
assert_eq!(url_host("http://u:p@[::1]:80/").as_deref(), Some("::1"));
|
||||
assert_eq!(url_host("example.com/x").as_deref(), Some("example.com"));
|
||||
assert_eq!(url_host("file:///tmp/x"), None);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
pub use crate::editor_settings::*;
|
||||
pub use crate::inspection_settings::*;
|
||||
pub use crate::proxy_settings::{ProxyKind, ProxyMode, ProxySettings};
|
||||
pub use crate::remote_settings::*;
|
||||
pub use crate::workflow_settings::*;
|
||||
|
||||
@@ -25,6 +26,8 @@ pub struct Settings {
|
||||
/// keymap's own overrides. `keymap` may name one of these ids.
|
||||
pub custom_keymaps: BTreeMap<String, CustomKeymap>,
|
||||
pub notifications: NotificationSettings,
|
||||
/// System Settings › HTTP Proxy (global only).
|
||||
pub proxy: ProxySettings,
|
||||
/// Interpreters / toolchains per language (Python, Node.js, Rust, Go, TypeScript).
|
||||
pub languages: LanguagesSettings,
|
||||
/// Project Structure: source / test / resource / excluded folders (project-relative).
|
||||
@@ -134,6 +137,7 @@ impl Default for Settings {
|
||||
keymap_overrides: BTreeMap::new(),
|
||||
custom_keymaps: BTreeMap::new(),
|
||||
notifications: NotificationSettings::default(),
|
||||
proxy: ProxySettings::default(),
|
||||
languages: LanguagesSettings::default(),
|
||||
project_structure: ProjectStructureSettings::default(),
|
||||
vcs: VcsSettings::default(),
|
||||
@@ -174,7 +178,7 @@ pub struct CustomKeymap {
|
||||
pub struct GeneralSettings {
|
||||
pub reopen_last_project: bool,
|
||||
pub confirm_exit: bool,
|
||||
/// Where Open… puts a project while one is open: `ask` | `new` | `same`.
|
||||
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
|
||||
pub open_project_in: String,
|
||||
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
|
||||
pub default_project_directory: String,
|
||||
@@ -559,12 +563,37 @@ pub struct JavaSettings {
|
||||
pub jdtls_vm_args: String,
|
||||
/// Build Tools › Maven.
|
||||
pub maven: MavenSettings,
|
||||
/// Build Tools › Gradle.
|
||||
pub gradle: GradleSettings,
|
||||
}
|
||||
|
||||
/// Build Tools › Gradle.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GradleSettings {
|
||||
/// Empty = Project JDK; otherwise a JDK home.
|
||||
pub jvm: String,
|
||||
pub distribution: GradleDistribution,
|
||||
/// Used only for the local distribution.
|
||||
pub home: String,
|
||||
/// Empty = Gradle's default user home.
|
||||
pub user_home: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GradleDistribution {
|
||||
#[default]
|
||||
Wrapper,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// Build Tools › Maven.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MavenSettings {
|
||||
/// Project-relative root POMs removed from the Maven project model.
|
||||
pub unlinked_projects: Vec<String>,
|
||||
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
@@ -687,3 +716,41 @@ mod tests {
|
||||
assert!(Settings::default().files.file_types.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod maven_linkage_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maven_linkage_defaults_and_round_trips() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(old.unlinked_projects.is_empty());
|
||||
let settings =
|
||||
MavenSettings { unlinked_projects: vec!["pom.xml".into(), "services/api/pom.xml".into()], ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert!(encoded.contains("unlinkedProjects"));
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_and_round_trip() {
|
||||
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
|
||||
assert_eq!(old.gradle, GradleSettings::default());
|
||||
for distribution in [GradleDistribution::Wrapper, GradleDistribution::Local] {
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.gradle = GradleSettings {
|
||||
jvm: "/jdk21".into(),
|
||||
distribution,
|
||||
home: "/gradle".into(),
|
||||
user_home: "/cache/gradle".into(),
|
||||
};
|
||||
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
|
||||
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Where the IDE looks up the executables it runs: language servers, the tools
|
||||
//! their settings come from, installers. An app started from the desktop gets a
|
||||
//! minimal `PATH`, and version managers set theirs up in shell rc files.
|
||||
|
||||
use crate::process::{host_var_os, login_env};
|
||||
use crate::version_managers;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Where executables are looked up: this process's `PATH` (the login shell's in the
|
||||
/// app), the login shell's `PATH` as last read, then per-user directories and mise's,
|
||||
/// asdf's and pyenv's shims, for when the shell's setup didn't reach the app. Language server
|
||||
/// launchers, their settings hooks and tool detection all look here, so a settings
|
||||
/// hook finds the executable its server is launched from.
|
||||
pub fn search_path() -> OsString {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
|
||||
let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone()));
|
||||
let shims: Vec<PathBuf> = version_managers::managers().iter().map(|m| m.shims()).collect();
|
||||
let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_path.as_deref(), home.as_deref(), &shims);
|
||||
std::env::join_paths(dirs).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolves a command on the shared path, or an explicit path relative to `root`.
|
||||
pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBuf> {
|
||||
let path = Path::new(command);
|
||||
let explicit = path.parent().is_some_and(|p| !p.as_os_str().is_empty());
|
||||
let pathext = host_var_os("PATHEXT");
|
||||
let names = crate::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(OsStr::to_str));
|
||||
let dirs = if explicit {
|
||||
vec![root.to_path_buf()]
|
||||
} else {
|
||||
std::env::split_paths(search).filter(|p| !p.as_os_str().is_empty()).map(|p| root.join(p)).collect()
|
||||
};
|
||||
dirs.into_iter().find_map(|dir| names.iter().map(|name| dir.join(name)).find(|p| is_executable(p)))
|
||||
}
|
||||
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
let Ok(metadata) = path.metadata() else { return false };
|
||||
if !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
metadata.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
true
|
||||
}
|
||||
|
||||
/// [`search_path`]'s directories, in order and once each. Only absolute ones: a
|
||||
/// relative entry (`.`, `node_modules/.bin`) resolves against the project, which
|
||||
/// could then supply the tools the IDE runs. One that can't be joined (a `:` in a
|
||||
/// directory name) is left out rather than failing the whole path.
|
||||
fn search_dirs(
|
||||
process_path: Option<&OsStr>,
|
||||
shell_path: Option<&OsStr>,
|
||||
home: Option<&Path>,
|
||||
shims: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = process_path.into_iter().chain(shell_path).flat_map(std::env::split_paths).collect();
|
||||
if let Some(home) = home {
|
||||
dirs.extend(USER_DIRS.iter().map(|d| home.join(d)));
|
||||
dirs.extend(nvm_bins(&home.join(".nvm/versions/node")));
|
||||
}
|
||||
// Version managers' shims after real tool directories: a shim exists for a command any
|
||||
// installed version has, and fails when the selected one lacks it. Before the system's,
|
||||
// as the managers' shell setup puts them.
|
||||
dirs.extend(home.map(|h| h.join(".pyenv/shims")));
|
||||
dirs.extend(shims.iter().cloned());
|
||||
dirs.extend(SYSTEM_DIRS.iter().map(PathBuf::from));
|
||||
let mut seen = HashSet::new();
|
||||
dirs.retain(|d| d.is_absolute() && std::env::join_paths([d]).is_ok() && seen.insert(d.clone()));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Tool directories below the home directory.
|
||||
const USER_DIRS: &[&str] = &[
|
||||
".cargo/bin",
|
||||
".local/bin",
|
||||
"go/bin",
|
||||
".bun/bin",
|
||||
".deno/bin",
|
||||
".npm-global/bin",
|
||||
".volta/bin",
|
||||
// Set up in shell rc files: fnm's default Node and pnpm's global bin (Linux, then macOS).
|
||||
".local/share/fnm/aliases/default/bin",
|
||||
"Library/Application Support/fnm/aliases/default/bin",
|
||||
".fnm/aliases/default/bin",
|
||||
".local/share/pnpm/bin",
|
||||
".local/share/pnpm",
|
||||
"Library/pnpm/bin",
|
||||
"Library/pnpm",
|
||||
];
|
||||
|
||||
const SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"];
|
||||
|
||||
/// The `bin` directories of nvm's Node versions in `dir`, newest first.
|
||||
fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut versions: Vec<(Vec<u64>, PathBuf)> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| (version_key(&e.file_name().to_string_lossy()), e.path().join("bin")))
|
||||
.collect();
|
||||
versions.sort();
|
||||
versions.into_iter().rev().map(|(_, bin)| bin).collect()
|
||||
}
|
||||
|
||||
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
|
||||
fn version_key(name: &str) -> Vec<u64> {
|
||||
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn commands_resolve_on_path_and_explicit_paths_under_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let bin = dir.path().join("bin");
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
let name = if cfg!(windows) { "ngserver.cmd" } else { "ngserver" };
|
||||
std::fs::write(bin.join(name), "").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let search = std::env::join_paths([&bin]).unwrap();
|
||||
assert_eq!(find_command("ngserver", &search, dir.path()), Some(bin.join(name)));
|
||||
assert_eq!(find_command("bin/ngserver", &search, dir.path()), Some(bin.join(name)));
|
||||
assert_eq!(find_command(bin.join(name).to_str().unwrap(), &search, dir.path()), Some(bin.join(name)));
|
||||
assert_eq!(find_command("absent", &search, dir.path()), None);
|
||||
for command in ["bin/ngserver", bin.join(name).to_str().unwrap()] {
|
||||
assert_eq!(find_command(command, OsStr::new(""), dir.path()), Some(bin.join(name)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_executable_files_do_not_shadow_commands() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dirs = [tmp.path().join("first"), tmp.path().join("second")];
|
||||
for (dir, mode) in dirs.iter().zip([0o644, 0o755]) {
|
||||
std::fs::create_dir(dir).unwrap();
|
||||
let file = dir.join("ngserver");
|
||||
std::fs::write(&file, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(file, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
}
|
||||
let path = std::env::join_paths(&dirs).unwrap();
|
||||
assert_eq!(find_command("ngserver", &path, tmp.path()), Some(dirs[1].join("ngserver")));
|
||||
assert_eq!(find_command("first/ngserver", &path, tmp.path()), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn search_dirs_put_the_process_path_first_and_shims_after_real_tools() {
|
||||
let shims = [PathBuf::from("/home/me/.local/share/mise/shims"), "/home/me/.asdf/shims".into()];
|
||||
let dirs = search_dirs(
|
||||
Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")),
|
||||
// nushell prints `$PATH` as is.
|
||||
Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")),
|
||||
Some(Path::new("/home/me")),
|
||||
&shims,
|
||||
);
|
||||
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
|
||||
assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), "/opt/tools".into(), "/home/me/.asdf/shims".into()]);
|
||||
assert!(at("/home/me/.npm-global/bin") < at("/home/me/.pyenv/shims"));
|
||||
assert!(at("/home/me/.local/share/mise/shims") < at("/usr/local/bin"));
|
||||
assert!(dirs.iter().all(|d| d.is_absolute()));
|
||||
let unique: HashSet<&PathBuf> = dirs.iter().collect();
|
||||
assert_eq!(unique.len(), dirs.len());
|
||||
// A `:` can't be in a `PATH` entry: those below such a home are left out.
|
||||
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, Some(Path::new("/home/a:b")), &[]);
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvm_versions_newest_first() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id()));
|
||||
for v in ["v9.11.2", "v20.9.0", "v22.1.0", "v20.10.0"] {
|
||||
std::fs::create_dir_all(dir.join(v).join("bin")).unwrap();
|
||||
}
|
||||
let bins = nvm_bins(&dir);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let names: Vec<_> = bins.iter().map(|b| b.parent().unwrap().file_name().unwrap().to_owned()).collect();
|
||||
assert_eq!(names, ["v22.1.0", "v20.10.0", "v20.9.0", "v9.11.2"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Version managers that keep one install per tool version (mise, asdf): where their
|
||||
//! installs and shims live. SDK detection lists the installs; child processes get the
|
||||
//! shims on `PATH` when the manager's shell hook didn't run (an app started from the Dock,
|
||||
//! `mise activate` only in an interactive rc file).
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const MISE: &str = "mise";
|
||||
pub const ASDF: &str = "asdf";
|
||||
|
||||
/// A toolchain the version managers install.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Java,
|
||||
Node,
|
||||
Python,
|
||||
Go,
|
||||
Maven,
|
||||
Gradle,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// The tool's names in mise and asdf plugins (`nodejs`, `golang` are asdf's).
|
||||
pub fn names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Tool::Java => &["java"],
|
||||
Tool::Node => &["node", "nodejs"],
|
||||
Tool::Python => &["python"],
|
||||
Tool::Go => &["go", "golang"],
|
||||
Tool::Maven => &["maven"],
|
||||
Tool::Gradle => &["gradle"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One version manager's data directory (`installs/`, `shims/`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Manager {
|
||||
pub name: &'static str,
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Manager {
|
||||
pub fn shims(&self) -> PathBuf {
|
||||
self.dir.join("shims")
|
||||
}
|
||||
|
||||
/// `installs/<tool>/<version>` directories of `tool`, sorted. The alias links mise
|
||||
/// adds next to an install (`21 -> ./21.0.2`, `latest`) are left out; a link to an
|
||||
/// install elsewhere (`mise link`) is kept.
|
||||
pub fn installs(&self, tool: Tool) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for name in tool.names() {
|
||||
let Ok(entries) = std::fs::read_dir(self.dir.join("installs").join(name)) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
let alias = e.file_type().is_ok_and(|t| t.is_symlink())
|
||||
&& std::fs::read_link(&path).is_ok_and(|t| t.is_relative() && t.components().count() <= 2);
|
||||
if !alias && path.is_dir() {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The install of `tool` named `version` exactly (alias links included).
|
||||
pub fn install_named(&self, tool: Tool, version: &str) -> Option<PathBuf> {
|
||||
if version.is_empty() || version.contains(['/', '\\']) || version.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
tool.names().iter().map(|n| self.dir.join("installs").join(n).join(version)).find(|p| p.is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
/// mise's and asdf's data directories that exist, as this process's environment (and the
|
||||
/// login shell's) set them.
|
||||
pub fn managers() -> Vec<Manager> {
|
||||
let lookup = |k: &str| {
|
||||
crate::process::host_var_os(k)
|
||||
.or_else(|| crate::process::login_env()?.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()))
|
||||
};
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
|
||||
managers_with(&lookup, home.as_deref()).into_iter().filter(|m| m.dir.is_dir()).collect()
|
||||
}
|
||||
|
||||
/// mise: `MISE_DATA_DIR`, else `$XDG_DATA_HOME/mise`, else `~/.local/share/mise`
|
||||
/// (`%LOCALAPPDATA%\mise` on Windows). asdf: `ASDF_DATA_DIR`, else `~/.asdf`.
|
||||
pub fn managers_with(env: &dyn Fn(&str) -> Option<OsString>, home: Option<&Path>) -> Vec<Manager> {
|
||||
let var = |k: &str| env(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let mise = var("MISE_DATA_DIR")
|
||||
.or_else(|| var("XDG_DATA_HOME").map(|d| d.join("mise")))
|
||||
.or_else(|| if cfg!(windows) { var("LOCALAPPDATA").map(|d| d.join("mise")) } else { None })
|
||||
.or_else(|| home.map(|h| h.join(".local/share/mise")));
|
||||
let asdf = var("ASDF_DATA_DIR").or_else(|| home.map(|h| h.join(".asdf")));
|
||||
let mut out = Vec::new();
|
||||
out.extend(mise.map(|dir| Manager { name: MISE, dir }));
|
||||
out.extend(asdf.map(|dir| Manager { name: ASDF, dir }));
|
||||
out
|
||||
}
|
||||
|
||||
/// Every install of `tool` with the manager it came from, mise's first.
|
||||
pub fn installs(tool: Tool) -> Vec<(PathBuf, &'static str)> {
|
||||
managers().iter().flat_map(|m| m.installs(tool).into_iter().map(|p| (p, m.name))).collect()
|
||||
}
|
||||
|
||||
/// The manager whose installs `path` lies in.
|
||||
pub fn manager_of(path: &Path) -> Option<&'static str> {
|
||||
manager_in(&managers(), path)
|
||||
}
|
||||
|
||||
pub fn manager_in(managers: &[Manager], path: &Path) -> Option<&'static str> {
|
||||
managers.iter().find_map(|m| {
|
||||
let installs = m.dir.join("installs");
|
||||
let real = dunce::canonicalize(&installs).ok();
|
||||
(path.starts_with(&installs) || real.is_some_and(|r| path.starts_with(r))).then_some(m.name)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `path` is a version manager's shim (it picks the version by the working directory).
|
||||
pub fn is_shim(path: &Path) -> bool {
|
||||
path.parent().is_some_and(is_shims_dir)
|
||||
}
|
||||
|
||||
/// Whether `dir` is a version manager's `shims` folder.
|
||||
pub fn is_shims_dir(dir: &Path) -> bool {
|
||||
managers().iter().any(|m| m.shims() == dir)
|
||||
}
|
||||
|
||||
/// `path` (a `PATH` value) with the shims of a manager it doesn't reach yet: mise's in
|
||||
/// front (a mise shim runs the next tool on `PATH` when no version is set), asdf's at the
|
||||
/// end (an asdf shim fails then). A `PATH` that has a manager's shims or installs already
|
||||
/// (its shell hook ran) is left as it is for that manager.
|
||||
pub fn path_with_shims(managers: &[Manager], path: &OsStr) -> OsString {
|
||||
let mut dirs: Vec<PathBuf> = if path.is_empty() { Vec::new() } else { std::env::split_paths(path).collect() };
|
||||
let before = dirs.len();
|
||||
for m in managers {
|
||||
let shims = m.shims();
|
||||
let installs = m.dir.join("installs");
|
||||
if !shims.is_dir() || dirs.iter().any(|d| d == &shims || d.starts_with(&installs)) {
|
||||
continue;
|
||||
}
|
||||
if m.name == MISE {
|
||||
dirs.insert(0, shims);
|
||||
} else {
|
||||
dirs.push(shims);
|
||||
}
|
||||
}
|
||||
if dirs.len() == before {
|
||||
return path.to_owned();
|
||||
}
|
||||
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn mkdir(p: &Path) {
|
||||
std::fs::create_dir_all(p).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dirs_follow_the_environment() {
|
||||
let home = Path::new("/home/me");
|
||||
let none = |_: &str| None;
|
||||
let dirs: Vec<_> = managers_with(&none, Some(home)).into_iter().map(|m| (m.name, m.dir)).collect();
|
||||
if !cfg!(windows) {
|
||||
assert_eq!(dirs[0], (MISE, home.join(".local/share/mise")));
|
||||
}
|
||||
assert_eq!(dirs[1], (ASDF, home.join(".asdf")));
|
||||
|
||||
let xdg = |k: &str| (k == "XDG_DATA_HOME").then(|| OsString::from("/xdg"));
|
||||
assert_eq!(managers_with(&xdg, Some(home))[0].dir, Path::new("/xdg/mise"));
|
||||
|
||||
let custom = |k: &str| match k {
|
||||
"MISE_DATA_DIR" => Some(OsString::from("/opt/mise")),
|
||||
"XDG_DATA_HOME" => Some(OsString::from("/xdg")),
|
||||
"ASDF_DATA_DIR" => Some(OsString::from("/opt/asdf")),
|
||||
_ => None,
|
||||
};
|
||||
let dirs: Vec<_> = managers_with(&custom, Some(home)).into_iter().map(|m| m.dir).collect();
|
||||
assert_eq!(dirs, [PathBuf::from("/opt/mise"), PathBuf::from("/opt/asdf")]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn installs_skip_alias_links() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
|
||||
let node = mise.dir.join("installs/node");
|
||||
mkdir(&node.join("20.11.0"));
|
||||
mkdir(&node.join("22.1.0"));
|
||||
std::os::unix::fs::symlink("./22.1.0", node.join("22")).unwrap();
|
||||
std::os::unix::fs::symlink("./22.1.0", node.join("latest")).unwrap();
|
||||
let elsewhere = tmp.path().join("opt-node");
|
||||
mkdir(&elsewhere);
|
||||
std::os::unix::fs::symlink(&elsewhere, node.join("linked")).unwrap();
|
||||
std::fs::write(node.join(".mise.backend.toml"), "").unwrap();
|
||||
|
||||
let names: Vec<_> =
|
||||
mise.installs(Tool::Node).iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(names, ["20.11.0", "22.1.0", "linked"]);
|
||||
assert_eq!(mise.install_named(Tool::Node, "22"), Some(node.join("22")));
|
||||
assert_eq!(mise.install_named(Tool::Node, "18"), None);
|
||||
assert_eq!(mise.install_named(Tool::Node, "../node"), None);
|
||||
|
||||
// asdf's plugin names.
|
||||
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
|
||||
mkdir(&asdf.dir.join("installs/golang/1.22.1"));
|
||||
assert_eq!(asdf.installs(Tool::Go), [asdf.dir.join("installs/golang/1.22.1")]);
|
||||
assert_eq!(manager_in(&[mise.clone(), asdf.clone()], &node.join("20.11.0/bin/node")), Some(MISE));
|
||||
assert_eq!(manager_in(&[mise, asdf], tmp.path()), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shims_join_a_path_the_hook_did_not_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mise = Manager { name: MISE, dir: tmp.path().join("mise") };
|
||||
let asdf = Manager { name: ASDF, dir: tmp.path().join("asdf") };
|
||||
let managers = [mise.clone(), asdf.clone()];
|
||||
let path = OsString::from("/usr/bin:/bin");
|
||||
assert_eq!(path_with_shims(&managers, &path), path, "no shims installed");
|
||||
|
||||
mkdir(&mise.shims());
|
||||
mkdir(&asdf.shims());
|
||||
let got = path_with_shims(&managers, &path);
|
||||
let want = format!("{}:/usr/bin:/bin:{}", mise.shims().display(), asdf.shims().display());
|
||||
assert_eq!(got, OsString::from(want));
|
||||
assert_eq!(path_with_shims(&managers, &got), got, "applied once");
|
||||
|
||||
// `mise activate` put an install on PATH: mise is active, only asdf's shims join.
|
||||
let active = format!("{}:/usr/bin", mise.dir.join("installs/node/22.1.0/bin").display());
|
||||
let got = path_with_shims(&managers, OsStr::new(&active));
|
||||
assert_eq!(got, OsString::from(format!("{active}:{}", asdf.shims().display())));
|
||||
}
|
||||
}
|
||||
@@ -213,8 +213,12 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
.output()
|
||||
.map_err(|e| IdeError::unavailable(format!("cannot run {program}: {e}")))?;
|
||||
if !out.status.success() {
|
||||
let text = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||
let text = if text.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_owned() } else { text };
|
||||
let text = ide_core::output_encoding::decode_tool_output(&out.stderr).trim().to_owned();
|
||||
let text = if text.is_empty() {
|
||||
ide_core::output_encoding::decode_tool_output(&out.stdout).trim().to_owned()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
return Err(IdeError::not_found(format!("exporting the coverage data failed: {text}")));
|
||||
}
|
||||
let text = read_report(lcov)?;
|
||||
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tempfile.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
|
||||
@@ -63,6 +63,12 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"tcp",
|
||||
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
|
||||
),
|
||||
"kotlin-debug-adapter" => cfg(
|
||||
"kotlin-debug-adapter",
|
||||
&[],
|
||||
"stdio",
|
||||
"Install the Kotlin Debug Adapter from Settings > Language Servers",
|
||||
),
|
||||
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
|
||||
"js-debug" => cfg(
|
||||
"js-debug-adapter",
|
||||
|
||||
@@ -70,8 +70,10 @@ pub async fn build(
|
||||
let on_output = std::sync::Arc::new(on_output);
|
||||
let err_out = on_output.clone();
|
||||
let err_task = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let mut lines = BufReader::new(stderr).split(b'\n');
|
||||
while let Ok(Some(line)) = lines.next_segment().await {
|
||||
let line = ide_core::output_encoding::decode_tool_output(&line);
|
||||
let line = line.trim_end_matches('\r');
|
||||
err_out(format!("{line}\n"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Package-aligned sources for fwcd's path-based class resolver. Never write into a project.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub const ADAPTER: &str = "kotlin-debug-adapter";
|
||||
|
||||
pub struct SourceMirror {
|
||||
dir: tempfile::TempDir,
|
||||
paths: HashMap<PathBuf, PathBuf>,
|
||||
copies: Vec<(PathBuf, PathBuf)>,
|
||||
source_dir: PathBuf,
|
||||
roots: HashMap<PathBuf, usize>,
|
||||
}
|
||||
|
||||
impl SourceMirror {
|
||||
pub fn create(project: &Path) -> IdeResult<Self> {
|
||||
let base = fs::canonicalize(std::env::temp_dir())?;
|
||||
// Check before creating anything, including when TMPDIR points into a project.
|
||||
if base.starts_with(fs::canonicalize(project)?) {
|
||||
return Err(IdeError::invalid("Kotlin source mirror must be outside the project"));
|
||||
}
|
||||
let dir = tempfile::Builder::new().prefix("openidea-kotlin-").tempdir_in(base)?;
|
||||
let source_dir = if project.join("src").is_dir() { project.join("src") } else { project.to_owned() };
|
||||
let mut mirror = Self {
|
||||
dir,
|
||||
paths: HashMap::new(),
|
||||
copies: Vec::new(),
|
||||
source_dir: source_dir.clone(),
|
||||
roots: HashMap::new(),
|
||||
};
|
||||
mirror.scan(&source_dir)?;
|
||||
Ok(mirror)
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
self.dir.path()
|
||||
}
|
||||
|
||||
fn scan(&mut self, dir: &Path) -> IdeResult<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_dir() {
|
||||
if !matches!(
|
||||
entry.file_name().to_str(),
|
||||
Some(".git" | ".gradle" | "build" | "target" | "node_modules" | ".idea" | ".openidea")
|
||||
) {
|
||||
self.scan(&path)?;
|
||||
}
|
||||
} else if matches!(path.extension().and_then(|s| s.to_str()), Some("kt" | "java")) {
|
||||
self.add(&path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add(&mut self, real: &Path) -> IdeResult<()> {
|
||||
if self.paths.contains_key(real) {
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(text) = fs::read_to_string(real) else {
|
||||
tracing::debug!("Kotlin debug mirror: skipping unreadable {}", real.display());
|
||||
return Ok(());
|
||||
};
|
||||
let language = if real.extension().is_some_and(|e| e == "java") { "java" } else { "kotlin" };
|
||||
let relative = real.strip_prefix(&self.source_dir).unwrap_or(real);
|
||||
let mut components = relative.components();
|
||||
let source_set = components.next().map(|c| PathBuf::from(c.as_os_str())).unwrap_or_default();
|
||||
let next_root = self.roots.len();
|
||||
let index = *self.roots.entry(source_set).or_insert(next_root);
|
||||
let mut mirror = self.root().join(format!("root{index}")).join("src/main").join(language);
|
||||
for part in package(&text).split('.').filter(|s| !s.is_empty()) {
|
||||
if part == ".." || part.contains(['/', '\\']) {
|
||||
tracing::debug!("Kotlin debug mirror: skipping {} (invalid package)", real.display());
|
||||
return Ok(());
|
||||
}
|
||||
mirror.push(part);
|
||||
}
|
||||
mirror.push(real.file_name().ok_or_else(|| IdeError::invalid("Source has no filename"))?);
|
||||
// Same package and file name in one source set (e.g. main and test of a module):
|
||||
// the first wins; breakpoints in the other stay unverified instead of failing the session.
|
||||
if self.paths.values().any(|p| p == &mirror) {
|
||||
tracing::debug!("Kotlin debug mirror: skipping ambiguous {}", real.display());
|
||||
return Ok(());
|
||||
}
|
||||
fs::create_dir_all(mirror.parent().unwrap())?;
|
||||
if link(real, &mirror).is_err() {
|
||||
fs::copy(real, &mirror)?;
|
||||
self.copies.push((real.to_owned(), mirror.clone()));
|
||||
}
|
||||
self.paths.insert(real.to_owned(), mirror);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn adapter_path(&self, real: &Path) -> Option<PathBuf> {
|
||||
self.paths.get(real).cloned()
|
||||
}
|
||||
pub fn client_path(&self, mirror: &Path) -> Option<PathBuf> {
|
||||
self.paths.iter().find_map(|(real, p)| (p == mirror).then(|| real.clone()))
|
||||
}
|
||||
pub fn refresh(&mut self) -> IdeResult<()> {
|
||||
for (real, mirror) in &self.copies {
|
||||
if real.is_file() {
|
||||
fs::copy(real, mirror)?;
|
||||
}
|
||||
}
|
||||
self.scan(&self.source_dir.clone())
|
||||
}
|
||||
pub fn translate(&self, value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(obj) => {
|
||||
if let Some(path) = obj.get("path").and_then(Value::as_str).and_then(|p| self.client_path(Path::new(p)))
|
||||
{
|
||||
obj.insert("path".into(), Value::String(path.to_string_lossy().into_owned()));
|
||||
}
|
||||
for child in obj.values_mut() {
|
||||
self.translate(child);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for child in items {
|
||||
self.translate(child);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
|
||||
std::os::unix::fs::symlink(real, mirror)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn link(real: &Path, mirror: &Path) -> std::io::Result<()> {
|
||||
std::os::windows::fs::symlink_file(real, mirror)
|
||||
}
|
||||
|
||||
// Remove comments before reading the package directive (including nested Kotlin block comments).
|
||||
fn package(text: &str) -> String {
|
||||
let mut clean = String::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut depth = 0;
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
depth += 1;
|
||||
clean.push(' ');
|
||||
} else if depth > 0 && c == '*' && chars.peek() == Some(&'/') {
|
||||
chars.next();
|
||||
depth -= 1;
|
||||
} else if depth == 0 && c == '/' && chars.peek() == Some(&'/') {
|
||||
for c in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
clean.push('\n');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if depth == 0 {
|
||||
clean.push(c);
|
||||
}
|
||||
}
|
||||
clean
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let rest = line.trim().strip_prefix("package")?;
|
||||
if !rest.starts_with(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
Some(rest.split(';').next().unwrap_or_default().trim().replace('`', ""))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn external_mirror_round_trip_and_cleanup() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let real = project.path().join("Main.kt");
|
||||
fs::write(&real, "/* package wrong */\npackage org.example\nfun main() {}\n").unwrap();
|
||||
let mirror = SourceMirror::create(project.path()).unwrap();
|
||||
let path = mirror.adapter_path(&real).unwrap();
|
||||
assert!(!path.starts_with(project.path()));
|
||||
assert!(path.ends_with("src/main/kotlin/org/example/Main.kt"));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), fs::read_to_string(&real).unwrap());
|
||||
let mut payload =
|
||||
serde_json::json!({"stackFrames": [{"source": {"path": path}, "line": 6}], "source": {"path": path}});
|
||||
mirror.translate(&mut payload);
|
||||
assert_eq!(payload["stackFrames"][0]["source"]["path"], real.to_string_lossy().as_ref());
|
||||
assert_eq!(payload["source"]["path"], real.to_string_lossy().as_ref());
|
||||
let root = mirror.root().to_owned();
|
||||
drop(mirror);
|
||||
assert!(!root.exists());
|
||||
assert!(real.exists());
|
||||
}
|
||||
#[test]
|
||||
fn copies_refresh_after_save() {
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let real = project.path().join("Main.java");
|
||||
fs::write(&real, "package example;\nclass Main {}\n").unwrap();
|
||||
let mut mirror = SourceMirror::create(project.path()).unwrap();
|
||||
let path = mirror.adapter_path(&real).unwrap();
|
||||
fs::remove_file(&path).unwrap();
|
||||
fs::copy(&real, &path).unwrap();
|
||||
mirror.copies.push((real.clone(), path.clone()));
|
||||
fs::write(&real, "package example;\nclass Main { int i; }\n").unwrap();
|
||||
mirror.refresh().unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), fs::read(&real).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Keep a suspended JVM stopped until Kotlin's adapter has installed its listeners.
|
||||
//! The official 0.4.4 adapter resumes VMStart while constructing its debuggee, before
|
||||
//! installing breakpoint hooks. DAP attach completes only after those hooks exist.
|
||||
use ide_core::IdeResult;
|
||||
use std::{io, time::Duration};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::oneshot,
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
pub(crate) struct StartupRelay {
|
||||
release: Option<oneshot::Sender<()>>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl StartupRelay {
|
||||
pub async fn start(host: String, port: u16) -> IdeResult<(Self, u16)> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
let local = listener.local_addr()?.port();
|
||||
let (release, ready) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let connect = async {
|
||||
let (adapter, _) = listener.accept().await?;
|
||||
let jvm = TcpStream::connect((host.as_str(), port)).await?;
|
||||
adapter.set_nodelay(true)?;
|
||||
jvm.set_nodelay(true)?;
|
||||
Ok::<_, io::Error>((adapter, jvm))
|
||||
};
|
||||
if let Ok(Ok((adapter, jvm))) = tokio::time::timeout(Duration::from_secs(60), connect).await {
|
||||
let (mut ar, mut aw) = adapter.into_split();
|
||||
let (mut jr, mut jw) = jvm.into_split();
|
||||
let upstream = forward(&mut ar, &mut jw, ready);
|
||||
let downstream = tokio::io::copy(&mut jr, &mut aw);
|
||||
// Close both sockets as soon as either side disconnects.
|
||||
tokio::select! {
|
||||
r = upstream => if let Err(e) = r { tracing::debug!("Kotlin JDWP relay: {e}"); },
|
||||
_ = downstream => {},
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok((Self { release: Some(release), task }, local))
|
||||
}
|
||||
|
||||
pub fn release(&mut self) {
|
||||
if let Some(ready) = self.release.take() {
|
||||
let _ = ready.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StartupRelay {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn packet(reader: &mut (impl AsyncRead + Unpin)) -> io::Result<Vec<u8>> {
|
||||
let mut header = [0; 11];
|
||||
reader.read_exact(&mut header).await?;
|
||||
let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize;
|
||||
if !(11..=16 * 1024 * 1024).contains(&len) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP packet length"));
|
||||
}
|
||||
let mut packet = vec![0; len];
|
||||
packet[..11].copy_from_slice(&header);
|
||||
reader.read_exact(&mut packet[11..]).await?;
|
||||
Ok(packet)
|
||||
}
|
||||
|
||||
async fn forward(
|
||||
reader: &mut (impl AsyncRead + Unpin),
|
||||
writer: &mut (impl AsyncWrite + Unpin),
|
||||
mut ready: oneshot::Receiver<()>,
|
||||
) -> io::Result<()> {
|
||||
let mut handshake = [0; 14];
|
||||
reader.read_exact(&mut handshake).await?;
|
||||
if &handshake != b"JDWP-Handshake" {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid JDWP handshake"));
|
||||
}
|
||||
writer.write_all(&handshake).await?;
|
||||
let mut released = false;
|
||||
let mut held: Vec<Vec<u8>> = Vec::new();
|
||||
loop {
|
||||
// Retain the in-flight read when attach completes, including partial packets.
|
||||
let read = packet(reader);
|
||||
tokio::pin!(read);
|
||||
let packet = tokio::select! {
|
||||
result = &mut read => result?,
|
||||
result = &mut ready, if !released => {
|
||||
result.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "Kotlin attach cancelled"))?;
|
||||
released = true;
|
||||
for packet in held.drain(..) {
|
||||
writer.write_all(&packet).await?;
|
||||
}
|
||||
read.await?
|
||||
}
|
||||
};
|
||||
// JDWP VirtualMachine.Resume (1/9), ThreadReference.Resume (11/3).
|
||||
// Other commands must proceed: attach itself queries threads and installs requests.
|
||||
if !released && packet[8] == 0 && matches!((packet[9], packet[10]), (1, 9) | (11, 3)) {
|
||||
if held.len() >= 1024 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "Too many pending JDWP resumes"));
|
||||
}
|
||||
held.push(packet);
|
||||
} else {
|
||||
writer.write_all(&packet).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn command(id: u8, set: u8, cmd: u8) -> Vec<u8> {
|
||||
vec![0, 0, 0, 11, 0, 0, 0, id, 0, set, cmd]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn holds_resumes_but_allows_setup_and_preserves_partial_reads() {
|
||||
let (mut adapter, mut reader) = tokio::io::duplex(4096);
|
||||
let (mut writer, mut jvm) = tokio::io::duplex(4096);
|
||||
let (release, ready) = oneshot::channel();
|
||||
let task = tokio::spawn(async move { forward(&mut reader, &mut writer, ready).await });
|
||||
adapter.write_all(b"JDWP-Handshake").await.unwrap();
|
||||
let mut handshake = [0; 14];
|
||||
jvm.read_exact(&mut handshake).await.unwrap();
|
||||
assert_eq!(&handshake, b"JDWP-Handshake");
|
||||
let resume = command(1, 1, 9);
|
||||
let thread_resume = command(2, 11, 3);
|
||||
let setup = command(3, 1, 4);
|
||||
adapter.write_all(&resume).await.unwrap();
|
||||
adapter.write_all(&thread_resume).await.unwrap();
|
||||
adapter.write_all(&setup).await.unwrap();
|
||||
assert_eq!(packet(&mut jvm).await.unwrap(), setup);
|
||||
let next = command(4, 1, 9);
|
||||
adapter.write_all(&next[..6]).await.unwrap();
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(packet(&mut jvm).await.unwrap(), resume);
|
||||
assert_eq!(packet(&mut jvm).await.unwrap(), thread_resume);
|
||||
adapter.write_all(&next[6..]).await.unwrap();
|
||||
assert_eq!(packet(&mut jvm).await.unwrap(), next);
|
||||
drop(adapter);
|
||||
assert!(task.await.unwrap().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_session_closes_relay() {
|
||||
let (relay, port) = StartupRelay::start("127.0.0.1".into(), 1).await.unwrap();
|
||||
let mut adapter = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
drop(relay);
|
||||
let result = tokio::time::timeout(Duration::from_secs(1), adapter.read_u8()).await.unwrap();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_packet_lengths() {
|
||||
for len in [0_u32, 10, 16 * 1024 * 1024 + 1] {
|
||||
let mut bytes = command(1, 1, 9);
|
||||
bytes[..4].copy_from_slice(&len.to_be_bytes());
|
||||
assert_eq!(packet(&mut bytes.as_slice()).await.unwrap_err().kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
pub mod kotlin;
|
||||
mod kotlin_jdwp;
|
||||
mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
@@ -563,6 +565,14 @@ impl DebugManager {
|
||||
let dbg = adapter::find_config(&self.languages, &config.adapter)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
|
||||
let mut spec = adapter::resolve(&dbg, cwd.clone());
|
||||
if config.adapter == kotlin::ADAPTER
|
||||
&& let Some(home) = arguments
|
||||
.as_object_mut()
|
||||
.and_then(|o| o.remove("__adapterJavaHome"))
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
@@ -722,7 +732,19 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn configure(&self, s: &Arc<Session>, arguments: Value) -> IdeResult<()> {
|
||||
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
let project = arguments
|
||||
.get("projectRoot")
|
||||
.and_then(Value::as_str)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| s.spec.cwd.clone());
|
||||
let mirror = tokio::task::spawn_blocking(move || kotlin::SourceMirror::create(&project))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
arguments["projectRoot"] = json!(mirror.root());
|
||||
*s.kotlin_mirror.lock() = Some(mirror);
|
||||
}
|
||||
let caps = s
|
||||
.conn
|
||||
.request_timeout(
|
||||
@@ -749,8 +771,32 @@ impl DebugManager {
|
||||
.await?;
|
||||
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
|
||||
|
||||
// The Kotlin adapter ignores the resolved classpath and project runtime on launch.
|
||||
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
|
||||
session::start_kotlin_debuggee(s, &mut arguments).await?;
|
||||
"attach"
|
||||
} else {
|
||||
&s.request
|
||||
};
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
let host = arguments
|
||||
.get("hostName")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| IdeError::invalid("Missing Kotlin JDWP host"))?
|
||||
.to_owned();
|
||||
let port = arguments
|
||||
.get("port")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|p| u16::try_from(p).ok())
|
||||
.filter(|p| *p != 0)
|
||||
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
|
||||
let (relay, port) = kotlin_jdwp::StartupRelay::start(host, port).await?;
|
||||
*s.kotlin_relay.lock() = Some(relay);
|
||||
arguments["hostName"] = json!("127.0.0.1");
|
||||
arguments["port"] = json!(port);
|
||||
}
|
||||
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
|
||||
let launch = s.conn.send_request(&s.request, arguments)?;
|
||||
let launch = s.conn.send_request(request, arguments)?;
|
||||
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
|
||||
let mut initialized = s.initialized.subscribe();
|
||||
let mut launch_result = None;
|
||||
@@ -780,6 +826,7 @@ impl DebugManager {
|
||||
s.emit(ide_core::event::DebugEvent::BreakpointsChanged { path: path.into() });
|
||||
}
|
||||
}
|
||||
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
|
||||
Err(e) => tracing::debug!("setBreakpoints for {} failed: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
@@ -802,7 +849,11 @@ impl DebugManager {
|
||||
let _ = self.send_exception_breakpoints(s).await;
|
||||
}
|
||||
if s.supports("supportsConfigurationDoneRequest") {
|
||||
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
drop(s.conn.send_request("configurationDone", json!({}))?);
|
||||
} else {
|
||||
s.conn.request_timeout("configurationDone", Value::Null, REQUEST_TIMEOUT).await?;
|
||||
}
|
||||
}
|
||||
s.configured.store(true, Ordering::Release);
|
||||
if launch_result.is_none() {
|
||||
@@ -813,6 +864,9 @@ impl DebugManager {
|
||||
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
}
|
||||
}
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
relay.release();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -869,6 +923,7 @@ impl DebugManager {
|
||||
Ok(states) => {
|
||||
result.get_or_insert(states);
|
||||
}
|
||||
Err(e) if s.adapter == kotlin::ADAPTER => return Err(e.into()),
|
||||
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
|
||||
}
|
||||
}
|
||||
@@ -886,6 +941,23 @@ impl DebugManager {
|
||||
Ok(states)
|
||||
}
|
||||
|
||||
/// Refresh fallback copies after an editor save.
|
||||
pub async fn refresh_source_mirrors(&self, owner: &str) -> IdeResult<()> {
|
||||
let sessions: Vec<_> = self.sessions.lock().values().filter(|s| s.owner.key == owner).cloned().collect();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
for s in sessions {
|
||||
if let Some(mirror) = s.kotlin_mirror.lock().as_mut()
|
||||
&& let Err(e) = mirror.refresh()
|
||||
{
|
||||
tracing::warn!("Could not refresh Kotlin debug sources: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Last known verification states of a file's breakpoints (after `BreakpointsChanged`).
|
||||
pub fn breakpoint_states(&self, path: &Path) -> Vec<BreakpointState> {
|
||||
self.states.lock().get(path).cloned().unwrap_or_default()
|
||||
|
||||
@@ -54,6 +54,8 @@ pub(crate) struct Session {
|
||||
/// Resolved path → the path as the IDE knows it, for adapters given resolved paths.
|
||||
resolved_paths: Mutex<HashMap<PathBuf, PathBuf>>,
|
||||
progress: Mutex<HashMap<String, TaskId>>,
|
||||
pub kotlin_relay: Mutex<Option<crate::kotlin_jdwp::StartupRelay>>,
|
||||
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
|
||||
}
|
||||
|
||||
/// Adapters that only match files by their resolved path: Godot maps a script to
|
||||
@@ -95,6 +97,8 @@ impl Session {
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
kotlin_relay: Mutex::new(None),
|
||||
kotlin_mirror: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +108,14 @@ impl Session {
|
||||
|
||||
/// `path` as sent to the adapter (resolved for [`RESOLVES_PATHS`] adapters).
|
||||
pub fn adapter_path(&self, path: &Path) -> PathBuf {
|
||||
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
|
||||
return path;
|
||||
}
|
||||
if self.adapter == "java-debug" {
|
||||
let native = java_source_path(&path.to_string_lossy());
|
||||
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
|
||||
return native;
|
||||
}
|
||||
if !RESOLVES_PATHS.contains(&self.adapter.as_str()) {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
@@ -118,7 +130,14 @@ impl Session {
|
||||
|
||||
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
|
||||
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
|
||||
self.resolved_paths.lock().get(Path::new(path)).cloned()
|
||||
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
|
||||
return Some(real);
|
||||
}
|
||||
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
|
||||
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
|
||||
(self.adapter == "java-debug" && is_windows_source_path(path))
|
||||
.then(|| PathBuf::from(path.replace('\\', "/")))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn supports(&self, cap: &str) -> bool {
|
||||
@@ -236,6 +255,7 @@ impl Session {
|
||||
|
||||
/// Kills the adapter and every debuggee we started.
|
||||
pub fn kill_processes(&self) {
|
||||
self.kotlin_relay.lock().take();
|
||||
if let Some(mut c) = self.adapter_process.lock().take() {
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
@@ -245,6 +265,19 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
// java-debug treats a forward-slash drive path as a URI (scheme "C").
|
||||
// Keep DAP path mode, but use native Windows separators for Java source lookup.
|
||||
fn is_windows_source_path(path: &str) -> bool {
|
||||
let bytes = path.as_bytes();
|
||||
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|
||||
|| path.starts_with("//")
|
||||
|| path.starts_with(r"\\")
|
||||
}
|
||||
|
||||
fn java_source_path(path: &str) -> PathBuf {
|
||||
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
|
||||
}
|
||||
|
||||
fn with_conditions(v: &mut Value, condition: Option<&str>, hit_condition: Option<&str>) {
|
||||
if let Some(c) = condition.filter(|c| !c.trim().is_empty()) {
|
||||
v["condition"] = c.into();
|
||||
@@ -297,7 +330,10 @@ pub(crate) async fn event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, body: Value) {
|
||||
async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut body: Value) {
|
||||
if let Some(mirror) = s.kotlin_mirror.lock().as_ref() {
|
||||
mirror.translate(&mut body);
|
||||
}
|
||||
let int = |k: &str| body.get(k).and_then(Value::as_i64);
|
||||
let str_ = |k: &str| body.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
match event {
|
||||
@@ -486,3 +522,74 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
|
||||
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
let port = crate::adapter::free_port()?;
|
||||
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
|
||||
let strings = |key: &str| -> Vec<String> {
|
||||
args.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let classpath =
|
||||
std::env::join_paths(strings("classPaths")).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
|
||||
let mut cmd = tokio::process::Command::new(java);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(strings("vmArguments"))
|
||||
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
|
||||
.arg("-cp")
|
||||
.arg(classpath);
|
||||
let modules = strings("modulePaths");
|
||||
if !modules.is_empty() {
|
||||
cmd.arg("--module-path")
|
||||
.arg(std::env::join_paths(modules).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?);
|
||||
}
|
||||
cmd.arg(
|
||||
args.get("mainClass")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("Missing Kotlin main class"))?,
|
||||
)
|
||||
.args(strings("programArgs"))
|
||||
.current_dir(&s.spec.cwd)
|
||||
.envs(ide_core::process::shell_env())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(env) = args.get("env").and_then(Value::as_object) {
|
||||
for (k, v) in env {
|
||||
if let Some(v) = v.as_str() {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut child = cmd.spawn()?;
|
||||
if let Some(err) = child.stderr.take() {
|
||||
forward_output(s, err, "stderr");
|
||||
}
|
||||
let mut stdout =
|
||||
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if stdout.read_line(&mut line).await? == 0 {
|
||||
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
|
||||
}
|
||||
if line.contains("Listening for transport dt_socket") {
|
||||
return Ok(());
|
||||
}
|
||||
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ide_core::IdeError::unavailable("JVM did not start its debug listener"))??;
|
||||
forward_output(s, stdout, "stdout");
|
||||
s.debuggees.lock().push(child);
|
||||
args["hostName"] = json!("127.0.0.1");
|
||||
args["port"] = json!(port);
|
||||
args["timeout"] = json!(10000);
|
||||
Ok(())
|
||||
}
|
||||
+127
-3
@@ -15,6 +15,8 @@ struct Script {
|
||||
run_in_terminal: Option<Vec<String>>,
|
||||
/// Send a `startDebugging` reverse request (child session) when a non-child `launch` arrives.
|
||||
start_debugging: bool,
|
||||
ignore_configuration_done: bool,
|
||||
source_path: Option<String>,
|
||||
}
|
||||
|
||||
struct FakeLauncher {
|
||||
@@ -130,7 +132,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
|
||||
}
|
||||
"configurationDone" => {
|
||||
ok!(msg, Value::Null);
|
||||
if !script.ignore_configuration_done {
|
||||
ok!(msg, Value::Null);
|
||||
}
|
||||
if let Some(l) = pending_launch.take() {
|
||||
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
|
||||
}
|
||||
@@ -139,14 +143,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
|
||||
);
|
||||
send!(
|
||||
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9}}})
|
||||
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
|
||||
);
|
||||
}
|
||||
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
|
||||
"stackTrace" => ok!(
|
||||
msg,
|
||||
json!({"stackFrames": [
|
||||
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": "/p/a.py"}},
|
||||
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
|
||||
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
|
||||
]})
|
||||
),
|
||||
@@ -893,6 +897,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
block_comment: None,
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
@@ -1015,3 +1020,122 @@ fn root_and_address_placeholders() {
|
||||
assert_eq!(adapter::parse_address("[::1]:6006"), Some(("::1".into(), 6006)));
|
||||
assert_eq!(adapter::parse_address("6006"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kotlin_configures_without_configuration_done_response() {
|
||||
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
|
||||
let project = tempfile::tempdir().unwrap();
|
||||
let path = project.path().join("Main.kt");
|
||||
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
|
||||
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
|
||||
let mut config = config();
|
||||
config.adapter = kotlin::ADAPTER.into();
|
||||
config.request = "attach".into();
|
||||
config.cwd = Some(project.path().to_owned().into());
|
||||
config.extra_json = Some(json!({"projectRoot": project.path(), "hostName": "127.0.0.1", "port": 5005}).to_string());
|
||||
let id = tokio::time::timeout(Duration::from_secs(2), h.start(config)).await.unwrap().unwrap();
|
||||
let root = PathBuf::from(h.request("attach")["arguments"]["projectRoot"].as_str().unwrap());
|
||||
assert!(!root.starts_with(project.path()));
|
||||
let request = h.request("setBreakpoints");
|
||||
assert_eq!(request["arguments"]["source"]["name"], "Main.kt");
|
||||
let mapped = PathBuf::from(request["arguments"]["source"]["path"].as_str().unwrap());
|
||||
assert!(mapped.starts_with(&root));
|
||||
assert!(mapped.is_file());
|
||||
assert_eq!(h.manager.session(id).unwrap().client_path(mapped.to_str().unwrap()), Some(path));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
/// Requires a compiled no-delay reporter fixture (Main.kt, package org.example,
|
||||
/// loop at line 5, println(i) at line 6), and the unmodified official adapter.
|
||||
/// KOTLIN_PROBE_CONFIG is a LaunchConfig JSON file; KOTLIN_PROBE_ADAPTER is its executable.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires official Kotlin adapter and compiled reporter fixture"]
|
||||
async fn kotlin_official_release_ten_no_delay_runs() {
|
||||
let config: LaunchConfig = serde_json::from_slice(
|
||||
&std::fs::read(std::env::var("KOTLIN_PROBE_CONFIG").expect("KOTLIN_PROBE_CONFIG")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let adapter = std::env::var("KOTLIN_PROBE_ADAPTER").expect("KOTLIN_PROBE_ADAPTER");
|
||||
let source = PathBuf::from(config.cwd.clone().unwrap()).join("src/main/kotlin/Main.kt");
|
||||
for run in 1..=10 {
|
||||
let (owner, events) = recording_owner("kotlin-probe");
|
||||
let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new())));
|
||||
let h = Harness { manager, owner, events, log: Arc::default() };
|
||||
h.manager.set_breakpoints(source.clone(), vec![bp(5)]).await.unwrap();
|
||||
let id = h.manager.start_with(config.clone(), h.owner.clone(), |_| Some(adapter.clone())).await.unwrap();
|
||||
let stopped = h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "breakpoint")).await;
|
||||
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
|
||||
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
|
||||
assert_eq!(frames[0].line, 5);
|
||||
assert_eq!(frames[0].path.as_deref(), Some(source.as_path()));
|
||||
let scopes = h.manager.scopes(id, frames[0].id).await.unwrap();
|
||||
let mut variables = Vec::new();
|
||||
for scope in scopes {
|
||||
variables.extend(h.manager.variables(id, scope.variables_reference).await.unwrap());
|
||||
}
|
||||
assert!(variables.iter().any(|v| v.name == "i" && v.value == "0"));
|
||||
h.manager.step(id, thread_id, StepKind::Over).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { reason, .. } if reason == "step")).await;
|
||||
let frames = h.manager.stack_trace(id, thread_id).await.unwrap();
|
||||
assert_eq!(frames[0].line, 4);
|
||||
h.manager.stop(id).await.unwrap();
|
||||
println!("Run {run}/10: breakpoint Main.kt:6, i=0, step Main.kt:5; stopped");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_windows_source_paths_for_launch_and_attach() {
|
||||
for request in ["launch", "attach"] {
|
||||
for (client, native, returned) in [
|
||||
("C:/work/Main.java", r"C:\work\Main.java", r"C:\work\Main.java"),
|
||||
("C:/work/Main.java", r"C:\work\Main.java", "C:/work/Main.java"),
|
||||
(r"c:\work\Main.java", r"c:\work\Main.java", "c:/work/Main.java"),
|
||||
(r"D:/my project\Main.java", r"D:\my project\Main.java", r"D:\my project\Main.java"),
|
||||
("//server/share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
|
||||
(r"\\server\share/Main.java", r"\\server\share\Main.java", "//server/share/Main.java"),
|
||||
("/work/Main.java", "/work/Main.java", "/work/Main.java"),
|
||||
] {
|
||||
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
|
||||
let path = PathBuf::from(client);
|
||||
h.manager.set_breakpoints(path.clone(), vec![bp(4)]).await.unwrap();
|
||||
let mut cfg = config();
|
||||
cfg.adapter = "java-debug".into();
|
||||
cfg.request = request.into();
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
assert_eq!(h.request("initialize")["arguments"]["pathFormat"], "path");
|
||||
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
|
||||
let commands = h.commands();
|
||||
assert!(
|
||||
commands.iter().position(|c| c == "setBreakpoints").unwrap()
|
||||
< commands.iter().position(|c| c == "configurationDone").unwrap()
|
||||
);
|
||||
let frames = h.manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
|
||||
h.wait_event(|e| {
|
||||
matches!(e, DebugEvent::BreakpointsChanged { path: p } if p.as_ref() == path.as_path())
|
||||
&& h.manager.breakpoint_states(&path)[0].message.as_deref() == Some("moved")
|
||||
})
|
||||
.await;
|
||||
assert_eq!(h.manager.breakpoint_states(&path)[0].message.as_deref(), Some("moved"));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
|
||||
for (returned, expected) in [
|
||||
(r"C:\work\Other.java", "C:/work/Other.java"),
|
||||
(r"\\server\share\Other.java", "//server/share/Other.java"),
|
||||
("C:/work/Other.java", "C:/work/Other.java"),
|
||||
("/work/Other.java", "/work/Other.java"),
|
||||
] {
|
||||
let h = harness(Script { source_path: Some(returned.into()), ..Script::default() });
|
||||
let mut cfg = config();
|
||||
cfg.adapter = "java-debug".into();
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
let frames = h.manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_deref(), Some(PathBuf::from(expected).as_path()));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
|
||||
|
||||
+486
-34
@@ -5,6 +5,9 @@
|
||||
//! whatever its type.
|
||||
|
||||
use crate::source::{DataSource, DbDriver, DbSslMode};
|
||||
use bb8::ManageConnection;
|
||||
use bb8::Pool as Bb8Pool;
|
||||
use futures::StreamExt;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -44,10 +47,65 @@ pub struct QueryResult {
|
||||
pub execution_ms: f64,
|
||||
}
|
||||
|
||||
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
|
||||
pub struct MssqlConnectionManager(tiberius::Config);
|
||||
|
||||
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
|
||||
|
||||
pub struct MssqlConnection(Option<MssqlClient>);
|
||||
|
||||
impl std::ops::Deref for MssqlConnection {
|
||||
type Target = MssqlClient;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.0.as_ref().expect("live SQL Server connection")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for MssqlConnection {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.0.as_mut().expect("live SQL Server connection")
|
||||
}
|
||||
}
|
||||
|
||||
impl ManageConnection for MssqlConnectionManager {
|
||||
type Connection = MssqlConnection;
|
||||
type Error = tiberius::error::Error;
|
||||
|
||||
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
|
||||
use tokio_util::compat::TokioAsyncWriteCompatExt;
|
||||
|
||||
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
|
||||
tcp.set_nodelay(true)?;
|
||||
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
|
||||
// Preserve the single redirect supported by the previous pool adapter.
|
||||
Err(tiberius::error::Error::Routing { host, port }) => {
|
||||
let mut config = self.0.clone();
|
||||
config.host(host);
|
||||
config.port(port);
|
||||
let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?;
|
||||
tcp.set_nodelay(true)?;
|
||||
MssqlClient::connect(config, tcp.compat_write()).await?
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
Ok(MssqlConnection(Some(client)))
|
||||
}
|
||||
|
||||
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
||||
conn.simple_query("SELECT 1").await?.into_results().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
|
||||
conn.0.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Pool {
|
||||
Sqlite(SqlitePool),
|
||||
Postgres(PgPool),
|
||||
Mysql(MySqlPool),
|
||||
SqlServer(Bb8Pool<MssqlConnectionManager>),
|
||||
}
|
||||
|
||||
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
|
||||
@@ -57,6 +115,7 @@ macro_rules! with_pool {
|
||||
Pool::Sqlite($p) => $body,
|
||||
Pool::Postgres($p) => $body,
|
||||
Pool::Mysql($p) => $body,
|
||||
Pool::SqlServer(_) => unreachable!(),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -79,6 +138,129 @@ pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mssql_err(e: tiberius::error::Error) -> IdeError {
|
||||
match &e {
|
||||
tiberius::error::Error::Server(d) => {
|
||||
let code = d.code();
|
||||
// Wrong login: SQL Server 18456.
|
||||
let auth = matches!(code, 18452 | 18456 | 18470);
|
||||
IdeError::new(
|
||||
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
|
||||
format!("[{code}] {}", d.message()),
|
||||
)
|
||||
}
|
||||
tiberius::error::Error::Io { .. } | tiberius::error::Error::Tls(_) => {
|
||||
IdeError::new(ErrorKind::Io, e.to_string())
|
||||
}
|
||||
_ => IdeError::new(ErrorKind::Protocol, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mssql_pool_err(e: bb8::RunError<tiberius::error::Error>) -> IdeError {
|
||||
match e {
|
||||
bb8::RunError::User(e) => mssql_err(e),
|
||||
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
|
||||
}
|
||||
}
|
||||
|
||||
const DAYS_1900_01_01: i64 = 693_595;
|
||||
|
||||
/// Formats a proleptic Gregorian date given days since `0001-01-01`.
|
||||
fn format_date(days: i64) -> String {
|
||||
let z = days + 306;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = if m <= 2 { y + 1 } else { y };
|
||||
format!("{year:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
fn format_hms(secs: u64, frac: u64, scale: u8) -> String {
|
||||
let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
|
||||
if scale == 0 || frac == 0 {
|
||||
format!("{h:02}:{m:02}:{s:02}")
|
||||
} else {
|
||||
let frac = format!("{frac:0width$}", width = usize::from(scale));
|
||||
format!("{h:02}:{m:02}:{s:02}.{}", frac.trim_end_matches('0'))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_time(t: tiberius::time::Time) -> String {
|
||||
let scale = t.scale().min(7);
|
||||
let div = 10u64.pow(u32::from(scale));
|
||||
format_hms(t.increments() / div, t.increments() % div, scale)
|
||||
}
|
||||
|
||||
fn format_numeric(n: tiberius::numeric::Numeric) -> String {
|
||||
let scale = u32::from(n.scale());
|
||||
if scale == 0 {
|
||||
return n.value().to_string();
|
||||
}
|
||||
let sign = if n.value() < 0 { "-" } else { "" };
|
||||
let abs = n.value().unsigned_abs();
|
||||
let div = 10u128.pow(scale);
|
||||
format!("{sign}{}.{:0width$}", abs / div, abs % div, width = scale as usize)
|
||||
}
|
||||
|
||||
fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> Option<String> {
|
||||
use tiberius::ColumnData as C;
|
||||
match cell {
|
||||
C::U8(v) => v.map(|v| v.to_string()),
|
||||
C::I16(v) => v.map(|v| v.to_string()),
|
||||
C::I32(v) => v.map(|v| v.to_string()),
|
||||
C::I64(v) => v.map(|v| v.to_string()),
|
||||
C::F32(v) => v.map(|v| v.to_string()),
|
||||
C::F64(v) if matches!(ty, tiberius::ColumnType::Money | tiberius::ColumnType::Money4) => {
|
||||
// Beyond ~9e11, money precision is limited by the driver's f64 decoding.
|
||||
v.map(|v| format!("{:.4}", v))
|
||||
}
|
||||
C::F64(v) => v.map(|v| v.to_string()),
|
||||
C::Bit(v) => v.map(|v| v.to_string()),
|
||||
C::String(v) => v.as_deref().map(str::to_owned),
|
||||
C::Guid(v) => v.map(|v| v.to_string()),
|
||||
C::Binary(v) => v.as_deref().map(hex),
|
||||
C::Numeric(v) => v.map(format_numeric),
|
||||
C::Xml(v) => v.as_deref().map(|x| x.as_ref().to_owned()),
|
||||
C::Date(v) => v.map(|d| format_date(i64::from(d.days()))),
|
||||
C::Time(v) => v.map(format_time),
|
||||
C::DateTime(v) => v.map(|dt| {
|
||||
let ms = (u64::from(dt.seconds_fragments()) * 1000 + 150) / 300;
|
||||
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(ms / 1000, ms % 1000, 3))
|
||||
}),
|
||||
C::SmallDateTime(v) => v.map(|dt| {
|
||||
let secs = u64::from(dt.seconds_fragments()) * 60;
|
||||
format!("{} {}", format_date(DAYS_1900_01_01 + i64::from(dt.days())), format_hms(secs, 0, 0))
|
||||
}),
|
||||
C::DateTime2(v) => {
|
||||
v.map(|dt| format!("{} {}", format_date(i64::from(dt.date().days())), format_time(dt.time())))
|
||||
}
|
||||
C::DateTimeOffset(v) => v.map(|dto| {
|
||||
let dt = dto.datetime2();
|
||||
let t = dt.time();
|
||||
let scale = t.scale().min(7);
|
||||
let div = 10u64.pow(u32::from(scale));
|
||||
let utc_secs = i64::from(dt.date().days()) * 86_400 + (t.increments() / div) as i64;
|
||||
let local_secs = utc_secs + i64::from(dto.offset()) * 60;
|
||||
let days = local_secs.div_euclid(86_400);
|
||||
let secs = local_secs.rem_euclid(86_400) as u64;
|
||||
let sign = if dto.offset() < 0 { '-' } else { '+' };
|
||||
let off = dto.offset().unsigned_abs();
|
||||
format!(
|
||||
"{} {}{sign}{:02}:{:02}",
|
||||
format_date(days),
|
||||
format_hms(secs, t.increments() % div, scale),
|
||||
off / 60,
|
||||
off % 60
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(2 + bytes.len() * 2);
|
||||
s.push_str("0x");
|
||||
@@ -88,10 +270,46 @@ fn hex(bytes: &[u8]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// How [`cell`] shows a value whose bytes are not text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Cells {
|
||||
/// Result grids: binary values as `0x…` hex.
|
||||
Display,
|
||||
/// Catalog queries: names and definitions, so bytes are read as UTF-8 whatever the
|
||||
/// column's type (MySQL flags `utf8mb3_bin` catalog columns BINARY, and sends every
|
||||
/// column as binary under `character_set_results = binary`).
|
||||
Text,
|
||||
}
|
||||
|
||||
/// Whether a value of type `ty` is binary data (see [`binary_type`]).
|
||||
fn is_binary<DB: sqlx::Database>(ty: &DB::TypeInfo) -> bool
|
||||
where
|
||||
String: sqlx::Type<DB>,
|
||||
{
|
||||
binary_type(ty.name(), <String as sqlx::Type<DB>>::compatible(ty))
|
||||
}
|
||||
|
||||
/// A BLOB / BINARY type that the driver does not read as text: MySQL flags text in a
|
||||
/// binary collation (`utf8mb4_bin`) BINARY too, and only the `binary` collation is bytes.
|
||||
fn binary_type(name: &str, text: bool) -> bool {
|
||||
let name = name.to_ascii_uppercase();
|
||||
(name.contains("BLOB") || name.contains("BINARY")) && !text
|
||||
}
|
||||
|
||||
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
|
||||
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
match (String::from_utf8(bytes), mode) {
|
||||
(Ok(s), _) => s,
|
||||
(Err(e), Cells::Display) => hex(e.as_bytes()),
|
||||
(Err(e), Cells::Text) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of column `i` of `row`.
|
||||
fn cell<R: Row>(row: &R, i: usize) -> Option<String>
|
||||
fn cell<R: Row>(row: &R, i: usize, mode: Cells) -> Option<String>
|
||||
where
|
||||
usize: ColumnIndex<R>,
|
||||
String: sqlx::Type<R::Database>,
|
||||
for<'r> String: Decode<'r, R::Database>,
|
||||
for<'r> Vec<u8>: Decode<'r, R::Database>,
|
||||
{
|
||||
@@ -99,18 +317,14 @@ where
|
||||
if raw.is_null() {
|
||||
return None;
|
||||
}
|
||||
let ty = raw.type_info().name().to_ascii_uppercase();
|
||||
if ty.contains("BLOB") || ty.contains("BINARY") {
|
||||
let raw = row.try_get_raw(i).ok()?;
|
||||
if mode == Cells::Display && is_binary::<R::Database>(&raw.type_info()) {
|
||||
return <Vec<u8> as Decode<R::Database>>::decode(raw).ok().map(|b| hex(&b));
|
||||
}
|
||||
match <String as Decode<R::Database>>::decode(raw) {
|
||||
Ok(s) => Some(s),
|
||||
Err(_) => {
|
||||
let raw = row.try_get_raw(i).ok()?;
|
||||
<Vec<u8> as Decode<R::Database>>::decode(raw)
|
||||
.ok()
|
||||
.map(|b| String::from_utf8(b).unwrap_or_else(|e| hex(e.as_bytes())))
|
||||
<Vec<u8> as Decode<R::Database>>::decode(raw).ok().map(|b| bytes_text(b, mode))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,9 +336,10 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows.
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
|
||||
/// with their cells read as `$mode`.
|
||||
macro_rules! fetch {
|
||||
($exec:expr, $sql:expr, $max:expr) => {{
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
|
||||
use futures::StreamExt;
|
||||
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
|
||||
let mut columns: Vec<ResultColumn> = Vec::new();
|
||||
@@ -145,7 +360,7 @@ macro_rules! fetch {
|
||||
has_more = true;
|
||||
break;
|
||||
}
|
||||
rows.push((0..row.len()).map(|i| cell(&row, i)).collect());
|
||||
rows.push((0..row.len()).map(|i| cell(&row, i, $mode)).collect());
|
||||
}
|
||||
Err(e) => {
|
||||
failure = Some(db_err(e));
|
||||
@@ -164,10 +379,41 @@ macro_rules! fetch {
|
||||
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
|
||||
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
|
||||
|
||||
async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
|
||||
// Transaction control must be a batch: sp_executesql rejects a changed transaction count.
|
||||
conn.simple_query(sql).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
|
||||
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
|
||||
let first = Some(Instant::now());
|
||||
let columns = stream
|
||||
.columns()
|
||||
.await
|
||||
.map_err(mssql_err)?
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|col| ResultColumn { name: col.name().to_owned(), type_name: format!("{:?}", col.column_type()) })
|
||||
.collect();
|
||||
let mut rows = Vec::new();
|
||||
let mut has_more = false;
|
||||
while let Some(item) = stream.next().await {
|
||||
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
|
||||
if rows.len() >= max {
|
||||
has_more = true;
|
||||
break;
|
||||
}
|
||||
rows.push(row.cells().map(|(col, cell)| tiberius_cell(cell, col.column_type())).collect());
|
||||
}
|
||||
}
|
||||
Ok((columns, rows, None, has_more, first))
|
||||
}
|
||||
|
||||
/// The server-side id of a connection (to cancel its statement from another one).
|
||||
macro_rules! backend_id {
|
||||
($conn:expr, $sql:expr) => {{
|
||||
let fetched: IdeResult<Fetched> = fetch!($conn, $sql, 1);
|
||||
let fetched: IdeResult<Fetched> = fetch!($conn, $sql, 1, Cells::Text);
|
||||
fetched
|
||||
.ok()
|
||||
.and_then(|f| f.1.into_iter().next())
|
||||
@@ -280,6 +526,7 @@ impl Pool {
|
||||
Pool::Sqlite(_) => DbDriver::Sqlite,
|
||||
Pool::Postgres(_) => DbDriver::Postgres,
|
||||
Pool::Mysql(_) => DbDriver::Mysql,
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,20 +667,60 @@ impl Pool {
|
||||
.map_err(db_err)?;
|
||||
Ok(Pool::Mysql(pool))
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
use tiberius::Config;
|
||||
let mut config = Config::new();
|
||||
config.host(&source.host);
|
||||
config.port(source.port.unwrap_or(1433));
|
||||
if !source.user.is_empty() {
|
||||
config.authentication(tiberius::AuthMethod::sql_server(
|
||||
source.user.clone(),
|
||||
password.unwrap_or_default().to_owned(),
|
||||
));
|
||||
}
|
||||
if !source.database.is_empty() {
|
||||
config.database(&source.database);
|
||||
}
|
||||
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
|
||||
config.trust_cert();
|
||||
}
|
||||
let manager = MssqlConnectionManager(config);
|
||||
let pool = bb8::Pool::builder()
|
||||
.max_size(MAX)
|
||||
.connection_timeout(timeout)
|
||||
.build(manager)
|
||||
.await
|
||||
.map_err(mssql_err)?;
|
||||
let mut conn = pool.get_owned().await.map_err(mssql_pool_err)?;
|
||||
conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
|
||||
Ok(Pool::SqlServer(pool))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
with_pool!(self, p => p.close().await)
|
||||
match self {
|
||||
Pool::SqlServer(_) => {}
|
||||
_ => with_pool!(self, p => p.close().await),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
with_pool!(self, p => p.is_closed())
|
||||
match self {
|
||||
Pool::SqlServer(_) => false,
|
||||
_ => with_pool!(self, p => p.is_closed()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection.
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
|
||||
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
|
||||
let fetched: IdeResult<Fetched> = with_pool!(self, p => fetch!(p, sql, max));
|
||||
let fetched: IdeResult<Fetched> = match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
mssql_fetch(&mut conn, sql, max).await
|
||||
}
|
||||
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
|
||||
};
|
||||
Ok(fetched?.1)
|
||||
}
|
||||
|
||||
@@ -452,21 +739,47 @@ impl Pool {
|
||||
/// Runs `statements` in one transaction; each must change `expect_one`
|
||||
/// row when flagged (table editor submits).
|
||||
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
with_pool!(self, p => {
|
||||
let mut tx = p.begin().await.map_err(db_err)?;
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in statements {
|
||||
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
|
||||
let n = done.rows_affected();
|
||||
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
|
||||
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
|
||||
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
|
||||
match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
let result = async {
|
||||
mssql_command(&mut conn, "BEGIN TRAN").await?;
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in statements {
|
||||
let n = conn.execute(sql, &[]).await.map_err(mssql_err)?.total();
|
||||
if *expect_one && n != 1 {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
|
||||
)));
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
mssql_command(&mut conn, "COMMIT TRAN").await?;
|
||||
Ok(total)
|
||||
}
|
||||
total += n;
|
||||
.await;
|
||||
if result.is_err() && mssql_command(&mut conn, "IF @@TRANCOUNT > 0 ROLLBACK TRAN").await.is_err() {
|
||||
// A failed rollback must never return an open transaction to the pool.
|
||||
conn.0.take();
|
||||
}
|
||||
result
|
||||
}
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(total)
|
||||
})
|
||||
_ => with_pool!(self, p => {
|
||||
let mut tx = p.begin().await.map_err(db_err)?;
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in statements {
|
||||
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
|
||||
let n = done.rows_affected();
|
||||
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
|
||||
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
|
||||
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(total)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +789,7 @@ pub enum Held {
|
||||
Sqlite(PoolConnection<Sqlite>),
|
||||
Postgres(PoolConnection<Postgres>),
|
||||
Mysql(PoolConnection<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -484,18 +798,25 @@ impl Held {
|
||||
Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?),
|
||||
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
|
||||
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
|
||||
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`).
|
||||
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
|
||||
let sql = sqlx::AssertSqlSafe(sql.to_owned());
|
||||
match self {
|
||||
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::SqlServer(c) => mssql_command(c, sql).await,
|
||||
other => {
|
||||
let sql = sqlx::AssertSqlSafe(sql.to_owned());
|
||||
match other {
|
||||
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::SqlServer(_) => unreachable!(),
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
/// Closes the connection instead of returning it to the pool (its statement was
|
||||
@@ -509,6 +830,9 @@ impl Held {
|
||||
Held::Mysql(c) => {
|
||||
let _ = c.detach().close().await;
|
||||
}
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +854,7 @@ impl Held {
|
||||
None => None,
|
||||
};
|
||||
let r = tokio::select! {
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows) } => r,
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
|
||||
() = cancel.cancelled() => Err(IdeError::cancelled()),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
@@ -556,6 +880,13 @@ impl Held {
|
||||
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
|
||||
}
|
||||
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
|
||||
Held::SqlServer(c) => {
|
||||
// Tiberius has no attention/cancel API; stop reading and discard the connection.
|
||||
tokio::select! {
|
||||
r = mssql_fetch(c, sql, max_rows) => r?,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
@@ -575,6 +906,7 @@ impl Held {
|
||||
pub fn begin_statement(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Mysql => "START TRANSACTION",
|
||||
DbDriver::SqlServer => "BEGIN TRAN",
|
||||
DbDriver::Sqlite | DbDriver::Postgres => "BEGIN",
|
||||
}
|
||||
}
|
||||
@@ -591,6 +923,95 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::source::{DbOptions, DbProperty};
|
||||
|
||||
#[test]
|
||||
fn discarded_sqlserver_connections_are_broken() {
|
||||
let manager = MssqlConnectionManager(tiberius::Config::new());
|
||||
assert!(manager.has_broken(&mut MssqlConnection(None)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlserver_connect_reports_io_errors() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut config = tiberius::Config::new();
|
||||
config.host("127.0.0.1");
|
||||
config.port(listener.local_addr().unwrap().port());
|
||||
drop(listener);
|
||||
let result = MssqlConnectionManager(config).connect().await;
|
||||
assert!(matches!(result, Err(tiberius::error::Error::Io { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_cells_preserve_values_and_nulls() {
|
||||
use std::borrow::Cow;
|
||||
use tiberius::time::{Date, DateTime, DateTime2, DateTimeOffset, SmallDateTime, Time};
|
||||
use tiberius::{ColumnData as C, ColumnType as T, Uuid, numeric::Numeric};
|
||||
let dt = DateTime2::new(Date::new(693_595), Time::new(1234567, 7));
|
||||
let cases = [
|
||||
(C::U8(Some(255)), "255"),
|
||||
(C::I16(Some(-123)), "-123"),
|
||||
(C::I32(Some(-456)), "-456"),
|
||||
(C::I64(Some(i64::MAX)), "9223372036854775807"),
|
||||
(C::F32(Some(1.25)), "1.25"),
|
||||
(C::F64(Some(1.25)), "1.25"),
|
||||
(C::Bit(Some(true)), "true"),
|
||||
(C::String(Some(Cow::Borrowed("hello"))), "hello"),
|
||||
(C::Binary(Some(Cow::Borrowed(&[0, 0xAB, 0xFF]))), "0x00ABFF"),
|
||||
(
|
||||
C::Guid(Some(Uuid::parse_str("ABCDEF01-2345-6789-ABCD-EF0123456789").unwrap())),
|
||||
"abcdef01-2345-6789-abcd-ef0123456789",
|
||||
),
|
||||
(C::Numeric(Some(Numeric::new_with_scale(-123, 5))), "-0.00123"),
|
||||
(C::Numeric(Some(Numeric::new_with_scale(12300, 4))), "1.2300"),
|
||||
(
|
||||
C::Numeric(Some(Numeric::new_with_scale(10i128.pow(38) - 1, 0))),
|
||||
"99999999999999999999999999999999999999",
|
||||
),
|
||||
(C::Xml(Some(Cow::Owned(tiberius::xml::XmlData::new("<a/>")))), "<a/>"),
|
||||
(C::Date(Some(Date::new(0))), "0001-01-01"),
|
||||
(C::Date(Some(Date::new(3_652_058))), "9999-12-31"),
|
||||
(C::Time(Some(Time::new(452961234567, 7))), "12:34:56.1234567"),
|
||||
(C::DateTime(Some(DateTime::new(-1, 1))), "1899-12-31 00:00:00.003"),
|
||||
(C::DateTime(Some(DateTime::new(0, 2))), "1900-01-01 00:00:00.007"),
|
||||
(C::SmallDateTime(Some(SmallDateTime::new(0, 754))), "1900-01-01 12:34:00"),
|
||||
(C::DateTime2(Some(dt)), "1900-01-01 00:00:00.1234567"),
|
||||
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, -60))), "1899-12-31 23:00:00.1234567-01:00"),
|
||||
(C::DateTimeOffset(Some(DateTimeOffset::new(dt, 330))), "1900-01-01 05:30:00.1234567+05:30"),
|
||||
];
|
||||
for (cell, expected) in cases {
|
||||
assert_eq!(tiberius_cell(&cell, T::Null).as_deref(), Some(expected), "{cell:?}");
|
||||
}
|
||||
for cell in [
|
||||
C::U8(None),
|
||||
C::I16(None),
|
||||
C::I32(None),
|
||||
C::I64(None),
|
||||
C::F32(None),
|
||||
C::F64(None),
|
||||
C::Bit(None),
|
||||
C::String(None),
|
||||
C::Guid(None),
|
||||
C::Binary(None),
|
||||
C::Numeric(None),
|
||||
C::Xml(None),
|
||||
C::Date(None),
|
||||
C::Time(None),
|
||||
C::DateTime(None),
|
||||
C::SmallDateTime(None),
|
||||
C::DateTime2(None),
|
||||
C::DateTimeOffset(None),
|
||||
] {
|
||||
assert_eq!(tiberius_cell(&cell, T::Null), None);
|
||||
}
|
||||
for ty in [T::Money, T::Money4] {
|
||||
for (v, expected) in
|
||||
[(0.0, "0.0000"), (1.2, "1.2000"), (-123.4567, "-123.4567"), (214748.3647, "214748.3647")]
|
||||
{
|
||||
assert_eq!(tiberius_cell(&C::F64(Some(v)), ty).as_deref(), Some(expected));
|
||||
}
|
||||
assert_eq!(tiberius_cell(&C::F64(None), ty), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn prop(name: &str, value: &str) -> DbProperty {
|
||||
DbProperty { name: name.into(), value: value.into() }
|
||||
}
|
||||
@@ -649,4 +1070,35 @@ mod tests {
|
||||
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
|
||||
assert_eq!(Setup::of(&s).unwrap().params, [("cache_size".to_owned(), "-2000".to_owned())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_bytes_typed_values_are_binary() {
|
||||
// (type name as the driver reports it, the driver reads it as text, binary)
|
||||
let cases = [
|
||||
("VARBINARY", false, true),
|
||||
("BLOB", false, true),
|
||||
("LONGBLOB", false, true),
|
||||
("BINARY", false, true),
|
||||
// MySQL catalog columns (`utf8mb3_bin`) and `utf8mb4_bin` text: flagged BINARY.
|
||||
("VARBINARY", true, false),
|
||||
("BLOB", true, false),
|
||||
("VARCHAR", true, false),
|
||||
("TEXT", true, false),
|
||||
("BYTEA", false, false),
|
||||
("INT", false, false),
|
||||
];
|
||||
for (name, text, binary) in cases {
|
||||
assert_eq!(binary_type(name, text), binary, "{name} text={text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_bytes_are_names_and_grid_bytes_are_hex() {
|
||||
// `oi_数据` as MySQL sends it under `character_set_results = binary`.
|
||||
let name = "oi_数据".as_bytes().to_vec();
|
||||
assert_eq!(bytes_text(name.clone(), Cells::Text), "oi_数据");
|
||||
assert_eq!(bytes_text(name, Cells::Display), "oi_数据");
|
||||
assert_eq!(bytes_text(vec![0xCA, 0xFE], Cells::Display), "0xCAFE");
|
||||
assert_eq!(bytes_text(vec![b'a', 0xFF], Cells::Text), "a\u{FFFD}");
|
||||
}
|
||||
}
|
||||
@@ -549,6 +549,7 @@ impl DbManager {
|
||||
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
|
||||
DbDriver::Postgres => "SELECT version()",
|
||||
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
|
||||
DbDriver::SqlServer => "SELECT @@VERSION",
|
||||
};
|
||||
let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?);
|
||||
pool.close().await;
|
||||
@@ -821,6 +822,13 @@ impl DbManager {
|
||||
let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next();
|
||||
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
let obj = format!("{}.{}", schema, name).replace('\'', "''");
|
||||
let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0)
|
||||
.into_iter()
|
||||
.next();
|
||||
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next();
|
||||
let (oid, kind) = match rel.as_deref() {
|
||||
|
||||
@@ -36,6 +36,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
|
||||
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
|
||||
DbDriver::Mysql => format!("EXPLAIN {s}"),
|
||||
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>>
|
||||
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
Ok(mysql(&names, &result.rows))
|
||||
}
|
||||
DbDriver::SqlServer => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +199,7 @@ mod tests {
|
||||
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,6 +16,8 @@ pub enum DbDriver {
|
||||
Postgres,
|
||||
/// MySQL and MariaDB.
|
||||
Mysql,
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -24,6 +26,7 @@ impl DbDriver {
|
||||
DbDriver::Sqlite => None,
|
||||
DbDriver::Postgres => Some(5432),
|
||||
DbDriver::Mysql => Some(3306),
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +251,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
|
||||
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
|
||||
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
_ => None,
|
||||
}
|
||||
@@ -472,6 +476,14 @@ mod tests {
|
||||
assert_eq!(load(tmp.path()).unwrap(), vec![s]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_defaults() {
|
||||
assert_eq!(DbDriver::SqlServer.default_port(), Some(1433));
|
||||
assert_eq!(scheme_driver("sqlserver"), Some(DbDriver::SqlServer));
|
||||
assert_eq!(scheme_driver("mssql"), Some(DbDriver::SqlServer));
|
||||
assert_eq!(scheme_driver("mssqlserver"), Some(DbDriver::SqlServer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_urls() {
|
||||
let u = parse_url("jdbc:postgresql://ann:s%[email protected]:5433/shop?sslmode=require&search_path=app").unwrap();
|
||||
|
||||
+105
-3
@@ -8,6 +8,7 @@ use specta::Type;
|
||||
pub fn quote_ident(driver: DbDriver, name: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Mysql => format!("`{}`", name.replace('`', "``")),
|
||||
DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")),
|
||||
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||
}
|
||||
}
|
||||
@@ -52,6 +53,7 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
|
||||
}
|
||||
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::Postgres => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
|
||||
@@ -61,6 +63,10 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY 1"
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('sys', 'information_schema', 'guest') ORDER BY 1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +89,11 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
DbDriver::SqlServer => format!(
|
||||
"SELECT table_name, CASE WHEN table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END \
|
||||
FROM information_schema.tables WHERE table_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +121,16 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
FROM information_schema.columns WHERE table_schema = {s} AND table_name = {t} \
|
||||
ORDER BY ordinal_position"
|
||||
),
|
||||
DbDriver::SqlServer => format!(
|
||||
"SELECT column_name, data_type, is_nullable, column_default, \
|
||||
CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \
|
||||
JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \
|
||||
AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = {s} \
|
||||
AND tc.table_name = {t} AND k.column_name = c.column_name) THEN 1 ELSE 0 END \
|
||||
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
|
||||
ORDER BY c.ordinal_position"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +155,13 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
FROM information_schema.statistics WHERE table_schema = {s} AND table_name = {t} \
|
||||
ORDER BY index_name, seq_in_index"
|
||||
),
|
||||
DbDriver::SqlServer => format!(
|
||||
"SELECT i.name, CASE WHEN i.is_unique = 1 THEN 1 ELSE 0 END, c.name \
|
||||
FROM sys.indexes i JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \
|
||||
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id \
|
||||
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
|
||||
ORDER BY i.name, ic.key_ordinal"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +199,20 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
WHERE c.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position LIMIT 50000"
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
"SELECT c.table_schema, c.table_name, CASE WHEN t.table_type LIKE '%VIEW%' THEN 'view' ELSE 'table' END, \
|
||||
c.column_name, c.data_type, c.is_nullable, \
|
||||
CASE WHEN EXISTS (SELECT 1 FROM information_schema.table_constraints tc \
|
||||
JOIN information_schema.key_column_usage k ON k.constraint_name = tc.constraint_name \
|
||||
AND k.table_schema = tc.table_schema AND k.table_name = tc.table_name \
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = c.table_schema \
|
||||
AND tc.table_name = c.table_name AND k.column_name = c.column_name) THEN 1 ELSE 0 END, \
|
||||
CASE WHEN c.column_default IS NOT NULL THEN 1 ELSE 0 END \
|
||||
FROM information_schema.columns c JOIN information_schema.tables t \
|
||||
ON t.table_schema = c.table_schema AND t.table_name = c.table_name \
|
||||
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +244,13 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
AND table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') \
|
||||
ORDER BY table_schema, table_name, constraint_name, ordinal_position LIMIT 20000"
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
"SELECT table_schema, table_name, constraint_name, column_name, \
|
||||
referenced_table_schema, referenced_table_name, referenced_column_name \
|
||||
FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL \
|
||||
AND table_schema NOT IN ('sys', 'information_schema') \
|
||||
ORDER BY table_schema, table_name, constraint_name, ordinal_position"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +267,9 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
|
||||
DbDriver::Mysql => format!(
|
||||
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
|
||||
),
|
||||
DbDriver::SqlServer => format!(
|
||||
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +279,7 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
|
||||
DbDriver::Sqlite => None,
|
||||
DbDriver::Postgres => Some("SELECT current_schema()"),
|
||||
DbDriver::Mysql => Some("SELECT DATABASE()"),
|
||||
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,8 +303,14 @@ pub fn table_page(
|
||||
) -> String {
|
||||
let mut sql = format!("SELECT * FROM {}", qualified(driver, schema, table));
|
||||
if let Some(f) = filter.map(str::trim).filter(|f| !f.is_empty()) {
|
||||
let f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
|
||||
sql.push_str(&format!(" WHERE {f}"));
|
||||
let mut f = f.strip_prefix("WHERE ").or_else(|| f.strip_prefix("where ")).unwrap_or(f);
|
||||
if let Some(i) = f.find(';') {
|
||||
f = &f[..i];
|
||||
}
|
||||
f = f.trim();
|
||||
if !f.is_empty() {
|
||||
sql.push_str(&format!(" WHERE {f}"));
|
||||
}
|
||||
}
|
||||
if !sort.is_empty() {
|
||||
let keys: Vec<String> = sort
|
||||
@@ -260,7 +319,14 @@ pub fn table_page(
|
||||
.collect();
|
||||
sql.push_str(&format!(" ORDER BY {}", keys.join(", ")));
|
||||
}
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
|
||||
if driver == DbDriver::SqlServer {
|
||||
if sort.is_empty() {
|
||||
sql.push_str(" ORDER BY (SELECT 1)");
|
||||
}
|
||||
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1));
|
||||
} else {
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
|
||||
}
|
||||
sql
|
||||
}
|
||||
|
||||
@@ -400,6 +466,10 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
"SELECT trigger_name, lower(action_timing), lower(event_manipulation) FROM information_schema.triggers \
|
||||
WHERE event_object_schema = {s} AND event_object_table = {t} ORDER BY 1"
|
||||
),
|
||||
DbDriver::SqlServer => format!(
|
||||
"SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \
|
||||
WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,6 +508,10 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
AND p.specific_name = r.specific_name AND p.ordinal_position > 0), r.dtd_identifier \
|
||||
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
|
||||
)),
|
||||
DbDriver::SqlServer => Some(format!(
|
||||
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
|
||||
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +633,10 @@ mod tests {
|
||||
"SELECT * FROM \"public\".\"users\" WHERE age > 3 ORDER BY \"id\" DESC LIMIT 51 OFFSET 100"
|
||||
);
|
||||
assert_eq!(table_page(DbDriver::Mysql, "", "t", Some(" "), &[], 0, 10), "SELECT * FROM `t` LIMIT 11 OFFSET 0");
|
||||
assert_eq!(
|
||||
table_page(DbDriver::SqlServer, "dbo", "users", Some("id = 1;"), &[], 0, 500),
|
||||
"SELECT * FROM [dbo].[users] WHERE id = 1 ORDER BY (SELECT 1) OFFSET 0 ROWS FETCH NEXT 501 ROWS ONLY"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -586,6 +664,30 @@ mod tests {
|
||||
assert!(edit_statement(DbDriver::Postgres, "s", "t", &DbRowEdit::Delete { key: vec![] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_quoting_and_schema() {
|
||||
assert_eq!(quote_ident(DbDriver::SqlServer, "a]b"), "[a]]b]");
|
||||
assert_eq!(qualified(DbDriver::SqlServer, "dbo", "t"), "[dbo].[t]");
|
||||
assert_eq!(qualified(DbDriver::SqlServer, "", "t"), "[t]");
|
||||
assert_eq!(current_schema(DbDriver::SqlServer), Some("SELECT DB_NAME()"));
|
||||
assert_eq!(crate::conn::begin_statement(DbDriver::SqlServer), "BEGIN TRAN");
|
||||
assert_eq!(
|
||||
schemas(DbDriver::SqlServer, true),
|
||||
"SELECT schema_name FROM information_schema.schemata ORDER BY 1"
|
||||
);
|
||||
assert!(schemas(DbDriver::SqlServer, false).contains("NOT IN ('sys', 'information_schema', 'guest')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_introspection_queries() {
|
||||
assert!(tables(DbDriver::SqlServer, "dbo").contains("information_schema.tables"));
|
||||
assert!(columns(DbDriver::SqlServer, "dbo", "t").contains("information_schema.columns"));
|
||||
assert!(indexes(DbDriver::SqlServer, "dbo", "t").contains("sys.indexes"));
|
||||
assert!(foreign_keys(DbDriver::SqlServer).contains("referenced_table_name IS NOT NULL"));
|
||||
assert!(table_foreign_keys(DbDriver::SqlServer, "dbo", "t").contains("table_schema = 'dbo'"));
|
||||
assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_trigger_events() {
|
||||
let ev = sqlite_trigger_event;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Introspection against a real MySQL / MariaDB server, skipped unless
|
||||
//! `OPENIDEA_TEST_MYSQL=host:port:user:password` names a throwaway server.
|
||||
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use ide_db::{DataSource, DbDriver, DbManager, DbOptions};
|
||||
|
||||
const SCHEMAS: [&str; 2] = ["oi_bin_meta", "oi_数据"];
|
||||
|
||||
fn server() -> Option<(String, u16, String, String)> {
|
||||
let v = std::env::var("OPENIDEA_TEST_MYSQL").ok()?;
|
||||
let mut it = v.splitn(4, ':');
|
||||
Some((it.next()?.into(), it.next()?.parse().ok()?, it.next()?.into(), it.next().unwrap_or("").into()))
|
||||
}
|
||||
|
||||
async fn manager(tmp: &tempfile::TempDir, startup_script: &str) -> Option<DbManager> {
|
||||
let (host, port, user, password) = server()?;
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
db.save_sources(vec![DataSource {
|
||||
id: "m".into(),
|
||||
name: "mysql".into(),
|
||||
driver: DbDriver::Mysql,
|
||||
host,
|
||||
port: Some(port),
|
||||
user,
|
||||
options: DbOptions { startup_script: startup_script.into(), ..Default::default() },
|
||||
..Default::default()
|
||||
}])
|
||||
.await
|
||||
.unwrap();
|
||||
db.set_password("m", Some(password), false).await.unwrap();
|
||||
Some(db)
|
||||
}
|
||||
|
||||
async fn create_schemas(db: &DbManager) {
|
||||
let c = CancellationToken::new();
|
||||
for s in SCHEMAS {
|
||||
for stmt in [
|
||||
format!("DROP DATABASE IF EXISTS `{s}`"),
|
||||
format!("CREATE DATABASE `{s}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"),
|
||||
format!(
|
||||
"CREATE TABLE `{s}`.`用户` (id INT PRIMARY KEY, name VARCHAR(20) COLLATE utf8mb4_bin, raw VARBINARY(4))"
|
||||
),
|
||||
format!("CREATE INDEX `名字` ON `{s}`.`用户` (name)"),
|
||||
format!("INSERT INTO `{s}`.`用户` VALUES (1, 'ann', 0xCAFE)"),
|
||||
] {
|
||||
db.execute("m", &stmt, 10, &c).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_metadata_is_text(db: &DbManager) -> IdeResult<()> {
|
||||
let schemas = db.schemas("m").await?;
|
||||
for s in SCHEMAS {
|
||||
assert!(schemas.iter().any(|n| n == s), "{s} missing from {schemas:?}");
|
||||
let tables = db.tables("m", s).await?;
|
||||
assert_eq!(tables.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(), ["用户"], "tables of {s}");
|
||||
let d = db.table_details("m", s, "用户").await?;
|
||||
let cols: Vec<_> = d.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(cols, ["id", "name", "raw"]);
|
||||
assert!(d.indexes.iter().any(|i| i.name == "名字"), "{:?}", d.indexes);
|
||||
assert!(db.ddl("m", s, "用户").await?.contains("CREATE TABLE `用户`"));
|
||||
}
|
||||
let completion = db.completion("m").await?;
|
||||
let json = serde_json::to_string(&completion).unwrap();
|
||||
assert!(json.contains("oi_数据") && json.contains("用户"), "{json}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// One test: both phases share the schemas they create.
|
||||
#[tokio::test]
|
||||
async fn metadata_names_are_text_whatever_the_column_collation() -> IdeResult<()> {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let Some(db) = manager(&tmp, "").await else { return Ok(()) };
|
||||
create_schemas(&db).await;
|
||||
// MySQL 8 sends `utf8mb3_bin` catalog names flagged BINARY (lower_case_table_names = 0).
|
||||
assert_metadata_is_text(&db).await?;
|
||||
// `_bin` text is text in the grid; true binary stays hex.
|
||||
let r = db.execute("m", "SELECT name, raw FROM `oi_数据`.`用户`", 10, &CancellationToken::new()).await?;
|
||||
assert_eq!(r.rows, vec![vec![Some("ann".to_owned()), Some("0xCAFE".to_owned())]]);
|
||||
|
||||
// Every text column arrives with the `binary` collation.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let raw = manager(&tmp, "SET character_set_results = binary").await.unwrap();
|
||||
assert_metadata_is_text(&raw).await
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Live SQL Server checks, skipped unless OPENIDEA_TEST_SQLSERVER=host:port:user:password
|
||||
//! names a throwaway server.
|
||||
|
||||
use ide_core::CancellationToken;
|
||||
use ide_db::{DataSource, DbDriver, DbSslMode, Held, Pool};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlserver_values_paging_transactions_and_cancellation() {
|
||||
let Ok(server) = std::env::var("OPENIDEA_TEST_SQLSERVER") else { return };
|
||||
let mut parts = server.splitn(4, ':');
|
||||
let source = DataSource {
|
||||
driver: DbDriver::SqlServer,
|
||||
host: parts.next().unwrap().into(),
|
||||
port: Some(parts.next().unwrap().parse().unwrap()),
|
||||
user: parts.next().unwrap().into(),
|
||||
database: "tempdb".into(),
|
||||
ssl: DbSslMode::Disable,
|
||||
..Default::default()
|
||||
};
|
||||
let pool = Pool::connect(&source, parts.next(), None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let mut held = Held::acquire(&pool).await.unwrap();
|
||||
let table = format!("tempdb.dbo.oi_pr276_values_{}", std::process::id());
|
||||
held.command(&format!("CREATE TABLE {table} (id int PRIMARY KEY, d decimal(38, 7), dt datetime2(7), guid uniqueidentifier, m money, tiny tinyint, x xml)")).await.unwrap();
|
||||
held.command(&format!("INSERT INTO {table} VALUES (1, 12345678901234567890.1234567, '2024-02-29T12:34:56.1234567', 'abcdef01-2345-6789-abcd-ef0123456789', 12.3400, 255, '<a/>'), (2, NULL, NULL, NULL, NULL, NULL, NULL)")).await.unwrap();
|
||||
let r = held.execute(&pool, &format!("SELECT * FROM {table} ORDER BY id"), 1, &cancel).await.unwrap();
|
||||
assert!(r.has_more);
|
||||
assert_eq!(
|
||||
r.rows[0],
|
||||
[
|
||||
"1",
|
||||
"12345678901234567890.1234567",
|
||||
"2024-02-29 12:34:56.1234567",
|
||||
"abcdef01-2345-6789-abcd-ef0123456789",
|
||||
"12.3400",
|
||||
"255",
|
||||
"<a/>"
|
||||
]
|
||||
.map(|s| Some(s.to_owned()))
|
||||
);
|
||||
let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 2"), 5, &cancel).await.unwrap();
|
||||
assert!(!r.has_more);
|
||||
assert!(r.rows[0][1..].iter().all(Option::is_none));
|
||||
let r = held.execute(&pool, &format!("SELECT * FROM {table} WHERE id = 3"), 5, &cancel).await.unwrap();
|
||||
assert_eq!(r.columns.len(), 7);
|
||||
assert!(r.rows.is_empty());
|
||||
let r = held.execute(&pool,
|
||||
"SELECT CAST('2024-02-29T00:15:00.1234567+05:30' AS datetimeoffset(7)), CAST('2024-02-29' AS date), CAST('12:34:56.1234567' AS time(7)), CAST('2024-02-29T12:34:56.007' AS datetime), CAST('2024-02-29T12:34:00' AS smalldatetime), CAST(-12.3400 AS smallmoney)",
|
||||
1, &cancel).await.unwrap();
|
||||
assert_eq!(
|
||||
r.rows[0],
|
||||
[
|
||||
"2024-02-29 00:15:00.1234567+05:30",
|
||||
"2024-02-29",
|
||||
"12:34:56.1234567",
|
||||
"2024-02-29 12:34:56.007",
|
||||
"2024-02-29 12:34:00",
|
||||
"-12.3400"
|
||||
]
|
||||
.map(|s| Some(s.to_owned()))
|
||||
);
|
||||
held.command(&format!("DROP TABLE {table}")).await.unwrap();
|
||||
drop(held);
|
||||
|
||||
// A unique permanent table lets transaction calls acquire any pooled connection.
|
||||
let table = format!("tempdb.dbo.oi_pr276_{}", std::process::id());
|
||||
pool.execute(&format!("CREATE TABLE {table} (id int PRIMARY KEY, v int)"), 1, &cancel).await.unwrap();
|
||||
pool.transaction(&[(format!("INSERT INTO {table} VALUES (1, 10)"), true)]).await.unwrap();
|
||||
for failing in [format!("INSERT INTO {table} VALUES (1, 20)"), format!("UPDATE {table} SET v = 30 WHERE id = 999")]
|
||||
{
|
||||
assert!(
|
||||
pool.transaction(&[(format!("UPDATE {table} SET v = 99 WHERE id = 1"), true), (failing, true)])
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(pool.rows(&format!("SELECT v FROM {table}"), 1).await.unwrap()[0][0].as_deref(), Some("10"));
|
||||
assert_eq!(pool.rows("SELECT @@TRANCOUNT", 1).await.unwrap()[0][0].as_deref(), Some("0"));
|
||||
}
|
||||
assert_eq!(pool.transaction(&[(format!("UPDATE {table} SET v = 11 WHERE id = 1"), true)]).await.unwrap(), 1);
|
||||
pool.execute(&format!("DROP TABLE {table}"), 1, &cancel).await.unwrap();
|
||||
|
||||
let cancel = CancellationToken::new();
|
||||
let stop = cancel.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
stop.cancel();
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(2), pool.execute("WAITFOR DELAY '00:00:10'; SELECT 1", 1, &cancel))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_err()
|
||||
);
|
||||
task.await.unwrap();
|
||||
assert_eq!(pool.rows("SELECT 1", 1).await.unwrap()[0][0].as_deref(), Some("1"));
|
||||
}
|
||||
@@ -54,7 +54,7 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
ide_core::output_encoding::decode_tool_output(&buf)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, s
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
if !pending.is_empty() {
|
||||
let line = String::from_utf8_lossy(pending).trim_end().to_string();
|
||||
let line = ide_core::output_encoding::decode_tool_output(pending).trim_end().to_string();
|
||||
pending.clear();
|
||||
let _ = tx.send(Event::Line(stream, line));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "ide-fonts"
|
||||
description = "Font families installed on this machine, for the font pickers: CoreText, DirectWrite or fontconfig"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
|
||||
@@ -0,0 +1,306 @@
|
||||
//! The font families installed on this machine, for the font pickers in
|
||||
//! Settings (editor, typography fallback, terminal, UI font).
|
||||
//!
|
||||
//! Each platform asks the font system the WebView itself resolves CSS
|
||||
//! `font-family` names against, so every listed name renders: CoreText on
|
||||
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
|
||||
//! The list is read on first use and cached until explicitly refreshed; the
|
||||
//! query can take a few hundred milliseconds, so callers run it off the UI and
|
||||
//! command threads.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// One installed family. `monospaced` is the font system's own flag (fixed
|
||||
/// pitch; on Linux also fontconfig's dual-width CJK monospace).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FontFamily {
|
||||
pub name: String,
|
||||
pub monospaced: bool,
|
||||
}
|
||||
|
||||
type Cache = Mutex<Option<Arc<[FontFamily]>>>;
|
||||
|
||||
/// The installed families, sorted case-insensitively, read on first use and
|
||||
/// re-queried when `refresh` is true.
|
||||
pub fn families(refresh: bool) -> Arc<[FontFamily]> {
|
||||
static CACHE: Cache = Mutex::new(None);
|
||||
cached(&CACHE, refresh, imp::list)
|
||||
}
|
||||
|
||||
fn cached(cache: &Cache, refresh: bool, list: impl FnOnce(bool) -> Vec<(String, bool)>) -> Arc<[FontFamily]> {
|
||||
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !refresh && let Some(families) = guard.as_ref() {
|
||||
return Arc::clone(families);
|
||||
}
|
||||
let families: Arc<[FontFamily]> = normalize(list(refresh)).into();
|
||||
*guard = Some(Arc::clone(&families));
|
||||
families
|
||||
}
|
||||
|
||||
/// Merges the per-face records a font system reports into one entry per
|
||||
/// family: trimmed, without empty or hidden (`.`-prefixed system UI) names,
|
||||
/// monospaced when any face is, sorted case-insensitively.
|
||||
pub fn normalize(faces: impl IntoIterator<Item = (String, bool)>) -> Vec<FontFamily> {
|
||||
let mut by_key: BTreeMap<(String, String), bool> = BTreeMap::new();
|
||||
for (name, mono) in faces {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
*by_key.entry((name.to_lowercase(), name.to_owned())).or_default() |= mono;
|
||||
}
|
||||
let mut out: Vec<FontFamily> = Vec::with_capacity(by_key.len());
|
||||
for ((key, name), monospaced) in by_key {
|
||||
// Same family under two casings (one font file each): keep the first.
|
||||
if let Some(last) = out.last_mut()
|
||||
&& last.name.to_lowercase() == key
|
||||
{
|
||||
last.monospaced |= monospaced;
|
||||
continue;
|
||||
}
|
||||
out.push(FontFamily { name, monospaced });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parses `fc-list -f '%{family[0]}\t%{spacing}\n'`: one line per face, the
|
||||
/// spacing empty (proportional) or fontconfig's number or name for it.
|
||||
pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (family, spacing) = line.split_once('\t').unwrap_or((line, ""));
|
||||
let family = family.replace("\\-", "-");
|
||||
let mono = match spacing.trim() {
|
||||
"dual" | "mono" | "charcell" => true,
|
||||
s => s.parse::<u32>().is_ok_and(|n| n >= 90),
|
||||
};
|
||||
(!family.trim().is_empty()).then_some((family, mono))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
|
||||
use objc2_core_text::{
|
||||
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
|
||||
kCTFontTraitsAttribute,
|
||||
};
|
||||
|
||||
/// Every face CoreText makes available to this process, as (family, fixed pitch).
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: CoreText's documented types: the matching descriptors array
|
||||
// holds CTFontDescriptors and the traits dictionary maps CFString keys.
|
||||
unsafe {
|
||||
let collection = CTFontCollection::from_available_fonts(None);
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let descriptors: &CFArray<CTFontDescriptor> = descriptors.cast_unchecked();
|
||||
descriptors
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
let name = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
|
||||
let traits = d
|
||||
.attribute(kCTFontTraitsAttribute)
|
||||
.and_then(|t| t.downcast::<CFDictionary>().ok())
|
||||
.and_then(|t| t.cast_unchecked::<CFString, CFType>().get(kCTFontSymbolicTrait))
|
||||
.and_then(|n| n.downcast::<CFNumber>().ok())
|
||||
.and_then(|n| n.as_i64())
|
||||
.unwrap_or(0);
|
||||
Some((name, traits as u32 & CTFontSymbolicTraits::TraitMonoSpace.0 != 0))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use windows::Win32::Graphics::DirectWrite::{
|
||||
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWriteCreateFactory, IDWriteFactory, IDWriteFont1, IDWriteLocalizedStrings,
|
||||
};
|
||||
use windows::core::{BOOL, Interface, w};
|
||||
|
||||
/// The system font collection's families, named in English when the
|
||||
/// family has an English name (what CSS matches), else its first name.
|
||||
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: plain DirectWrite COM calls on interfaces we own.
|
||||
unsafe { read(refresh) }.unwrap_or_default()
|
||||
}
|
||||
|
||||
unsafe fn read(refresh: bool) -> windows::core::Result<Vec<(String, bool)>> {
|
||||
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
|
||||
let mut collection = None;
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
|
||||
let Some(collection) = collection else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for i in 0..unsafe { collection.GetFontFamilyCount() } {
|
||||
let Ok(family) = (unsafe { collection.GetFontFamily(i) }) else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = unsafe { family.GetFamilyNames() }.ok().and_then(|n| unsafe { english(&n) }) else {
|
||||
continue;
|
||||
};
|
||||
let mono = unsafe {
|
||||
family.GetFirstMatchingFont(
|
||||
DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWRITE_FONT_STRETCH_NORMAL,
|
||||
DWRITE_FONT_STYLE_NORMAL,
|
||||
)
|
||||
}
|
||||
.ok()
|
||||
.and_then(|f| f.cast::<IDWriteFont1>().ok())
|
||||
.is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool());
|
||||
out.push((name, mono));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
unsafe fn english(names: &IDWriteLocalizedStrings) -> Option<String> {
|
||||
let mut index = 0u32;
|
||||
let mut exists = BOOL(0);
|
||||
unsafe { names.FindLocaleName(w!("en-us"), &mut index, &mut exists) }.ok()?;
|
||||
if !exists.as_bool() {
|
||||
index = 0;
|
||||
}
|
||||
let len = unsafe { names.GetStringLength(index) }.ok()? as usize;
|
||||
let mut buf = vec![0u16; len + 1];
|
||||
unsafe { names.GetString(index, &mut buf) }.ok()?;
|
||||
Some(String::from_utf16_lossy(&buf[..len]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod imp {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// fontconfig's view, which WebKitGTK resolves names with. Empty when
|
||||
/// `fc-list` isn't installed.
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
Command::new("fc-list")
|
||||
.args(["-f", "%{family[0]}\t%{spacing}\n"])
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| super::parse_fc_list(&String::from_utf8_lossy(&o.stdout)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
mod imp {
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn fam(name: &str, monospaced: bool) -> FontFamily {
|
||||
FontFamily { name: name.into(), monospaced }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_merges_faces_sorts_and_drops_hidden() {
|
||||
let faces = [
|
||||
("Menlo", true),
|
||||
("Arial", false),
|
||||
("Menlo", true),
|
||||
(".SF NS Mono", true),
|
||||
(" ", false),
|
||||
("arial", false),
|
||||
("PingFang SC", false),
|
||||
("Courier New", false),
|
||||
("Courier New", true),
|
||||
("andale mono", true),
|
||||
]
|
||||
.map(|(n, m)| (n.to_owned(), m));
|
||||
assert_eq!(
|
||||
normalize(faces),
|
||||
vec![
|
||||
fam("andale mono", true),
|
||||
fam("Arial", false),
|
||||
fam("Courier New", true),
|
||||
fam("Menlo", true),
|
||||
fam("PingFang SC", false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fc_list_spacing_marks_monospace() {
|
||||
let out = "DejaVu Sans Mono\t100\nDejaVu Sans\t\nNoto Sans Mono CJK SC\t90\nTerminus\t110\nCantarell\nFira\\-Code\tmono\n\t100\n";
|
||||
assert_eq!(
|
||||
parse_fc_list(out),
|
||||
[
|
||||
("DejaVu Sans Mono", true),
|
||||
("DejaVu Sans", false),
|
||||
("Noto Sans Mono CJK SC", true),
|
||||
("Terminus", true),
|
||||
("Cantarell", false),
|
||||
("Fira-Code", true),
|
||||
]
|
||||
.map(|(n, m)| (n.to_owned(), m))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caches_until_refresh_requested() {
|
||||
let cache = Mutex::new(None);
|
||||
let calls = AtomicUsize::new(0);
|
||||
let lister = |refresh: bool| {
|
||||
let n = calls.fetch_add(1, Ordering::SeqCst);
|
||||
vec![(format!("Font {n} (refresh={refresh})"), n.is_multiple_of(2))]
|
||||
};
|
||||
|
||||
let first = cached(&cache, false, lister);
|
||||
assert_eq!(&*first, &[fam("Font 0 (refresh=false)", true)]);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
let second = cached(&cache, false, lister);
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
let refreshed = cached(&cache, true, lister);
|
||||
assert_eq!(&*refreshed, &[fam("Font 1 (refresh=true)", false)]);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
|
||||
let after_refresh = cached(&cache, false, lister);
|
||||
assert!(Arc::ptr_eq(&refreshed, &after_refresh));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
/// The real font system: every desktop OS ships some fonts, at least one
|
||||
/// of them monospaced, and the list comes back sorted without duplicates.
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
#[test]
|
||||
fn lists_installed_families() {
|
||||
let list = families(false);
|
||||
assert!(list.len() > 20, "only {} families", list.len());
|
||||
assert!(list.iter().any(|f| f.monospaced));
|
||||
assert!(list.iter().any(|f| !f.monospaced));
|
||||
let keys: Vec<String> = list.iter().map(|f| f.name.to_lowercase()).collect();
|
||||
assert!(keys.windows(2).all(|w| w[0] < w[1]));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let menlo = list.iter().find(|f| f.name == "Menlo").expect("Menlo ships with macOS");
|
||||
assert!(menlo.monospaced);
|
||||
assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced));
|
||||
}
|
||||
assert_eq!(&*families(true), &*list);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ name = "openidea-server"
|
||||
path = "src/bin/server.rs"
|
||||
|
||||
[dependencies]
|
||||
encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
@@ -34,17 +35,20 @@ ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-notebook.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
tokio = { workspace = true, features = ["io-std"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
tempfile.workspace = true
|
||||
@@ -281,7 +281,8 @@ fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &d
|
||||
for pipe in pipes.into_iter().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
for line in BufReader::new(pipe).split(b'\n').map_while(Result::ok) {
|
||||
let line = ide_core::output_encoding::decode_tool_output(&line).trim_end_matches('\r').to_owned();
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -395,6 +396,8 @@ mod tests {
|
||||
assert_eq!(changed_names(&before, &after), ["B", "C", "D"]);
|
||||
}
|
||||
|
||||
// Used by the Unix-only post-start tests.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
#[derive(Default)]
|
||||
struct Events(Mutex<Vec<IdeEvent>>);
|
||||
impl EventSink for Events {
|
||||
@@ -403,6 +406,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn run(cmd: LifecycleCommand, root: &Path) -> Vec<IdeEvent> {
|
||||
let events = Arc::new(Events::default());
|
||||
let sink: SharedSink = events.clone();
|
||||
@@ -410,6 +414,7 @@ mod tests {
|
||||
events.0.lock().clone()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn failure(events: &[IdeEvent]) -> Option<String> {
|
||||
events.iter().find_map(|e| match e {
|
||||
IdeEvent::Notify { level: NotifyLevel::Error, body, .. } => body.clone(),
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Automatic runtime provisioning belongs to the host, not SDK discovery.
|
||||
use ide_core::{CancellationToken, IdeEvent, SharedSink, TaskId};
|
||||
use ide_language::LanguageServerConfig;
|
||||
use ide_sdk::java::{jdtls_jdks, jdtls_min_java, jdtls_runtime, language_server_runtimes_dir};
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
busy: bool,
|
||||
sinks: Vec<Weak<dyn ide_core::EventSink>>,
|
||||
attempted: HashSet<(PathBuf, u32)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Provisioner {
|
||||
state: Mutex<State>,
|
||||
changed: Condvar,
|
||||
}
|
||||
|
||||
static PROVISIONER: LazyLock<Provisioner> = LazyLock::new(Provisioner::default);
|
||||
|
||||
fn needs_download(explicit: bool, suitable: bool, attempted: bool) -> bool {
|
||||
!explicit && !suitable && !attempted
|
||||
}
|
||||
|
||||
impl Provisioner {
|
||||
fn subscribe(&self, sink: &SharedSink) {
|
||||
let mut state = self.state.lock();
|
||||
state.sinks.retain(|s| s.strong_count() > 0);
|
||||
let weak = Arc::downgrade(sink);
|
||||
if !state.sinks.iter().any(|s| s.ptr_eq(&weak)) {
|
||||
state.sinks.push(weak);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&self, event: IdeEvent) {
|
||||
let sinks: Vec<_> = self.state.lock().sinks.iter().filter_map(Weak::upgrade).collect();
|
||||
for sink in sinks {
|
||||
sink.emit(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self, key: (PathBuf, u32), cancel: &CancellationToken, work: impl FnOnce()) {
|
||||
let mut state = self.state.lock();
|
||||
while state.busy && !cancel.is_cancelled() {
|
||||
self.changed.wait_for(&mut state, Duration::from_millis(100));
|
||||
}
|
||||
if cancel.is_cancelled() || !state.attempted.insert(key) {
|
||||
return;
|
||||
}
|
||||
state.busy = true;
|
||||
drop(state);
|
||||
// Even a panicking installer must release waiters and retain the attempt.
|
||||
struct Flight<'a>(&'a Provisioner);
|
||||
impl Drop for Flight<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.state.lock().busy = false;
|
||||
self.0.changed.notify_all();
|
||||
}
|
||||
}
|
||||
let _flight = Flight(self);
|
||||
work();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(
|
||||
cfg: &LanguageServerConfig,
|
||||
data_dir: &Path,
|
||||
explicit: bool,
|
||||
sink: &SharedSink,
|
||||
launch_cancel: &CancellationToken,
|
||||
) {
|
||||
let min_java = jdtls_min_java(cfg);
|
||||
let jdks = jdtls_jdks(min_java, data_dir);
|
||||
// A launch environment override is explicit too, even if it is invalid.
|
||||
if !needs_download(explicit || cfg.env.contains_key("JAVA_HOME"), jdtls_runtime(&jdks, min_java).is_some(), false) {
|
||||
return;
|
||||
}
|
||||
PROVISIONER.subscribe(sink);
|
||||
PROVISIONER.run((data_dir.to_path_buf(), min_java), launch_cancel, || {
|
||||
if jdtls_runtime(&jdtls_jdks(min_java, data_dir), min_java).is_some() {
|
||||
return;
|
||||
}
|
||||
let id = TaskId::next();
|
||||
let title = format!("Downloading Java {min_java} language server runtime");
|
||||
// Closing one project must not cancel a download other projects await.
|
||||
let cancel = CancellationToken::new();
|
||||
let _handle = ide_core::task::on_cancel(id, {
|
||||
let cancel = cancel.clone();
|
||||
move || cancel.cancel()
|
||||
});
|
||||
let emit = |message, fraction, done| {
|
||||
PROVISIONER.emit(IdeEvent::Task { id, title: title.clone(), message, fraction, done, cancellable: !done });
|
||||
};
|
||||
emit(Some("Eclipse Temurin".into()), None, false);
|
||||
let result = ide_tools::Tools::new(data_dir).download_java_runtime(
|
||||
min_java,
|
||||
&language_server_runtimes_dir(data_dir),
|
||||
&cancel,
|
||||
&|event| match event {
|
||||
ide_tools::InstallEvent::Line { text } => emit(Some(text), None, false),
|
||||
ide_tools::InstallEvent::Progress { fraction, message } => {
|
||||
emit(Some(message), fraction.map(|f| f as f32), false)
|
||||
}
|
||||
},
|
||||
);
|
||||
emit(result.as_ref().err().map(ToString::to_string), Some(1.0), true);
|
||||
if let Err(error) = result {
|
||||
// The configuration hook retains the existing manual download action.
|
||||
tracing::warn!("automatic jdtls runtime download: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn download_decision() {
|
||||
assert!(needs_download(false, false, false));
|
||||
for (explicit, suitable, attempted) in [(true, false, false), (false, true, false), (false, false, true)] {
|
||||
assert!(!needs_download(explicit, suitable, attempted));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_projects_share_one_attempt_and_failures_do_not_retry() {
|
||||
let provisioner = Provisioner::default();
|
||||
let calls = AtomicUsize::new(0);
|
||||
let barrier = std::sync::Barrier::new(8);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..8 {
|
||||
scope.spawn(|| {
|
||||
barrier.wait();
|
||||
provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
provisioner.run((PathBuf::from("/data"), 25), &CancellationToken::new(), || panic!("retried"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_versions_are_still_single_flight() {
|
||||
let provisioner = Provisioner::default();
|
||||
let active = AtomicUsize::new(0);
|
||||
let calls = AtomicUsize::new(0);
|
||||
std::thread::scope(|scope| {
|
||||
for feature in [25, 26, 27] {
|
||||
let provisioner = &provisioner;
|
||||
let active = &active;
|
||||
let calls = &calls;
|
||||
scope.spawn(move || {
|
||||
provisioner.run((PathBuf::from("/data"), feature), &CancellationToken::new(), || {
|
||||
assert_eq!(active.fetch_add(1, Ordering::SeqCst), 0);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_waiter_does_not_consume_an_attempt() {
|
||||
let provisioner = Provisioner::default();
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
provisioner.run((PathBuf::from("/data"), 25), &cancel, || panic!("cancelled"));
|
||||
assert!(provisioner.state.lock().attempted.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,11 @@
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod environment;
|
||||
mod java_runtime;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod project;
|
||||
mod proxy;
|
||||
mod requests;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
@@ -56,6 +59,8 @@ pub struct Host {
|
||||
pub remote: Arc<ide_remote::RemoteService>,
|
||||
/// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`).
|
||||
pub environment: environment::Environment,
|
||||
/// The HTTP proxy's saved password (see `proxy.rs`).
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
@@ -71,7 +76,8 @@ impl Host {
|
||||
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);
|
||||
Self {
|
||||
let host = Self {
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, data_dir.clone()),
|
||||
@@ -89,7 +95,9 @@ impl Host {
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
}
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
}
|
||||
|
||||
/// The tree-sitter grammars (blocks only if start-up warm-up is still running).
|
||||
@@ -126,26 +134,75 @@ impl Host {
|
||||
}
|
||||
|
||||
pub fn close_project(&self, window: &str) {
|
||||
if let Some(project) = self.projects.write().remove(window) {
|
||||
let project = self.projects.write().remove(window);
|
||||
if let Some(project) = project {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops what this host started: language servers, agents, runs and terminals.
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
|
||||
for service in ¬ebooks {
|
||||
service.stop_admission();
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
let wait = move || {
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Err(error) = worker.join() {
|
||||
tracing::error!("notebook shutdown thread failed: {error:?}");
|
||||
}
|
||||
};
|
||||
if tokio::runtime::Handle::try_current()
|
||||
.is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
|
||||
{
|
||||
tokio::task::block_in_place(wait);
|
||||
} else {
|
||||
wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
|
||||
}
|
||||
|
||||
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
|
||||
/// For a process that exits without dropping the host (the remote server).
|
||||
pub async fn shutdown(&self) {
|
||||
self.environment.shutdown();
|
||||
self.processes.kill_all();
|
||||
self.terminals.kill_all();
|
||||
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
|
||||
for project in projects {
|
||||
for project in &projects {
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
project.lsp.shutdown_all().await;
|
||||
}
|
||||
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
|
||||
// outer LSP timeout must never abort it before owned children are reaped.
|
||||
for project in &projects {
|
||||
project.notebooks.shutdown().await;
|
||||
}
|
||||
let stop_language_servers = async {
|
||||
for project in &projects {
|
||||
project.lsp.shutdown_all().await;
|
||||
}
|
||||
};
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(10), stop_language_servers).await.is_err() {
|
||||
tracing::warn!("language servers did not shut down in time");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
//! Project-incarnation notebook ownership and whole-batch execution admission.
|
||||
use crate::ops::Channel;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use ide_notebook::kernel::{KernelManager, KernelOutput};
|
||||
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Mutex as AsyncMutex, watch};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookOutputEvent {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub cell: String,
|
||||
pub msg_id: String,
|
||||
pub kind: String,
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub content: serde_json::Value,
|
||||
pub version: u32,
|
||||
pub dirty: bool,
|
||||
pub changed_cells: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Registry {
|
||||
stopped: bool,
|
||||
paths: HashMap<PathBuf, String>,
|
||||
documents: HashMap<String, Arc<ManagedDocument>>,
|
||||
}
|
||||
|
||||
struct ManagedDocument {
|
||||
id: String,
|
||||
path: PathBuf,
|
||||
state: Mutex<DocumentState>,
|
||||
control: AsyncMutex<()>,
|
||||
}
|
||||
|
||||
struct DocumentState {
|
||||
document: NotebookDocument,
|
||||
generation: u64,
|
||||
transitioning: bool,
|
||||
closed: bool,
|
||||
active: Option<Arc<Batch>>,
|
||||
kernel_status: Option<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
struct Batch {
|
||||
cancel: CancellationToken,
|
||||
done: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
struct BatchGuard {
|
||||
document: Arc<ManagedDocument>,
|
||||
batch: Arc<Batch>,
|
||||
}
|
||||
|
||||
pub(crate) struct ExecutionAdmission {
|
||||
guard: BatchGuard,
|
||||
generation: u64,
|
||||
plan: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Drop for BatchGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.document.state.lock();
|
||||
if state.active.as_ref().is_some_and(|active| Arc::ptr_eq(active, &self.batch)) {
|
||||
if let Err(error) = state.document.set_execution_active(false) {
|
||||
tracing::error!("notebook batch release: {error}");
|
||||
}
|
||||
state.active = None;
|
||||
}
|
||||
self.batch.done.send_replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NotebookService {
|
||||
registry: Arc<Mutex<Registry>>,
|
||||
kernels: Arc<KernelManager>,
|
||||
sink: SharedSink,
|
||||
}
|
||||
|
||||
impl NotebookService {
|
||||
pub fn new(runtime_dir: PathBuf, sink: SharedSink) -> Arc<Self> {
|
||||
let registry = Arc::new(Mutex::new(Registry::default()));
|
||||
let status_registry = registry.clone();
|
||||
let status_sink = sink.clone();
|
||||
let kernels = KernelManager::new(
|
||||
runtime_dir,
|
||||
Arc::new(move |path, status, message| {
|
||||
let document = {
|
||||
let registry = status_registry.lock();
|
||||
if registry.stopped {
|
||||
return;
|
||||
}
|
||||
registry.paths.get(path).and_then(|id| registry.documents.get(id)).cloned()
|
||||
};
|
||||
if let Some(document) = document {
|
||||
let deliver = {
|
||||
let mut state = document.state.lock();
|
||||
if state.closed {
|
||||
return;
|
||||
}
|
||||
state.kernel_status = Some((status.as_str().to_owned(), message.clone()));
|
||||
!state.transitioning
|
||||
};
|
||||
if deliver {
|
||||
status_sink.emit(IdeEvent::NotebookKernel {
|
||||
id: document.id.clone(),
|
||||
path: path.to_path_buf().into(),
|
||||
state: status.as_str().to_owned(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
Arc::new(Self { registry, kernels: Arc::new(kernels), sink })
|
||||
}
|
||||
|
||||
pub fn open(&self, path: &Path) -> IdeResult<NotebookSnapshot> {
|
||||
let path = dunce::canonicalize(path)?;
|
||||
// Serialize first load with path insertion: split views share one incarnation.
|
||||
let mut registry = self.registry.lock();
|
||||
if registry.stopped {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if let Some(id) = registry.paths.get(&path) {
|
||||
return snapshot(®istry.documents[id]);
|
||||
}
|
||||
let document = NotebookDocument::load(&path)?;
|
||||
let initial = document.snapshot();
|
||||
let id = initial.id.clone();
|
||||
registry.paths.insert(path.clone(), id.clone());
|
||||
registry.documents.insert(
|
||||
id.clone(),
|
||||
Arc::new(ManagedDocument {
|
||||
id,
|
||||
path,
|
||||
state: Mutex::new(DocumentState {
|
||||
document,
|
||||
generation: 0,
|
||||
transitioning: false,
|
||||
closed: false,
|
||||
active: None,
|
||||
kernel_status: None,
|
||||
}),
|
||||
control: AsyncMutex::new(()),
|
||||
}),
|
||||
);
|
||||
Ok(initial)
|
||||
}
|
||||
|
||||
fn resolve(&self, id: &str) -> IdeResult<Arc<ManagedDocument>> {
|
||||
let registry = self.registry.lock();
|
||||
if registry.stopped {
|
||||
return Err(IdeError::not_found("notebook project has closed"));
|
||||
}
|
||||
registry.documents.get(id).cloned().ok_or_else(|| IdeError::not_found("notebook incarnation is closed"))
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> IdeResult<NotebookSnapshot> {
|
||||
snapshot(self.resolve(id)?.as_ref())
|
||||
}
|
||||
|
||||
pub fn edit(&self, id: &str, version: u32, edit: NotebookEdit) -> IdeResult<NotebookSnapshot> {
|
||||
let document = self.resolve(id)?;
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
state.document.edit(version, edit)
|
||||
}
|
||||
|
||||
pub fn save(&self, id: &str) -> IdeResult<NotebookSnapshot> {
|
||||
let document = self.resolve(id)?;
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
state.document.save()
|
||||
}
|
||||
|
||||
pub fn undo(&self, id: &str, redo: bool) -> IdeResult<NotebookSnapshot> {
|
||||
let document = self.resolve(id)?;
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
if state.active.is_some() {
|
||||
return Err(IdeError::unavailable("stop notebook execution before undo or redo"));
|
||||
}
|
||||
if redo { state.document.redo() } else { state.document.undo() }
|
||||
}
|
||||
|
||||
pub async fn interrupt(&self, id: &str) -> IdeResult<()> {
|
||||
let document = self.resolve(id)?;
|
||||
let batch = document.state.lock().active.clone();
|
||||
if let Some(batch) = &batch {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
// Draining makes prepareClose/save a stable checkpoint, including when
|
||||
// Python catches KeyboardInterrupt or cancel races with kernel startup.
|
||||
let interrupt = self.kernels.interrupt(&document.path).await;
|
||||
self.drain(&document, batch).await?;
|
||||
interrupt
|
||||
}
|
||||
|
||||
async fn drain(&self, document: &ManagedDocument, batch: Option<Arc<Batch>>) -> IdeResult<()> {
|
||||
let Some(batch) = batch else { return Ok(()) };
|
||||
let mut done = batch.done.subscribe();
|
||||
match tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done)).await {
|
||||
Ok(Ok(_)) => return Ok(()),
|
||||
Ok(Err(_)) => return Err(IdeError::internal("notebook execution completion channel closed")),
|
||||
Err(_) => {}
|
||||
}
|
||||
// An uncooperative kernel must not outlive a close/reload authorization.
|
||||
self.kernels.shutdown_kernel(&document.path).await?;
|
||||
tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done))
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("notebook execution did not stop after kernel shutdown"))?
|
||||
.map_err(|_| IdeError::internal("notebook execution completion channel closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload(&self, id: &str, restart: bool) -> IdeResult<NotebookSnapshot> {
|
||||
let document = self.resolve(id)?;
|
||||
let _control = document.control.lock().await;
|
||||
let batch = invalidate(&document)?;
|
||||
let result = async {
|
||||
let _ = self.kernels.interrupt(&document.path).await;
|
||||
self.drain(&document, batch).await?;
|
||||
if document.state.lock().closed {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if restart {
|
||||
self.kernels.restart(&document.path).await?;
|
||||
snapshot(&document)
|
||||
} else {
|
||||
self.kernels.shutdown_kernel(&document.path).await?;
|
||||
document.state.lock().document.reload()
|
||||
}
|
||||
}
|
||||
.await;
|
||||
let status = {
|
||||
let mut state = document.state.lock();
|
||||
state.transitioning = false;
|
||||
if state.closed { None } else { state.kernel_status.clone() }
|
||||
};
|
||||
if let Some((state, message)) = status {
|
||||
self.sink.emit(IdeEvent::NotebookKernel {
|
||||
id: document.id.clone(),
|
||||
path: document.path.clone().into(),
|
||||
state,
|
||||
message,
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Frontend disposal may arrive after project replacement has already retired this id.
|
||||
pub async fn close(&self, id: &str) -> IdeResult<()> {
|
||||
let document = match self.resolve(id) {
|
||||
Ok(document) => document,
|
||||
Err(error) if error.kind == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let _control = document.control.lock().await;
|
||||
if document.state.lock().closed {
|
||||
return Ok(());
|
||||
}
|
||||
let batch = invalidate(&document)?;
|
||||
let result = async {
|
||||
let _ = self.kernels.interrupt(&document.path).await;
|
||||
self.drain(&document, batch).await?;
|
||||
self.kernels.shutdown_kernel(&document.path).await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
document.state.lock().transitioning = false;
|
||||
return Err(error);
|
||||
}
|
||||
document.state.lock().closed = true;
|
||||
let mut registry = self.registry.lock();
|
||||
registry.documents.remove(id);
|
||||
registry.paths.remove(&document.path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn admit(
|
||||
&self,
|
||||
id: &str,
|
||||
cells: Vec<String>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<ExecutionAdmission> {
|
||||
admit(self.resolve(id)?, cells, cancel)
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(
|
||||
self: &Arc<Self>,
|
||||
admission: ExecutionAdmission,
|
||||
python: PathBuf,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
let document = admission.guard.document.clone();
|
||||
let service = self.clone();
|
||||
// Keep admission and native execution owned if an IPC receiver disappears.
|
||||
tokio::spawn(async move {
|
||||
let ExecutionAdmission { guard, generation, plan } = admission;
|
||||
let result =
|
||||
service.run_batch(document.clone(), generation, plan, python, guard.batch.clone(), on_output).await;
|
||||
drop(guard);
|
||||
match result {
|
||||
Ok(()) => snapshot(&document),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
async fn run_batch(
|
||||
&self,
|
||||
document: Arc<ManagedDocument>,
|
||||
generation: u64,
|
||||
plan: Vec<(String, String)>,
|
||||
python: PathBuf,
|
||||
batch: Arc<Batch>,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<()> {
|
||||
for (cell, source) in plan {
|
||||
{
|
||||
let mut state = document.state.lock();
|
||||
if batch.cancel.is_cancelled() || state.closed || state.generation != generation {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// A user may delete a queued cell while an earlier one executes.
|
||||
// Never resurrect it from the captured source or a late output.
|
||||
match state.document.begin_execution(&cell) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind == ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
let output_error = Arc::new(Mutex::new(None));
|
||||
let callback_document = document.clone();
|
||||
let callback_batch = batch.clone();
|
||||
let callback_error = output_error.clone();
|
||||
let callback_cell = cell.clone();
|
||||
let channel = on_output.clone();
|
||||
let result = self
|
||||
.kernels
|
||||
.execute(
|
||||
&document.path,
|
||||
&python,
|
||||
&source,
|
||||
batch.cancel.clone(),
|
||||
Arc::new(move |output: KernelOutput| {
|
||||
let event = match commit_output(
|
||||
&callback_document,
|
||||
&callback_batch,
|
||||
generation,
|
||||
&callback_cell,
|
||||
output,
|
||||
) {
|
||||
Ok(Some(event)) => event,
|
||||
Ok(None) => return,
|
||||
Err(error) => {
|
||||
*callback_error.lock() = Some(error);
|
||||
callback_batch.cancel.cancel();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = channel.send(event);
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(error) = output_error.lock().take() {
|
||||
return Err(error);
|
||||
}
|
||||
let result = result?;
|
||||
if batch.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if result.status != "ok" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let state = document.state.lock();
|
||||
if state.closed || state.generation != generation || batch.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synchronously revoke admission before handing cleanup to an async runtime.
|
||||
pub fn stop_admission(&self) {
|
||||
let mut registry = self.registry.lock();
|
||||
registry.stopped = true;
|
||||
for document in registry.documents.values() {
|
||||
let mut state = document.state.lock();
|
||||
state.closed = true;
|
||||
state.generation += 1;
|
||||
if let Some(batch) = &state.active {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
self.stop_admission();
|
||||
self.kernels.shutdown().await;
|
||||
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
|
||||
for document in documents {
|
||||
let batch = document.state.lock().active.clone();
|
||||
if let Err(error) = self.drain(&document, batch).await {
|
||||
tracing::error!("notebook cleanup: {error}");
|
||||
}
|
||||
}
|
||||
let mut registry = self.registry.lock();
|
||||
registry.paths.clear();
|
||||
registry.documents.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn mutable(state: &DocumentState) -> IdeResult<()> {
|
||||
if state.closed {
|
||||
Err(IdeError::not_found("notebook incarnation is closed"))
|
||||
} else if state.transitioning {
|
||||
Err(IdeError::unavailable("notebook lifecycle operation is in progress"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(document: &ManagedDocument) -> IdeResult<NotebookSnapshot> {
|
||||
let state = document.state.lock();
|
||||
if state.closed {
|
||||
return Err(IdeError::not_found("notebook incarnation is closed"));
|
||||
}
|
||||
Ok(state.document.snapshot())
|
||||
}
|
||||
|
||||
fn invalidate(document: &ManagedDocument) -> IdeResult<Option<Arc<Batch>>> {
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
state.transitioning = true;
|
||||
state.generation += 1;
|
||||
if let Some(batch) = &state.active {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
Ok(state.active.clone())
|
||||
}
|
||||
|
||||
fn execution_plan(snapshot: &NotebookSnapshot, cells: Vec<String>) -> IdeResult<Vec<(String, String)>> {
|
||||
let mut selected: HashSet<_> = cells.iter().map(String::as_str).collect();
|
||||
if selected.is_empty() || selected.len() != cells.len() {
|
||||
return Err(IdeError::invalid("select distinct code cells to execute"));
|
||||
}
|
||||
let mut plan = Vec::with_capacity(selected.len());
|
||||
for cell in &snapshot.cells {
|
||||
if selected.remove(cell.id.as_str()) {
|
||||
if cell.cell_type != "code" {
|
||||
return Err(IdeError::invalid("only code cells can execute"));
|
||||
}
|
||||
plan.push((cell.id.clone(), cell.source.clone()));
|
||||
}
|
||||
}
|
||||
if !selected.is_empty() {
|
||||
return Err(IdeError::not_found("an execution cell no longer exists"));
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn admit(
|
||||
document: Arc<ManagedDocument>,
|
||||
cells: Vec<String>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<ExecutionAdmission> {
|
||||
let (batch, generation, plan) = {
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
if state.active.is_some() {
|
||||
return Err(IdeError::unavailable("notebook execution batch is already running"));
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let plan = execution_plan(&state.document.snapshot(), cells)?;
|
||||
state.document.set_execution_active(true)?;
|
||||
let (done, _) = watch::channel(false);
|
||||
let batch = Arc::new(Batch { cancel, done });
|
||||
state.active = Some(batch.clone());
|
||||
(batch, state.generation, plan)
|
||||
};
|
||||
Ok(ExecutionAdmission { guard: BatchGuard { document, batch }, generation, plan })
|
||||
}
|
||||
|
||||
/// Commit under the short document lock; the caller delivers only after release.
|
||||
fn commit_output(
|
||||
document: &ManagedDocument,
|
||||
batch: &Arc<Batch>,
|
||||
generation: u64,
|
||||
cell: &str,
|
||||
output: KernelOutput,
|
||||
) -> IdeResult<Option<NotebookOutputEvent>> {
|
||||
let mut state = document.state.lock();
|
||||
if state.closed
|
||||
|| state.generation != generation
|
||||
|| !state.active.as_ref().is_some_and(|active| Arc::ptr_eq(active, batch))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let changed_cells = match state.document.record_output(cell, &output) {
|
||||
Ok(changed) => changed,
|
||||
Err(error) if error.kind == ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
Ok(Some(NotebookOutputEvent {
|
||||
id: document.id.clone(),
|
||||
path: document.path.to_string_lossy().into_owned(),
|
||||
cell: cell.to_owned(),
|
||||
msg_id: output.msg_id,
|
||||
kind: output.kind,
|
||||
content: output.content,
|
||||
version: state.document.version(),
|
||||
dirty: state.document.is_dirty(),
|
||||
changed_cells,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, Arc<NotebookService>, NotebookSnapshot) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("session.ipynb");
|
||||
std::fs::write(&path, serde_json::to_vec(&json!({
|
||||
"nbformat": 4, "nbformat_minor": 5, "metadata": {},
|
||||
"cells": [
|
||||
{"id":"first","cell_type":"code","metadata":{},"source":"x = 1","outputs":[],"execution_count":null},
|
||||
{"id":"second","cell_type":"code","metadata":{},"source":"print(x)","outputs":[],"execution_count":null},
|
||||
{"id":"prose","cell_type":"markdown","metadata":{},"source":"notes"}
|
||||
]
|
||||
})).unwrap()).unwrap();
|
||||
let service = NotebookService::new(dir.path().join("kernels"), Arc::new(ide_core::NullSink));
|
||||
let snapshot = service.open(&path).unwrap();
|
||||
(dir, service, snapshot)
|
||||
}
|
||||
|
||||
fn output(kind: &str, content: serde_json::Value) -> KernelOutput {
|
||||
KernelOutput { kind: kind.into(), content, msg_id: "actual-parent-request".into() }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_split_opens_share_incarnation_but_reopen_and_projects_do_not() {
|
||||
let (dir, service, initial) = fixture();
|
||||
let alternate = dir.path().join(".").join("session.ipynb");
|
||||
assert_eq!(service.open(&alternate).unwrap().id, initial.id);
|
||||
let other = NotebookService::new(dir.path().join("other-runtime"), Arc::new(ide_core::NullSink));
|
||||
let independent = other.open(&alternate).unwrap();
|
||||
assert_ne!(initial.id, independent.id);
|
||||
assert_eq!(other.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
|
||||
service.close(&initial.id).await.unwrap();
|
||||
let reopened = service.open(&alternate).unwrap();
|
||||
assert_ne!(reopened.id, initial.id);
|
||||
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
|
||||
// A delayed old-incarnation close cannot dispose a new session at that path.
|
||||
service.close(&initial.id).await.unwrap();
|
||||
assert_eq!(service.get(&reopened.id).unwrap().id, reopened.id);
|
||||
service.shutdown().await;
|
||||
other.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
|
||||
let (dir, service, initial) = fixture();
|
||||
// A batch that never drains models a kernel that ignores interrupt and shutdown.
|
||||
let admission =
|
||||
service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
|
||||
let error = service.close(&initial.id).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Unavailable);
|
||||
assert!(admission.guard.batch.cancel.is_cancelled());
|
||||
// The backend still owns the incarnation: a reopen shares it instead of
|
||||
// starting a second kernel, and the failed close left it usable.
|
||||
let reopened = service.open(&dir.path().join("session.ipynb")).unwrap();
|
||||
assert_eq!(reopened.id, initial.id);
|
||||
assert!(matches!(service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()),
|
||||
Err(error) if error.kind == ErrorKind::Unavailable));
|
||||
drop(admission);
|
||||
service.close(&initial.id).await.unwrap();
|
||||
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
|
||||
assert_ne!(service.open(&dir.path().join("session.ipynb")).unwrap().id, initial.id);
|
||||
service.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_admission_captures_document_order_and_retains_busy_until_drained() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
let cells = vec![initial.cells[1].id.clone(), initial.cells[0].id.clone()];
|
||||
let admission = admit(document.clone(), cells.clone(), CancellationToken::new()).unwrap();
|
||||
assert_eq!(
|
||||
admission.plan,
|
||||
vec![(initial.cells[0].id.clone(), "x = 1".into()), (initial.cells[1].id.clone(), "print(x)".into()),]
|
||||
);
|
||||
service
|
||||
.edit(
|
||||
&initial.id,
|
||||
initial.version,
|
||||
NotebookEdit::SetSource { cell: initial.cells[1].id.clone(), source: "print('new source')".into() },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(admission.plan[1].1, "print(x)");
|
||||
assert_eq!(service.undo(&initial.id, false).unwrap_err().kind, ErrorKind::Unavailable);
|
||||
admission.guard.batch.cancel.cancel();
|
||||
assert!(matches!(admit(document.clone(), cells.clone(), CancellationToken::new()), Err(error)
|
||||
if error.kind == ErrorKind::Unavailable));
|
||||
drop(admission);
|
||||
let next = admit(document, cells, CancellationToken::new()).unwrap();
|
||||
assert_eq!(next.plan[1].1, "print('new source')");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_commits_revision_and_cross_cell_display_updates_before_delivery() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
let cells: Vec<_> = initial.cells[..2].iter().map(|cell| cell.id.clone()).collect();
|
||||
let admission = admit(document.clone(), cells.clone(), CancellationToken::new()).unwrap();
|
||||
let event = commit_output(
|
||||
&document,
|
||||
&admission.guard.batch,
|
||||
admission.generation,
|
||||
&cells[0],
|
||||
output(
|
||||
"display_data",
|
||||
json!({"data":{"text/plain":"before"},"metadata":{},"transient":{"display_id":"shared"}}),
|
||||
),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let committed = service.get(&initial.id).unwrap();
|
||||
assert_eq!(event.version, committed.version);
|
||||
assert!(event.version > initial.version);
|
||||
assert!(event.dirty);
|
||||
assert_eq!(event.msg_id, "actual-parent-request");
|
||||
assert_eq!(committed.cells[0].outputs[0]["data"]["text/plain"], "before");
|
||||
let updated = commit_output(
|
||||
&document,
|
||||
&admission.guard.batch,
|
||||
admission.generation,
|
||||
&cells[1],
|
||||
output(
|
||||
"update_display_data",
|
||||
json!({"data":{"text/plain":"after"},"metadata":{},"transient":{"display_id":"shared"}}),
|
||||
),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(updated.changed_cells.contains(&cells[0]));
|
||||
let committed = service.get(&initial.id).unwrap();
|
||||
assert_eq!(committed.cells[0].outputs[0]["data"]["text/plain"], "after");
|
||||
assert!(committed.cells[0].outputs[0].get("transient").is_none());
|
||||
assert_eq!(updated.version, committed.version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidated_or_completed_batches_cannot_commit_and_deleted_cells_stay_deleted() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
let cell = initial.cells[0].id.clone();
|
||||
let admission = admit(document.clone(), vec![cell.clone()], CancellationToken::new()).unwrap();
|
||||
let deleted =
|
||||
service.edit(&initial.id, initial.version, NotebookEdit::DeleteCell { cell: cell.clone() }).unwrap();
|
||||
assert!(
|
||||
commit_output(
|
||||
&document,
|
||||
&admission.guard.batch,
|
||||
admission.generation,
|
||||
&cell,
|
||||
output("stream", json!({"name":"stdout","text":"late"}))
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(service.get(&initial.id).unwrap().version, deleted.version);
|
||||
let other = initial.cells[1].id.clone();
|
||||
invalidate(&document).unwrap();
|
||||
assert!(admission.guard.batch.cancel.is_cancelled());
|
||||
assert!(
|
||||
commit_output(
|
||||
&document,
|
||||
&admission.guard.batch,
|
||||
admission.generation,
|
||||
&other,
|
||||
output("stream", json!({"name":"stdout","text":"old generation"}))
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let batch = admission.guard.batch.clone();
|
||||
let generation = document.state.lock().generation;
|
||||
drop(admission);
|
||||
assert!(
|
||||
commit_output(
|
||||
&document,
|
||||
&batch,
|
||||
generation,
|
||||
&other,
|
||||
output("stream", json!({"name":"stdout","text":"post idle"}))
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(service.get(&initial.id).unwrap().version, deleted.version);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_preserves_incarnation_and_project_shutdown_revokes_all_admission() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let changed = service
|
||||
.edit(
|
||||
&initial.id,
|
||||
initial.version,
|
||||
NotebookEdit::SetSource { cell: initial.cells[0].id.clone(), source: "changed".into() },
|
||||
)
|
||||
.unwrap();
|
||||
let reloaded = service.reload(&initial.id, false).await.unwrap();
|
||||
assert_eq!(reloaded.id, initial.id);
|
||||
assert!(reloaded.version > changed.version);
|
||||
assert_eq!(reloaded.cells[0].source, "x = 1");
|
||||
assert!(!reloaded.dirty);
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
let admission = admit(document.clone(), vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
|
||||
service.stop_admission();
|
||||
assert!(admission.guard.batch.cancel.is_cancelled());
|
||||
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
|
||||
assert!(matches!(admit(document, vec![initial.cells[0].id.clone()], CancellationToken::new()), Err(error)
|
||||
if error.kind == ErrorKind::NotFound));
|
||||
drop(admission);
|
||||
service.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_selection_does_not_claim_admission() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
for cells in [
|
||||
vec![],
|
||||
vec![initial.cells[0].id.clone(), initial.cells[0].id.clone()],
|
||||
vec![initial.cells[2].id.clone()],
|
||||
vec!["deleted".into()],
|
||||
] {
|
||||
assert!(admit(document.clone(), cells, CancellationToken::new()).is_err());
|
||||
}
|
||||
let cancelled = CancellationToken::new();
|
||||
cancelled.cancel();
|
||||
assert!(matches!(admit(document.clone(), vec![initial.cells[0].id.clone()], cancelled), Err(error)
|
||||
if error.kind == ErrorKind::Cancelled));
|
||||
let valid = admit(document, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
|
||||
assert_eq!(valid.plan[0].1, "x = 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupt_cancels_before_drain_and_does_not_release_a_live_batch() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
service
|
||||
.edit(
|
||||
&initial.id,
|
||||
initial.version,
|
||||
NotebookEdit::SetSource { cell: initial.cells[0].id.clone(), source: "changed source".into() },
|
||||
)
|
||||
.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let admission = service.admit(&initial.id, vec![initial.cells[0].id.clone()], cancel.clone()).unwrap();
|
||||
let interrupt_service = service.clone();
|
||||
let id = initial.id.clone();
|
||||
let interrupt = tokio::spawn(async move { interrupt_service.interrupt(&id).await });
|
||||
tokio::time::timeout(Duration::from_secs(1), cancel.cancelled()).await.unwrap();
|
||||
assert!(!interrupt.is_finished(), "interrupt must wait for owned execution to drain");
|
||||
assert!(matches!(service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()),
|
||||
Err(error) if error.kind == ErrorKind::Unavailable));
|
||||
let mut done = admission.guard.batch.done.subscribe();
|
||||
drop(admission);
|
||||
assert!(*done.borrow_and_update());
|
||||
tokio::time::timeout(Duration::from_secs(1), interrupt).await.unwrap().unwrap().unwrap();
|
||||
let next = service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
|
||||
assert_eq!(next.plan[0].1, "changed source");
|
||||
drop(next);
|
||||
let undone = service.undo(&initial.id, false).unwrap();
|
||||
assert_eq!(undone.cells[0].source, "x = 1");
|
||||
service.close(&initial.id).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,14 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
|
||||
host.project(window)?.agents.entries(&task, subagent)
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.project(window)?.agents.create(request).await
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let requested = request.project_root.take();
|
||||
request.project_root = Some(IpcPath(
|
||||
host.blocking(move || validate_project_root(&root, requested.as_ref().map(|p| p.0.as_path()))).await?,
|
||||
));
|
||||
project.agents.create(request).await
|
||||
}
|
||||
|
||||
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
|
||||
@@ -140,6 +146,10 @@ pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.archive(&task)
|
||||
}
|
||||
|
||||
pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.unarchive(&task)
|
||||
}
|
||||
|
||||
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
@@ -183,6 +193,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_respond", |c| async move {
|
||||
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
|
||||
});
|
||||
d.add("agent_unarchive", |c| async move { unarchive(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add(
|
||||
"agent_delete",
|
||||
@@ -191,3 +202,43 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
|
||||
}
|
||||
|
||||
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
|
||||
let Some(requested) = requested else {
|
||||
return Ok(root.to_path_buf());
|
||||
};
|
||||
if !requested.is_absolute() {
|
||||
return Err(IdeError::invalid("Choose an absolute project root"));
|
||||
}
|
||||
let requested = dunce::canonicalize(requested)?;
|
||||
let window_root = dunce::canonicalize(root)?;
|
||||
if requested == window_root
|
||||
|| ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.projects.contains(&requested))
|
||||
{
|
||||
return Ok(requested);
|
||||
}
|
||||
Err(IdeError::invalid("Agent project must be the window project or a workspace member"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn agent_roots_are_exact_window_or_workspace_members() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("workspace");
|
||||
let member = tmp.path().join("member");
|
||||
let other = tmp.path().join("other");
|
||||
for dir in [&root, &member, &other, &member.join("child")] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
assert_eq!(validate_project_root(&root, None).unwrap(), root);
|
||||
assert_eq!(validate_project_root(&root, Some(&root)).unwrap(), dunce::canonicalize(&root).unwrap());
|
||||
assert!(validate_project_root(&root, Some(&member)).is_err());
|
||||
ide_workspace::workspace::Workspace::save(&root, "Workspace", vec![member.clone()], true).unwrap();
|
||||
assert_eq!(validate_project_root(&root, Some(&member)).unwrap(), dunce::canonicalize(&member).unwrap());
|
||||
for rejected in [other, member.join("child"), member.join("../other"), "relative".into()] {
|
||||
assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,12 @@ fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> B
|
||||
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
|
||||
/// the Maven home and options from Settings › Maven.
|
||||
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
|
||||
let mut model = ide_build::load_linked(
|
||||
root,
|
||||
cache,
|
||||
ide_sdk::apply::maven_local_repository(root, settings),
|
||||
&settings.languages.java.maven,
|
||||
);
|
||||
let jdk = ide_sdk::apply::project_jdk(root, settings);
|
||||
if let Some(m) = &mut model.maven {
|
||||
let mvn = ide_sdk::apply::maven_command(root, settings);
|
||||
@@ -41,7 +46,8 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
}
|
||||
}
|
||||
if let Some(g) = &mut model.gradle {
|
||||
g.env = java_env(jdk.as_ref(), g.java_version);
|
||||
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
|
||||
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
|
||||
}
|
||||
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
|
||||
let missing = |release: Option<u32>| match jdk {
|
||||
@@ -62,7 +68,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
if let Some(g) = &mut model.gradle
|
||||
&& g.sync_error.is_some()
|
||||
{
|
||||
let hint = missing(g.java_version);
|
||||
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
|
||||
explain(&mut g.sync_error, hint);
|
||||
}
|
||||
model
|
||||
@@ -107,6 +113,8 @@ pub async fn build_sync(
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
@@ -115,12 +123,27 @@ pub async fn build_sync(
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
|
||||
}
|
||||
Ok(load(&root, &cache, &settings))
|
||||
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
let model = result??;
|
||||
if tool == BuildTool::Maven
|
||||
&& let Some(maven) = &model.maven
|
||||
{
|
||||
// jdtls re-imports on pom changes but misses artifacts added to ~/.m2
|
||||
// meanwhile ("missing artifact" stays until Invalidate Caches); asking it
|
||||
// to update the project configurations re-resolves them (#436).
|
||||
let lsp = project.lsp.clone();
|
||||
let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
|
||||
@@ -203,6 +226,7 @@ pub async fn build_run(
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let config_dir = host.config_dir.clone();
|
||||
let token = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
@@ -212,7 +236,23 @@ pub async fn build_run(
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
|
||||
let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
|
||||
let mut combined: Option<compile::BuildPlan> = None;
|
||||
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
|
||||
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
|
||||
let member_cache = cache.join(crate::project::project_key(member));
|
||||
let plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?;
|
||||
match &mut combined {
|
||||
Some(combined) => combined.steps.extend(plan.steps),
|
||||
None => combined = Some(plan),
|
||||
}
|
||||
}
|
||||
let mut plan = combined.ok_or_else(|| IdeError::invalid("No workspace project to build"))?;
|
||||
plan.title = workspace.name;
|
||||
plan
|
||||
} else {
|
||||
compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?
|
||||
};
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
@@ -261,3 +301,34 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
|
||||
std::fs::write(&wrapper, "").unwrap();
|
||||
let mut settings = Settings::default();
|
||||
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
assert_eq!(default.command, wrapper.to_string_lossy());
|
||||
settings.languages.java.gradle.jvm = "jdk21".into();
|
||||
settings.languages.java.gradle.user_home = "user-home".into();
|
||||
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
|
||||
settings.languages.java.gradle.home = "gradle-install".into();
|
||||
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
assert_eq!(
|
||||
model.command,
|
||||
root.join("gradle-install")
|
||||
.join("bin")
|
||||
.join(if cfg!(windows) { "gradle.bat" } else { "gradle" })
|
||||
.to_string_lossy()
|
||||
);
|
||||
assert_eq!(model.env["JAVA_HOME"], root.join("jdk21").to_string_lossy());
|
||||
assert_eq!(model.env["GRADLE_USER_HOME"], root.join("user-home").to_string_lossy());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
mod output;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DebugSessionId, IdeResult};
|
||||
@@ -6,18 +8,82 @@ use std::path::PathBuf;
|
||||
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
&& let Ok(project) = host.project(window)
|
||||
{
|
||||
super::python::prepare_debug(&mut config, &super::python::paths(&project))?;
|
||||
}
|
||||
let label = window;
|
||||
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
|
||||
let (sink, flush_output) = output::coalescing_sink(host.window_sink(label));
|
||||
let owner = SessionOwner::new(host.owner_window(label), sink);
|
||||
let settings = match host.project(label) {
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
if config.adapter == ide_dap::kotlin::ADAPTER {
|
||||
let data_dir = host.data_dir.clone();
|
||||
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let runtime_settings = settings.clone();
|
||||
let runtime = host
|
||||
.blocking(move || {
|
||||
if let Some(jdk) = ide_sdk::apply::jdtls_jdk(&runtime_root, &runtime_settings)
|
||||
.map_err(ide_core::IdeError::unavailable)?
|
||||
{
|
||||
if jdk.major >= 11 {
|
||||
return Ok(jdk);
|
||||
}
|
||||
return Err(ide_core::IdeError::unavailable(
|
||||
"Kotlin debugger needs JDK 11 or newer. Choose a newer language server JDK in Settings.",
|
||||
));
|
||||
}
|
||||
let jdks = ide_sdk::java::jdtls_jdks(11, &data_dir);
|
||||
ide_sdk::java::jdtls_runtime(&jdks, 11).cloned().ok_or_else(|| {
|
||||
ide_core::IdeError::unavailable(
|
||||
"Kotlin debugger needs JDK 11 or newer. Download or choose a language server JDK in Settings.",
|
||||
)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
let mut extra: serde_json::Value = config
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.map_err(|e| ide_core::IdeError::invalid(e.to_string()))?
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
extra["__adapterJavaHome"] = serde_json::json!(runtime.home);
|
||||
config.extra_json = Some(extra.to_string());
|
||||
}
|
||||
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let custom = |command: &str| {
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.debugger.start_with(config, owner, custom).await
|
||||
let result = host.debugger.start_with(config, owner, custom).await;
|
||||
flush_output();
|
||||
result
|
||||
}
|
||||
|
||||
/// A JVM launched by java-debug writes what java-debug decodes its output as
|
||||
/// (see [`ide_core::output_encoding::java_debug_vm_args`]).
|
||||
fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchConfig {
|
||||
if config.adapter != "java-debug" || config.request != "launch" {
|
||||
return config;
|
||||
}
|
||||
let Some(serde_json::Value::Object(mut extra)) =
|
||||
config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok())
|
||||
else {
|
||||
return config;
|
||||
};
|
||||
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let encoding = extra.get("encoding").and_then(|v| v.as_str());
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
|
||||
extra.insert("vmArgs".into(), vm_args.into());
|
||||
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
pub async fn debug_set_breakpoints(
|
||||
@@ -355,3 +421,46 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn launch(adapter: &str, request: &str, extra: serde_json::Value) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
name: "DromaraApplication".into(),
|
||||
adapter: adapter.into(),
|
||||
request: request.into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn vm_args(config: &LaunchConfig) -> serde_json::Value {
|
||||
serde_json::from_str::<serde_json::Value>(config.extra_json.as_deref().unwrap()).unwrap()["vmArgs"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_on_windows_write_utf8() {
|
||||
let extra =
|
||||
serde_json::json!({"mainClass": "org.dromara.DromaraApplication", "vmArgs": "-Xmx1g", "debugServer": 1});
|
||||
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
|
||||
assert_eq!(
|
||||
vm_args(&fixed),
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
|
||||
"org.dromara.DromaraApplication"
|
||||
);
|
||||
// Attaching, other adapters and other platforms are left alone.
|
||||
for (adapter, request, windows) in
|
||||
[("java-debug", "attach", true), ("debugpy", "launch", true), ("java-debug", "launch", false)]
|
||||
{
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
use ide_core::event::DebugEvent;
|
||||
use ide_core::{DebugSessionId, IdeEvent, SharedSink};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const WINDOW: Duration = Duration::from_millis(50);
|
||||
const MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Pending {
|
||||
sessions: HashMap<DebugSessionId, (String, String)>,
|
||||
scheduled: bool,
|
||||
}
|
||||
|
||||
fn flush(pending: &mut Pending, sink: &SharedSink, session: DebugSessionId) {
|
||||
if let Some((category, output)) = pending.sessions.remove(&session) {
|
||||
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
|
||||
}
|
||||
}
|
||||
|
||||
/// All debug output sources (including build output and child sessions) share this
|
||||
/// bounded batch before reaching desktop or remote IPC. Control events flush
|
||||
/// their session first, so a stop or exit cannot overtake preceding output.
|
||||
pub(super) fn coalescing_sink(sink: SharedSink) -> (SharedSink, impl Fn()) {
|
||||
let pending = Arc::new(Mutex::new(Pending::default()));
|
||||
let flush_pending = {
|
||||
let pending = pending.clone();
|
||||
let sink = sink.clone();
|
||||
move || {
|
||||
let mut state = pending.lock();
|
||||
for (session, (category, output)) in state.sessions.drain() {
|
||||
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
|
||||
}
|
||||
}
|
||||
};
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
let output: SharedSink = Arc::new(move |event| {
|
||||
let mut state = pending.lock();
|
||||
match event {
|
||||
IdeEvent::Debug { session, event: DebugEvent::Output { category, output } } => {
|
||||
if output.is_empty() {
|
||||
return;
|
||||
}
|
||||
if state.sessions.get(&session).is_some_and(|(c, _)| *c != category) {
|
||||
flush(&mut state, &sink, session);
|
||||
}
|
||||
let mut rest = output.as_str();
|
||||
while !rest.is_empty() {
|
||||
let (_, text) = state.sessions.entry(session).or_insert_with(|| (category.clone(), String::new()));
|
||||
let mut end = rest.len().min(MAX_BYTES - text.len());
|
||||
while !rest.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.push_str(&rest[..end]);
|
||||
rest = &rest[end..];
|
||||
if text.len() == MAX_BYTES || !rest.is_empty() {
|
||||
flush(&mut state, &sink, session);
|
||||
}
|
||||
}
|
||||
if !state.scheduled && !state.sessions.is_empty() {
|
||||
state.scheduled = true;
|
||||
let pending = pending.clone();
|
||||
let sink = sink.clone();
|
||||
runtime.spawn(async move {
|
||||
tokio::time::sleep(WINDOW).await;
|
||||
let mut state = pending.lock();
|
||||
for (session, (category, output)) in state.sessions.drain() {
|
||||
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category, output } });
|
||||
}
|
||||
state.scheduled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
IdeEvent::Debug { session, event } => {
|
||||
flush(&mut state, &sink, session);
|
||||
sink.emit(IdeEvent::Debug { session, event });
|
||||
}
|
||||
event => sink.emit(event),
|
||||
}
|
||||
});
|
||||
(output, flush_pending)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup() -> (SharedSink, Arc<Mutex<Vec<IdeEvent>>>) {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let saved = events.clone();
|
||||
(coalescing_sink(Arc::new(move |event| saved.lock().push(event))).0, events)
|
||||
}
|
||||
|
||||
fn output(sink: &SharedSink, session: u32, category: &str, output: &str) {
|
||||
sink.emit(IdeEvent::Debug {
|
||||
session: DebugSessionId(session),
|
||||
event: DebugEvent::Output { category: category.into(), output: output.into() },
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn explicit_start_completion_flush_does_not_duplicate_output() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let saved = events.clone();
|
||||
let (sink, flush) = coalescing_sink(Arc::new(move |event| saved.lock().push(event)));
|
||||
output(&sink, 1, "console", "build failed\n");
|
||||
flush();
|
||||
assert_eq!(events.lock().len(), 1);
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(WINDOW).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(events.lock().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn batches_per_session_and_flushes_idle_output() {
|
||||
let (sink, events) = setup();
|
||||
for _ in 0..500 {
|
||||
output(&sink, 1, "stdout", "line\n");
|
||||
}
|
||||
output(&sink, 2, "stdout", "other\n");
|
||||
assert!(events.lock().is_empty());
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(WINDOW).await;
|
||||
tokio::task::yield_now().await;
|
||||
let events = events.lock();
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(events.iter().any(|event| matches!(event,
|
||||
IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Output { output, .. } }
|
||||
if output == &"line\n".repeat(500))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn categories_and_control_events_keep_order() {
|
||||
let (sink, events) = setup();
|
||||
output(&sink, 1, "stdout", "out");
|
||||
output(&sink, 1, "stderr", "err");
|
||||
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
|
||||
let events = events.lock();
|
||||
assert_eq!(events.len(), 3);
|
||||
assert!(matches!(&events[0], IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. }
|
||||
if category == "stdout" && output == "out"));
|
||||
assert!(matches!(&events[1], IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. }
|
||||
if category == "stderr" && output == "err"));
|
||||
assert!(matches!(&events[2], IdeEvent::Debug { event: DebugEvent::Terminated, .. }));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn terminating_one_session_preserves_other_pending_output() {
|
||||
let (sink, events) = setup();
|
||||
output(&sink, 1, "stdout", "first");
|
||||
output(&sink, 2, "stdout", "second");
|
||||
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
|
||||
assert_eq!(events.lock().len(), 2);
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(WINDOW).await;
|
||||
tokio::task::yield_now().await;
|
||||
let events = events.lock();
|
||||
assert_eq!(events.len(), 3);
|
||||
assert!(matches!(&events[2],
|
||||
IdeEvent::Debug { session: DebugSessionId(2), event: DebugEvent::Output { output, .. } }
|
||||
if output == "second"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn large_unicode_events_are_bounded_without_loss() {
|
||||
let (sink, events) = setup();
|
||||
let text = "日志\n".repeat(100_000);
|
||||
output(&sink, 1, "stdout", &text);
|
||||
sink.emit(IdeEvent::Debug { session: DebugSessionId(1), event: DebugEvent::Terminated });
|
||||
let mut combined = String::new();
|
||||
for event in events.lock().iter() {
|
||||
if let IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } = event {
|
||||
assert!(output.len() <= MAX_BYTES);
|
||||
combined.push_str(output);
|
||||
}
|
||||
}
|
||||
assert_eq!(combined, text);
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,14 @@ pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: C
|
||||
|
||||
pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save(doc)).await
|
||||
host.blocking(move || project.documents.save(doc)).await?;
|
||||
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
|
||||
}
|
||||
|
||||
pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save_all()).await
|
||||
host.blocking(move || project.documents.save_all()).await?;
|
||||
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
|
||||
}
|
||||
|
||||
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
|
||||
@@ -22,6 +22,13 @@ pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResu
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
}
|
||||
|
||||
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
|
||||
/// without opening it: previews of closed files.
|
||||
pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || Ok(project.documents.ui_mode_of(&path))).await
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
|
||||
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
|
||||
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
|
||||
@@ -108,6 +115,7 @@ pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult<Strin
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("doc_ui_mode", |c| async move { doc_ui_mode(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("doc_set_encoding", |c| async move {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
|
||||
});
|
||||
|
||||
@@ -4,10 +4,11 @@ use crate::rpc::Dispatcher;
|
||||
|
||||
use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
|
||||
use ide_lsp::jdtls::JavaLaunchInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cancellable with `cancel_request(request)`.
|
||||
/// Builds the workspace (when `build`), then resolves the runtime (or test)
|
||||
/// classpath and the JDK of the module at `module` (absolute, or relative to the project root).
|
||||
/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
|
||||
@@ -17,22 +18,39 @@ pub async fn java_resolve_launch(
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result = resolve_launch(host, window, module, test, build, &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
async fn resolve_launch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
// Let jdtls finish its build before the external compiler writes Kotlin/Scala classes.
|
||||
let mut info = project.lsp.java_launch(&module, test, build, cancel).await?;
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
let (root, cache, module, settings) =
|
||||
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &cancel)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut info = project.lsp.java_launch(&module, test, build).await?;
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
@@ -64,6 +82,9 @@ pub async fn java_resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
@@ -82,13 +103,14 @@ pub async fn java_build_workspace(
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module).await
|
||||
project.lsp.java_build(&module, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_resolve_launch", |c| async move {
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
|
||||
.await
|
||||
});
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
|
||||
@@ -186,8 +186,19 @@ pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
}
|
||||
|
||||
/// Computes the rename edit without applying it, so the UI can preview conflicts.
|
||||
pub async fn lsp_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
|
||||
host.project(window)?.lsp.rename(doc, pos, new_name).await
|
||||
pub async fn lsp_rename(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
new_name: String,
|
||||
) -> IdeResult<Renamed> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.rename(doc, pos, new_name, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
@@ -220,11 +231,16 @@ pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeRes
|
||||
pub async fn lsp_call_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await
|
||||
) -> IdeResult<CallHierarchy> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.call_hierarchy(doc, pos, direction, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_type_hierarchy(
|
||||
@@ -335,7 +351,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
lsp_prepare_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_rename", |c| async move {
|
||||
lsp_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
lsp_rename(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
});
|
||||
d.add("lsp_format", |c| async move {
|
||||
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
|
||||
@@ -348,7 +364,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_code_lens", |c| async move { lsp_code_lens(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_document_links", |c| async move { lsp_document_links(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_call_hierarchy", |c| async move {
|
||||
lsp_call_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
lsp_call_hierarchy(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?)
|
||||
.await
|
||||
});
|
||||
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
|
||||
|
||||
@@ -15,7 +15,9 @@ pub mod history;
|
||||
pub mod java;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod notebook;
|
||||
pub mod project;
|
||||
mod python;
|
||||
pub mod refactor;
|
||||
pub mod run;
|
||||
pub mod sdk;
|
||||
@@ -59,6 +61,7 @@ impl<T> Clone for Channel<T> {
|
||||
pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
agent::register(d);
|
||||
lsp::register(d);
|
||||
notebook::register(d);
|
||||
nav::register(d);
|
||||
refactor::register(d);
|
||||
analysis::register(d);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Native notebook operations shared by desktop IPC and the remote dispatcher.
|
||||
use crate::Host;
|
||||
pub use crate::notebook::NotebookOutputEvent;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_notebook::{NotebookEdit, NotebookSnapshot};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn open(host: &Host, window: &str, path: String) -> IdeResult<NotebookSnapshot> {
|
||||
let project = host.project(window)?;
|
||||
let path = PathBuf::from(path);
|
||||
let path = if path.is_absolute() { path } else { project.root.join(path) };
|
||||
let notebooks = project.notebooks.clone();
|
||||
host.blocking(move || notebooks.open(&path)).await
|
||||
}
|
||||
|
||||
pub fn get(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
host.project(window)?.notebooks.get(&id)
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
version: u32,
|
||||
edit: NotebookEdit,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.edit(&id, version, edit)).await
|
||||
}
|
||||
|
||||
pub async fn save(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.save(&id)).await
|
||||
}
|
||||
|
||||
pub async fn undo(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.undo(&id, false)).await
|
||||
}
|
||||
|
||||
pub async fn redo(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.undo(&id, true)).await
|
||||
}
|
||||
|
||||
pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
// An abandoned IPC future must not leave a lifecycle transition half applied.
|
||||
tokio::spawn(async move { notebooks.reload(&id, false).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn restart(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.reload(&id, true).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn close(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.close(&id).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn interrupt(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.interrupt(&id).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
cells: Vec<String>,
|
||||
python: Option<String>,
|
||||
request: RequestId,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let _request = RequestGuard { host, request, cancel: cancel.clone() };
|
||||
// Reserve the WHOLE batch and capture source before asynchronous discovery.
|
||||
let admission = project.notebooks.admit(&id, cells, cancel.clone())?;
|
||||
let selected = match python {
|
||||
Some(path) if path.trim().is_empty() => return Err(IdeError::invalid("select a Python interpreter")),
|
||||
Some(path) => PathBuf::from(path),
|
||||
None => {
|
||||
let root = project.root.clone();
|
||||
let settings = project.settings.effective();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
result = host.blocking(move || {
|
||||
ide_sdk::apply::python_interpreter(&root, &settings)
|
||||
.or_else(|| ide_sdk::detect(ide_sdk::SdkKind::Python, Some(&root))
|
||||
.into_iter().next().map(|sdk| sdk.path.into_path_buf()))
|
||||
.ok_or_else(|| IdeError::unavailable("select a Python environment with ipykernel installed"))
|
||||
}) => result?,
|
||||
}
|
||||
}
|
||||
};
|
||||
// Do not canonicalize this path: resolving a venv symlink to base Python
|
||||
// changes sys.prefix and executes in the wrong environment.
|
||||
let python = if selected.is_absolute() { selected } else { project.root.join(selected) };
|
||||
project.notebooks.execute(admission, python, on_output).await
|
||||
}
|
||||
|
||||
struct RequestGuard<'a> {
|
||||
host: &'a Host,
|
||||
request: RequestId,
|
||||
cancel: ide_core::CancellationToken,
|
||||
}
|
||||
|
||||
impl Drop for RequestGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
// If the invocation is dropped, the owned batch still receives cancellation.
|
||||
self.cancel.cancel();
|
||||
self.host.end_request(self.request);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("notebook_open", |c| async move { open(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("notebook_get", |c| async move { get(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("notebook_edit", |c| async move {
|
||||
edit(&c.host, &c.window, c.arg("id")?, c.arg("version")?, c.arg("edit")?).await
|
||||
});
|
||||
d.add("notebook_save", |c| async move { save(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_reload", |c| async move { reload(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_undo", |c| async move { undo(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_redo", |c| async move { redo(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_close", |c| async move { close(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_interrupt", |c| async move { interrupt(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_restart", |c| async move { restart(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_execute", |c| async move {
|
||||
execute(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("id")?,
|
||||
c.arg("cells")?,
|
||||
c.arg("python")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub struct ProjectInfo {
|
||||
pub name: String,
|
||||
pub is_git: bool,
|
||||
pub settings: Settings,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace: Option<ide_workspace::workspace::Workspace>,
|
||||
}
|
||||
|
||||
pub fn info(project: &Project) -> ProjectInfo {
|
||||
@@ -28,6 +30,7 @@ pub fn info(project: &Project) -> ProjectInfo {
|
||||
is_git: !project.repos.read().is_empty(),
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,16 +46,52 @@ pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
|
||||
|
||||
/// Effective settings for the window's project, or global settings without one.
|
||||
pub fn settings(host: &Host, window: &str) -> Settings {
|
||||
match host.project(window) {
|
||||
host.with_proxy_password(match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
|
||||
Ok(host.project(window)?.roots())
|
||||
}
|
||||
|
||||
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
|
||||
let project = host.project(window)?;
|
||||
ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
|
||||
project.settings.reload();
|
||||
project.refresh_index_scope();
|
||||
project.refresh_repositories();
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
Ok(info(&project))
|
||||
}
|
||||
|
||||
/// Links a folder, or prepares an automatic workspace for the caller to open in place.
|
||||
pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult<ide_core::IpcPath> {
|
||||
let project = host.project(window)?;
|
||||
let path = dunce::canonicalize(path)?;
|
||||
if project.light {
|
||||
return Err(ide_core::IdeError::invalid("Open a project before attaching a folder"));
|
||||
}
|
||||
if let Some(mut workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
|
||||
if !workspace.projects.contains(&path) {
|
||||
workspace.projects.push(path);
|
||||
save_workspace(host, window, &workspace.name, workspace.projects)?;
|
||||
}
|
||||
return Ok(project.root.clone().into());
|
||||
}
|
||||
if path == project.root {
|
||||
return Ok(project.root.clone().into());
|
||||
}
|
||||
Ok(ide_workspace::workspace::Workspace::create_for_project(
|
||||
&host.data_dir,
|
||||
&project.root,
|
||||
&info(&project).name,
|
||||
&path,
|
||||
)?
|
||||
.into())
|
||||
}
|
||||
|
||||
/// What [`set_settings`] changed; the caller tells the windows.
|
||||
pub struct SettingsApplied {
|
||||
pub settings: Settings,
|
||||
@@ -64,7 +103,8 @@ pub struct SettingsApplied {
|
||||
|
||||
/// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's
|
||||
/// project or globally, and applies it to the open projects: file defaults,
|
||||
/// file types, language servers, index scope and SSH configurations.
|
||||
/// file types, language servers, index scope, SSH configurations and the HTTP proxy
|
||||
/// (whose password goes to the saved passwords, not the settings file).
|
||||
pub fn set_settings(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -77,6 +117,11 @@ pub fn set_settings(
|
||||
let current = host.project(window).ok();
|
||||
// A LightEdit window has no project layer: its values are global.
|
||||
let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light);
|
||||
let is_proxy = key == "proxy" || key.starts_with("proxy.");
|
||||
let Some(value) = host.take_proxy_password(key, value)? else {
|
||||
host.apply_proxy();
|
||||
return Ok(SettingsApplied { settings: settings(host, window), project_scope, roots_changed: None });
|
||||
};
|
||||
let settings = match ¤t {
|
||||
Some(p) if project_scope => p.settings.set(scope, key, value)?,
|
||||
Some(p) => {
|
||||
@@ -113,6 +158,9 @@ pub fn set_settings(
|
||||
if key == "ssh" || key.starts_with("ssh.") {
|
||||
host.ssh.set_configs(host.global_settings.effective().ssh.configs);
|
||||
}
|
||||
if is_proxy {
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
|
||||
for project in affected() {
|
||||
@@ -120,7 +168,9 @@ pub fn set_settings(
|
||||
host.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
}
|
||||
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
|
||||
let roots_changed = key == "projectStructure"
|
||||
|| key.starts_with("projectStructure.")
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key);
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
@@ -132,5 +182,5 @@ pub fn set_settings(
|
||||
});
|
||||
}
|
||||
let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into());
|
||||
Ok(SettingsApplied { settings, project_scope, roots_changed })
|
||||
Ok(SettingsApplied { settings: host.with_proxy_password(settings), project_scope, roots_changed })
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use crate::project::Project;
|
||||
use ide_core::IdeResult;
|
||||
use ide_dap::LaunchConfig;
|
||||
use ide_workspace::roots::RootKind;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) fn paths(project: &Project) -> Vec<PathBuf> {
|
||||
let mut paths = vec![project.root.clone()];
|
||||
paths.extend(project.content_roots());
|
||||
for root in project.roots().roots {
|
||||
if matches!(root.kind, RootKind::Source | RootKind::Test) {
|
||||
let path = project.root.join(root.path);
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
pub(super) fn prepare_debug(config: &mut LaunchConfig, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
// Adapter-specific env takes precedence over the common launch fields in DAP.
|
||||
let mut extra: Option<serde_json::Value> = config.extra_json.as_deref().and_then(|s| serde_json::from_str(s).ok());
|
||||
// A custom adapter may run on another host; local paths are not portable.
|
||||
if extra.as_ref().is_some_and(|e| e.get("debugServer").is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(env) = extra.as_mut().and_then(|e| e.get_mut("env")) {
|
||||
if env.is_null() {
|
||||
*env = serde_json::json!({});
|
||||
}
|
||||
if !env.is_object() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut vars = BTreeMap::new();
|
||||
if let Some(value) = env.get("PYTHONPATH").and_then(|v| v.as_str()) {
|
||||
vars.insert("PYTHONPATH".into(), value.to_owned());
|
||||
}
|
||||
ide_run::python_path::extend(&mut vars, paths, env.get("PYTHONPATH").is_none())?;
|
||||
env["PYTHONPATH"] = serde_json::json!(vars["PYTHONPATH"]);
|
||||
config.extra_json = extra.map(|e| e.to_string());
|
||||
} else {
|
||||
ide_run::python_path::extend(&mut config.env, paths, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_preserves_remote_paths_and_handles_null_env() {
|
||||
let root = std::env::temp_dir().join("project");
|
||||
let mut config = LaunchConfig {
|
||||
name: "Python".into(),
|
||||
adapter: "debugpy".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(serde_json::json!({"env": null, "debugServer": "remote:5678"}).to_string()),
|
||||
};
|
||||
let before = config.extra_json.clone();
|
||||
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
|
||||
assert_eq!(config.extra_json, before);
|
||||
assert!(config.env.is_empty());
|
||||
config.extra_json = Some(serde_json::json!({"env": null}).to_string());
|
||||
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap();
|
||||
let paths = std::env::split_paths(value["env"]["PYTHONPATH"].as_str().unwrap()).collect::<Vec<_>>();
|
||||
assert_eq!(paths[0], root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_uses_the_effective_environment_field() {
|
||||
for extra in [false, true] {
|
||||
let root = std::env::temp_dir().join("project");
|
||||
let vendor = root.join("vendor");
|
||||
let vars = BTreeMap::from([("PYTHONPATH".into(), vendor.to_string_lossy().into_owned())]);
|
||||
let mut config = LaunchConfig {
|
||||
name: "Python".into(),
|
||||
adapter: "debugpy".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("app/main.py".into()),
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: vars.clone(),
|
||||
extra_json: extra.then(|| serde_json::json!({"env": vars}).to_string()),
|
||||
};
|
||||
prepare_debug(&mut config, std::slice::from_ref(&root)).unwrap();
|
||||
let vars: BTreeMap<String, String> = if extra {
|
||||
let value: serde_json::Value = serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap();
|
||||
serde_json::from_value(value["env"].clone()).unwrap()
|
||||
} else {
|
||||
config.env
|
||||
};
|
||||
assert_eq!(std::env::split_paths(&vars["PYTHONPATH"]).collect::<Vec<_>>(), [root, vendor]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,59 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let user = ide_run::load_user_configs(&root)?;
|
||||
let mut all: Vec<RunConfig> =
|
||||
ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect();
|
||||
all.extend(user);
|
||||
// Project interpreter / package manager / toolchains from Languages & Frameworks.
|
||||
ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective());
|
||||
let root_suppressed = ide_run::load_suppressed_config_ids(&root)?;
|
||||
let settings = project.settings.effective();
|
||||
let mut all = detected_and_saved(&root, &settings, &user, &root_suppressed);
|
||||
if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? {
|
||||
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
|
||||
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&member)).effective();
|
||||
let saved = ide_run::load_user_configs(&member)?;
|
||||
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
|
||||
let configs = detected_and_saved(&member, &settings, &saved, &suppressed);
|
||||
let key = crate::project::project_key(&member);
|
||||
let name = member.file_name().unwrap_or_default().to_string_lossy();
|
||||
for mut config in configs {
|
||||
config.id = format!("workspace:{}:{}", key, config.id);
|
||||
config.name = format!("{name} / {}", config.name);
|
||||
config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into());
|
||||
config.detected = true;
|
||||
if !user.iter().any(|u| u.id == config.id) && !root_suppressed.contains(&config.id) {
|
||||
all.push(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> {
|
||||
ide_run::save_user_configs(&host.project(window)?.root, configs)
|
||||
/// A project's detected configurations (minus removed ones and unlinked Maven projects), then
|
||||
/// its saved ones; toolchains from Languages & Frameworks applied. Gradle choices stay as saved:
|
||||
/// derived defaults must not be written back as explicit environment.
|
||||
fn detected_and_saved(
|
||||
root: &std::path::Path,
|
||||
settings: &ide_core::settings::Settings,
|
||||
saved: &[RunConfig],
|
||||
suppressed: &[String],
|
||||
) -> Vec<RunConfig> {
|
||||
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
|
||||
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
|
||||
.into_iter()
|
||||
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
|
||||
.collect();
|
||||
all.extend(saved.iter().cloned());
|
||||
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
pub fn save_configs(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
configs: &[RunConfig],
|
||||
removed_detected_ids: &[String],
|
||||
) -> IdeResult<()> {
|
||||
ide_run::save_user_configs_with_removals(&host.project(window)?.root, configs, removed_detected_ids)
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
@@ -27,6 +70,9 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
// Settings › Run Targets: over SSH.
|
||||
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
|
||||
}
|
||||
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
let out = on_output.clone();
|
||||
@@ -35,15 +81,42 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
}
|
||||
let settings = project.settings.effective();
|
||||
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
|
||||
let cwd = config.cwd.as_deref()?;
|
||||
workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count())
|
||||
});
|
||||
let root = member.as_ref().unwrap_or(&project.root);
|
||||
let settings = match &member {
|
||||
Some(root) => ide_workspace::SettingsStore::load(&host.config_dir, Some(root)).effective(),
|
||||
None => project.settings.effective(),
|
||||
};
|
||||
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
|
||||
let mut config = config;
|
||||
ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings);
|
||||
if matches!(config.kind.as_str(), "gradle" | "tomcat") {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
} else {
|
||||
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
|
||||
}
|
||||
if config.command == ide_sdk::godot::COMMAND {
|
||||
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
|
||||
}
|
||||
if config.kind == "tomcat" {
|
||||
if host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("This Tomcat configuration is already running"));
|
||||
}
|
||||
let (root, cache) = (root.clone(), project.cache_dir.clone());
|
||||
config = tokio::task::spawn_blocking(move || {
|
||||
ide_run::tomcat::prepare(&root, &cache, &mut config)?;
|
||||
Ok::<_, IdeError>(config)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
}
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) };
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
|
||||
};
|
||||
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,18 @@ pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap
|
||||
ide_run::system_env()
|
||||
}
|
||||
|
||||
/// Writes the Gradle init script that adds `jvm_args` to the JVMs the build forks
|
||||
/// (`bootRun`, `run`, tests) and returns its path, for `--init-script`.
|
||||
pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: Vec<String>) -> IdeResult<String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_run::gradle_jvm::write_init_script(&ide_run::gradle_jvm::default_dir(), &jvm_args)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
pub async fn run_test_reports(
|
||||
@@ -37,11 +49,93 @@ pub async fn run_test_reports(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult<ide_run::tomcat::TomcatHome> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
tokio::task::spawn_blocking(move || ide_run::tomcat::validate_home(&root.join(home)))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
|
||||
ide_build::web::artifacts(&project.root, &model)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> IdeResult<Option<RunConfig>> {
|
||||
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
|
||||
return Ok(None);
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
|
||||
let artifacts = ide_build::web::artifacts(&project.root, &model);
|
||||
let id = config
|
||||
.extras
|
||||
.params
|
||||
.get("artifact")
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| artifacts.first().map(|a| &a.id))
|
||||
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
|
||||
let plan = ide_build::web::build(
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
|
||||
)?;
|
||||
let mut env = plan.env;
|
||||
env.extend(config.env.clone());
|
||||
Ok(Some(RunConfig {
|
||||
id: format!("tomcat-build:{}", config.id),
|
||||
name: format!("Build {}", config.name),
|
||||
kind: if id.starts_with("maven:") { "maven" } else { "gradle" }.into(),
|
||||
command: plan.command,
|
||||
args: plan.args,
|
||||
cwd: Some(std::path::PathBuf::from(plan.cwd).into()),
|
||||
env,
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras {
|
||||
include_system_env: config.extras.include_system_env,
|
||||
env_files: config.extras.env_files,
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
|
||||
}
|
||||
tokio::task::spawn_blocking(move || ide_run::tomcat::update(&project.root, &project.cache_dir, &config, &mode))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
|
||||
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
|
||||
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("run_tomcat_update", |c| async move {
|
||||
run_tomcat_update(&c.host, &c.window, c.arg("config")?, c.arg("mode")?).await
|
||||
});
|
||||
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
|
||||
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
|
||||
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
|
||||
d.add("run_gradle_jvm_args_script", |c| async move {
|
||||
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
|
||||
});
|
||||
d.add("run_test_reports", |c| async move {
|
||||
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
|
||||
});
|
||||
|
||||
@@ -42,13 +42,16 @@ pub async fn start(
|
||||
let cancel = host.begin_request(request);
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
|
||||
project.content_roots()
|
||||
}
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
@@ -66,7 +69,7 @@ pub async fn start(
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(&root).chain(&content_roots) {
|
||||
for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
@@ -74,7 +77,13 @@ pub async fn start(
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&cancel,
|
||||
&|file| events(SearchEvent::File { file }),
|
||||
&|mut file| {
|
||||
if dir != &root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
events(SearchEvent::File { file });
|
||||
},
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
|
||||
@@ -45,13 +45,16 @@ pub enum RemoteOp {
|
||||
branch: String,
|
||||
},
|
||||
/// Pushes local `branch` (only up to `revision` when set) to its upstream, or to
|
||||
/// remote branch `target` of the same remote.
|
||||
/// remote branch `target`; `remote` overrides the upstream's remote (Push dialog's
|
||||
/// remote picker).
|
||||
PushBranch {
|
||||
branch: String,
|
||||
revision: Option<String>,
|
||||
force_with_lease: bool,
|
||||
#[serde(default)]
|
||||
target: Option<String>,
|
||||
#[serde(default)]
|
||||
remote: Option<String>,
|
||||
},
|
||||
/// Fast-forwards local `branch` (not checked out) to its upstream.
|
||||
UpdateBranch {
|
||||
@@ -187,18 +190,33 @@ pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -
|
||||
pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false))
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
}
|
||||
|
||||
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await
|
||||
blocking(move || {
|
||||
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
|
||||
pub async fn vcs_blame(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
path: PathBuf,
|
||||
text: Option<String>,
|
||||
revision: Option<String>,
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.documents.text_of(&path);
|
||||
blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await
|
||||
// An explicit text wins (merge view annotates its side panes, not the worktree).
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
blocking(move || {
|
||||
project.with_repo_of(&path, |r| {
|
||||
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
@@ -242,6 +260,34 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).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(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
for path in &paths {
|
||||
if path == &project.root || !path.starts_with(&project.root) {
|
||||
return Err(ide_core::IdeError::invalid("Ignore selection is outside the project"));
|
||||
}
|
||||
}
|
||||
apply_git_executable(&project.settings.effective());
|
||||
let result = ide_vcs::add_to_ignore(&paths, destination);
|
||||
// Exclude may live outside a worktree's watched directory. Refresh filters and UI explicitly.
|
||||
project.watcher.reload_ignore_rules();
|
||||
for (_, watcher) in project.content_watchers.lock().iter() {
|
||||
watcher.reload_ignore_rules();
|
||||
}
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
@@ -249,7 +295,7 @@ pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeR
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await
|
||||
blocking(move || project.with_repo(|r| r.stage_patch(&patch, &|path| project.vcs_encoding(path, Some(":0"))))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
@@ -266,7 +312,12 @@ pub async fn vcs_commit(
|
||||
) -> IdeResult<String> {
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| {
|
||||
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_checkout(
|
||||
@@ -416,12 +467,17 @@ pub async fn vcs_remote(
|
||||
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
|
||||
}
|
||||
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target } => match target {
|
||||
Some(target) => {
|
||||
r.push_branch_to(&branch, revision.as_deref(), &target, force_with_lease, &cancel, &out)
|
||||
}
|
||||
None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out),
|
||||
},
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to(
|
||||
&branch,
|
||||
PushBranchOptions {
|
||||
revision: revision.as_deref(),
|
||||
target: target.as_deref(),
|
||||
remote: remote.as_deref(),
|
||||
force_with_lease,
|
||||
},
|
||||
&cancel,
|
||||
&out,
|
||||
),
|
||||
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PullFrom { remote, branches, rebase, options } => {
|
||||
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
|
||||
@@ -437,12 +493,14 @@ pub async fn vcs_remote(
|
||||
/// The user's side is `ours` (sides swapped during a rebase, as in IDEA).
|
||||
pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_vcs::MergeVersions> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await
|
||||
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path, project.vcs_encoding(&path, Some(":2"))?)))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await
|
||||
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
|
||||
@@ -454,13 +512,11 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S
|
||||
/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`.
|
||||
pub fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let current = ide_vfs::load_text(&path)?.text;
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
let current = ide_vfs::load_text_as(&path, encoding.name())?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default();
|
||||
let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/");
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
let patch = ide_vcs::build_partial_patch(&rel, &index_text, ¤t, &selected);
|
||||
if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) }
|
||||
r.stage_hunks(&path, ¤t, &selected, encoding)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -680,13 +736,17 @@ pub async fn vcs_diff_revisions(
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> IdeResult<String> {
|
||||
pub async fn vcs_create_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
source: PatchSource,
|
||||
) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo(|r| match source {
|
||||
project.with_repo_at(repo.as_deref(), |r| match source {
|
||||
PatchSource::Changes { paths, reverse } => {
|
||||
let paths: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
|
||||
in_primary_repo(&project, &paths)?;
|
||||
r.create_patch(&paths, reverse)
|
||||
}
|
||||
PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse),
|
||||
@@ -695,14 +755,24 @@ pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) ->
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult<Vec<PatchFile>> {
|
||||
pub async fn vcs_patch_preview(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<Vec<PatchFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.patch_preview(&patch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult<ApplyOutcome> {
|
||||
pub async fn vcs_apply_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
@@ -775,7 +845,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_blame", |c| async move {
|
||||
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
|
||||
});
|
||||
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
|
||||
d.add("vcs_commit_files", |c| async move {
|
||||
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
|
||||
@@ -786,6 +858,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_branches", |c| async move { vcs_branches(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_stashes", |c| async move { vcs_stashes(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_stage", |c| async move { vcs_stage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
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_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 });
|
||||
@@ -901,9 +976,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_diff_revisions", |c| async move {
|
||||
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("source")?).await });
|
||||
d.add("vcs_patch_preview", |c| async move { vcs_patch_preview(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_apply_patch", |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_create_patch", |c| async move {
|
||||
vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await
|
||||
});
|
||||
d.add("vcs_patch_preview", |c| async move {
|
||||
vcs_patch_preview(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await
|
||||
});
|
||||
d.add(
|
||||
"vcs_apply_patch",
|
||||
|c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await },
|
||||
);
|
||||
d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await });
|
||||
d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) });
|
||||
d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await });
|
||||
|
||||
+244
-39
@@ -2,7 +2,7 @@
|
||||
//! watchers and the per-project services, opened by [`crate::Host::open_project`].
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, LanguageId, SharedSink};
|
||||
use ide_index::ProjectIndex;
|
||||
use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
|
||||
use ide_lsp::LspManager;
|
||||
@@ -46,6 +46,8 @@ pub struct Project {
|
||||
pub http_history: Arc<ide_http::History>,
|
||||
/// Coding agent tasks (Agents tool window).
|
||||
pub agents: Arc<ide_agent::AgentManager>,
|
||||
/// Native notebooks and owned kernels, scoped to this project incarnation.
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
@@ -65,6 +67,8 @@ impl Host {
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let root = dunce::canonicalize(root)?;
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
ide_workspace::workspace::Workspace::load(&root)?;
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
// it, with its warm index, language servers and unsaved documents.
|
||||
if let Ok(current) = self.project(window)
|
||||
@@ -79,20 +83,14 @@ impl Host {
|
||||
let post_start = self.environment.project_opened(&root, &sink);
|
||||
let settings = SettingsStore::load(&self.config_dir, Some(&root));
|
||||
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
|
||||
let excludes = exclude_globs(&settings.effective(), &detected_roots.read());
|
||||
let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read());
|
||||
let extra_roots = content_roots(&settings.effective(), &root);
|
||||
mark("roots");
|
||||
|
||||
// The project's own registry: File Types associations may be project-scoped.
|
||||
let languages = Arc::new(self.languages.fork());
|
||||
languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root)));
|
||||
let documents = {
|
||||
let languages = languages.clone();
|
||||
Arc::new(Documents::new(
|
||||
sink.clone(),
|
||||
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
|
||||
))
|
||||
};
|
||||
let documents = documents(&sink, &languages, Some(&root));
|
||||
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root)));
|
||||
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?);
|
||||
{
|
||||
@@ -108,7 +106,7 @@ impl Host {
|
||||
let index = ProjectIndex::open(
|
||||
&root,
|
||||
&self.data_dir.join("index").join(&project_key),
|
||||
extra_roots.clone(),
|
||||
index_roots(&root, extra_roots.clone()),
|
||||
excludes.clone(),
|
||||
languages.clone(),
|
||||
self.syntax(),
|
||||
@@ -116,31 +114,40 @@ impl Host {
|
||||
)?;
|
||||
mark("index");
|
||||
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let config_dir = self.config_dir.clone();
|
||||
lsp.set_config_hook(Box::new(move |cfg, server_root| {
|
||||
let effective = settings.effective();
|
||||
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
|
||||
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
|
||||
});
|
||||
let effective = member
|
||||
.as_ref()
|
||||
.map(|p| SettingsStore::load(&config_dir, Some(p)).effective())
|
||||
.unwrap_or_else(|| settings.effective());
|
||||
let root = member.as_ref().unwrap_or(&root);
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, &root, server_root, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
|
||||
if cfg.name == "jdtls" {
|
||||
let bundles: Vec<PathBuf> =
|
||||
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
|
||||
let min_java = ide_sdk::java::jdtls_min_java(cfg);
|
||||
let jdks = ide_sdk::java::jdtls_jdks(min_java);
|
||||
let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir);
|
||||
let lombok = ide_sdk::lombok::lombok_agent(
|
||||
server_root,
|
||||
ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)),
|
||||
ide_tools::managed_bundle(&data_dir, "lombok"),
|
||||
ide_sdk::apply::maven_local_repository(&root, &effective),
|
||||
ide_sdk::apply::maven_local_repository(root, &effective),
|
||||
);
|
||||
if let Some(problem) = lombok.problem() {
|
||||
tracing::warn!("{problem}");
|
||||
}
|
||||
let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| {
|
||||
let server_jdk = ide_sdk::apply::jdtls_jdk(root, &effective).unwrap_or_else(|e| {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
@@ -149,7 +156,7 @@ impl Host {
|
||||
server_root,
|
||||
&data_dir,
|
||||
&jdks,
|
||||
ide_sdk::apply::project_jdk(&root, &effective).as_ref(),
|
||||
ide_sdk::apply::project_jdk(root, &effective).as_ref(),
|
||||
server_jdk.as_ref(),
|
||||
min_java,
|
||||
&bundles,
|
||||
@@ -158,6 +165,27 @@ impl Host {
|
||||
ide_sdk::jdtls::launch_directly(cfg);
|
||||
}
|
||||
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
|
||||
let excluded_poms = unlinked_maven_poms(root, &effective);
|
||||
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms);
|
||||
}));
|
||||
}
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let sink = sink.clone();
|
||||
lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| {
|
||||
let effective = settings.effective();
|
||||
if cfg.name == "jdtls" {
|
||||
crate::java_runtime::prepare(
|
||||
cfg,
|
||||
&data_dir,
|
||||
!effective.languages.java.jdtls_jdk.trim().is_empty()
|
||||
|| ide_sdk::apply::jdtls_jdk(&root, &effective).is_err(),
|
||||
&sink,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
{
|
||||
@@ -188,6 +216,7 @@ impl Host {
|
||||
let settings = settings.clone();
|
||||
let index = index.clone();
|
||||
let documents = documents.clone();
|
||||
let languages = languages.clone();
|
||||
let sink = sink.clone();
|
||||
let root = root.clone();
|
||||
let detected_roots = detected_roots.clone();
|
||||
@@ -203,6 +232,12 @@ impl Host {
|
||||
index.update(&changed, &batch.removed);
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
// A framework's root marker (`angular.json`) created or deleted: its
|
||||
// templates gain or lose their mode. Markers in `node_modules` only
|
||||
// count here when the folder isn't excluded (it is by default).
|
||||
if batch.created.iter().chain(&batch.removed).any(|p| languages.is_framework_marker(p)) {
|
||||
documents.redetect_languages();
|
||||
}
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
if !batch.removed.is_empty() {
|
||||
delete_deployed(
|
||||
@@ -221,7 +256,10 @@ impl Host {
|
||||
if *detected_roots.read() != fresh {
|
||||
// Detected excluded folders (`target`, `build`) leave the index too.
|
||||
let effective = settings.effective();
|
||||
index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh));
|
||||
index.set_scope(
|
||||
index_roots(&root, content_roots(&effective, &root)),
|
||||
exclude_globs(&root, &effective, &fresh),
|
||||
);
|
||||
*detected_roots.write() = fresh;
|
||||
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
|
||||
}
|
||||
@@ -229,7 +267,7 @@ impl Host {
|
||||
// A repository was cloned / initialised / removed below the project: its
|
||||
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
|
||||
let known = repos.read().roots();
|
||||
let git_dirs = changed.iter().chain(&batch.removed).any(|p| {
|
||||
let git_dirs = changed.iter().chain(&batch.removed).chain(&batch.vcs).any(|p| {
|
||||
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
|
||||
else {
|
||||
return false;
|
||||
@@ -239,7 +277,10 @@ impl Host {
|
||||
if git_dirs {
|
||||
scan_repositories(&root, &repos, &settings, &sink);
|
||||
}
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
let vcs_moved = batch.vcs.iter().any(|p| vcs_path_changed(p));
|
||||
if git_dirs || vcs_moved {
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}
|
||||
}),
|
||||
)?
|
||||
};
|
||||
@@ -282,6 +323,10 @@ impl Host {
|
||||
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
|
||||
|
||||
let (db, http_history, agents) = self.project_services(&root, &project_key);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
|
||||
let project = Arc::new(Project {
|
||||
root,
|
||||
@@ -301,6 +346,7 @@ impl Host {
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
@@ -375,13 +421,7 @@ impl Host {
|
||||
let effective = settings.effective();
|
||||
let languages = Arc::new(self.languages.fork());
|
||||
languages.set_file_types(FileTypes::from_settings(&effective.files, None));
|
||||
let documents = {
|
||||
let languages = languages.clone();
|
||||
Arc::new(Documents::new(
|
||||
sink.clone(),
|
||||
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
|
||||
))
|
||||
};
|
||||
let documents = documents(&sink, &languages, None);
|
||||
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, None));
|
||||
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &dir)?);
|
||||
let index = ProjectIndex::unindexed(&dir, languages.clone(), self.syntax(), sink.clone())?;
|
||||
@@ -393,6 +433,10 @@ impl Host {
|
||||
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);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
let project = Arc::new(Project {
|
||||
sink,
|
||||
languages,
|
||||
@@ -410,6 +454,7 @@ impl Host {
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
@@ -440,6 +485,18 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
/// The documents of a project whose languages and UI modes `languages` detects,
|
||||
/// looking for frameworks' root markers up to `root` (anywhere for loose files).
|
||||
fn documents(sink: &SharedSink, languages: &Arc<LanguageRegistry>, root: Option<&Path>) -> Arc<Documents> {
|
||||
let (detect, modes, root) = (languages.clone(), languages.clone(), root.map(Path::to_path_buf));
|
||||
Arc::new(
|
||||
Documents::new(sink.clone(), Box::new(move |p: &Path, first: Option<&str>| detect.detect(p, first)))
|
||||
.with_ui_mode_detection(Box::new(move |p: &Path, lang: &LanguageId| {
|
||||
modes.framework_ui_mode(lang, p, root.as_deref())
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn light_watcher(
|
||||
dir: &Path,
|
||||
sink: &SharedSink,
|
||||
@@ -465,7 +522,9 @@ fn light_watcher(
|
||||
fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStore, sink: &SharedSink) {
|
||||
let ignored = settings.effective().ignored_names();
|
||||
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
|
||||
match Repos::discover(root, &skip) {
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots(&settings.effective(), root));
|
||||
match Repos::discover_roots(&roots, &skip) {
|
||||
Ok(found) => {
|
||||
if found.roots() != repos.read().roots() {
|
||||
*repos.write() = found;
|
||||
@@ -477,6 +536,10 @@ fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStor
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn refresh_repositories(&self) {
|
||||
scan_repositories(&self.root, &self.repos, &self.settings, &self.sink);
|
||||
}
|
||||
|
||||
/// The project's git repositories.
|
||||
pub fn repos(&self) -> Repos {
|
||||
self.repos.read().clone()
|
||||
@@ -567,7 +630,7 @@ impl Project {
|
||||
|
||||
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
|
||||
pub fn roots(&self) -> ProjectRoots {
|
||||
ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure)
|
||||
linked_roots(&self.root, &self.settings.effective(), &self.detected_roots.read())
|
||||
}
|
||||
|
||||
/// Where the Maven / Gradle tool windows cache what the build tools reported.
|
||||
@@ -602,17 +665,23 @@ impl Project {
|
||||
q: &ide_lsp::CandidateQuery,
|
||||
cancel: &CancellationToken,
|
||||
) -> ide_lsp::CandidateFiles {
|
||||
if !q.root.starts_with(&self.root) {
|
||||
let root = self
|
||||
.content_roots()
|
||||
.into_iter()
|
||||
.chain(std::iter::once(self.root.clone()))
|
||||
.filter(|root| q.root.starts_with(root))
|
||||
.max_by_key(|root| root.components().count());
|
||||
let Some(root) = root else {
|
||||
return ide_lsp::CandidateFiles::default();
|
||||
}
|
||||
};
|
||||
let query = ide_search::WordQuery {
|
||||
word: q.word.clone(),
|
||||
dir: q.root.clone(),
|
||||
exclude: self.search_excludes(),
|
||||
limit: q.limit,
|
||||
};
|
||||
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&self.root.join(rel), None));
|
||||
match ide_search::files_with_word(&self.root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
|
||||
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None));
|
||||
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
|
||||
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
|
||||
Err(e) => {
|
||||
tracing::debug!("reference candidates: {}", e.message);
|
||||
@@ -623,7 +692,7 @@ impl Project {
|
||||
|
||||
/// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected).
|
||||
pub fn exclude_globs(&self) -> Vec<String> {
|
||||
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
|
||||
exclude_globs(&self.root, &self.settings.effective(), &self.detected_roots.read())
|
||||
}
|
||||
|
||||
/// Project Structure content roots besides the project folder (existing directories).
|
||||
@@ -649,7 +718,8 @@ impl Project {
|
||||
return;
|
||||
}
|
||||
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
|
||||
self.index.set_scope(roots.clone(), excludes.clone());
|
||||
self.lsp.set_content_roots(roots.clone());
|
||||
self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone());
|
||||
self.watcher.set_excludes(&excludes);
|
||||
let mut watchers = self.content_watchers.lock();
|
||||
watchers.retain(|(p, _)| roots.contains(p));
|
||||
@@ -668,11 +738,29 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the open document's selected encoding, or the same detection/settings as opening it.
|
||||
pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> {
|
||||
if let Some(id) = self.documents.by_path(path) {
|
||||
let info = self.documents.snapshot(id)?;
|
||||
return encoding_rs::Encoding::for_label(info.encoding.as_bytes())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("unknown document encoding"));
|
||||
}
|
||||
let defaults = self.documents.file_defaults();
|
||||
let fallback = defaults.fallback_for(path);
|
||||
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"));
|
||||
}
|
||||
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
|
||||
Ok(ide_vfs::detect_encoding(&bytes, fallback))
|
||||
}
|
||||
|
||||
/// Current text of `path`: the open buffer when available, else the file on disk.
|
||||
pub fn text_of(&self, path: &Path) -> IdeResult<String> {
|
||||
match self.documents.text_of(path) {
|
||||
Some(text) => Ok(text),
|
||||
None => Ok(ide_vfs::load_text(path)?.text),
|
||||
None => Ok(ide_vfs::load_text_with_fallback(path, self.documents.file_defaults().fallback_for(path))?.text),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -735,8 +823,16 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa
|
||||
}
|
||||
}
|
||||
|
||||
/// Project Structure content roots outside `root` (existing absolute directories).
|
||||
/// A workspace uses its root only for state, never as indexed content.
|
||||
fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
|
||||
ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders }
|
||||
}
|
||||
|
||||
/// Workspace members in manifest order, or extra Project Structure directories.
|
||||
fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec<PathBuf> {
|
||||
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
|
||||
return workspace.projects;
|
||||
}
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for r in &settings.project_structure.content_roots {
|
||||
let p = PathBuf::from(r.trim());
|
||||
@@ -773,13 +869,27 @@ fn content_root_watcher(
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
|
||||
pub(crate) fn unlinked_maven_poms(root: &Path, settings: &ide_core::settings::Settings) -> Vec<PathBuf> {
|
||||
ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven).poms().map(PathBuf::from).collect()
|
||||
}
|
||||
|
||||
fn linked_roots(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> ProjectRoots {
|
||||
let unlinked = ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven);
|
||||
let detected = detected
|
||||
.iter()
|
||||
.filter(|folder| !folder.detected_from.as_ref().is_some_and(|pom| unlinked.contains(pom)))
|
||||
.cloned()
|
||||
.collect();
|
||||
ProjectRoots::compute(detected, &settings.project_structure)
|
||||
}
|
||||
|
||||
fn exclude_globs(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
|
||||
let mut globs = settings.file_excludes();
|
||||
globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs());
|
||||
globs.extend(linked_roots(root, settings, detected).excluded_globs());
|
||||
globs
|
||||
}
|
||||
|
||||
fn project_key(root: &Path) -> String {
|
||||
pub(crate) fn project_key(root: &Path) -> String {
|
||||
// Stable FNV-1a directory name per project for caches.
|
||||
let s = root.to_string_lossy();
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
@@ -906,3 +1016,98 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
|
||||
let exe = settings.vcs.git.executable.trim();
|
||||
ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod maven_linkage_tests {
|
||||
#[test]
|
||||
fn unlinked_roots_remain_plain_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for name in ["a", "b"] {
|
||||
std::fs::create_dir_all(tmp.path().join(name).join("src/main/java")).unwrap();
|
||||
std::fs::write(tmp.path().join(name).join("pom.xml"), "<project/>").unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(tmp.path().join("a/child/src/main/java")).unwrap();
|
||||
std::fs::write(tmp.path().join("a/pom.xml"), "<project><modules><module>child</module></modules></project>")
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("a/child/pom.xml"), "<project/>").unwrap();
|
||||
let detected = ide_workspace::roots::detect(tmp.path());
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.languages.java.maven.unlinked_projects.push("a/pom.xml".into());
|
||||
let excluded_poms = super::unlinked_maven_poms(tmp.path(), &settings);
|
||||
assert_eq!(excluded_poms, vec![std::path::PathBuf::from("a/child/pom.xml"), "a/pom.xml".into()]);
|
||||
let configs = ide_run::detect_excluding(tmp.path(), &excluded_poms);
|
||||
assert!(configs.iter().any(|c| c.kind == "maven"));
|
||||
assert!(
|
||||
configs
|
||||
.iter()
|
||||
.filter(|c| c.kind == "maven")
|
||||
.all(|c| { c.cwd.as_ref().is_some_and(|dir| dir.as_ref() == std::path::Path::new("b")) })
|
||||
);
|
||||
let mut server = serde_json::from_value::<ide_language::LanguageServerConfig>(
|
||||
serde_json::json!({"name": "jdtls", "command": "jdtls"}),
|
||||
)
|
||||
.unwrap();
|
||||
ide_sdk::apply::configure_maven_import(&mut server, tmp.path(), &excluded_poms);
|
||||
let options = serde_json::to_value(server.initialization_options).unwrap();
|
||||
let exclusions = options.pointer("/settings/java/import/exclusions").unwrap().as_array().unwrap();
|
||||
assert!(exclusions.contains(&serde_json::json!(tmp.path().join("a").to_string_lossy().replace('\\', "/"))));
|
||||
assert!(
|
||||
exclusions.contains(&serde_json::json!(tmp.path().join("a/child").to_string_lossy().replace('\\', "/")))
|
||||
);
|
||||
let roots = super::linked_roots(tmp.path(), &settings, &detected);
|
||||
assert!(roots.roots.iter().any(|r| r.path == "b/src/main/java"));
|
||||
assert!(!roots.roots.iter().any(|r| r.path.starts_with("a/")));
|
||||
assert!(tmp.path().join("a/pom.xml").is_file());
|
||||
let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings);
|
||||
assert_eq!(model.maven.unwrap().projects.len(), 1);
|
||||
settings.languages.java.maven.unlinked_projects.push("b/pom.xml".into());
|
||||
let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings);
|
||||
assert!(model.maven.unwrap().projects.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn vcs_path_changed(path: &Path) -> bool {
|
||||
!path.ends_with(".git/index") || !ide_vcs::is_own_index_write(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod index_event_tests {
|
||||
use super::vcs_path_changed;
|
||||
|
||||
#[test]
|
||||
fn status_write_is_suppressed_but_external_index_and_refs_refresh() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git").current_dir(dir).args(args).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
};
|
||||
git(&["init", "-q"]);
|
||||
let file = dir.join("file.txt");
|
||||
std::fs::write(&file, "original\n").unwrap();
|
||||
git(&["add", "file.txt"]);
|
||||
// Same contents, different stat data: status must refresh the index.
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&file)
|
||||
.unwrap()
|
||||
.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1))
|
||||
.unwrap();
|
||||
let index = dir.join(".git/index");
|
||||
let before = std::fs::read(&index).unwrap();
|
||||
let repo = ide_vcs::Repo::discover(dir).unwrap().unwrap();
|
||||
repo.status().unwrap();
|
||||
assert_ne!(before, std::fs::read(&index).unwrap(), "status must actually write the index");
|
||||
assert!(!vcs_path_changed(&index));
|
||||
assert!(!vcs_path_changed(&index), "duplicate notifications for the same identity");
|
||||
for path in [".git/HEAD", ".git/refs/heads/main", ".git/packed-refs"] {
|
||||
assert!(vcs_path_changed(&dir.join(path)));
|
||||
}
|
||||
std::fs::write(&file, "external staged change\n").unwrap();
|
||||
git(&["add", "file.txt"]);
|
||||
assert!(vcs_path_changed(&index));
|
||||
assert!(vcs_path_changed(&index));
|
||||
std::fs::remove_file(&index).unwrap();
|
||||
assert!(vcs_path_changed(&index));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! System Settings › HTTP Proxy on the host: the global settings made current for
|
||||
//! connections and child processes, the password kept in the saved passwords
|
||||
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::settings::Settings;
|
||||
|
||||
const PASSWORD_KEY: &str = "httpProxy";
|
||||
|
||||
impl Host {
|
||||
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
|
||||
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
|
||||
// A password typed into settings.toml by hand counts too, unless one was saved.
|
||||
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
|
||||
settings.proxy.password = password;
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
/// Makes the global proxy settings the proxy of every later connection and child process.
|
||||
pub fn apply_proxy(&self) {
|
||||
let settings = self.with_proxy_password(self.global_settings.effective());
|
||||
if let Some(problem) = settings.proxy.problem() {
|
||||
tracing::warn!("HTTP proxy: {problem}");
|
||||
}
|
||||
ide_core::proxy_settings::set_proxy(settings.proxy);
|
||||
}
|
||||
|
||||
/// Takes the proxy password out of a settings write of `key` and saves it.
|
||||
/// Returns the value left to write, `None` when nothing is.
|
||||
pub(crate) fn take_proxy_password(
|
||||
&self,
|
||||
key: &str,
|
||||
mut value: serde_json::Value,
|
||||
) -> IdeResult<Option<serde_json::Value>> {
|
||||
let password = match key {
|
||||
"proxy.password" => Some(value.take()),
|
||||
"proxy" => value.as_object_mut().and_then(|o| o.remove("password")),
|
||||
_ => return Ok(Some(value)),
|
||||
};
|
||||
if let Some(password) = password {
|
||||
match password.as_str().filter(|p| !p.is_empty()) {
|
||||
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
|
||||
None => self.proxy_password.delete(PASSWORD_KEY),
|
||||
}
|
||||
}
|
||||
Ok((key != "proxy.password").then_some(value))
|
||||
}
|
||||
}
|
||||
@@ -200,7 +200,12 @@ impl Dispatcher {
|
||||
.add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) })
|
||||
.add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) })
|
||||
.add("run_save_configs", |c| async move {
|
||||
ops::run::save_configs(&c.host, &c.window, &c.arg::<Vec<ide_run::RunConfig>>("configs")?)
|
||||
ops::run::save_configs(
|
||||
&c.host,
|
||||
&c.window,
|
||||
&c.arg::<Vec<ide_run::RunConfig>>("configs")?,
|
||||
&c.arg::<Option<Vec<String>>>("removedDetectedIds")?.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
|
||||
.add("run_start", |c| async move {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! System Settings › HTTP Proxy: applied on write, the password kept out of settings.toml.
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_core::proxy_settings::{ProxyMode, ProxyRoute, proxy};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_workspace::SettingsScope;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn host(base: &std::path::Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
fn set(host: &Host, key: &str, value: &str) -> ide_core::settings::Settings {
|
||||
ops::project::set_settings(host, "main", SettingsScope::Global, key, value).unwrap().settings
|
||||
}
|
||||
|
||||
fn settings_files(base: &std::path::Path) -> String {
|
||||
let mut text = String::new();
|
||||
for entry in walk(&base.join("config")) {
|
||||
text.push_str(&std::fs::read_to_string(entry).unwrap_or_default());
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) }
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
assert_eq!(proxy().mode, ProxyMode::Auto);
|
||||
|
||||
set(&host, "proxy.mode", "\"manual\"");
|
||||
set(&host, "proxy.host", "\"proxy.corp\"");
|
||||
set(&host, "proxy.port", "3128");
|
||||
set(&host, "proxy.authenticate", "true");
|
||||
set(&host, "proxy.login", "\"me\"");
|
||||
let settings = set(&host, "proxy.password", "\"s3cret\"");
|
||||
assert_eq!(settings.proxy.password, "s3cret", "the dialog sees the saved password");
|
||||
assert_eq!(
|
||||
proxy().route_for_url("https://example.com/"),
|
||||
ProxyRoute::Via("http://me:[email protected]:3128".into())
|
||||
);
|
||||
|
||||
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
|
||||
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
|
||||
assert!(saved.contains("s3cret"));
|
||||
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
|
||||
|
||||
// A whole-table write keeps the password out of the file too; an empty one forgets it.
|
||||
let settings = set(&host, "proxy", r#"{"mode":"none","host":"proxy.corp","password":""}"#);
|
||||
assert_eq!(settings.proxy.password, "");
|
||||
assert!(!settings_files(tmp.path()).contains("password"));
|
||||
assert_eq!(proxy().route_for_url("https://example.com/"), ProxyRoute::Direct);
|
||||
|
||||
set(&host, "proxy", r#"{"mode":"auto"}"#);
|
||||
assert_eq!(proxy().mode, ProxyMode::Auto);
|
||||
}
|
||||
@@ -109,7 +109,13 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
let started = project.join("started.txt");
|
||||
wait_for("postStartCommand", || std::fs::read_to_string(&started).is_ok_and(|s| s.ends_with('\n')));
|
||||
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
|
||||
wait_for("the task to end", || events.0.lock().iter().any(|e| matches!(e, IdeEvent::Task { done: true, .. })));
|
||||
wait_for("postStartCommand to end", || {
|
||||
events
|
||||
.0
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "postStartCommand"))
|
||||
});
|
||||
assert!(events.0.lock().iter().any(
|
||||
|e| matches!(e, IdeEvent::Task { message: Some(m), title, .. } if m == "post-start-output" && title == "postStartCommand")
|
||||
));
|
||||
|
||||
@@ -108,6 +108,26 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
invoke(&peer, "vcs_init", json!({ "dir": project })).await.unwrap();
|
||||
until("git repository", || project.join(".git").is_dir()).await;
|
||||
|
||||
// Real project-host ignore writes travel through the remote command dispatcher.
|
||||
assert!(Dispatcher::standard().commands().contains(&"vcs_add_to_ignore"));
|
||||
let ignore_root = dunce::canonicalize(&project).unwrap();
|
||||
for (name, destination) in [("shared.tmp", "gitignore"), ("private.tmp", "localExclude")] {
|
||||
let path = ignore_root.join(name);
|
||||
std::fs::write(&path, "temporary").unwrap();
|
||||
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [path], "destination": destination })).await.unwrap();
|
||||
let entries = invoke(&peer, "fs_read_dir", json!({ "path": ignore_root })).await.unwrap();
|
||||
let entry = entries.as_array().unwrap().iter().find(|e| e["name"] == name).unwrap();
|
||||
assert_eq!(entry["ignored"], true);
|
||||
assert_eq!(entry["excluded"], false);
|
||||
}
|
||||
assert!(std::fs::read_to_string(project.join(".gitignore")).unwrap().contains("/shared.tmp"));
|
||||
assert!(std::fs::read_to_string(project.join(".git/info/exclude")).unwrap().contains("/private.tmp"));
|
||||
let outside = tmp.path().join("outside.tmp");
|
||||
std::fs::write(&outside, "keep").unwrap();
|
||||
assert!(
|
||||
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [outside], "destination": "gitignore" })).await.is_err()
|
||||
);
|
||||
|
||||
// Unknown commands and bad arguments come back as IdeErrors.
|
||||
let e = invoke(&peer, "window_minimize", json!({})).await.unwrap_err();
|
||||
assert!(e.message.contains("not available remotely"), "{e:?}");
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn host(base: &std::path::Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn removed_detections_stay_removed_and_overrides_restore() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("package.json"), r#"{"scripts":{"dev":"node app.js","test":"node test.js"}}"#).unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.open_project("main", &root).unwrap();
|
||||
let detected = ops::run::configs(&host, "main").unwrap();
|
||||
assert!(detected.len() >= 2);
|
||||
assert!(detected.iter().all(|c| c.detected));
|
||||
let id = detected[0].id.clone();
|
||||
ops::run::save_configs(&host, "main", &[], std::slice::from_ref(&id)).unwrap();
|
||||
let remaining = ops::run::configs(&host, "main").unwrap();
|
||||
assert!(!remaining.iter().any(|c| c.id == id));
|
||||
assert_eq!(remaining.len(), detected.len() - 1);
|
||||
// Reopening a project reads suppression from disk.
|
||||
host.open_project("again", &root).unwrap();
|
||||
assert!(!ops::run::configs(&host, "again").unwrap().iter().any(|c| c.id == id));
|
||||
let mut saved = detected[0].clone();
|
||||
saved.detected = false;
|
||||
ops::run::save_configs(&host, "main", &[saved], &[]).unwrap();
|
||||
assert!(ide_run::load_suppressed_config_ids(&root).unwrap().is_empty());
|
||||
assert!(!ops::run::configs(&host, "main").unwrap().iter().find(|c| c.id == id).unwrap().detected);
|
||||
ops::run::save_configs(&host, "main", &[], &[]).unwrap();
|
||||
assert!(ops::run::configs(&host, "main").unwrap().iter().find(|c| c.id == id).unwrap().detected);
|
||||
// An override may coexist with suppression (e.g. a shared file added elsewhere).
|
||||
ops::run::save_configs(&host, "main", &[], std::slice::from_ref(&id)).unwrap();
|
||||
std::fs::write(
|
||||
ide_run::user_configs_path(&root),
|
||||
format!("[[configurations]]\nid = {id:?}\nname = \"Override\"\ncommand = \"node\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ops::run::configs(&host, "main").unwrap().iter().any(|c| c.id == id && !c.detected));
|
||||
ops::run::save_configs(&host, "main", &[], &[]).unwrap();
|
||||
assert!(!ops::run::configs(&host, "main").unwrap().iter().any(|c| c.id == id));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.open_project("main", &root).unwrap();
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"languages.java.gradle.jvm",
|
||||
"\"/configured-jdk\"",
|
||||
)
|
||||
.unwrap();
|
||||
let configs = ops::run::configs(&host, "main").unwrap();
|
||||
let mut gradle = configs.into_iter().find(|c| c.kind == "gradle").unwrap();
|
||||
assert!(!gradle.env.contains_key("JAVA_HOME"));
|
||||
gradle.detected = false;
|
||||
gradle.env.insert("JAVA_HOME".into(), "/explicit-jdk".into());
|
||||
ops::run::save_configs(&host, "main", &[gradle.clone()], &[]).unwrap();
|
||||
let saved = ops::run::configs(&host, "main").unwrap().into_iter().find(|c| c.id == gradle.id).unwrap();
|
||||
assert_eq!(saved.env["JAVA_HOME"], "/explicit-jdk");
|
||||
assert_eq!(saved.command, gradle.command);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use ide_core::{ChangeSet, NullSink, TextChange};
|
||||
use ide_host::{Host, HostConfig, ops::vcs};
|
||||
use std::{path::Path, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
git(&root, &["init", "-b", "main"]);
|
||||
git(&root, &["config", "user.name", "Test"]);
|
||||
git(&root, &["config", "user.email", "[email protected]"]);
|
||||
git(&root, &["config", "commit.gpgsign", "false"]);
|
||||
git(&root, &["config", "core.autocrlf", "false"]);
|
||||
let path = root.join("Example.java");
|
||||
let old = "// 中文注释\nclass Example {\n // 保持不变\n int value = 1;\n}\n";
|
||||
let original = ide_vfs::encode(old, "gbk", false).unwrap();
|
||||
std::fs::write(&path, &original).unwrap();
|
||||
std::fs::write(root.join("Utf8.java"), old).unwrap();
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "GBK Java"]);
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
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 project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.project_fallback = "gbk".into();
|
||||
project.documents.set_file_defaults(defaults);
|
||||
assert_eq!(project.text_of(&path).unwrap(), old);
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().as_deref(), Some(old));
|
||||
let doc = project.documents.open(&path).unwrap();
|
||||
assert_eq!(doc.encoding, "gbk");
|
||||
// A live document's explicit selection wins over later project defaults.
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.project_fallback = "windows-1252".into();
|
||||
project.documents.set_file_defaults(defaults);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
|
||||
let from = old.find("value = 1").unwrap() + "value = ".len();
|
||||
let offset = old[..from].encode_utf16().count() as u32;
|
||||
project
|
||||
.documents
|
||||
.edit(
|
||||
doc.id,
|
||||
doc.version,
|
||||
ChangeSet { changes: vec![TextChange { from: offset, to: offset + 1, insert: "2".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap();
|
||||
assert_eq!(hunks.len(), 1);
|
||||
assert_eq!((hunks[0].start, hunks[0].lines), (3, 1));
|
||||
assert_eq!(hunks[0].old_text, " int value = 1;\n");
|
||||
project.documents.save(doc.id).unwrap();
|
||||
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).unwrap();
|
||||
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(),
|
||||
ide_vfs::encode(&old.replace("value = 1", "value = 2"), "gbk", false).unwrap()
|
||||
);
|
||||
repo.unstage(std::slice::from_ref(&path)).unwrap();
|
||||
let snapshot = project.documents.snapshot(doc.id).unwrap();
|
||||
let line_start =
|
||||
snapshot.text.split_inclusive('\n').take(3).map(|s| s.encode_utf16().count()).sum::<usize>() as u32;
|
||||
project
|
||||
.documents
|
||||
.edit(
|
||||
doc.id,
|
||||
snapshot.version,
|
||||
ChangeSet {
|
||||
changes: vec![TextChange {
|
||||
from: line_start,
|
||||
to: line_start + " int value = 2;\n".encode_utf16().count() as u32,
|
||||
insert: hunks[0].old_text.clone(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project.documents.save(doc.id).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
|
||||
assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test"));
|
||||
// Missing working files still use configured encoding for historical content.
|
||||
project.documents.close(doc.id);
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.project_fallback = "gbk".into();
|
||||
project.documents.set_file_defaults(defaults);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", path, None).await.unwrap().as_deref(), Some(old));
|
||||
let utf8 = root.join("Utf8.java");
|
||||
std::fs::remove_file(&utf8).unwrap();
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", utf8, None).await.unwrap().as_deref(), Some(old));
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Repository-scoped patch operations must never fall back to the primary root.
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops::vcs};
|
||||
use std::{path::Path, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn patches_read_and_write_the_requested_repository() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let project_root = base.join("project");
|
||||
let roots = [project_root.join("front"), project_root.join("back")];
|
||||
let mut revisions = Vec::new();
|
||||
for (i, root) in roots.iter().enumerate() {
|
||||
std::fs::create_dir_all(root).unwrap();
|
||||
git(root, &["init", "-b", "main"]);
|
||||
git(root, &["config", "user.name", "Test"]);
|
||||
git(root, &["config", "user.email", "[email protected]"]);
|
||||
std::fs::write(root.join("same.txt"), format!("root {i}\n")).unwrap();
|
||||
git(root, &["add", "."]);
|
||||
git(root, &["commit", "-m", "initial"]);
|
||||
std::fs::write(root.join("same.txt"), format!("changed {i}\n")).unwrap();
|
||||
git(root, &["commit", "-am", "change"]);
|
||||
revisions.push(git(root, &["rev-parse", "HEAD"]));
|
||||
}
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
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 project = host.open_project("main", &project_root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if project.repos().all().len() == 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(project.repos().all().len(), 2);
|
||||
for (i, root) in roots.iter().enumerate() {
|
||||
let patch = vcs::vcs_create_patch(
|
||||
&host,
|
||||
"main",
|
||||
Some(root.clone()),
|
||||
vcs::PatchSource::Commits { commits: vec![revisions[i].clone()], reverse: true },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(patch.contains(&format!("-changed {i}")));
|
||||
assert_eq!(vcs::vcs_patch_preview(&host, "main", Some(root.clone()), patch.clone()).await.unwrap().len(), 1);
|
||||
vcs::vcs_apply_patch(&host, "main", Some(root.clone()), patch).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(root.join("same.txt")).unwrap(), format!("root {i}\n"));
|
||||
}
|
||||
assert!(vcs::vcs_apply_patch(&host, "main", Some(base.join("outside")), String::new()).await.is_err());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use ide_core::{ChangeSet, NullSink, RequestId, TextChange};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_workspace::workspace::Workspace;
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_directories() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let roots = vec![base.join("frontend"), base.join("backend")];
|
||||
for (index, root) in roots.iter().enumerate() {
|
||||
std::fs::create_dir(root).unwrap();
|
||||
assert!(std::process::Command::new("git").args(["init", "-q"]).current_dir(root).status().unwrap().success());
|
||||
std::fs::write(root.join("same.txt"), format!("shared needle {index}\n")).unwrap();
|
||||
std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"echo test"}}"#).unwrap();
|
||||
}
|
||||
let root = base.join("workspace");
|
||||
Workspace::save(&root, "Full stack", vec![roots[0].clone()], true).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
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,
|
||||
});
|
||||
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert_eq!(ops::project::info(&project).name, "Full stack");
|
||||
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path());
|
||||
ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
|
||||
assert_eq!(project.content_roots(), roots);
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(project.index.find_files("internal-needle", 100).is_empty());
|
||||
assert!(project.index.find_files("workspace.json", 100).is_empty());
|
||||
assert_eq!(project.index.find_files("same.txt", 100).len(), 2);
|
||||
for _ in 0..500 {
|
||||
if project.repos().all().len() == 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(project.repos().all().len(), 2);
|
||||
let configs = ops::run::configs(&host, "main").unwrap();
|
||||
for member in &roots {
|
||||
assert!(configs.iter().any(|c| c.cwd.as_ref().is_some_and(|cwd| &**cwd == member)));
|
||||
}
|
||||
let mut ids = configs.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), configs.len());
|
||||
let a = ops::doc::open(&host, "main", roots[0].join("same.txt")).await.unwrap();
|
||||
let b = ops::doc::open(&host, "main", roots[1].join("same.txt")).await.unwrap();
|
||||
assert_ne!(a.id, b.id);
|
||||
ops::doc::edit(
|
||||
&host,
|
||||
"main",
|
||||
a.id,
|
||||
a.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "unsaved ".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hits = Arc::new(Mutex::new(Vec::new()));
|
||||
let out = hits.clone();
|
||||
let query = serde_json::from_value(serde_json::json!({"pattern":"needle", "isRegex":false, "caseSensitive":false,"wholeWord":false,"include":[],"exclude":[],"scope":{"kind":"project"},"maxMatches":100})).unwrap();
|
||||
ops::search::start(
|
||||
&host,
|
||||
"main",
|
||||
RequestId::next(),
|
||||
query,
|
||||
Arc::new(move |event| {
|
||||
if let ops::search::SearchEvent::File { file } = event {
|
||||
out.lock().unwrap().push(file);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hits.lock().unwrap().len(), 2);
|
||||
let found = hits.lock().unwrap().iter().map(|f| f.rel_path.clone()).collect::<Vec<_>>();
|
||||
assert!(found.contains(&"frontend/same.txt".to_owned()));
|
||||
assert!(found.contains(&"backend/same.txt".to_owned()));
|
||||
let updated = ops::project::save_workspace(&host, "main", "Frontend", vec![roots[0].clone()]).unwrap();
|
||||
assert_eq!(updated.workspace.unwrap().projects, vec![roots[0].clone()]);
|
||||
assert_eq!(project.content_roots(), vec![roots[0].clone()]);
|
||||
assert!(project.documents.text_of(&roots[0].join("same.txt")).unwrap().starts_with("unsaved"));
|
||||
assert!(roots[1].join("same.txt").exists());
|
||||
assert!(!roots[1].join(".openidea").exists());
|
||||
let invalid = base.join("invalid");
|
||||
std::fs::create_dir_all(invalid.join(".openidea")).unwrap();
|
||||
std::fs::write(invalid.join(ide_workspace::workspace::FILE), "broken").unwrap();
|
||||
assert!(host.open_project("main", &invalid).is_err());
|
||||
assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project));
|
||||
host.open_project("main", &roots[0]).unwrap();
|
||||
let automatic = ops::project::attach_workspace(&host, "main", &roots[1]).unwrap();
|
||||
assert_eq!(host.project("main").unwrap().root, roots[0]);
|
||||
let automatic = host.open_project("main", &automatic).unwrap();
|
||||
assert_eq!(automatic.content_roots(), roots);
|
||||
assert_eq!(ops::project::info(&automatic).name, "frontend");
|
||||
assert!(automatic.root.starts_with(base.join("data/workspaces")));
|
||||
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(automatic.index.find_files("workspace.json", 100).is_empty());
|
||||
let updated = ops::project::save_workspace(&host, "main", "frontend", vec![roots[1].clone()]).unwrap();
|
||||
assert_eq!(updated.workspace.unwrap().projects, vec![roots[1].clone()]);
|
||||
assert_eq!(automatic.root, updated.root.as_ref());
|
||||
assert!(automatic.index.wait_idle(Duration::from_secs(10)));
|
||||
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! The one place the backend builds an HTTP client: TLS with the platform's
|
||||
//! certificates, timeouts, and the proxy of System Settings › HTTP Proxy.
|
||||
|
||||
use ide_core::proxy_settings::{ProxyMode, ProxyRoute, ProxySettings};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `builder` routed as `proxy` says: direct, the environment's proxy (reqwest's
|
||||
/// default), or the manual proxy except for the "No proxy for" hosts.
|
||||
pub fn with_proxy(builder: reqwest::ClientBuilder, proxy: &ProxySettings) -> reqwest::ClientBuilder {
|
||||
match proxy.mode {
|
||||
ProxyMode::Auto => builder,
|
||||
ProxyMode::None => builder.no_proxy(),
|
||||
ProxyMode::Manual => {
|
||||
let proxy = proxy.clone();
|
||||
// A custom proxy also turns off the environment's.
|
||||
builder.proxy(reqwest::Proxy::custom(move |url| match proxy.route(url.host_str()) {
|
||||
ProxyRoute::Via(via) => Some(via),
|
||||
ProxyRoute::Direct | ProxyRoute::System => None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A client for `proxy`.
|
||||
pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
|
||||
use rustls_platform_verifier::BuilderVerifierExt;
|
||||
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let tls = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| fail(e.to_string()))?
|
||||
.with_platform_verifier()
|
||||
.map_err(|e| fail(e.to_string()))?
|
||||
.with_no_client_auth();
|
||||
let builder = reqwest::Client::builder()
|
||||
.tls_backend_preconfigured(tls)
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")));
|
||||
with_proxy(builder, proxy).build().map_err(|e| fail(e.to_string()))
|
||||
}
|
||||
|
||||
/// The client for the current proxy settings, rebuilt when they change.
|
||||
pub fn shared() -> IdeResult<reqwest::Client> {
|
||||
static CLIENT: Mutex<Option<(Arc<ProxySettings>, reqwest::Client)>> = Mutex::new(None);
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let mut cached = CLIENT.lock();
|
||||
if let Some((settings, client)) = cached.as_ref()
|
||||
&& **settings == *proxy
|
||||
{
|
||||
return Ok(client.clone());
|
||||
}
|
||||
let client = build(&proxy)?;
|
||||
*cached = Some((proxy, client.clone()));
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// HTTP Proxy › Check connection: GETs `url` through `proxy` (the settings as
|
||||
/// edited, not yet applied). Any response but `407` counts as connected.
|
||||
pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
if let Some(problem) = proxy.problem() {
|
||||
return Err(IdeError::invalid(problem));
|
||||
}
|
||||
let url = url.trim();
|
||||
let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") };
|
||||
let client = build(proxy)?;
|
||||
let request = client.get(&url).timeout(Duration::from_secs(30));
|
||||
let response = tokio::select! {
|
||||
r = request.send() => r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, describe(&e)))?,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
let status = response.status();
|
||||
if status == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED {
|
||||
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "Proxy authentication required (407)"));
|
||||
}
|
||||
Ok(format!("{} {}", status.as_u16(), status.canonical_reason().unwrap_or("")).trim_end().to_owned())
|
||||
}
|
||||
|
||||
/// reqwest errors with their causes ("error sending request: … connection refused").
|
||||
pub(crate) fn describe(e: &reqwest::Error) -> String {
|
||||
use std::error::Error;
|
||||
let mut msg = e.to_string();
|
||||
let mut source = e.source();
|
||||
while let Some(s) = source {
|
||||
let s_msg = s.to_string();
|
||||
if !msg.contains(&s_msg) {
|
||||
msg.push_str(": ");
|
||||
msg.push_str(&s_msg);
|
||||
}
|
||||
source = s.source();
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::proxy_settings::ProxyKind;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
/// A one-shot HTTP server on loopback: answers `200` and hands back the request head.
|
||||
fn server() -> (u16, std::thread::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut head = String::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
head.push_str(&line);
|
||||
}
|
||||
let mut stream = stream;
|
||||
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok").unwrap();
|
||||
head
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
fn manual(port: u16) -> ProxySettings {
|
||||
ProxySettings { mode: ProxyMode::Manual, host: "127.0.0.1".into(), port, ..Default::default() }
|
||||
}
|
||||
|
||||
async fn get(proxy: &ProxySettings, url: &str) -> IdeResult<String> {
|
||||
check_connection(proxy, url, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_for_every_mode() {
|
||||
for mode in [ProxyMode::None, ProxyMode::Auto, ProxyMode::Manual] {
|
||||
for kind in [ProxyKind::Http, ProxyKind::Socks] {
|
||||
let p = ProxySettings { mode, kind, host: "proxy".into(), port: 3128, ..Default::default() };
|
||||
assert!(build(&p).is_ok(), "{mode:?} {kind:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_proxy_receives_the_request_with_credentials() {
|
||||
let (port, proxy_server) = server();
|
||||
let proxy = ProxySettings { authenticate: true, login: "me".into(), password: "s3cret".into(), ..manual(port) };
|
||||
assert_eq!(get(&proxy, "http://example.invalid/ping").await.unwrap(), "200 OK");
|
||||
let head = proxy_server.join().unwrap();
|
||||
assert!(head.starts_with("GET http://example.invalid/ping HTTP/1.1"), "{head}");
|
||||
// base64("me:s3cret")
|
||||
assert!(head.to_ascii_lowercase().contains("proxy-authorization: basic bwu6cznjcmv0"), "{head}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_proxy_without_authentication_sends_no_credentials() {
|
||||
let (port, proxy_server) = server();
|
||||
let proxy = ProxySettings { login: "me".into(), password: "x".into(), ..manual(port) };
|
||||
get(&proxy, "http://example.invalid/").await.unwrap();
|
||||
assert!(!proxy_server.join().unwrap().to_ascii_lowercase().contains("proxy-authorization"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_proxy_hosts_and_no_proxy_mode_connect_directly() {
|
||||
// Nothing listens on the proxy port: a request through it would fail.
|
||||
let dead = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let (port, target) = server();
|
||||
let listed = ProxySettings { no_proxy: "example.com, 127.0.*".into(), ..manual(dead) };
|
||||
assert!(listed.bypasses("127.0.0.1"));
|
||||
get(&listed, &format!("http://127.0.0.1:{port}/direct")).await.unwrap();
|
||||
assert!(target.join().unwrap().starts_with("GET /direct HTTP/1.1"));
|
||||
|
||||
let (port, target) = server();
|
||||
let none = ProxySettings { mode: ProxyMode::None, ..manual(dead) };
|
||||
get(&none, &format!("http://127.0.0.1:{port}/none")).await.unwrap();
|
||||
assert!(target.join().unwrap().starts_with("GET /none HTTP/1.1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_reports_errors() {
|
||||
let dead = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let e = get(&manual(dead), "http://example.invalid/").await.unwrap_err();
|
||||
assert!(!e.message.is_empty());
|
||||
let e = get(&manual(0), "http://example.invalid/").await.unwrap_err();
|
||||
assert!(e.message.contains("port"), "{}", e.message);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Preparing (substitution, body includes) and executing requests.
|
||||
|
||||
use crate::client::describe;
|
||||
use crate::env::{Environments, Resolver};
|
||||
use crate::parse::{BodyPart, RawRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
@@ -7,7 +8,6 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Bodies above this are cut (the full size is still reported).
|
||||
@@ -98,29 +98,6 @@ pub fn prepare(
|
||||
})
|
||||
}
|
||||
|
||||
fn client() -> IdeResult<reqwest::Client> {
|
||||
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
|
||||
CLIENT
|
||||
.get_or_init(|| {
|
||||
use rustls_platform_verifier::BuilderVerifierExt;
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let tls = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(|e| e.to_string())?
|
||||
.with_platform_verifier()
|
||||
.map_err(|e| e.to_string())?
|
||||
.with_no_client_auth();
|
||||
reqwest::Client::builder()
|
||||
.tls_backend_preconfigured(tls)
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.clone()
|
||||
.map_err(|e| IdeError::internal(format!("HTTP client: {e}")))
|
||||
}
|
||||
|
||||
fn looks_textual(content_type: Option<&str>, bytes: &[u8]) -> bool {
|
||||
if let Some(ct) = content_type {
|
||||
let ct = ct.to_ascii_lowercase();
|
||||
@@ -148,7 +125,7 @@ fn looks_textual(content_type: Option<&str>, bytes: &[u8]) -> bool {
|
||||
|
||||
/// Sends `req`; cancelled when `cancel` fires.
|
||||
pub async fn execute(req: &PreparedRequest, cancel: &CancellationToken) -> IdeResult<HttpResponse> {
|
||||
let client = client()?;
|
||||
let client = crate::client::shared()?;
|
||||
let method = reqwest::Method::from_bytes(req.method.as_bytes())
|
||||
.map_err(|_| IdeError::invalid(format!("invalid method {}", req.method)))?;
|
||||
let mut builder = client.request(method, &req.url);
|
||||
@@ -205,22 +182,6 @@ pub async fn execute(req: &PreparedRequest, cancel: &CancellationToken) -> IdeRe
|
||||
})
|
||||
}
|
||||
|
||||
/// reqwest errors with their causes ("error sending request: … connection refused").
|
||||
fn describe(e: &reqwest::Error) -> String {
|
||||
use std::error::Error;
|
||||
let mut msg = e.to_string();
|
||||
let mut source = e.source();
|
||||
while let Some(s) = source {
|
||||
let s_msg = s.to_string();
|
||||
if !msg.contains(&s_msg) {
|
||||
msg.push_str(": ");
|
||||
msg.push_str(&s_msg);
|
||||
}
|
||||
source = s.source();
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
//!
|
||||
//! [`parse`] splits a file into requests, [`env`] resolves `{{variables}}` from
|
||||
//! `http-client(.private).env.json`, [`exec`] sends a request (cancellable,
|
||||
//! timed) and [`history`] keeps the responses.
|
||||
//! timed) and [`history`] keeps the responses. [`client`] builds every HTTP client
|
||||
//! of the backend, with the proxy of System Settings › HTTP Proxy.
|
||||
|
||||
pub mod client;
|
||||
pub mod env;
|
||||
pub mod exec;
|
||||
pub mod history;
|
||||
|
||||
@@ -15,6 +15,7 @@ const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj"];
|
||||
|
||||
pub(crate) struct PathFilter {
|
||||
root: PathBuf,
|
||||
include_root: bool,
|
||||
/// Content roots outside `root`, indexed as `<folder name>/<path>`.
|
||||
extra: Vec<ExtraRoot>,
|
||||
excludes: GlobSet,
|
||||
@@ -22,7 +23,7 @@ pub(crate) struct PathFilter {
|
||||
/// applies nested ignore files itself).
|
||||
gitignore: Gitignore,
|
||||
/// What the filter was built from: `(extra roots, exclude globs)`.
|
||||
config: (Vec<PathBuf>, Vec<String>),
|
||||
config: (crate::IndexRoots, Vec<String>),
|
||||
}
|
||||
|
||||
struct ExtraRoot {
|
||||
@@ -68,7 +69,7 @@ pub(crate) fn mtime_of(meta: &std::fs::Metadata) -> u64 {
|
||||
}
|
||||
|
||||
impl PathFilter {
|
||||
pub(crate) fn new(root: &Path, extra_roots: &[PathBuf], exclude_globs: &[String]) -> Self {
|
||||
pub(crate) fn new(root: &Path, extra_roots: &crate::IndexRoots, exclude_globs: &[String]) -> Self {
|
||||
let mut set = GlobSetBuilder::new();
|
||||
for raw in exclude_globs {
|
||||
let p = raw.trim().trim_end_matches('/');
|
||||
@@ -92,10 +93,9 @@ impl PathFilter {
|
||||
}
|
||||
let excludes = set.build().unwrap_or_else(|_| GlobSet::empty());
|
||||
let mut extra: Vec<ExtraRoot> = Vec::new();
|
||||
for r in extra_roots {
|
||||
for r in &extra_roots.folders {
|
||||
let path = dunce::canonicalize(r).unwrap_or_else(|_| r.clone());
|
||||
let nested = path.starts_with(root)
|
||||
|| root.starts_with(&path)
|
||||
let nested = (extra_roots.include_root && (path.starts_with(root) || root.starts_with(&path)))
|
||||
|| extra.iter().any(|e| path.starts_with(&e.path) || e.path.starts_with(&path));
|
||||
if nested || !path.is_dir() {
|
||||
continue;
|
||||
@@ -105,16 +105,17 @@ impl PathFilter {
|
||||
}
|
||||
Self {
|
||||
root: root.to_owned(),
|
||||
include_root: extra_roots.include_root,
|
||||
extra,
|
||||
excludes,
|
||||
gitignore: root_ignores(root),
|
||||
config: (extra_roots.to_vec(), exclude_globs.to_vec()),
|
||||
config: (extra_roots.clone(), exclude_globs.to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the filter was built from these roots and globs.
|
||||
pub(crate) fn same_config(&self, extra_roots: &[PathBuf], excludes: &[String]) -> bool {
|
||||
self.config.0 == extra_roots && self.config.1 == excludes
|
||||
pub(crate) fn same_config(&self, extra_roots: &crate::IndexRoots, excludes: &[String]) -> bool {
|
||||
&self.config.0 == extra_roots && self.config.1 == excludes
|
||||
}
|
||||
|
||||
pub(crate) fn root(&self) -> &Path {
|
||||
@@ -123,12 +124,14 @@ impl PathFilter {
|
||||
|
||||
/// The project root, then the extra content roots.
|
||||
pub(crate) fn roots(&self) -> impl Iterator<Item = &Path> {
|
||||
std::iter::once(self.root.as_path()).chain(self.extra.iter().map(|e| e.path.as_path()))
|
||||
std::iter::once(self.root.as_path())
|
||||
.filter(|_| self.include_root)
|
||||
.chain(self.extra.iter().map(|e| e.path.as_path()))
|
||||
}
|
||||
|
||||
/// The root containing `path`, its display prefix (`None` for the project root) and ignore rules.
|
||||
fn base_of(&self, path: &Path) -> Option<(&Path, Option<&str>, &Gitignore)> {
|
||||
if path.starts_with(&self.root) {
|
||||
if self.include_root && path.starts_with(&self.root) {
|
||||
return Some((&self.root, None, &self.gitignore));
|
||||
}
|
||||
self.extra
|
||||
@@ -159,6 +162,9 @@ impl PathFilter {
|
||||
/// Key of `path` in the persistent store: only files below the project root
|
||||
/// are persisted (extra content roots are rescanned when the project opens).
|
||||
pub(crate) fn store_key(&self, path: &Path) -> Option<String> {
|
||||
if !self.include_root {
|
||||
return None;
|
||||
}
|
||||
let rel = rel_string(path.strip_prefix(&self.root).ok()?);
|
||||
(!rel.is_empty()).then_some(rel)
|
||||
}
|
||||
@@ -290,7 +296,8 @@ mod tests {
|
||||
touch(&root, f);
|
||||
}
|
||||
std::fs::write(root.join(".gitignore"), "build/\n").unwrap();
|
||||
let filter = PathFilter::new(&root, &[], &["**/node_modules".into(), "target".into(), "docs/**".into()]);
|
||||
let filter =
|
||||
PathFilter::new(&root, &vec![].into(), &["**/node_modules".into(), "target".into(), "docs/**".into()]);
|
||||
let mut found: Vec<String> =
|
||||
filter.walk(&root, &AtomicBool::new(false)).iter().filter_map(|f| filter.rel(&f.path)).collect();
|
||||
found.sort();
|
||||
@@ -313,7 +320,7 @@ mod tests {
|
||||
touch(&base, f);
|
||||
}
|
||||
std::fs::write(shared.join(".gitignore"), "out/\n").unwrap();
|
||||
let filter = PathFilter::new(&root, &[shared.clone(), root.join("src")], &["target".into()]);
|
||||
let filter = PathFilter::new(&root, &vec![shared.clone(), root.join("src")].into(), &["target".into()]);
|
||||
assert_eq!(filter.roots().count(), 2, "roots inside the project are not extra roots");
|
||||
let mut found: Vec<String> = filter
|
||||
.roots()
|
||||
@@ -327,7 +334,7 @@ mod tests {
|
||||
assert!(!filter.is_excluded(&shared.join("src/new.rs"), false));
|
||||
assert!(filter.is_excluded(&shared.join("target/y.rs"), false));
|
||||
assert!(filter.is_excluded(&shared.join("out/y.rs"), false));
|
||||
assert!(filter.same_config(&[shared.clone(), root.join("src")], &["target".into()]));
|
||||
assert!(!filter.same_config(&[shared], &["target".into()]));
|
||||
assert!(filter.same_config(&vec![shared.clone(), root.join("src")].into(), &["target".into()]));
|
||||
assert!(!filter.same_config(&vec![shared].into(), &["target".into()]));
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,19 @@ pub struct ProjectIndex {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
/// Folders indexed alongside the internal state root.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct IndexRoots {
|
||||
pub include_root: bool,
|
||||
pub folders: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl From<Vec<PathBuf>> for IndexRoots {
|
||||
fn from(folders: Vec<PathBuf>) -> Self {
|
||||
Self { include_root: true, folders }
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectIndex {
|
||||
/// Opens (or creates) the persistent index at `cache_dir` and starts a
|
||||
/// background full scan that skips files whose content hash is unchanged.
|
||||
@@ -77,7 +90,7 @@ impl ProjectIndex {
|
||||
pub fn open(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
content_roots: Vec<PathBuf>,
|
||||
content_roots: impl Into<IndexRoots>,
|
||||
excludes: Vec<String>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<Syntax>,
|
||||
@@ -88,7 +101,7 @@ impl ProjectIndex {
|
||||
return Err(ide_core::IdeError::not_found(format!("{} is not a directory", root.display())));
|
||||
}
|
||||
let store = store::Store::open(cache_dir, &root, &syntax.fingerprint());
|
||||
let filter = filter::PathFilter::new(&root, &content_roots, &excludes);
|
||||
let filter = filter::PathFilter::new(&root, &content_roots.into(), &excludes);
|
||||
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, store));
|
||||
Self::start(shared, root, true)
|
||||
}
|
||||
@@ -102,7 +115,7 @@ impl ProjectIndex {
|
||||
sink: SharedSink,
|
||||
) -> IdeResult<Arc<Self>> {
|
||||
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_owned());
|
||||
let filter = filter::PathFilter::new(&root, &[], &[]);
|
||||
let filter = filter::PathFilter::new(&root, &Vec::new().into(), &[]);
|
||||
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, None));
|
||||
Self::start(shared, root, false)
|
||||
}
|
||||
@@ -121,7 +134,8 @@ impl ProjectIndex {
|
||||
/// Changes what is indexed besides the root (content roots) and the exclude
|
||||
/// globs, rescanning in the background (unchanged files are not re-read).
|
||||
/// Returns whether anything changed.
|
||||
pub fn set_scope(&self, content_roots: Vec<PathBuf>, excludes: Vec<String>) -> bool {
|
||||
pub fn set_scope(&self, content_roots: impl Into<IndexRoots>, excludes: Vec<String>) -> bool {
|
||||
let content_roots = content_roots.into();
|
||||
if self.shared.filter().same_config(&content_roots, &excludes) {
|
||||
return false;
|
||||
}
|
||||
@@ -222,7 +236,14 @@ impl ProjectIndex {
|
||||
/// the IDE was closed) are left out. Virtual documents are always listed.
|
||||
pub fn recent_files(&self) -> Vec<PathBuf> {
|
||||
let recent = self.shared.recent.lock().clone();
|
||||
recent.into_iter().filter(|p| p.to_str().is_some_and(ide_core::text::is_virtual_uri) || p.exists()).collect()
|
||||
let filter = self.shared.filter();
|
||||
recent
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.to_str().is_some_and(ide_core::text::is_virtual_uri)
|
||||
|| (p.exists() && (!p.starts_with(&self.root) || filter.contains(p)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Blocks until the initial scan and every queued update has been applied
|
||||
|
||||
@@ -508,3 +508,38 @@ fn unindexed_index_never_scans_or_persists() {
|
||||
assert!(!fx.cache.exists());
|
||||
assert!(!events.lock().iter().any(|e| matches!(e, IdeEvent::Task { .. })), "no indexing task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() {
|
||||
let fx = Fixture::new(&[(".openidea/workspace.json", "{}"), ("internal.rs", "fn hidden() {}")]);
|
||||
// Simulate a cache produced before the settings root was excluded.
|
||||
let old = fx.open();
|
||||
old.mark_recent(&fx.root.join("internal.rs"));
|
||||
assert!(old.wait_idle(WAIT));
|
||||
drop(old);
|
||||
let member = fx.root.join("member");
|
||||
std::fs::create_dir_all(&member).unwrap();
|
||||
std::fs::write(member.join("visible.rs"), "fn visible() {}").unwrap();
|
||||
let idx = ProjectIndex::open(
|
||||
&fx.root,
|
||||
&fx.cache,
|
||||
crate::IndexRoots { include_root: false, folders: vec![member.clone()] },
|
||||
Vec::new(),
|
||||
Arc::new(LanguageRegistry::load(None).unwrap()),
|
||||
Arc::new(Syntax::new()),
|
||||
Arc::new(NullSink),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(idx.find_files("internal", 100).is_empty());
|
||||
assert!(idx.find_files("workspace.json", 100).is_empty());
|
||||
assert!(idx.recent_files().is_empty());
|
||||
assert_eq!(idx.find_files("visible", 100).len(), 1);
|
||||
idx.update(&[fx.root.join("internal.rs"), fx.root.join(".openidea/workspace.json")], &[]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(symbols(&idx, "hidden", SymbolFilter::All).is_empty());
|
||||
assert_eq!(symbols(&idx, "visible", SymbolFilter::All), ["visible"]);
|
||||
idx.set_scope(crate::IndexRoots { include_root: false, folders: Vec::new() }, Vec::new());
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(idx.find_files("", 100).is_empty());
|
||||
}
|
||||
Loaded 100 of 713 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user